@botiverse/porthole 0.1.2 → 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 (84) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +10 -3
  8. package/dist/compile/policy.js +16 -5
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/config/schema.d.ts +11 -0
  12. package/dist/config/validate.js +12 -0
  13. package/dist/conformance/clipboard.d.ts +2 -0
  14. package/dist/conformance/clipboard.js +167 -0
  15. package/dist/conformance/kit.d.ts +1 -1
  16. package/dist/conformance/probes.js +2 -0
  17. package/dist/conformance/served.d.ts +8 -1
  18. package/dist/conformance/served.js +12 -8
  19. package/dist/guide/guide.js +11 -5
  20. package/dist/host/clipboard.d.ts +11 -0
  21. package/dist/host/clipboard.js +43 -0
  22. package/dist/host/create-host.js +165 -7
  23. package/dist/host/types.d.ts +68 -2
  24. package/dist/index.d.ts +8 -5
  25. package/dist/index.js +7 -3
  26. package/dist/mcp/adapter.js +10 -1
  27. package/dist/presets/presets.d.ts +3 -1
  28. package/dist/presets/presets.js +1 -0
  29. package/dist/protocol/mcp-apps.d.ts +5 -0
  30. package/dist/protocol/mcp-apps.js +5 -0
  31. package/dist/protocol/shell-adapter.js +6 -2
  32. package/dist/react/dialog.js +2 -0
  33. package/dist/react/frame.d.ts +50 -2
  34. package/dist/react/frame.js +153 -11
  35. package/dist/react/index.d.ts +2 -1
  36. package/dist/react/index.js +2 -1
  37. package/dist/react/toast.d.ts +33 -0
  38. package/dist/react/toast.js +42 -0
  39. package/dist/server/naming.d.ts +6 -0
  40. package/dist/server/naming.js +38 -0
  41. package/dist/server/pages.js +2 -0
  42. package/dist/server/server.js +49 -17
  43. package/dist/server/types.d.ts +6 -3
  44. package/dist/shell/annotate.d.ts +20 -0
  45. package/dist/shell/annotate.js +700 -0
  46. package/dist/shell/document.d.ts +6 -0
  47. package/dist/shell/document.js +12 -3
  48. package/dist/shell/plan.d.ts +2 -0
  49. package/dist/shell/plan.js +2 -1
  50. package/dist/shell/runtime.d.ts +4 -1
  51. package/dist/shell/runtime.js +71 -3
  52. package/package.json +1 -1
  53. package/src/annotate/types.ts +126 -0
  54. package/src/annotate/validate.ts +186 -0
  55. package/src/bridge/bridge.ts +13 -0
  56. package/src/compile/policy.ts +17 -5
  57. package/src/config/capabilities.ts +33 -3
  58. package/src/config/schema.ts +12 -0
  59. package/src/config/validate.ts +15 -0
  60. package/src/conformance/clipboard.ts +184 -0
  61. package/src/conformance/kit.ts +1 -1
  62. package/src/conformance/probes.ts +2 -0
  63. package/src/conformance/served.ts +24 -9
  64. package/src/guide/guide.ts +22 -5
  65. package/src/host/clipboard.ts +41 -0
  66. package/src/host/create-host.ts +181 -7
  67. package/src/host/types.ts +64 -2
  68. package/src/index.ts +24 -0
  69. package/src/mcp/adapter.ts +10 -1
  70. package/src/presets/presets.ts +4 -0
  71. package/src/protocol/mcp-apps.ts +6 -0
  72. package/src/protocol/shell-adapter.ts +11 -2
  73. package/src/react/dialog.tsx +2 -0
  74. package/src/react/frame.tsx +231 -13
  75. package/src/react/index.ts +9 -1
  76. package/src/react/toast.tsx +96 -0
  77. package/src/server/naming.ts +41 -0
  78. package/src/server/pages.ts +2 -0
  79. package/src/server/server.ts +61 -20
  80. package/src/server/types.ts +6 -3
  81. package/src/shell/annotate.ts +700 -0
  82. package/src/shell/document.ts +21 -3
  83. package/src/shell/plan.ts +4 -1
  84. package/src/shell/runtime.ts +71 -3
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
+ import { writeClipboard } from "../host/clipboard.js";
3
4
  import { createHost } from "../host/create-host.js";
4
5
  import { ConfirmDialog } from "./dialog.js";
5
6
  import { createLiveFrames } from "./live.js";
7
+ import { CopyToast } from "./toast.js";
6
8
  /**
7
9
  * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
8
10
  * run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
@@ -28,14 +30,36 @@ export function PortholeProvider(props) {
28
30
  const confirm = useCallback((request, proceed) => own.current !== undefined
29
31
  ? own.current(request, proceed)
30
32
  : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
31
- const { hostInfo, locator } = props;
33
+ // The default copyText: the host's page writes the clipboard (in the frame's click, which
34
+ // activated this page too) and shows the person what went on it, or offers to copy it.
35
+ const [notice, setNotice] = useState(null);
36
+ const copyText = useCallback(async ({ text }, context) => {
37
+ const copied = await writeClipboard(text, globalThis.document);
38
+ setNotice({ text, copied, title: context.title });
39
+ return copied;
40
+ }, []);
41
+ const closeNotice = useCallback(() => setNotice(null), []);
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;
32
54
  const value = useMemo(() => ({
33
55
  live,
34
56
  heights,
35
57
  confirm,
58
+ copyText,
36
59
  ...(hostInfo === undefined ? {} : { hostInfo }),
37
- ...(locator === undefined ? {} : { locator }),
38
- }), [live, heights, confirm, hostInfo, locator]);
60
+ ...(locates ? { locate } : {}),
61
+ ...(nonce === undefined ? {} : { nonce }),
62
+ }), [live, heights, confirm, copyText, hostInfo, locates, locate, nonce]);
39
63
  const current = queue[0] ?? null;
40
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) => {
41
65
  // A yes runs the effect inside this very click (a window it opens is the person's).
@@ -44,7 +68,10 @@ export function PortholeProvider(props) {
44
68
  }
45
69
  current?.resolve(accepted ? "accepted" : "declined");
46
70
  setQueue((waiting) => waiting.slice(1));
47
- } })) : null] }));
71
+ } })) : null, _jsx(CopyToast, { notice: notice, ...(props.toastClassName === undefined ? {} : { className: props.toastClassName }), onClose: closeNotice, onCopy: (failed) => {
72
+ // A click on the host's own page: the write has the gesture it needs now.
73
+ void writeClipboard(failed.text, globalThis.document).then((copied) => setNotice({ ...failed, copied }));
74
+ } })] }));
48
75
  }
49
76
  const PLACEHOLDER_STYLE = {
50
77
  position: "absolute",
@@ -119,6 +146,8 @@ export function PortholeFrame(props) {
119
146
  return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
120
147
  position: "relative",
121
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%" } : {}),
122
151
  ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
123
152
  ...props.style,
124
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 })) }));
@@ -130,8 +159,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
130
159
  const [ready, setReady] = useState(false);
131
160
  // The height this display started at: later reports don't make it start again.
132
161
  const [startHeight] = useState(initialHeightPx);
133
- const { config, document: view, instance, content, partial, scheme } = frame;
134
- 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;
135
167
  // The latest props, read when the display calls back (never during render).
136
168
  const latest = useEffectEvent(() => frame);
137
169
  const notify = useEffectEvent((event, detail) => {
@@ -146,9 +178,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
146
178
  frame.onResize?.(size);
147
179
  return;
148
180
  }
149
- case "error":
150
- frame.onError?.(detail.message);
181
+ case "contentError":
182
+ frame.onContentError?.(detail.message);
183
+ return;
184
+ case "hostError":
185
+ frame.onHostError?.(detail);
186
+ return;
187
+ case "error": {
188
+ const { message, source } = detail;
189
+ frame.onError?.(message, source);
151
190
  return;
191
+ }
152
192
  case "navigated":
153
193
  frame.onNavigated?.();
154
194
  return;
@@ -163,6 +203,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
163
203
  case "outcome":
164
204
  frame.onOutcome?.(detail);
165
205
  return;
206
+ case "annotation":
207
+ frame.onAnnotation?.(detail);
208
+ return;
166
209
  }
167
210
  });
168
211
  useEffect(() => {
@@ -183,6 +226,13 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
183
226
  for (const name of Object.keys(first.handlers ?? {})) {
184
227
  handlers[name] = (args, context) => latest().handlers?.[name]?.(args, named(context));
185
228
  }
229
+ // Copy buttons without a handler of the host's: the provider's, which shows a toast.
230
+ if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
231
+ const fallback = page.copyText;
232
+ if (fallback !== undefined) {
233
+ handlers.copyText = (args, context) => fallback(args, named(context));
234
+ }
235
+ }
186
236
  const asks = first.confirm !== undefined || page.confirm !== undefined;
187
237
  let display;
188
238
  try {
@@ -195,7 +245,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
195
245
  : {}),
196
246
  theme: (each) => latest().theme?.(each) ?? {},
197
247
  ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
198
- ...(page.locator === undefined ? {} : { locator: page.locator }),
248
+ ...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
249
+ ...(page.nonce === undefined ? {} : { nonce: page.nonce }),
199
250
  }).mount(container, {
200
251
  displayId: first.displayId,
201
252
  title: first.title,
@@ -203,10 +254,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
203
254
  initialHeightPx: startHeight,
204
255
  ...(view === undefined ? {} : { document: view }),
205
256
  ...(instance === undefined ? {} : { instance }),
257
+ ...(fills ? { fill: true } : {}),
206
258
  });
207
259
  }
208
260
  catch (error) {
209
- first.onError?.(error instanceof Error ? error.message : String(error));
261
+ hostFailed(first, error, "mount");
210
262
  return;
211
263
  }
212
264
  handle.current = display;
@@ -218,10 +270,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
218
270
  "refused",
219
271
  "muted",
220
272
  "outcome",
273
+ "annotation",
274
+ "contentError",
275
+ "hostError",
221
276
  ];
222
277
  for (const event of events) {
223
278
  display.on(event, (detail) => notify(event, detail));
224
279
  }
280
+ if (config.capabilities.annotate !== undefined) {
281
+ applyAnnotations(display, first);
282
+ }
225
283
  if (first.content !== undefined) {
226
284
  display.setContent(first.content, { partial: first.partial === true });
227
285
  }
@@ -231,12 +289,49 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
231
289
  };
232
290
  // A new display only for a new config, document, instance or page (or generation, by key);
233
291
  // content, theme, handlers, id and title are fed to the running one.
234
- }, [config, view, instance, page, startHeight]);
292
+ }, [config, view, instance, page, startHeight, fills]);
235
293
  useEffect(() => {
236
294
  if (content !== undefined) {
237
295
  handle.current?.setContent(content, { partial: partial === true });
238
296
  }
239
297
  }, [content, partial]);
298
+ // Annotations follow their props (a new display gets them all when it is made, above).
299
+ const annotates = config.capabilities.annotate !== undefined;
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);
309
+ const mode = annotating === undefined || annotating === null
310
+ ? ""
311
+ : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
312
+ useEffect(() => {
313
+ const display = handle.current;
314
+ if (!annotates || display === null) {
315
+ return;
316
+ }
317
+ const wanted = mode === "" ? null : JSON.parse(mode);
318
+ attempt(() => wanted === null
319
+ ? display.annotations.stop()
320
+ : display.annotations.start({ elements: wanted[0], text: wanted[1] }), latest());
321
+ }, [annotates, mode]);
322
+ useEffect(() => {
323
+ const display = handle.current;
324
+ if (annotates && display !== null) {
325
+ attempt(() => display.annotations.show(annotations ?? []), latest());
326
+ }
327
+ }, [annotates, annotations]);
328
+ useEffect(() => {
329
+ const display = handle.current;
330
+ const id = focusOf(latest().focusedAnnotation);
331
+ if (annotates && display !== null && focusToken !== "" && id !== null) {
332
+ attempt(() => display.annotations.focus(id), latest());
333
+ }
334
+ }, [annotates, focusToken]);
240
335
  useEffect(() => {
241
336
  handle.current?.setTheme(scheme);
242
337
  }, [scheme]);
@@ -253,3 +348,50 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
253
348
  ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
254
349
  }, 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 })] }));
255
350
  }
351
+ /** Tells the frame's props what failed on the host's side. */
352
+ function hostFailed(frame, error, kind) {
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ frame.onHostError?.({ message, kind });
355
+ frame.onError?.(message, "host");
356
+ }
357
+ /** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
358
+ function attempt(run, frame) {
359
+ try {
360
+ run();
361
+ }
362
+ catch (error) {
363
+ hostFailed(frame, error, "annotations");
364
+ }
365
+ }
366
+ /** A new display's annotations, from the props as they are. */
367
+ function applyAnnotations(display, frame) {
368
+ attempt(() => {
369
+ if (frame.annotations !== undefined && frame.annotations.length > 0) {
370
+ display.annotations.show(frame.annotations);
371
+ }
372
+ const focused = focusOf(frame.focusedAnnotation);
373
+ if (focused !== null) {
374
+ display.annotations.focus(focused);
375
+ }
376
+ if (frame.annotating !== undefined && frame.annotating !== null) {
377
+ display.annotations.start(frame.annotating);
378
+ }
379
+ }, frame);
380
+ }
381
+ /**
382
+ * `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
383
+ * render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
384
+ * only when it really changes.
385
+ */
386
+ function useByValue(value) {
387
+ const key = value === undefined ? "" : JSON.stringify(value);
388
+ return useMemo(() => (key === "" ? undefined : JSON.parse(key)), [key]);
389
+ }
390
+ /** The id `focusedAnnotation` names, if any. */
391
+ function focusOf(value) {
392
+ return typeof value === "string"
393
+ ? value
394
+ : value === null || value === undefined
395
+ ? null
396
+ : value.id;
397
+ }
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
3
  * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
4
  *
5
- * <PortholeProvider> // per page: live-frame cap, heights, the dialog
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
6
6
  * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
7
  * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
8
  * theme={tokensFor} onError={report} />
@@ -10,4 +10,5 @@
10
10
  */
11
11
  export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, type PlaceholderProps, type PortholeFrameProps, type PortholeProviderProps, } from "./frame.tsx";
12
12
  export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
13
+ export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, type CopyNotice, type CopyToastProps, } from "./toast.tsx";
13
14
  export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
3
  * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
4
  *
5
- * <PortholeProvider> // per page: live-frame cap, heights, the dialog
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
6
6
  * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
7
  * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
8
  * theme={tokensFor} onError={report} />
@@ -10,4 +10,5 @@
10
10
  */
11
11
  export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, } from "./frame.js";
12
12
  export { ARM_DELAY_MS, ConfirmDialog } from "./dialog.js";
13
+ export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, } from "./toast.js";
13
14
  export { admitFrame, createLiveFrames } from "./live.js";
@@ -0,0 +1,33 @@
1
+ import { type ReactElement } from "react";
2
+ /**
3
+ * The default copy notice (P052): when content's `copyText` copied something, the host's page
4
+ * shows the person what went on their clipboard, clipped, in a small toast outside every frame.
5
+ * If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
6
+ * host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
7
+ * styles it through the `porthole-toast` class and data attributes.
8
+ */
9
+ /** How long a toast for a copy that worked stays. */
10
+ export declare const TOAST_MS = 5000;
11
+ /** The most of the copied text a toast shows. */
12
+ export declare const TOAST_CHARS = 280;
13
+ export interface CopyNotice {
14
+ /** The whole text copied (or to copy). */
15
+ readonly text: string;
16
+ /** Whether it is on the clipboard. */
17
+ readonly copied: boolean;
18
+ /** The display's title, as the host named it. */
19
+ readonly title: string;
20
+ }
21
+ export interface CopyToastProps {
22
+ readonly notice: CopyNotice | null;
23
+ /** The person asked to copy it again (it failed), from this click. */
24
+ readonly onCopy: (notice: CopyNotice) => void;
25
+ readonly onClose: () => void;
26
+ readonly className?: string;
27
+ }
28
+ /** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
29
+ export declare function clipped(text: string): {
30
+ readonly shown: string;
31
+ readonly clipped: boolean;
32
+ };
33
+ export declare function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement;
@@ -0,0 +1,42 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect } from "react";
3
+ /**
4
+ * The default copy notice (P052): when content's `copyText` copied something, the host's page
5
+ * shows the person what went on their clipboard, clipped, in a small toast outside every frame.
6
+ * If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
7
+ * host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
8
+ * styles it through the `porthole-toast` class and data attributes.
9
+ */
10
+ /** How long a toast for a copy that worked stays. */
11
+ export const TOAST_MS = 5_000;
12
+ /** The most of the copied text a toast shows. */
13
+ export const TOAST_CHARS = 280;
14
+ const TOAST_LINES = 4;
15
+ /** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
16
+ export function clipped(text) {
17
+ const lines = text.split("\n");
18
+ let shown = lines.slice(0, TOAST_LINES).join("\n");
19
+ if (shown.length > TOAST_CHARS) {
20
+ shown = shown.slice(0, TOAST_CHARS);
21
+ }
22
+ return shown.length < text.length
23
+ ? { shown: `${shown}…`, clipped: true }
24
+ : { shown, clipped: false };
25
+ }
26
+ const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
27
+ .porthole-toast p{margin:0 0 6px}
28
+ .porthole-toast [data-porthole-source]{opacity:.72}
29
+ .porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
30
+ .porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
31
+ .porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
32
+ export function CopyToast({ notice, onCopy, onClose, className }) {
33
+ useEffect(() => {
34
+ if (notice === null || !notice.copied) {
35
+ return;
36
+ }
37
+ const timer = setTimeout(onClose, TOAST_MS);
38
+ return () => clearTimeout(timer);
39
+ }, [notice, onClose]);
40
+ const view = notice === null ? null : clipped(notice.text);
41
+ return (_jsxs("div", { role: "status", "aria-live": "polite", className: className === undefined ? "porthole-toast" : `porthole-toast ${className}`, "data-porthole-toast": "", "data-porthole-copied": notice === null ? "" : String(notice.copied), hidden: notice === null, children: [_jsx("style", { children: STYLE }), notice === null || view === null ? null : (_jsxs(_Fragment, { children: [_jsxs("p", { children: [_jsx("strong", { children: notice.copied ? "Copied" : "Not copied yet" }), " ", _jsx("span", { "data-porthole-source": "", children: `from “${notice.title}”` })] }), _jsx("pre", { "data-porthole-copied-text": "", children: view.shown }), view.clipped ? (_jsx("p", { "data-porthole-length": "", children: `${notice.text.length.toLocaleString("en-US")} characters in all` })) : null, _jsxs("menu", { children: [notice.copied ? null : (_jsx("button", { type: "button", "data-porthole-copy": "", onClick: () => onCopy(notice), children: "Copy" })), _jsx("button", { type: "button", "data-porthole-close": "", onClick: onClose, children: notice.copied ? "OK" : "Close" })] })] }))] }));
42
+ }
@@ -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>`;
@@ -2,7 +2,7 @@ import { compile } from "../compile/compile.js";
2
2
  import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
3
3
  import { sandboxProxyDocument } from "../mcp/proxy.js";
4
4
  import { createKeys, newTicket, ticketId } from "./keys.js";
5
- import { isHostLabel } from "./naming.js";
5
+ import { isHostLabel, reservedLabels } from "./naming.js";
6
6
  import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
7
7
  import { memoryTicketStore } from "./tickets.js";
8
8
  /**
@@ -26,6 +26,17 @@ const DEFAULT_SESSION_SECONDS = 3_600;
26
26
  const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
27
27
  /** Browsers keep a cookie of up to 4 KB, name and attributes included. */
28
28
  const MAX_COOKIE_BYTES = 3_800;
29
+ /** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
30
+ function normalHost(value, protocol) {
31
+ const host = value
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/\.(?=:\d+$|$)/u, "");
35
+ const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
36
+ return defaultPort !== "" && host.endsWith(defaultPort)
37
+ ? host.slice(0, -defaultPort.length)
38
+ : host;
39
+ }
29
40
  const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
30
41
  /** Which configs a kind of served document may be served under. */
31
42
  const MODES = {
@@ -148,25 +159,20 @@ export function createServer(options) {
148
159
  throw new TypeError("content.port must be a port number");
149
160
  }
150
161
  const keys = createKeys(options.secret);
151
- const reserved = new Set(content.reserved ?? []);
152
- for (const label of reserved) {
153
- if (!isHostLabel(label)) {
154
- throw new TypeError(`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`);
155
- }
156
- }
162
+ const isReserved = reservedLabels(content.reserved);
157
163
  /** The label a scope is served on: the host's naming, checked, or the keyed default. */
158
164
  const nameOf = (scope) => {
159
165
  if (content.name === undefined) {
160
166
  return keys.label(scope);
161
167
  }
162
168
  const label = content.name(scope);
163
- if (!isHostLabel(label) || reserved.has(label)) {
169
+ if (!isHostLabel(label) || isReserved(label)) {
164
170
  throw new TypeError(`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`);
165
171
  }
166
172
  return label;
167
173
  };
168
174
  /** Whether a request's label may be one this server named. */
169
- const ours = (label) => !reserved.has(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
175
+ const ours = (label) => !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
170
176
  const secure = content.scheme === "https";
171
177
  const defaultPort = secure ? 443 : 80;
172
178
  const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
@@ -431,6 +437,7 @@ export function createServer(options) {
431
437
  "cache-control": "no-store",
432
438
  "referrer-policy": "no-referrer",
433
439
  "x-content-type-options": "nosniff",
440
+ "x-robots-tag": "noindex, nofollow",
434
441
  });
435
442
  }
436
443
  async function serveDocument(request, url, label, key) {
@@ -563,7 +570,16 @@ export function createServer(options) {
563
570
  return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
564
571
  }
565
572
  }
566
- /** The label a request is for: null when it isn't under the content domain at all. */
573
+ const underDomain = (host) => host === domain || host.endsWith(`.${domain}`);
574
+ /**
575
+ * The label a request is for: null when it isn't under the content domain at all (or is in a
576
+ * reserved namespace), "mismatch" when its Host header names another host than its URL does
577
+ * and either is under the content domain. A Node adapter may build the URL from an
578
+ * absolute-form request target (`GET http://other/…`) while a host routes by the Host header
579
+ * (and a cache keys on it): the two must agree before anything is served. A Host that is a
580
+ * local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
581
+ * not a name and isn't compared.
582
+ */
567
583
  function labelOf(request) {
568
584
  let url;
569
585
  try {
@@ -573,13 +589,25 @@ export function createServer(options) {
573
589
  return null;
574
590
  }
575
591
  const host = url.hostname.toLowerCase().replace(/\.$/u, "");
576
- if (host !== domain && !host.endsWith(`.${domain}`)) {
592
+ const header = request.headers.get("host");
593
+ if (header !== null) {
594
+ const named = normalHost(header, url.protocol);
595
+ const name = named.replace(/:\d+$/u, "");
596
+ const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
597
+ if (!local &&
598
+ named !== normalHost(url.host, url.protocol) &&
599
+ (underDomain(host) || underDomain(name))) {
600
+ return "mismatch";
601
+ }
602
+ }
603
+ if (!underDomain(host)) {
577
604
  return null;
578
605
  }
579
606
  const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
580
- // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`): it and the names
581
- // under it go to the host's own routes. Any other deeper name is ours to refuse.
582
- if (reserved.has(label.split(".").at(-1) ?? "")) {
607
+ // A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
608
+ // own: it and the names under it go to the host's own routes. Any other deeper name is ours
609
+ // to refuse.
610
+ if (isReserved(label.split(".").at(-1) ?? "")) {
583
611
  return null;
584
612
  }
585
613
  return { url, label };
@@ -639,7 +667,7 @@ export function createServer(options) {
639
667
  },
640
668
  async access(request) {
641
669
  const at = labelOf(request);
642
- if (at === null || !ours(at.label)) {
670
+ if (at === null || at === "mismatch" || !ours(at.label)) {
643
671
  return null;
644
672
  }
645
673
  const access = accessOf(request, at.label, null);
@@ -650,8 +678,11 @@ export function createServer(options) {
650
678
  if (at === null) {
651
679
  return null;
652
680
  }
653
- const { url, label } = at;
654
681
  const method = request.method.toUpperCase();
682
+ if (at === "mismatch") {
683
+ return page(400, "Bad request", "The request's Host header and its URL name different hosts.", method);
684
+ }
685
+ const { url, label } = at;
655
686
  try {
656
687
  if (!ours(label)) {
657
688
  return page(404, "Nothing here", "This address names no content.", method);
@@ -673,7 +704,8 @@ export function createServer(options) {
673
704
  if (document?.[1] !== undefined) {
674
705
  return await serveDocument(request, url, label, document[1]);
675
706
  }
676
- if (url.pathname.startsWith(PREFIX)) {
707
+ // Without an upstream, only Porthole's own paths are served here: any other is nothing.
708
+ if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
677
709
  return page(404, "Nothing here", "This address names no content.", method);
678
710
  }
679
711
  return await serveApp(request, label);
@@ -18,10 +18,13 @@ export interface ContentDomain {
18
18
  */
19
19
  readonly name?: (scope: string) => string;
20
20
  /**
21
- * Labels this server never serves or names (another server's namespace under the same domain,
22
- * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
21
+ * Labels this server never serves or names: another server's namespace under the same domain
22
+ * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
23
+ * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
24
+ * whether a label is reserved. Requests for them, and for the names under them, get null (the
25
+ * host's routes), so a host routes them without wrapping `handle`.
23
26
  */
24
- readonly reserved?: readonly string[];
27
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
25
28
  }
26
29
  /**
27
30
  * Who may open an instance, decided by the host before it calls `issue`: a viewer the host
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The shell's annotation runtime (P053), spliced into the shell's runtime (`__ANNOTATE__`) when
3
+ * the config declares `annotate`, so it runs before any content does, with the DOM's own
4
+ * functions as they were. Plain ES2020 like the rest of the shell; no template literals (it sits
5
+ * in one).
6
+ *
7
+ * The host drives it (`porthole/annotate.start|stop|show|focus|clear`) and it reports
8
+ * (`porthole/annotate.selected|unselected|cancelled|anchored`):
9
+ * - picking an element: the element under the pointer (or finger) is outlined with its name; a
10
+ * click or tap picks it, and the content's own handlers never see that click; Escape cancels;
11
+ * - selecting text: each selection that settles is reported, with where it is, and its clearing;
12
+ * - marks: each anchor is found in the content as it is now (its CSS selector, its text where it
13
+ * was, its text anywhere, its words with any spacing) and drawn over it, in a layer of the
14
+ * shell's own (a closed shadow root), again whenever the content changes; what can't be found
15
+ * is reported `orphaned`.
16
+ *
17
+ * It shares the realm with the content, which can interfere with all of it: the host takes its
18
+ * reports as UI hints only.
19
+ */
20
+ export declare const ANNOTATE_RUNTIME: string;