@botiverse/porthole 0.0.1 → 0.1.1
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/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +198 -0
- package/dist/bridge/types.d.ts +83 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +103 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +152 -0
- package/dist/config/schema.js +11 -0
- package/dist/config/validate.d.ts +22 -0
- package/dist/config/validate.js +261 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +426 -0
- package/dist/conformance/index.d.ts +101 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +33 -0
- package/dist/conformance/probes.js +876 -0
- package/dist/conformance/served.d.ts +75 -0
- package/dist/conformance/served.js +335 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +265 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +292 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +16 -0
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +127 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +21 -0
- package/dist/isolation/served.d.ts +35 -0
- package/dist/isolation/served.js +117 -0
- package/dist/isolation/types.d.ts +96 -0
- package/dist/isolation/types.js +2 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +51 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +85 -0
- package/dist/presets/presets.d.ts +71 -0
- package/dist/presets/presets.js +140 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +91 -0
- package/dist/react/frame.js +255 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +86 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +542 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +29 -0
- package/dist/shell/document.js +85 -0
- package/dist/shell/plan.d.ts +37 -0
- package/dist/shell/plan.js +35 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +848 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +63 -1
- package/src/bridge/bridge.ts +278 -0
- package/src/bridge/types.ts +95 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +130 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +168 -0
- package/src/config/validate.ts +318 -0
- package/src/conformance/behavior.ts +485 -0
- package/src/conformance/index.ts +116 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1129 -0
- package/src/conformance/served.ts +501 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +430 -0
- package/src/host/create-host.ts +335 -0
- package/src/host/locate.ts +25 -0
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +126 -0
- package/src/index.ts +128 -0
- package/src/isolation/served.ts +151 -0
- package/src/isolation/types.ts +91 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +55 -0
- package/src/platform/web.ts +91 -0
- package/src/presets/presets.ts +217 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +486 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +117 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +657 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +121 -0
- package/src/shell/plan.ts +69 -0
- package/src/shell/runtime.ts +848 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { capabilitySpec } from "../config/capabilities.js";
|
|
2
|
+
import { BUILT_IN_TOPICS } from "../config/validate.js";
|
|
3
|
+
import { declaredCapabilities, effectiveGates, egressOrigins, sandboxTokens, } from "../compile/policy.js";
|
|
4
|
+
import { shellPlan } from "../shell/plan.js";
|
|
5
|
+
import { themeDefault } from "../theme/variables.js";
|
|
6
|
+
import { GUIDE_EXAMPLES } from "./examples.js";
|
|
7
|
+
const VARIABLE = /--[a-z0-9]+(?:-[a-z0-9]+)*/gu;
|
|
8
|
+
function context(config) {
|
|
9
|
+
const variables = Object.keys(config.theme.variables);
|
|
10
|
+
const has = (...names) => names.every((name) => variables.includes(name));
|
|
11
|
+
const declared = new Set(declaredCapabilities(config));
|
|
12
|
+
const globals = new Set(shellPlan(config)?.globals ?? []);
|
|
13
|
+
return {
|
|
14
|
+
config,
|
|
15
|
+
noun: config.mode === "artifact" ? "page" : config.mode === "mcp-app" ? "view" : "widget",
|
|
16
|
+
has,
|
|
17
|
+
when: (text) => (has(...(text.match(VARIABLE) ?? [])) ? text : null),
|
|
18
|
+
names: (prefix, except = []) => variables
|
|
19
|
+
.filter((name) => name.startsWith(prefix) && !except.some((other) => name.startsWith(other)))
|
|
20
|
+
.join(", "),
|
|
21
|
+
declared: (name) => declared.has(name),
|
|
22
|
+
global: (name) => globals.has(name),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Lines, without those left out (null); "" is a blank line, and blank lines never repeat. */
|
|
26
|
+
const lines = (...each) => each
|
|
27
|
+
.filter((line) => line !== null)
|
|
28
|
+
.join("\n")
|
|
29
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
30
|
+
.trim();
|
|
31
|
+
const KIND_WORDS = {
|
|
32
|
+
script: "scripts",
|
|
33
|
+
style: "styles",
|
|
34
|
+
font: "fonts",
|
|
35
|
+
img: "images",
|
|
36
|
+
media: "audio and video",
|
|
37
|
+
connect: "data (fetch, WebSocket)",
|
|
38
|
+
};
|
|
39
|
+
/** What content may load, in prose, from the same origins the CSP lists. */
|
|
40
|
+
function network(config) {
|
|
41
|
+
const kinds = ["script", "style", "font", "img", "media", "connect"];
|
|
42
|
+
const open = kinds
|
|
43
|
+
.map((kind) => [kind, egressOrigins(config, kind)])
|
|
44
|
+
.filter(([, origins]) => origins.length > 0);
|
|
45
|
+
if (open.length === 0) {
|
|
46
|
+
return "Network: none. Nothing loads from outside (no scripts, styles, fonts, images or data from any server) and any fetch fails, so write everything inline: SVG and CSS draw charts well.";
|
|
47
|
+
}
|
|
48
|
+
// Origins that serve the same kinds go together: "scripts, styles and data from a, b; fonts from c".
|
|
49
|
+
const byOrigin = new Map();
|
|
50
|
+
for (const [kind, origins] of open) {
|
|
51
|
+
for (const origin of origins) {
|
|
52
|
+
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), kind]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const groups = new Map();
|
|
56
|
+
for (const [origin, each] of byOrigin) {
|
|
57
|
+
const words = each.map((kind) => KIND_WORDS[kind]);
|
|
58
|
+
const key = words.length === 1
|
|
59
|
+
? (words[0] ?? "")
|
|
60
|
+
: `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`;
|
|
61
|
+
groups.set(key, [...(groups.get(key) ?? []), origin]);
|
|
62
|
+
}
|
|
63
|
+
const parts = [...groups].map(([words, origins]) => `${words} from ${origins.join(", ")}`);
|
|
64
|
+
return `Network: only ${parts.join("; ")}. Anything else fails.`;
|
|
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";
|
|
68
|
+
const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
|
|
69
|
+
/** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
|
|
70
|
+
function forbidden(config) {
|
|
71
|
+
const tokens = sandboxTokens(config);
|
|
72
|
+
const items = ["position: fixed", "full screen"];
|
|
73
|
+
if (!tokens.includes("allow-modals")) {
|
|
74
|
+
items.push("alert/confirm/prompt");
|
|
75
|
+
}
|
|
76
|
+
if (!tokens.includes("allow-forms")) {
|
|
77
|
+
items.push("forms that submit");
|
|
78
|
+
}
|
|
79
|
+
if (!tokens.includes("allow-popups")) {
|
|
80
|
+
items.push("popups and new windows");
|
|
81
|
+
}
|
|
82
|
+
if (!tokens.includes("allow-downloads")) {
|
|
83
|
+
items.push("downloads");
|
|
84
|
+
}
|
|
85
|
+
items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
|
|
86
|
+
return `Not allowed: ${items.join(", ")}.`;
|
|
87
|
+
}
|
|
88
|
+
function widget(c) {
|
|
89
|
+
const { config, noun } = c;
|
|
90
|
+
const { layout, limits } = config;
|
|
91
|
+
const title = noun === "page" ? "Pages" : noun === "view" ? "Views" : "Widgets";
|
|
92
|
+
const tool = config.guide.toolName === undefined ? "" : ` (${config.guide.toolName})`;
|
|
93
|
+
const what = config.mode === "artifact"
|
|
94
|
+
? "What you pass: a whole HTML document (<!doctype html>, <head>, <body>). It is written into its frame once all of it has arrived, then its scripts run in order."
|
|
95
|
+
: config.mode === "mcp-app"
|
|
96
|
+
? "What you pass: an MCP Apps view, a whole HTML document that talks to its host through the official App SDK (@modelcontextprotocol/ext-apps) or the protocol's own messages."
|
|
97
|
+
: "What you pass: SVG that starts with <svg, or an HTML fragment: no <!doctype>, <html>, <head> or <body>. Order it styles, then markup, then scripts: people see the styled markup as you write it, and scripts run once all of it is written, in order, each external one loaded before the next runs.";
|
|
98
|
+
const height = c.declared("resize")
|
|
99
|
+
? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
|
|
100
|
+
: "Its height is fixed by the app: keep it short, or let it scroll inside.";
|
|
101
|
+
const viewBox = layout.narrowWidthPx + 20;
|
|
102
|
+
const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
|
|
103
|
+
const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
|
|
104
|
+
const textSize = themeDefault(config.theme.variables["--font-text-md-size"] ?? "14px", "light").replace(/^(\d+(?:\.\d+)?)px$/u, "$1 px");
|
|
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)
|
|
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."
|
|
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-") === ""
|
|
110
|
+
? null
|
|
111
|
+
: `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`, c.names("--color-accent") === ""
|
|
112
|
+
? null
|
|
113
|
+
: `- Accent: ${[
|
|
114
|
+
c.when("--color-accent (a button)"),
|
|
115
|
+
c.when("--color-accent-soft (a selected item's fill)"),
|
|
116
|
+
c.when("in a figure, --color-chart-1 (topic chart)"),
|
|
117
|
+
]
|
|
118
|
+
.filter((part) => part !== null)
|
|
119
|
+
.join("; ")}`, c.names("--font-") === "" ? null : `- Type: ${c.names("--font-")}`, c.names("--border-") === "" && c.names("--shadow-") === ""
|
|
120
|
+
? null
|
|
121
|
+
: `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`, otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`, "", exampleFits(c, GUIDE_EXAMPLES.stats)
|
|
122
|
+
? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
|
|
123
|
+
: null, c.when(`Don't use --color-background-primary as a card's fill: it is the app's own surface, so a card on it vanishes.`), `A theme change updates the variables in place and fires window "${config.theme.event}": CSS that uses var(...) follows by itself; a canvas or a library you gave colors to must redraw.`, config.mode === "inline"
|
|
124
|
+
? "Buttons and inputs already match the app (its borders, radius, colors and focus ring, 44 px tall): use them as they are, or restyle them with your own CSS."
|
|
125
|
+
: null, "", "Diagrams (boxes and arrows): show the mechanism asked about (what calls what, where data goes), only the parts the ask names; label each arrow with what moves.", `- Top to bottom, at most 2 boxes a row, in the phone viewBox; the title in HTML above. Too much for one glance on a phone: make two.`, "- SVG doesn't wrap: break lines yourself. A box is its longest line + 24 wide (13 px text takes about 7 units a character, a 14 px bold name 8.5: a 136-wide box holds 16); a name and a line, 52 tall; 16 between boxes.", c.when("- Boxes fill none, stroke --color-border-secondary; at most one stands out: stroke --color-chart-1, 2 wide, fill --color-chart-1 at fill-opacity 0.12. Arrows --color-text-tertiary, in the gaps, never across a box, their labels beside them. Every <text> sets fill: names --color-text-primary, the rest --color-text-secondary."), exampleFits(c, GUIDE_EXAMPLES.diagram) ? `\n${GUIDE_EXAMPLES.diagram}` : null);
|
|
126
|
+
}
|
|
127
|
+
/** Variables outside the groups the colors list names. */
|
|
128
|
+
function otherVariables(c) {
|
|
129
|
+
const grouped = [
|
|
130
|
+
"--color-background-",
|
|
131
|
+
"--color-text-",
|
|
132
|
+
"--color-border-",
|
|
133
|
+
"--color-ring-",
|
|
134
|
+
"--color-accent",
|
|
135
|
+
"--color-chart-",
|
|
136
|
+
"--font-",
|
|
137
|
+
"--border-",
|
|
138
|
+
"--shadow-",
|
|
139
|
+
];
|
|
140
|
+
return Object.keys(c.config.theme.variables)
|
|
141
|
+
.filter((name) => !grouped.some((prefix) => name.startsWith(prefix)))
|
|
142
|
+
.join(", ");
|
|
143
|
+
}
|
|
144
|
+
/** Whether an example uses only declared variables and loads nothing. */
|
|
145
|
+
function exampleFits(c, example) {
|
|
146
|
+
return c.has(...(example.match(VARIABLE) ?? [])) && !/https?:\/\//u.test(example);
|
|
147
|
+
}
|
|
148
|
+
function chart(c) {
|
|
149
|
+
const { config } = c;
|
|
150
|
+
const categorical = Object.keys(config.theme.variables).filter((name) => /^--color-chart-\d+$/u.test(name));
|
|
151
|
+
const sequential = Object.keys(config.theme.variables).filter((name) => /^--color-chart-seq-\d+$/u.test(name));
|
|
152
|
+
const libraries = loadsScripts(config)
|
|
153
|
+
? `Libraries (Chart.js, ECharts, d3) draw in px too, in a box with a set height (Chart.js responsive: true, maintainAspectRatio: false). They don't read CSS variables: pass in getComputedStyle(document.documentElement).getPropertyValue("${categorical[0] ?? "--color-text-primary"}").trim(), redraw on window "${config.theme.event}", and override their palette and white background.`
|
|
154
|
+
: "No chart library loads here: draw the SVG yourself, from a script, as below.";
|
|
155
|
+
return lines(`# Charts (a chart is a ${c.noun})`, "", "Pick the form first: a trend over time → line; compare categories → horizontal bars; x against y → scatter; one number → a big number, not a chart; a few numbers → a table, not a chart.", "", "The look:", '- The title states the finding, with its number ("Failed builds fell from 9 to 2 a week"), not the topic; a line under it says what is measured, in what unit.', c.when("- Grey first: the data in a quiet grey (--color-text-disabled), one accent (--color-chart-1) on what the title is about. More hues only for series that differ in kind and must each be found."), "- Values on the marks or a light axis, not both; label only the points the finding rests on. Bars start at zero.", "- Write the numbers once, as rows, and draw marks, labels and readout from them.", "", "Size: draw it at the frame's width, 1 unit = 1 px: read the box's clientWidth, draw an <svg> that wide with 12 px text, and again from a ResizeObserver. Its text stays 12 px on every screen; keep x labels 44 px apart. No script: the phone viewBox (topic widget). Bars need no SVG: an HTML grid row per category.", "", libraries, "", categorical.length === 0 ? null : "Colors:", categorical.length === 0
|
|
156
|
+
? null
|
|
157
|
+
: `- Series of different kinds: ${categorical[0]} … ${categorical.at(-1)}, in this order, never cycled, at most ${categorical.length} (fold the rest into "Other"); a series keeps its color when a filter hides others.`, sequential.length === 0
|
|
158
|
+
? null
|
|
159
|
+
: `- Magnitude (a heatmap): ${sequential[0]} … ${sequential.at(-1)}, 1 nearest zero.${c.has("--color-chart-neg", "--color-chart-mid", "--color-chart-pos") ? " Above or below a midpoint: --color-chart-neg, --color-chart-mid, --color-chart-pos." : ""}`, c.when("- Never a rainbow. Status (good, bad) uses --color-text-success, --color-text-warning, --color-text-danger with a word, never as a series color."), c.when("- Text is never in a series color: --color-text-primary or --color-text-secondary; the mark beside it carries identity."), "", c.when("Marks: 2 px lines, bars with a 2 px gap; grid lines --color-border-tertiary, 1 px; one y axis (two scales: two charts); the legend in HTML."), "", 'Reading values: a readout line in HTML that a tap on a column fills (each column a hit area the plot\'s height), the latest values shown before any tap; a "Table" toggle for the numbers.', exampleFits(c, GUIDE_EXAMPLES.line)
|
|
160
|
+
? `\nA line chart drawn at the frame's width, its numbers on a tap:\n${GUIDE_EXAMPLES.line}`
|
|
161
|
+
: null, exampleFits(c, GUIDE_EXAMPLES.bars)
|
|
162
|
+
? `\nBars in HTML, under a title like the line chart's:\n${GUIDE_EXAMPLES.bars}`
|
|
163
|
+
: null);
|
|
164
|
+
}
|
|
165
|
+
/** How a capability's gates read to the author: one sentence each. */
|
|
166
|
+
function gateWords(gates, effect) {
|
|
167
|
+
const sentences = [];
|
|
168
|
+
if (gates.includes("confirm")) {
|
|
169
|
+
sentences.push(`The app shows the person ${effect} first, and nothing happens unless they confirm.`);
|
|
170
|
+
}
|
|
171
|
+
if (gates.includes("user-activation")) {
|
|
172
|
+
sentences.push("Call it from a click (it needs one).");
|
|
173
|
+
}
|
|
174
|
+
for (const gate of gates) {
|
|
175
|
+
if (typeof gate === "object") {
|
|
176
|
+
sentences.push(`At most ${gate.rateLimit.perMinute} a minute.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return sentences.join(" ");
|
|
180
|
+
}
|
|
181
|
+
const ENTRIES = {
|
|
182
|
+
sendPrompt: (c, gates) => c.global("sendPrompt")
|
|
183
|
+
? `- sendPrompt(text): asks to send text to you as the person's message (at most ${c.config.limits.maxPromptChars.toLocaleString("en-US")} characters). Use it for "explain this point", "try these settings". ${gateWords(gates, "the whole text")} It returns a promise: true if it was sent.`
|
|
184
|
+
: `- Messages: the MCP Apps request ui/message (with the App SDK, app.sendMessage) asks to send text to you as the person's message. ${gateWords(gates, "the whole text")}`,
|
|
185
|
+
openLink: (c, gates) => c.global("openLink")
|
|
186
|
+
? `- openLink(url): asks to open an http(s) link in a new tab; a click on an <a href> does the same for you. ${gateWords(gates, "the link")} It returns a promise: true if it opened.`
|
|
187
|
+
: `- Links: the MCP Apps request ui/open-link (with the App SDK, app.openLink) asks to open an http(s) link in a new tab. ${gateWords(gates, "the link")}`,
|
|
188
|
+
downloadFile: (_c, gates) => `- Downloads: the MCP Apps request ui/download-file (with the App SDK, app.downloadFile). ${gateWords(gates, "the file")}`,
|
|
189
|
+
callTool: (_c, gates) => `- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${gateWords(gates, "the call")}`,
|
|
190
|
+
readData: (_c, gates) => `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
|
|
191
|
+
setModelContext: (_c, gates) => `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
|
|
192
|
+
reportError: (c, gates) => `- Errors: what your code throws, and anything the sandbox blocks, is reported to the app (at most ${c.config.limits.maxErrorReports} a run). ${gateWords(gates, "it")}`.trim(),
|
|
193
|
+
resize: () => "",
|
|
194
|
+
};
|
|
195
|
+
function interactive(c) {
|
|
196
|
+
const { config, noun } = c;
|
|
197
|
+
const entries = declaredCapabilities(config)
|
|
198
|
+
.map((name) => {
|
|
199
|
+
const own = config.capabilities[name]?.guide;
|
|
200
|
+
return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
|
|
201
|
+
})
|
|
202
|
+
.filter((entry) => entry !== "");
|
|
203
|
+
const state = config.isolation === "opaque"
|
|
204
|
+
? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
|
|
205
|
+
: `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
|
|
206
|
+
const none = entries.length === 0
|
|
207
|
+
? `- It can only draw: it has no way to send anything to you or the app.`
|
|
208
|
+
: null;
|
|
209
|
+
return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
|
|
210
|
+
}
|
|
211
|
+
const TOPICS = {
|
|
212
|
+
widget,
|
|
213
|
+
chart,
|
|
214
|
+
interactive,
|
|
215
|
+
};
|
|
216
|
+
/** The manual for `topics`, in the order asked (all built-in topics by default). */
|
|
217
|
+
export function generateGuide(config, topics) {
|
|
218
|
+
const c = context(config);
|
|
219
|
+
const own = config.guide.topics ?? {};
|
|
220
|
+
return (topics ?? BUILT_IN_TOPICS)
|
|
221
|
+
.map((topic) => {
|
|
222
|
+
if (Object.hasOwn(TOPICS, topic)) {
|
|
223
|
+
return TOPICS[topic](c);
|
|
224
|
+
}
|
|
225
|
+
const text = own[topic];
|
|
226
|
+
if (text === undefined) {
|
|
227
|
+
throw new RangeError(`No guide topic ${JSON.stringify(topic)}`);
|
|
228
|
+
}
|
|
229
|
+
return text;
|
|
230
|
+
})
|
|
231
|
+
.join("\n\n");
|
|
232
|
+
}
|
|
233
|
+
/** The description of the host's show tool, for the agent's tool list. */
|
|
234
|
+
export function generateToolDescription(config) {
|
|
235
|
+
const c = context(config);
|
|
236
|
+
const { layout } = config;
|
|
237
|
+
const what = config.mode === "artifact"
|
|
238
|
+
? "Show a whole HTML page you write: a report, a document, a small app."
|
|
239
|
+
: config.mode === "mcp-app"
|
|
240
|
+
? "Show an MCP Apps view."
|
|
241
|
+
: "Render an SVG or an HTML fragment you write, shown inline: charts, diagrams, explainers, small interactive tools.";
|
|
242
|
+
const origins = [
|
|
243
|
+
...new Set(["script", "style", "font", "img", "media", "connect"].flatMap((kind) => egressOrigins(config, kind))),
|
|
244
|
+
];
|
|
245
|
+
const net = origins.length === 0
|
|
246
|
+
? "with no network (nothing loads from outside)"
|
|
247
|
+
: `loading only from ${origins.join(", ")}`;
|
|
248
|
+
const globals = declaredCapabilities(config).flatMap((name) => {
|
|
249
|
+
const spec = capabilitySpec(name);
|
|
250
|
+
if (spec.global === undefined || !c.global(spec.global)) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
if (spec.global === "sendPrompt") {
|
|
254
|
+
return ["sendPrompt(text) asks the person to send text to you as their message."];
|
|
255
|
+
}
|
|
256
|
+
if (spec.global === "openLink") {
|
|
257
|
+
return ["openLink(url) asks the person to open a link."];
|
|
258
|
+
}
|
|
259
|
+
return [];
|
|
260
|
+
});
|
|
261
|
+
const read = config.guide.guideToolName === undefined
|
|
262
|
+
? ""
|
|
263
|
+
: ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
|
|
264
|
+
return `${what} It runs in a sandbox about ${layout.narrowWidthPx} px wide on a phone and ${layout.wideWidthPx} on a desktop, from the same code${c.declared("resize") ? " (its height follows the content)" : ""}, in the person's light or dark theme through CSS variables, ${net}. ${globals.join(" ")}${read} Each time it is shown it runs again from this code.`.replace(/ {2,}/gu, " ");
|
|
265
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { AdapterConnection, ShellConnection } from "../protocol/adapter.ts";
|
|
3
|
+
import type { HostOptions, PortholeHost } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
|
|
6
|
+
* for a bad config.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createHost<C extends AdapterConnection = ShellConnection>(config: PortholeConfig, options?: HostOptions<C>): PortholeHost<C>;
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createBridge } from "../bridge/bridge.js";
|
|
2
|
+
import { compile } from "../compile/compile.js";
|
|
3
|
+
import { declaredCapabilities } from "../compile/policy.js";
|
|
4
|
+
import { capabilitySpec } from "../config/capabilities.js";
|
|
5
|
+
import { locateServed } from "./locate.js";
|
|
6
|
+
import { watchOutcome } from "./outcome.js";
|
|
7
|
+
import { webPlatform } from "../platform/web.js";
|
|
8
|
+
import { MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "../protocol/mcp-apps.js";
|
|
9
|
+
import { shellAdapter } from "../protocol/shell-adapter.js";
|
|
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;
|
|
13
|
+
/** How many times a display is started again after its content navigated its frame away. */
|
|
14
|
+
const MAX_RESTARTS = 3;
|
|
15
|
+
const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
|
|
16
|
+
const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
|
|
17
|
+
/**
|
|
18
|
+
* Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
|
|
19
|
+
* for a bad config.
|
|
20
|
+
*/
|
|
21
|
+
export function createHost(config, options = {}) {
|
|
22
|
+
const compiled = compile(config);
|
|
23
|
+
if ((config.isolation === "domain" || config.isolation === "external") &&
|
|
24
|
+
options.locator === undefined) {
|
|
25
|
+
throw new TypeError(`${config.isolation} isolation needs a locator (HostOptions.locator)`);
|
|
26
|
+
}
|
|
27
|
+
// A url frame is someone else's app: no shell, no protocol, and its navigations are its own.
|
|
28
|
+
const speaks = config.mode !== "url";
|
|
29
|
+
const adapter = (options.adapter ?? shellAdapter);
|
|
30
|
+
const platform = options.platform ?? webPlatform;
|
|
31
|
+
const hostInfo = options.hostInfo ?? { name: "porthole", version: "0" };
|
|
32
|
+
return {
|
|
33
|
+
compiled,
|
|
34
|
+
mount(container, mount) {
|
|
35
|
+
const handlers = { ...options.handlers, ...mount.handlers };
|
|
36
|
+
const missing = declaredCapabilities(config).filter((name) => capabilitySpec(name).served === "handler" &&
|
|
37
|
+
handlers[name] === undefined);
|
|
38
|
+
if (missing.length > 0) {
|
|
39
|
+
throw new TypeError(`No handler for the declared capabilities: ${missing.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
if (config.isolation !== "opaque" && mount.instance === undefined) {
|
|
42
|
+
throw new TypeError(`${config.isolation} isolation needs MountOptions.instance`);
|
|
43
|
+
}
|
|
44
|
+
const listeners = new Map();
|
|
45
|
+
const emit = (event, detail) => {
|
|
46
|
+
for (const listener of listeners.get(event) ?? []) {
|
|
47
|
+
listener(detail);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
let scheme = mount.scheme;
|
|
51
|
+
let height = mount.initialHeightPx ?? 0;
|
|
52
|
+
let surface = null;
|
|
53
|
+
let connection = null;
|
|
54
|
+
let destroyed = false;
|
|
55
|
+
let restarts = 0;
|
|
56
|
+
let generation = 0;
|
|
57
|
+
let abort = null;
|
|
58
|
+
let outcomeWatch = null;
|
|
59
|
+
let themeFrame = null;
|
|
60
|
+
const view = container.ownerDocument.defaultView;
|
|
61
|
+
// The adapter's own operations, the last call of each, replayed into every new frame.
|
|
62
|
+
const replay = new Map();
|
|
63
|
+
const hostContext = () => {
|
|
64
|
+
const width = container.getBoundingClientRect().width;
|
|
65
|
+
// The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
|
|
66
|
+
const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
|
|
67
|
+
const standard = {};
|
|
68
|
+
const own = {};
|
|
69
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
70
|
+
(STANDARD.has(name) ? standard : own)[name] = value;
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
theme: scheme,
|
|
74
|
+
styles: { variables: standard },
|
|
75
|
+
...(Object.keys(own).length === 0 ? {} : { [PORTHOLE_STYLES]: { variables: own } }),
|
|
76
|
+
displayMode: "inline",
|
|
77
|
+
availableDisplayModes: ["inline"],
|
|
78
|
+
...(width > 0 ? { containerDimensions: { maxWidth: Math.round(width) } } : {}),
|
|
79
|
+
...(view === null
|
|
80
|
+
? {}
|
|
81
|
+
: {
|
|
82
|
+
locale: view.navigator.language,
|
|
83
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
84
|
+
platform: "web",
|
|
85
|
+
}),
|
|
86
|
+
...options.hostContext?.(),
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
const onBridge = (event) => {
|
|
90
|
+
switch (event.type) {
|
|
91
|
+
case "resize": {
|
|
92
|
+
const next = Math.min(config.limits.maxHeightPx, Math.max(config.limits.minHeightPx, Math.ceil(event.height)));
|
|
93
|
+
height = next;
|
|
94
|
+
surface?.setHeight(next);
|
|
95
|
+
emit("resize", {
|
|
96
|
+
height: next,
|
|
97
|
+
...(event.width === undefined ? {} : { width: event.width }),
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
case "error":
|
|
102
|
+
emit("error", { message: event.message });
|
|
103
|
+
return;
|
|
104
|
+
case "refused":
|
|
105
|
+
emit("refused", { capability: event.capability, refusal: event.refusal });
|
|
106
|
+
return;
|
|
107
|
+
case "muted":
|
|
108
|
+
emit("muted", undefined);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const stop = () => {
|
|
113
|
+
abort?.abort();
|
|
114
|
+
abort = null;
|
|
115
|
+
outcomeWatch?.stop();
|
|
116
|
+
outcomeWatch = null;
|
|
117
|
+
connection?.close();
|
|
118
|
+
connection = null;
|
|
119
|
+
surface?.destroy();
|
|
120
|
+
surface = null;
|
|
121
|
+
};
|
|
122
|
+
const start = (source) => {
|
|
123
|
+
const created = platform.createSurface(container, {
|
|
124
|
+
source,
|
|
125
|
+
title: mount.title,
|
|
126
|
+
sandbox: compiled.sandbox,
|
|
127
|
+
allow: compiled.allow,
|
|
128
|
+
referrerPolicy: compiled.referrerPolicy,
|
|
129
|
+
colorScheme: scheme,
|
|
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 }),
|
|
134
|
+
});
|
|
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;
|
|
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
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const bridge = createBridge({
|
|
156
|
+
config,
|
|
157
|
+
display: { id: mount.displayId, title: mount.title },
|
|
158
|
+
handlers,
|
|
159
|
+
...(options.confirm === undefined ? {} : { confirm: options.confirm }),
|
|
160
|
+
userActivation: () => created.userActivation(),
|
|
161
|
+
onEvent: onBridge,
|
|
162
|
+
});
|
|
163
|
+
const opened = adapter.connect({
|
|
164
|
+
display: { id: mount.displayId, title: mount.title },
|
|
165
|
+
compiled,
|
|
166
|
+
transport: created.transport,
|
|
167
|
+
bridge,
|
|
168
|
+
hostInfo,
|
|
169
|
+
hostContext,
|
|
170
|
+
emit: (event) => {
|
|
171
|
+
if (event.type === "initialized") {
|
|
172
|
+
watch.initialized();
|
|
173
|
+
emit("initialized", undefined);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
177
|
+
});
|
|
178
|
+
connection = opened;
|
|
179
|
+
for (const [name, args] of replay) {
|
|
180
|
+
opened[name]?.(...args);
|
|
181
|
+
}
|
|
182
|
+
created.onNavigated(() => {
|
|
183
|
+
emit("navigated", undefined);
|
|
184
|
+
if (restarts < MAX_RESTARTS) {
|
|
185
|
+
restarts += 1;
|
|
186
|
+
run();
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
stop();
|
|
190
|
+
emit("error", {
|
|
191
|
+
message: "The content kept navigating its frame away; it was stopped.",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
const run = () => {
|
|
197
|
+
stop();
|
|
198
|
+
if (destroyed) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
generation += 1;
|
|
202
|
+
if (config.isolation === "opaque") {
|
|
203
|
+
start({
|
|
204
|
+
kind: "srcdoc",
|
|
205
|
+
html: compiled.shellDocument({
|
|
206
|
+
origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
|
|
207
|
+
title: mount.title,
|
|
208
|
+
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const mine = generation;
|
|
214
|
+
const controller = new AbortController();
|
|
215
|
+
abort = controller;
|
|
216
|
+
const locator = options.locator;
|
|
217
|
+
const instance = mount.instance;
|
|
218
|
+
if (locator === undefined || instance === undefined) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const hostOrigin = container.ownerDocument.defaultView?.location.origin ?? "";
|
|
222
|
+
locateServed(locator, instance, config, hostOrigin, controller.signal).then((source) => {
|
|
223
|
+
if (mine === generation && !destroyed) {
|
|
224
|
+
start(source);
|
|
225
|
+
}
|
|
226
|
+
}, (error) => {
|
|
227
|
+
if (mine === generation && !controller.signal.aborted) {
|
|
228
|
+
emit("error", { message: error instanceof Error ? error.message : String(error) });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
run();
|
|
233
|
+
const controls = {
|
|
234
|
+
displayId: mount.displayId,
|
|
235
|
+
setTheme(next) {
|
|
236
|
+
scheme = next;
|
|
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() {
|
|
255
|
+
connection?.hostContextChanged(hostContext());
|
|
256
|
+
},
|
|
257
|
+
on(event, listener) {
|
|
258
|
+
const set = listeners.get(event) ?? new Set();
|
|
259
|
+
set.add(listener);
|
|
260
|
+
listeners.set(event, set);
|
|
261
|
+
return () => set.delete(listener);
|
|
262
|
+
},
|
|
263
|
+
reload() {
|
|
264
|
+
restarts = 0;
|
|
265
|
+
run();
|
|
266
|
+
},
|
|
267
|
+
destroy() {
|
|
268
|
+
destroyed = true;
|
|
269
|
+
stop();
|
|
270
|
+
listeners.clear();
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
// The adapter's own operations (`setContent` for the shell adapter): forwarded to the frame
|
|
274
|
+
// running now, and remembered (the last call of each) for a frame started again.
|
|
275
|
+
return new Proxy(controls, {
|
|
276
|
+
get(target, key, receiver) {
|
|
277
|
+
if (typeof key === "string" && !(key in target) && !BASE_KEYS.has(key)) {
|
|
278
|
+
return (...args) => {
|
|
279
|
+
replay.delete(key);
|
|
280
|
+
replay.set(key, args);
|
|
281
|
+
const operation = connection?.[key];
|
|
282
|
+
return typeof operation === "function"
|
|
283
|
+
? operation.apply(connection, args)
|
|
284
|
+
: undefined;
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return Reflect.get(target, key, receiver);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { FrameSource, InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Where a served frame (domain or external isolation) loads from: the host's locator answers, and
|
|
5
|
+
* `checkFrameLocation` checks the answer (one of the config's content origins, never the host's
|
|
6
|
+
* own site) before any frame is made.
|
|
7
|
+
*/
|
|
8
|
+
export declare function locateServed(locator: InstanceLocator, instance: InstanceRef, config: PortholeConfig, hostOrigin: string, signal?: AbortSignal): Promise<FrameSource>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { checkFrameLocation } from "../isolation/served.js";
|
|
2
|
+
/**
|
|
3
|
+
* Where a served frame (domain or external isolation) loads from: the host's locator answers, and
|
|
4
|
+
* `checkFrameLocation` checks the answer (one of the config's content origins, never the host's
|
|
5
|
+
* own site) before any frame is made.
|
|
6
|
+
*/
|
|
7
|
+
export async function locateServed(locator, instance, config, hostOrigin, signal) {
|
|
8
|
+
const location = await locator.locate(instance, signal);
|
|
9
|
+
checkFrameLocation(location, config.contentOrigins, hostOrigin);
|
|
10
|
+
return {
|
|
11
|
+
kind: "url",
|
|
12
|
+
url: new URL(location.url).href,
|
|
13
|
+
origin: location.origin,
|
|
14
|
+
...(location.reports === true ? { reports: true } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
|
9
|
+
/** A frame-status message, or null for anything else. */
|
|
10
|
+
export declare function parseFrameStatus(data: unknown): FrameStatus | null;
|
|
11
|
+
export interface OutcomeWatch {
|
|
12
|
+
/** The frame finished its handshake. */
|
|
13
|
+
initialized(): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
export declare function watchOutcome(options: {
|
|
17
|
+
readonly surface: Surface;
|
|
18
|
+
readonly source: FrameSource;
|
|
19
|
+
/** Whether the frame speaks for itself (a shell or a view): then its handshake is the proof. */
|
|
20
|
+
readonly speaks: boolean;
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
readonly onOutcome: (outcome: FrameOutcome) => void;
|
|
23
|
+
}): OutcomeWatch;
|
|
24
|
+
export {};
|