@persona/layout-editor-remote-module 0.0.0-bootstrap.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,371 @@
1
+ ---
2
+ name: getting-started
3
+ description: >
4
+ Wire up @persona/layout-editor-remote-module in a sa-* subapp or app-builder app: register
5
+ personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using a document
6
+ (.html) render delivery, add a zod configSchema and an RJSF uiConfigSchema, and talk to the host
7
+ from the iframed document with LayoutModuleBridge from the bridge-frame entry (layout data, host
8
+ context such as layoutType / layoutObjectId / isReadOnly, resolveDataPath, and height). Activate
9
+ when adding a new layout module to an app, integrating this package for the first time, wiring a
10
+ module document to the dashboard host, or debugging a manifest that fails to build or validate
11
+ (duplicate key, missing defaultSize entry, render path not found) or a module that never receives
12
+ data from the host.
13
+ metadata:
14
+ type: core
15
+ library: '@persona/layout-editor-remote-module'
16
+ library_version: '0.4.0'
17
+ sources:
18
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/vite-plugin.ts'
19
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/define.ts'
20
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/schema.ts'
21
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/manifest-path.ts'
22
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/index.ts'
23
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/LayoutModuleBridge.tsx'
24
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/iframe/createModuleBridge.ts'
25
+ - 'persona-id/persona-layout-editor:packages/remote-module/src/bridge/protocol.ts'
26
+ - 'persona-id/persona-layout-editor:packages/remote-module/README.md'
27
+ ---
28
+
29
+ # Getting started with @persona/layout-editor-remote-module
30
+
31
+ Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets a `sa-*`
32
+ subapp or app-builder App declare a layout module directly in its own `vite.config.ts`, without
33
+ editing persona-web. The plugin builds each module and emits a
34
+ `{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document persona-web fetches and intakes.
35
+
36
+ This package is publicly available from the npm registry.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ yarn add @persona/layout-editor-remote-module
42
+ ```
43
+
44
+ `vite`, `esbuild`, and `zod` are peer dependencies; `react` / `react-dom` (`^17 || ^18 || ^19`) are
45
+ optional peers needed only by the `bridge-frame` entry. Add `esbuild` to your own `devDependencies`
46
+ if your package manager warns about an unmet peer — the plugin's dev-mode bundling calls esbuild
47
+ directly and should resolve to the same copy Vite already pulls in.
48
+
49
+ ## Setup (vite.config.ts)
50
+
51
+ ```ts
52
+ import { defineConfig } from 'vite';
53
+ import {
54
+ defineLayoutModule,
55
+ personaLayoutModules,
56
+ } from '@persona/layout-editor-remote-module/vite-plugin';
57
+
58
+ export default defineConfig({
59
+ plugins: [
60
+ personaLayoutModules({
61
+ modules: [
62
+ defineLayoutModule({
63
+ key: 'investigation_console',
64
+ displayName: 'Investigation Console',
65
+ descriptionShort: 'Full investigation workspace',
66
+ allowAddingToLayout: true,
67
+ layouts: ['case'],
68
+ defaultSize: { case: { width: 12, height: 40 } },
69
+ render: { entry: './index.html' },
70
+ }),
71
+ ],
72
+ }),
73
+ ],
74
+ });
75
+ ```
76
+
77
+ An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
78
+ specific layouts, `defaultSize` must provide an entry for each one.
79
+
80
+ Optional fields: `allowDynamicHeight` (expose the dynamic-height option in the module's
81
+ configuration), `badge` (`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
82
+ (`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
83
+ `build_your_own`), `icon` (an image path relative to the module's origin, such as
84
+ `/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is
85
+ rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to
86
+ narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below).
87
+
88
+ `entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) —
89
+ not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config.
90
+
91
+ ## Core Patterns
92
+
93
+ ### Document delivery — iframed page
94
+
95
+ A module's `render.entry` must end in `.html`: persona-web iframes it rather than importing
96
+ anything. Any other extension (`.ts`/`.tsx`/`.js`) builds as a `code` delivery, which the plugin and
97
+ manifest still accept but persona-web does not render yet — the module shows up in the library and
98
+ renders nothing. Use `path` for a client-routed SPA whose `index.html` already renders the whole app
99
+ — it names the logical route the SPA's own router should land on, not the file's build output path:
100
+
101
+ ```ts
102
+ defineLayoutModule({
103
+ key: 'investigation_console',
104
+ displayName: 'Investigation Console',
105
+ descriptionShort: 'Full investigation workspace',
106
+ allowAddingToLayout: true,
107
+ layouts: ['case'],
108
+ defaultSize: { case: { width: 12, height: 40 } },
109
+ render: { entry: './index.html', path: '/embed/investigations/console' },
110
+ });
111
+ ```
112
+
113
+ If the `.html` file is already one of the app's own build entries (its main `index.html`), the
114
+ plugin detects that and doesn't inject it again.
115
+
116
+ ### `configSchema` — validated module configuration
117
+
118
+ Author config as zod; the plugin converts it to JSON Schema (`z.toJSONSchema()`) for persona-web's
119
+ config panel:
120
+
121
+ ```ts
122
+ import { z } from 'zod';
123
+
124
+ defineLayoutModule({
125
+ // ...
126
+ configSchema: z.object({
127
+ threshold: z.number().min(0).max(1).default(0.7),
128
+ }),
129
+ });
130
+ ```
131
+
132
+ Keep `configSchema` to a plain data shape (objects, primitives, enums, arrays) — it crosses the wire
133
+ as static JSON Schema, not re-executed as zod on the consuming side.
134
+
135
+ ### `uiConfigSchema` — config panel layout
136
+
137
+ persona-web renders the config panel with RJSF. Pass an RJSF `uiSchema` (widget choice, field order,
138
+ labels) as `uiConfigSchema`; it is already plain data, so the plugin copies it into the manifest
139
+ unchanged:
140
+
141
+ ```ts
142
+ defineLayoutModule({
143
+ // ...
144
+ configSchema: z.object({
145
+ threshold: z.number().min(0).max(1).default(0.7),
146
+ title: z.string().optional(),
147
+ }),
148
+ uiConfigSchema: {
149
+ 'ui:order': ['title', 'threshold'],
150
+ threshold: { 'ui:widget': 'range' },
151
+ },
152
+ });
153
+ ```
154
+
155
+ ### `bridge-frame` — talking to the host from the iframed document
156
+
157
+ The host (persona-web / layout editor) renders a document-delivered module in an iframe and speaks
158
+ to it over `postMessage`. Wrap the module's React root in `LayoutModuleBridge` from the
159
+ `bridge-frame` entry; it owns the whole handshake and hands the module a render prop:
160
+
161
+ ```tsx
162
+ import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';
163
+
164
+ export function App() {
165
+ return (
166
+ <LayoutModuleBridge fallback={<Spinner />}>
167
+ {({ data, context, resolveDataPath }) => (
168
+ <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
169
+ )}
170
+ </LayoutModuleBridge>
171
+ );
172
+ }
173
+ ```
174
+
175
+ - `children` is only invoked once the host has sent the initial layout data — it never sees
176
+ `data: undefined`. Until then `fallback` (default `null`) is rendered.
177
+ - `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an
178
+ update.
179
+ - `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
180
+ cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see
181
+ below).
182
+ - `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host (persona-web) owns the
183
+ implementation. It rejects if the host reports an error or does not answer within
184
+ `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
185
+ directly in a tab still renders.
186
+ - Height is reported automatically: the bridge wraps `children` in a `div` and observes it with a
187
+ `ResizeObserver` so the host can size the iframe (`observeHeight={false}` to opt out).
188
+ - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown.
189
+ - Mount exactly one `LayoutModuleBridge` per document.
190
+
191
+ Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
192
+ `resolveTimeoutMs?: number`, `allowedHostOrigins?` (only needed to embed from a host origin outside
193
+ the built-in Persona allow-list — `*.withpersona.com`, staging, `*.persona-web.pages.dev`, Coder,
194
+ `localhost`).
195
+
196
+ #### Host context
197
+
198
+ `RemoteModuleContext` (exported as a type from `bridge-frame`) has:
199
+
200
+ - `layoutType` — `'account' | 'case' | 'transaction'`, the layout the module is placed on.
201
+ - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
202
+ `case` layout), or `null` when there is none.
203
+ - `isReadOnly` — the host object is locked for edits; hide or disable mutating actions.
204
+ - `isInEditor` — rendered inside the layout editor rather than on a live record.
205
+ - `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network
206
+ calls or side effects keyed off the record.
207
+
208
+ `context` is `null` only when the document is not embedded (opened directly in a tab).
209
+
210
+ ```tsx
211
+ import {
212
+ LayoutModuleBridge,
213
+ type RemoteModuleContext,
214
+ } from '@persona/layout-editor-remote-module/bridge-frame';
215
+
216
+ function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
217
+ if (!context?.layoutObjectId) {
218
+ return <EmptyState />;
219
+ }
220
+ return <RiskScore caseId={context.layoutObjectId} readOnly={context.isReadOnly} />;
221
+ }
222
+ ```
223
+
224
+ #### Access token and environment id
225
+
226
+ The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The
227
+ builder app or `sa-*` subapp hosting the module consumes and strips those parameters itself, so by
228
+ the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor
229
+ strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use
230
+ `strict-origin`) so the URL parameters cannot leak through subsequent requests.
231
+
232
+ ## Common Mistakes
233
+
234
+ ### [HIGH] Reading layout data outside `LayoutModuleBridge`
235
+
236
+ Wrong:
237
+
238
+ ```tsx
239
+ const data = await requestDataSomehow(); // no such API
240
+ const resolveDataPath = window.resolveDataPath; // no such API
241
+ ```
242
+
243
+ Correct:
244
+
245
+ ```tsx
246
+ function Module({
247
+ data,
248
+ resolveDataPath,
249
+ }: {
250
+ data: Json;
251
+ resolveDataPath: (
252
+ dataPath: string,
253
+ variableMap?: Record<string, string | number>,
254
+ ) => Promise<Json>;
255
+ }) {
256
+ // ...
257
+ }
258
+
259
+ <LayoutModuleBridge>
260
+ {({ data, resolveDataPath }) => <Module data={data} resolveDataPath={resolveDataPath} />}
261
+ </LayoutModuleBridge>;
262
+ ```
263
+
264
+ `data` and `resolveDataPath` only exist inside the render prop.
265
+
266
+ Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
267
+
268
+ ### [MEDIUM] Module renders forever in `fallback` when embedded
269
+
270
+ `children` never running means the handshake didn't complete. Check, in order: the document is
271
+ really cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects
272
+ unknown parents — pass your own only for a non-Persona dev host); the module is a `.html` document
273
+ delivery, not a script entry; the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
274
+ never send `init`). Opening the document directly in a tab is _not_ embedded — `data` and `context`
275
+ resolve to `null` and `children` renders, so a stuck fallback is always a host/origin problem.
276
+
277
+ Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`.
278
+
279
+ ### [HIGH] `key` uses the wrong charset or collides with another module
280
+
281
+ Wrong:
282
+
283
+ ```ts
284
+ defineLayoutModule({ key: 'risk-summary' /* ... */ }); // hyphens
285
+ defineLayoutModule({ key: 'Risk_Summary' /* ... */ }); // uppercase
286
+ ```
287
+
288
+ Correct:
289
+
290
+ ```ts
291
+ defineLayoutModule({ key: 'risk_summary' /* ... */ });
292
+ ```
293
+
294
+ `key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a
295
+ persisted module identity string and DOM ids. A bad charset fails zod validation at build time
296
+ (`closeBundle`); a duplicate throws
297
+ `duplicate module key "..." — keys must be unique within an app`. Never change a released module's
298
+ `key` — it's written into saved layouts.
299
+
300
+ Source: `src/schema.ts` (`moduleKeySchema`), `src/vite-plugin.ts` (`assertUniqueKeys`).
301
+
302
+ ### [HIGH] `defaultSize` missing an entry for a listed layout
303
+
304
+ Wrong:
305
+
306
+ ```ts
307
+ defineLayoutModule({
308
+ layouts: ['case', 'account'],
309
+ defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry
310
+ });
311
+ ```
312
+
313
+ Correct:
314
+
315
+ ```ts
316
+ defineLayoutModule({
317
+ layouts: ['case', 'account'],
318
+ defaultSize: {
319
+ case: { width: 4, height: 20 },
320
+ account: { width: 6, height: 24 },
321
+ },
322
+ });
323
+ ```
324
+
325
+ The manifest schema requires an entry in `defaultSize` for every layout named in `layouts`; a
326
+ missing one fails validation in `closeBundle` with
327
+ `defaultSize must have an entry for every layout in \`layouts\``. This error surfaces at build time,
328
+ not at the defineLayoutModule call site.
329
+
330
+ This requirement applies to each listed layout; an empty `layouts` imposes no `defaultSize`
331
+ requirement, and the host falls back to its own default size.
332
+
333
+ Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`).
334
+
335
+ ### [MEDIUM] `render.entry` doesn't resolve to a real file
336
+
337
+ `personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry
338
+ path doesn't exist on disk: `render "..." does not resolve to a file (...)`. This is nearly always a
339
+ relative-path mistake: `entry` resolves against the Vite **root**, not against `vite.config.ts`'s
340
+ directory, so a non-default `root` in the same config changes what a given relative `entry` points
341
+ at.
342
+
343
+ Source: `src/vite-plugin.ts` (`checkDeliveryExists`).
344
+
345
+ ### [MEDIUM] `manifestPath` set to a directory instead of a file
346
+
347
+ Wrong:
348
+
349
+ ```ts
350
+ personaLayoutModules({ manifestPath: '/manifests/', modules: [] });
351
+ ```
352
+
353
+ Correct:
354
+
355
+ ```ts
356
+ personaLayoutModules({ manifestPath: '/manifests/my-app.json', modules: [] });
357
+ ```
358
+
359
+ `manifestPath` must include the full JSON file name — it defaults to `DEFAULT_MANIFEST_PATH`
360
+ (`/_layout_modules_manifest.json`). A leading `/` is optional and stripped either way.
361
+
362
+ Code outside `vite.config.ts` that needs the default path (e.g. a subapp server adding CORS on the
363
+ manifest route) should import the constant from the runtime-safe `.` entry rather than hardcoding it
364
+ — this avoids pulling in vite/esbuild:
365
+
366
+ ```ts
367
+ import { DEFAULT_MANIFEST_PATH } from '@persona/layout-editor-remote-module';
368
+ ```
369
+
370
+ Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment),
371
+ `src/manifest-path.ts`.