@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.
- package/dist/bridge/bridge.js +20 -6
- package/dist/bridge/types.d.ts +19 -3
- package/dist/compile/policy.d.ts +6 -5
- package/dist/compile/policy.js +10 -12
- package/dist/config/schema.d.ts +29 -0
- package/dist/config/schema.js +16 -1
- package/dist/config/validate.d.ts +3 -1
- package/dist/config/validate.js +55 -10
- package/dist/conformance/behavior.js +50 -0
- package/dist/conformance/index.d.ts +11 -5
- package/dist/conformance/index.js +2 -2
- package/dist/conformance/probes.d.ts +26 -2
- package/dist/conformance/probes.js +51 -40
- package/dist/conformance/served.d.ts +7 -0
- package/dist/conformance/served.js +31 -7
- package/dist/guide/guide.js +6 -2
- package/dist/host/create-host.js +46 -2
- package/dist/host/locate.js +6 -1
- package/dist/host/outcome.d.ts +25 -0
- package/dist/host/outcome.js +147 -0
- package/dist/host/types.d.ts +39 -2
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/isolation/served.d.ts +3 -0
- package/dist/isolation/served.js +20 -0
- package/dist/isolation/types.d.ts +9 -0
- package/dist/isolation/types.js +2 -1
- package/dist/platform/types.d.ts +7 -0
- package/dist/platform/web.js +15 -2
- package/dist/presets/presets.d.ts +11 -3
- package/dist/presets/presets.js +17 -1
- package/dist/react/frame.d.ts +16 -0
- package/dist/react/frame.js +44 -10
- package/dist/server/crypto.d.ts +19 -0
- package/dist/server/crypto.js +144 -0
- package/dist/server/index.d.ts +12 -4
- package/dist/server/index.js +11 -3
- package/dist/server/keys.js +12 -9
- package/dist/server/naming.d.ts +17 -0
- package/dist/server/naming.js +34 -0
- package/dist/server/pages.d.ts +11 -6
- package/dist/server/pages.js +86 -12
- package/dist/server/server.js +191 -23
- package/dist/server/tickets.d.ts +24 -0
- package/dist/server/tickets.js +46 -0
- package/dist/server/types.d.ts +76 -5
- package/dist/shell/document.d.ts +3 -2
- package/dist/shell/document.js +4 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +1 -0
- package/dist/shell/runtime.js +20 -4
- package/package.json +2 -9
- package/src/bridge/bridge.ts +28 -6
- package/src/bridge/types.ts +21 -3
- package/src/compile/policy.ts +10 -12
- package/src/config/schema.ts +38 -0
- package/src/config/validate.ts +63 -14
- package/src/conformance/behavior.ts +55 -0
- package/src/conformance/index.ts +18 -4
- package/src/conformance/probes.ts +88 -52
- package/src/conformance/served.ts +37 -8
- package/src/guide/guide.ts +8 -2
- package/src/host/create-host.ts +47 -2
- package/src/host/locate.ts +6 -1
- package/src/host/outcome.ts +177 -0
- package/src/host/types.ts +42 -2
- package/src/index.ts +19 -1
- package/src/isolation/served.ts +24 -0
- package/src/isolation/types.ts +15 -1
- package/src/platform/types.ts +7 -0
- package/src/platform/web.ts +15 -2
- package/src/presets/presets.ts +31 -3
- package/src/react/frame.tsx +78 -14
- package/src/server/crypto.ts +166 -0
- package/src/server/index.ts +20 -3
- package/src/server/keys.ts +21 -13
- package/src/server/naming.ts +42 -0
- package/src/server/pages.ts +106 -11
- package/src/server/server.ts +233 -23
- package/src/server/tickets.ts +65 -0
- package/src/server/types.ts +84 -7
- package/src/shell/document.ts +7 -6
- package/src/shell/plan.ts +3 -0
- 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",
|
package/src/conformance/index.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
8
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
1041
|
-
test(
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/guide/guide.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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-") === ""
|
package/src/host/create-host.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/host/locate.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|