@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.
- package/README.md +147 -0
- package/dist/bridge-frame.d.mts +45 -0
- package/dist/bridge-frame.mjs +350 -0
- package/dist/bridge-host.d.mts +61 -0
- package/dist/bridge-host.mjs +217 -0
- package/dist/define-Dt8sbrVO.d.mts +236 -0
- package/dist/index.d.mts +98 -0
- package/dist/index.mjs +58 -0
- package/dist/protocol-DMsysRFd.mjs +211 -0
- package/dist/protocol-edoRdXpF.d.mts +30 -0
- package/dist/schema-BeI8GdXS.mjs +105 -0
- package/dist/vite-plugin.d.mts +43 -0
- package/dist/vite-plugin.mjs +435 -0
- package/package.json +117 -4
- package/skills/getting-started/SKILL.md +371 -0
|
@@ -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`.
|