@botiverse/porthole 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +184 -0
  4. package/dist/bridge/types.d.ts +67 -0
  5. package/dist/bridge/types.js +1 -0
  6. package/dist/compile/compile.d.ts +35 -0
  7. package/dist/compile/compile.js +30 -0
  8. package/dist/compile/policy.d.ts +55 -0
  9. package/dist/compile/policy.js +106 -0
  10. package/dist/config/capabilities.d.ts +154 -0
  11. package/dist/config/capabilities.js +63 -0
  12. package/dist/config/schema.d.ts +131 -0
  13. package/dist/config/schema.js +1 -0
  14. package/dist/config/validate.d.ts +20 -0
  15. package/dist/config/validate.js +230 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +376 -0
  18. package/dist/conformance/index.d.ts +95 -0
  19. package/dist/conformance/index.js +4 -0
  20. package/dist/conformance/kit.d.ts +42 -0
  21. package/dist/conformance/kit.js +24 -0
  22. package/dist/conformance/oracles.d.ts +35 -0
  23. package/dist/conformance/oracles.js +77 -0
  24. package/dist/conformance/probes.d.ts +9 -0
  25. package/dist/conformance/probes.js +865 -0
  26. package/dist/conformance/served.d.ts +68 -0
  27. package/dist/conformance/served.js +311 -0
  28. package/dist/guide/examples.d.ts +70 -0
  29. package/dist/guide/examples.js +69 -0
  30. package/dist/guide/guide.d.ts +16 -0
  31. package/dist/guide/guide.js +261 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +248 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +11 -0
  36. package/dist/host/types.d.ts +95 -0
  37. package/dist/host/types.js +1 -0
  38. package/dist/index.d.ts +23 -0
  39. package/dist/index.js +19 -0
  40. package/dist/isolation/served.d.ts +32 -0
  41. package/dist/isolation/served.js +97 -0
  42. package/dist/isolation/types.d.ts +87 -0
  43. package/dist/isolation/types.js +1 -0
  44. package/dist/mcp/adapter.d.ts +43 -0
  45. package/dist/mcp/adapter.js +327 -0
  46. package/dist/mcp/index.d.ts +27 -0
  47. package/dist/mcp/index.js +25 -0
  48. package/dist/mcp/proxy.d.ts +25 -0
  49. package/dist/mcp/proxy.js +106 -0
  50. package/dist/mcp/view.d.ts +31 -0
  51. package/dist/mcp/view.js +72 -0
  52. package/dist/platform/types.d.ts +44 -0
  53. package/dist/platform/types.js +1 -0
  54. package/dist/platform/web.d.ts +8 -0
  55. package/dist/platform/web.js +72 -0
  56. package/dist/presets/presets.d.ts +65 -0
  57. package/dist/presets/presets.js +125 -0
  58. package/dist/protocol/adapter.d.ts +73 -0
  59. package/dist/protocol/adapter.js +1 -0
  60. package/dist/protocol/jsonrpc.d.ts +43 -0
  61. package/dist/protocol/jsonrpc.js +50 -0
  62. package/dist/protocol/mcp-apps.d.ts +79 -0
  63. package/dist/protocol/mcp-apps.js +99 -0
  64. package/dist/protocol/shell-adapter.d.ts +5 -0
  65. package/dist/protocol/shell-adapter.js +228 -0
  66. package/dist/react/dialog.d.ts +20 -0
  67. package/dist/react/dialog.js +98 -0
  68. package/dist/react/frame.d.ts +75 -0
  69. package/dist/react/frame.js +221 -0
  70. package/dist/react/index.d.ts +13 -0
  71. package/dist/react/index.js +13 -0
  72. package/dist/react/live.d.ts +24 -0
  73. package/dist/react/live.js +57 -0
  74. package/dist/server/index.d.ts +27 -0
  75. package/dist/server/index.js +26 -0
  76. package/dist/server/keys.d.ts +19 -0
  77. package/dist/server/keys.js +78 -0
  78. package/dist/server/pages.d.ts +6 -0
  79. package/dist/server/pages.js +20 -0
  80. package/dist/server/proxy.d.ts +21 -0
  81. package/dist/server/proxy.js +107 -0
  82. package/dist/server/server.d.ts +7 -0
  83. package/dist/server/server.js +518 -0
  84. package/dist/server/tickets.d.ts +8 -0
  85. package/dist/server/tickets.js +28 -0
  86. package/dist/server/types.d.ts +97 -0
  87. package/dist/server/types.js +1 -0
  88. package/dist/shell/document.d.ts +28 -0
  89. package/dist/shell/document.js +84 -0
  90. package/dist/shell/plan.d.ts +35 -0
  91. package/dist/shell/plan.js +34 -0
  92. package/dist/shell/runtime.d.ts +28 -0
  93. package/dist/shell/runtime.js +832 -0
  94. package/dist/theme/variables.d.ts +73 -0
  95. package/dist/theme/variables.js +171 -0
  96. package/dist/transport/post-message.d.ts +9 -0
  97. package/dist/transport/post-message.js +38 -0
  98. package/dist/transport/types.d.ts +15 -0
  99. package/dist/transport/types.js +1 -0
  100. package/package.json +71 -1
  101. package/src/bridge/bridge.ts +256 -0
  102. package/src/bridge/types.ts +77 -0
  103. package/src/compile/compile.ts +68 -0
  104. package/src/compile/policy.ts +133 -0
  105. package/src/config/capabilities.ts +138 -0
  106. package/src/config/schema.ts +140 -0
  107. package/src/config/validate.ts +284 -0
  108. package/src/conformance/behavior.ts +430 -0
  109. package/src/conformance/index.ts +102 -0
  110. package/src/conformance/kit.ts +63 -0
  111. package/src/conformance/oracles.ts +126 -0
  112. package/src/conformance/probes.ts +1093 -0
  113. package/src/conformance/served.ts +472 -0
  114. package/src/guide/examples.ts +74 -0
  115. package/src/guide/guide.ts +424 -0
  116. package/src/host/create-host.ts +290 -0
  117. package/src/host/locate.ts +20 -0
  118. package/src/host/types.ts +95 -0
  119. package/src/index.ts +118 -0
  120. package/src/isolation/served.ts +127 -0
  121. package/src/isolation/types.ts +77 -0
  122. package/src/mcp/adapter.ts +441 -0
  123. package/src/mcp/index.ts +50 -0
  124. package/src/mcp/proxy.ts +117 -0
  125. package/src/mcp/view.ts +97 -0
  126. package/src/platform/types.ts +48 -0
  127. package/src/platform/web.ts +78 -0
  128. package/src/presets/presets.ts +193 -0
  129. package/src/protocol/adapter.ts +67 -0
  130. package/src/protocol/jsonrpc.ts +83 -0
  131. package/src/protocol/mcp-apps.ts +138 -0
  132. package/src/protocol/shell-adapter.ts +261 -0
  133. package/src/react/dialog.tsx +158 -0
  134. package/src/react/frame.tsx +422 -0
  135. package/src/react/index.ts +20 -0
  136. package/src/react/live.ts +76 -0
  137. package/src/server/index.ts +37 -0
  138. package/src/server/keys.ts +100 -0
  139. package/src/server/pages.ts +31 -0
  140. package/src/server/proxy.ts +131 -0
  141. package/src/server/server.ts +617 -0
  142. package/src/server/tickets.ts +30 -0
  143. package/src/server/types.ts +104 -0
  144. package/src/shell/document.ts +120 -0
  145. package/src/shell/plan.ts +66 -0
  146. package/src/shell/runtime.ts +832 -0
  147. package/src/theme/variables.ts +190 -0
  148. package/src/transport/post-message.ts +45 -0
  149. package/src/transport/types.ts +16 -0
@@ -0,0 +1,261 @@
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
+ const loadsScripts = (config) => egressOrigins(config, "script").length > 0;
67
+ /** What the sandbox withholds, from the sandbox tokens and the shell's own walls. */
68
+ function forbidden(config) {
69
+ const tokens = sandboxTokens(config);
70
+ const items = ["position: fixed", "full screen"];
71
+ if (!tokens.includes("allow-modals")) {
72
+ items.push("alert/confirm/prompt");
73
+ }
74
+ if (!tokens.includes("allow-forms")) {
75
+ items.push("forms that submit");
76
+ }
77
+ if (!tokens.includes("allow-popups")) {
78
+ items.push("popups and new windows");
79
+ }
80
+ if (!tokens.includes("allow-downloads")) {
81
+ items.push("downloads");
82
+ }
83
+ items.push("clipboard writes", "frames (iframe, object, embed)", "WebRTC");
84
+ return `Not allowed: ${items.join(", ")}.`;
85
+ }
86
+ function widget(c) {
87
+ const { config, noun } = c;
88
+ const { layout, limits } = config;
89
+ const title = noun === "page" ? "Pages" : noun === "view" ? "Views" : "Widgets";
90
+ const tool = config.guide.toolName === undefined ? "" : ` (${config.guide.toolName})`;
91
+ const what = config.mode === "artifact"
92
+ ? "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."
93
+ : config.mode === "mcp-app"
94
+ ? "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."
95
+ : "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.";
96
+ const height = c.declared("resize")
97
+ ? `Its height follows its content, at most ${limits.maxHeightPx.toLocaleString("en-US")} px (then it scrolls inside).`
98
+ : "Its height is fixed by the app: keep it short, or let it scroll inside.";
99
+ const viewBox = layout.narrowWidthPx + 20;
100
+ const smallest = Math.round(((13 * layout.minWidthPx) / viewBox) * 10) / 10;
101
+ const largest = Math.round(((13 * 400) / viewBox) * 10) / 10;
102
+ 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 runs in the person's light or dark theme, and 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
+ ? "\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, "", `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
+ ? null
107
+ : `- Borders: ${c.names("--color-border-")}${c.names("--color-ring-") === "" ? "" : `; focus rings: ${c.names("--color-ring-")}`}`, c.names("--color-accent") === ""
108
+ ? null
109
+ : `- Accent: ${[
110
+ c.when("--color-accent (a button)"),
111
+ c.when("--color-accent-soft (a selected item's fill)"),
112
+ c.when("in a figure, --color-chart-1 (topic chart)"),
113
+ ]
114
+ .filter((part) => part !== null)
115
+ .join("; ")}`, c.names("--font-") === "" ? null : `- Type: ${c.names("--font-")}`, c.names("--border-") === "" && c.names("--shadow-") === ""
116
+ ? null
117
+ : `- Shape: ${[c.names("--border-"), c.names("--shadow-")].filter((each) => each !== "").join(", ")}`, otherVariables(c) === "" ? null : `- Also: ${otherVariables(c)}`, "", exampleFits(c, GUIDE_EXAMPLES.stats)
118
+ ? `Cards that read in both themes, and wrap on a phone:\n${GUIDE_EXAMPLES.stats}\n`
119
+ : 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"
120
+ ? "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."
121
+ : 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);
122
+ }
123
+ /** Variables outside the groups the colors list names. */
124
+ function otherVariables(c) {
125
+ const grouped = [
126
+ "--color-background-",
127
+ "--color-text-",
128
+ "--color-border-",
129
+ "--color-ring-",
130
+ "--color-accent",
131
+ "--color-chart-",
132
+ "--font-",
133
+ "--border-",
134
+ "--shadow-",
135
+ ];
136
+ return Object.keys(c.config.theme.variables)
137
+ .filter((name) => !grouped.some((prefix) => name.startsWith(prefix)))
138
+ .join(", ");
139
+ }
140
+ /** Whether an example uses only declared variables and loads nothing. */
141
+ function exampleFits(c, example) {
142
+ return c.has(...(example.match(VARIABLE) ?? [])) && !/https?:\/\//u.test(example);
143
+ }
144
+ function chart(c) {
145
+ const { config } = c;
146
+ const categorical = Object.keys(config.theme.variables).filter((name) => /^--color-chart-\d+$/u.test(name));
147
+ const sequential = Object.keys(config.theme.variables).filter((name) => /^--color-chart-seq-\d+$/u.test(name));
148
+ const libraries = loadsScripts(config)
149
+ ? `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.`
150
+ : "No chart library loads here: draw the SVG yourself, from a script, as below.";
151
+ 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
152
+ ? null
153
+ : `- 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
154
+ ? null
155
+ : `- 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)
156
+ ? `\nA line chart drawn at the frame's width, its numbers on a tap:\n${GUIDE_EXAMPLES.line}`
157
+ : null, exampleFits(c, GUIDE_EXAMPLES.bars)
158
+ ? `\nBars in HTML, under a title like the line chart's:\n${GUIDE_EXAMPLES.bars}`
159
+ : null);
160
+ }
161
+ /** How a capability's gates read to the author: one sentence each. */
162
+ function gateWords(gates, effect) {
163
+ const sentences = [];
164
+ if (gates.includes("confirm")) {
165
+ sentences.push(`The app shows the person ${effect} first, and nothing happens unless they confirm.`);
166
+ }
167
+ if (gates.includes("user-activation")) {
168
+ sentences.push("Call it from a click (it needs one).");
169
+ }
170
+ for (const gate of gates) {
171
+ if (typeof gate === "object") {
172
+ sentences.push(`At most ${gate.rateLimit.perMinute} a minute.`);
173
+ }
174
+ }
175
+ return sentences.join(" ");
176
+ }
177
+ const ENTRIES = {
178
+ sendPrompt: (c, gates) => c.global("sendPrompt")
179
+ ? `- 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.`
180
+ : `- 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")}`,
181
+ openLink: (c, gates) => c.global("openLink")
182
+ ? `- 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.`
183
+ : `- 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")}`,
184
+ downloadFile: (_c, gates) => `- Downloads: the MCP Apps request ui/download-file (with the App SDK, app.downloadFile). ${gateWords(gates, "the file")}`,
185
+ callTool: (_c, gates) => `- Tools: the MCP Apps request tools/call (with the App SDK, app.callServerTool). ${gateWords(gates, "the call")}`,
186
+ readData: (_c, gates) => `- Data: the MCP Apps request resources/read (with the App SDK, app.readServerResource). ${gateWords(gates, "the request")}`,
187
+ setModelContext: (_c, gates) => `- Context for the agent: the MCP Apps request ui/update-model-context (with the App SDK, app.updateModelContext). ${gateWords(gates, "it")}`,
188
+ 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(),
189
+ resize: () => "",
190
+ };
191
+ function interactive(c) {
192
+ const { config, noun } = c;
193
+ const entries = declaredCapabilities(config)
194
+ .map((name) => {
195
+ const own = config.capabilities[name]?.guide;
196
+ return own === undefined ? ENTRIES[name](c, effectiveGates(config, name)) : `- ${own}`;
197
+ })
198
+ .filter((entry) => entry !== "");
199
+ const state = config.isolation === "opaque"
200
+ ? `- 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.`
201
+ : `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`;
202
+ const none = entries.length === 0
203
+ ? `- It can only draw: it has no way to send anything to you or the app.`
204
+ : null;
205
+ return lines(`# Interactive ${noun}s`, "", ...entries, none, state);
206
+ }
207
+ const TOPICS = {
208
+ widget,
209
+ chart,
210
+ interactive,
211
+ };
212
+ /** The manual for `topics`, in the order asked (all built-in topics by default). */
213
+ export function generateGuide(config, topics) {
214
+ const c = context(config);
215
+ const own = config.guide.topics ?? {};
216
+ return (topics ?? BUILT_IN_TOPICS)
217
+ .map((topic) => {
218
+ if (Object.hasOwn(TOPICS, topic)) {
219
+ return TOPICS[topic](c);
220
+ }
221
+ const text = own[topic];
222
+ if (text === undefined) {
223
+ throw new RangeError(`No guide topic ${JSON.stringify(topic)}`);
224
+ }
225
+ return text;
226
+ })
227
+ .join("\n\n");
228
+ }
229
+ /** The description of the host's show tool, for the agent's tool list. */
230
+ export function generateToolDescription(config) {
231
+ const c = context(config);
232
+ const { layout } = config;
233
+ const what = config.mode === "artifact"
234
+ ? "Show a whole HTML page you write: a report, a document, a small app."
235
+ : config.mode === "mcp-app"
236
+ ? "Show an MCP Apps view."
237
+ : "Render an SVG or an HTML fragment you write, shown inline: charts, diagrams, explainers, small interactive tools.";
238
+ const origins = [
239
+ ...new Set(["script", "style", "font", "img", "media", "connect"].flatMap((kind) => egressOrigins(config, kind))),
240
+ ];
241
+ const net = origins.length === 0
242
+ ? "with no network (nothing loads from outside)"
243
+ : `loading only from ${origins.join(", ")}`;
244
+ const globals = declaredCapabilities(config).flatMap((name) => {
245
+ const spec = capabilitySpec(name);
246
+ if (spec.global === undefined || !c.global(spec.global)) {
247
+ return [];
248
+ }
249
+ if (spec.global === "sendPrompt") {
250
+ return ["sendPrompt(text) asks the person to send text to you as their message."];
251
+ }
252
+ if (spec.global === "openLink") {
253
+ return ["openLink(url) asks the person to open a link."];
254
+ }
255
+ return [];
256
+ });
257
+ const read = config.guide.guideToolName === undefined
258
+ ? ""
259
+ : ` Read ${config.guide.guideToolName} once before your first ${c.noun}${config.mode === "inline" ? " (topic chart before your first chart)" : ""}.`;
260
+ 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, " ");
261
+ }
@@ -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,248 @@
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 { webPlatform } from "../platform/web.js";
7
+ import { MCP_APPS_STYLE_VARIABLES, PORTHOLE_STYLES, } from "../protocol/mcp-apps.js";
8
+ import { shellAdapter } from "../protocol/shell-adapter.js";
9
+ import { resolveTheme } from "../theme/variables.js";
10
+ /** How many times a display is started again after its content navigated its frame away. */
11
+ const MAX_RESTARTS = 3;
12
+ const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
13
+ const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
14
+ /**
15
+ * Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
16
+ * for a bad config.
17
+ */
18
+ export function createHost(config, options = {}) {
19
+ const compiled = compile(config);
20
+ if ((config.isolation === "domain" || config.isolation === "external") &&
21
+ options.locator === undefined) {
22
+ throw new TypeError(`${config.isolation} isolation needs a locator (HostOptions.locator)`);
23
+ }
24
+ // A url frame is someone else's app: no shell, no protocol, and its navigations are its own.
25
+ const speaks = config.mode !== "url";
26
+ const adapter = (options.adapter ?? shellAdapter);
27
+ const platform = options.platform ?? webPlatform;
28
+ const hostInfo = options.hostInfo ?? { name: "porthole", version: "0" };
29
+ return {
30
+ compiled,
31
+ mount(container, mount) {
32
+ const handlers = { ...options.handlers, ...mount.handlers };
33
+ const missing = declaredCapabilities(config).filter((name) => capabilitySpec(name).served === "handler" &&
34
+ handlers[name] === undefined);
35
+ if (missing.length > 0) {
36
+ throw new TypeError(`No handler for the declared capabilities: ${missing.join(", ")}`);
37
+ }
38
+ if (config.isolation !== "opaque" && mount.instance === undefined) {
39
+ throw new TypeError(`${config.isolation} isolation needs MountOptions.instance`);
40
+ }
41
+ const listeners = new Map();
42
+ const emit = (event, detail) => {
43
+ for (const listener of listeners.get(event) ?? []) {
44
+ listener(detail);
45
+ }
46
+ };
47
+ let scheme = mount.scheme;
48
+ let height = mount.initialHeightPx ?? 0;
49
+ let surface = null;
50
+ let connection = null;
51
+ let destroyed = false;
52
+ let restarts = 0;
53
+ let generation = 0;
54
+ let abort = null;
55
+ // The adapter's own operations, the last call of each, replayed into every new frame.
56
+ const replay = new Map();
57
+ const hostContext = () => {
58
+ const width = container.getBoundingClientRect().width;
59
+ const view = container.ownerDocument.defaultView;
60
+ // The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
61
+ const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
62
+ const standard = {};
63
+ const own = {};
64
+ for (const [name, value] of Object.entries(variables)) {
65
+ (STANDARD.has(name) ? standard : own)[name] = value;
66
+ }
67
+ return {
68
+ theme: scheme,
69
+ styles: { variables: standard },
70
+ ...(Object.keys(own).length === 0 ? {} : { [PORTHOLE_STYLES]: { variables: own } }),
71
+ displayMode: "inline",
72
+ availableDisplayModes: ["inline"],
73
+ ...(width > 0 ? { containerDimensions: { maxWidth: Math.round(width) } } : {}),
74
+ ...(view === null
75
+ ? {}
76
+ : {
77
+ locale: view.navigator.language,
78
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
79
+ platform: "web",
80
+ }),
81
+ ...options.hostContext?.(),
82
+ };
83
+ };
84
+ const onBridge = (event) => {
85
+ switch (event.type) {
86
+ case "resize": {
87
+ const next = Math.min(config.limits.maxHeightPx, Math.max(config.limits.minHeightPx, Math.ceil(event.height)));
88
+ height = next;
89
+ surface?.setHeight(next);
90
+ emit("resize", {
91
+ height: next,
92
+ ...(event.width === undefined ? {} : { width: event.width }),
93
+ });
94
+ return;
95
+ }
96
+ case "error":
97
+ emit("error", { message: event.message });
98
+ return;
99
+ case "refused":
100
+ emit("refused", { capability: event.capability, refusal: event.refusal });
101
+ return;
102
+ case "muted":
103
+ emit("muted", undefined);
104
+ return;
105
+ }
106
+ };
107
+ const stop = () => {
108
+ abort?.abort();
109
+ abort = null;
110
+ connection?.close();
111
+ connection = null;
112
+ surface?.destroy();
113
+ surface = null;
114
+ };
115
+ const start = (source) => {
116
+ const created = platform.createSurface(container, {
117
+ source,
118
+ title: mount.title,
119
+ sandbox: compiled.sandbox,
120
+ allow: compiled.allow,
121
+ referrerPolicy: compiled.referrerPolicy,
122
+ colorScheme: scheme,
123
+ initialHeightPx: height,
124
+ });
125
+ surface = created;
126
+ if (!speaks) {
127
+ return;
128
+ }
129
+ const bridge = createBridge({
130
+ config,
131
+ display: { id: mount.displayId, title: mount.title },
132
+ handlers,
133
+ ...(options.confirm === undefined ? {} : { confirm: options.confirm }),
134
+ userActivation: () => created.userActivation(),
135
+ onEvent: onBridge,
136
+ });
137
+ const opened = adapter.connect({
138
+ display: { id: mount.displayId, title: mount.title },
139
+ compiled,
140
+ transport: created.transport,
141
+ bridge,
142
+ hostInfo,
143
+ hostContext,
144
+ emit: (event) => {
145
+ if (event.type === "initialized") {
146
+ emit("initialized", undefined);
147
+ }
148
+ },
149
+ ...(mount.document === undefined ? {} : { document: mount.document }),
150
+ });
151
+ connection = opened;
152
+ for (const [name, args] of replay) {
153
+ opened[name]?.(...args);
154
+ }
155
+ created.onNavigated(() => {
156
+ emit("navigated", undefined);
157
+ if (restarts < MAX_RESTARTS) {
158
+ restarts += 1;
159
+ run();
160
+ }
161
+ else {
162
+ stop();
163
+ emit("error", {
164
+ message: "The content kept navigating its frame away; it was stopped.",
165
+ });
166
+ }
167
+ });
168
+ };
169
+ const run = () => {
170
+ stop();
171
+ if (destroyed) {
172
+ return;
173
+ }
174
+ generation += 1;
175
+ if (config.isolation === "opaque") {
176
+ start({
177
+ kind: "srcdoc",
178
+ html: compiled.shellDocument({
179
+ origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
180
+ title: mount.title,
181
+ ...(mount.document === undefined ? {} : { document: mount.document }),
182
+ }),
183
+ });
184
+ return;
185
+ }
186
+ const mine = generation;
187
+ const controller = new AbortController();
188
+ abort = controller;
189
+ const locator = options.locator;
190
+ const instance = mount.instance;
191
+ if (locator === undefined || instance === undefined) {
192
+ return;
193
+ }
194
+ const hostOrigin = container.ownerDocument.defaultView?.location.origin ?? "";
195
+ locateServed(locator, instance, config, hostOrigin, controller.signal).then((source) => {
196
+ if (mine === generation && !destroyed) {
197
+ start(source);
198
+ }
199
+ }, (error) => {
200
+ if (mine === generation && !controller.signal.aborted) {
201
+ emit("error", { message: error instanceof Error ? error.message : String(error) });
202
+ }
203
+ });
204
+ };
205
+ run();
206
+ const controls = {
207
+ displayId: mount.displayId,
208
+ setTheme(next) {
209
+ scheme = next;
210
+ surface?.setColorScheme(next);
211
+ connection?.hostContextChanged(hostContext());
212
+ },
213
+ on(event, listener) {
214
+ const set = listeners.get(event) ?? new Set();
215
+ set.add(listener);
216
+ listeners.set(event, set);
217
+ return () => set.delete(listener);
218
+ },
219
+ reload() {
220
+ restarts = 0;
221
+ run();
222
+ },
223
+ destroy() {
224
+ destroyed = true;
225
+ stop();
226
+ listeners.clear();
227
+ },
228
+ };
229
+ // The adapter's own operations (`setContent` for the shell adapter): forwarded to the frame
230
+ // running now, and remembered (the last call of each) for a frame started again.
231
+ return new Proxy(controls, {
232
+ get(target, key, receiver) {
233
+ if (typeof key === "string" && !(key in target) && !BASE_KEYS.has(key)) {
234
+ return (...args) => {
235
+ replay.delete(key);
236
+ replay.set(key, args);
237
+ const operation = connection?.[key];
238
+ return typeof operation === "function"
239
+ ? operation.apply(connection, args)
240
+ : undefined;
241
+ };
242
+ }
243
+ return Reflect.get(target, key, receiver);
244
+ },
245
+ });
246
+ },
247
+ };
248
+ }
@@ -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,11 @@
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 { kind: "url", url: new URL(location.url).href, origin: location.origin };
11
+ }
@@ -0,0 +1,95 @@
1
+ import type { Confirm, CapabilityHandlers, Refusal } from "../bridge/types.ts";
2
+ import type { CompiledConfig } from "../compile/compile.ts";
3
+ import type { CapabilityName } from "../config/capabilities.ts";
4
+ import type { ColorScheme } from "../config/schema.ts";
5
+ import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
6
+ import type { PlatformHost } from "../platform/types.ts";
7
+ import type { AdapterConnection, ProtocolAdapter, ShellConnection } from "../protocol/adapter.ts";
8
+ import type { HostContext } from "../protocol/mcp-apps.ts";
9
+ /**
10
+ * The host-side API (framework-agnostic; the web platform by default). A host compiles a config
11
+ * once (`createHost`), then mounts a display per piece of content. Everything here is mechanism;
12
+ * the host passes policy: its handlers, who confirms, its theme values.
13
+ */
14
+ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
15
+ /** Handlers for the declared capabilities; a mount may pass its own over these. */
16
+ readonly handlers?: CapabilityHandlers;
17
+ /** Who confirms `confirm`-gated calls. Absent: those calls are refused. */
18
+ readonly confirm?: Confirm;
19
+ /**
20
+ * The host's live values for the config's theme variables, for `scheme` (read from its own
21
+ * design tokens). A name it leaves out, or answers empty, gets the config's default.
22
+ */
23
+ readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
24
+ /** The host's own context fields (locale, time zone, platform): merged into the host context. */
25
+ readonly hostContext?: () => Partial<HostContext>;
26
+ /** How the host names itself in the handshake. */
27
+ readonly hostInfo?: {
28
+ readonly name: string;
29
+ readonly version: string;
30
+ };
31
+ /** The protocol adapter. Default: the core's shell adapter (inline and artifact modes). */
32
+ readonly adapter?: ProtocolAdapter<C>;
33
+ /** The platform host. Default: the web's iframe host. */
34
+ readonly platform?: PlatformHost;
35
+ /** Required for domain and external isolation: where an instance's frame loads from. */
36
+ readonly locator?: InstanceLocator;
37
+ }
38
+ export interface MountOptions {
39
+ /** The host's id for this display: what its height is remembered by, and calls name. */
40
+ readonly displayId: string;
41
+ /** The display's accessible name and the title its confirmations show (the host's words). */
42
+ readonly title: string;
43
+ readonly scheme: ColorScheme;
44
+ /** Handlers for this display only, over the host's. */
45
+ readonly handlers?: CapabilityHandlers;
46
+ /** Domain and external isolation: the instance to locate. */
47
+ readonly instance?: InstanceRef;
48
+ /** A whole document the frame starts with (an MCP Apps view), hardened by the shell. */
49
+ readonly document?: string;
50
+ /** The height to start at, before the content reports its own. */
51
+ readonly initialHeightPx?: number;
52
+ }
53
+ /** What a display reports, by event name. */
54
+ export interface DisplayEvents {
55
+ /** The frame finished its handshake. */
56
+ readonly initialized: undefined;
57
+ /** The content's size, after the config's limits. */
58
+ readonly resize: {
59
+ readonly height: number;
60
+ readonly width?: number;
61
+ };
62
+ /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
63
+ readonly error: {
64
+ readonly message: string;
65
+ };
66
+ /** The content navigated its frame away; the frame was started again from its content. */
67
+ readonly navigated: undefined;
68
+ /** A call didn't run. */
69
+ readonly refused: {
70
+ readonly capability: CapabilityName;
71
+ readonly refusal: Refusal;
72
+ };
73
+ /** Too many declines: the display stops asking until it runs again. */
74
+ readonly muted: undefined;
75
+ }
76
+ export interface DisplayControls {
77
+ readonly displayId: string;
78
+ /** The theme flipped: the frame is re-themed in place, without a reload. */
79
+ setTheme(scheme: ColorScheme): void;
80
+ on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void): () => void;
81
+ /** Runs the content again from the start, in a new frame. */
82
+ reload(): void;
83
+ /** Tears the view down and removes the frame. */
84
+ destroy(): void;
85
+ }
86
+ /** A mounted display: the framework's controls plus the adapter's own operations. */
87
+ export type DisplayHandle<C extends AdapterConnection = ShellConnection> = DisplayControls & Omit<C, keyof AdapterConnection>;
88
+ export interface PortholeHost<C extends AdapterConnection = ShellConnection> {
89
+ readonly compiled: CompiledConfig;
90
+ /**
91
+ * Creates a display in `container`. Throws if a declared capability served by a handler has
92
+ * none (here or in the host's options), so a manual never teaches a function nobody serves.
93
+ */
94
+ mount(container: HTMLElement, options: MountOptions): DisplayHandle<C>;
95
+ }
@@ -0,0 +1 @@
1
+ export {};