@lucrii/app-bridge
v0.1.9
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.
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, opts?) | Promise<void> - opens the platform-managed OAuth flow for a manifest-declared provider. opts.templateValues supplies the per-installation values a templated provider's endpoint URLs need |
| 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.warnand 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');
}For a provider whose OAuth host is per-installation (declared with
template_vars on the manifest's provider entry, e.g. Shopify's
{shop}.myshopify.com), pass the values the placeholder needs as
templateValues:
try {
await ui.startOAuth('shopify', { templateValues: { shop: 'acme.myshopify.com' } });
ui.toast('Connected', 'success');
} catch {
ui.toast('Connection cancelled', 'error');
}The platform validates each supplied value against the provider's declared
template_vars pattern (and rejects a private or metadata host) before
substituting it into the authorize/token URL, so a hostile value can't
redirect the flow off the provider's own host. Omit templateValues for a
provider with no templated endpoints.
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.
