uidudevelopers
Tools

App bridge

How a custom app talks to the uidu page that frames it — UiduAppProvider and useUiduApp, connect(), trusted origins, the protocol, and fromBridge.

The reference for the moving parts behind a custom app. The path walks through building one; this page is what each piece does.

  • @uidu/app-bridge — the browser half of the handshake: connect() asks the uidu page framing the app for a session over postMessage and keeps its token fresh.
  • @uidu/client — fromBridge(bridge) turns that session into a normal client config.
  • @uidu/react — <UiduAppProvider> and useUiduApp() do both for a React tree.

How the handshake works

uidu page (https://acme.uidu.org/…)                    your app (https://rooms.example.com)
  └─ <iframe src="https://rooms.example.com">
                                      ◀── uidu:ready      connect()
       uidu:context ──▶  { token, expiresAt, graphqlUrl, context }
                                      ◀── uidu:refresh    getToken(), before expiry
       uidu:context ──▶  (new token)

your app ── Authorization: Bearer <token> ──▶ graphqlUrl  (the workspace's own host)

The session lasts about five minutes and the bridge refreshes it on demand. context tells the app who is looking and where: user, space (null when installed on the workspace), workspaceApp (the installed app — the owner of its Models), locale, theme ('light' | 'dark') and accent (the page's colour, to set as --primary).


<UiduAppProvider> and useUiduApp() — @uidu/react

The React way in. <UiduAppProvider> connects on mount and renders its children in every state; once connected it also wraps them in a <UiduProvider> with a session-backed client, so useUiduClient() works too. useUiduApp() returns the state.

// app/layout.tsx renders <AppProvider> around {children}
'use client';
import { UiduAppProvider } from '@uidu/react';
import { DEFAULT_HOST_ORIGINS } from '@uidu/app-bridge';

export function AppProvider({ children }: { children: React.ReactNode }) {
  // https://*.uidu.org by default; add a local uidu or a custom domain
  return (
    <UiduAppProvider hostOrigins={[...DEFAULT_HOST_ORIGINS, 'https://me.uidu.local:8443']}>
      {children}
    </UiduAppProvider>
  );
}
'use client';
import { useEffect, useState } from 'react';
import { useUiduApp } from '@uidu/react';
import { ensureModel, listModelItems, type ModelItem } from '@uidu/client';

export function Bookings() {
  const app = useUiduApp(); // status: 'connecting' | 'ready' | 'error'
  const [items, setItems] = useState<ModelItem[]>([]);

  const client = app.status === 'ready' ? app.client : null;
  const workspaceAppId = app.status === 'ready' ? app.context.workspaceApp.id : null;

  useEffect(() => {
    if (!client || !workspaceAppId) return;
    ensureModel(client, {
      workspaceAppId,
      name: 'Booking',
      fields: [{ shortname: 'room', name: 'Room', kind: 'string' }],
    })
      .then((model) => listModelItems(client, { modelId: model.id }))
      .then(setItems);
  }, [client, workspaceAppId]);

  if (app.status === 'connecting') return <p>Connecting…</p>;
  if (app.status === 'error') return <p>Open this app from uidu ({app.error.code})</p>;

  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{String(item.fieldValuesByShortname?.room ?? '')}</li>
      ))}
    </ul>
  );
}
PropDefaultDescription
hostOriginsDEFAULT_HOST_ORIGINSOrigins allowed to hand the app a session. Replaces the default — spread it when adding your own.
timeout5000Milliseconds to wait for the host, per handshake or refresh.
refreshMargin60000Ask for a new token when the current one has less than this left.

useUiduApp() returns a UiduAppState:

statusFields
'connecting'everything else null
'ready'bridge, client (a UiduClient), context (kept up to date when the host sends a new one), error (the last failed refresh, or null)
'error'error — an AppBridgeError; there is no session

useUiduApp() throws when called outside a <UiduAppProvider>.


connect(options) — @uidu/app-bridge

Outside React — or to manage the bridge yourself — connect directly and build the client with fromBridge.

import { connect, AppBridgeError, DEFAULT_HOST_ORIGINS } from '@uidu/app-bridge';
import { createClient, fromBridge, listGoals } from '@uidu/client';

try {
  const bridge = await connect({ hostOrigins: [...DEFAULT_HOST_ORIGINS] });
  const client = createClient(fromBridge(bridge));

  bridge.context.user?.name;
  bridge.context.workspaceApp.id;
  document.documentElement.classList.toggle('dark', bridge.context.theme === 'dark');

  bridge.on('context', (context) => {/* theme, locale… changed */});
  bridge.on('error', (error) => {/* a refresh was refused or timed out */});

  const goals = await listGoals(client);
} catch (error) {
  if (error instanceof AppBridgeError && error.code === 'NOT_EMBEDDED') {
    // opened directly, not inside uidu — fall back (localStorage, a notice…)
  }
}

connect() resolves once the host has sent the first session, and rejects with an AppBridgeError otherwise:

codeMeaning
NOT_EMBEDDEDNot inside an iframe (or not in a browser).
UNKNOWN_HOSTFramed, but the browser didn't say by whom (no referrer).
UNTRUSTED_HOSTFramed by a page outside hostOrigins.
TIMEOUTThe host didn't answer in time — usually a page without a bridge.
HOST_ERRORThe host refused: not a member, app removed, …
INVALID_MESSAGEThe host sent a session the bridge won't use (e.g. a graphqlUrl outside the host's origin).
DESTROYEDdestroy() was called.

Returns: Promise<AppBridge> — context, graphqlUrl, hostOrigin, version, getToken() (cached; concurrent callers share one refresh), on('context' | 'error', listener) (returns an unsubscribe function) and destroy().

Origins

Whoever frames the app could post it a fake session pointing at a graphqlUrl of its choosing, so the bridge only trusts the origins you list:

  • DEFAULT_HOST_ORIGINS — ['https://*.uidu.org'].
  • matchesOrigin(origin, pattern) — exact origin, or a wildcard where * stands for one or more subdomain labels (never the bare domain, scheme or port).
  • isTrustedOrigin(origin, patterns) — any pattern matches.
  • detectParentOrigin(window) — the framing page's origin, from location.ancestorOrigins or document.referrer.

Mirror the same list in your Content-Security-Policy: frame-ancestors header, so only uidu can frame the app in the first place.

Protocol

PROTOCOL_VERSION is 1. The app posts { type: 'uidu:ready' } on load and { type: 'uidu:refresh' } before the token expires; the host answers uidu:context (token, expiresAt, graphqlUrl, context) or uidu:error (message). parseHostMessage(data) narrows an inbound event.data to one of those, or null — it checks the shape only, not the sender.


fromBridge(bridge) — @uidu/client

Turns a connected bridge into a UiduClientConfig: endpoint is the session's graphqlUrl, and getToken asks the bridge for a token on every request, so a refreshed token is picked up without rebuilding the client. Typed structurally ({ graphqlUrl, getToken }), so @uidu/client doesn't depend on the bridge.

import { createClient, fromBridge } from '@uidu/client';

const client = createClient(fromBridge(bridge));

Returns: UiduClientConfig. If the bridge can't produce a token, requests reject with a UiduError whose code is TOKEN_ERROR.


Reference

On this page