@lucrii/app-bridge
v0.1.7
Published
The client-side SDK for Lucrii app UIs. Apps run inside a sandboxed iframe in the Lucrii host; this package owns the `postMessage` RPC, KV access, OAuth-flow triggers, host-API calls, toast/confirm/popup helpers, theme sync, and first-party analytics.
Downloads
1,000
Readme
@lucrii/app-bridge
The client-side SDK for Lucrii app UIs. Apps run inside a sandboxed iframe in
the Lucrii host; this package owns the postMessage RPC, KV access, OAuth-flow
triggers, host-API calls, toast/confirm/popup helpers, theme sync, and
first-party analytics.
Install
npm install @lucrii/app-bridgePair with @lucrii/app-components for native-feeling
Svelte UI.
Quick start
import { APIClient, createBridge, KVClient, UIHelpers } from '@lucrii/app-bridge';
const bridge = createBridge(); // signals readiness to the host
const kv = new KVClient(bridge);
const api = new APIClient(bridge);
const ui = new UIHelpers(bridge);
const init = await bridge.waitForInit();
// init.installationId — the per-org installation ID
// init.theme — "light" | "dark"
// init.orgCustomisation — drives terminology helpers
const config = await kv.get<{ apiKey: string }>('config');
ui.toast('App loaded', 'success');createBridge() throws if the page isn't running inside an iframe. Pass it
through to every helper class — they all share the same bridge instance.
Svelte usage
Most apps use Svelte. Co-locate the bridge in a single module so handlers can re-use it:
// bridge.ts
import { createBridge } from '@lucrii/app-bridge';
export const bridge = createBridge();<script lang="ts">
import { Button, Card, CardContent, Input } from '@lucrii/app-components';
import { KVClient, UIHelpers } from '@lucrii/app-bridge';
import { bridge } from '../bridge';
const kv = new KVClient(bridge);
const ui = new UIHelpers(bridge);
let apiKey = $state('');
let saving = $state(false);
async function save() {
saving = true;
try {
await kv.setSecret('api_key', apiKey);
ui.toast('Saved', 'success');
} catch {
ui.toast('Save failed', 'error');
} finally {
saving = false;
}
}
</script>
<Card>
<CardContent>
<Input type="password" bind:value={apiKey} placeholder="sk_..." />
<Button onclick={save} disabled={saving || !apiKey}>Save</Button>
</CardContent>
</Card>API
createBridge(): LucriiBridge
Instantiates a bridge and posts lucrii:ready to the host. Throws outside an
iframe context.
LucriiBridge
Low-level instance returned by createBridge. You'll mostly only call
waitForInit and onThemeChange directly — everything else is wrapped by the
helper classes.
| Method | Returns |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| waitForInit() | Promise<{ installationId: number; theme?: "light" \| "dark"; orgCustomisation?: OrganisationCustomisation }> — resolves on lucrii:init |
| onThemeChange(cb) | Registers cb(theme) for runtime theme switches |
| destroy() | Removes listeners + rejects pending requests |
The bridge also automatically toggles document.documentElement.classList.dark
on theme changes, so Tailwind's dark: variants work out of the box when paired
with @lucrii/app-components/tokens.css.
KVClient
Per-installation KV, proxied through the host (same backing store as the
connector's ctx.kv).
| Method | Returns |
| ----------------------------- | -------------------------------------------------------- |
| get<T>(key) | Promise<{ key, value: T, version, is_secret } \| null> |
| set(key, value, ttl?) | Promise<void> — TTL in seconds |
| setSecret(key, value, ttl?) | Promise<void> — encrypted at rest |
| delete(key) | Promise<void> |
APIClient
Authenticated calls into the Lucrii host API on behalf of the current installation.
| Method | Returns |
| -------------------------- | -------------------------------------------- |
| fetch<T>(path, options?) | Promise<T> — options: { method?, body? } |
UIHelpers
Triggers host-rendered UI. Toast is fire-and-forget; the rest await the user's response.
| Method | Returns |
| ------------------------------ | ------------------------------------------------------------------------------------------------------ |
| toast(message, variant?) | void — variant: "success" \| "error" \| "info" |
| confirm(title, message) | Promise<boolean> |
| openPopup(url, options?) | Promise<void> — options: { width?, height? } |
| openExternal(url) | void — opens url in a new tab via the parent frame; throws on non-http(s) schemes |
| startOAuth(providerKey) | Promise<void> — opens the platform-managed OAuth flow for a manifest-declared provider |
| disconnectOAuth(providerKey) | Promise<void> — clears the per-installation token data and fires the connector's onDisconnect hook |
openExternal exists because some sites send
Cross-Origin-Opener-Policy: same-origin, which ERR_BLOCKED_BY_RESPONSEs a
plain target="_blank" popup originating from inside an iframe. Routing the
open through the parent frame inherits its top-level COOP context, so the new
tab opens normally. Both sides validate the URL scheme — only http: and
https: are allowed; javascript:, data:, etc. are rejected.
AnalyticsHelpers
First-party analytics. Constructor takes the installation ID (from
bridge.waitForInit()). Same gating as the connector-side ctx.analytics.track
— third-party apps log a one-shot console.warn and the event is dropped
silently.
const analytics = new AnalyticsHelpers(api, init.installationId);
await analytics.track('settings_saved', { provider: 'stripe' });| Method | Returns |
| ---------------------------------- | --------------------------------------------------- |
| track(eventName, properties?) | Promise<void> |
| resetAnalyticsWarn() (top-level) | void — test helper that re-arms the one-shot warn |
Terminology helper
Lucrii orgs configure whether they sell goods, services, or both. The host sends
the choice through bridge.waitForInit().orgCustomisation, and
getSalesOrderTerminology returns the right wording for headings/labels.
import { getSalesOrderTerminology } from '@lucrii/app-bridge';
const terms = getSalesOrderTerminology(init.orgCustomisation);
// services_only → { singular: "Invoice", plural: "Invoices" }
// otherwise → { singular: "Sales Order", plural: "Sales Orders" }OAuth
The bridge never sees client secrets — token exchange happens in the platform
using the secret set via lucrii secrets set. The UI just triggers the flow:
try {
await ui.startOAuth('stripe');
ui.toast('Connected', 'success');
} catch {
ui.toast('Connection cancelled', 'error');
}The host opens a popup, the provider redirects back, the platform exchanges the
code, and the token data lands in the installation's KV (canonically at
oauth:{provider}:token_data — see oauthKey in @lucrii/connector-sdk). Your
connector can then read it server-side via
getOAuthTokenData(ctx.kv, "stripe"). The exact stored shape is
provider-defined.
disconnectOAuth(providerKey) is the inverse: clears the token data, fires the
connector's onDisconnect hook (which should revoke provider-side resources —
webhooks, etc.).
Scripts
npm run check # tsc --noEmit
npm test # vitest
npm run lint # prettier --check + eslint
npm run format # prettier --writeLicense
See the repository root.
