App Bridge
The App Bridge connects your app to the Stok.ly webapp. Stok.ly shows your app in iframes (a hidden worker, full pages, tabs, sections and dialogs). The bridge lets those frames:
- receive events from Stok.ly, such as "the user clicked your action" or "the table needs values for your columns", and answer them;
- send commands to Stok.ly, such as showing a toast, opening a dialog or navigating.
Protocol version: 1.
Loading the SDK
Add the script to every page Stok.ly loads in an iframe (your worker and any page, tab, section or dialog URLs):
<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>The script defines one global, StoklyAppBridge. The v1 URL always serves the latest compatible release of protocol version 1.
To test your app against upcoming bridge changes on Stok.ly's dev environment, load the dev track instead: https://assets.dev.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js. It is built from unreleased changes, so only use it for testing and switch back to the production URL before you publish.
Frames
Each URL in your manifest is loaded in its own iframe. The bridge tells you which kind of frame you are in through bridge.frame:
frame | Loaded from | Visible | Typical use |
|---|---|---|---|
worker | worker_url | no | Handling events: actions, bulk actions, column values |
page | pages[].content_url | yes | A full page at /apps/<appId>/<route> |
tab | a tab extension's content_url | yes | A tab on a record view |
section | a section extension's content_url | yes | A section on a record view |
dialog | an action's content_url, or openDialog | yes | A dialog |
Stok.ly loads your worker once per session, in the background, and keeps it running. Events for your actions and columns go to the worker, so declare worker_url whenever your manifest has an action without a content_url, any column, or any hook. Stok.ly loads the worker when the browser is idle and reloads it if your release's worker_url changes. If it fails to load, Stok.ly tries again on the next user action that needs it, or after 60 seconds otherwise.
Visible frames are sandboxed with allow-scripts allow-same-origin allow-forms allow-popups, and the worker with allow-scripts allow-same-origin, so alert and confirm, top-level navigation and downloads don't work. Use toast, openDialog and navigate instead. Frame URLs must be HTTPS (HTTP only on localhost, 127.0.0.1 and [::1]) and on one of your release's origins.
A manifest excerpt:
{
"worker_url": "https://app.example.com/worker",
"pages": [
{ "id": "dashboard", "route": "dashboard", "title": "Dashboard", "content_url": "https://app.example.com/dashboard" },
{ "id": "settings", "route": "settings", "title": "Settings", "content_url": "https://app.example.com/settings" },
{ "id": "report", "route": "reports/{id}", "title": "Report", "content_url": "https://app.example.com/report" }
],
"extensions": [
{ "id": "nav", "extension_key": "web.sidebar.stock-inventory", "label": "Example", "page": "dashboard" },
{ "id": "sync", "extension_key": "web.items.view.actions", "label": "Sync", "icon": "sync", "confirm": true },
{ "id": "stock-score", "extension_key": "web.items.list.table.columns", "label": "Stock score" }
]
}Connecting
const bridge = await StoklyAppBridge.createBridge();createBridge() waits for Stok.ly to connect and resolves with a bridge. Stok.ly connects when your frame has loaded, and gives up if your frame does not answer within 5 seconds, so:
- call
createBridge()while the page is loading (for example, in a plain script at the end of<body>), not after a long async startup; - register your event handlers straight after it resolves.
Calling createBridge() again in the same frame returns the same bridge, until you call close().
Options, all optional:
| Option | Default | Meaning |
|---|---|---|
hostOrigins | [] | Extra exact origins trusted as the Stok.ly host, for example ["http://localhost:4300"]. Local development only, see Security. |
timeoutMs | 30000 | How long a command waits for Stok.ly to answer. |
initTimeoutMs | wait forever | Reject if Stok.ly has not connected within this time. Useful when the page can also be opened outside Stok.ly. |
autoResize | true | Keep your frame's height fitted to your content, see Frame height. Set it to false to size the frame yourself with resize. |
The bridge exposes:
| Member | Description |
|---|---|
appId | Your app's id, the same one shown in the developer portal and used in its Stok.ly URLs (/apps/<appId>/...). |
frame | "worker", "page", "tab", "section" or "dialog". |
context | IDs describing where the frame is shown, plus extensionId and extensionKey on a tab, section or action dialog frame, for example { itemId, extensionId, extensionKey } on an item tab. Values are strings, numbers, booleans, null or arrays of strings or numbers. A bulk action's dialog also gets the selected recordIds. A dialog opened with openDialog has an empty context. On a page frame, { params, query }, see Pages. |
version | The protocol version, 1. |
on(event, handler) | Handle an event. Returns a function that removes the handler. |
onHook(target, phase, handler) | Handle a hook on a core action, see Hooks. Returns a function that removes the handler. |
toast, openDialog, closeDialog, navigate, refresh, getSessionToken, setQuery, setTitle, choose, setState, setCreateState, setDirty, resize | Commands, see Command reference. |
command(name, payload, { timeoutMs? }) | Send any command by name. timeoutMs overrides the bridge's timeoutMs for this command, and 0 waits forever. |
close() | Disconnect. Pending commands reject. |
Worker example
https://app.example.com/worker:
<!doctype html>
<html>
<body>
<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>
<script>
StoklyAppBridge.createBridge().then((bridge) => {
// A page action (or sub-action) without a content_url.
bridge.on("page-action.invoked", async ({ extensionId, context }, meta) => {
if (extensionId !== "sync") {
throw new Error(`unknown action ${extensionId}`);
}
// context.itemId is untrusted input. Your backend must check that the
// item belongs to the account your OAuth token was granted for.
await syncItem(context.itemId);
await bridge.toast("Item synced", "success");
await bridge.refresh({ eventId: meta.id });
});
// A bulk action on a table.
bridge.on("bulk-action.invoked", async ({ recordIds }) => {
await bridge.toast(`Queued ${recordIds.length} records`, "info");
});
// Values for your columns, one object per row.
bridge.on("columns.requested", async ({ columnIds, recordIds }) => {
// Fetch through your backend, which authorises every ID it is given.
const scores = await fetchScores(recordIds);
const values = {};
for (const id of recordIds) {
values[id] = { "stock-score": scores[id] ?? null };
}
return { values };
});
});
async function syncItem(itemId) {
// Call your backend. It authorises the request and calls the Stok.ly API
// with your OAuth token; never trust the ID on its own.
}
async function fetchScores(recordIds) {
return {};
}
</script>
</body>
</html>While your handler runs, Stok.ly shows the action as loading. If the handler throws or rejects, the action fails and the user sees an error toast with your app's name and your error message (up to 200 characters). If it doesn't answer in time, the toast says your app didn't respond.
Frame example
A dashboard page that opens another page and a dialog:
<!doctype html>
<html>
<body>
<button id="settings">Settings</button>
<button id="import">Import</button>
<script src="https://assets.stok.ly/developers/lib/app-bridge/v1/app-bridge.min.js"></script>
<script>
StoklyAppBridge.createBridge().then((bridge) => {
document.getElementById("settings").addEventListener("click", () => {
bridge.navigate({ page: "settings" });
});
document.getElementById("import").addEventListener("click", () => {
bridge.openDialog("https://app.example.com/import", { title: "Import", width: 640 });
});
});
</script>
</body>
</html>And the dialog at https://app.example.com/import:
StoklyAppBridge.createBridge().then((bridge) => {
document.getElementById("done").addEventListener("click", async () => {
await bridge.toast("Import started", "success");
await bridge.closeDialog();
});
});Pages: route params and query
A page is served at /apps/<appId>/<route>. A route is segments separated by /, each either a literal ([a-z0-9-]+) or a param ({name}, where name matches [a-zA-Z][a-zA-Z0-9_]*):
- the first segment must be a literal, and param names must be unique within a route;
accountkeyandextensionKeyare reserved, in any letter case, and can't be param names;- a param matches exactly one non-empty segment, of up to 256 characters once decoded, and can't be
.or..; - a literal beats a param in the same position, so
reports/newandreports/{id}can coexist, and/apps/<appId>/reports/newopensreports/new; - two routes of the same shape, such as
reports/{id}andreports/{name}, are an error; - a nav extension's
pagemust be a page without params, since a link has no values to fill them with.
Reading params and query
Stok.ly passes the route params and the address bar's query params to your page in two ways:
- appended to your
content_urlas query params, so your server can read them from the URL; - in
bridge.contextof thepageframe, as{ params: { ... }, query: { ... } }, both maps of strings.
For /apps/<appId>/reports/42?tab=summary:
const bridge = await StoklyAppBridge.createBridge();
bridge.context.params.id; // "42"
bridge.context.query.tab; // "summary"
new URLSearchParams(location.search).get("id"); // "42" as well
new URLSearchParams(location.search).get("tab"); // "summary" as wellEvery other frame (tab, section, action dialog) gets its context IDs and extensionKey appended to its content_url the same way. An array value becomes a repeated key.
When names clash on your frame URL, extensionKey wins, then context IDs, then route params, then your content_url's own query params. An address-bar query key that clashes with any of them, or is reserved, is left out, from both the frame URL and bridge.context.query.
Every visible frame must still load the SDK and call createBridge(): a frame that doesn't connect is replaced by an error row (see Frame height).
Stok.ly doesn't add accountkey to your frame URLs. To know which account the user is working in, read accountkey from a verified session token.
Query limits
Query params, whether they arrive in the address bar or are sent with navigate or setQuery, must follow these limits:
- keys match
^[a-zA-Z0-9_.-]{1,64}$; - values are strings;
- the whole query, encoded, is at most 2048 characters.
Commands that break them are refused with an error saying why. Query params in the address bar that break them are silently left out. StoklyAppBridge.validateQuery(query) runs the same check and returns { query, errors }: the valid entries, and a message for each one left out.
Linking to a page
await bridge.navigate({ page: "report", params: { id: "42" }, query: { tab: "summary" } });params must supply exactly the route's params, no more and no fewer. query is only allowed with page, and can't use a reserved name or one of the route's param names.
Updating the address bar
A page can keep its own state in the address bar, so a reload or a shared link comes back to the same view:
const { query } = await bridge.setQuery({ tab: "details" }); // replaces the query
await bridge.setQuery({ sort: "name" }, { merge: true }); // adds to itsetQuery updates the webapp's URL without adding a history entry and without reloading your frame, and resolves with the resulting query. It's only allowed from the page frame currently shown, and is rate limited. merge defaults to false, so the query is replaced. To remove a key, replace the query without it. The same limits apply, and the query can't use a reserved name or one of the page route's param names.
Since your frame isn't reloaded, your frame's own URL and bridge.context.query go stale after setQuery. Read them once, when the page loads, and keep your state in your page from then on. If the user changes the query some other way (for example with Back, or a link to the same page), Stok.ly reloads your frame with the new values.
As with all context, treat params and query values as untrusted input. Anyone can put anything in a link to your page, so validate the values and authorise every ID on your backend.
Setting the page title
By default, the heading above your page and the browser tab title (<title> | Stok.ly) both show the page's title from your manifest. A page that shows one record can name it instead:
await bridge.setTitle("Report 42"); // heading "Report 42", tab "Report 42 | Stok.ly"
await bridge.setTitle(""); // back to the manifest titleTitles are cut to 100 characters, and the title goes back to the manifest's when the user leaves the page. setTitle (like choose) needs SDK 1.3.0, and Stok.ly releases from before it refuse the command with host-error. The CDN's v1 URL always has the latest SDK, so if your app must work with older Stok.ly releases, handle the rejection:
bridge.setTitle(`Report ${id}`).catch(() => {}); // keep the manifest titleFrame height
Your pages, tabs, sections and dialogs grow and shrink with their content, so users never see a second scrollbar inside Stok.ly's page. You don't need to do anything for this: by default the bridge watches your document and, whenever its height changes, tells Stok.ly the new height with the resize command. It sends at most one resize per animation frame, and only when the height has actually changed. Your worker is hidden, so it's never resized.
A few things to know:
- While your frame loads and connects, Stok.ly shows a compact loading row in its place (a dialog keeps its size). Once connected, until your frame sends its first
resize, it gets a default height: 400 pixels for a section or page, 600 for a tab and 60% of the window for a dialog. - A frame that doesn't connect within 5 seconds (for example one that doesn't load the SDK, or fails to load) is replaced by a one line message saying your app couldn't be loaded, with a Retry button.
- Stok.ly keeps every frame between 40 and 20000 pixels tall.
- If Stok.ly refuses a
resize, for example an older Stok.ly release that doesn't support it, the bridge stops resizing and your frame keeps its current height.
Sizing the frame yourself
To control the height yourself, turn automatic resizing off and call resize with a height in CSS pixels:
const bridge = await StoklyAppBridge.createBridge({ autoResize: false });
await bridge.resize(480);CSS to avoid
The bridge measures your document's own height, so your CSS decides whether that follows your content:
- Don't give
htmlorbodyaheightormin-heightof100%, or one invh. Your document would then follow the frame's height instead of your content, so it could never shrink, and could keep growing until it reaches the 20000 pixel limit. - Content positioned
absoluteorfixed, outside the normal flow, isn't measured. Leave room for it in the flow, or size the frame yourself. - In a dialog, the dialog's body scrolls when your content is taller than the dialog allows.
Hooks
Hooks let your app take part in Stok.ly's own actions, such as saving or deleting an item. For example, an app with a section on the item view can check the section's inputs when the user clicks the item's Save, and block the save if they're invalid, then store them once the item has saved.
Naming
A hook is named by the full key of a core action plus a phase:
beforeruns before the action, and can cancel it (or skip records of a bulk action);afterruns once the action succeeded. It never runs for a failed or cancelled action.
Only actions Stok.ly marks as hookable can be hooked. The extension point reference lists every hookable action and the phases each supports. Most create, save, delete and status actions (cancel, void, approve, close and so on) are hookable, as are the delete and status bulk actions on list pages. Examples:
| Target | Context | Notes |
|---|---|---|
web.items.view.actions.save | { itemId } | before runs once the form is valid and has changes, before anything is written |
web.items.view.actions.delete | { itemId } | before runs once the user has confirmed |
web.purchase-orders.view.actions.cancel | { purchaseOrderId } | before runs once the user has confirmed |
web.items.create.actions.create | {}, and { itemId } in after | the new item's id is in the after context. See Create pages |
web.items.list.table.bulk-actions.delete | {}, plus recordIds | before gets every selected record. after gets the records that succeeded and failed, and is only sent when at least one succeeded. Records your hook skipped are in neither list |
- The context of a view-page action holds the id of the record, named after it (
itemId,purchaseOrderIdand so on). Actions on a page with no single record, such as the currencies save, have an empty context. - A
beforehook runs only once the action is going ahead: after the form has validated and after any confirmation the user is asked for. Anafterhook never runs if the action failed. - A few actions that open a dialog to collect input are not hookable yet.
- Hooks can read and cancel, but can't change what's saved.
- Pages that both create and edit a record (stock transfers, currencies) have no create hook. Their save hook runs instead, with no id in the context while creating.
- Duplicating a record (for example a template or a pick rule) runs the same create hooks as creating one, with nothing to tell them apart.
- While handling a hook, your worker can't
navigate,openDialogorchoose.
Hooks only run in the Stok.ly web UI. Changes made through the API, CSV imports, integrations or scheduled rules never trigger them, so don't rely on abeforehook as your only validation.
Declare every hook in your manifest. Stok.ly only sends your worker the hooks it declares, so declaring only the phases you handle keeps actions fast. Hooks need a worker_url, since only your worker handles them:
"hooks": [
{ "target": "web.items.view.actions.save", "phases": ["before", "after"] },
{ "target": "web.items.list.table.bulk-actions.delete", "phases": ["before"] }
]Handling hooks
In your worker:
bridge.onHook("web.items.view.actions.save", "before", ({ context, frames }) => {
const section = frames["notes-section"];
if (!section || !section.supplierCode) {
return { cancel: true, message: "Supplier code is required" };
}
// returning nothing (or { cancel: false }) lets the save go ahead
});
bridge.onHook("web.items.view.actions.save", "after", async ({ context, frames }) => {
await saveNotes(context.itemId, frames["notes-section"]);
});onHook is built on the hook.before and hook.after events, routing each by target, so don't also register those events with on. Registering the same target and phase again replaces the handler. Once you've registered any onHook handler, a hook for a target you have no handler for is answered with no cancel. A worker that never calls onHook fails every hook its manifest declares, and the user sees a warning.
A before handler returns (or resolves to):
| Field | Meaning |
|---|---|
cancel | true blocks the action. The user sees your app's name and message, for example "Example App: Supplier code is required". If several apps cancel, their messages are combined. |
message | Shown when cancel is true, collapsed to one line of at most 200 characters. Without one, the user sees "Example App cancelled this action". |
skip | Bulk actions only: { [recordId]: reason }. Those records are left out and never processed, and each shows as an error in the results: "Skipped by Example App: reason". Ids that aren't part of the action are ignored. |
An after handler's result is ignored. The user has already seen the action succeed.
Failures never block users
Hooks fail open, so a broken app never stops anyone working:
- a
beforehandler that throws, rejects or doesn't answer within 5 seconds lets the action go ahead, and the user sees a warning that your app couldn't check it. The 5 seconds include loading your worker if it isn't running yet; - an
afterhandler has 30 seconds. If it throws, rejects or times out, the user sees a warning.
Answer before hooks quickly, since the user is waiting.
Frame state
A hook's payload carries frames: the state your app's frames on the current page have published, by extension id. Frames publish it with setState:
// in your section frame
input.addEventListener("input", () => {
bridge.setState({ supplierCode: input.value });
bridge.setDirty(true);
});- The state is any JSON value of at most 16 KB (UTF-8 encoded JSON). The latest value wins,
nullclears it, and it's cleared when the frame is destroyed. - Only tab, section and action dialog frames can publish state, keyed by their extension's
id. Frames that haven't published any are left out offrames. framesonly has your own app's frames, and only those on the page the action runs on.- Before sending a hook, Stok.ly waits for every
setStateyour frames have already sent to arrive, so a user who types and then clicks Save straight away is safe. CallsetStateas the user types rather than debouncing it.
Unsaved changes
setDirty(true) tells Stok.ly your frame has unsaved changes. While any app frame on the page is dirty, leaving the page (or closing the tab) asks the user first, just as it does for unsaved changes in Stok.ly's own forms. On the item view, a dirty frame of an app that hooks the Save's after phase also makes Save go ahead (and run its hooks) even when the item itself has no changes.
Once your after hooks for an action have succeeded, Stok.ly clears the dirty flag of your frames on the page. Call setDirty(false) yourself when the user discards their changes. The flag is also cleared when the frame is destroyed.
Choosing records
choose lets the user pick records with Stok.ly's own chooser, so your app needs no list UI or data access just to ask "which order?". Your app must have been granted the entity's read scope on the account. Call it and use the ids with your app's API token:
const { cancelled, records } = await bridge.choose("sale-orders", { multiple: true });
if (!cancelled) {
await fetch("/my-backend/link", { method: "POST", body: JSON.stringify({ orderIds: records.map((r) => r.id) }) });
}Pass selected to show records already ticked. From a worker, call it only while handling a page action or bulk action event.
Create pages
Create pages have their own actions (back, next and create, or just create on single-page creates) under <entity>.create.actions, for example web.items.create.actions. Pages with a sections container use the same keys as the view page, so one section shows on both. You can hook the create action of each, with the before and after phases:
"hooks": [
{ "target": "web.items.create.actions.create", "phases": ["before", "after"] },
{ "target": "web.item-types.create.actions.create", "phases": ["after"] },
{ "target": "web.item-attributes.create.actions.create", "phases": ["after"] }
]A create page's before context has no record id, since the record doesn't exist yet. Its after context has the new record's id, for example { itemId } for web.items.create.actions.create, so that's where to store anything your frames collected for the new record. Sections on a create page get no itemId in their own context either.
A create hook's payload also carries these fields:
| Phase | Fields |
|---|---|
before | state, and draft when included |
after | state, and record when included |
stateis what your own frames published withsetCreateState, a JSON object. Frames on a create page can publish it as the user fills in your section, and your hook reads it when they click Create.draft(the values in the form) andrecord(the record that was created) are only included when your app's installed scopes include the read scope for what's being created, for examplecatalogue.items.readfor items.- Secrets never reach apps: credentials and similar fields (a channel's or courier's specifics, a CSV schedule's password, a customer's secret phrase) are left out of
draftandrecord. - The
recordis the created record, with itsid. Its shape can differ fromdraft: for items,draftis the form's values andrecordis the item as sent to the API. The id is also in theaftercontext, named after the record, for examplecontext.itemId. - Invoice create pages make several invoices at once:
draftis{ invoices }, and theafterhook has no single id.
// in your section frame, on a create page
input.addEventListener("input", () => {
bridge.setCreateState({ supplierCode: input.value });
});
// in your worker
bridge.onHook("web.items.create.actions.create", "before", ({ state }) => {
if (!state.supplierCode) {
return { cancel: true, message: "Supplier code is required" };
}
});
bridge.onHook("web.items.create.actions.create", "after", async ({ context, state }) => {
await saveNotes(context.itemId, state);
});setCreateState merges its keys into the current state, and the state is cleared when the create page closes.
Event reference
Events are sent from Stok.ly to your worker. Stok.ly only sends you events for your own extensions. Register a handler with bridge.on(name, handler). The handler receives (payload, meta), where meta.id is the event id. Whatever it returns (or resolves to) is the response. Stok.ly waits up to 30 seconds.
An event with no handler is answered with the error no handler for <event>. Registering a second handler for the same event replaces the first.
page-action.invoked
page-action.invokedThe user clicked one of your page actions or sub-actions that has no content_url. If the action has "confirm": true, the user has already confirmed.
{
extensionId: string; // your extension's id, e.g. "sync"
extensionKey: string; // the container key, e.g. "web.items.view.actions"
context: object; // IDs for the current page, e.g. { itemId: "..." }
}Response: none required.
bulk-action.invoked
bulk-action.invokedThe user ran one of your bulk actions on selected table rows.
{
extensionId: string;
extensionKey: string;
context: object;
recordIds: string[]; // the selected rows
}Response: none required.
columns.requested
columns.requestedA table showing your columns needs values for the rows on screen. It is sent again when the user pages, filters or refreshes.
{
extensionKey: string; // e.g. "web.items.list.table.columns"
columnIds: string[]; // your column ids on this table
recordIds: string[]; // the rows shown
context: object;
}Response:
{
values: {
[recordId: string]: {
[columnId: string]: string | number | boolean | null;
};
};
}Values are rendered as plain text. A missing row or column shows an empty cell. Answer all your columns in one response. Rows and columns that weren't requested are ignored, strings are cut to 500 characters, and numbers that aren't finite are dropped.
hook.before and hook.after
hook.before and hook.afterA hooked core action is about to run (hook.before), or has succeeded (hook.after). Handle them with onHook, see Hooks. Stok.ly waits up to 5 seconds for hook.before and 30 seconds for hook.after.
{
target: string; // the core action's full key, e.g. "web.items.view.actions.save"
context: object; // the action's context, e.g. { itemId: "..." }
frames: object; // { [extensionId]: state } for your frames on the page
state?: object; // create actions only: what your frames published with setCreateState
draft?: unknown; // create actions, before only: the form values (JSON). Needs the flow's read scope
record?: unknown; // create actions, after only: the created record (JSON). Needs the flow's read scope
recordIds?: string[]; // bulk actions only. In hook.after, the records that succeeded
failedRecordIds?: string[]; // hook.after of bulk actions only: the records that failed
}Response to hook.before: { cancel?: boolean, message?: string, skip?: { [recordId]: string } }, or nothing. The response to hook.after is ignored.
Command reference
Commands are sent from any of your frames to Stok.ly. Each returns a promise that resolves when Stok.ly has carried out the command, and rejects if Stok.ly refuses it. Stok.ly checks every command against your app's own manifest.
| Command | Helper | Payload | Result |
|---|---|---|---|
toast | toast(message, type?) | { message: string, type?: "info" | "success" | "error" } | none |
openDialog | openDialog(url, { title?, width? }) | { url: string, title?: string, width?: number | string } | none |
closeDialog | closeDialog() | {} | none |
navigate | navigate({ page, params?, query? }) or navigate({ path }) | { page: string, params?: object, query?: object } or { path: string } | none |
refresh | refresh({ eventId? }) | { eventId?: string } | none |
getSessionToken | getSessionToken() | {} | { token: string, expiresAt: number } |
setQuery | setQuery(query, { merge? }) | { query: object, merge?: boolean } | { query: object } |
setTitle | setTitle(title) | { title: string } | none |
choose | choose(entity, { multiple?, selected? }) | { entity: string, multiple?: boolean, selected?: string[] } | { cancelled: boolean, records: { id: string, label: string }[] } |
setState | setState(state) | { state: any } | none |
setCreateState | setCreateState(state) | { state: object } | none |
setDirty | setDirty(dirty) | { dirty: boolean } | none |
resize | resize(height) | { height: number } | none |
Rules:
toast:typedefaults to"info". The message can't be empty and is cut to 300 characters. At most 5 toasts per app every 10 seconds.openDialog:urlmust be on one of the origins your manifest declares.widthas a number is pixels, clamped to 200 to 2000, and as a string is a number (up to 4 digits and 2 decimals) withpx,vw,%,remorem, such as"80vw"; any other string is refused. It defaults to 900 pixels, and the dialog is never wider than 95% of the window.titledefaults to your app's name and is cut to 200 characters. Resolves once the dialog is open. A dialog opened this way has an empty context and can't usesetState. A worker can only open a dialog while it handles a page action or bulk action event.closeDialog: only allowed from adialogframe, and closes that dialog.navigate:pagemust be one of your page ids, withparamsfor exactly its route's params and an optionalquery, see Linking to a page.pathmust be an internal Stok.ly path starting with/, and must not be another app's page. Any query or hash inpathis dropped, andpage,paramsandqueryaren't allowed with it. Paths with//, backslashes, control characters, empty,.or..segments, outlets or matrix params are refused.paramsthat would resolve to one of your other pages' literal routes are refused. A worker can only navigate while it handles a page action or bulk action event, never while it handles a hook. The promise rejects if the navigation is cancelled, for example when the user chooses to stay on a page with unsaved changes.refresh: pass the id of the bulk action event you are handling (meta.id) to refresh the table it came from. For a page action event, the id reloads the current page. The id stays valid for 10 minutes; ids fromcolumns.requestedor hook events are refused. Without an id, Stok.ly reloads the current page. At most 1 refresh per app every 3 seconds.getSessionToken: see Session token.expiresAtis in milliseconds since the Unix epoch.setQuery: only allowed from thepageframe currently shown, and not while a navigation is in progress. At most 10 per second. See Updating the address bar.setTitle: only allowed from thepageframe currently shown. Sets the heading above your page and the browser tab title, which otherwise show the page's manifesttitle. An empty string goes back to the manifest title. The title is cut to 100 characters and resets when the user leaves the page. The SDK rejects a title that isn't a string withinvalidbefore sending it. Stok.ly releases from before SDK 1.3.0 refuse it, see Setting the page title.choose: opens Stok.ly's record chooser.entityis"sale-orders","items","purchase-orders","suppliers","locations"or"customers", and your app must have been granted that entity's read scope on the account (sales.orders.read,catalogue.items.read,purchasing.orders.read,catalogue.suppliers.read,inventory.locations.readorcustomers.customers.read), otherwise it is refused.multiplelets the user pick several (default one), andselectedis a list of string ids to show already ticked when the chooser opens. Only the first 100selectedids are pre-ticked; this doesn't limit how many records the user can pick. Only allowed from a frame, or from a worker while it handles a user event (page action or bulk action). It returns ids and labels only, never record data: read the records with your app's API token. If the user closes the chooser,cancelledistrueandrecordsis empty. It has no timeout, since it waits for the user, and only ends when the user answers or the bridge closes (closed). The SDK rejects an unknown entity or aselectedthat isn't an array withinvalidbefore sending it. Needs SDK 1.3.0, and older Stok.ly releases refuse it. See Choosing records.setState: only allowed from a tab, section or action dialog frame, see Frame state.statemust be JSON of at most 16 KB, which the SDK checks before sending.StoklyAppBridge.validateFrameState(state)runs the same check.setCreateState: only allowed from a page, tab, section or dialog frame (not the worker), while a create page is open, see Create pages.statemust be a JSON object, which the SDK checks (with the 16 KB limit) before sending. It is shallow-merged into the existing create state, the merged state must stay within 16 KB, and it's cleared when the create page closes.setDirty: only allowed from a page, tab, section or dialog frame. The SDK rejects adirtythat isn't a boolean withinvalidbefore sending it. See Unsaved changes.resize: only allowed from a page, tab, section or dialog frame, see Frame height.heightis in CSS pixels, and Stok.ly keeps it between 40 and 20000. The SDK rejects a height that is negative or not a finite number withinvalidbefore sending it. You only needresizeif you've setautoResize: false.
Errors and timeouts
Failed commands reject with a StoklyAppBridge.BridgeError, which has a message and a code:
code | Meaning |
|---|---|
host-error | Stok.ly refused or failed the command, or the SDK couldn't send it (for example a payload that can't be cloned). message says why. |
timeout | No answer within timeoutMs (30 seconds by default). choose never times out. |
closed | bridge.close() was called. |
init-timeout | createBridge({ initTimeoutMs }) did not connect in time. |
no-window | createBridge() was called outside a browser. |
invalid | The SDK refused the call without sending it, for example setState with a value that isn't JSON. message says why. |
Data access
The bridge carries references, not data. Events and bridge.context contain IDs (itemId, recordIds and so on), never the contents of the page. To read or change data, call the Stok.ly API with your app's OAuth token, from your backend. The API only allows what your app's granted scopes permit.
This keeps your app working when the webapp's screens change, and means your app sees exactly what its scopes allow, wherever it runs.
Security
Only let Stok.ly frame your app
Serve every app URL (your worker, pages, tabs, sections and dialogs) with:
Content-Security-Policy: frame-ancestors https://*.stok.ly
This stops other sites from embedding your app and trying to drive it.
The SDK only trusts Stok.ly
The SDK only connects to a host whose origin is https://stok.ly or an https *.stok.ly subdomain, and silently ignores anyone else. StoklyAppBridge.isTrustedHostOrigin(origin, extraOrigins) exposes the same check.
The hostOrigins option adds exact origins to that list. Use it for local development only (for example createBridge({ hostOrigins: ["http://localhost:4300"] })), and never ship it to production, because any page on that origin could then act as the host.
Treat bridge context as untrusted input
Everything the bridge hands your frames (context, recordIds, route params, query params and so on) comes from the browser and isn't verified. The session token is the verified way to know who the user is and which account they are working in. So:
- identify the user and account from a session token your backend has verified, never from
contextor the frame URL; - treat
context,recordIds, route params, query params and every other ID as untrusted input; - authorise all data access on your backend, using your app's OAuth token with the Stok.ly API, which only returns what that account granted your app;
- never let an ID from the bridge alone decide what your backend reads or changes.
Session token
getSessionToken() returns a short lived, signed token identifying the current user and account. Send it to your backend, which verifies it to find out who is using your frame. Stok.ly only issues a session token while your app is installed on the user's account (an install is complete once your app has exchanged its authorization code, see Installing your app), so getSessionToken() fails before the app is installed and once the account uninstalls it.
Sending it to your backend
const { token } = await bridge.getSessionToken();
const res = await fetch("https://app.example.com/api/me", {
headers: { Authorization: `Bearer ${token}` },
});Tokens last 60 seconds, and expiresAt is in milliseconds since the Unix epoch. Call getSessionToken() again for each backend request, or when the token you have is close to expiresAt, rather than storing it. Stok.ly caches the token briefly, so calling it often is fine.
The session token is not a Stok.ly API credential, and the Stok.ly API rejects it. Call the Stok.ly API with your app's OAuth token, as before.
Claims
The token is a JWT signed with RS256, with these claims:
| Claim | Value |
|---|---|
iss | https://api.stok.ly/apps in production, https://api.dev.stok.ly/apps in dev |
aud | your app's Client ID, which is its app id |
sub | the Stok.ly user id |
accountkey | the Stok.ly account the user is working in |
permissions | string[]: the short keys of the permissions the signed-in user holds for your app, for example ["approve"]. Empty if they hold none, and every permission your app defines for an account admin |
app_id | your app's id |
token_use | always "app_session" |
jti | a unique id for this token |
iat | when the token was issued |
exp | when the token expires, 60 seconds after iat |
Verifying it
Stok.ly publishes its public keys as a JWKS:
- Production:
https://api.stok.ly/v1/app-extensions/jwks.json, issuerhttps://api.stok.ly/apps. - Dev:
https://api.dev.stok.ly/v1/app-extensions/jwks.json, issuerhttps://api.dev.stok.ly/apps.
On every request, your backend must:
- verify the signature against the JWKS, using the key that matches the token's
kid; - check that
issis the Stok.ly issuer; - check that
audis your own Client ID; - check
exp; - check that
token_useis"app_session".
In Node, with the jose library:
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL("https://api.stok.ly/v1/app-extensions/jwks.json"));
async function verifySessionToken(token) {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://api.stok.ly/apps",
audience: process.env.STOKLY_CLIENT_ID,
algorithms: ["RS256"],
});
if (payload.token_use !== "app_session") {
throw new Error("not a Stok.ly app session token");
}
return { userId: payload.sub, accountkey: payload.accountkey, permissions: payload.permissions ?? [] };
}Enforce permissions in your own API, using the permissions from the verified token, for example by rejecting a request to approve a drop shipment unless permissions.includes("approve").
createRemoteJWKSet caches the keys and picks one by kid, fetching them again when it sees a new kid. When Stok.ly rotates its key, the old and new keys are both published for a while, so rotation needs nothing from you as long as you look keys up by kid rather than pinning one. jwtVerify checks the signature, iss, aud and exp. Reject the request if any check fails.
Protocol details
You do not need this section if you use the SDK. It describes what the SDK does.
- When your frame loads, Stok.ly posts
{ type: "stokly:init", version: 1, appId, frame, context }to it withpostMessage, targeted at your frame's origin, and transfers oneMessagePort. It repeats this, each time with a new port, until the SDK answers or 5 seconds pass. - The SDK only accepts this message from
window.parent, from a trusted host origin (see Security), withversion: 1and exactly one port. It ignores anything else, and any init after the first. - The SDK answers
{ type: "ready" }on the port. All further messages use the port. - Requests are
{ id, kind: "event" | "command", name, payload }. Responses are{ id, kind: "response", ok: true, result }or{ id, kind: "response", ok: false, error }, whereerroris a message string. - Before a hook, Stok.ly sends the event
bridge.flush(with an empty payload) to each of the app's frames on the page, and waits for the responses. The SDK answers it itself, straight away. Since messages on a port arrive in order, everysetStatethe frame sent before then has arrived. Any response counts, so a frame that doesn't know the event is fine too.
Updated about 7 hours ago
