@botiverse/porthole 0.1.3 → 0.1.5

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 (51) hide show
  1. package/dist/compile/policy.d.ts +10 -2
  2. package/dist/compile/policy.js +18 -4
  3. package/dist/config/schema.d.ts +20 -0
  4. package/dist/config/schema.js +2 -0
  5. package/dist/config/validate.js +174 -2
  6. package/dist/conformance/behavior.js +14 -1
  7. package/dist/conformance/kit.d.ts +5 -0
  8. package/dist/conformance/kit.js +11 -0
  9. package/dist/conformance/probes.js +19 -2
  10. package/dist/conformance/served.d.ts +8 -1
  11. package/dist/conformance/served.js +12 -8
  12. package/dist/guide/guide.d.ts +2 -0
  13. package/dist/guide/guide.js +22 -5
  14. package/dist/host/create-host.js +24 -7
  15. package/dist/host/types.d.ts +44 -2
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +2 -1
  18. package/dist/presets/presets.d.ts +3 -1
  19. package/dist/presets/presets.js +5 -0
  20. package/dist/react/frame.d.ts +34 -4
  21. package/dist/react/frame.js +91 -22
  22. package/dist/server/naming.d.ts +6 -0
  23. package/dist/server/naming.js +38 -0
  24. package/dist/server/pages.js +2 -0
  25. package/dist/server/server.js +53 -17
  26. package/dist/server/types.d.ts +6 -3
  27. package/dist/shell/annotate.js +31 -0
  28. package/dist/shell/document.d.ts +6 -0
  29. package/dist/shell/document.js +8 -2
  30. package/dist/shell/runtime.js +47 -2
  31. package/package.json +1 -1
  32. package/src/compile/policy.ts +22 -6
  33. package/src/config/schema.ts +23 -0
  34. package/src/config/validate.ts +193 -2
  35. package/src/conformance/behavior.ts +20 -1
  36. package/src/conformance/kit.ts +16 -0
  37. package/src/conformance/probes.ts +21 -2
  38. package/src/conformance/served.ts +24 -9
  39. package/src/guide/guide.ts +25 -4
  40. package/src/host/create-host.ts +26 -7
  41. package/src/host/types.ts +39 -2
  42. package/src/index.ts +4 -0
  43. package/src/presets/presets.ts +8 -0
  44. package/src/react/frame.tsx +136 -27
  45. package/src/server/naming.ts +41 -0
  46. package/src/server/pages.ts +2 -0
  47. package/src/server/server.ts +65 -20
  48. package/src/server/types.ts +6 -3
  49. package/src/shell/annotate.ts +31 -0
  50. package/src/shell/document.ts +15 -2
  51. package/src/shell/runtime.ts +47 -2
@@ -57,6 +57,16 @@ export function createHost(config, options = {}) {
57
57
  listener(detail);
58
58
  }
59
59
  };
60
+ // What the content did wrong (the agent's to hear and fix), and what failed on the host's
61
+ // side (its own to handle), each on its own event and on `error` with its source.
62
+ const contentError = (message) => {
63
+ emit("contentError", { message });
64
+ emit("error", { message, source: "content" });
65
+ };
66
+ const hostError = (message, kind) => {
67
+ emit("hostError", { message, kind });
68
+ emit("error", { message, source: "host" });
69
+ };
60
70
  let scheme = mount.scheme;
61
71
  let height = mount.initialHeightPx ?? 0;
62
72
  let surface = null;
@@ -228,7 +238,7 @@ export function createHost(config, options = {}) {
228
238
  return;
229
239
  }
230
240
  case "error":
231
- emit("error", { message: event.message });
241
+ contentError(event.message);
232
242
  return;
233
243
  case "refused":
234
244
  emit("refused", { capability: event.capability, refusal: event.refusal });
@@ -260,8 +270,9 @@ export function createHost(config, options = {}) {
260
270
  allow: compiled.allow,
261
271
  referrerPolicy: compiled.referrerPolicy,
262
272
  colorScheme: scheme,
263
- // An app reports no size: its frame fills the host's container, at least the minimum.
264
- ...(speaks
273
+ // An app reports no size: its frame fills the host's container, at least the minimum;
274
+ // so does any display the host mounts to fill (its content scrolls inside).
275
+ ...(speaks && mount.fill !== true
265
276
  ? { initialHeightPx: height }
266
277
  : { fill: true, initialHeightPx: config.limits.minHeightPx }),
267
278
  });
@@ -327,9 +338,7 @@ export function createHost(config, options = {}) {
327
338
  }
328
339
  else {
329
340
  stop();
330
- emit("error", {
331
- message: "The content kept navigating its frame away; it was stopped.",
332
- });
341
+ contentError("The content kept navigating its frame away; it was stopped.");
333
342
  }
334
343
  });
335
344
  };
@@ -345,6 +354,7 @@ export function createHost(config, options = {}) {
345
354
  html: compiled.shellDocument({
346
355
  origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
347
356
  title: mount.title,
357
+ ...nonceOf(options.nonce),
348
358
  ...(mount.document === undefined ? {} : { document: mount.document }),
349
359
  }),
350
360
  });
@@ -365,7 +375,9 @@ export function createHost(config, options = {}) {
365
375
  }
366
376
  }, (error) => {
367
377
  if (mine === generation && !controller.signal.aborted) {
368
- emit("error", { message: error instanceof Error ? error.message : String(error) });
378
+ // The host's own failure (its backend down, access gone): never the content's.
379
+ hostError(error instanceof Error ? error.message : String(error), "locate");
380
+ emit("outcome", { state: "blocked", reason: "locate" });
369
381
  }
370
382
  });
371
383
  };
@@ -431,3 +443,8 @@ export function createHost(config, options = {}) {
431
443
  },
432
444
  };
433
445
  }
446
+ /** The host's nonce option, read now (`HostOptions.nonce`). */
447
+ function nonceOf(nonce) {
448
+ const value = typeof nonce === "function" ? nonce() : nonce;
449
+ return value === undefined || value === "" ? {} : { nonce: value };
450
+ }
@@ -35,6 +35,13 @@ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
35
35
  readonly platform?: PlatformHost;
36
36
  /** Required for domain and external isolation: where an instance's frame loads from. */
37
37
  readonly locator?: InstanceLocator;
38
+ /**
39
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): an opaque frame
40
+ * (`srcdoc`) inherits the page's policy, so the shell and the content's scripts carry it. A
41
+ * function is read each time a frame starts (a page whose nonce changes). Served frames inherit
42
+ * nothing and need none.
43
+ */
44
+ readonly nonce?: string | (() => string | undefined);
38
45
  }
39
46
  export interface MountOptions {
40
47
  /** The host's id for this display: what its height is remembered by, and calls name. */
@@ -50,7 +57,21 @@ 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
  }
67
+ /** What failed on the host's side (`hostError`). */
68
+ export type HostErrorKind =
69
+ /** The locator rejected: the host's backend didn't give a location. */
70
+ "locate"
71
+ /** A display couldn't be made (the React binding: a bad config, a missing handler). */
72
+ | "mount"
73
+ /** An annotation call was refused (an invalid mark, an unknown id). */
74
+ | "annotations";
54
75
  /** What a display reports, by event name. */
55
76
  /** Why a display's frame showed nothing of its content (`FrameOutcome`). */
56
77
  export type FrameBlock =
@@ -63,7 +84,9 @@ export type FrameBlock =
63
84
  /** Its app didn't answer. */
64
85
  | "upstream"
65
86
  /** The host's own check refused it (revoked, no longer a member): `code` says which. */
66
- | "denied" | "not-found" | "error";
87
+ | "denied"
88
+ /** The host's locator failed (its backend down, access gone): see the `hostError` event. */
89
+ | "locate" | "not-found" | "error";
67
90
  /**
68
91
  * How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
69
92
  * app's document loaded), it was blocked (and why), or nothing settled in
@@ -92,9 +115,28 @@ export interface DisplayEvents {
92
115
  readonly height: number;
93
116
  readonly width?: number;
94
117
  };
95
- /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
118
+ /**
119
+ * What the content did wrong: what it threw, what the sandbox blocked (its own words:
120
+ * untrusted), or that it kept navigating its frame away. The agent's to hear and fix.
121
+ */
122
+ readonly contentError: {
123
+ readonly message: string;
124
+ };
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: {
130
+ readonly message: string;
131
+ readonly kind: HostErrorKind;
132
+ };
133
+ /**
134
+ * Both of the above, with their `source`: for a host that listened here before they were
135
+ * split (0.1.3). Prefer `contentError` and `hostError`.
136
+ */
96
137
  readonly error: {
97
138
  readonly message: string;
139
+ readonly source: "content" | "host";
98
140
  };
99
141
  /** The content navigated its frame away; the frame was started again from its content. */
100
142
  readonly navigated: undefined;
package/dist/index.d.ts CHANGED
@@ -3,14 +3,15 @@
3
3
  * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
4
  */
5
5
  export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
6
- export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
6
+ export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
7
+ export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
7
8
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
8
9
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
9
10
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
10
11
  export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
11
12
  export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
12
13
  export { compile, type CompiledConfig } from "./compile/compile.ts";
13
- export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
14
+ export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
14
15
  export { shellPlan, type ShellPlan } from "./shell/plan.ts";
15
16
  export type { ShellDocumentOptions } from "./shell/document.ts";
16
17
  export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  // The config and its vocabulary.
6
6
  export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
7
+ export { PASSIVE_KINDS } from "./config/schema.js";
7
8
  export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
8
9
  export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
9
10
  export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
@@ -12,7 +13,7 @@ export { FRAME_STATUS_METHOD } from "./isolation/types.js";
12
13
  export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
13
14
  // The compiler.
14
15
  export { compile } from "./compile/compile.js";
15
- export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, } from "./compile/policy.js";
16
+ export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
16
17
  export { shellPlan } from "./shell/plan.js";
17
18
  export { generateGuide, generateToolDescription, } from "./guide/guide.js";
18
19
  export { GUIDE_EXAMPLES } from "./guide/examples.js";
@@ -1,5 +1,5 @@
1
1
  import type { CapabilityName } from "../config/capabilities.ts";
2
- import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
2
+ import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
3
3
  /**
4
4
  * The preset policy layer (P001): named configs with safe defaults. Every preset starts with
5
5
  * egress closed and only the capabilities that carry nothing out of the frame (its size, its
@@ -30,6 +30,8 @@ export interface PresetOptions {
30
30
  readonly isolation?: Exclude<IsolationKind, "external">;
31
31
  readonly contentOrigins?: readonly string[];
32
32
  readonly embedder?: EmbedderConfig;
33
+ /** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
34
+ readonly storage?: StorageConfig;
33
35
  }
34
36
  /** The url preset's options: where the app is served, and the embedding page's extra frames. */
35
37
  export interface UrlPresetOptions {
@@ -60,6 +60,10 @@ const mergeEgress = (base, extra) => {
60
60
  merged[kind] = origins;
61
61
  }
62
62
  }
63
+ const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
64
+ if (passive.length > 0) {
65
+ merged.anyHttps = passive;
66
+ }
63
67
  return merged;
64
68
  };
65
69
  /** The default variables, with the host's changes and additions, less those it dropped (null). */
@@ -102,6 +106,7 @@ function build(mode, defaults, options) {
102
106
  guide: options.guide ?? {},
103
107
  contentOrigins: options.contentOrigins ?? [],
104
108
  embedder: options.embedder ?? {},
109
+ ...(options.storage === undefined ? {} : { storage: options.storage }),
105
110
  });
106
111
  }
107
112
  export const presets = {
@@ -3,7 +3,7 @@ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate
3
3
  import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
4
4
  import type { CapabilityName } from "../config/capabilities.ts";
5
5
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
6
- import type { FrameOutcome } from "../host/types.ts";
6
+ import type { FrameOutcome, HostErrorKind } from "../host/types.ts";
7
7
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
8
8
  /** The height a display holds before it has reported its own. */
9
9
  export declare const PLACEHOLDER_HEIGHT_PX = 96;
@@ -24,6 +24,11 @@ export interface PortholeProviderProps {
24
24
  readonly dialogClassName?: string;
25
25
  /** A class for the default copy toast, to restyle it. */
26
26
  readonly toastClassName?: string;
27
+ /**
28
+ * The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
29
+ * policy, so their shell carries it (P054).
30
+ */
31
+ readonly nonce?: string;
27
32
  readonly children?: ReactNode;
28
33
  }
29
34
  export declare function PortholeProvider(props: PortholeProviderProps): ReactElement;
@@ -60,12 +65,31 @@ export interface PortholeFrameProps {
60
65
  readonly generation?: number;
61
66
  /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
62
67
  readonly eager?: boolean;
68
+ /**
69
+ * Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
70
+ * growing with the content: an artifact viewer's page. A url-mode app always fills.
71
+ */
72
+ readonly fill?: boolean;
63
73
  readonly onInitialized?: () => void;
64
74
  readonly onResize?: (size: {
65
75
  readonly height: number;
66
76
  readonly width?: number;
67
77
  }) => void;
68
- readonly onError?: (message: string) => void;
78
+ /**
79
+ * What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
80
+ * away): the agent's to hear and fix.
81
+ */
82
+ readonly onContentError?: (message: string) => void;
83
+ /**
84
+ * What failed on the host's side: its locator (`locate`), a display that couldn't be made
85
+ * (`mount`), an annotation call refused (`annotations`). Never the agent's.
86
+ */
87
+ readonly onHostError?: (error: {
88
+ readonly message: string;
89
+ readonly kind: HostErrorKind;
90
+ }) => void;
91
+ /** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
92
+ readonly onError?: (message: string, source: "content" | "host") => void;
69
93
  /** The content navigated its frame away; it was started again (or stopped, after a few). */
70
94
  readonly onNavigated?: () => void;
71
95
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
@@ -94,8 +118,14 @@ export interface PortholeFrameProps {
94
118
  readonly annotating?: AnnotateMode | null;
95
119
  /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
96
120
  readonly annotations?: readonly AnnotationMark[];
97
- /** The mark to bring into view and make the active one; each new id focuses it. */
98
- readonly focusedAnnotation?: string | null;
121
+ /**
122
+ * The mark to bring into view and make the active one; each new id focuses it. To focus the
123
+ * same mark again (a second click on its comment), give `{ id, key }` with a new key.
124
+ */
125
+ readonly focusedAnnotation?: string | {
126
+ readonly id: string;
127
+ readonly key: string | number;
128
+ } | null;
99
129
  /**
100
130
  * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
101
131
  * pick, where marks are. The content's word: UI hints, never permissions.
@@ -39,15 +39,27 @@ export function PortholeProvider(props) {
39
39
  return copied;
40
40
  }, []);
41
41
  const closeNotice = useCallback(() => setNotice(null), []);
42
- const { hostInfo, locator } = props;
42
+ // Compared by value, and read when used: an inline hostInfo or locator doesn't start every
43
+ // frame again on each render.
44
+ const hostInfo = useByValue(props.hostInfo);
45
+ const ownLocator = useRef(props.locator);
46
+ useLayoutEffect(() => {
47
+ ownLocator.current = props.locator;
48
+ });
49
+ const locate = useCallback((instance, signal) => ownLocator.current === undefined
50
+ ? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
51
+ : ownLocator.current.locate(instance, signal), []);
52
+ const locates = props.locator !== undefined;
53
+ const { nonce } = props;
43
54
  const value = useMemo(() => ({
44
55
  live,
45
56
  heights,
46
57
  confirm,
47
58
  copyText,
48
59
  ...(hostInfo === undefined ? {} : { hostInfo }),
49
- ...(locator === undefined ? {} : { locator }),
50
- }), [live, heights, confirm, copyText, hostInfo, locator]);
60
+ ...(locates ? { locate } : {}),
61
+ ...(nonce === undefined ? {} : { nonce }),
62
+ }), [live, heights, confirm, copyText, hostInfo, locates, locate, nonce]);
51
63
  const current = queue[0] ?? null;
52
64
  return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
53
65
  // A yes runs the effect inside this very click (a window it opens is the person's).
@@ -134,6 +146,8 @@ export function PortholeFrame(props) {
134
146
  return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
135
147
  position: "relative",
136
148
  width: "100%",
149
+ // A display that fills takes its parent's height (the host sizes the parent, or this box).
150
+ ...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
137
151
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
138
152
  ...props.style,
139
153
  }, children: state === "running" ? (_jsx(Mount, { frame: props, page: page, initialHeightPx: height, placeholder: placeholder, run: run }, String(props.generation ?? 0))) : (placeholder({ state, title: props.title, run })) }));
@@ -145,8 +159,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
145
159
  const [ready, setReady] = useState(false);
146
160
  // The height this display started at: later reports don't make it start again.
147
161
  const [startHeight] = useState(initialHeightPx);
148
- const { config, document: view, instance, content, partial, scheme } = frame;
149
- const fills = config.mode === "url";
162
+ const { document: view, content, partial, scheme } = frame;
163
+ // By value: a config or instance made inline (each render) is the same display.
164
+ const config = useByValue(frame.config);
165
+ const instance = useByValue(frame.instance);
166
+ const fills = config.mode === "url" || frame.fill === true;
150
167
  // The latest props, read when the display calls back (never during render).
151
168
  const latest = useEffectEvent(() => frame);
152
169
  const notify = useEffectEvent((event, detail) => {
@@ -161,9 +178,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
161
178
  frame.onResize?.(size);
162
179
  return;
163
180
  }
164
- case "error":
165
- frame.onError?.(detail.message);
181
+ case "contentError":
182
+ frame.onContentError?.(detail.message);
183
+ return;
184
+ case "hostError":
185
+ frame.onHostError?.(detail);
166
186
  return;
187
+ case "error": {
188
+ const { message, source } = detail;
189
+ frame.onError?.(message, source);
190
+ return;
191
+ }
167
192
  case "navigated":
168
193
  frame.onNavigated?.();
169
194
  return;
@@ -220,7 +245,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
220
245
  : {}),
221
246
  theme: (each) => latest().theme?.(each) ?? {},
222
247
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
223
- ...(page.locator === undefined ? {} : { locator: page.locator }),
248
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
249
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
224
250
  }).mount(container, {
225
251
  displayId: first.displayId,
226
252
  title: first.title,
@@ -228,10 +254,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
228
254
  initialHeightPx: startHeight,
229
255
  ...(view === undefined ? {} : { document: view }),
230
256
  ...(instance === undefined ? {} : { instance }),
257
+ ...(fills ? { fill: true } : {}),
231
258
  });
232
259
  }
233
260
  catch (error) {
234
- first.onError?.(error instanceof Error ? error.message : String(error));
261
+ hostFailed(first, error, "mount");
235
262
  return;
236
263
  }
237
264
  handle.current = display;
@@ -244,6 +271,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
244
271
  "muted",
245
272
  "outcome",
246
273
  "annotation",
274
+ "contentError",
275
+ "hostError",
247
276
  ];
248
277
  for (const event of events) {
249
278
  display.on(event, (detail) => notify(event, detail));
@@ -260,7 +289,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
260
289
  };
261
290
  // A new display only for a new config, document, instance or page (or generation, by key);
262
291
  // content, theme, handlers, id and title are fed to the running one.
263
- }, [config, view, instance, page, startHeight]);
292
+ }, [config, view, instance, page, startHeight, fills]);
264
293
  useEffect(() => {
265
294
  if (content !== undefined) {
266
295
  handle.current?.setContent(content, { partial: partial === true });
@@ -268,7 +297,15 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
268
297
  }, [content, partial]);
269
298
  // Annotations follow their props (a new display gets them all when it is made, above).
270
299
  const annotates = config.capabilities.annotate !== undefined;
271
- const { annotating, annotations, focusedAnnotation } = frame;
300
+ const { annotating } = frame;
301
+ const focusedAnnotation = focusOf(frame.focusedAnnotation);
302
+ const focusToken = focusedAnnotation === null
303
+ ? ""
304
+ : typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
305
+ ? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
306
+ : JSON.stringify([focusedAnnotation]);
307
+ // By value: the same marks given again don't mark them again.
308
+ const annotations = useByValue(frame.annotations);
272
309
  const mode = annotating === undefined || annotating === null
273
310
  ? ""
274
311
  : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
@@ -290,19 +327,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
290
327
  }, [annotates, annotations]);
291
328
  useEffect(() => {
292
329
  const display = handle.current;
293
- if (annotates && display !== null && typeof focusedAnnotation === "string") {
294
- attempt(() => display.annotations.focus(focusedAnnotation), latest());
330
+ const id = focusOf(latest().focusedAnnotation);
331
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
332
+ attempt(() => display.annotations.focus(id), latest());
295
333
  }
296
- }, [annotates, focusedAnnotation]);
334
+ }, [annotates, focusToken]);
335
+ // A flip or a restyle after the display was made: its first scheme and values went with it.
336
+ const [mountScheme] = useState(scheme);
337
+ const flipped = useRef(false);
297
338
  useEffect(() => {
298
- handle.current?.setTheme(scheme);
299
- }, [scheme]);
339
+ if (flipped.current || scheme !== mountScheme) {
340
+ flipped.current = true;
341
+ handle.current?.setTheme(scheme);
342
+ }
343
+ }, [scheme, mountScheme]);
300
344
  const { themeKey } = frame;
345
+ const [mountThemeKey] = useState(themeKey);
301
346
  useEffect(() => {
302
- if (themeKey !== undefined) {
347
+ if (themeKey !== undefined && themeKey !== mountThemeKey) {
303
348
  handle.current?.refreshTheme();
304
349
  }
305
- }, [themeKey]);
350
+ }, [themeKey, mountThemeKey]);
306
351
  return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
307
352
  position: "relative",
308
353
  // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
@@ -310,13 +355,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
310
355
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
311
356
  }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
312
357
  }
313
- /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
358
+ /** Tells the frame's props what failed on the host's side. */
359
+ function hostFailed(frame, error, kind) {
360
+ const message = error instanceof Error ? error.message : String(error);
361
+ frame.onHostError?.({ message, kind });
362
+ frame.onError?.(message, "host");
363
+ }
364
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
314
365
  function attempt(run, frame) {
315
366
  try {
316
367
  run();
317
368
  }
318
369
  catch (error) {
319
- frame.onError?.(error instanceof Error ? error.message : String(error));
370
+ hostFailed(frame, error, "annotations");
320
371
  }
321
372
  }
322
373
  /** A new display's annotations, from the props as they are. */
@@ -325,11 +376,29 @@ function applyAnnotations(display, frame) {
325
376
  if (frame.annotations !== undefined && frame.annotations.length > 0) {
326
377
  display.annotations.show(frame.annotations);
327
378
  }
328
- if (typeof frame.focusedAnnotation === "string") {
329
- display.annotations.focus(frame.focusedAnnotation);
379
+ const focused = focusOf(frame.focusedAnnotation);
380
+ if (focused !== null) {
381
+ display.annotations.focus(focused);
330
382
  }
331
383
  if (frame.annotating !== undefined && frame.annotating !== null) {
332
384
  display.annotations.start(frame.annotating);
333
385
  }
334
386
  }, frame);
335
387
  }
388
+ /**
389
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
390
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
391
+ * only when it really changes.
392
+ */
393
+ function useByValue(value) {
394
+ const key = value === undefined ? "" : JSON.stringify(value);
395
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)), [key]);
396
+ }
397
+ /** The id `focusedAnnotation` names, if any. */
398
+ function focusOf(value) {
399
+ return typeof value === "string"
400
+ ? value
401
+ : value === null || value === undefined
402
+ ? null
403
+ : value.id;
404
+ }
@@ -15,3 +15,9 @@ export declare function isHostLabel(label: unknown): label is string;
15
15
  * than being folded into another scope's name.
16
16
  */
17
17
  export declare function prefixedName(prefix: string): (scope: string) => string;
18
+ /**
19
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
20
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
21
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
22
+ */
23
+ export declare function reservedLabels(reserved: readonly string[] | ((label: string) => boolean) | undefined): (label: string) => boolean;
@@ -32,3 +32,41 @@ export function prefixedName(prefix) {
32
32
  return label;
33
33
  };
34
34
  }
35
+ const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
36
+ /**
37
+ * Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
38
+ * (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
39
+ * reserves the label (fail closed: the request goes to the host's routes, never served as ours).
40
+ */
41
+ export function reservedLabels(reserved) {
42
+ if (reserved === undefined) {
43
+ return () => false;
44
+ }
45
+ if (typeof reserved === "function") {
46
+ return (label) => {
47
+ try {
48
+ return reserved(label);
49
+ }
50
+ catch {
51
+ return true;
52
+ }
53
+ };
54
+ }
55
+ if (!Array.isArray(reserved)) {
56
+ throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
57
+ }
58
+ const whole = new Set();
59
+ const prefixes = [];
60
+ for (const entry of reserved) {
61
+ if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
62
+ prefixes.push(entry.slice(0, -1));
63
+ }
64
+ else if (isHostLabel(entry)) {
65
+ whole.add(entry);
66
+ }
67
+ else {
68
+ throw new TypeError(`content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`);
69
+ }
70
+ }
71
+ return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
72
+ }
@@ -77,6 +77,8 @@ const pageHeaders = (frameAncestors, connect) => ({
77
77
  "content-security-policy": policy(frameAncestors, connect),
78
78
  "referrer-policy": "no-referrer",
79
79
  "x-content-type-options": "nosniff",
80
+ // Content origins are behind tickets: nothing on them is for search engines.
81
+ "x-robots-tag": "noindex, nofollow",
80
82
  });
81
83
  export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = [], code) {
82
84
  const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}" data-porthole-reason="${escape(reason)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;