# 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.

Source: https://storeware.ai/docs/extensions/app-bridge

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.

- `createAppBridge` from `@storeware/plugin-sdk/bridge` speaks it for you, in any framework or none.
- [`@storeware/app-bridge-react`](https://storeware.ai/docs/extensions/app-bridge-react) wraps 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.** The CLI and the SDK are not on npm yet, so the commands below do not run for everyone. To build an app now, write to https://storeware.ai/contact.

## Connect

```ts
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](#resource-picker) the merchant's grant lets you open                                            |
| `modal`   | Whether the page is in a window, where [`modal.close`](#modals) 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

```ts
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

```ts
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

```ts
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

```ts
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

```ts
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

```ts
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:

```ts
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

```ts
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

```ts
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

```ts
bridge.navigate('/orders?status=open');
bridge.appNavigate('/settings');
bridge.redirect('https://help.example.com/reviews');
```

- `navigate` opens a page of this shop's admin, by its path inside the shop.
- `appNavigate` opens a page of your home.
- `redirect` leaves Storeware. The admin shows the merchant where it goes first, and opens it in a new tab when they agree. https only, or http on `localhost`.

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'`:

```js
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.
