# App bridge

URL: https://developers.uidu.org/docs/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](https://developers.uidu.org/docs/paths/custom-app.md). 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](https://developers.uidu.org/docs/domains/models.md)), `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>`](https://developers.uidu.org/docs/reference/react.md) with a
session-backed client, so `useUiduClient()` works too. `useUiduApp()` returns the state.

```tsx
// 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>
  );
}
```

```tsx
'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`.

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

```ts
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-bridge` reference](https://developers.uidu.org/docs/reference/app-bridge.md) — every export of the bridge.
* [`@uidu/react` reference](https://developers.uidu.org/docs/reference/react.md) — `UiduAppProvider`, `useUiduApp` and types.
