UNPKG

@persona/layout-editor-remote-module

Version:

Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.

221 lines (177 loc) • 9.53 kB
# @persona/layout-editor-remote-module Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — used by Persona dashboard apps to declare a layout module directly in a typical app's `vite.config.ts`, without editing the host. ## Install ```sh yarn add @persona/layout-editor-remote-module ``` `vite` and `zod` are peer dependencies. The optional `react` and `react-dom` peer dependencies are required only when using the React bridge components. The package is published to the public npm registry. ## Usage ```ts // vite.config.ts import { defineLayoutModule, personaLayoutModules, } from '@persona/layout-editor-remote-module/vite-plugin'; export default defineConfig({ plugins: [ react(), personaLayoutModules({ modules: [ defineLayoutModule({ key: 'risk_summary', displayName: 'Risk Summary', descriptionShort: 'Show the account risk score', allowAddingToLayout: true, layouts: ['case'], defaultSize: { case: { width: 4, height: 20 } }, // The host loads this HTML page in an iframe. render: { entry: './src/layout-modules/risk-summary/index.html' }, }), ], }), ], }); ``` An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists specific layouts, `defaultSize` must provide an entry for each one. `defaultConfig` optionally overrides top-level `configSchema` defaults for new placements on a specific layout. It is a shallow merge and never rewrites saved config. Each set key must validate against `configSchema`; when `layouts` is non-empty, every `defaultConfig` layout must be listed there. Hosts that predate this field ignore it, so keep a sensible `configSchema` default: ```ts import { z } from 'zod'; defineLayoutModule({ layouts: ['case', 'account'], configSchema: z.object({ ownerId: z.string().default('current_case.id') }), defaultConfig: { account: { ownerId: 'current_account.id' } }, }); ``` Entries must be `.html` documents, which the host loads in an iframe. Non-HTML entries fail during Vite config resolution. See `LayoutModuleEntry`'s doc comment in `src/define.ts` for the full contract, including the optional `path` field for a client-routed SPA. ## Communicating with the host Document-delivered modules can use the browser bridge to receive layout data, report the height their document needs (including `body` and ancestor margins and padding), and resolve data paths through the host. The host renders the document with `RemoteModuleFrame`: ```tsx import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-host'; <RemoteModuleFrame documentUrl={registration.document} moduleOrigin={registration.origin} title='Risk summary' sandbox='allow-scripts allow-same-origin' data={moduleData} context={moduleContext} dynamicHeight loadingFallback={<div>Loading module…</div>} resolveDataPath={resolveDataPath} />; ``` The required `sandbox` value is chosen by the host. The optional `allow` prop sets the iframe's Permissions-Policy `allow` attribute when the host wants to delegate a capability to the module document, e.g. `allow='clipboard-write'` so the module can call `navigator.clipboard.writeText`. `loadingFallback` is drawn over the iframe until the module has rendered. `LayoutModuleBridge` advertises the `loaded` message automatically after its first render and waits for the first height measurement before sending it when height observation is enabled. A module that needs a loading state should render it on its first render; `LayoutModuleBridge`'s `fallback` is hidden under the host overlay until then and remains available for render errors. - `createModuleBridge` and `useRemoteModuleBridge` keep legacy behavior by default; direct bridge users opt in with `signalsLoaded: true` and call `bridge.signalLoaded()` after rendering. - Frames without the `loaded` capability are treated as legacy: `ready`, another bridge message, or a silent-frame timeout after iframe load dismisses the overlay. By default, the bridge accepts host origins matching `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`, and `127.0.0.1`. Add development or preview origins with `allowedHostOrigins`, optionally composing the exported `personaDashboardHostOrigins` helper: ```tsx import { LayoutModuleBridge, personaDashboardHostOrigins, } from '@persona/layout-editor-remote-module/bridge-frame'; <LayoutModuleBridge allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)} > {({ data }) => <RiskSummary data={data} />} </LayoutModuleBridge>; ``` The document entry uses `LayoutModuleBridge` as a render-prop component: ```tsx import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame'; <LayoutModuleBridge> {({ data, context, resolveDataPath }) => ( <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} /> )} </LayoutModuleBridge>; ``` Alongside `data` (the module's persisted config), the host sends a typed `context` payload (`RemoteModuleContext`) describing where the module is rendered — facts the host owns and the module cannot derive itself: - `layoutType` — which layout kind the module is placed on (`account`, `case`, or `transaction`) - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a `case` layout), or `null` when there is none - `isReadOnly` — the host object is locked for edits - `isInEditor` — the module is rendered inside the layout editor rather than on a live record - `isUsingStubData` — the host is feeding placeholder/stub data instead of a real record - `dynamicHeight` — whether the host sizes the iframe to the height the bridge reports (`RemoteModuleFrame`'s `dynamicHeight`). The reported height covers the whole frame document, including `body` and ancestor margins and padding. A module that fills a fixed-height iframe with viewport units (`100vh`) should size to its content when it's set — combined with any `body` margin or padding the reported height exceeds the iframe, so it grows on every report. `RemoteModuleFrame` sets it from its `dynamicHeight` prop, overriding any value in the `context` prop. `context` is sent with `init` and pushed again whenever it changes, just like `data`; inside the iframe it arrives through `ready()`, `getContext()`, and the `context` render prop (all `RemoteModuleContext | null` until init). The host still appends `access_token`, `environment_id`, and `locale` as query parameters. The app hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL parameters cannot leak through subsequent requests. The bridge's security model is intentionally narrow: - The host sends only to the exact module origin and accepts messages only from that origin and iframe window. - The iframe accepts only messages from its parent window and an allowed host origin, then pins the first trusted origin for the lifetime of the bridge. - The iframe derives the ready target from `ancestorOrigins` or the referrer, and the host retries `init` until the frame answers. - Messages use a versioned protocol that stays compatible with frames built against older releases (from 0.5.3): receivers ignore message types they don't know and strip keys they don't know, and fields are never removed, renamed or narrowed. `context.layoutType` can be a layout type added later, so handle values you don't know. Credentials remain in the iframe URL and are never handled through bridge messages. - `extraParams` rejects reserved names and credential-like keys. ### Consuming a manifest The plugin emits `{ modules: RemoteModuleRegistrationManifestV1[] }` at `manifestPath`. A host fetching that document should run it through `parseRemoteModuleManifest` — it validates the envelope, every module, and key uniqueness, and returns the typed document: ```ts import { parseRemoteModuleManifest, RemoteModuleManifestParseError, } from '@persona/layout-editor-remote-module'; const response = await fetch(`${appOrigin}/_layout_modules_manifest.json`); const { modules } = parseRemoteModuleManifest(await response.json()); ``` It throws `RemoteModuleManifestParseError` (with `.issues` and a prettified `.message`) on a malformed document; `safeParseRemoteModuleManifest` returns `{ success, data | error }` instead of throwing. ### Rendering a config panel A host stores a placed module's config under a `config` property, so its RJSF form edits `{ config }`. The `./config-form` entry adapts a manifest to that shape, with no React or RJSF dependency: ```ts import { nestRemoteConfigSchema, nestRemoteUiConfigSchema, remoteConfigDefaults, } from '@persona/layout-editor-remote-module/config-form'; const schema = nestRemoteConfigSchema(module.configSchema); // `$ref`s rewritten to match const uiSchema = nestRemoteUiConfigSchema(module.uiConfigSchema); const initialConfig = remoteConfigDefaults(module.configSchema, module.defaultConfig?.[layoutType]); // top-level `default`s plus per-layout overrides ``` Render the form with `schema`, `uiSchema` and `formData: { config }`, and read the edited config back from `formData.config`.