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 overpostMessageand keeps its token fresh.@uidu/client—fromBridge(bridge)turns that session into a normal client config.@uidu/react—<UiduAppProvider>anduseUiduApp()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>
);
}| Prop | Default | Description |
|---|---|---|
hostOrigins | DEFAULT_HOST_ORIGINS | Origins allowed to hand the app a session. Replaces the default — spread it when adding your own. |
timeout | 5000 | Milliseconds to wait for the host, per handshake or refresh. |
refreshMargin | 60000 | Ask for a new token when the current one has less than this left. |
useUiduApp() returns a UiduAppState:
status | Fields |
|---|---|
'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:
code | Meaning |
|---|---|
NOT_EMBEDDED | Not inside an iframe (or not in a browser). |
UNKNOWN_HOST | Framed, but the browser didn't say by whom (no referrer). |
UNTRUSTED_HOST | Framed by a page outside hostOrigins. |
TIMEOUT | The host didn't answer in time — usually a page without a bridge. |
HOST_ERROR | The host refused: not a member, app removed, … |
INVALID_MESSAGE | The host sent a session the bridge won't use (e.g. a graphqlUrl outside the host's origin). |
DESTROYED | destroy() 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, fromlocation.ancestorOriginsordocument.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
@uidu/app-bridgereference — every export of the bridge.@uidu/reactreference —UiduAppProvider,useUiduAppand types.