@botiverse/porthole 0.1.0 → 0.1.2

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/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +6 -5
  4. package/dist/compile/policy.js +10 -12
  5. package/dist/config/schema.d.ts +29 -0
  6. package/dist/config/schema.js +16 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +55 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +25 -0
  20. package/dist/host/outcome.js +147 -0
  21. package/dist/host/types.d.ts +39 -2
  22. package/dist/index.d.ts +6 -3
  23. package/dist/index.js +4 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +11 -3
  31. package/dist/presets/presets.js +17 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/crypto.d.ts +19 -0
  35. package/dist/server/crypto.js +144 -0
  36. package/dist/server/index.d.ts +12 -4
  37. package/dist/server/index.js +11 -3
  38. package/dist/server/keys.js +12 -9
  39. package/dist/server/naming.d.ts +17 -0
  40. package/dist/server/naming.js +34 -0
  41. package/dist/server/pages.d.ts +11 -6
  42. package/dist/server/pages.js +86 -12
  43. package/dist/server/server.js +191 -23
  44. package/dist/server/tickets.d.ts +24 -0
  45. package/dist/server/tickets.js +46 -0
  46. package/dist/server/types.d.ts +76 -5
  47. package/dist/shell/document.d.ts +3 -2
  48. package/dist/shell/document.js +4 -3
  49. package/dist/shell/plan.d.ts +2 -0
  50. package/dist/shell/plan.js +1 -0
  51. package/dist/shell/runtime.js +20 -4
  52. package/package.json +2 -9
  53. package/src/bridge/bridge.ts +28 -6
  54. package/src/bridge/types.ts +21 -3
  55. package/src/compile/policy.ts +10 -12
  56. package/src/config/schema.ts +38 -0
  57. package/src/config/validate.ts +63 -14
  58. package/src/conformance/behavior.ts +55 -0
  59. package/src/conformance/index.ts +18 -4
  60. package/src/conformance/probes.ts +88 -52
  61. package/src/conformance/served.ts +37 -8
  62. package/src/guide/guide.ts +8 -2
  63. package/src/host/create-host.ts +47 -2
  64. package/src/host/locate.ts +6 -1
  65. package/src/host/outcome.ts +177 -0
  66. package/src/host/types.ts +42 -2
  67. package/src/index.ts +19 -1
  68. package/src/isolation/served.ts +24 -0
  69. package/src/isolation/types.ts +15 -1
  70. package/src/platform/types.ts +7 -0
  71. package/src/platform/web.ts +15 -2
  72. package/src/presets/presets.ts +31 -3
  73. package/src/react/frame.tsx +78 -14
  74. package/src/server/crypto.ts +166 -0
  75. package/src/server/index.ts +20 -3
  76. package/src/server/keys.ts +21 -13
  77. package/src/server/naming.ts +42 -0
  78. package/src/server/pages.ts +106 -11
  79. package/src/server/server.ts +233 -23
  80. package/src/server/tickets.ts +65 -0
  81. package/src/server/types.ts +84 -7
  82. package/src/shell/document.ts +7 -6
  83. package/src/shell/plan.ts +3 -0
  84. package/src/shell/runtime.ts +20 -4
@@ -295,6 +295,61 @@ export const BEHAVIOR_PROBES: readonly Probe[] = [
295
295
  },
296
296
  },
297
297
 
298
+ {
299
+ id: "11.document-theme",
300
+ item: 11,
301
+ title:
302
+ "a whole page takes the person's scheme only when it declares it supports it (or the host says always)",
303
+ async run(context) {
304
+ const page = (head: string, id: string) =>
305
+ `<!doctype html><html><head><title>Page</title>${head}</head><body><p id="${id}">page</p></body></html>`;
306
+ const scheme = (display: LaunchedDisplay, id: string) =>
307
+ display.frame().then(async (frame) => {
308
+ await expect(frame.locator(`#${id}`)).toHaveText("page");
309
+ return frame.evaluate(() => ({
310
+ scheme: getComputedStyle(document.documentElement).colorScheme,
311
+ theme: document.documentElement.getAttribute("data-theme"),
312
+ variable:
313
+ getComputedStyle(document.documentElement)
314
+ .getPropertyValue("--color-text-primary")
315
+ .trim() !== "",
316
+ }));
317
+ });
318
+ const plain = await context.launch({
319
+ config: presets.artifact(),
320
+ scheme: "dark",
321
+ content: page("", "plain"),
322
+ });
323
+ // An author's own page, declaring nothing: shown as styled, in light; the variables are there.
324
+ expect(await scheme(plain, "plain")).toEqual({
325
+ scheme: "normal",
326
+ theme: null,
327
+ variable: true,
328
+ });
329
+ await plain.setTheme("light");
330
+ await plain.setTheme("dark");
331
+ await settle(300);
332
+ expect(await scheme(plain, "plain")).toEqual({
333
+ scheme: "normal",
334
+ theme: null,
335
+ variable: true,
336
+ });
337
+ const declared = await context.launch({
338
+ config: presets.artifact(),
339
+ scheme: "dark",
340
+ content: page('<meta name="color-scheme" content="light dark">', "declared"),
341
+ });
342
+ await expect.poll(async () => (await scheme(declared, "declared")).theme).toBe("dark");
343
+ expect((await scheme(declared, "declared")).scheme).toBe("dark");
344
+ const always = await context.launch({
345
+ config: presets.artifact({ theme: { documents: "always" } }),
346
+ scheme: "dark",
347
+ content: page("", "always"),
348
+ });
349
+ await expect.poll(async () => (await scheme(always, "always")).theme).toBe("dark");
350
+ },
351
+ },
352
+
298
353
  // ── 12: the host-function gates ────────────────────────────────────────────────────────────
299
354
  {
300
355
  id: "12.prompt-gesture-confirm",
@@ -1,11 +1,17 @@
1
1
  /**
2
2
  * `@botiverse/porthole/conformance`: the leak/conformance suite (P008), runnable by any host
3
3
  * against its own integration. A host implements `ConformanceHost` (how to show one display in a
4
- * page and drive it), then registers the suite in its Playwright project:
4
+ * page and drive it), then registers the tests in a spec file of its own, so they are located
5
+ * there (`playwright test conformance.spec.ts`, `--grep "\[1\.connect\]"`):
5
6
  *
6
7
  * import { test } from "@playwright/test";
7
- * import { defineLeakSuite } from "@botiverse/porthole/conformance";
8
- * defineLeakSuite(test, myHost);
8
+ * import { leakTests } from "@botiverse/porthole/conformance";
9
+ * for (const each of leakTests(myHost)) {
10
+ * test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
11
+ * }
12
+ *
13
+ * The host's own `@playwright/test` (and, for the App SDK probes, `@modelcontextprotocol/ext-apps`)
14
+ * is what runs it: Porthole declares neither (P041).
9
15
  *
10
16
  * Each probe attempts one egress or escape channel (DESIGN.md § Egress hardening) and asserts,
11
17
  * with an out-of-band oracle where one exists (an HTTP canary that also counts connections, a UDP
@@ -90,12 +96,20 @@ export interface LeakSuiteOptions {
90
96
  readonly skip?: Readonly<Record<string, string>>;
91
97
  }
92
98
 
93
- export { defineLeakSuite, PROBES, type Probe } from "./probes.ts";
99
+ export {
100
+ defineLeakSuite,
101
+ leakTests,
102
+ PROBES,
103
+ type ConformanceFixtures,
104
+ type ConformanceTest,
105
+ type Probe,
106
+ } from "./probes.ts";
94
107
  export { httpOracle, settle, udpOracle, type HttpOracle, type UdpOracle } from "./oracles.ts";
95
108
 
96
109
  // The served modes' cases (domain and url mode), with their own host interface.
97
110
  export {
98
111
  defineServedSuite,
112
+ servedTests,
99
113
  type ServedConformanceHost,
100
114
  type ServedDisplay,
101
115
  type ServedSuiteOptions,
@@ -4,6 +4,8 @@ import {
4
4
  type PlaywrightTestOptions,
5
5
  type PlaywrightWorkerArgs,
6
6
  type PlaywrightWorkerOptions,
7
+ type Page,
8
+ type TestInfo,
7
9
  type TestType,
8
10
  } from "@playwright/test";
9
11
  import { presets } from "../presets/presets.ts";
@@ -1030,64 +1032,98 @@ type AnyTest = TestType<
1030
1032
  PlaywrightWorkerArgs & PlaywrightWorkerOptions
1031
1033
  >;
1032
1034
 
1033
- /** Registers every probe as a test, grouped by egress item. */
1035
+ /** The fixtures a conformance test uses. */
1036
+ export interface ConformanceFixtures {
1037
+ readonly page: Page;
1038
+ readonly browserName: string;
1039
+ }
1040
+
1041
+ /**
1042
+ * One conformance test, for a host's own spec file to register: its tests are then located in that
1043
+ * file, so `playwright test my-conformance.spec.ts` (or `--grep "\[1\.connect\]"`) finds them.
1044
+ *
1045
+ * for (const each of leakTests(myHost)) {
1046
+ * test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
1047
+ * }
1048
+ */
1049
+ export interface ConformanceTest {
1050
+ readonly id: string;
1051
+ /** `[<id>] <what it proves>`: the probe id is greppable. */
1052
+ readonly title: string;
1053
+ run(fixtures: ConformanceFixtures, info: TestInfo): Promise<void>;
1054
+ }
1055
+
1056
+ /** Every probe as a test entry (see `ConformanceTest`). */
1057
+ export function leakTests(
1058
+ host: ConformanceHost,
1059
+ options: LeakSuiteOptions = {},
1060
+ ): ConformanceTest[] {
1061
+ return PROBES.map((probe) => ({
1062
+ id: probe.id,
1063
+ title: `[${probe.id}] ${probe.title}`,
1064
+ async run({ page, browserName }, info) {
1065
+ const reason = options.skip?.[probe.id];
1066
+ info.skip(reason !== undefined, reason);
1067
+ const closers: (() => Promise<void>)[] = [];
1068
+ const requests: string[] = [];
1069
+ page.on("response", (response) => requests.push(response.url()));
1070
+ try {
1071
+ await probe.run({
1072
+ page,
1073
+ engine: browserName,
1074
+ launch: (spec) => host.launch(page, spec),
1075
+ http: async (files) => {
1076
+ const oracle = await httpOracle(files);
1077
+ closers.push(() => oracle.close());
1078
+ return oracle;
1079
+ },
1080
+ udp: async () => {
1081
+ const oracle = await udpOracle();
1082
+ closers.push(() => oracle.close());
1083
+ return oracle;
1084
+ },
1085
+ result: async (display, timeoutMs = 10_000) => {
1086
+ await expect
1087
+ .poll(
1088
+ async () =>
1089
+ (await display.frame())
1090
+ .evaluate(() =>
1091
+ Boolean(
1092
+ (window as unknown as { portholeProbe?: { done?: boolean } }).portholeProbe
1093
+ ?.done,
1094
+ ),
1095
+ )
1096
+ .catch(() => false),
1097
+ { timeout: timeoutMs },
1098
+ )
1099
+ .toBe(true);
1100
+ return (await display.frame()).evaluate(
1101
+ () => (window as unknown as { portholeProbe: Record<string, unknown> }).portholeProbe,
1102
+ );
1103
+ },
1104
+ requestsTo: (origin) =>
1105
+ requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1106
+ measured: (text) => info.annotations.push({ type: "measured", description: text }),
1107
+ });
1108
+ } finally {
1109
+ await Promise.all(closers.map((close) => close()));
1110
+ }
1111
+ },
1112
+ }));
1113
+ }
1114
+
1115
+ /**
1116
+ * Registers every probe as a test. Its tests are located in Porthole's own files; a host that
1117
+ * wants to select them by its spec file registers `leakTests` itself instead.
1118
+ */
1034
1119
  export function defineLeakSuite(
1035
1120
  test: AnyTest,
1036
1121
  host: ConformanceHost,
1037
1122
  options: LeakSuiteOptions = {},
1038
1123
  ): void {
1039
1124
  test.describe(`Porthole leak suite (${host.name})`, () => {
1040
- for (const probe of PROBES) {
1041
- test(`[${probe.id}] ${probe.title}`, async ({ page, browserName }) => {
1042
- const reason = options.skip?.[probe.id];
1043
- test.skip(reason !== undefined, reason);
1044
- const closers: (() => Promise<void>)[] = [];
1045
- const requests: string[] = [];
1046
- page.on("response", (response) => requests.push(response.url()));
1047
- try {
1048
- await probe.run({
1049
- page,
1050
- engine: browserName,
1051
- launch: (spec) => host.launch(page, spec),
1052
- http: async (files) => {
1053
- const oracle = await httpOracle(files);
1054
- closers.push(() => oracle.close());
1055
- return oracle;
1056
- },
1057
- udp: async () => {
1058
- const oracle = await udpOracle();
1059
- closers.push(() => oracle.close());
1060
- return oracle;
1061
- },
1062
- result: async (display, timeoutMs = 10_000) => {
1063
- await expect
1064
- .poll(
1065
- async () =>
1066
- (await display.frame())
1067
- .evaluate(() =>
1068
- Boolean(
1069
- (window as unknown as { portholeProbe?: { done?: boolean } })
1070
- .portholeProbe?.done,
1071
- ),
1072
- )
1073
- .catch(() => false),
1074
- { timeout: timeoutMs },
1075
- )
1076
- .toBe(true);
1077
- return (await display.frame()).evaluate(
1078
- () =>
1079
- (window as unknown as { portholeProbe: Record<string, unknown> }).portholeProbe,
1080
- );
1081
- },
1082
- requestsTo: (origin) =>
1083
- requests.filter((url) => url.startsWith(`${origin}/`) || url === origin),
1084
- measured: (text) =>
1085
- test.info().annotations.push({ type: "measured", description: text }),
1086
- });
1087
- } finally {
1088
- await Promise.all(closers.map((close) => close()));
1089
- }
1090
- });
1125
+ for (const each of leakTests(host, options)) {
1126
+ test(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
1091
1127
  }
1092
1128
  });
1093
1129
  }
@@ -11,7 +11,8 @@
11
11
  * Each case attempts one escape or access channel and asserts, with an out-of-band oracle where
12
12
  * one exists (a canary URL the host counts requests to), that nothing got through.
13
13
  */
14
- import { expect, type Frame, type Page, type TestType } from "@playwright/test";
14
+ import { expect, type Frame, type Page, type TestInfo, type TestType } from "@playwright/test";
15
+ import type { ConformanceTest } from "./probes.ts";
15
16
  import type { EgressConfig } from "../config/schema.ts";
16
17
  import type { FrameLocation, InstanceRef } from "../isolation/types.ts";
17
18
  import { MCP_APPS_METHODS } from "../protocol/mcp-apps.ts";
@@ -137,22 +138,49 @@ async function plantHostSecrets(page: Page): Promise<void> {
137
138
  }
138
139
 
139
140
  /** Registers every served-mode case as a test. */
141
+ /**
142
+ * Registers every served-mode case as a test. A host that wants to select them by its own spec
143
+ * file registers `servedTests` itself (see `ConformanceTest`).
144
+ */
140
145
  export function defineServedSuite(
141
146
  test: TestType<object, object>,
142
147
  host: ServedConformanceHost,
143
148
  options: ServedSuiteOptions = {},
144
149
  ): void {
145
150
  const typed = test as unknown as TestType<Fixtures, object>;
151
+ typed.describe(`served modes (${host.name})`, () => {
152
+ for (const each of servedTests(host, options)) {
153
+ // Playwright reads the fixtures a test uses from its parameter's destructuring.
154
+ typed(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
155
+ }
156
+ });
157
+ }
158
+
159
+ /** Every served-mode case as a test entry (see `ConformanceTest`). */
160
+ export function servedTests(
161
+ host: ServedConformanceHost,
162
+ options: ServedSuiteOptions = {},
163
+ ): ConformanceTest[] {
164
+ const tests: ConformanceTest[] = [];
165
+ let info: TestInfo | null = null;
166
+ // The case being run's own TestInfo (for an expected failure measured on one engine).
167
+ const typed = {
168
+ fail: (condition: boolean, reason: string) => info?.fail(condition, reason),
169
+ };
146
170
  const define = (id: string, title: string, body: (fixtures: Fixtures) => Promise<void>) => {
147
- const reason = options.skip?.[id];
148
- // Playwright reads the fixtures a test uses from its parameter's destructuring.
149
- typed(`${id}: ${title}`, async ({ page, browserName }) => {
150
- typed.skip(reason !== undefined, reason);
151
- await body({ page, browserName });
171
+ tests.push({
172
+ id,
173
+ title: `${id}: ${title}`,
174
+ async run({ page, browserName }, testInfo) {
175
+ const reason = options.skip?.[id];
176
+ testInfo.skip(reason !== undefined, reason);
177
+ info = testInfo;
178
+ await body({ page, browserName });
179
+ },
152
180
  });
153
181
  };
154
182
 
155
- typed.describe(`served modes (${host.name})`, () => {
183
+ {
156
184
  const modes = [
157
185
  ["domain", (scope: string) => host.shell(scope)],
158
186
  ["url", (scope: string) => host.app(scope)],
@@ -468,5 +496,6 @@ export function defineServedSuite(
468
496
  .toBe("ready");
469
497
  },
470
498
  );
471
- });
499
+ }
500
+ return tests;
472
501
  }
@@ -107,6 +107,10 @@ function network(config: PortholeConfig): string {
107
107
  return `Network: only ${parts.join("; ")}. Anything else fails.`;
108
108
  }
109
109
 
110
+ /** Whether a whole document takes the theme only when it declares support (ThemeConfig.documents). */
111
+ const optsIn = (config: PortholeConfig): boolean =>
112
+ config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
113
+
110
114
  const loadsScripts = (config: PortholeConfig): boolean =>
111
115
  egressOrigins(config, "script").length > 0;
112
116
 
@@ -157,7 +161,7 @@ function widget(c: Context): string {
157
161
  "",
158
162
  what,
159
163
  "",
160
- `Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the box's clientWidth, again from a ResizeObserver). ${height} It runs in the person's light or dark theme, and again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
164
+ `Where it runs: in a sandboxed frame as wide as the column it sits in, about ${layout.narrowWidthPx} px on a phone (${layout.minWidthPx} on a small one) and ${layout.wideWidthPx} on a desktop; the same ${noun} is read on both, so never write for one of them. Design for ${layout.narrowWidthPx} first; where the layout depends on the width, read it as it runs (the box's clientWidth, again from a ResizeObserver). ${height} ${optsIn(config) ? `It shows in light, as you style it; to follow the person's light or dark theme, declare it in its head (<meta name="color-scheme" content="light dark">) and draw with the variables below.` : "It runs in the person's light or dark theme."} It runs again each time it is shown${loadsScripts(config) ? ": pin library versions" : ""}.`,
161
165
  "",
162
166
  "Sizes that read on a phone:",
163
167
  c.when(
@@ -173,7 +177,9 @@ function widget(c: Context): string {
173
177
  ? "\nLibraries: pin an exact version at least two weeks old (chart.js@4.4.1 in its URL), never @latest or a range, so the code can't be swapped under you and draws the same every time."
174
178
  : null,
175
179
  "",
176
- `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
180
+ optsIn(config)
181
+ ? `Colors, for a ${noun} that declares light and dark: every color from these variables, never hard-coded, so both work; its own background then stays transparent (it sits on the app's surface).`
182
+ : `Colors: every color from these variables, never hard-coded, so light and dark both work. The ${noun}'s own background is transparent (it sits on the app's surface).`,
177
183
  c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`,
178
184
  c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`,
179
185
  c.names("--color-border-") === ""
@@ -4,6 +4,7 @@ import { declaredCapabilities } from "../compile/policy.ts";
4
4
  import { capabilitySpec } from "../config/capabilities.ts";
5
5
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
6
6
  import { locateServed } from "./locate.ts";
7
+ import { watchOutcome, type OutcomeWatch } from "./outcome.ts";
7
8
  import type { FrameSource } from "../isolation/types.ts";
8
9
  import { webPlatform } from "../platform/web.ts";
9
10
  import type { Surface } from "../platform/types.ts";
@@ -23,6 +24,9 @@ import type {
23
24
  PortholeHost,
24
25
  } from "./types.ts";
25
26
 
27
+ /** How long a display may take to load before its outcome is a timeout, by default. */
28
+ const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
29
+
26
30
  /** How many times a display is started again after its content navigated its frame away. */
27
31
  const MAX_RESTARTS = 3;
28
32
 
@@ -81,12 +85,14 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
81
85
  let restarts = 0;
82
86
  let generation = 0;
83
87
  let abort: AbortController | null = null;
88
+ let outcomeWatch: OutcomeWatch | null = null;
89
+ let themeFrame: number | null = null;
90
+ const view = container.ownerDocument.defaultView;
84
91
  // The adapter's own operations, the last call of each, replayed into every new frame.
85
92
  const replay = new Map<string, unknown[]>();
86
93
 
87
94
  const hostContext = (): HostContext => {
88
95
  const width = container.getBoundingClientRect().width;
89
- const view = container.ownerDocument.defaultView;
90
96
  // The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
91
97
  const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
92
98
  const standard: Record<string, string> = {};
@@ -142,6 +148,8 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
142
148
  const stop = () => {
143
149
  abort?.abort();
144
150
  abort = null;
151
+ outcomeWatch?.stop();
152
+ outcomeWatch = null;
145
153
  connection?.close();
146
154
  connection = null;
147
155
  surface?.destroy();
@@ -156,10 +164,29 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
156
164
  allow: compiled.allow,
157
165
  referrerPolicy: compiled.referrerPolicy,
158
166
  colorScheme: scheme,
159
- initialHeightPx: height,
167
+ // An app reports no size: its frame fills the host's container, at least the minimum.
168
+ ...(speaks
169
+ ? { initialHeightPx: height }
170
+ : { fill: true, initialHeightPx: config.limits.minHeightPx }),
160
171
  });
161
172
  surface = created;
173
+ const watch = watchOutcome({
174
+ surface: created,
175
+ source,
176
+ speaks,
177
+ timeoutMs: config.limits.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
178
+ onOutcome: (outcome) => emit("outcome", outcome),
179
+ });
180
+ outcomeWatch = watch;
162
181
  if (!speaks) {
182
+ // No handshake in url mode: the app's first document loading is as ready as it gets.
183
+ let ready = false;
184
+ created.onLoad(() => {
185
+ if (!ready) {
186
+ ready = true;
187
+ emit("initialized", undefined);
188
+ }
189
+ });
163
190
  return;
164
191
  }
165
192
  const bridge = createBridge({
@@ -179,6 +206,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
179
206
  hostContext,
180
207
  emit: (event) => {
181
208
  if (event.type === "initialized") {
209
+ watch.initialized();
182
210
  emit("initialized", undefined);
183
211
  }
184
212
  },
@@ -249,6 +277,23 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
249
277
  setTheme(next: ColorScheme) {
250
278
  scheme = next;
251
279
  surface?.setColorScheme(next);
280
+ // Read the host's values two frames later: a host that restyles its page for the new
281
+ // scheme on its next frame has done so by then.
282
+ if (view === null) {
283
+ connection?.hostContextChanged(hostContext());
284
+ return;
285
+ }
286
+ if (themeFrame !== null) {
287
+ view.cancelAnimationFrame(themeFrame);
288
+ }
289
+ themeFrame = view.requestAnimationFrame(() => {
290
+ themeFrame = view.requestAnimationFrame(() => {
291
+ themeFrame = null;
292
+ connection?.hostContextChanged(hostContext());
293
+ });
294
+ });
295
+ },
296
+ refreshTheme() {
252
297
  connection?.hostContextChanged(hostContext());
253
298
  },
254
299
  on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void) {
@@ -16,5 +16,10 @@ export async function locateServed(
16
16
  ): Promise<FrameSource> {
17
17
  const location = await locator.locate(instance, signal);
18
18
  checkFrameLocation(location, config.contentOrigins, hostOrigin);
19
- return { kind: "url", url: new URL(location.url).href, origin: location.origin };
19
+ return {
20
+ kind: "url",
21
+ url: new URL(location.url).href,
22
+ origin: location.origin,
23
+ ...(location.reports === true ? { reports: true } : {}),
24
+ };
20
25
  }
@@ -0,0 +1,177 @@
1
+ import { FRAME_STATUS_METHOD, type FrameSource } from "../isolation/types.ts";
2
+ import type { Surface } from "../platform/types.ts";
3
+ import type { FrameBlock, FrameOutcome } from "./types.ts";
4
+
5
+ /**
6
+ * How a display's first document went (P040). A frame's content is another origin's, so the host
7
+ * can't look inside; it goes by what it can see:
8
+ *
9
+ * - a shell or view that speaks for itself is loaded when it finishes its handshake;
10
+ * - a page of Porthole's `/server` says how it went (`porthole/notifications/frame-status`): it is
11
+ * entering an app, or blocked (its ticket used, no cookie kept in a frame, the app down); a url
12
+ * app is loaded when its document loads after the entry page;
13
+ * - a load from which nothing ever spoke, where something should have, is a page that never ran:
14
+ * framing refused;
15
+ * - an app from a host's own gateway is loaded on its first load, unless its page says it is
16
+ * blocked (the gateway may send the same message);
17
+ * - nothing settled in `timeoutMs`: a timeout.
18
+ */
19
+
20
+ /** How long after a load the frame's word may still arrive. */
21
+ const SETTLE_MS = 400;
22
+
23
+ const BLOCKS: readonly FrameBlock[] = [
24
+ "framing",
25
+ "cookies",
26
+ "session",
27
+ "upstream",
28
+ "denied",
29
+ "not-found",
30
+ "error",
31
+ ];
32
+
33
+ interface FrameStatus {
34
+ readonly state: "entering" | "blocked";
35
+ readonly reason?: FrameBlock;
36
+ readonly status?: number;
37
+ readonly code?: string;
38
+ }
39
+
40
+ /** A frame-status message, or null for anything else. */
41
+ export function parseFrameStatus(data: unknown): FrameStatus | null {
42
+ if (data === null || typeof data !== "object") {
43
+ return null;
44
+ }
45
+ const message = data as { jsonrpc?: unknown; method?: unknown; params?: unknown };
46
+ if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
47
+ return null;
48
+ }
49
+ const params = message.params as {
50
+ state?: unknown;
51
+ reason?: unknown;
52
+ status?: unknown;
53
+ code?: unknown;
54
+ } | null;
55
+ if (params === null || typeof params !== "object") {
56
+ return null;
57
+ }
58
+ if (params.state === "entering") {
59
+ return { state: "entering" };
60
+ }
61
+ if (params.state !== "blocked") {
62
+ return null;
63
+ }
64
+ const reason = BLOCKS.includes(params.reason as FrameBlock)
65
+ ? (params.reason as FrameBlock)
66
+ : "error";
67
+ return {
68
+ state: "blocked",
69
+ reason,
70
+ ...(typeof params.status === "number" && Number.isInteger(params.status)
71
+ ? { status: params.status }
72
+ : {}),
73
+ ...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
74
+ ? { code: params.code }
75
+ : {}),
76
+ };
77
+ }
78
+
79
+ export interface OutcomeWatch {
80
+ /** The frame finished its handshake. */
81
+ initialized(): void;
82
+ stop(): void;
83
+ }
84
+
85
+ export function watchOutcome(options: {
86
+ readonly surface: Surface;
87
+ readonly source: FrameSource;
88
+ /** Whether the frame speaks for itself (a shell or a view): then its handshake is the proof. */
89
+ readonly speaks: boolean;
90
+ readonly timeoutMs: number;
91
+ readonly onOutcome: (outcome: FrameOutcome) => void;
92
+ }): OutcomeWatch {
93
+ const { surface, source, speaks } = options;
94
+ const reports = source.kind === "url" && source.reports === true;
95
+ // The first outcome; after a load, only a later block (a status page, access revoked) is told.
96
+ let decided: FrameOutcome["state"] | null = null;
97
+ let heard = false;
98
+ let entering = false;
99
+ let loads = 0;
100
+ const timers = new Set<ReturnType<typeof setTimeout>>();
101
+ const later = (ms: number, run: () => void) => {
102
+ const timer = setTimeout(() => {
103
+ timers.delete(timer);
104
+ run();
105
+ }, ms);
106
+ timers.add(timer);
107
+ };
108
+ const clearTimers = () => {
109
+ for (const timer of timers) {
110
+ clearTimeout(timer);
111
+ }
112
+ timers.clear();
113
+ };
114
+ const stop = () => {
115
+ clearTimers();
116
+ unlisten();
117
+ unload();
118
+ };
119
+ const decide = (outcome: FrameOutcome) => {
120
+ if (decided === null) {
121
+ decided = outcome.state;
122
+ clearTimers();
123
+ options.onOutcome(outcome);
124
+ } else if (decided === "loaded" && outcome.state === "blocked") {
125
+ decided = "blocked";
126
+ options.onOutcome(outcome);
127
+ }
128
+ };
129
+ const unlisten = surface.transport.listen((data) => {
130
+ heard = true;
131
+ const status = parseFrameStatus(data);
132
+ if (status?.state === "entering") {
133
+ entering = true;
134
+ } else if (status?.state === "blocked") {
135
+ decide({
136
+ state: "blocked",
137
+ reason: status.reason ?? "error",
138
+ ...(status.status === undefined ? {} : { status: status.status }),
139
+ ...(status.code === undefined ? {} : { code: status.code }),
140
+ });
141
+ }
142
+ });
143
+ const neverRan = () =>
144
+ later(SETTLE_MS, () => {
145
+ if (!heard) {
146
+ decide({ state: "blocked", reason: "framing" });
147
+ }
148
+ });
149
+ const unload = surface.onLoad(() => {
150
+ loads += 1;
151
+ if (decided !== null) {
152
+ return;
153
+ }
154
+ if (speaks) {
155
+ // A served shell or view says something before its first load ends; one that never did
156
+ // never ran. (An opaque srcdoc frame always runs: its handshake decides.)
157
+ if (source.kind === "url") {
158
+ neverRan();
159
+ }
160
+ return;
161
+ }
162
+ if (!reports) {
163
+ later(SETTLE_MS, () => decide({ state: "loaded" }));
164
+ return;
165
+ }
166
+ if (entering && loads >= 2) {
167
+ decide({ state: "loaded" });
168
+ } else if (!entering) {
169
+ neverRan();
170
+ }
171
+ });
172
+ later(options.timeoutMs, () => decide({ state: "timeout" }));
173
+ return {
174
+ initialized: () => decide({ state: "loaded" }),
175
+ stop,
176
+ };
177
+ }