@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
|
@@ -71,17 +71,40 @@ async function plantHostSecrets(page) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
/** Registers every served-mode case as a test. */
|
|
74
|
+
/**
|
|
75
|
+
* Registers every served-mode case as a test. A host that wants to select them by its own spec
|
|
76
|
+
* file registers `servedTests` itself (see `ConformanceTest`).
|
|
77
|
+
*/
|
|
74
78
|
export function defineServedSuite(test, host, options = {}) {
|
|
75
79
|
const typed = test;
|
|
80
|
+
typed.describe(`served modes (${host.name})`, () => {
|
|
81
|
+
for (const each of servedTests(host, options)) {
|
|
82
|
+
// Playwright reads the fixtures a test uses from its parameter's destructuring.
|
|
83
|
+
typed(each.title, ({ page, browserName }, info) => each.run({ page, browserName }, info));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** Every served-mode case as a test entry (see `ConformanceTest`). */
|
|
88
|
+
export function servedTests(host, options = {}) {
|
|
89
|
+
const tests = [];
|
|
90
|
+
let info = null;
|
|
91
|
+
// The case being run's own TestInfo (for an expected failure measured on one engine).
|
|
92
|
+
const typed = {
|
|
93
|
+
fail: (condition, reason) => info?.fail(condition, reason),
|
|
94
|
+
};
|
|
76
95
|
const define = (id, title, body) => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
tests.push({
|
|
97
|
+
id,
|
|
98
|
+
title: `${id}: ${title}`,
|
|
99
|
+
async run({ page, browserName }, testInfo) {
|
|
100
|
+
const reason = options.skip?.[id];
|
|
101
|
+
testInfo.skip(reason !== undefined, reason);
|
|
102
|
+
info = testInfo;
|
|
103
|
+
await body({ page, browserName });
|
|
104
|
+
},
|
|
82
105
|
});
|
|
83
106
|
};
|
|
84
|
-
|
|
107
|
+
{
|
|
85
108
|
const modes = [
|
|
86
109
|
["domain", (scope) => host.shell(scope)],
|
|
87
110
|
["url", (scope) => host.app(scope)],
|
|
@@ -307,5 +330,6 @@ export function defineServedSuite(test, host, options = {}) {
|
|
|
307
330
|
})
|
|
308
331
|
.toBe("ready");
|
|
309
332
|
});
|
|
310
|
-
}
|
|
333
|
+
}
|
|
334
|
+
return tests;
|
|
311
335
|
}
|
package/dist/guide/guide.js
CHANGED
|
@@ -63,6 +63,8 @@ function network(config) {
|
|
|
63
63
|
const parts = [...groups].map(([words, origins]) => `${words} from ${origins.join(", ")}`);
|
|
64
64
|
return `Network: only ${parts.join("; ")}. Anything else fails.`;
|
|
65
65
|
}
|
|
66
|
+
/** Whether a whole document takes the theme only when it declares support (ThemeConfig.documents). */
|
|
67
|
+
const optsIn = (config) => config.mode === "artifact" && (config.theme.documents ?? "declared") === "declared";
|
|
66
68
|
const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
|
|
67
69
|
/** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
|
|
68
70
|
function forbidden(config) {
|
|
@@ -100,9 +102,11 @@ function widget(c) {
|
|
|
100
102
|
const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
|
|
101
103
|
const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
|
|
102
104
|
const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
|
|
103
|
-
return lines(`# ${title}${tool}`, "", what, "", `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
|
|
105
|
+
return lines(`# ${title}${tool}`, "", what, "", `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" : ""}.`, "", "Sizes that read on a phone:", c.when(`- Words go in HTML (titles, notes, legends, readouts): it keeps its size and wraps. The frame's text is already ${textSize} --font-sans in --color-text-primary; notes --font-text-sm-size, never under 12 px.`), `- An SVG scales with the frame: its text shows at font-size × frame width ÷ viewBox width, so a 680-wide viewBox turns 12 into 5 px on a phone. Draw it for the phone: viewBox="0 0 ${viewBox} H", no text under 13, width="100%" style="max-width: 400px"; its text then shows at ${smallest} to ${largest} px on every screen. Or draw it 1 unit = 1 px with a script (topic chart).`, `- The frame never scrolls sideways: nothing fixed wider than ${layout.minWidthPx} px; images max-width: 100%; a grid or flex child with long text min-width: 0; a wide table or code block in its own box with overflow-x: auto.`, "- Cards in a row wrap (flex-wrap: wrap; flex: 1 1 100px), or a grid of repeat(auto-fit, minmax(160px, 1fr)): at most 2 columns under 500 px, never a fixed repeat(3, …).", "- Touch: anything clickable is at least 44 px tall. Phones don't hover: what a hover shows, a tap (click) shows too.", "", `${forbidden(config)} ${network(config)} Keep it under ${Math.floor(limits.maxBytes / 1000)} KB.`, loadsScripts(config)
|
|
104
106
|
? "\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."
|
|
105
|
-
: null, "",
|
|
107
|
+
: null, "", optsIn(config)
|
|
108
|
+
? `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).`
|
|
109
|
+
: `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).`, c.names("--color-background-") === "" ? null : `- Surfaces: ${c.names("--color-background-")}`, c.names("--color-text-") === "" ? null : `- Text: ${c.names("--color-text-")}`, c.names("--color-border-") === ""
|
|
106
110
|
? null
|
|
107
111
|
: `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`, c.names("--color-accent") === ""
|
|
108
112
|
? null
|
package/dist/host/create-host.js
CHANGED
|
@@ -3,10 +3,13 @@ import { compile } from "../compile/compile.js";
|
|
|
3
3
|
import { declaredCapabilities } from "../compile/policy.js";
|
|
4
4
|
import { capabilitySpec } from "../config/capabilities.js";
|
|
5
5
|
import { locateServed } from "./locate.js";
|
|
6
|
+
import { watchOutcome } from "./outcome.js";
|
|
6
7
|
import { webPlatform } from "../platform/web.js";
|
|
7
8
|
import { MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "../protocol/mcp-apps.js";
|
|
8
9
|
import { shellAdapter } from "../protocol/shell-adapter.js";
|
|
9
10
|
import { resolveTheme } from "../theme/variables.js";
|
|
11
|
+
/** How long a display may take to load before its outcome is a timeout, by default. */
|
|
12
|
+
const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
|
|
10
13
|
/** How many times a display is started again after its content navigated its frame away. */
|
|
11
14
|
const MAX_RESTARTS = 3;
|
|
12
15
|
const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
|
|
@@ -52,11 +55,13 @@ export function createHost(config, options = {}) {
|
|
|
52
55
|
let restarts = 0;
|
|
53
56
|
let generation = 0;
|
|
54
57
|
let abort = null;
|
|
58
|
+
let outcomeWatch = null;
|
|
59
|
+
let themeFrame = null;
|
|
60
|
+
const view = container.ownerDocument.defaultView;
|
|
55
61
|
// The adapter's own operations, the last call of each, replayed into every new frame.
|
|
56
62
|
const replay = new Map();
|
|
57
63
|
const hostContext = () => {
|
|
58
64
|
const width = container.getBoundingClientRect().width;
|
|
59
|
-
const view = container.ownerDocument.defaultView;
|
|
60
65
|
// The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
|
|
61
66
|
const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
|
|
62
67
|
const standard = {};
|
|
@@ -107,6 +112,8 @@ export function createHost(config, options = {}) {
|
|
|
107
112
|
const stop = () => {
|
|
108
113
|
abort?.abort();
|
|
109
114
|
abort = null;
|
|
115
|
+
outcomeWatch?.stop();
|
|
116
|
+
outcomeWatch = null;
|
|
110
117
|
connection?.close();
|
|
111
118
|
connection = null;
|
|
112
119
|
surface?.destroy();
|
|
@@ -120,10 +127,29 @@ export function createHost(config, options = {}) {
|
|
|
120
127
|
allow: compiled.allow,
|
|
121
128
|
referrerPolicy: compiled.referrerPolicy,
|
|
122
129
|
colorScheme: scheme,
|
|
123
|
-
|
|
130
|
+
// An app reports no size: its frame fills the host's container, at least the minimum.
|
|
131
|
+
...(speaks
|
|
132
|
+
? { initialHeightPx: height }
|
|
133
|
+
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
124
134
|
});
|
|
125
135
|
surface = created;
|
|
136
|
+
const watch = watchOutcome({
|
|
137
|
+
surface: created,
|
|
138
|
+
source,
|
|
139
|
+
speaks,
|
|
140
|
+
timeoutMs: config.limits.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
|
|
141
|
+
onOutcome: (outcome) => emit("outcome", outcome),
|
|
142
|
+
});
|
|
143
|
+
outcomeWatch = watch;
|
|
126
144
|
if (!speaks) {
|
|
145
|
+
// No handshake in url mode: the app's first document loading is as ready as it gets.
|
|
146
|
+
let ready = false;
|
|
147
|
+
created.onLoad(() => {
|
|
148
|
+
if (!ready) {
|
|
149
|
+
ready = true;
|
|
150
|
+
emit("initialized", undefined);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
127
153
|
return;
|
|
128
154
|
}
|
|
129
155
|
const bridge = createBridge({
|
|
@@ -143,6 +169,7 @@ export function createHost(config, options = {}) {
|
|
|
143
169
|
hostContext,
|
|
144
170
|
emit: (event) => {
|
|
145
171
|
if (event.type === "initialized") {
|
|
172
|
+
watch.initialized();
|
|
146
173
|
emit("initialized", undefined);
|
|
147
174
|
}
|
|
148
175
|
},
|
|
@@ -208,6 +235,23 @@ export function createHost(config, options = {}) {
|
|
|
208
235
|
setTheme(next) {
|
|
209
236
|
scheme = next;
|
|
210
237
|
surface?.setColorScheme(next);
|
|
238
|
+
// Read the host's values two frames later: a host that restyles its page for the new
|
|
239
|
+
// scheme on its next frame has done so by then.
|
|
240
|
+
if (view === null) {
|
|
241
|
+
connection?.hostContextChanged(hostContext());
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (themeFrame !== null) {
|
|
245
|
+
view.cancelAnimationFrame(themeFrame);
|
|
246
|
+
}
|
|
247
|
+
themeFrame = view.requestAnimationFrame(() => {
|
|
248
|
+
themeFrame = view.requestAnimationFrame(() => {
|
|
249
|
+
themeFrame = null;
|
|
250
|
+
connection?.hostContextChanged(hostContext());
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
refreshTheme() {
|
|
211
255
|
connection?.hostContextChanged(hostContext());
|
|
212
256
|
},
|
|
213
257
|
on(event, listener) {
|
package/dist/host/locate.js
CHANGED
|
@@ -7,5 +7,10 @@ import { checkFrameLocation } from "../isolation/served.js";
|
|
|
7
7
|
export async function locateServed(locator, instance, config, hostOrigin, signal) {
|
|
8
8
|
const location = await locator.locate(instance, signal);
|
|
9
9
|
checkFrameLocation(location, config.contentOrigins, hostOrigin);
|
|
10
|
-
return {
|
|
10
|
+
return {
|
|
11
|
+
kind: "url",
|
|
12
|
+
url: new URL(location.url).href,
|
|
13
|
+
origin: location.origin,
|
|
14
|
+
...(location.reports === true ? { reports: true } : {}),
|
|
15
|
+
};
|
|
11
16
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type FrameSource } from "../isolation/types.ts";
|
|
2
|
+
import type { Surface } from "../platform/types.ts";
|
|
3
|
+
import type { FrameBlock, FrameOutcome } from "./types.ts";
|
|
4
|
+
interface FrameStatus {
|
|
5
|
+
readonly state: "entering" | "blocked";
|
|
6
|
+
readonly reason?: FrameBlock;
|
|
7
|
+
readonly status?: number;
|
|
8
|
+
readonly code?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A frame-status message, or null for anything else. */
|
|
11
|
+
export declare function parseFrameStatus(data: unknown): FrameStatus | null;
|
|
12
|
+
export interface OutcomeWatch {
|
|
13
|
+
/** The frame finished its handshake. */
|
|
14
|
+
initialized(): void;
|
|
15
|
+
stop(): void;
|
|
16
|
+
}
|
|
17
|
+
export declare function watchOutcome(options: {
|
|
18
|
+
readonly surface: Surface;
|
|
19
|
+
readonly source: FrameSource;
|
|
20
|
+
/** Whether the frame speaks for itself (a shell or a view): then its handshake is the proof. */
|
|
21
|
+
readonly speaks: boolean;
|
|
22
|
+
readonly timeoutMs: number;
|
|
23
|
+
readonly onOutcome: (outcome: FrameOutcome) => void;
|
|
24
|
+
}): OutcomeWatch;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a display's first document went (P040). A frame's content is another origin's, so the host
|
|
4
|
+
* can't look inside; it goes by what it can see:
|
|
5
|
+
*
|
|
6
|
+
* - a shell or view that speaks for itself is loaded when it finishes its handshake;
|
|
7
|
+
* - a page of Porthole's `/server` says how it went (`porthole/notifications/frame-status`): it is
|
|
8
|
+
* entering an app, or blocked (its ticket used, no cookie kept in a frame, the app down); a url
|
|
9
|
+
* app is loaded when its document loads after the entry page;
|
|
10
|
+
* - a load from which nothing ever spoke, where something should have, is a page that never ran:
|
|
11
|
+
* framing refused;
|
|
12
|
+
* - an app from a host's own gateway is loaded on its first load, unless its page says it is
|
|
13
|
+
* blocked (the gateway may send the same message);
|
|
14
|
+
* - nothing settled in `timeoutMs`: a timeout.
|
|
15
|
+
*/
|
|
16
|
+
/** How long after a load the frame's word may still arrive. */
|
|
17
|
+
const SETTLE_MS = 400;
|
|
18
|
+
const BLOCKS = [
|
|
19
|
+
"framing",
|
|
20
|
+
"cookies",
|
|
21
|
+
"session",
|
|
22
|
+
"upstream",
|
|
23
|
+
"denied",
|
|
24
|
+
"not-found",
|
|
25
|
+
"error",
|
|
26
|
+
];
|
|
27
|
+
/** A frame-status message, or null for anything else. */
|
|
28
|
+
export function parseFrameStatus(data) {
|
|
29
|
+
if (data === null || typeof data !== "object") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const message = data;
|
|
33
|
+
if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const params = message.params;
|
|
37
|
+
if (params === null || typeof params !== "object") {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (params.state === "entering") {
|
|
41
|
+
return { state: "entering" };
|
|
42
|
+
}
|
|
43
|
+
if (params.state !== "blocked") {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const reason = BLOCKS.includes(params.reason)
|
|
47
|
+
? params.reason
|
|
48
|
+
: "error";
|
|
49
|
+
return {
|
|
50
|
+
state: "blocked",
|
|
51
|
+
reason,
|
|
52
|
+
...(typeof params.status === "number" && Number.isInteger(params.status)
|
|
53
|
+
? { status: params.status }
|
|
54
|
+
: {}),
|
|
55
|
+
...(typeof params.code === "string" && /^[a-z0-9-]{1,40}$/u.test(params.code)
|
|
56
|
+
? { code: params.code }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function watchOutcome(options) {
|
|
61
|
+
const { surface, source, speaks } = options;
|
|
62
|
+
const reports = source.kind === "url" && source.reports === true;
|
|
63
|
+
// The first outcome; after a load, only a later block (a status page, access revoked) is told.
|
|
64
|
+
let decided = null;
|
|
65
|
+
let heard = false;
|
|
66
|
+
let entering = false;
|
|
67
|
+
let loads = 0;
|
|
68
|
+
const timers = new Set();
|
|
69
|
+
const later = (ms, run) => {
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
timers.delete(timer);
|
|
72
|
+
run();
|
|
73
|
+
}, ms);
|
|
74
|
+
timers.add(timer);
|
|
75
|
+
};
|
|
76
|
+
const clearTimers = () => {
|
|
77
|
+
for (const timer of timers) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
timers.clear();
|
|
81
|
+
};
|
|
82
|
+
const stop = () => {
|
|
83
|
+
clearTimers();
|
|
84
|
+
unlisten();
|
|
85
|
+
unload();
|
|
86
|
+
};
|
|
87
|
+
const decide = (outcome) => {
|
|
88
|
+
if (decided === null) {
|
|
89
|
+
decided = outcome.state;
|
|
90
|
+
clearTimers();
|
|
91
|
+
options.onOutcome(outcome);
|
|
92
|
+
}
|
|
93
|
+
else if (decided === "loaded" && outcome.state === "blocked") {
|
|
94
|
+
decided = "blocked";
|
|
95
|
+
options.onOutcome(outcome);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const unlisten = surface.transport.listen((data) => {
|
|
99
|
+
heard = true;
|
|
100
|
+
const status = parseFrameStatus(data);
|
|
101
|
+
if (status?.state === "entering") {
|
|
102
|
+
entering = true;
|
|
103
|
+
}
|
|
104
|
+
else if (status?.state === "blocked") {
|
|
105
|
+
decide({
|
|
106
|
+
state: "blocked",
|
|
107
|
+
reason: status.reason ?? "error",
|
|
108
|
+
...(status.status === undefined ? {} : { status: status.status }),
|
|
109
|
+
...(status.code === undefined ? {} : { code: status.code }),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
const neverRan = () => later(SETTLE_MS, () => {
|
|
114
|
+
if (!heard) {
|
|
115
|
+
decide({ state: "blocked", reason: "framing" });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const unload = surface.onLoad(() => {
|
|
119
|
+
loads += 1;
|
|
120
|
+
if (decided !== null) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (speaks) {
|
|
124
|
+
// A served shell or view says something before its first load ends; one that never did
|
|
125
|
+
// never ran. (An opaque srcdoc frame always runs: its handshake decides.)
|
|
126
|
+
if (source.kind === "url") {
|
|
127
|
+
neverRan();
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (!reports) {
|
|
132
|
+
later(SETTLE_MS, () => decide({ state: "loaded" }));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (entering && loads >= 2) {
|
|
136
|
+
decide({ state: "loaded" });
|
|
137
|
+
}
|
|
138
|
+
else if (!entering) {
|
|
139
|
+
neverRan();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
later(options.timeoutMs, () => decide({ state: "timeout" }));
|
|
143
|
+
return {
|
|
144
|
+
initialized: () => decide({ state: "loaded" }),
|
|
145
|
+
stop,
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -51,9 +51,41 @@ export interface MountOptions {
|
|
|
51
51
|
readonly initialHeightPx?: number;
|
|
52
52
|
}
|
|
53
53
|
/** What a display reports, by event name. */
|
|
54
|
+
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
55
|
+
export type FrameBlock =
|
|
56
|
+
/** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
|
|
57
|
+
"framing"
|
|
58
|
+
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
59
|
+
| "cookies"
|
|
60
|
+
/** Its ticket was used or its session ended. */
|
|
61
|
+
| "session"
|
|
62
|
+
/** Its app didn't answer. */
|
|
63
|
+
| "upstream"
|
|
64
|
+
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
65
|
+
| "denied" | "not-found" | "error";
|
|
66
|
+
/**
|
|
67
|
+
* How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
|
|
68
|
+
* app's document loaded), it was blocked (and why), or nothing settled in
|
|
69
|
+
* `limits.loadTimeoutMs`. Once per run, plus a later `blocked` if the frame shows a status page
|
|
70
|
+
* after it loaded (access revoked mid-session). A host shows its own fallback ("Open in a new
|
|
71
|
+
* tab") on anything but loaded.
|
|
72
|
+
*/
|
|
73
|
+
export type FrameOutcome = {
|
|
74
|
+
readonly state: "loaded";
|
|
75
|
+
} | {
|
|
76
|
+
readonly state: "blocked";
|
|
77
|
+
readonly reason: FrameBlock;
|
|
78
|
+
readonly status?: number;
|
|
79
|
+
/** The host's own reason code, for `denied` (`Authorizer`). */
|
|
80
|
+
readonly code?: string;
|
|
81
|
+
} | {
|
|
82
|
+
readonly state: "timeout";
|
|
83
|
+
};
|
|
54
84
|
export interface DisplayEvents {
|
|
55
|
-
/** The frame finished its handshake. */
|
|
85
|
+
/** The frame finished its handshake (url mode, which has none: its first document loaded). */
|
|
56
86
|
readonly initialized: undefined;
|
|
87
|
+
/** How the frame's first document went (`FrameOutcome`). */
|
|
88
|
+
readonly outcome: FrameOutcome;
|
|
57
89
|
/** The content's size, after the config's limits. */
|
|
58
90
|
readonly resize: {
|
|
59
91
|
readonly height: number;
|
|
@@ -75,8 +107,13 @@ export interface DisplayEvents {
|
|
|
75
107
|
}
|
|
76
108
|
export interface DisplayControls {
|
|
77
109
|
readonly displayId: string;
|
|
78
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* The theme flipped: the frame is re-themed in place, without a reload. The host's values are
|
|
112
|
+
* read two animation frames later, once a host that restyles its page asynchronously has.
|
|
113
|
+
*/
|
|
79
114
|
setTheme(scheme: ColorScheme): void;
|
|
115
|
+
/** The host's theme values changed (same scheme): read them now and re-theme the frame. */
|
|
116
|
+
refreshTheme(): void;
|
|
80
117
|
on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void): () => void;
|
|
81
118
|
/** Runs the content again from the start, in a new frame. */
|
|
82
119
|
reload(): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
6
6
|
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
7
|
-
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.ts";
|
|
7
|
+
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
|
|
8
|
+
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
|
|
9
|
+
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
10
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
8
11
|
export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
|
|
9
12
|
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
10
13
|
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
@@ -18,6 +21,6 @@ export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
|
|
|
18
21
|
export type { Transport } from "./transport/types.ts";
|
|
19
22
|
export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
|
|
20
23
|
export { MCP_APPS_METHODS, MCP_APPS_PROTOCOL_VERSION, MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, type HostContext, } from "./protocol/mcp-apps.ts";
|
|
21
|
-
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmRequest, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
24
|
+
export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
|
|
22
25
|
export { createHost } from "./host/create-host.ts";
|
|
23
|
-
export type { DisplayControls, DisplayEvents, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
|
26
|
+
export type { DisplayControls, DisplayEvents, FrameBlock, FrameOutcome, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
// The config and its vocabulary.
|
|
6
6
|
export { CAPABILITIES, CAPABILITY_NAMES, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
7
|
-
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.js";
|
|
7
|
+
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
|
|
8
|
+
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
|
|
9
|
+
export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
|
|
10
|
+
export { FRAME_STATUS_METHOD } from "./isolation/types.js";
|
|
8
11
|
// Presets.
|
|
9
12
|
export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
|
|
10
13
|
// The compiler.
|
|
@@ -8,6 +8,9 @@ interface ParsedOrigin {
|
|
|
8
8
|
export declare function parseOrigin(origin: string): ParsedOrigin | null;
|
|
9
9
|
/** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
|
|
10
10
|
export declare function isExactOrigin(origin: unknown): origin is string;
|
|
11
|
+
export declare function isHostOrigin(origin: unknown): origin is string;
|
|
12
|
+
/** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
|
|
13
|
+
export declare function hostOriginProblem(origin: unknown): string | null;
|
|
11
14
|
/**
|
|
12
15
|
* Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
|
|
13
16
|
* it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
|
package/dist/isolation/served.js
CHANGED
|
@@ -19,6 +19,26 @@ export function parseOrigin(origin) {
|
|
|
19
19
|
export function isExactOrigin(origin) {
|
|
20
20
|
return typeof origin === "string" && parseOrigin(origin) !== null;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* A host page's origin, exactly as `location.origin` and a message's `event.origin` give it:
|
|
24
|
+
* `scheme://host[:port]`, lowercase, any scheme (an Electron or Tauri app's own registered scheme,
|
|
25
|
+
* `ferry://app`, `app://local`), never `"null"` (an opaque origin isn't one origin: every opaque
|
|
26
|
+
* page says "null") and never a path. The shell takes messages only from these, exactly.
|
|
27
|
+
*/
|
|
28
|
+
const HOST_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/([a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*|\[[0-9a-f:.]+\])(:\d{1,5})?$/u;
|
|
29
|
+
export function isHostOrigin(origin) {
|
|
30
|
+
return typeof origin === "string" && origin.length <= 300 && HOST_ORIGIN.test(origin);
|
|
31
|
+
}
|
|
32
|
+
/** Why `origin` can't be a host origin, in words a host's developer can act on; null if it can. */
|
|
33
|
+
export function hostOriginProblem(origin) {
|
|
34
|
+
if (isHostOrigin(origin)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (origin === "null") {
|
|
38
|
+
return 'the host page\'s origin is opaque ("null"): serve it from a real origin (an Electron custom protocol must be registered as standard, and file: pages have none)';
|
|
39
|
+
}
|
|
40
|
+
return `${JSON.stringify(origin)} is not an exact origin (scheme://host[:port], lowercase, no path)`;
|
|
41
|
+
}
|
|
22
42
|
/**
|
|
23
43
|
* Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
|
|
24
44
|
* it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
|
|
@@ -64,7 +64,15 @@ export interface FrameLocation {
|
|
|
64
64
|
readonly url: string;
|
|
65
65
|
/** Its origin: the host takes messages only from this origin, from this frame. */
|
|
66
66
|
readonly origin: string;
|
|
67
|
+
/**
|
|
68
|
+
* Whether the pages there report how loading went (`porthole/notifications/frame-status`):
|
|
69
|
+
* Porthole's `/server` does, so a page that never ran (refused framing) can be told apart from
|
|
70
|
+
* one that loaded. A host's own gateway may send the same message without this.
|
|
71
|
+
*/
|
|
72
|
+
readonly reports?: boolean;
|
|
67
73
|
}
|
|
74
|
+
/** The message a served page sends its host about loading (own-prefixed, P040). */
|
|
75
|
+
export declare const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
|
|
68
76
|
/**
|
|
69
77
|
* Turns an instance into the URL its frame loads. Implemented by the host, usually by asking its
|
|
70
78
|
* own backend (which authenticates the viewer and calls `PortholeServer.issue`). Called on every
|
|
@@ -84,4 +92,5 @@ export type FrameSource = {
|
|
|
84
92
|
readonly kind: "url";
|
|
85
93
|
readonly url: string;
|
|
86
94
|
readonly origin: string;
|
|
95
|
+
readonly reports?: boolean;
|
|
87
96
|
};
|
package/dist/isolation/types.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
/** The message a served page sends its host about loading (own-prefixed, P040). */
|
|
2
|
+
export const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
|
package/dist/platform/types.d.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface SurfaceSpec {
|
|
|
15
15
|
readonly allow: string;
|
|
16
16
|
readonly referrerPolicy: "no-referrer";
|
|
17
17
|
readonly colorScheme: ColorScheme;
|
|
18
|
+
/**
|
|
19
|
+
* Fill the container's height (a url-mode app, which reports no size), at least
|
|
20
|
+
* `initialHeightPx`, instead of taking the content's.
|
|
21
|
+
*/
|
|
22
|
+
readonly fill?: boolean;
|
|
18
23
|
/** The height to start at (a remembered height, so a frame mounting again doesn't jump). */
|
|
19
24
|
readonly initialHeightPx: number;
|
|
20
25
|
}
|
|
@@ -35,6 +40,8 @@ export interface Surface {
|
|
|
35
40
|
* framework starts it again from its content and reports it.
|
|
36
41
|
*/
|
|
37
42
|
onNavigated(listener: () => void): () => void;
|
|
43
|
+
/** Every load of the frame's document, the first included. */
|
|
44
|
+
onLoad(listener: () => void): () => void;
|
|
38
45
|
destroy(): void;
|
|
39
46
|
}
|
|
40
47
|
export interface PlatformHost<Container = HTMLElement> {
|
package/dist/platform/web.js
CHANGED
|
@@ -23,7 +23,9 @@ export const webPlatform = {
|
|
|
23
23
|
border: "0",
|
|
24
24
|
background: "transparent",
|
|
25
25
|
colorScheme: spec.colorScheme,
|
|
26
|
-
|
|
26
|
+
...(spec.fill === true
|
|
27
|
+
? { height: "100%", minHeight: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }
|
|
28
|
+
: { height: `${Math.max(0, Math.round(spec.initialHeightPx))}px` }),
|
|
27
29
|
});
|
|
28
30
|
const origin = spec.source.kind === "srcdoc" ? "null" : spec.source.origin;
|
|
29
31
|
if (spec.source.kind === "srcdoc") {
|
|
@@ -33,9 +35,13 @@ export const webPlatform = {
|
|
|
33
35
|
frame.src = spec.source.url;
|
|
34
36
|
}
|
|
35
37
|
const navigated = new Set();
|
|
38
|
+
const loaded = new Set();
|
|
36
39
|
let loads = 0;
|
|
37
40
|
frame.addEventListener("load", () => {
|
|
38
41
|
loads += 1;
|
|
42
|
+
for (const listener of loaded) {
|
|
43
|
+
listener();
|
|
44
|
+
}
|
|
39
45
|
if (loads > 1) {
|
|
40
46
|
for (const listener of navigated) {
|
|
41
47
|
listener();
|
|
@@ -49,7 +55,9 @@ export const webPlatform = {
|
|
|
49
55
|
transport,
|
|
50
56
|
element: frame,
|
|
51
57
|
setHeight(px) {
|
|
52
|
-
|
|
58
|
+
if (spec.fill !== true) {
|
|
59
|
+
frame.style.height = `${Math.max(0, Math.round(px))}px`;
|
|
60
|
+
}
|
|
53
61
|
},
|
|
54
62
|
setColorScheme(scheme) {
|
|
55
63
|
frame.style.colorScheme = scheme;
|
|
@@ -58,12 +66,17 @@ export const webPlatform = {
|
|
|
58
66
|
const activation = view.navigator.userActivation;
|
|
59
67
|
return activation === undefined ? undefined : activation.isActive;
|
|
60
68
|
},
|
|
69
|
+
onLoad(listener) {
|
|
70
|
+
loaded.add(listener);
|
|
71
|
+
return () => loaded.delete(listener);
|
|
72
|
+
},
|
|
61
73
|
onNavigated(listener) {
|
|
62
74
|
navigated.add(listener);
|
|
63
75
|
return () => navigated.delete(listener);
|
|
64
76
|
},
|
|
65
77
|
destroy() {
|
|
66
78
|
navigated.clear();
|
|
79
|
+
loaded.clear();
|
|
67
80
|
transport.close();
|
|
68
81
|
frame.remove();
|
|
69
82
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
-
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue } from "../config/schema.ts";
|
|
2
|
+
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
|
|
3
3
|
/**
|
|
4
4
|
* The preset policy layer (P001): named configs with safe defaults. Every preset starts with
|
|
5
5
|
* egress closed and only the capabilities that carry nothing out of the frame (its size, its
|
|
@@ -14,10 +14,14 @@ export interface PresetOptions {
|
|
|
14
14
|
};
|
|
15
15
|
/** Origins to open, added to the preset's (closed) egress. */
|
|
16
16
|
readonly egress?: EgressConfig;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Theme defaults to change, variables to add, or (null) defaults to drop; the theme event's
|
|
19
|
+
* name; and whether whole documents follow the person's scheme (`ThemeConfig.documents`).
|
|
20
|
+
*/
|
|
18
21
|
readonly theme?: {
|
|
19
|
-
readonly variables?: Readonly<Record<string, ThemeValue>>;
|
|
22
|
+
readonly variables?: Readonly<Record<string, ThemeValue | null>>;
|
|
20
23
|
readonly event?: string;
|
|
24
|
+
readonly documents?: "declared" | "always";
|
|
21
25
|
};
|
|
22
26
|
readonly limits?: Partial<Limits>;
|
|
23
27
|
readonly layout?: Partial<LayoutConfig>;
|
|
@@ -30,6 +34,10 @@ export interface PresetOptions {
|
|
|
30
34
|
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
31
35
|
export interface UrlPresetOptions {
|
|
32
36
|
readonly contentOrigins: readonly string[];
|
|
37
|
+
/** Sandbox tokens to add for the app (popups for an OAuth flow, modals, downloads; P037). */
|
|
38
|
+
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
39
|
+
/** Features to delegate to the app (clipboard writes, full screen; P049). */
|
|
40
|
+
readonly permissions?: readonly UrlPermission[];
|
|
33
41
|
readonly limits?: Partial<Limits>;
|
|
34
42
|
readonly layout?: Partial<LayoutConfig>;
|
|
35
43
|
readonly embedder?: EmbedderConfig;
|