@onebooks/app-bridge
v0.1.0
Published
Browser SDK for OneBooks embedded apps: App Bridge protocol v1 (postMessage) — session tokens, host navigation, toasts, dialogs and auto-resize.
Maintainers
Readme
@onebooks/app-bridge
Browser SDK for OneBooks embedded apps — the App Bridge protocol v1
(postMessage-based) that lets your app page talk to the OneBooks host:
session tokens, navigation, toasts, dialogs, resizing and theme/locale
events. See Contract 4 in the app-platform plan for the wire protocol this
package implements.
Zero runtime dependencies. ESM, TypeScript, Node ≥ 20 to build/test (the built artifact runs in any modern browser).
Install
npm install @onebooks/app-bridgeYou can also load the pre-bundled global build directly with a <script>
tag — copy dist/app-bridge.global.js next to your page; see
Script tag usage below.
Quickstart
Your app is loaded inside an <iframe> by the OneBooks host with a ?host=
query parameter (plus locale, embedded, and for extensions target,
resourceType, resourceId). createApp() reads all of that for you:
import { createApp } from '@onebooks/app-bridge';
const app = createApp(); // host/locale/etc. read from location.search
await app.ready(); // tell the host you're loaded
const ctx = await app.context();
console.log(ctx.business.name, ctx.locale, ctx.theme);
// Call your own backend with an auto-attached session token:
const res = await app.fetch('/api/invoices/recent');
const invoices = await res.json();
await app.toast('Loaded!', { tone: 'success' });API reference
createApp(options?)
createApp(options?: { host?: string; timeoutMs?: number }): OneBooksApphost— the OneBooks host origin to talk to. Defaults to the?host=query parameter. Must be anhttps:origin, orhttp://localhost(any port) for local development — anything else throwsAppBridgeErrorwith codeINVALID_HOST(orMISSING_HOSTif neither is present).timeoutMs— how long to wait for a host response before rejecting. Default10000.
Throws synchronously (does not return a rejected promise) if host is
missing or invalid, since without it nothing can be sent safely.
app.params
The parsed embed query parameters: { host, locale?, embedded, target?,
resourceType?, resourceId? }.
app.ready()
Tell the host your app finished loading. Call this first, before anything
else. Resolves { hostVersion: number }.
app.sessionToken()
Resolves a short-lived bearer token scoped to your app's backend. Cached
until ~10s before its expiresAt, and concurrent calls share a single
in-flight request (single-flight) — safe to call from multiple places
without worrying about request storms. You rarely need this directly; use
app.fetch() instead.
Because the token is reused for up to ~50 seconds, your backend must not
exchange it on every request: a session token can be exchanged at
POST /oauth/token only once (a replay is rejected with
invalid_grant). Verify the session token on every request, exchange it
only when you have no stored token pair for that (bid, sub) yet, and cache
the access/refresh pair you get back — see @onebooks/node's
verifySessionToken / oauth.exchangeSessionToken.
app.context()
Resolves { locale, dir, theme, business: { name, country, currency }, user:
{ name }, target: { type, resourceType?, resourceId? } | null }.
app.toast(message, options?)
Shows a host-rendered toast. options.tone is 'info' | 'success' |
'error'. message should stay under ~200 characters — the host truncates
longer text (an empty message is rejected with INVALID_PAYLOAD). At most 5
toasts per 10 seconds per frame; more reject with RATE_LIMITED.
app.navigate.host({ resource, id? })
Navigates the host to one of its own screens: resource is one of
'invoice' | 'quote' | 'customer' | 'supplier' | 'purchase' |
'sales-return' | 'purchase-return' | 'item-list' | 'dashboard'.
app.navigate.app(path)
Asks the host to change your app's iframe location to path — a same-app
path starting with a single / (at most 2048 characters, no //, \ or
://). A path with a . or .. segment — literal or percent-encoded
(%2e, %2E%2e, …) — is rejected with INVALID_PAYLOAD rather than
normalised. Only available on an app page; blocks and modals get
NOT_PERMITTED.
app.close(result?)
Closes your app's modal (an action extension) and hands result back to the
host. Only available in a modal; pages and blocks get NOT_PERMITTED.
app.resize(height) / app.autoResize()
resize(height) reports your content height in pixels (clamped 60–1600 by
both the SDK and the host). Blocks and modals only — a full app page is
sized by the host (NOT_PERMITTED).
autoResize() keeps the frame matched to your content for you — call it once
after your content mounts:
const stopAutoResize = app.autoResize();
// later, if you tear the app down without a full page unload:
stopAutoResize();- It measures your content, not the frame. The height it reports is that
of your page's
<body>content — an auto-height<body>'s own height plus its top and bottom margins — and it observes<body>with a throttledResizeObserver: it reports the initial size straight away, then callsresize()whenever the height changes (at most every 150 ms). So the frame shrinks as well as grows. - Leave
<body>at its natural height. Don't give<body>a fixed or100%height (or amin-heightof100vh):autoResize()would then measure that height instead of your content, and the frame would stop following it. - Its resize requests are fire-and-forget. A host that refuses one (on a
full app page, which the host sizes itself) or doesn't answer never
surfaces as an unhandled rejection. Call
resize()yourself when you need to know the outcome. - If
ResizeObserverisn't available in the embed context,autoResize()becomes a no-op (and its return value a no-op cleanup) rather than throwing. A document with no<body>is measured and observed through<html>instead.
app.confirm({ title, message, confirmLabel?, destructive? })
Host-rendered confirmation dialog. Resolves true when the merchant
confirms and false when they cancel or dismiss it — cancelling never
rejects. title ≤ 80 chars, message ≤ 500 chars, confirmLabel ≤ 30
chars. The dialog names your app as the one asking.
One dialog at a time per frame: while a confirm() or pick() dialog is
open, a second call rejects with RATE_LIMITED (it never replaces the dialog
the merchant is reading) — await the first before opening another.
app.pick({ resource, multiple? })
Host-rendered resource picker ('customer' | 'supplier' | 'item' |
'invoice'). Resolves an array of { id, label } — an empty array when
the merchant cancels (the host's CANCELLED answer is turned into [],
so no try/catch is needed for an ordinary cancel). Only works if your
installation holds the resource's read scope; otherwise it rejects with
NOT_PERMITTED. Like confirm(), only one dialog per frame at a time — a
second call while one is open rejects with RATE_LIMITED.
const [customer] = await app.pick({ resource: 'customer' });
if (!customer) return; // cancelledapp.loading(loading)
loading(true) shows a thin progress bar across the top of your app's area;
loading(false) removes it. It only appears once your app has called
ready().
app.setTitle(title)
Adds a secondary heading (≤ 80 chars) next to your app's registered name
in the host chrome — e.g. Parcel Labels · Shipment 1042. It never replaces
the registered name: who the content is from is always shown as registered.
Blocks have no chrome, so there it has no visible effect.
What merchants see around your app
The host always attributes your content, whatever your page renders: an app
page or modal sits under a bar showing your registered app name (plus any
setTitle() heading), your developer name, and "This content is provided by
{app}, not OneBooks." A block shows your extension's label with
"Provided by {app} ({developer}), not OneBooks" beneath it — the registered
app and developer names, never the label you chose.
app.on(event, callback)
Subscribes to host-pushed events: 'theme.changed' ({ theme }) and
'locale.changed' ({ locale, dir }). Returns an unsubscribe function.
Events are sent only after your app has called ready().
A OneBooks language change does not reload your frame. The embed URL's
locale parameter (and context().locale) is only the starting language;
when the merchant switches language, the host sends locale.changed and your
page must re-render itself:
const off = app.on('locale.changed', ({ locale, dir }) => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
render(locale);
});
app.on('theme.changed', ({ theme }) => applyTheme(theme));
// later
off();app.fetch(input, init?)
A fetch() wrapper for calling your own backend. If the request target
is same-origin with the embedded page, it automatically attaches
Authorization: Bearer <session token> (fetching/caching the token as
needed). If the target is a different origin, the request is passed
through unmodified — the session token is never attached to, or leaked
toward, a third-party origin.
// same-origin — gets the Authorization header
await app.fetch('/api/invoices/recent');
// cross-origin — sent as-is, no token attached
await app.fetch('https://cdn.example.com/logo.png');Errors
Every rejected promise from this SDK carries an AppBridgeError:
interface AppBridgeError extends Error {
code: string; // see below
message: string;
}| Code | Meaning |
|---|---|
| UNKNOWN_ACTION | Sent by the host — your SDK/host versions are out of sync. |
| INVALID_PAYLOAD | The host rejected your request's payload shape. |
| NOT_PERMITTED | Your installation lacks the scope/permission for this action. |
| CANCELLED | The user dismissed a dialog/picker. confirm() and pick() never reject with it — they resolve false and [] instead; it only reaches you from actions with no natural "no" result. |
| RATE_LIMITED | Too many toasts (more than 5 per 10 s), or a confirm()/pick() while another dialog from your frame is still open. |
| INTERNAL | Unexpected host-side error. |
| MISSING_HOST | Raised locally by createApp() — no host available. |
| INVALID_HOST | Raised locally by createApp() — host isn't https or localhost. |
| TIMEOUT | Raised locally — the host didn't respond within timeoutMs. |
Script tag usage
npm run build also produces dist/app-bridge.global.js, a self-contained
IIFE bundle for apps that don't use a bundler:
<script src="/static/app-bridge.global.js"></script>
<script>
const app = OneBooksAppBridge.createApp();
app.ready();
</script>The bundle exposes window.OneBooksAppBridge = { createApp, AppBridgeError }.
The SDK's version (sent to the host in ready) is exported as
SDK_VERSION from the ES module build.
(The developer-sdk/templates/embedded-node example app does exactly this —
it copies dist/app-bridge.global.js into its own static/ folder at
build time.)
Security model
- Every response is accepted only if both
event.origin === hostandevent.source === window.parent— origin alone is spoofable by anything else the page loads into another frame/window. app.fetch()only attaches the session token to same-origin requests.- The session token itself is short-lived (~60s server-side) and refreshed
transparently; this SDK never persists it (no
localStorage, no cookies).
Development
npm install
npm run build # tsc -> dist/, then esbuild -> dist/app-bridge.global.js
npm test # node:test via tsx, using a fake window/parent (no browser needed)
npm run typecheckLicense
MIT
