@botiverse/porthole 0.1.0 → 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.
Files changed (68) hide show
  1. package/dist/bridge/bridge.js +20 -6
  2. package/dist/bridge/types.d.ts +19 -3
  3. package/dist/compile/policy.d.ts +3 -3
  4. package/dist/compile/policy.js +6 -9
  5. package/dist/config/schema.d.ts +21 -0
  6. package/dist/config/schema.js +11 -1
  7. package/dist/config/validate.d.ts +3 -1
  8. package/dist/config/validate.js +41 -10
  9. package/dist/conformance/behavior.js +50 -0
  10. package/dist/conformance/index.d.ts +11 -5
  11. package/dist/conformance/index.js +2 -2
  12. package/dist/conformance/probes.d.ts +26 -2
  13. package/dist/conformance/probes.js +51 -40
  14. package/dist/conformance/served.d.ts +7 -0
  15. package/dist/conformance/served.js +31 -7
  16. package/dist/guide/guide.js +6 -2
  17. package/dist/host/create-host.js +46 -2
  18. package/dist/host/locate.js +6 -1
  19. package/dist/host/outcome.d.ts +24 -0
  20. package/dist/host/outcome.js +134 -0
  21. package/dist/host/types.d.ts +34 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +3 -1
  24. package/dist/isolation/served.d.ts +3 -0
  25. package/dist/isolation/served.js +20 -0
  26. package/dist/isolation/types.d.ts +9 -0
  27. package/dist/isolation/types.js +2 -1
  28. package/dist/platform/types.d.ts +7 -0
  29. package/dist/platform/web.js +15 -2
  30. package/dist/presets/presets.d.ts +9 -3
  31. package/dist/presets/presets.js +16 -1
  32. package/dist/react/frame.d.ts +16 -0
  33. package/dist/react/frame.js +44 -10
  34. package/dist/server/pages.d.ts +11 -6
  35. package/dist/server/pages.js +78 -12
  36. package/dist/server/server.js +34 -10
  37. package/dist/shell/document.d.ts +3 -2
  38. package/dist/shell/document.js +4 -3
  39. package/dist/shell/plan.d.ts +2 -0
  40. package/dist/shell/plan.js +1 -0
  41. package/dist/shell/runtime.js +20 -4
  42. package/package.json +1 -9
  43. package/src/bridge/bridge.ts +28 -6
  44. package/src/bridge/types.ts +21 -3
  45. package/src/compile/policy.ts +6 -9
  46. package/src/config/schema.ts +28 -0
  47. package/src/config/validate.ts +48 -14
  48. package/src/conformance/behavior.ts +55 -0
  49. package/src/conformance/index.ts +18 -4
  50. package/src/conformance/probes.ts +88 -52
  51. package/src/conformance/served.ts +37 -8
  52. package/src/guide/guide.ts +8 -2
  53. package/src/host/create-host.ts +47 -2
  54. package/src/host/locate.ts +6 -1
  55. package/src/host/outcome.ts +159 -0
  56. package/src/host/types.ts +33 -2
  57. package/src/index.ts +11 -1
  58. package/src/isolation/served.ts +24 -0
  59. package/src/isolation/types.ts +15 -1
  60. package/src/platform/types.ts +7 -0
  61. package/src/platform/web.ts +15 -2
  62. package/src/presets/presets.ts +27 -3
  63. package/src/react/frame.tsx +78 -14
  64. package/src/server/pages.ts +97 -11
  65. package/src/server/server.ts +48 -8
  66. package/src/shell/document.ts +7 -6
  67. package/src/shell/plan.ts +3 -0
  68. package/src/shell/runtime.ts +20 -4
@@ -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 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)
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, "", `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-") === ""
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
@@ -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
- initialHeightPx: height,
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) {
@@ -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 { kind: "url", url: new URL(location.url).href, origin: location.origin };
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,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 {};
@@ -0,0 +1,134 @@
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
+ "not-found",
24
+ "error",
25
+ ];
26
+ /** A frame-status message, or null for anything else. */
27
+ export function parseFrameStatus(data) {
28
+ if (data === null || typeof data !== "object") {
29
+ return null;
30
+ }
31
+ const message = data;
32
+ if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
33
+ return null;
34
+ }
35
+ const params = message.params;
36
+ if (params === null || typeof params !== "object") {
37
+ return null;
38
+ }
39
+ if (params.state === "entering") {
40
+ return { state: "entering" };
41
+ }
42
+ if (params.state !== "blocked") {
43
+ return null;
44
+ }
45
+ const reason = BLOCKS.includes(params.reason)
46
+ ? params.reason
47
+ : "error";
48
+ return {
49
+ state: "blocked",
50
+ reason,
51
+ ...(typeof params.status === "number" && Number.isInteger(params.status)
52
+ ? { status: params.status }
53
+ : {}),
54
+ };
55
+ }
56
+ export function watchOutcome(options) {
57
+ const { surface, source, speaks } = options;
58
+ const reports = source.kind === "url" && source.reports === true;
59
+ let decided = false;
60
+ let heard = false;
61
+ let entering = false;
62
+ let loads = 0;
63
+ const timers = new Set();
64
+ const later = (ms, run) => {
65
+ const timer = setTimeout(() => {
66
+ timers.delete(timer);
67
+ run();
68
+ }, ms);
69
+ timers.add(timer);
70
+ };
71
+ const stop = () => {
72
+ for (const timer of timers) {
73
+ clearTimeout(timer);
74
+ }
75
+ timers.clear();
76
+ unlisten();
77
+ unload();
78
+ };
79
+ const decide = (outcome) => {
80
+ if (!decided) {
81
+ decided = true;
82
+ stop();
83
+ options.onOutcome(outcome);
84
+ }
85
+ };
86
+ const unlisten = surface.transport.listen((data) => {
87
+ heard = true;
88
+ const status = parseFrameStatus(data);
89
+ if (status?.state === "entering") {
90
+ entering = true;
91
+ }
92
+ else if (status?.state === "blocked") {
93
+ decide({
94
+ state: "blocked",
95
+ reason: status.reason ?? "error",
96
+ ...(status.status === undefined ? {} : { status: status.status }),
97
+ });
98
+ }
99
+ });
100
+ const neverRan = () => later(SETTLE_MS, () => {
101
+ if (!heard) {
102
+ decide({ state: "blocked", reason: "framing" });
103
+ }
104
+ });
105
+ const unload = surface.onLoad(() => {
106
+ loads += 1;
107
+ if (decided) {
108
+ return;
109
+ }
110
+ if (speaks) {
111
+ // A served shell or view says something before its first load ends; one that never did
112
+ // never ran. (An opaque srcdoc frame always runs: its handshake decides.)
113
+ if (source.kind === "url") {
114
+ neverRan();
115
+ }
116
+ return;
117
+ }
118
+ if (!reports) {
119
+ later(SETTLE_MS, () => decide({ state: "loaded" }));
120
+ return;
121
+ }
122
+ if (entering && loads >= 2) {
123
+ decide({ state: "loaded" });
124
+ }
125
+ else if (!entering) {
126
+ neverRan();
127
+ }
128
+ });
129
+ later(options.timeoutMs, () => decide({ state: "timeout" }));
130
+ return {
131
+ initialized: () => decide({ state: "loaded" }),
132
+ stop,
133
+ };
134
+ }
@@ -51,9 +51,36 @@ 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" | "not-found" | "error";
64
+ /**
65
+ * How a display's first document went, once per run: it loaded (a shell or view finished its
66
+ * handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
67
+ * `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
68
+ * loaded.
69
+ */
70
+ export type FrameOutcome = {
71
+ readonly state: "loaded";
72
+ } | {
73
+ readonly state: "blocked";
74
+ readonly reason: FrameBlock;
75
+ readonly status?: number;
76
+ } | {
77
+ readonly state: "timeout";
78
+ };
54
79
  export interface DisplayEvents {
55
- /** The frame finished its handshake. */
80
+ /** The frame finished its handshake (url mode, which has none: its first document loaded). */
56
81
  readonly initialized: undefined;
82
+ /** How the frame's first document went (`FrameOutcome`). */
83
+ readonly outcome: FrameOutcome;
57
84
  /** The content's size, after the config's limits. */
58
85
  readonly resize: {
59
86
  readonly height: number;
@@ -75,8 +102,13 @@ export interface DisplayEvents {
75
102
  }
76
103
  export interface DisplayControls {
77
104
  readonly displayId: string;
78
- /** The theme flipped: the frame is re-themed in place, without a reload. */
105
+ /**
106
+ * The theme flipped: the frame is re-themed in place, without a reload. The host's values are
107
+ * read two animation frames later, once a host that restyles its page asynchronously has.
108
+ */
79
109
  setTheme(scheme: ColorScheme): void;
110
+ /** The host's theme values changed (same scheme): read them now and re-theme the frame. */
111
+ refreshTheme(): void;
80
112
  on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void): () => void;
81
113
  /** Runs the content again from the start, in a new frame. */
82
114
  reload(): void;
package/dist/index.d.ts CHANGED
@@ -4,7 +4,9 @@
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_SANDBOX_EXTRAS, type UrlSandboxExtra } from "./config/schema.ts";
9
+ export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
8
10
  export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
9
11
  export { compile, type CompiledConfig } from "./compile/compile.ts";
10
12
  export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
@@ -18,6 +20,6 @@ export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
18
20
  export type { Transport } from "./transport/types.ts";
19
21
  export type { AdapterConnection, AdapterEvent, AdapterSession, ProtocolAdapter, ShellConnection, } from "./protocol/adapter.ts";
20
22
  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";
23
+ export type { CallContext, CallOutcome, CapabilityHandler, CapabilityHandlers, Confirm, ConfirmAnswer, ConfirmRequest, Proceed, HostFunctionBridge, Refusal, } from "./bridge/types.ts";
22
24
  export { createHost } from "./host/create-host.ts";
23
25
  export type { DisplayControls, DisplayEvents, DisplayHandle, HostOptions, MountOptions, PortholeHost, } from "./host/types.ts";
package/dist/index.js CHANGED
@@ -4,7 +4,9 @@
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_SANDBOX_EXTRAS } from "./config/schema.js";
9
+ export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
8
10
  // Presets.
9
11
  export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
10
12
  // 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
@@ -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
  };
@@ -1 +1,2 @@
1
- export {};
1
+ /** The message a served page sends its host about loading (own-prefixed, P040). */
2
+ export const FRAME_STATUS_METHOD = "porthole/notifications/frame-status";
@@ -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> {
@@ -23,7 +23,9 @@ export const webPlatform = {
23
23
  border: "0",
24
24
  background: "transparent",
25
25
  colorScheme: spec.colorScheme,
26
- height: `${Math.max(0, Math.round(spec.initialHeightPx))}px`,
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
- frame.style.height = `${Math.max(0, Math.round(px))}px`;
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, 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
- /** Theme defaults to change or variables to add, and the theme event's name. */
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,8 @@ 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[];
33
39
  readonly limits?: Partial<Limits>;
34
40
  readonly layout?: Partial<LayoutConfig>;
35
41
  readonly embedder?: EmbedderConfig;
@@ -62,6 +62,19 @@ const mergeEgress = (base, extra) => {
62
62
  }
63
63
  return merged;
64
64
  };
65
+ /** The default variables, with the host's changes and additions, less those it dropped (null). */
66
+ function themeVariables(changes) {
67
+ const variables = { ...DEFAULT_THEME_VARIABLES };
68
+ for (const [name, value] of Object.entries(changes ?? {})) {
69
+ if (value === null) {
70
+ delete variables[name];
71
+ }
72
+ else {
73
+ variables[name] = value;
74
+ }
75
+ }
76
+ return variables;
77
+ }
65
78
  function build(mode, defaults, options) {
66
79
  const capabilities = {
67
80
  ...BASE_CAPABILITIES,
@@ -80,8 +93,9 @@ function build(mode, defaults, options) {
80
93
  capabilities,
81
94
  egress: mergeEgress({}, options.egress),
82
95
  theme: {
83
- variables: { ...DEFAULT_THEME_VARIABLES, ...options.theme?.variables },
96
+ variables: themeVariables(options.theme?.variables),
84
97
  event: options.theme?.event ?? DEFAULT_THEME_EVENT,
98
+ ...(options.theme?.documents === undefined ? {} : { documents: options.theme.documents }),
85
99
  },
86
100
  limits: { ...BASE_LIMITS, ...defaults.limits, ...options.limits },
87
101
  layout: { ...BASE_LAYOUT, ...options.layout },
@@ -121,5 +135,6 @@ export const presets = {
121
135
  guide: {},
122
136
  contentOrigins: options.contentOrigins,
123
137
  embedder: options.embedder ?? {},
138
+ ...(options.sandbox === undefined ? {} : { sandbox: options.sandbox }),
124
139
  }),
125
140
  };
@@ -2,6 +2,7 @@ import { type CSSProperties, type ReactElement, type ReactNode } from "react";
2
2
  import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
3
3
  import type { CapabilityName } from "../config/capabilities.ts";
4
4
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
5
+ import type { FrameOutcome } from "../host/types.ts";
5
6
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
6
7
  /** The height a display holds before it has reported its own. */
7
8
  export declare const PLACEHOLDER_HEIGHT_PX = 96;
@@ -67,6 +68,21 @@ export interface PortholeFrameProps {
67
68
  readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
68
69
  /** Too many declines: the display stops asking until it runs again. */
69
70
  readonly onMuted?: () => void;
71
+ /**
72
+ * How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
73
+ * own fallback (a url-mode app "Open in a new tab") on anything but loaded.
74
+ */
75
+ readonly onOutcome?: (outcome: FrameOutcome) => void;
76
+ /**
77
+ * Other ids this display had (the call's id, before the display's own settled): its remembered
78
+ * height is found under them, for a frame that mounts again under its new id.
79
+ */
80
+ readonly aliases?: readonly string[];
81
+ /**
82
+ * Change it when the host's theme values change without a scheme flip (or after an async
83
+ * restyle): the running frame reads them again.
84
+ */
85
+ readonly themeKey?: string | number;
70
86
  /** Draws the placeholder instead of the default one. */
71
87
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
72
88
  readonly className?: string;