@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,335 @@
|
|
|
1
|
+
import { createBridge, type BridgeEvent } from "../bridge/bridge.ts";
|
|
2
|
+
import { compile } from "../compile/compile.ts";
|
|
3
|
+
import { declaredCapabilities } from "../compile/policy.ts";
|
|
4
|
+
import { capabilitySpec } from "../config/capabilities.ts";
|
|
5
|
+
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
6
|
+
import { locateServed } from "./locate.ts";
|
|
7
|
+
import { watchOutcome, type OutcomeWatch } from "./outcome.ts";
|
|
8
|
+
import type { FrameSource } from "../isolation/types.ts";
|
|
9
|
+
import { webPlatform } from "../platform/web.ts";
|
|
10
|
+
import type { Surface } from "../platform/types.ts";
|
|
11
|
+
import type { AdapterConnection, ProtocolAdapter, ShellConnection } from "../protocol/adapter.ts";
|
|
12
|
+
import {
|
|
13
|
+
MCP_APPS_STYLE_VARIABLES,
|
|
14
|
+
PORTHOLE_STYLES,
|
|
15
|
+
type HostContext,
|
|
16
|
+
} from "../protocol/mcp-apps.ts";
|
|
17
|
+
import { shellAdapter } from "../protocol/shell-adapter.ts";
|
|
18
|
+
import { resolveTheme } from "../theme/variables.ts";
|
|
19
|
+
import type {
|
|
20
|
+
DisplayEvents,
|
|
21
|
+
DisplayHandle,
|
|
22
|
+
HostOptions,
|
|
23
|
+
MountOptions,
|
|
24
|
+
PortholeHost,
|
|
25
|
+
} from "./types.ts";
|
|
26
|
+
|
|
27
|
+
/** How long a display may take to load before its outcome is a timeout, by default. */
|
|
28
|
+
const DEFAULT_LOAD_TIMEOUT_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
/** How many times a display is started again after its content navigated its frame away. */
|
|
31
|
+
const MAX_RESTARTS = 3;
|
|
32
|
+
|
|
33
|
+
const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
|
|
34
|
+
const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
|
|
38
|
+
* for a bad config.
|
|
39
|
+
*/
|
|
40
|
+
export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
41
|
+
config: PortholeConfig,
|
|
42
|
+
options: HostOptions<C> = {},
|
|
43
|
+
): PortholeHost<C> {
|
|
44
|
+
const compiled = compile(config);
|
|
45
|
+
if (
|
|
46
|
+
(config.isolation === "domain" || config.isolation === "external") &&
|
|
47
|
+
options.locator === undefined
|
|
48
|
+
) {
|
|
49
|
+
throw new TypeError(`${config.isolation} isolation needs a locator (HostOptions.locator)`);
|
|
50
|
+
}
|
|
51
|
+
// A url frame is someone else's app: no shell, no protocol, and its navigations are its own.
|
|
52
|
+
const speaks = config.mode !== "url";
|
|
53
|
+
const adapter = (options.adapter ?? shellAdapter) as ProtocolAdapter<C>;
|
|
54
|
+
const platform = options.platform ?? webPlatform;
|
|
55
|
+
const hostInfo = options.hostInfo ?? { name: "porthole", version: "0" };
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
compiled,
|
|
59
|
+
mount(container: HTMLElement, mount: MountOptions): DisplayHandle<C> {
|
|
60
|
+
const handlers = { ...options.handlers, ...mount.handlers };
|
|
61
|
+
const missing = declaredCapabilities(config).filter(
|
|
62
|
+
(name) =>
|
|
63
|
+
capabilitySpec(name).served === "handler" &&
|
|
64
|
+
(handlers as Record<string, unknown>)[name] === undefined,
|
|
65
|
+
);
|
|
66
|
+
if (missing.length > 0) {
|
|
67
|
+
throw new TypeError(`No handler for the declared capabilities: ${missing.join(", ")}`);
|
|
68
|
+
}
|
|
69
|
+
if (config.isolation !== "opaque" && mount.instance === undefined) {
|
|
70
|
+
throw new TypeError(`${config.isolation} isolation needs MountOptions.instance`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const listeners = new Map<keyof DisplayEvents, Set<(detail: never) => void>>();
|
|
74
|
+
const emit = <E extends keyof DisplayEvents>(event: E, detail: DisplayEvents[E]) => {
|
|
75
|
+
for (const listener of listeners.get(event) ?? []) {
|
|
76
|
+
(listener as (detail: DisplayEvents[E]) => void)(detail);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let scheme: ColorScheme = mount.scheme;
|
|
81
|
+
let height = mount.initialHeightPx ?? 0;
|
|
82
|
+
let surface: Surface | null = null;
|
|
83
|
+
let connection: C | null = null;
|
|
84
|
+
let destroyed = false;
|
|
85
|
+
let restarts = 0;
|
|
86
|
+
let generation = 0;
|
|
87
|
+
let abort: AbortController | null = null;
|
|
88
|
+
let outcomeWatch: OutcomeWatch | null = null;
|
|
89
|
+
let themeFrame: number | null = null;
|
|
90
|
+
const view = container.ownerDocument.defaultView;
|
|
91
|
+
// The adapter's own operations, the last call of each, replayed into every new frame.
|
|
92
|
+
const replay = new Map<string, unknown[]>();
|
|
93
|
+
|
|
94
|
+
const hostContext = (): HostContext => {
|
|
95
|
+
const width = container.getBoundingClientRect().width;
|
|
96
|
+
// The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
|
|
97
|
+
const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
|
|
98
|
+
const standard: Record<string, string> = {};
|
|
99
|
+
const own: Record<string, string> = {};
|
|
100
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
101
|
+
(STANDARD.has(name) ? standard : own)[name] = value;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
theme: scheme,
|
|
105
|
+
styles: { variables: standard },
|
|
106
|
+
...(Object.keys(own).length === 0 ? {} : { [PORTHOLE_STYLES]: { variables: own } }),
|
|
107
|
+
displayMode: "inline",
|
|
108
|
+
availableDisplayModes: ["inline"],
|
|
109
|
+
...(width > 0 ? { containerDimensions: { maxWidth: Math.round(width) } } : {}),
|
|
110
|
+
...(view === null
|
|
111
|
+
? {}
|
|
112
|
+
: {
|
|
113
|
+
locale: view.navigator.language,
|
|
114
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
115
|
+
platform: "web" as const,
|
|
116
|
+
}),
|
|
117
|
+
...options.hostContext?.(),
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onBridge = (event: BridgeEvent) => {
|
|
122
|
+
switch (event.type) {
|
|
123
|
+
case "resize": {
|
|
124
|
+
const next = Math.min(
|
|
125
|
+
config.limits.maxHeightPx,
|
|
126
|
+
Math.max(config.limits.minHeightPx, Math.ceil(event.height)),
|
|
127
|
+
);
|
|
128
|
+
height = next;
|
|
129
|
+
surface?.setHeight(next);
|
|
130
|
+
emit("resize", {
|
|
131
|
+
height: next,
|
|
132
|
+
...(event.width === undefined ? {} : { width: event.width }),
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
case "error":
|
|
137
|
+
emit("error", { message: event.message });
|
|
138
|
+
return;
|
|
139
|
+
case "refused":
|
|
140
|
+
emit("refused", { capability: event.capability, refusal: event.refusal });
|
|
141
|
+
return;
|
|
142
|
+
case "muted":
|
|
143
|
+
emit("muted", undefined);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const stop = () => {
|
|
149
|
+
abort?.abort();
|
|
150
|
+
abort = null;
|
|
151
|
+
outcomeWatch?.stop();
|
|
152
|
+
outcomeWatch = null;
|
|
153
|
+
connection?.close();
|
|
154
|
+
connection = null;
|
|
155
|
+
surface?.destroy();
|
|
156
|
+
surface = null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const start = (source: FrameSource) => {
|
|
160
|
+
const created = platform.createSurface(container, {
|
|
161
|
+
source,
|
|
162
|
+
title: mount.title,
|
|
163
|
+
sandbox: compiled.sandbox,
|
|
164
|
+
allow: compiled.allow,
|
|
165
|
+
referrerPolicy: compiled.referrerPolicy,
|
|
166
|
+
colorScheme: scheme,
|
|
167
|
+
// An app reports no size: its frame fills the host's container, at least the minimum.
|
|
168
|
+
...(speaks
|
|
169
|
+
? { initialHeightPx: height }
|
|
170
|
+
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
171
|
+
});
|
|
172
|
+
surface = created;
|
|
173
|
+
const watch = watchOutcome({
|
|
174
|
+
surface: created,
|
|
175
|
+
source,
|
|
176
|
+
speaks,
|
|
177
|
+
timeoutMs: config.limits.loadTimeoutMs ?? DEFAULT_LOAD_TIMEOUT_MS,
|
|
178
|
+
onOutcome: (outcome) => emit("outcome", outcome),
|
|
179
|
+
});
|
|
180
|
+
outcomeWatch = watch;
|
|
181
|
+
if (!speaks) {
|
|
182
|
+
// No handshake in url mode: the app's first document loading is as ready as it gets.
|
|
183
|
+
let ready = false;
|
|
184
|
+
created.onLoad(() => {
|
|
185
|
+
if (!ready) {
|
|
186
|
+
ready = true;
|
|
187
|
+
emit("initialized", undefined);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const bridge = createBridge({
|
|
193
|
+
config,
|
|
194
|
+
display: { id: mount.displayId, title: mount.title },
|
|
195
|
+
handlers,
|
|
196
|
+
...(options.confirm === undefined ? {} : { confirm: options.confirm }),
|
|
197
|
+
userActivation: () => created.userActivation(),
|
|
198
|
+
onEvent: onBridge,
|
|
199
|
+
});
|
|
200
|
+
const opened = adapter.connect({
|
|
201
|
+
display: { id: mount.displayId, title: mount.title },
|
|
202
|
+
compiled,
|
|
203
|
+
transport: created.transport,
|
|
204
|
+
bridge,
|
|
205
|
+
hostInfo,
|
|
206
|
+
hostContext,
|
|
207
|
+
emit: (event) => {
|
|
208
|
+
if (event.type === "initialized") {
|
|
209
|
+
watch.initialized();
|
|
210
|
+
emit("initialized", undefined);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
214
|
+
});
|
|
215
|
+
connection = opened;
|
|
216
|
+
for (const [name, args] of replay) {
|
|
217
|
+
(opened as unknown as Record<string, (...each: unknown[]) => unknown>)[name]?.(...args);
|
|
218
|
+
}
|
|
219
|
+
created.onNavigated(() => {
|
|
220
|
+
emit("navigated", undefined);
|
|
221
|
+
if (restarts < MAX_RESTARTS) {
|
|
222
|
+
restarts += 1;
|
|
223
|
+
run();
|
|
224
|
+
} else {
|
|
225
|
+
stop();
|
|
226
|
+
emit("error", {
|
|
227
|
+
message: "The content kept navigating its frame away; it was stopped.",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const run = () => {
|
|
234
|
+
stop();
|
|
235
|
+
if (destroyed) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
generation += 1;
|
|
239
|
+
if (config.isolation === "opaque") {
|
|
240
|
+
start({
|
|
241
|
+
kind: "srcdoc",
|
|
242
|
+
html: compiled.shellDocument({
|
|
243
|
+
origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
|
|
244
|
+
title: mount.title,
|
|
245
|
+
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
246
|
+
}),
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const mine = generation;
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
abort = controller;
|
|
253
|
+
const locator = options.locator;
|
|
254
|
+
const instance = mount.instance;
|
|
255
|
+
if (locator === undefined || instance === undefined) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const hostOrigin = container.ownerDocument.defaultView?.location.origin ?? "";
|
|
259
|
+
locateServed(locator, instance, config, hostOrigin, controller.signal).then(
|
|
260
|
+
(source) => {
|
|
261
|
+
if (mine === generation && !destroyed) {
|
|
262
|
+
start(source);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
(error: unknown) => {
|
|
266
|
+
if (mine === generation && !controller.signal.aborted) {
|
|
267
|
+
emit("error", { message: error instanceof Error ? error.message : String(error) });
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
run();
|
|
274
|
+
|
|
275
|
+
const controls = {
|
|
276
|
+
displayId: mount.displayId,
|
|
277
|
+
setTheme(next: ColorScheme) {
|
|
278
|
+
scheme = next;
|
|
279
|
+
surface?.setColorScheme(next);
|
|
280
|
+
// Read the host's values two frames later: a host that restyles its page for the new
|
|
281
|
+
// scheme on its next frame has done so by then.
|
|
282
|
+
if (view === null) {
|
|
283
|
+
connection?.hostContextChanged(hostContext());
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (themeFrame !== null) {
|
|
287
|
+
view.cancelAnimationFrame(themeFrame);
|
|
288
|
+
}
|
|
289
|
+
themeFrame = view.requestAnimationFrame(() => {
|
|
290
|
+
themeFrame = view.requestAnimationFrame(() => {
|
|
291
|
+
themeFrame = null;
|
|
292
|
+
connection?.hostContextChanged(hostContext());
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
refreshTheme() {
|
|
297
|
+
connection?.hostContextChanged(hostContext());
|
|
298
|
+
},
|
|
299
|
+
on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void) {
|
|
300
|
+
const set = listeners.get(event) ?? new Set();
|
|
301
|
+
set.add(listener);
|
|
302
|
+
listeners.set(event, set);
|
|
303
|
+
return () => set.delete(listener);
|
|
304
|
+
},
|
|
305
|
+
reload() {
|
|
306
|
+
restarts = 0;
|
|
307
|
+
run();
|
|
308
|
+
},
|
|
309
|
+
destroy() {
|
|
310
|
+
destroyed = true;
|
|
311
|
+
stop();
|
|
312
|
+
listeners.clear();
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// The adapter's own operations (`setContent` for the shell adapter): forwarded to the frame
|
|
317
|
+
// running now, and remembered (the last call of each) for a frame started again.
|
|
318
|
+
return new Proxy(controls, {
|
|
319
|
+
get(target, key, receiver) {
|
|
320
|
+
if (typeof key === "string" && !(key in target) && !BASE_KEYS.has(key)) {
|
|
321
|
+
return (...args: unknown[]) => {
|
|
322
|
+
replay.delete(key);
|
|
323
|
+
replay.set(key, args);
|
|
324
|
+
const operation = (connection as unknown as Record<string, unknown> | null)?.[key];
|
|
325
|
+
return typeof operation === "function"
|
|
326
|
+
? (operation as (...each: unknown[]) => unknown).apply(connection, args)
|
|
327
|
+
: undefined;
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return Reflect.get(target, key, receiver);
|
|
331
|
+
},
|
|
332
|
+
}) as unknown as DisplayHandle<C>;
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PortholeConfig } from "../config/schema.ts";
|
|
2
|
+
import { checkFrameLocation } from "../isolation/served.ts";
|
|
3
|
+
import type { FrameSource, InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where a served frame (domain or external isolation) loads from: the host's locator answers, and
|
|
7
|
+
* `checkFrameLocation` checks the answer (one of the config's content origins, never the host's
|
|
8
|
+
* own site) before any frame is made.
|
|
9
|
+
*/
|
|
10
|
+
export async function locateServed(
|
|
11
|
+
locator: InstanceLocator,
|
|
12
|
+
instance: InstanceRef,
|
|
13
|
+
config: PortholeConfig,
|
|
14
|
+
hostOrigin: string,
|
|
15
|
+
signal?: AbortSignal,
|
|
16
|
+
): Promise<FrameSource> {
|
|
17
|
+
const location = await locator.locate(instance, signal);
|
|
18
|
+
checkFrameLocation(location, config.contentOrigins, hostOrigin);
|
|
19
|
+
return {
|
|
20
|
+
kind: "url",
|
|
21
|
+
url: new URL(location.url).href,
|
|
22
|
+
origin: location.origin,
|
|
23
|
+
...(location.reports === true ? { reports: true } : {}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD, type FrameSource } from "../isolation/types.ts";
|
|
2
|
+
import type { Surface } from "../platform/types.ts";
|
|
3
|
+
import type { FrameBlock, FrameOutcome } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How a display's first document went (P040). A frame's content is another origin's, so the host
|
|
7
|
+
* can't look inside; it goes by what it can see:
|
|
8
|
+
*
|
|
9
|
+
* - a shell or view that speaks for itself is loaded when it finishes its handshake;
|
|
10
|
+
* - a page of Porthole's `/server` says how it went (`porthole/notifications/frame-status`): it is
|
|
11
|
+
* entering an app, or blocked (its ticket used, no cookie kept in a frame, the app down); a url
|
|
12
|
+
* app is loaded when its document loads after the entry page;
|
|
13
|
+
* - a load from which nothing ever spoke, where something should have, is a page that never ran:
|
|
14
|
+
* framing refused;
|
|
15
|
+
* - an app from a host's own gateway is loaded on its first load, unless its page says it is
|
|
16
|
+
* blocked (the gateway may send the same message);
|
|
17
|
+
* - nothing settled in `timeoutMs`: a timeout.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** How long after a load the frame's word may still arrive. */
|
|
21
|
+
const SETTLE_MS = 400;
|
|
22
|
+
|
|
23
|
+
const BLOCKS: readonly FrameBlock[] = [
|
|
24
|
+
"framing",
|
|
25
|
+
"cookies",
|
|
26
|
+
"session",
|
|
27
|
+
"upstream",
|
|
28
|
+
"not-found",
|
|
29
|
+
"error",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
interface FrameStatus {
|
|
33
|
+
readonly state: "entering" | "blocked";
|
|
34
|
+
readonly reason?: FrameBlock;
|
|
35
|
+
readonly status?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A frame-status message, or null for anything else. */
|
|
39
|
+
export function parseFrameStatus(data: unknown): FrameStatus | null {
|
|
40
|
+
if (data === null || typeof data !== "object") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const message = data as { jsonrpc?: unknown; method?: unknown; params?: unknown };
|
|
44
|
+
if (message.jsonrpc !== "2.0" || message.method !== FRAME_STATUS_METHOD) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const params = message.params as { state?: unknown; reason?: unknown; status?: unknown } | null;
|
|
48
|
+
if (params === null || typeof params !== "object") {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (params.state === "entering") {
|
|
52
|
+
return { state: "entering" };
|
|
53
|
+
}
|
|
54
|
+
if (params.state !== "blocked") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const reason = BLOCKS.includes(params.reason as FrameBlock)
|
|
58
|
+
? (params.reason as FrameBlock)
|
|
59
|
+
: "error";
|
|
60
|
+
return {
|
|
61
|
+
state: "blocked",
|
|
62
|
+
reason,
|
|
63
|
+
...(typeof params.status === "number" && Number.isInteger(params.status)
|
|
64
|
+
? { status: params.status }
|
|
65
|
+
: {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface OutcomeWatch {
|
|
70
|
+
/** The frame finished its handshake. */
|
|
71
|
+
initialized(): void;
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function watchOutcome(options: {
|
|
76
|
+
readonly surface: Surface;
|
|
77
|
+
readonly source: FrameSource;
|
|
78
|
+
/** Whether the frame speaks for itself (a shell or a view): then its handshake is the proof. */
|
|
79
|
+
readonly speaks: boolean;
|
|
80
|
+
readonly timeoutMs: number;
|
|
81
|
+
readonly onOutcome: (outcome: FrameOutcome) => void;
|
|
82
|
+
}): OutcomeWatch {
|
|
83
|
+
const { surface, source, speaks } = options;
|
|
84
|
+
const reports = source.kind === "url" && source.reports === true;
|
|
85
|
+
let decided = false;
|
|
86
|
+
let heard = false;
|
|
87
|
+
let entering = false;
|
|
88
|
+
let loads = 0;
|
|
89
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
90
|
+
const later = (ms: number, run: () => void) => {
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
timers.delete(timer);
|
|
93
|
+
run();
|
|
94
|
+
}, ms);
|
|
95
|
+
timers.add(timer);
|
|
96
|
+
};
|
|
97
|
+
const stop = () => {
|
|
98
|
+
for (const timer of timers) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
timers.clear();
|
|
102
|
+
unlisten();
|
|
103
|
+
unload();
|
|
104
|
+
};
|
|
105
|
+
const decide = (outcome: FrameOutcome) => {
|
|
106
|
+
if (!decided) {
|
|
107
|
+
decided = true;
|
|
108
|
+
stop();
|
|
109
|
+
options.onOutcome(outcome);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const unlisten = surface.transport.listen((data) => {
|
|
113
|
+
heard = true;
|
|
114
|
+
const status = parseFrameStatus(data);
|
|
115
|
+
if (status?.state === "entering") {
|
|
116
|
+
entering = true;
|
|
117
|
+
} else if (status?.state === "blocked") {
|
|
118
|
+
decide({
|
|
119
|
+
state: "blocked",
|
|
120
|
+
reason: status.reason ?? "error",
|
|
121
|
+
...(status.status === undefined ? {} : { status: status.status }),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const neverRan = () =>
|
|
126
|
+
later(SETTLE_MS, () => {
|
|
127
|
+
if (!heard) {
|
|
128
|
+
decide({ state: "blocked", reason: "framing" });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const unload = surface.onLoad(() => {
|
|
132
|
+
loads += 1;
|
|
133
|
+
if (decided) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (speaks) {
|
|
137
|
+
// A served shell or view says something before its first load ends; one that never did
|
|
138
|
+
// never ran. (An opaque srcdoc frame always runs: its handshake decides.)
|
|
139
|
+
if (source.kind === "url") {
|
|
140
|
+
neverRan();
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!reports) {
|
|
145
|
+
later(SETTLE_MS, () => decide({ state: "loaded" }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (entering && loads >= 2) {
|
|
149
|
+
decide({ state: "loaded" });
|
|
150
|
+
} else if (!entering) {
|
|
151
|
+
neverRan();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
later(options.timeoutMs, () => decide({ state: "timeout" }));
|
|
155
|
+
return {
|
|
156
|
+
initialized: () => decide({ state: "loaded" }),
|
|
157
|
+
stop,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* The host-side API (framework-agnostic; the web platform by default). A host compiles a config
|
|
12
|
+
* once (`createHost`), then mounts a display per piece of content. Everything here is mechanism;
|
|
13
|
+
* the host passes policy: its handlers, who confirms, its theme values.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface HostOptions<C extends AdapterConnection = ShellConnection> {
|
|
17
|
+
/** Handlers for the declared capabilities; a mount may pass its own over these. */
|
|
18
|
+
readonly handlers?: CapabilityHandlers;
|
|
19
|
+
/** Who confirms `confirm`-gated calls. Absent: those calls are refused. */
|
|
20
|
+
readonly confirm?: Confirm;
|
|
21
|
+
/**
|
|
22
|
+
* The host's live values for the config's theme variables, for `scheme` (read from its own
|
|
23
|
+
* design tokens). A name it leaves out, or answers empty, gets the config's default.
|
|
24
|
+
*/
|
|
25
|
+
readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
|
|
26
|
+
/** The host's own context fields (locale, time zone, platform): merged into the host context. */
|
|
27
|
+
readonly hostContext?: () => Partial<HostContext>;
|
|
28
|
+
/** How the host names itself in the handshake. */
|
|
29
|
+
readonly hostInfo?: { readonly name: string; readonly version: string };
|
|
30
|
+
/** The protocol adapter. Default: the core's shell adapter (inline and artifact modes). */
|
|
31
|
+
readonly adapter?: ProtocolAdapter<C>;
|
|
32
|
+
/** The platform host. Default: the web's iframe host. */
|
|
33
|
+
readonly platform?: PlatformHost;
|
|
34
|
+
/** Required for domain and external isolation: where an instance's frame loads from. */
|
|
35
|
+
readonly locator?: InstanceLocator;
|
|
36
|
+
}
|
|
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
|
+
|
|
54
|
+
/** What a display reports, by event name. */
|
|
55
|
+
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
56
|
+
export type FrameBlock =
|
|
57
|
+
/** The page that frames it isn't one its server allows (frame-ancestors), or it never ran. */
|
|
58
|
+
| "framing"
|
|
59
|
+
/** The browser keeps no cookie for it inside a frame (WebKit, P025): open it on a tab. */
|
|
60
|
+
| "cookies"
|
|
61
|
+
/** Its ticket was used or its session ended. */
|
|
62
|
+
| "session"
|
|
63
|
+
/** Its app didn't answer. */
|
|
64
|
+
| "upstream"
|
|
65
|
+
| "not-found"
|
|
66
|
+
| "error";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How a display's first document went, once per run: it loaded (a shell or view finished its
|
|
70
|
+
* handshake; a url-mode app's document loaded), it was blocked (and why), or nothing settled in
|
|
71
|
+
* `limits.loadTimeoutMs`. A host shows its own fallback ("Open in a new tab") on anything but
|
|
72
|
+
* loaded.
|
|
73
|
+
*/
|
|
74
|
+
export type FrameOutcome =
|
|
75
|
+
| { readonly state: "loaded" }
|
|
76
|
+
| { readonly state: "blocked"; readonly reason: FrameBlock; readonly status?: number }
|
|
77
|
+
| { readonly state: "timeout" };
|
|
78
|
+
|
|
79
|
+
export interface DisplayEvents {
|
|
80
|
+
/** The frame finished its handshake (url mode, which has none: its first document loaded). */
|
|
81
|
+
readonly initialized: undefined;
|
|
82
|
+
/** How the frame's first document went (`FrameOutcome`). */
|
|
83
|
+
readonly outcome: FrameOutcome;
|
|
84
|
+
/** The content's size, after the config's limits. */
|
|
85
|
+
readonly resize: { readonly height: number; readonly width?: number };
|
|
86
|
+
/** What the content threw, or what the sandbox blocked (its own words: untrusted). */
|
|
87
|
+
readonly error: { readonly message: string };
|
|
88
|
+
/** The content navigated its frame away; the frame was started again from its content. */
|
|
89
|
+
readonly navigated: undefined;
|
|
90
|
+
/** A call didn't run. */
|
|
91
|
+
readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
|
|
92
|
+
/** Too many declines: the display stops asking until it runs again. */
|
|
93
|
+
readonly muted: undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DisplayControls {
|
|
97
|
+
readonly displayId: string;
|
|
98
|
+
/**
|
|
99
|
+
* The theme flipped: the frame is re-themed in place, without a reload. The host's values are
|
|
100
|
+
* read two animation frames later, once a host that restyles its page asynchronously has.
|
|
101
|
+
*/
|
|
102
|
+
setTheme(scheme: ColorScheme): void;
|
|
103
|
+
/** The host's theme values changed (same scheme): read them now and re-theme the frame. */
|
|
104
|
+
refreshTheme(): void;
|
|
105
|
+
on<E extends keyof DisplayEvents>(
|
|
106
|
+
event: E,
|
|
107
|
+
listener: (detail: DisplayEvents[E]) => void,
|
|
108
|
+
): () => void;
|
|
109
|
+
/** Runs the content again from the start, in a new frame. */
|
|
110
|
+
reload(): void;
|
|
111
|
+
/** Tears the view down and removes the frame. */
|
|
112
|
+
destroy(): void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A mounted display: the framework's controls plus the adapter's own operations. */
|
|
116
|
+
export type DisplayHandle<C extends AdapterConnection = ShellConnection> = DisplayControls &
|
|
117
|
+
Omit<C, keyof AdapterConnection>;
|
|
118
|
+
|
|
119
|
+
export interface PortholeHost<C extends AdapterConnection = ShellConnection> {
|
|
120
|
+
readonly compiled: CompiledConfig;
|
|
121
|
+
/**
|
|
122
|
+
* Creates a display in `container`. Throws if a declared capability served by a handler has
|
|
123
|
+
* none (here or in the host's options), so a manual never teaches a function nobody serves.
|
|
124
|
+
*/
|
|
125
|
+
mount(container: HTMLElement, options: MountOptions): DisplayHandle<C>;
|
|
126
|
+
}
|