@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
Markdown
# /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 /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 '/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 '/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 '/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 '/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`.