@syncropel/extensions
v0.2.0
Published
TypeScript SDK for building iframe-embedded applications for the Syncropel platform. Speaks SAP v0.2 (with graceful v0.1 fallback) — handshake, capability negotiation, request/response correlation, scoped capabilities, and a createAdapter() factory with l
Maintainers
Readme
@syncropel/extensions
Build iframe-embedded applications for the Syncropel platform. This package handles the postMessage protocol between your extension and its host — the handshake, capability negotiation, and request/response correlation are wired up for you.
Typical uses: custom dashboards, data-entry panels, visualization widgets, audio or video tools, canvas-based editors — anything interactive that needs to emit or query records in the host workspace.
import { createAdapter } from "@syncropel/extensions";
const adapter = createAdapter({
ext: { name: "@vendor/my-extension", version: "1.0.0" },
capabilities: ["records.emit", "records.query"],
onInit: ({ actor, namespace, selection, capabilities_granted }) => {
console.log("Connected:", actor, "granted:", capabilities_granted);
adapter.setHeight(document.body.scrollHeight);
},
onSelectionChange: ({ selection }) => {
console.log("Selection changed:", selection.kind);
},
});
// Emit a record:
document.querySelector("#save")!.addEventListener("click", async () => {
const result = await adapter.emit({
act: "PUT",
kind: "vendor.notes.entry",
body: { text: "hello" },
});
if (result.status === "ok") {
adapter.setStatus({ label: "Saved", tone: "success", indicator: "static" });
} else {
adapter.setStatus({ label: result.error?.message ?? "Error", tone: "error" });
}
});Features
createAdapter()factory — handles thehello/readyhandshake, request/response correlation, and message validation. Four lifecycle hooks (onInit,onSelectionChange,onRecords,onGoodbye) and eight operations (emit,query,subscribe,unsubscribe,navigate,setHeight,setStatus,destroy).- Type-safe message envelopes — every host→extension and extension→host message has a strongly-typed interface. Discriminated unions keyed on
kindcatch mistakes at compile time. - Runtime validator —
validateMessage(raw, direction)returns{ok, reason?}for any postMessage payload. Enforces envelope shape, clock-skew window (±5 min), capability allowlist, and per-kind payload requirements. intersectCapabilities()helper — compute the granted set as the intersection of an extension's requested capabilities and the host's per-namespace policy.- Framework-agnostic — vanilla JS, React, Vue, Svelte, or anything else. Only runtime dep is the browser
windowobject. - Zero runtime dependencies — pure TypeScript, ESM, tree-shakable.
Install
npm install @syncropel/extensionsShips compiled JavaScript + TypeScript declarations. Runs in any modern browser (uses window.postMessage, crypto.randomUUID).
The protocol in 60 seconds
- The host embeds your extension as a sandboxed iframe. You do not choose the sandbox flags — the host does (
allow-scripts allow-formsat minimum). - When the iframe loads, the host sends a
hellomessage listing the current selection, actor, namespace, and capabilities it's willing to grant. - Your adapter replies with a
readymessage listing the capabilities you actually want. - The host responds with an
action.resultconfirming the final grant (intersection of requested + policy). - From then on, you may send any of:
records.emit,records.query,records.subscribe,records.unsubscribe,navigate,height,status. You'll receivecontext.update(selection change),records.update(subscription delivery),action.result(reply to your requests), orgoodbye(session end).
Every request you send gets an opaque id; the host's action.result echoes that id so the adapter can correlate. createAdapter() handles this correlation for you.
Capabilities
Extensions request capabilities in ready; the host grants the intersection with its own per-namespace policy.
| Capability | Purpose |
|---|---|
| records.emit | Emit records to the host's record store |
| records.query | One-shot query against the record store |
| records.subscribe | Subscribe to a live record stream |
| navigate | Ask the host to navigate to a path or URL |
| permissions.camera | Camera (gated by host's iframe sandbox) |
| permissions.microphone | Microphone |
| permissions.clipboard | Clipboard read/write |
| permissions.fullscreen | Request fullscreen |
| permissions.notifications | Browser notifications |
| sharedArrayBuffer | High-performance shared memory (host may reject on security grounds) |
| persistent-storage | Persistent origin storage |
Inspect what was actually granted at adapter.capabilities.
API
createAdapter(options)
Options:
| Field | Type | Required | Description |
|---|---|---|---|
| ext.name | string | ✓ | Extension identifier, e.g. "@vendor/tool" |
| ext.version | string | ✓ | Semver string |
| ext.publisher | string | | Publisher DID, optional |
| capabilities | SAPCapability[] | ✓ | Which capabilities to request |
| onInit | fn | | Fires after handshake with granted caps + selection context |
| onSelectionChange | fn | | Fires when host's selection changes |
| onRecords | fn | | Fires when a subscription delivers records |
| onGoodbye | fn | | Fires when host terminates the session |
| window | Window | | Override for test harnesses |
Returns:
| Member | Purpose |
|---|---|
| adapter.capabilities | Readonly granted capability list |
| adapter.version | "0.1" once handshake completes, else null |
| adapter.emit({ act, body, thread?, parents? }) | Emit a record → Promise<ActionResultPayload> |
| adapter.query(vql) | One-shot query → Promise<ActionResultPayload> |
| adapter.subscribe(vql) | Subscribe to a stream → Promise<ActionResultPayload> with subscription_id in result |
| adapter.unsubscribe(subscriptionId) | Cancel a subscription |
| adapter.navigate(to) | Request host navigation |
| adapter.setHeight(px) | Tell host to resize the iframe (0–10000 px clamped) |
| adapter.setStatus({ label, tone, indicator? }) | Show a status strip in host chrome |
| adapter.destroy() | Remove message listener + clear state |
validateMessage(raw, direction)
Returns { ok: true } or { ok: false, reason }. Never throws. Used internally; also exported for hosts or test harnesses.
import { validateMessage } from "@syncropel/extensions";
window.addEventListener("message", (ev) => {
const check = validateMessage(ev.data, "from-host");
if (!check.ok) {
console.warn("Drop:", check.reason);
return;
}
// ... process ev.data
});intersectCapabilities(requested, policy)
Returns the set the extension may actually use. Useful on the host side.
import { intersectCapabilities } from "@syncropel/extensions";
const granted = intersectCapabilities(
["records.emit", "records.query", "sharedArrayBuffer"],
["records.emit", "records.query", "navigate"], // namespace policy
);
// ["records.emit", "records.query"]Related packages
- @syncropel/sdk — emit + query records directly (for server-side adapters, not iframe extensions)
- @syncropel/projections — schema for declarative UI documents (the no-code extension path — ship a JSON document instead of an iframe)
- @syncropel/react — React components + design tokens (use these in your iframe for native-looking chrome)
