@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 ADDED
@@ -0,0 +1,147 @@
1
+ # @persona/layout-editor-remote-module
2
+
3
+ Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — used by
4
+ app-builder Apps and `sa-*` subapps to declare a layout module directly in their own
5
+ `vite.config.ts`, without editing persona-web.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ yarn add @persona/layout-editor-remote-module
11
+ ```
12
+
13
+ `vite`, `esbuild`, and `zod` are peer dependencies. The plugin's dev-mode bundling uses `esbuild`
14
+ directly, and declaring it as a peer means it resolves to the same copy `vite` already pulls in
15
+ rather than a second version. With Yarn's `node-modules` linker the hoisted transitive copy usually
16
+ satisfies it; add `esbuild` to your own `devDependencies` if your package manager warns about an
17
+ unmet peer.
18
+
19
+ The optional `react` and `react-dom` peer dependencies are required only when using the React bridge
20
+ components.
21
+
22
+ The package is published to the public npm registry.
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ // vite.config.ts
28
+ import {
29
+ defineLayoutModule,
30
+ personaLayoutModules,
31
+ } from '@persona/layout-editor-remote-module/vite-plugin';
32
+
33
+ export default defineConfig({
34
+ plugins: [
35
+ react(),
36
+ personaLayoutModules({
37
+ modules: [
38
+ defineLayoutModule({
39
+ key: 'risk_summary',
40
+ displayName: 'Risk Summary',
41
+ descriptionShort: 'Show the account risk score',
42
+ allowAddingToLayout: true,
43
+ layouts: ['case'],
44
+ defaultSize: { case: { width: 4, height: 20 } },
45
+ // `.tsx`/`.js` — a `code` delivery, imported and rendered directly
46
+ // in the host's own React tree.
47
+ render: { entry: './src/layout-modules/risk-summary/render.tsx' },
48
+ }),
49
+ ],
50
+ }),
51
+ ],
52
+ });
53
+ ```
54
+
55
+ An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
56
+ specific layouts, `defaultSize` must provide an entry for each one.
57
+
58
+ An `.html` entry (rather than `.tsx`/`.js`) is a `document` delivery instead — the host iframes it
59
+ rather than importing anything. See `LayoutModuleEntry`'s doc comment in `src/define.ts` for the
60
+ full contract, including the optional `path` field for a client-routed SPA.
61
+
62
+ ## Communicating with the host
63
+
64
+ Document-delivered modules can use the browser bridge to receive layout data, report their height,
65
+ and resolve data paths through the host. The host renders the document with `RemoteModuleFrame`:
66
+
67
+ ```tsx
68
+ import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-host';
69
+
70
+ <RemoteModuleFrame
71
+ documentUrl={registration.document}
72
+ moduleOrigin={registration.origin}
73
+ title='Risk summary'
74
+ sandbox='allow-scripts allow-same-origin'
75
+ data={moduleData}
76
+ context={moduleContext}
77
+ dynamicHeight
78
+ resolveDataPath={resolveDataPath}
79
+ />;
80
+ ```
81
+
82
+ The required `sandbox` value is chosen by the host.
83
+
84
+ The document entry uses `LayoutModuleBridge` as a render-prop component:
85
+
86
+ ```tsx
87
+ import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';
88
+
89
+ <LayoutModuleBridge>
90
+ {({ data, context, resolveDataPath }) => (
91
+ <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
92
+ )}
93
+ </LayoutModuleBridge>;
94
+ ```
95
+
96
+ Alongside `data` (the module's persisted config), the host sends a typed `context` payload
97
+ (`RemoteModuleContext`) describing where the module is rendered — facts the host owns and the module
98
+ cannot derive itself:
99
+
100
+ - `layoutType` — which layout kind the module is placed on (`account`, `case`, or `transaction`)
101
+ - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
102
+ `case` layout), or `null` when there is none
103
+ - `isReadOnly` — the host object is locked for edits
104
+ - `isInEditor` — the module is rendered inside the layout editor rather than on a live record
105
+ - `isUsingStubData` — the host is feeding placeholder/stub data instead of a real record
106
+
107
+ `context` is sent with `init` and pushed again whenever it changes, just like `data`; inside the
108
+ iframe it arrives through `ready()`, `getContext()`, and the `context` render prop (all
109
+ `RemoteModuleContext | null` until init).
110
+
111
+ The host still appends `access_token`, `environment_id`, and `locale` as query parameters. The
112
+ builder app or `sa-*` subapp hosting the module consumes and strips those parameters itself, so by
113
+ the time `LayoutModuleBridge` runs they have already been handled; the bridge neither reads nor
114
+ strips them. Keep the host iframe's `referrerpolicy` at its default `no-referrer` (or use
115
+ `strict-origin`) so the URL parameters cannot leak through subsequent requests.
116
+
117
+ The bridge's security model is intentionally narrow:
118
+
119
+ - The host sends only to the exact module origin and accepts messages only from that origin and
120
+ iframe window.
121
+ - The iframe accepts only messages from its parent window and an allowed host origin, then pins the
122
+ first trusted origin for the lifetime of the bridge.
123
+ - The iframe derives the ready target from `ancestorOrigins` or the referrer, and the host retries
124
+ `init` until the frame answers.
125
+ - Messages use a strict, versioned protocol. Credentials remain in the iframe URL and are never
126
+ handled through bridge messages.
127
+ - `extraParams` rejects reserved names and credential-like keys.
128
+
129
+ ### Consuming a manifest
130
+
131
+ The plugin emits `{ modules: RemoteModuleRegistrationManifestV1[] }` at `manifestPath`. A host
132
+ fetching that document should run it through `parseRemoteModuleManifest` — it validates the
133
+ envelope, every module, and key uniqueness, and returns the typed document:
134
+
135
+ ```ts
136
+ import {
137
+ parseRemoteModuleManifest,
138
+ RemoteModuleManifestParseError,
139
+ } from '@persona/layout-editor-remote-module';
140
+
141
+ const response = await fetch(`${appOrigin}/_layout_modules_manifest.json`);
142
+ const { modules } = parseRemoteModuleManifest(await response.json());
143
+ ```
144
+
145
+ It throws `RemoteModuleManifestParseError` (with `.issues` and a prettified `.message`) on a
146
+ malformed document; `safeParseRemoteModuleManifest` returns `{ success, data | error }` instead of
147
+ throwing.
@@ -0,0 +1,45 @@
1
+ import { n as Json, t as RemoteModuleContext } from "./protocol-edoRdXpF.mjs";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/bridge/iframe/createModuleBridge.d.ts
5
+ type ModuleBridgeInitPayload = {
6
+ data: Json;
7
+ context: RemoteModuleContext | null;
8
+ };
9
+ type CreateModuleBridgeOptions = {
10
+ allowedHostOrigins?: (string | RegExp)[] | ((origin: string) => boolean);
11
+ resolveTimeoutMs?: number;
12
+ };
13
+ type ModuleBridge = {
14
+ ready: () => Promise<ModuleBridgeInitPayload>;
15
+ getData: () => Json;
16
+ getContext: () => RemoteModuleContext | null;
17
+ subscribe: (listener: () => void) => () => void;
18
+ reportRenderError: (message: string) => void;
19
+ resolveDataPath: (dataPath: string, variableMap?: Record<string, string | number>) => Promise<Json>;
20
+ observeHeight: (element: Element) => () => void;
21
+ destroy: () => void;
22
+ };
23
+ //#endregion
24
+ //#region src/bridge/iframe/LayoutModuleBridge.d.ts
25
+ type LayoutModuleBridgeRenderProps = {
26
+ data: ReturnType<ModuleBridge['getData']>;
27
+ context: RemoteModuleContext | null;
28
+ resolveDataPath: ModuleBridge['resolveDataPath'];
29
+ };
30
+ type LayoutModuleBridgeProps = {
31
+ allowedHostOrigins?: CreateModuleBridgeOptions['allowedHostOrigins'];
32
+ resolveTimeoutMs?: number;
33
+ observeHeight?: boolean;
34
+ fallback?: ReactNode;
35
+ children: (props: LayoutModuleBridgeRenderProps) => ReactNode;
36
+ };
37
+ declare function LayoutModuleBridge({
38
+ allowedHostOrigins,
39
+ resolveTimeoutMs,
40
+ observeHeight,
41
+ fallback,
42
+ children
43
+ }: LayoutModuleBridgeProps): ReactNode;
44
+ //#endregion
45
+ export { type Json, LayoutModuleBridge, type LayoutModuleBridgeProps, type LayoutModuleBridgeRenderProps, type RemoteModuleContext };
@@ -0,0 +1,350 @@
1
+ import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-DMsysRFd.mjs";
2
+ import { Component, useEffect, useRef, useState } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+
5
+ //#region src/bridge/rpc.ts
6
+ var BridgeDestroyedError = class extends Error {
7
+ constructor(message = "Bridge has been destroyed") {
8
+ super(message);
9
+ this.name = "BridgeDestroyedError";
10
+ }
11
+ };
12
+ var BridgeTimeoutError = class extends Error {
13
+ constructor(message = "Bridge request timed out") {
14
+ super(message);
15
+ this.name = "BridgeTimeoutError";
16
+ }
17
+ };
18
+ function createRequestId() {
19
+ const cryptoApi = globalThis.crypto;
20
+ if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
21
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
22
+ }
23
+ function createPendingRequests() {
24
+ const pending = /* @__PURE__ */ new Map();
25
+ return {
26
+ add(timeoutMs) {
27
+ const requestId = createRequestId();
28
+ return {
29
+ requestId,
30
+ promise: new Promise((resolve, reject) => {
31
+ const timer = setTimeout(() => {
32
+ pending.delete(requestId);
33
+ reject(new BridgeTimeoutError());
34
+ }, timeoutMs);
35
+ pending.set(requestId, {
36
+ resolve,
37
+ reject,
38
+ timer
39
+ });
40
+ })
41
+ };
42
+ },
43
+ settle(requestId, settlement) {
44
+ const request = pending.get(requestId);
45
+ if (!request) return false;
46
+ pending.delete(requestId);
47
+ clearTimeout(request.timer);
48
+ if ("error" in settlement) request.reject(settlement.error);
49
+ else request.resolve(settlement.result);
50
+ return true;
51
+ },
52
+ rejectAll(error) {
53
+ for (const [requestId, request] of pending) {
54
+ pending.delete(requestId);
55
+ clearTimeout(request.timer);
56
+ request.reject(error);
57
+ }
58
+ }
59
+ };
60
+ }
61
+
62
+ //#endregion
63
+ //#region src/bridge/iframe/createModuleBridge.ts
64
+ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins, resolveTimeoutMs = 3e4 } = {}) {
65
+ const isEmbedded = typeof window !== "undefined" && window.parent !== window;
66
+ const allowedOrigin = resolveAllowedHostOrigins(allowedHostOrigins);
67
+ if (!isEmbedded) {
68
+ const notEmbeddedError = new BridgeDestroyedError("Bridge is not embedded");
69
+ return {
70
+ ready: async () => ({
71
+ data: null,
72
+ context: null
73
+ }),
74
+ getData: () => null,
75
+ getContext: () => null,
76
+ subscribe: () => () => {},
77
+ reportRenderError: () => {},
78
+ resolveDataPath: async () => {
79
+ throw notEmbeddedError;
80
+ },
81
+ observeHeight: () => () => {},
82
+ destroy: () => {}
83
+ };
84
+ }
85
+ let destroyed = false;
86
+ let hostOrigin;
87
+ let data = null;
88
+ let context = null;
89
+ let readyResolved = false;
90
+ const listeners = /* @__PURE__ */ new Set();
91
+ const observers = /* @__PURE__ */ new Set();
92
+ const pendingRequests = createPendingRequests();
93
+ const queuedRequests = /* @__PURE__ */ new Map();
94
+ let resolveReady;
95
+ let rejectReady;
96
+ const readyPromise = new Promise((resolve, reject) => {
97
+ resolveReady = resolve;
98
+ rejectReady = reject;
99
+ });
100
+ const post = (message) => {
101
+ if (hostOrigin) window.parent.postMessage(message, hostOrigin);
102
+ };
103
+ const flushQueuedRequests = () => {
104
+ if (!hostOrigin) return;
105
+ for (const [requestId, request] of queuedRequests) {
106
+ post({
107
+ source: SOURCE,
108
+ protocolVersion: PROTOCOL_VERSION,
109
+ type: "resolve-data-path",
110
+ requestId,
111
+ dataPath: request.dataPath,
112
+ ...request.variableMap === void 0 ? {} : { variableMap: request.variableMap }
113
+ });
114
+ queuedRequests.delete(requestId);
115
+ }
116
+ };
117
+ const pinHostOrigin = (origin) => {
118
+ if (!allowedOrigin(origin)) return false;
119
+ if (hostOrigin !== void 0 && hostOrigin !== origin) return false;
120
+ hostOrigin = origin;
121
+ flushQueuedRequests();
122
+ return true;
123
+ };
124
+ const notify = () => {
125
+ for (const listener of listeners) listener();
126
+ };
127
+ const onMessage = (event) => {
128
+ if (destroyed || event.source !== window.parent) return;
129
+ const message = parseHostToModuleMessage(event.data);
130
+ if (!message || !pinHostOrigin(event.origin)) return;
131
+ switch (message.type) {
132
+ case "init":
133
+ data = message.data;
134
+ context = message.context;
135
+ notify();
136
+ if (!readyResolved) {
137
+ readyResolved = true;
138
+ resolveReady({
139
+ data,
140
+ context
141
+ });
142
+ }
143
+ break;
144
+ case "data":
145
+ data = message.data;
146
+ notify();
147
+ break;
148
+ case "context":
149
+ context = message.context;
150
+ notify();
151
+ break;
152
+ case "data-path-result":
153
+ if (message.error !== void 0) pendingRequests.settle(message.requestId, { error: new Error(message.error) });
154
+ else pendingRequests.settle(message.requestId, { result: message.result ?? null });
155
+ break;
156
+ }
157
+ };
158
+ window.addEventListener("message", onMessage);
159
+ const parentOrigin = candidateParentOrigin();
160
+ if (parentOrigin && allowedOrigin(parentOrigin)) window.parent.postMessage({
161
+ source: SOURCE,
162
+ protocolVersion: PROTOCOL_VERSION,
163
+ type: "ready"
164
+ }, parentOrigin);
165
+ return {
166
+ ready: () => readyPromise,
167
+ getData: () => data,
168
+ getContext: () => context,
169
+ subscribe(listener) {
170
+ listeners.add(listener);
171
+ return () => listeners.delete(listener);
172
+ },
173
+ reportRenderError(message) {
174
+ post({
175
+ source: SOURCE,
176
+ protocolVersion: PROTOCOL_VERSION,
177
+ type: "render-error",
178
+ message
179
+ });
180
+ },
181
+ resolveDataPath(dataPath, variableMap) {
182
+ if (destroyed) return Promise.reject(new BridgeDestroyedError());
183
+ const { requestId, promise } = pendingRequests.add(resolveTimeoutMs);
184
+ queuedRequests.set(requestId, {
185
+ dataPath,
186
+ variableMap
187
+ });
188
+ flushQueuedRequests();
189
+ return promise;
190
+ },
191
+ observeHeight(element) {
192
+ if (destroyed || typeof ResizeObserver === "undefined") return () => {};
193
+ let lastHeight;
194
+ let frame;
195
+ let disconnected = false;
196
+ const postHeight = (height) => {
197
+ if (disconnected || height === lastHeight) return;
198
+ lastHeight = height;
199
+ post({
200
+ source: SOURCE,
201
+ protocolVersion: PROTOCOL_VERSION,
202
+ type: "set-height",
203
+ height
204
+ });
205
+ };
206
+ const observer = new ResizeObserver((entries) => {
207
+ const entry = entries[0];
208
+ if (!entry) return;
209
+ const borderBoxSize = entry.borderBoxSize;
210
+ const height = (Array.isArray(borderBoxSize) ? borderBoxSize[0]?.blockSize : void 0) ?? entry.contentRect.height;
211
+ if (frame !== void 0) return;
212
+ const callback = () => {
213
+ frame = void 0;
214
+ postHeight(height);
215
+ };
216
+ frame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame(callback) : window.setTimeout(callback, 0);
217
+ });
218
+ observers.add(observer);
219
+ observer.observe(element);
220
+ return () => {
221
+ disconnected = true;
222
+ if (frame !== void 0) {
223
+ window.cancelAnimationFrame?.(frame);
224
+ window.clearTimeout(frame);
225
+ }
226
+ observer.disconnect();
227
+ observers.delete(observer);
228
+ };
229
+ },
230
+ destroy() {
231
+ if (destroyed) return;
232
+ destroyed = true;
233
+ window.removeEventListener("message", onMessage);
234
+ for (const observer of observers) observer.disconnect();
235
+ observers.clear();
236
+ queuedRequests.clear();
237
+ const error = new BridgeDestroyedError();
238
+ pendingRequests.rejectAll(error);
239
+ if (!readyResolved) rejectReady(error);
240
+ }
241
+ };
242
+ }
243
+
244
+ //#endregion
245
+ //#region src/bridge/iframe/useRemoteModuleBridge.ts
246
+ const rejectNotReady = async () => {
247
+ throw new BridgeDestroyedError("Bridge is not ready");
248
+ };
249
+ function useRemoteModuleBridge(options = {}) {
250
+ const optionsRef = useRef(options);
251
+ const [bridge, setBridge] = useState(null);
252
+ const [ready, setReady] = useState(false);
253
+ const [data, setData] = useState(null);
254
+ const [context, setContext] = useState(null);
255
+ useEffect(() => {
256
+ let active = true;
257
+ const currentBridge = createModuleBridge(optionsRef.current);
258
+ setBridge(currentBridge);
259
+ setData(currentBridge.getData());
260
+ setContext(currentBridge.getContext());
261
+ const unsubscribe = currentBridge.subscribe(() => {
262
+ if (active) {
263
+ setData(currentBridge.getData());
264
+ setContext(currentBridge.getContext());
265
+ }
266
+ });
267
+ currentBridge.ready().then(({ data: initialData, context: initialContext }) => {
268
+ if (active) {
269
+ setData(initialData);
270
+ setContext(initialContext);
271
+ setReady(true);
272
+ }
273
+ }, () => {
274
+ if (active) setReady(false);
275
+ });
276
+ return () => {
277
+ active = false;
278
+ unsubscribe();
279
+ currentBridge.destroy();
280
+ setBridge(null);
281
+ setData(null);
282
+ setContext(null);
283
+ setReady(false);
284
+ };
285
+ }, []);
286
+ return {
287
+ bridge,
288
+ context,
289
+ data,
290
+ ready,
291
+ resolveDataPath: bridge?.resolveDataPath ?? rejectNotReady
292
+ };
293
+ }
294
+
295
+ //#endregion
296
+ //#region src/bridge/iframe/LayoutModuleBridge.tsx
297
+ let mountedBridgeCount = 0;
298
+ function BridgeErrorContent({ render }) {
299
+ return render();
300
+ }
301
+ var BridgeErrorBoundary = class extends Component {
302
+ state = { error: null };
303
+ static getDerivedStateFromError(error) {
304
+ return { error };
305
+ }
306
+ componentDidCatch(error, _errorInfo) {
307
+ this.props.bridge.reportRenderError(error.message);
308
+ }
309
+ render() {
310
+ return this.state.error ? this.props.fallback : /* @__PURE__ */ jsx(BridgeErrorContent, { render: this.props.render });
311
+ }
312
+ };
313
+ function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeight = true, fallback = null, children }) {
314
+ const { bridge, context, data, ready, resolveDataPath } = useRemoteModuleBridge({
315
+ allowedHostOrigins,
316
+ resolveTimeoutMs
317
+ });
318
+ const rootRef = useRef(null);
319
+ useEffect(() => {
320
+ mountedBridgeCount += 1;
321
+ if (typeof process !== "undefined" && mountedBridgeCount > 1) console.warn("Multiple LayoutModuleBridge instances are mounted in the same document; this is unsupported.");
322
+ return () => {
323
+ mountedBridgeCount -= 1;
324
+ };
325
+ }, []);
326
+ useEffect(() => {
327
+ if (!ready || !observeHeight || !rootRef.current || !bridge) return;
328
+ return bridge.observeHeight(rootRef.current);
329
+ }, [
330
+ bridge,
331
+ observeHeight,
332
+ ready
333
+ ]);
334
+ if (!ready || !bridge) return fallback;
335
+ return /* @__PURE__ */ jsx(BridgeErrorBoundary, {
336
+ bridge,
337
+ fallback,
338
+ render: () => /* @__PURE__ */ jsx("div", {
339
+ ref: rootRef,
340
+ children: children({
341
+ context,
342
+ data,
343
+ resolveDataPath
344
+ })
345
+ })
346
+ });
347
+ }
348
+
349
+ //#endregion
350
+ export { LayoutModuleBridge };
@@ -0,0 +1,61 @@
1
+ import { n as Json, t as RemoteModuleContext } from "./protocol-edoRdXpF.mjs";
2
+ import { CSSProperties, ReactNode } from "react";
3
+
4
+ //#region src/bridge/host/buildModuleSrc.d.ts
5
+ type BuildModuleSrcOptions = {
6
+ accessToken?: string;
7
+ environmentId?: string;
8
+ locale?: string;
9
+ extraParams?: Record<string, string>;
10
+ };
11
+ //#endregion
12
+ //#region src/bridge/host/createHostBridge.d.ts
13
+ type ResolveDataPath = (dataPath: string, variableMap?: Record<string, string | number>) => Promise<Json | undefined> | Json | undefined;
14
+ //#endregion
15
+ //#region src/bridge/host/RemoteModuleFrame.d.ts
16
+ type RemoteModuleFrameProps = {
17
+ documentUrl: string;
18
+ moduleOrigin: string;
19
+ accessToken?: string;
20
+ environmentId?: string;
21
+ locale?: string;
22
+ extraParams?: BuildModuleSrcOptions['extraParams'];
23
+ title: string;
24
+ sandbox: string;
25
+ referrerPolicy?: ReferrerPolicy;
26
+ initRetryMs?: number;
27
+ initRetryTimeoutMs?: number;
28
+ dynamicHeight?: boolean;
29
+ heightOffset?: number;
30
+ minHeight?: number;
31
+ className?: string;
32
+ style?: CSSProperties;
33
+ data: Json;
34
+ context: RemoteModuleContext;
35
+ resolveDataPath: ResolveDataPath;
36
+ onRenderError?: (message: string) => void;
37
+ };
38
+ declare function RemoteModuleFrame({
39
+ documentUrl,
40
+ moduleOrigin,
41
+ accessToken,
42
+ environmentId,
43
+ locale,
44
+ extraParams,
45
+ title,
46
+ sandbox,
47
+ referrerPolicy,
48
+ initRetryMs,
49
+ initRetryTimeoutMs,
50
+ dynamicHeight,
51
+ heightOffset,
52
+ minHeight,
53
+ className,
54
+ style,
55
+ data,
56
+ context,
57
+ resolveDataPath,
58
+ onRenderError
59
+ }: RemoteModuleFrameProps): ReactNode;
60
+ //#endregion
61
+ export { type Json, type RemoteModuleContext, RemoteModuleFrame, type RemoteModuleFrameProps, type ResolveDataPath };