@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
@@ -19,10 +19,13 @@ export interface ContentDomain {
19
19
  */
20
20
  readonly name?: (scope: string) => string;
21
21
  /**
22
- * Labels this server never serves or names (another server's namespace under the same domain,
23
- * `f` for `<instance>.f.<domain>`, or `www`). Requests for them get null (the host's routes).
22
+ * Labels this server never serves or names: another server's namespace under the same domain
23
+ * (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
24
+ * `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
25
+ * whether a label is reserved. Requests for them, and for the names under them, get null (the
26
+ * host's routes), so a host routes them without wrapping `handle`.
24
27
  */
25
- readonly reserved?: readonly string[];
28
+ readonly reserved?: readonly string[] | ((label: string) => boolean);
26
29
  }
27
30
 
28
31
  /**
@@ -369,6 +369,9 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
369
369
  const root = ensureLayer();
370
370
  const parts = [];
371
371
  const style = createElement.call(document, "style");
372
+ if (nonce !== "") {
373
+ style.nonce = nonce;
374
+ }
372
375
  style.textContent = STYLE;
373
376
  parts.push(style);
374
377
  const flashOn = Date.now() < flashUntil;
@@ -439,20 +442,41 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
439
442
  let anchorTimer = 0;
440
443
  let deadline = 0;
441
444
  let reportAll = false;
445
+ // Nothing is reported before the content is in place (an empty shell would orphan every
446
+ // mark), nor before a run of changes settles; a focus asked for meanwhile waits for it.
447
+ let waitingForContent = false;
448
+ let pendingFocus = null;
449
+ const settling = () => anchorTimer !== 0 || waitingForContent || marks.some((mark) => mark.state === null);
442
450
  const scheduleAnchor = (all) => {
443
451
  if (all) {
444
452
  reportAll = true;
445
453
  }
454
+ if (!contentIsIn()) {
455
+ if (!waitingForContent) {
456
+ waitingForContent = true;
457
+ whenContentIsIn(() => {
458
+ waitingForContent = false;
459
+ scheduleAnchor(false);
460
+ });
461
+ }
462
+ return;
463
+ }
446
464
  const now = Date.now();
447
465
  if (deadline === 0) {
448
466
  deadline = now + 1000;
449
467
  }
450
468
  clearTimeout(anchorTimer);
451
469
  anchorTimer = setTimeout(() => {
470
+ anchorTimer = 0;
452
471
  deadline = 0;
453
472
  const every = reportAll;
454
473
  reportAll = false;
455
474
  anchorAll(every);
475
+ if (pendingFocus !== null) {
476
+ const id = pendingFocus;
477
+ pendingFocus = null;
478
+ focus(id);
479
+ }
456
480
  }, Math.max(0, Math.min(150, deadline - now)));
457
481
  };
458
482
  new MutationObserver((records) => {
@@ -475,6 +499,12 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
475
499
  if (!mark) {
476
500
  return;
477
501
  }
502
+ if (settling()) {
503
+ // Where it is isn't known yet: focus it once anchoring has settled, then say where.
504
+ pendingFocus = id;
505
+ active = id;
506
+ return;
507
+ }
478
508
  active = id;
479
509
  if (mark.found !== null) {
480
510
  const rect = rectOf(mark.found);
@@ -657,6 +687,7 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
657
687
  focus(String(params.id));
658
688
  break;
659
689
  case M.clear:
690
+ pendingFocus = null;
660
691
  marks = [];
661
692
  active = null;
662
693
  draw();
@@ -25,8 +25,17 @@ export interface ShellDocumentOptions {
25
25
  * both apply, and the stricter wins.
26
26
  */
27
27
  readonly cspMeta?: boolean;
28
+ /**
29
+ * The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): a `srcdoc` frame
30
+ * inherits the embedding page's policy, so the shell's script and style carry it, and so does
31
+ * every script the shell runs for the content. The frame can read it.
32
+ */
33
+ readonly nonce?: string;
28
34
  }
29
35
 
36
+ /** A CSP nonce: base64 or base64url characters, 8 to 256 of them. */
37
+ const NONCE = /^[A-Za-z0-9+/_=-]{8,256}$/u;
38
+
30
39
  const escapeAttribute = (value: string): string =>
31
40
  value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;");
32
41
 
@@ -86,6 +95,10 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
86
95
  throw new TypeError(`shellDocument: ${problem}`);
87
96
  }
88
97
  }
98
+ if (options.nonce !== undefined && !NONCE.test(options.nonce)) {
99
+ throw new TypeError("shellDocument: a nonce is 8 to 256 base64 characters");
100
+ }
101
+ const nonce = options.nonce === undefined ? "" : ` nonce="${options.nonce}"`;
89
102
  // The annotation runtime only where it is declared; spliced in before the plan, which may carry
90
103
  // any text (a document) and must not be searched.
91
104
  const runtime = SHELL_RUNTIME.replace("__ANNOTATE__", () =>
@@ -113,13 +126,13 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
113
126
  ${head.join("\n")}
114
127
  <meta name="viewport" content="width=device-width, initial-scale=1">
115
128
  <title>${escapeText(options.title ?? "Content")}</title>
116
- <style>
129
+ <style${nonce}>
117
130
  ${BASE_STYLE}
118
131
  </style>
119
132
  </head>
120
133
  <body>
121
134
  <div id="porthole-root"></div>
122
- <script>${runtime}</script>
135
+ <script${nonce}>${runtime}</script>
123
136
  </body>
124
137
  </html>
125
138
  `;
@@ -31,6 +31,9 @@
31
31
  export const SHELL_RUNTIME = String.raw`(() => {
32
32
  "use strict";
33
33
  const plan = __PLAN__;
34
+ // The embedding page's nonce, where it gave one (a srcdoc frame inherits its policy): every
35
+ // script the shell runs for the content carries it.
36
+ const nonce = document.currentScript && document.currentScript.nonce ? document.currentScript.nonce : "";
34
37
  const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
35
38
  // Fixed in place: content can neither restore what is removed nor replace what is wrapped.
36
39
  const lock = (target, name, value) => {
@@ -485,6 +488,9 @@ export const SHELL_RUNTIME = String.raw`(() => {
485
488
  }
486
489
  // A script made by a script is async unless told otherwise: keep the page's order.
487
490
  live.async = script.hasAttribute("async");
491
+ if (nonce !== "") {
492
+ live.nonce = nonce;
493
+ }
488
494
  live.textContent = script.textContent;
489
495
  const external = live.hasAttribute("src") && !live.async && live.type !== "module";
490
496
  const loaded = external
@@ -509,6 +515,27 @@ export const SHELL_RUNTIME = String.raw`(() => {
509
515
  window.dispatchEvent(new Event("load"));
510
516
  };
511
517
 
518
+ // Whether the content is in place: a fragment or page the shell draws, once drawn whole; a
519
+ // view the plan carries, once rendered; a view that is the document itself, from the start.
520
+ const contentHooks = [];
521
+ let contentIn = typeof plan.document !== "string" && plan.protocol !== "shell";
522
+ const contentIsIn = () => contentIn;
523
+ const whenContentIsIn = (run) => {
524
+ if (contentIn) {
525
+ run();
526
+ } else {
527
+ contentHooks.push(run);
528
+ }
529
+ };
530
+ const contentArrived = () => {
531
+ if (!contentIn) {
532
+ contentIn = true;
533
+ for (const run of contentHooks.splice(0)) {
534
+ run();
535
+ }
536
+ }
537
+ };
538
+
512
539
  // A fragment takes the person's scheme; a whole document only if it declares that it supports
513
540
  // both (or the config says always), else it shows as its author styled it (P039).
514
541
  let lastContext = null;
@@ -544,6 +571,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
544
571
  schemeFollows();
545
572
  await runScripts(scripts);
546
573
  announceLoaded();
574
+ contentArrived();
547
575
  };
548
576
 
549
577
  // A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
@@ -579,12 +607,16 @@ export const SHELL_RUNTIME = String.raw`(() => {
579
607
  const notify = (method, params) => send({ method, params });
580
608
 
581
609
  const themeNames = new Set(plan.themeVariables);
610
+ // What the content was last told of (its scheme and values): the theme it starts with is no
611
+ // change, and a host context that changes neither (a host re-sending it) is none either.
612
+ let announced = null;
582
613
  const applyTheme = (context) => {
583
614
  if (context === null || typeof context !== "object") {
584
615
  return;
585
616
  }
586
617
  // The spec's variables, and Porthole's own beside them (its own-prefixed field).
587
618
  const own = context["porthole/styles"];
619
+ const applied = [];
588
620
  for (const variables of [context.styles && context.styles.variables, own && own.variables]) {
589
621
  if (variables === null || typeof variables !== "object") {
590
622
  continue;
@@ -593,12 +625,21 @@ export const SHELL_RUNTIME = String.raw`(() => {
593
625
  const value = variables[name];
594
626
  if (themeNames.has(name) && typeof value === "string") {
595
627
  document.documentElement.style.setProperty(name, value);
628
+ applied.push(name + ":" + value);
596
629
  }
597
630
  }
598
631
  }
599
632
  lastContext = context;
600
633
  schemeFollows();
601
- window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
634
+ const now = JSON.stringify([context.theme, applied.sort()]);
635
+ if (announced === null) {
636
+ announced = now;
637
+ return;
638
+ }
639
+ if (now !== announced) {
640
+ announced = now;
641
+ window.dispatchEvent(new CustomEvent(plan.themeEvent, { detail: { theme: context.theme } }));
642
+ }
602
643
  };
603
644
 
604
645
  // No size before there is something to size: an empty shell's height would make the host
@@ -672,7 +713,10 @@ export const SHELL_RUNTIME = String.raw`(() => {
672
713
  window.addEventListener("error", (event) => {
673
714
  const error = event.error;
674
715
  const where = event.lineno ? " (line " + event.lineno + ")" : "";
675
- report(error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : event.message + where);
716
+ // V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
717
+ const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
718
+ const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
719
+ report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
676
720
  });
677
721
  window.addEventListener("unhandledrejection", (event) => {
678
722
  const reason = event.reason;
@@ -804,6 +848,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
804
848
  schedule();
805
849
  await runScripts(scripts);
806
850
  announceLoaded();
851
+ contentArrived();
807
852
  schedule();
808
853
  };
809
854