Skip to main content
App Bridge lets your app’s page talk to the Tassyir dashboard. The page runs inside the dashboard’s frame. With this package it can get ID tokens, show toasts and modals, use the save bar and title bar, navigate, pick products, collections or customers, and follow the dashboard’s language, direction and theme. It runs in the browser only. It is ESM only, has no runtime dependency, and works with any framework or none. It collects no analytics and uses no browser storage.

Install

Example

The whole loop: the page asks the bridge for an ID token, sends it to your server, and your server verifies it with @tassyir/app before it calls the Admin API. The page (your manifest’s admin.page):
Your server (here a Fetch handler; the @tassyir/app README has the Express version with a token store):
To know who is looking without calling the API, verify only: const who = await tassyir.verifyIdToken(idToken) gives userId, businessId, installationId, sessionId and locale.

Who is looking

Only the ID token says who is looking. The dashboard loads your page at admin.page, plus the app path and the dashboard’s own query string. Tassyir vouches for nothing in that address. Never read a business, a user or a store from it to decide what to show or allow. context is for display only. To learn who is looking, send idToken()’s token to your server and verify it there with @tassyir/app (verifyIdToken, or authenticate to also get an Admin API token). A token that has not been verified proves nothing. The dashboard never sends your page a Tassyir credential. The ID token is the only one, it is minted for your app and this installation, and it expires after 60 seconds.

Calls

Every call posts one message to the dashboard. It resolves with the payload of the dashboard’s reply, or rejects with an AppBridgeError. Payloads are the ones the Tassyir App Bridge protocol defines. The SDK checks them before posting, so most mistakes are refused without a round trip.
  • idToken mints a new token on every call; the SDK never caches one. Send each token to your server once.
  • toast.show: tone is info (default), success or error. The dashboard signs the toast with your app’s name and shows message as text: control and bidi characters become spaces, white space is folded, and it is cut to 150 characters. More than 2,000 characters is refused.
  • modal.open shows one of your pages (path, the app-path rules below) in a dashboard modal and resolves when the modal closes. title follows the toast rules and is cut to 80 characters; "" is refused, null means none. size is small, medium (default) or large. The modal’s page may call idToken, toast.show, modal.close, resource.pick and context.get only; anything else is NOT_ALLOWED_HERE.
  • App paths (navigate, modal.open) are relative to admin.page and start with one /: /reviews/2?tab=sent. No scheme or host, no backslash, white space, control or bidi character, fragment, or ., .. or empty segment (percent-encoded ones too). At most 1,024 characters before the ?, 2,048 in all, and a query of at most 1,024 characters once written the way URLSearchParams writes it.
  • navigate({ path }) says your app moved to another of its pages. The frame is not reloaded (show the page yourself, for example with your router); the dashboard’s address follows, as a new history entry or, with replace: true, in place.
  • navigate({ dashboard }) leaves your app: home, orders, order (with id), customers, customer (with id), tracking, inventory, products (the current store’s) or apps.
  • saveBar.show shows the dashboard’s save bar, busy while saving is true. Its buttons send saveBar.save and saveBar.discard. It stays until saveBar.hide, and it does not stop the merchant from leaving the page.
  • titleBar.set sets your page’s title beside the app’s name; null or "" clears it.
  • resource.pick opens the dashboard’s picker for product, collection or customer and resolves when the merchant closes it: selection is empty when cancelled, one item unless multiple, 50 at most. Products are { id, name, image }, collections { id, name }, customers { id, provinceCode }, plus name and phone only when the installation holds read_customer_contact. It needs read_products (products, collections) or read_customers (customers) in your manifest.
modal.open and resource.pick wait for the merchant as long as the dialog stays open. Every other call times out after 10 seconds (timeoutMs per bridge or per call), and any call takes an AbortSignal (signal).

Events and context

The context is { locale: 'ar' | 'en', dir: 'rtl' | 'ltr', theme: 'light' | 'dark', storeId: string | null }. storeId is the dashboard’s current store, or null when there is none or your installation is limited to other stores. bridge.context.current holds the latest one (null until the dashboard has sent it), and context.changed listeners hear real changes only.

How the bridge talks to the dashboard

  • Messages go to https://app.tassyir.io only, never *. A message is taken only when it comes from the page’s parent window and from that origin. Replies are matched to calls by id.
  • Ready. createAppBridge() says hello at once (a context.get), which is what makes the dashboard count the page as loaded, and it repeats it with a back-off until the dashboard answers: a message posted before the dashboard listens is lost. Calls made meanwhile wait and are sent in order once it answers.
  • Leaving the page. On pagehide the bridge clears the title and hides the save bar (titleBar.set({ title: null }), saveBar.hide()), so neither outlives the page that set them. It does not when the page is kept in the back/forward cache, because the whole dashboard is kept with it.
  • Limits. The dashboard counts calls per installation over a sliding minute and refuses more with RATE_LIMITED. Past 240 messages a minute it drops them without answering. The bridge counts what this page sends and refuses (RATE_LIMITED, source: 'sdk') instead of posting what the dashboard would refuse or drop.
A page that makes more than 30 requests a minute to your server cannot ask for a token per request: batch the requests, or have your server open its own short session once it has verified a token.

Errors

Every refusal is an AppBridgeError with:
  • code: stable; act on it, never on message;
  • call: the call that failed;
  • source: dashboard (the dashboard’s reply) or sdk (refused or failed before an answer);
  • path: the payload field at fault, for the SDK’s own INVALID_PAYLOAD;
  • message for people, and fix, one sentence saying what to do.
isAppBridgeError(error, code?) narrows an unknown error.

Options

Leave dashboardOrigin unset in production. Set it only to run your page inside a dashboard on your machine or on staging, and never from the page’s address. It must be an exact origin (https, or http on localhost); * is refused. bridge.destroy() stops listening and rejects waiting calls with DESTROYED. bridge.embedded is false when the page is not in a frame (every call then rejects NOT_EMBEDDED), and bridge.connected turns true once the dashboard has answered.

Size

About 7 kB gzipped once minified (17.7 kB minified, 32 kB as shipped). Half of it is the error messages and fix sentences.

Not here yet

React hooks (a provider, useAppBridge, useBridgeContext) come in a later release. bridge.context.current and bridge.on('context.changed', …) are built for useSyncExternalStore meanwhile.

Checking against the dashboard

src/frontend-crosscheck.test.ts runs the dashboard’s own bridge host code (lib/apps/bridge-protocol.ts in the private tassyir/frontend repository) against what this package sends: the constants, about 24,000 payloads, and every call end to end. CI cannot reach that repository, so it runs locally:
It reads origin/main (or TASSYIR_FRONTEND_REF) with git show; run git -C ../frontend fetch origin first.