@persona/layout-editor-remote-module 0.8.0 → 0.9.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 CHANGED
@@ -56,8 +56,9 @@ contract, including the optional `path` field for a client-routed SPA.
56
56
 
57
57
  ## Communicating with the host
58
58
 
59
- Document-delivered modules can use the browser bridge to receive layout data, report their height,
60
- and resolve data paths through the host. The host renders the document with `RemoteModuleFrame`:
59
+ Document-delivered modules can use the browser bridge to receive layout data, report the height
60
+ their document needs (including `body` and ancestor margins and padding), and resolve data paths
61
+ through the host. The host renders the document with `RemoteModuleFrame`:
61
62
 
62
63
  ```tsx
63
64
  import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-host';
@@ -116,10 +117,12 @@ cannot derive itself:
116
117
  - `isInEditor` — the module is rendered inside the layout editor rather than on a live record
117
118
  - `isUsingStubData` — the host is feeding placeholder/stub data instead of a real record
118
119
  - `dynamicHeight` — whether the host sizes the iframe to the height the bridge reports
119
- (`RemoteModuleFrame`'s `dynamicHeight`). A module that fills a fixed-height iframe with viewport
120
- units (`100vh`) should size to its content when it's set, or the reported height just follows the
121
- iframe. `RemoteModuleFrame` sets it from its `dynamicHeight` prop, overriding any value in the
122
- `context` prop.
120
+ (`RemoteModuleFrame`'s `dynamicHeight`). The reported height covers the whole frame document,
121
+ including `body` and ancestor margins and padding. A module that fills a fixed-height iframe with
122
+ viewport units (`100vh`) should size to its content when it's set — combined with any `body`
123
+ margin or padding the reported height exceeds the iframe, so it grows on every report.
124
+ `RemoteModuleFrame` sets it from its `dynamicHeight` prop, overriding any value in the `context`
125
+ prop.
123
126
 
124
127
  `context` is sent with `init` and pushed again whenever it changes, just like `data`; inside the
125
128
  iframe it arrives through `ready()`, `getContext()`, and the `context` render prop (all
@@ -140,10 +143,10 @@ The bridge's security model is intentionally narrow:
140
143
  - The iframe derives the ready target from `ancestorOrigins` or the referrer, and the host retries
141
144
  `init` until the frame answers.
142
145
  - Messages use a versioned protocol that stays compatible with frames built against older releases
143
- (from the release after 0.5.1): receivers ignore message types they don't know and strip keys they
144
- don't know, and fields are never removed, renamed or narrowed. `context.layoutType` can be a
145
- layout type added later, so handle values you don't know. Credentials remain in the iframe URL and
146
- are never handled through bridge messages.
146
+ (from 0.5.3): receivers ignore message types they don't know and strip keys they don't know, and
147
+ fields are never removed, renamed or narrowed. `context.layoutType` can be a layout type added
148
+ later, so handle values you don't know. Credentials remain in the iframe URL and are never handled
149
+ through bridge messages.
147
150
  - `extraParams` rejects reserved names and credential-like keys.
148
151
 
149
152
  ### Consuming a manifest
@@ -61,6 +61,15 @@ function createPendingRequests() {
61
61
 
62
62
  //#endregion
63
63
  //#region src/bridge/iframe/createModuleBridge.ts
64
+ const measureDocumentHeight = (element) => {
65
+ const rect = element.getBoundingClientRect();
66
+ let trailing = 0;
67
+ for (let ancestor = element.parentElement; ancestor; ancestor = ancestor.parentElement) {
68
+ const style = window.getComputedStyle(ancestor);
69
+ trailing += parseFloat(style.paddingBottom) + parseFloat(style.borderBottomWidth) + parseFloat(style.marginBottom);
70
+ }
71
+ return Math.ceil(rect.top + window.scrollY + rect.height + trailing);
72
+ };
64
73
  function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins, resolveTimeoutMs = 3e4 } = {}) {
65
74
  const isEmbedded = typeof window !== "undefined" && window.parent !== window;
66
75
  const allowedOrigin = resolveAllowedHostOrigins(allowedHostOrigins);
@@ -203,20 +212,18 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
203
212
  height
204
213
  });
205
214
  };
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;
215
+ const observer = new ResizeObserver(() => {
211
216
  if (frame !== void 0) return;
212
217
  const callback = () => {
213
218
  frame = void 0;
214
- postHeight(height);
219
+ postHeight(measureDocumentHeight(element));
215
220
  };
216
221
  frame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame(callback) : window.setTimeout(callback, 0);
217
222
  });
218
223
  observers.add(observer);
219
224
  observer.observe(element);
225
+ const body = element.ownerDocument?.body;
226
+ if (body && body !== element) observer.observe(body);
220
227
  return () => {
221
228
  disconnected = true;
222
229
  if (frame !== void 0) {
@@ -337,6 +344,7 @@ function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeigh
337
344
  fallback,
338
345
  render: () => /* @__PURE__ */ jsx("div", {
339
346
  ref: rootRef,
347
+ style: { display: "flow-root" },
340
348
  children: children({
341
349
  context,
342
350
  data,
@@ -1,5 +1,5 @@
1
1
  import { n as Json, t as RemoteModuleContext } from "./protocol-Dqnl8JTs.mjs";
2
- import { CSSProperties, ReactNode } from "react";
2
+ import { CSSProperties, ReactNode, Ref } from "react";
3
3
 
4
4
  //#region src/bridge/host/buildModuleSrc.d.ts
5
5
  type BuildModuleSrcOptions = {
@@ -30,6 +30,8 @@ type RemoteModuleFrameProps = {
30
30
  minHeight?: number;
31
31
  className?: string;
32
32
  style?: CSSProperties;
33
+ /** Receives the underlying <iframe> so a host can attach extra postMessage bridges. */
34
+ iframeRef?: Ref<HTMLIFrameElement>;
33
35
  data: Json;
34
36
  context: RemoteModuleContext;
35
37
  resolveDataPath: ResolveDataPath;
@@ -52,6 +54,7 @@ declare function RemoteModuleFrame({
52
54
  minHeight,
53
55
  className,
54
56
  style,
57
+ iframeRef: externalIframeRef,
55
58
  data,
56
59
  context,
57
60
  resolveDataPath,
@@ -1,5 +1,5 @@
1
1
  import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-D_pdVZjs.mjs";
2
- import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
5
5
  //#region src/bridge/host/buildModuleSrc.ts
@@ -145,14 +145,27 @@ function withDynamicHeight(context, dynamicHeight) {
145
145
  dynamicHeight
146
146
  };
147
147
  }
148
- function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environmentId, locale, extraParams, title, sandbox, referrerPolicy = "no-referrer", initRetryMs, initRetryTimeoutMs, dynamicHeight = false, heightOffset = 0, minHeight, className, style, data, context, resolveDataPath, onRenderError }) {
148
+ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environmentId, locale, extraParams, title, sandbox, referrerPolicy = "no-referrer", initRetryMs, initRetryTimeoutMs, dynamicHeight = false, heightOffset = 0, minHeight, className, style, iframeRef: externalIframeRef, data, context, resolveDataPath, onRenderError }) {
149
149
  const iframeRef = useRef(null);
150
150
  const dataRef = useRef(data);
151
151
  const contextRef = useRef(withDynamicHeight(context, dynamicHeight));
152
152
  const onRenderErrorRef = useRef(onRenderError);
153
153
  const resolveDataPathRef = useRef(resolveDataPath);
154
154
  const bridgeRef = useRef(void 0);
155
+ const externalCleanupRef = useRef(void 0);
155
156
  const [height, setHeight] = useState();
157
+ const setRefs = useCallback((node) => {
158
+ iframeRef.current = node;
159
+ if (typeof externalIframeRef === "function") if (node) {
160
+ const cleanup = externalIframeRef(node);
161
+ externalCleanupRef.current = typeof cleanup === "function" ? cleanup : void 0;
162
+ } else if (externalCleanupRef.current) {
163
+ const cleanup = externalCleanupRef.current;
164
+ externalCleanupRef.current = void 0;
165
+ cleanup();
166
+ } else externalIframeRef(null);
167
+ else if (externalIframeRef) externalIframeRef.current = node;
168
+ }, [externalIframeRef]);
156
169
  const hostContext = useMemo(() => withDynamicHeight(context, dynamicHeight), [context, dynamicHeight]);
157
170
  dataRef.current = data;
158
171
  contextRef.current = hostContext;
@@ -205,7 +218,7 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
205
218
  }, [hostContext]);
206
219
  const computedHeight = dynamicHeight && height !== void 0 ? `${Math.max(height + heightOffset, minHeight ?? 0)}px` : minHeight === void 0 ? "100%" : `${minHeight}px`;
207
220
  return /* @__PURE__ */ jsx("iframe", {
208
- ref: iframeRef,
221
+ ref: setRefs,
209
222
  src,
210
223
  title,
211
224
  sandbox,
@@ -214,6 +227,7 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
214
227
  style: {
215
228
  ...style,
216
229
  height: computedHeight,
230
+ display: style?.display ?? "block",
217
231
  width: "100%",
218
232
  border: "none"
219
233
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persona/layout-editor-remote-module",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.",
5
5
  "license": "SEE LICENSE",
6
6
  "type": "module",
@@ -2,15 +2,15 @@
2
2
  name: getting-started
3
3
  description: >
4
4
  Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register
5
- personaLayoutModules in a typical app's vite.config.ts, author modules with defineLayoutModule
6
- using a document (.html) render delivery, add a zod configSchema and an RJSF uiConfigSchema
7
- (labels, field order, DataSourceField data-source pickers), and talk to the host from the iframed
8
- document with LayoutModuleBridge from the bridge-frame entry (layout data, host context such as
9
- layoutType / layoutObjectId / isReadOnly, resolveDataPath, and height). Activate when adding a new
10
- layout module to an app, integrating this package for the first time, wiring a module document to
11
- the dashboard host, or debugging a manifest that fails to build or validate (duplicate key,
12
- missing defaultSize entry, render path not found) or a module that never receives data from the
13
- host.
5
+ personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using an .html
6
+ document entry, add a zod configSchema and an RJSF uiConfigSchema (labels, field order,
7
+ DataSourceField pickers), and talk to the host from the iframe with LayoutModuleBridge (layout
8
+ data, host context such as layoutType / layoutObjectId / isReadOnly / dynamicHeight,
9
+ resolveDataPath, height). Activate when adding a layout module, integrating this package for the
10
+ first time, wiring a module document to the dashboard host, or debugging a manifest that fails to
11
+ build or validate (duplicate key, missing defaultSize entry, render path not found), a module that
12
+ never receives data from the host, or an iframe whose height collapses or keeps growing under
13
+ dynamic height.
14
14
  metadata:
15
15
  type: core
16
16
  library: '@persona/layout-editor-remote-module'
@@ -31,8 +31,13 @@ This package is publicly available from the npm registry.
31
31
  yarn add @persona/layout-editor-remote-module
32
32
  ```
33
33
 
34
- `vite` and `zod` are peer dependencies; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional
35
- peers needed only by the `bridge-frame` entry.
34
+ `vite` (`^6 || ^7 || ^8`) and `zod` are peer dependencies. Vite 5 is unsupported because the plugin
35
+ relies on the Vite 6 Environment API; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional peers
36
+ needed only by the `bridge-frame` entry.
37
+
38
+ Build the iframed document against `@persona/layout-editor-remote-module` 0.5.3 or later. Older
39
+ frames reject any host message that carries a key they don't know, so they stop working once the
40
+ host sends new context fields such as `dynamicHeight`.
36
41
 
37
42
  ## Setup (vite.config.ts)
38
43
 
@@ -65,8 +70,10 @@ export default defineConfig({
65
70
  An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
66
71
  specific layouts, `defaultSize` must provide an entry for each one.
67
72
 
68
- Optional fields: `allowDynamicHeight` (expose the dynamic-height option in the module's
69
- configuration), `badge` (`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
73
+ Optional fields: `allowDynamicHeight` (exposes the option only; the host sizes the iframe to the
74
+ module's reported height only when the user turns "dynamic height" on for that placement, and the
75
+ module learns this through [`context.dynamicHeight`](#host-context)), `badge`
76
+ (`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
70
77
  (`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
71
78
  `build_your_own`), `categoryCustom` (a free-form module-library category label, up to 80 characters;
72
79
  the host renders it verbatim — it is not translated — and groups modules that share the same
@@ -81,6 +88,12 @@ narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below
81
88
  `entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) —
82
89
  not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config.
83
90
 
91
+ In a multi-environment build (e.g. with `@cloudflare/vite-plugin`), the plugin adds module documents
92
+ only to the `client` environment's build inputs and writes the manifest only into the client
93
+ environment's `build.outDir`. Non-client environments (workers/SSR) never see the `.html` entries.
94
+
95
+ Source: `src/vite-plugin.ts` (`configEnvironment`, `closeBundle`).
96
+
84
97
  ## Core Patterns
85
98
 
86
99
  ### Document delivery — iframed page
@@ -273,8 +286,11 @@ export function App() {
273
286
  implementation. It rejects if the host reports an error or does not answer within
274
287
  `resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
275
288
  directly in a tab still renders.
276
- - Height is reported automatically: the bridge wraps `children` in a `div` and observes it with a
277
- `ResizeObserver` so the host can size the iframe (`observeHeight={false}` to opt out).
289
+ - Height is reported automatically: the bridge wraps `children` in a `display: flow-root` div and
290
+ reports the height the frame document needs, including `body` and ancestor margins, padding, and
291
+ borders, so the host can size the iframe (`observeHeight={false}` to opt out). The host uses the
292
+ reported height only when [`context.dynamicHeight`](#host-context) is true; otherwise, the iframe
293
+ keeps its grid size.
278
294
  - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown.
279
295
  - Mount exactly one `LayoutModuleBridge` per document.
280
296
 
@@ -301,15 +317,24 @@ import {
301
317
 
302
318
  `RemoteModuleContext` (exported as a type from `bridge-frame`) has:
303
319
 
304
- - `layoutType` — `'account' | 'case' | 'transaction'`, the layout the module is placed on.
320
+ - `layoutType` — known values are `'account' | 'case' | 'transaction'`, but its type is
321
+ `LayoutType | (string & {})` and the bridge accepts layout types added after the module's release.
322
+ Handle unknown values with a default branch rather than a switch that tries to cover every case.
305
323
  - `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
306
324
  `case` layout), or `null` when there is none.
307
325
  - `isReadOnly` — the host object is locked for edits; hide or disable mutating actions.
308
326
  - `isInEditor` — rendered inside the layout editor rather than on a live record.
309
327
  - `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network
310
328
  calls or side effects keyed off the record.
329
+ - `dynamicHeight` — `boolean`, optional in the type; `RemoteModuleFrame` always sets it. It is true
330
+ when the host sizes the iframe to the height the bridge reports. When true, size to content
331
+ (`height: auto`) instead of filling the viewport. When false or absent, the iframe has a fixed
332
+ grid height, so filling it (`100vh`) is fine. Treat a `null` context (not embedded) like false;
333
+ see
334
+ [Viewport-relative height under dynamic height](#high-viewport-relative-height-under-dynamic-height).
311
335
 
312
- `context` is `null` only when the document is not embedded (opened directly in a tab).
336
+ `context` is `null` only when the document is not embedded (opened directly in a tab); inside
337
+ `children` it is otherwise always set, because `children` runs only after `init`.
313
338
 
314
339
  ```tsx
315
340
  import {
@@ -325,6 +350,11 @@ function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
325
350
  }
326
351
  ```
327
352
 
353
+ The protocol only ever adds fields. Receivers built on 0.5.3+ ignore unknown message types and strip
354
+ unknown keys, so new context fields may appear in later host releases without a frame upgrade.
355
+
356
+ Source: `src/bridge/protocol.ts`.
357
+
328
358
  #### Access token and environment id
329
359
 
330
360
  The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app
@@ -337,35 +367,9 @@ parameters cannot leak through subsequent requests.
337
367
 
338
368
  ### [HIGH] Reading layout data outside `LayoutModuleBridge`
339
369
 
340
- Wrong:
341
-
342
- ```tsx
343
- const data = await requestDataSomehow(); // no such API
344
- const resolveDataPath = window.resolveDataPath; // no such API
345
- ```
346
-
347
- Correct:
348
-
349
- ```tsx
350
- function Module({
351
- data,
352
- resolveDataPath,
353
- }: {
354
- data: Json;
355
- resolveDataPath: (
356
- dataPath: string,
357
- variableMap?: Record<string, string | number>,
358
- ) => Promise<Json>;
359
- }) {
360
- // ...
361
- }
362
-
363
- <LayoutModuleBridge>
364
- {({ data, resolveDataPath }) => <Module data={data} resolveDataPath={resolveDataPath} />}
365
- </LayoutModuleBridge>;
366
- ```
367
-
368
- `data` and `resolveDataPath` only exist inside the render prop.
370
+ Do not invent APIs such as `requestDataSomehow()` or `window.resolveDataPath`. `data` and
371
+ `resolveDataPath` exist only in the `LayoutModuleBridge` render prop; pass them to nested components
372
+ from there.
369
373
 
370
374
  Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
371
375
 
@@ -373,12 +377,34 @@ Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
373
377
 
374
378
  `children` never running means the handshake didn't complete. Check, in order: the document is
375
379
  really cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects
376
- unknown parents — pass your own only for a non-Persona dev host); the module is a `.html` document
377
- delivery, not a script entry; the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
380
+ unknown parents — pass your own only for a non-Persona dev host); the frame's
381
+ `@persona/layout-editor-remote-module` is 0.5.3 or later (a frame on 0.5.2 or older rejects an
382
+ `init` whose context has keys it doesn't know, e.g. `dynamicHeight`, sent by hosts on 0.8.0+, so
383
+ `children` never runs); and the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
378
384
  never send `init`). Opening the document directly in a tab is _not_ embedded — `data` and `context`
379
- resolve to `null` and `children` renders, so a stuck fallback is always a host/origin problem.
385
+ resolve to `null` and `children` renders, so a stuck fallback is always a host, origin, or frame
386
+ version problem.
387
+
388
+ Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`, `src/bridge/protocol.ts`.
389
+
390
+ ### [HIGH] Viewport-relative height under dynamic height
391
+
392
+ Wrong: the module root uses `height: 100vh` or `min-height: 100vh` (or `height: 100%` on `html` /
393
+ `body` / root) regardless of context. With dynamic height on, the bridge reports the iframe's own
394
+ height, so the module can never shrink. And because the reported height includes the frame
395
+ document's `body`/ancestor margin and padding, `100vh` or `min-height: 100vh` combined with any body
396
+ margin or padding reports a height larger than the iframe, so the iframe grows on every report.
397
+
398
+ Correct: switch to `auto` when `context?.dynamicHeight` is true:
399
+
400
+ ```tsx
401
+ const sizing = context?.dynamicHeight ? 'content' : 'viewport';
402
+ // root: height: sizing === 'content' ? 'auto' : '100vh'
403
+ // min-height: sizing === 'content' ? 'auto' : '100vh'
404
+ ```
380
405
 
381
- Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`.
406
+ Source: `src/bridge/protocol.ts` (`remoteModuleContextSchema.dynamicHeight`),
407
+ `src/bridge/host/RemoteModuleFrame.tsx`.
382
408
 
383
409
  ### [HIGH] `DataSourceField` on a non-string property, or read as the resolved value
384
410
 
@@ -397,19 +423,6 @@ Spell `'DataSourceField'` exactly; module-supplied field components are not poss
397
423
 
398
424
  ### [HIGH] `key` uses the wrong charset or collides with another module
399
425
 
400
- Wrong:
401
-
402
- ```ts
403
- defineLayoutModule({ key: 'risk-summary' /* ... */ }); // hyphens
404
- defineLayoutModule({ key: 'Risk_Summary' /* ... */ }); // uppercase
405
- ```
406
-
407
- Correct:
408
-
409
- ```ts
410
- defineLayoutModule({ key: 'risk_summary' /* ... */ });
411
- ```
412
-
413
426
  `key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a
414
427
  persisted module identity string and DOM ids. A bad charset fails zod validation at build time
415
428
  (`closeBundle`); a duplicate throws
@@ -470,28 +483,11 @@ Source: `src/vite-plugin.ts` (`checkDeliveryExists`).
470
483
 
471
484
  ### [MEDIUM] `manifestPath` set to a directory instead of a file
472
485
 
473
- Wrong:
474
-
475
- ```ts
476
- personaLayoutModules({ manifestPath: '/manifests/', modules: [] });
477
- ```
478
-
479
- Correct:
480
-
481
- ```ts
482
- personaLayoutModules({ manifestPath: '/manifests/my-app.json', modules: [] });
483
- ```
484
-
485
- `manifestPath` must include the full JSON file name — it defaults to `DEFAULT_MANIFEST_PATH`
486
- (`/_layout_modules_manifest.json`). A leading `/` is optional and stripped either way.
487
-
488
- Code outside `vite.config.ts` that needs the default path (e.g. a subapp server adding CORS on the
489
- manifest route) should import the constant from the runtime-safe `.` entry rather than hardcoding it
490
- — this avoids pulling in Vite:
491
-
492
- ```ts
493
- import { DEFAULT_MANIFEST_PATH } from '@persona/layout-editor-remote-module';
494
- ```
486
+ Use a file path such as `/manifests/my-app.json`, not a directory such as `/manifests/`.
487
+ `manifestPath` defaults to `DEFAULT_MANIFEST_PATH` (`/_layout_modules_manifest.json`); a leading `/`
488
+ is optional and stripped either way. Code outside `vite.config.ts` that needs the default (e.g. a
489
+ subapp server adding CORS on the manifest route) should import `DEFAULT_MANIFEST_PATH` from the
490
+ runtime-safe `.` entry to avoid pulling in Vite.
495
491
 
496
492
  Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment),
497
493
  `src/manifest-path.ts`.