@botiverse/porthole 0.1.3 → 0.1.4

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.
Files changed (42) hide show
  1. package/dist/compile/policy.d.ts +6 -0
  2. package/dist/compile/policy.js +9 -1
  3. package/dist/config/schema.d.ts +11 -0
  4. package/dist/config/validate.js +12 -0
  5. package/dist/conformance/served.d.ts +8 -1
  6. package/dist/conformance/served.js +12 -8
  7. package/dist/guide/guide.js +3 -1
  8. package/dist/host/create-host.js +24 -7
  9. package/dist/host/types.d.ts +44 -2
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.js +1 -1
  12. package/dist/presets/presets.d.ts +3 -1
  13. package/dist/presets/presets.js +1 -0
  14. package/dist/react/frame.d.ts +34 -4
  15. package/dist/react/frame.js +80 -18
  16. package/dist/server/naming.d.ts +6 -0
  17. package/dist/server/naming.js +38 -0
  18. package/dist/server/pages.js +2 -0
  19. package/dist/server/server.js +49 -17
  20. package/dist/server/types.d.ts +6 -3
  21. package/dist/shell/annotate.js +31 -0
  22. package/dist/shell/document.d.ts +6 -0
  23. package/dist/shell/document.js +8 -2
  24. package/dist/shell/runtime.js +33 -1
  25. package/package.json +1 -1
  26. package/src/compile/policy.ts +10 -1
  27. package/src/config/schema.ts +12 -0
  28. package/src/config/validate.ts +15 -0
  29. package/src/conformance/served.ts +24 -9
  30. package/src/guide/guide.ts +3 -1
  31. package/src/host/create-host.ts +26 -7
  32. package/src/host/types.ts +39 -2
  33. package/src/index.ts +2 -0
  34. package/src/presets/presets.ts +4 -0
  35. package/src/react/frame.tsx +125 -23
  36. package/src/server/naming.ts +41 -0
  37. package/src/server/pages.ts +2 -0
  38. package/src/server/server.ts +61 -20
  39. package/src/server/types.ts +6 -3
  40. package/src/shell/annotate.ts +31 -0
  41. package/src/shell/document.ts +15 -2
  42. package/src/shell/runtime.ts +33 -1
@@ -175,4 +175,16 @@ export interface PortholeConfig {
175
175
  readonly sandbox?: readonly UrlSandboxExtra[];
176
176
  /** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
177
177
  readonly permissions?: readonly UrlPermission[];
178
+ /**
179
+ * Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
180
+ * and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
181
+ * displays one scope (all of a conversation's widgets) names it here (`{ shared:
182
+ * "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
183
+ */
184
+ readonly storage?: StorageConfig;
185
+ }
186
+
187
+ export interface StorageConfig {
188
+ /** What the displays sharing one origin have in common, as a noun ("conversation"). */
189
+ readonly shared?: string;
178
190
  }
@@ -131,6 +131,7 @@ export function validateConfig(input: unknown): PortholeConfig {
131
131
  "embedder",
132
132
  "sandbox",
133
133
  "permissions",
134
+ "storage",
134
135
  ],
135
136
  "config",
136
137
  );
@@ -315,6 +316,20 @@ export function validateConfig(input: unknown): PortholeConfig {
315
316
  });
316
317
  }
317
318
 
319
+ if (config.storage !== undefined) {
320
+ const storage = object(config.storage, "config.storage");
321
+ only(storage, ["shared"], "config.storage");
322
+ if (config.isolation !== "domain") {
323
+ fail("config.storage", "only domain isolation keeps storage across displays");
324
+ }
325
+ if (
326
+ storage.shared !== undefined &&
327
+ (typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))
328
+ ) {
329
+ fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
330
+ }
331
+ }
332
+
318
333
  if (config.permissions !== undefined) {
319
334
  if (config.mode !== "url") {
320
335
  fail("config.permissions", "only url mode may be delegated features");
@@ -38,8 +38,17 @@ export interface ServedConformanceHost {
38
38
  * Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
39
39
  * the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
40
40
  * may show several, one after another.
41
+ *
42
+ * For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
43
+ * the shell (`createHost`, the React binding) sends it as the display's content, since a shell
44
+ * draws nothing (and reports no height) until it has content, and the cases click inside it. A
45
+ * host whose frame has a height of its own may ignore it.
41
46
  */
42
- show(page: Page, instance: InstanceRef): Promise<ServedDisplay>;
47
+ show(
48
+ page: Page,
49
+ instance: InstanceRef,
50
+ options?: { readonly content?: string },
51
+ ): Promise<ServedDisplay>;
43
52
  /** A fresh ticketed location for `instance`, as the host's backend issues it. */
44
53
  locate(instance: InstanceRef): Promise<FrameLocation>;
45
54
  /** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
@@ -74,6 +83,9 @@ type Fixtures = { page: Page; browserName: string };
74
83
 
75
84
  const SETTLE_MS = 800;
76
85
 
86
+ /** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
87
+ const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
88
+
77
89
  /** What the frame can see of its host: everything here must be closed to it. */
78
90
  async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
79
91
  return frame.evaluate(() => {
@@ -185,13 +197,16 @@ export function servedTests(
185
197
  ["domain", (scope: string) => host.shell(scope)],
186
198
  ["url", (scope: string) => host.app(scope)],
187
199
  ] as const;
200
+ // A served shell draws (and has a height to click in) once its host gives it content.
201
+ const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
202
+ host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
188
203
 
189
204
  for (const [mode, instance] of modes) {
190
205
  define(
191
206
  `served.${mode}.origin`,
192
207
  "content runs on its own origin, never the host's",
193
208
  async ({ page }) => {
194
- const display = await host.show(page, instance(`${mode}-origin`));
209
+ const display = await showMode(page, mode, instance(`${mode}-origin`));
195
210
  const frame = await display.frame();
196
211
  const origin = await frame.evaluate(() => window.origin);
197
212
  expect(origin).toBe(display.origin);
@@ -203,7 +218,7 @@ export function servedTests(
203
218
  `served.${mode}.no-reach`,
204
219
  "content can't reach the host's DOM, storage, cookies or URL",
205
220
  async ({ page }) => {
206
- const display = await host.show(page, instance(`${mode}-reach`));
221
+ const display = await showMode(page, mode, instance(`${mode}-reach`));
207
222
  await plantHostSecrets(page);
208
223
  expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
209
224
  },
@@ -213,7 +228,7 @@ export function servedTests(
213
228
  `served.${mode}.no-top-navigation`,
214
229
  "content can't navigate the host's page, even after a click",
215
230
  async ({ page }) => {
216
- const display = await host.show(page, instance(`${mode}-top`));
231
+ const display = await showMode(page, mode, instance(`${mode}-top`));
217
232
  const frame = await display.frame();
218
233
  const before = page.url();
219
234
  const target = await host.canary(`${mode}-top`);
@@ -246,7 +261,7 @@ export function servedTests(
246
261
  `served.${mode}.no-popups`,
247
262
  "content can't open a window, even after a click",
248
263
  async ({ page }) => {
249
- const display = await host.show(page, instance(`${mode}-popup`));
264
+ const display = await showMode(page, mode, instance(`${mode}-popup`));
250
265
  const frame = await display.frame();
251
266
  const target = await host.canary(`${mode}-popup`);
252
267
  const opened: Page[] = [];
@@ -283,13 +298,13 @@ export function servedTests(
283
298
  "served.domain.storage-scope",
284
299
  "storage persists within a scope and is never shared across scopes",
285
300
  async ({ page }) => {
286
- const first = await host.show(page, host.shell("scope-a"));
301
+ const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
287
302
  await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
288
303
  await first.destroy();
289
- const again = await host.show(page, host.shell("scope-a"));
304
+ const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
290
305
  expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
291
306
  expect(again.origin).toBe(first.origin);
292
- const other = await host.show(page, host.shell("scope-b"));
307
+ const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
293
308
  expect(other.origin).not.toBe(first.origin);
294
309
  expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
295
310
  },
@@ -367,7 +382,7 @@ export function servedTests(
367
382
  "served.domain.egress",
368
383
  "the CSP in the document's headers closes egress",
369
384
  async ({ page }) => {
370
- const display = await host.show(page, host.shell("egress"));
385
+ const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
371
386
  const frame = await display.frame();
372
387
  const fetched = await frame.evaluate(
373
388
  async (url) => {
@@ -368,7 +368,9 @@ function interactive(c: Context): string {
368
368
  const state =
369
369
  config.isolation === "opaque"
370
370
  ? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
371
- : `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
371
+ : config.storage?.shared === undefined
372
+ ? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
373
+ : `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
372
374
  const none =
373
375
  entries.length === 0
374
376
  ? `- It can only draw: it has no way to send anything to you or the app.`
@@ -26,6 +26,7 @@ import { shellAdapter } from "../protocol/shell-adapter.ts";
26
26
  import { resolveTheme } from "../theme/variables.ts";
27
27
  import type {
28
28
  AnnotationControls,
29
+ HostErrorKind,
29
30
  DisplayEvents,
30
31
  DisplayHandle,
31
32
  HostOptions,
@@ -96,6 +97,16 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
96
97
  (listener as (detail: DisplayEvents[E]) => void)(detail);
97
98
  }
98
99
  };
100
+ // What the content did wrong (the agent's to hear and fix), and what failed on the host's
101
+ // side (its own to handle), each on its own event and on `error` with its source.
102
+ const contentError = (message: string) => {
103
+ emit("contentError", { message });
104
+ emit("error", { message, source: "content" });
105
+ };
106
+ const hostError = (message: string, kind: HostErrorKind) => {
107
+ emit("hostError", { message, kind });
108
+ emit("error", { message, source: "host" });
109
+ };
99
110
 
100
111
  let scheme: ColorScheme = mount.scheme;
101
112
  let height = mount.initialHeightPx ?? 0;
@@ -277,7 +288,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
277
288
  return;
278
289
  }
279
290
  case "error":
280
- emit("error", { message: event.message });
291
+ contentError(event.message);
281
292
  return;
282
293
  case "refused":
283
294
  emit("refused", { capability: event.capability, refusal: event.refusal });
@@ -311,8 +322,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
311
322
  allow: compiled.allow,
312
323
  referrerPolicy: compiled.referrerPolicy,
313
324
  colorScheme: scheme,
314
- // An app reports no size: its frame fills the host's container, at least the minimum.
315
- ...(speaks
325
+ // An app reports no size: its frame fills the host's container, at least the minimum;
326
+ // so does any display the host mounts to fill (its content scrolls inside).
327
+ ...(speaks && mount.fill !== true
316
328
  ? { initialHeightPx: height }
317
329
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
318
330
  });
@@ -377,9 +389,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
377
389
  run();
378
390
  } else {
379
391
  stop();
380
- emit("error", {
381
- message: "The content kept navigating its frame away; it was stopped.",
382
- });
392
+ contentError("The content kept navigating its frame away; it was stopped.");
383
393
  }
384
394
  });
385
395
  };
@@ -396,6 +406,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
396
406
  html: compiled.shellDocument({
397
407
  origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
398
408
  title: mount.title,
409
+ ...nonceOf(options.nonce),
399
410
  ...(mount.document === undefined ? {} : { document: mount.document }),
400
411
  }),
401
412
  });
@@ -418,7 +429,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
418
429
  },
419
430
  (error: unknown) => {
420
431
  if (mine === generation && !controller.signal.aborted) {
421
- emit("error", { message: error instanceof Error ? error.message : String(error) });
432
+ // The host's own failure (its backend down, access gone): never the content's.
433
+ hostError(error instanceof Error ? error.message : String(error), "locate");
434
+ emit("outcome", { state: "blocked", reason: "locate" });
422
435
  }
423
436
  },
424
437
  );
@@ -488,3 +501,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
488
501
  },
489
502
  };
490
503
  }
504
+
505
+ /** The host's nonce option, read now (`HostOptions.nonce`). */
506
+ function nonceOf(nonce: HostOptions["nonce"]): { nonce?: string } {
507
+ const value = typeof nonce === "function" ? nonce() : nonce;
508
+ return value === undefined || value === "" ? {} : { nonce: value };
509
+ }
package/src/host/types.ts CHANGED
@@ -34,6 +34,13 @@ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
34
34
  readonly platform?: PlatformHost;
35
35
  /** Required for domain and external isolation: where an instance's frame loads from. */
36
36
  readonly locator?: InstanceLocator;
37
+ /**
38
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): an opaque frame
39
+ * (`srcdoc`) inherits the page's policy, so the shell and the content's scripts carry it. A
40
+ * function is read each time a frame starts (a page whose nonce changes). Served frames inherit
41
+ * nothing and need none.
42
+ */
43
+ readonly nonce?: string | (() => string | undefined);
37
44
  }
38
45
 
39
46
  export interface MountOptions {
@@ -50,8 +57,23 @@ export interface MountOptions {
50
57
  readonly document?: string;
51
58
  /** The height to start at, before the content reports its own. */
52
59
  readonly initialHeightPx?: number;
60
+ /**
61
+ * Fill the container (give it a height) instead of taking the content's height: the content
62
+ * scrolls inside its frame (an artifact viewer's page). The content's sizes are still reported
63
+ * (`resize`). A url-mode app always fills.
64
+ */
65
+ readonly fill?: boolean;
53
66
  }
54
67
 
68
+ /** What failed on the host's side (`hostError`). */
69
+ export type HostErrorKind =
70
+ /** The locator rejected: the host's backend didn't give a location. */
71
+ | "locate"
72
+ /** A display couldn't be made (the React binding: a bad config, a missing handler). */
73
+ | "mount"
74
+ /** An annotation call was refused (an invalid mark, an unknown id). */
75
+ | "annotations";
76
+
55
77
  /** What a display reports, by event name. */
56
78
  /** Why a display's frame showed nothing of its content (`FrameOutcome`). */
57
79
  export type FrameBlock =
@@ -65,6 +87,8 @@ export type FrameBlock =
65
87
  | "upstream"
66
88
  /** The host's own check refused it (revoked, no longer a member): `code` says which. */
67
89
  | "denied"
90
+ /** The host's locator failed (its backend down, access gone): see the `hostError` event. */
91
+ | "locate"
68
92
  | "not-found"
69
93
  | "error";
70
94
 
@@ -93,8 +117,21 @@ export interface DisplayEvents {
93
117
  readonly outcome: FrameOutcome;
94
118
  /** The content's size, after the config's limits. */
95
119
  readonly resize: { readonly height: number; readonly width?: number };
96
- /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
97
- readonly error: { readonly message: string };
120
+ /**
121
+ * What the content did wrong: what it threw, what the sandbox blocked (its own words:
122
+ * untrusted), or that it kept navigating its frame away. The agent's to hear and fix.
123
+ */
124
+ readonly contentError: { readonly message: string };
125
+ /**
126
+ * What failed on the host's side (`kind`): its locator, for now. The host's to handle; never
127
+ * the agent's. The display's `outcome` says `blocked: locate` too.
128
+ */
129
+ readonly hostError: { readonly message: string; readonly kind: HostErrorKind };
130
+ /**
131
+ * Both of the above, with their `source`: for a host that listened here before they were
132
+ * split (0.1.3). Prefer `contentError` and `hostError`.
133
+ */
134
+ readonly error: { readonly message: string; readonly source: "content" | "host" };
98
135
  /** The content navigated its frame away; the frame was started again from its content. */
99
136
  readonly navigated: undefined;
100
137
  /** A call didn't run. */
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export type {
29
29
  LayoutConfig,
30
30
  Limits,
31
31
  PortholeConfig,
32
+ StorageConfig,
32
33
  ThemeConfig,
33
34
  ThemeValue,
34
35
  } from "./config/schema.ts";
@@ -65,6 +66,7 @@ export {
65
66
  effectiveGates,
66
67
  egressOrigins,
67
68
  embedderCsp,
69
+ embedderFrameSourcesOf,
68
70
  type CspDirective,
69
71
  type EgressKind,
70
72
  } from "./compile/policy.ts";
@@ -9,6 +9,7 @@ import type {
9
9
  LayoutConfig,
10
10
  Limits,
11
11
  PortholeConfig,
12
+ StorageConfig,
12
13
  ThemeValue,
13
14
  UrlPermission,
14
15
  UrlSandboxExtra,
@@ -45,6 +46,8 @@ export interface PresetOptions {
45
46
  readonly isolation?: Exclude<IsolationKind, "external">;
46
47
  readonly contentOrigins?: readonly string[];
47
48
  readonly embedder?: EmbedderConfig;
49
+ /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
50
+ readonly storage?: StorageConfig;
48
51
  }
49
52
 
50
53
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
@@ -174,6 +177,7 @@ function build(
174
177
  guide: options.guide ?? {},
175
178
  contentOrigins: options.contentOrigins ?? [],
176
179
  embedder: options.embedder ?? {},
180
+ ...(options.storage === undefined ? {} : { storage: options.storage }),
177
181
  });
178
182
  }
179
183
 
@@ -29,7 +29,7 @@ import type { CapabilityName } from "../config/capabilities.ts";
29
29
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
30
30
  import { writeClipboard } from "../host/clipboard.ts";
31
31
  import { createHost } from "../host/create-host.ts";
32
- import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
32
+ import type { DisplayEvents, DisplayHandle, FrameOutcome, HostErrorKind } from "../host/types.ts";
33
33
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
34
34
  import { ConfirmDialog } from "./dialog.tsx";
35
35
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -53,7 +53,9 @@ interface PageState {
53
53
  readonly heights: Map<string, number>;
54
54
  readonly confirm?: Confirm;
55
55
  readonly hostInfo?: { readonly name: string; readonly version: string };
56
- readonly locator?: InstanceLocator;
56
+ readonly nonce?: string;
57
+ /** The provider's locator, read when a frame locates (so an inline one restarts nothing). */
58
+ readonly locate?: InstanceLocator["locate"];
57
59
  /** The default `copyText`: copies on the host's page and shows the toast (P052). */
58
60
  readonly copyText?: CapabilityHandler<"copyText">;
59
61
  }
@@ -77,6 +79,11 @@ export interface PortholeProviderProps {
77
79
  readonly dialogClassName?: string;
78
80
  /** A class for the default copy toast, to restyle it. */
79
81
  readonly toastClassName?: string;
82
+ /**
83
+ * The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
84
+ * policy, so their shell carries it (P054).
85
+ */
86
+ readonly nonce?: string;
80
87
  readonly children?: ReactNode;
81
88
  }
82
89
 
@@ -113,7 +120,22 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
113
120
  return copied;
114
121
  }, []);
115
122
  const closeNotice = useCallback(() => setNotice(null), []);
116
- const { hostInfo, locator } = props;
123
+ // Compared by value, and read when used: an inline hostInfo or locator doesn't start every
124
+ // frame again on each render.
125
+ const hostInfo = useByValue(props.hostInfo);
126
+ const ownLocator = useRef(props.locator);
127
+ useLayoutEffect(() => {
128
+ ownLocator.current = props.locator;
129
+ });
130
+ const locate = useCallback<InstanceLocator["locate"]>(
131
+ (instance, signal) =>
132
+ ownLocator.current === undefined
133
+ ? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
134
+ : ownLocator.current.locate(instance, signal),
135
+ [],
136
+ );
137
+ const locates = props.locator !== undefined;
138
+ const { nonce } = props;
117
139
  const value = useMemo<PageState>(
118
140
  () => ({
119
141
  live,
@@ -121,9 +143,10 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
121
143
  confirm,
122
144
  copyText,
123
145
  ...(hostInfo === undefined ? {} : { hostInfo }),
124
- ...(locator === undefined ? {} : { locator }),
146
+ ...(locates ? { locate } : {}),
147
+ ...(nonce === undefined ? {} : { nonce }),
125
148
  }),
126
- [live, heights, confirm, copyText, hostInfo, locator],
149
+ [live, heights, confirm, copyText, hostInfo, locates, locate, nonce],
127
150
  );
128
151
  const current = queue[0] ?? null;
129
152
  return (
@@ -192,9 +215,28 @@ export interface PortholeFrameProps {
192
215
  readonly generation?: number;
193
216
  /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
194
217
  readonly eager?: boolean;
218
+ /**
219
+ * Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
220
+ * growing with the content: an artifact viewer's page. A url-mode app always fills.
221
+ */
222
+ readonly fill?: boolean;
195
223
  readonly onInitialized?: () => void;
196
224
  readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
197
- readonly onError?: (message: string) => void;
225
+ /**
226
+ * What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
227
+ * away): the agent's to hear and fix.
228
+ */
229
+ readonly onContentError?: (message: string) => void;
230
+ /**
231
+ * What failed on the host's side: its locator (`locate`), a display that couldn't be made
232
+ * (`mount`), an annotation call refused (`annotations`). Never the agent's.
233
+ */
234
+ readonly onHostError?: (error: {
235
+ readonly message: string;
236
+ readonly kind: HostErrorKind;
237
+ }) => void;
238
+ /** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
239
+ readonly onError?: (message: string, source: "content" | "host") => void;
198
240
  /** The content navigated its frame away; it was started again (or stopped, after a few). */
199
241
  readonly onNavigated?: () => void;
200
242
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
@@ -223,8 +265,14 @@ export interface PortholeFrameProps {
223
265
  readonly annotating?: AnnotateMode | null;
224
266
  /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
225
267
  readonly annotations?: readonly AnnotationMark[];
226
- /** The mark to bring into view and make the active one; each new id focuses it. */
227
- readonly focusedAnnotation?: string | null;
268
+ /**
269
+ * The mark to bring into view and make the active one; each new id focuses it. To focus the
270
+ * same mark again (a second click on its comment), give `{ id, key }` with a new key.
271
+ */
272
+ readonly focusedAnnotation?:
273
+ | string
274
+ | { readonly id: string; readonly key: string | number }
275
+ | null;
228
276
  /**
229
277
  * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
230
278
  * pick, where marks are. The content's word: UI hints, never permissions.
@@ -343,6 +391,8 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
343
391
  style={{
344
392
  position: "relative",
345
393
  width: "100%",
394
+ // A display that fills takes its parent's height (the host sizes the parent, or this box).
395
+ ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
346
396
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
347
397
  ...props.style,
348
398
  }}
@@ -380,8 +430,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
380
430
  const [ready, setReady] = useState(false);
381
431
  // The height this display started at: later reports don't make it start again.
382
432
  const [startHeight] = useState(initialHeightPx);
383
- const { config, document: view, instance, content, partial, scheme } = frame;
384
- const fills = config.mode === "url";
433
+ const { document: view, content, partial, scheme } = frame;
434
+ // By value: a config or instance made inline (each render) is the same display.
435
+ const config = useByValue(frame.config);
436
+ const instance = useByValue(frame.instance);
437
+ const fills = config.mode === "url" || frame.fill === true;
385
438
 
386
439
  // The latest props, read when the display calls back (never during render).
387
440
  const latest = useEffectEvent(() => frame);
@@ -398,9 +451,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
398
451
  frame.onResize?.(size);
399
452
  return;
400
453
  }
401
- case "error":
402
- frame.onError?.((detail as DisplayEvents["error"]).message);
454
+ case "contentError":
455
+ frame.onContentError?.((detail as DisplayEvents["contentError"]).message);
456
+ return;
457
+ case "hostError":
458
+ frame.onHostError?.(detail as DisplayEvents["hostError"]);
403
459
  return;
460
+ case "error": {
461
+ const { message, source } = detail as DisplayEvents["error"];
462
+ frame.onError?.(message, source);
463
+ return;
464
+ }
404
465
  case "navigated":
405
466
  frame.onNavigated?.();
406
467
  return;
@@ -468,7 +529,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
468
529
  : {}),
469
530
  theme: (each) => latest().theme?.(each) ?? {},
470
531
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
471
- ...(page.locator === undefined ? {} : { locator: page.locator }),
532
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
533
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
472
534
  }).mount(container, {
473
535
  displayId: first.displayId,
474
536
  title: first.title,
@@ -476,9 +538,10 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
476
538
  initialHeightPx: startHeight,
477
539
  ...(view === undefined ? {} : { document: view }),
478
540
  ...(instance === undefined ? {} : { instance }),
541
+ ...(fills ? { fill: true } : {}),
479
542
  });
480
543
  } catch (error) {
481
- first.onError?.(error instanceof Error ? error.message : String(error));
544
+ hostFailed(first, error, "mount");
482
545
  return;
483
546
  }
484
547
  handle.current = display;
@@ -491,6 +554,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
491
554
  "muted",
492
555
  "outcome",
493
556
  "annotation",
557
+ "contentError",
558
+ "hostError",
494
559
  ];
495
560
  for (const event of events) {
496
561
  display.on(event, (detail) => notify(event, detail));
@@ -507,7 +572,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
507
572
  };
508
573
  // A new display only for a new config, document, instance or page (or generation, by key);
509
574
  // content, theme, handlers, id and title are fed to the running one.
510
- }, [config, view, instance, page, startHeight]);
575
+ }, [config, view, instance, page, startHeight, fills]);
511
576
 
512
577
  useEffect(() => {
513
578
  if (content !== undefined) {
@@ -516,7 +581,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
516
581
  }, [content, partial]);
517
582
  // Annotations follow their props (a new display gets them all when it is made, above).
518
583
  const annotates = config.capabilities.annotate !== undefined;
519
- const { annotating, annotations, focusedAnnotation } = frame;
584
+ const { annotating } = frame;
585
+ const focusedAnnotation = focusOf(frame.focusedAnnotation);
586
+ const focusToken =
587
+ focusedAnnotation === null
588
+ ? ""
589
+ : typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
590
+ ? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
591
+ : JSON.stringify([focusedAnnotation]);
592
+ // By value: the same marks given again don't mark them again.
593
+ const annotations = useByValue(frame.annotations);
520
594
  const mode =
521
595
  annotating === undefined || annotating === null
522
596
  ? ""
@@ -543,10 +617,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
543
617
  }, [annotates, annotations]);
544
618
  useEffect(() => {
545
619
  const display = handle.current;
546
- if (annotates && display !== null && typeof focusedAnnotation === "string") {
547
- attempt(() => display.annotations.focus(focusedAnnotation), latest());
620
+ const id = focusOf(latest().focusedAnnotation);
621
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
622
+ attempt(() => display.annotations.focus(id), latest());
548
623
  }
549
- }, [annotates, focusedAnnotation]);
624
+ }, [annotates, focusToken]);
550
625
  useEffect(() => {
551
626
  handle.current?.setTheme(scheme);
552
627
  }, [scheme]);
@@ -577,12 +652,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
577
652
  );
578
653
  }
579
654
 
580
- /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
655
+ /** Tells the frame's props what failed on the host's side. */
656
+ function hostFailed(frame: PortholeFrameProps, error: unknown, kind: HostErrorKind): void {
657
+ const message = error instanceof Error ? error.message : String(error);
658
+ frame.onHostError?.({ message, kind });
659
+ frame.onError?.(message, "host");
660
+ }
661
+
662
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
581
663
  function attempt(run: () => void, frame: PortholeFrameProps): void {
582
664
  try {
583
665
  run();
584
666
  } catch (error) {
585
- frame.onError?.(error instanceof Error ? error.message : String(error));
667
+ hostFailed(frame, error, "annotations");
586
668
  }
587
669
  }
588
670
 
@@ -592,11 +674,31 @@ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): vo
592
674
  if (frame.annotations !== undefined && frame.annotations.length > 0) {
593
675
  display.annotations.show(frame.annotations);
594
676
  }
595
- if (typeof frame.focusedAnnotation === "string") {
596
- display.annotations.focus(frame.focusedAnnotation);
677
+ const focused = focusOf(frame.focusedAnnotation);
678
+ if (focused !== null) {
679
+ display.annotations.focus(focused);
597
680
  }
598
681
  if (frame.annotating !== undefined && frame.annotating !== null) {
599
682
  display.annotations.start(frame.annotating);
600
683
  }
601
684
  }, frame);
602
685
  }
686
+
687
+ /**
688
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
689
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
690
+ * only when it really changes.
691
+ */
692
+ function useByValue<T>(value: T): T {
693
+ const key = value === undefined ? "" : JSON.stringify(value);
694
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)) as T, [key]);
695
+ }
696
+
697
+ /** The id `focusedAnnotation` names, if any. */
698
+ function focusOf(value: PortholeFrameProps["focusedAnnotation"]): string | null {
699
+ return typeof value === "string"
700
+ ? value
701
+ : value === null || value === undefined
702
+ ? null
703
+ : value.id;
704
+ }