App Bridge
How a page the admin frames talks to the admin. The handshake, toasts, the title bar, the save bar, modals, confirm, the resource picker and navigation.
App Bridge is the set of messages between a page of yours that the admin frames and the admin around it. Your home, a panel and an action's window all speak it.
createAppBridgefrom@storeware/plugin-sdk/bridgespeaks it for you, in any framework or none.@storeware/app-bridge-reactwraps that in a provider and hooks.- The messages are zod schemas in
@storeware/plugin-sdk/bridge/protocol, the same ones the admin checks every message against.
Early access
Connect
import { createAppBridge } from '@storeware/plugin-sdk/bridge';
const bridge = createAppBridge({ hostOrigin: 'https://app.storeware.ai' });
bridge.applyTheme(document.documentElement);
bridge.autoResize(document.body);
const welcome = await bridge.ready;
console.log(welcome.point, welcome.context.productId);hostOrigin is the admin's origin. Every message goes to it and nothing from any other origin is believed. Read it from your configuration, so the same code works against a local admin.
Anything you send before the admin answers is held and sent after, so a page can call bridge.toast on its first line.
The handshake
Your page says hello with the versions it speaks, and the admin answers welcome with everything the page needs to start:
| Field | What it is |
|---|---|
version | The version agreed: 2 |
app | Your app's name and slug |
storeId | The shop the merchant is in |
point | Where the page is framed: a point such as admin.order.detail.sidebar, or admin.app.home for your home and its modals |
context | The record beside the page, such as productId, and on a list's action, selection |
theme | The admin's 13 --sw-* variables and its scheme |
session | A session token and when it expiresAt |
pickers | Which resource pickers the merchant's grant lets you open |
modal | Whether the page is in a window, where modal.close works |
A page says hello each time it loads. When your frame follows one of its own links, the new page starts clean: whatever the last page put in the title bar, the loading bar or the save bar is cleared.
Requests and answers
A message with an id is answered with a response of the same id: ok: true with a result, or ok: false with an error of { code, message }. The SDK turns a refusal into a BridgeError with its code.
| Code | Why |
|---|---|
invalid | The message did not match its schema. The message names the field. |
not_ready | Sent before the handshake finished. |
rate_limited | More than 3 toasts in 10 seconds. |
not_granted | A picker for records your grant cannot read. |
unavailable | Not offered where the page is: a save bar on a page without one, a modal inside a modal, modal.close outside one. |
busy | The merchant is already answering one of your app's questions. |
failed | The admin tried and could not, such as minting a session. |
The SDK waits 10 seconds for the admin's answer (timeoutMs changes it). A modal, a confirm and a picker wait for the merchant, as long as they take.
Session tokens
const response = await fetch('/api/reviews', {
headers: { authorization: `Bearer ${await bridge.sessionToken()}` },
});sessionToken returns the current token while it has more than a minute left, and asks the admin for a fresh one after. The admin also sends a fresh one every four minutes. On your server, authenticateAdmin reads the bearer token as it reads ?session= on the first load.
Toasts
bridge.toast('Review published');
bridge.toast('That review could not be published', 'error');A toast is shown with your app's name in front of it, so a merchant never mistakes it for Storeware's. It is cut at 200 characters, and a page may show 3 in any 10 seconds.
Loading bar
bridge.loading(true);
await importReviews();
bridge.loading(false);A thin bar across the top of your frame, for work the merchant is waiting on. It stands still for anybody who has asked for reduced motion.
Title bar
bridge.titleBar({
title: 'Reviews',
primary: { id: 'publish', label: 'Publish' },
secondary: [{ id: 'export', label: 'Export' }],
});
bridge.on('title_bar.action', (id) => {
if (id === 'publish') publish();
if (id === 'export') exportReviews();
});A heading and buttons above your frame, drawn by the admin. One primary button and up to 3 secondary ones, each with disabled and destructive if you need them. Send it again to change it, or {} to clear it.
Save bar
bridge.saveBar.dirty(true);
bridge.on('save_bar.save', async () => {
try {
await save();
bridge.saveBar.result(true);
} catch {
bridge.saveBar.result(false, 'The name is missing');
}
});
bridge.on('save_bar.discard', () => resetForm());While your page is dirty the admin's save bar shows, and leaving the page asks the merchant first, as it does for Storeware's own forms. Save saves every form on the page, yours included: the admin sends save_bar.save and waits up to 30 seconds for your result. A failure keeps your changes unsaved and names your app in the bar.
The save bar is on your home, the shop's settings, and the product and customer pages. Anywhere else, and inside a window, save_bar is answered unavailable.
Modals
const data = await bridge.modal.open('/reviews/new', 'New review', { size: 'large' });Opens a page of your home in a window over the admin, and resolves when it closes. The path is inside your [app] url, so your app needs a home to open one. Inside the window, the page closes itself:
bridge.modal.close({ saved: true });data is what the window's page passed, up to 16 KB of JSON, or undefined when the merchant closed it. A window cannot open another window.
An action at an .action point opens in a window too, so modal.close closes it when the action is done.
Confirm
const sure = await bridge.confirm({
title: 'Delete 3 reviews?',
message: 'They are taken off the shop at once.',
confirmLabel: 'Delete',
destructive: true,
});The admin asks in its own dialog, titled with your app's name ("Reviews asks"), and resolves true or false.
Resource picker
const products = await bridge.pick('products', { multiple: true, max: 10 });
// [{ id: '…', title: 'Stoneware mug', subtitle: 'stoneware-mug' }]The merchant searches their own records and chooses. You are given each one's id, title and sometimes a subtitle, and an empty list when they cancel. multiple lets them choose up to max, at most 50.
A picker opens only for records your grant already reads:
| Resource | Needs |
|---|---|
products | products:read |
variants | products:read |
collections | products:read |
customers | customers:read |
Holding the write permission counts. Anything else is refused with not_granted; welcome.pickers lists what you may open.
Navigation
bridge.navigate('/orders?status=open');
bridge.appNavigate('/settings');
bridge.redirect('https://help.example.com/reviews');navigateopens a page of this shop's admin, by its path inside the shop.appNavigateopens a page of your home.redirectleaves Storeware. The admin shows the merchant where it goes first, and opens it in a new tab when they agree. https only, or http onlocalhost.
Paths are relative, start with one / and never contain ... If the page has unsaved changes, the merchant is asked before leaving.
Without the SDK
The protocol is plain postMessage. Every version 2 message carries protocol: 'storeware.bridge':
const HOST = 'https://app.storeware.ai';
const post = (message) => parent.postMessage({ protocol: 'storeware.bridge', ...message }, HOST);
addEventListener('message', (event) => {
if (event.origin !== HOST) return;
if (event.data?.protocol !== 'storeware.bridge') return;
if (event.data.type === 'welcome') {
post({ type: 'toast', message: `Hello, shop ${event.data.storeId}` });
}
});
post({ type: 'hello', id: 'hello-1', versions: [2] });Messages
From your page to the admin:
| Type | Fields | Answered with |
|---|---|---|
hello | id, versions | welcome |
session.request | id | { token, expiresAt } |
resize | height | |
toast | message, tone (info or error) | |
loading | on | |
title_bar | title, primary, secondary | |
save_bar | dirty | |
save_bar.result | ok, message | |
modal.open | id, path, title, size | { data }, once closed |
modal.close | data | |
confirm | id, title, message, confirmLabel, destructive | { confirmed } |
picker | id, resource, multiple, max | { selection } |
navigate | path | |
app.navigate | path | |
redirect | url |
Any of them may carry an id, and is then answered. From the admin to your page: welcome, response, session (a fresh token), theme, title_bar.action, save_bar.save and save_bar.discard.
Version 1
Pages written for version 1 (protocol: 'storeware.bridge.v1', sent by connectToHost in the SDK) keep working. The admin answers ready with context and theme, and still takes resize, toast and navigate. Version 1 has no handshake and none of the rest, so new pages should use version 2.