The App SDK

What It Is

The App SDK is the browser side of a Custom App: one small JavaScript module that tells the front-end who is signed in and in which language, gives it the tenant's look, and calls the app's own routes and the tenant's records through the REST API.

Every tenant serves it, and every app page the platform serves can import it by name, without a build step:

import { user, api, records } from '@jetstack/sdk';

Why It Matters

Without it, every front-end re-implements the same plumbing: reading window.__JETSTACK__, attaching the CSRF token, turning error responses into exceptions, paging through lists, uploading files before saving a record. The SDK does it once, the same way everywhere — and with jetstack types the editor knows every record type and field, so a misspelled field fails in the editor instead of in the tenant.

How It Works

  • The platform adds an import map to each app page, so @jetstack/sdk resolves to the tenant's copy (/js/jetstack-sdk.js). When the page has its own import map, the entry is added to it; an entry the app sets itself wins.
  • The SDK reads the bootstrap the platform injects into the page (window.__JETSTACK__), so it needs no configuration.
  • Requests run with the signed-in user's session and permissions. Writes carry the session's CSRF token, and effects a handler produces (flash messages, banners) are shown by the platform runtime.
  • A failed request throws a JetstackError with the HTTP status, the parsed body, and — for handler errors — the errorId to look up in jetstack logs.

Front-ends with a bundler can install the same module as the package @jetstack/sdk.

Who And Where

FunctionReturns
user()The signed-in user — {id, name, email, roles, roleIds}, roles by system name — or null for a visitor
hasRole(role)Whether the user holds the role (system name)
locale()The user's language, e.g. cs
timezone()The time zone the user sees times in, e.g. Europe/Prague
tenant(){title, url}
embed(){embedded, record}: whether the app is shown inside a module page, and the record the module opened it for
viewport()"full" when the app takes the whole window, else "embedded" (see Full-Window Apps)
basePath()Where the app's pages are on the domain in use: "/" when the app is mounted at the root of its own domain (see Entry Domains), else "/app/<slug>/". Give it to a client router
pageUrl(path)The address of one of the app's pages on the domain in use, e.g. pageUrl('/cases/12')
links()The platform's own pages — {home, dashboard, profile, signIn, signOut, exitLoginAs} — for an app's own user menu; a link the person cannot use is null
context()The raw bootstrap

From Another Site

A website on another domain can use the SDK to call an app's public routes:

import { configure, api } from 'https://crm.acme.com/js/jetstack-sdk.js';

configure({ url: 'https://crm.acme.com', app: 'leads' });
await api.post('/contact', { name, email, message });

How it behaves:

  • The app must allow the site. It lists the site in its manifest's cors, and the routes are "auth": "public".
  • No session is sent. Calls run without the visitor's session and need no CSRF token.
  • Platform APIs refuse. records(), notifications, jobs and search throw a JetstackError, because they need one of the tenant's own pages.

See External Front Ends.

The Tenant's Look

theme() returns the tenant's theme — {cssUrl, mode, colors} — and applyTheme() adopts it: it loads the tenant's Bootstrap build, colors included, and follows its light/dark setting.

import { applyTheme } from '@jetstack/sdk';
await applyTheme(); // before the first render avoids a flash of unstyled content

Use it when the app should feel like part of the tenant. Leave it out when the app has its own design, for example a public portal.

The App's Own Routes

api calls the routes of the app's manifest, relative to the app:

import { api, JetstackError } from '@jetstack/sdk';

const { cases } = await api.get('/cases', { query: { status: 'open' } });
try {
  await api.post('/cases', { title: 'New case' });
} catch (e) {
  if (e instanceof JetstackError && e.status === 422) showErrors(e.body);
}

To send files to a handler, post a FormData; the handler receives them in $files:

const form = new FormData(document.querySelector('#import'));
const { imported } = await api.post('/imports', form);

Records

records(type) works with the records of one type (by system name), with the signed-in user's permissions:

import { records } from '@jetstack/sdk';

const cases = records('case');
const { items, total } = await cases.list({
  filters: [{ property: 'status', type: 'is', param: 'open' }],
  orderBy: 'created_on', orderDir: 'desc', perPage: 20,
  fields: ['title', 'status'],
});
for await (const c of cases.all({ perPage: 100 })) { /* every matching record */ }

const created = await cases.create({ title: 'New case', priority: 'high' });
await cases.patch(created.id, { status: 'closed' });
await cases.delete(created.id);

const [contract] = await cases.upload('contract', fileInput.files); // upload promise id
await cases.patch(created.id, { contract });
  • list returns {items, page, perPage, total}; all pages through everything.
  • get, update, patch and delete take an id or a uuid.
  • References are written as an id, a uuid or — for enumerations — a code.
  • Filters follow the REST API's filter operators; a filter the API cannot apply is refused, never ignored.

Use records for screens that work directly with data the user may see anyway — lists, detail pages, administrative screens. Put anything with rules the browser must not decide — approvals, anything public — behind a handler, and call it with api.

Live Events

events.on(type, fn) listens to the person's live event stream and returns a function that stops listening. The stream opens with the first listener; an app embedded in a platform page shares the page's stream.

import { events } from '@jetstack/sdk';

const stop = events.on('app', (event) => {
  if (event.name === 'case.updated') reloadCase(event.data.id);
});
events.on('resync', reloadAll); // after a reconnect

Types: notification, notification-read, job, app (the app's own, from $jetstack->live->publish()), resync, and * for all of them.

What The Assistant Sees

page tells the AI assistant what the page shows — the title, the record, the selected records and a short summary. The chat reads it with each message:

import { page } from '@jetstack/sdk';

page.set({ title: 'Open cases', type: 'case', selection: ['12', '15'] });
page.get();   // what the assistant would read now
page.clear(); // back to what the markup says

The page can say the same with data-jetstack-* attributes instead. See Telling The Assistant What Is On Screen.

The platform's own services, with the person's permissions — see Platform Services For Apps:

HelperWhat it does
notifications.list({limit, cursor, unreadOnly, after})A page of the person's notifications, with unreadCount
notifications.unreadCount()How many are unread
notifications.markRead(id), markAllRead(), archive(id)Read or archive them
notifications.onChange(fn)Calls fn on a new notification, a read or an archive in any window, and after a reconnect
jobs.list(), jobs.get(id), jobs.dismiss(id)The person's background jobs of the last 24 hours
jobs.watch(id, fn)Calls fn with the job on every change until it ends
search(text, {types, limit, cursor})Records the person may read that match the text, a page at a time
search.types()The record types search can find for the person

Types In The Editor

jetstack types writes the SDK's typings, the tenant's record types and — when the project has none — a jsconfig.json to .jetstack/types/ and the project root. With them, records('case') knows the fields of a case in both plain JavaScript (// @ts-check) and TypeScript:

// @ts-check
import { records } from '@jetstack/sdk';
const c = await records('case').get(1);
c.titel; // error: Property 'titel' does not exist on type 'Case'