@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,290 @@
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 type { FrameSource } from "../isolation/types.ts";
8
+ import { webPlatform } from "../platform/web.ts";
9
+ import type { Surface } from "../platform/types.ts";
10
+ import type { AdapterConnection, ProtocolAdapter, ShellConnection } from "../protocol/adapter.ts";
11
+ import {
12
+ MCP_APPS_STYLE_VARIABLES,
13
+ PORTHOLE_STYLES,
14
+ type HostContext,
15
+ } from "../protocol/mcp-apps.ts";
16
+ import { shellAdapter } from "../protocol/shell-adapter.ts";
17
+ import { resolveTheme } from "../theme/variables.ts";
18
+ import type {
19
+ DisplayEvents,
20
+ DisplayHandle,
21
+ HostOptions,
22
+ MountOptions,
23
+ PortholeHost,
24
+ } from "./types.ts";
25
+
26
+ /** How many times a display is started again after its content navigated its frame away. */
27
+ const MAX_RESTARTS = 3;
28
+
29
+ const BASE_KEYS = new Set(["hostContextChanged", "teardown", "close"]);
30
+ const STANDARD = new Set(MCP_APPS_STYLE_VARIABLES);
31
+
32
+ /**
33
+ * Compiles `config` once and returns a host that mounts displays under it. Throws a `ConfigError`
34
+ * for a bad config.
35
+ */
36
+ export function createHost<C extends AdapterConnection = ShellConnection>(
37
+ config: PortholeConfig,
38
+ options: HostOptions<C> = {},
39
+ ): PortholeHost<C> {
40
+ const compiled = compile(config);
41
+ if (
42
+ (config.isolation === "domain" || config.isolation === "external") &&
43
+ options.locator === undefined
44
+ ) {
45
+ throw new TypeError(`${config.isolation} isolation needs a locator (HostOptions.locator)`);
46
+ }
47
+ // A url frame is someone else's app: no shell, no protocol, and its navigations are its own.
48
+ const speaks = config.mode !== "url";
49
+ const adapter = (options.adapter ?? shellAdapter) as ProtocolAdapter<C>;
50
+ const platform = options.platform ?? webPlatform;
51
+ const hostInfo = options.hostInfo ?? { name: "porthole", version: "0" };
52
+
53
+ return {
54
+ compiled,
55
+ mount(container: HTMLElement, mount: MountOptions): DisplayHandle<C> {
56
+ const handlers = { ...options.handlers, ...mount.handlers };
57
+ const missing = declaredCapabilities(config).filter(
58
+ (name) =>
59
+ capabilitySpec(name).served === "handler" &&
60
+ (handlers as Record<string, unknown>)[name] === undefined,
61
+ );
62
+ if (missing.length > 0) {
63
+ throw new TypeError(`No handler for the declared capabilities: ${missing.join(", ")}`);
64
+ }
65
+ if (config.isolation !== "opaque" && mount.instance === undefined) {
66
+ throw new TypeError(`${config.isolation} isolation needs MountOptions.instance`);
67
+ }
68
+
69
+ const listeners = new Map<keyof DisplayEvents, Set<(detail: never) => void>>();
70
+ const emit = <E extends keyof DisplayEvents>(event: E, detail: DisplayEvents[E]) => {
71
+ for (const listener of listeners.get(event) ?? []) {
72
+ (listener as (detail: DisplayEvents[E]) => void)(detail);
73
+ }
74
+ };
75
+
76
+ let scheme: ColorScheme = mount.scheme;
77
+ let height = mount.initialHeightPx ?? 0;
78
+ let surface: Surface | null = null;
79
+ let connection: C | null = null;
80
+ let destroyed = false;
81
+ let restarts = 0;
82
+ let generation = 0;
83
+ let abort: AbortController | null = null;
84
+ // The adapter's own operations, the last call of each, replayed into every new frame.
85
+ const replay = new Map<string, unknown[]>();
86
+
87
+ const hostContext = (): HostContext => {
88
+ const width = container.getBoundingClientRect().width;
89
+ const view = container.ownerDocument.defaultView;
90
+ // The spec's names in `styles` (the App SDK refuses others there), the rest in our field.
91
+ const variables = resolveTheme(config.theme, scheme, options.theme?.(scheme));
92
+ const standard: Record<string, string> = {};
93
+ const own: Record<string, string> = {};
94
+ for (const [name, value] of Object.entries(variables)) {
95
+ (STANDARD.has(name) ? standard : own)[name] = value;
96
+ }
97
+ return {
98
+ theme: scheme,
99
+ styles: { variables: standard },
100
+ ...(Object.keys(own).length === 0 ? {} : { [PORTHOLE_STYLES]: { variables: own } }),
101
+ displayMode: "inline",
102
+ availableDisplayModes: ["inline"],
103
+ ...(width > 0 ? { containerDimensions: { maxWidth: Math.round(width) } } : {}),
104
+ ...(view === null
105
+ ? {}
106
+ : {
107
+ locale: view.navigator.language,
108
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
109
+ platform: "web" as const,
110
+ }),
111
+ ...options.hostContext?.(),
112
+ };
113
+ };
114
+
115
+ const onBridge = (event: BridgeEvent) => {
116
+ switch (event.type) {
117
+ case "resize": {
118
+ const next = Math.min(
119
+ config.limits.maxHeightPx,
120
+ Math.max(config.limits.minHeightPx, Math.ceil(event.height)),
121
+ );
122
+ height = next;
123
+ surface?.setHeight(next);
124
+ emit("resize", {
125
+ height: next,
126
+ ...(event.width === undefined ? {} : { width: event.width }),
127
+ });
128
+ return;
129
+ }
130
+ case "error":
131
+ emit("error", { message: event.message });
132
+ return;
133
+ case "refused":
134
+ emit("refused", { capability: event.capability, refusal: event.refusal });
135
+ return;
136
+ case "muted":
137
+ emit("muted", undefined);
138
+ return;
139
+ }
140
+ };
141
+
142
+ const stop = () => {
143
+ abort?.abort();
144
+ abort = null;
145
+ connection?.close();
146
+ connection = null;
147
+ surface?.destroy();
148
+ surface = null;
149
+ };
150
+
151
+ const start = (source: FrameSource) => {
152
+ const created = platform.createSurface(container, {
153
+ source,
154
+ title: mount.title,
155
+ sandbox: compiled.sandbox,
156
+ allow: compiled.allow,
157
+ referrerPolicy: compiled.referrerPolicy,
158
+ colorScheme: scheme,
159
+ initialHeightPx: height,
160
+ });
161
+ surface = created;
162
+ if (!speaks) {
163
+ return;
164
+ }
165
+ const bridge = createBridge({
166
+ config,
167
+ display: { id: mount.displayId, title: mount.title },
168
+ handlers,
169
+ ...(options.confirm === undefined ? {} : { confirm: options.confirm }),
170
+ userActivation: () => created.userActivation(),
171
+ onEvent: onBridge,
172
+ });
173
+ const opened = adapter.connect({
174
+ display: { id: mount.displayId, title: mount.title },
175
+ compiled,
176
+ transport: created.transport,
177
+ bridge,
178
+ hostInfo,
179
+ hostContext,
180
+ emit: (event) => {
181
+ if (event.type === "initialized") {
182
+ emit("initialized", undefined);
183
+ }
184
+ },
185
+ ...(mount.document === undefined ? {} : { document: mount.document }),
186
+ });
187
+ connection = opened;
188
+ for (const [name, args] of replay) {
189
+ (opened as unknown as Record<string, (...each: unknown[]) => unknown>)[name]?.(...args);
190
+ }
191
+ created.onNavigated(() => {
192
+ emit("navigated", undefined);
193
+ if (restarts < MAX_RESTARTS) {
194
+ restarts += 1;
195
+ run();
196
+ } else {
197
+ stop();
198
+ emit("error", {
199
+ message: "The content kept navigating its frame away; it was stopped.",
200
+ });
201
+ }
202
+ });
203
+ };
204
+
205
+ const run = () => {
206
+ stop();
207
+ if (destroyed) {
208
+ return;
209
+ }
210
+ generation += 1;
211
+ if (config.isolation === "opaque") {
212
+ start({
213
+ kind: "srcdoc",
214
+ html: compiled.shellDocument({
215
+ origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
216
+ title: mount.title,
217
+ ...(mount.document === undefined ? {} : { document: mount.document }),
218
+ }),
219
+ });
220
+ return;
221
+ }
222
+ const mine = generation;
223
+ const controller = new AbortController();
224
+ abort = controller;
225
+ const locator = options.locator;
226
+ const instance = mount.instance;
227
+ if (locator === undefined || instance === undefined) {
228
+ return;
229
+ }
230
+ const hostOrigin = container.ownerDocument.defaultView?.location.origin ?? "";
231
+ locateServed(locator, instance, config, hostOrigin, controller.signal).then(
232
+ (source) => {
233
+ if (mine === generation && !destroyed) {
234
+ start(source);
235
+ }
236
+ },
237
+ (error: unknown) => {
238
+ if (mine === generation && !controller.signal.aborted) {
239
+ emit("error", { message: error instanceof Error ? error.message : String(error) });
240
+ }
241
+ },
242
+ );
243
+ };
244
+
245
+ run();
246
+
247
+ const controls = {
248
+ displayId: mount.displayId,
249
+ setTheme(next: ColorScheme) {
250
+ scheme = next;
251
+ surface?.setColorScheme(next);
252
+ connection?.hostContextChanged(hostContext());
253
+ },
254
+ on<E extends keyof DisplayEvents>(event: E, listener: (detail: DisplayEvents[E]) => void) {
255
+ const set = listeners.get(event) ?? new Set();
256
+ set.add(listener);
257
+ listeners.set(event, set);
258
+ return () => set.delete(listener);
259
+ },
260
+ reload() {
261
+ restarts = 0;
262
+ run();
263
+ },
264
+ destroy() {
265
+ destroyed = true;
266
+ stop();
267
+ listeners.clear();
268
+ },
269
+ };
270
+
271
+ // The adapter's own operations (`setContent` for the shell adapter): forwarded to the frame
272
+ // running now, and remembered (the last call of each) for a frame started again.
273
+ return new Proxy(controls, {
274
+ get(target, key, receiver) {
275
+ if (typeof key === "string" && !(key in target) && !BASE_KEYS.has(key)) {
276
+ return (...args: unknown[]) => {
277
+ replay.delete(key);
278
+ replay.set(key, args);
279
+ const operation = (connection as unknown as Record<string, unknown> | null)?.[key];
280
+ return typeof operation === "function"
281
+ ? (operation as (...each: unknown[]) => unknown).apply(connection, args)
282
+ : undefined;
283
+ };
284
+ }
285
+ return Reflect.get(target, key, receiver);
286
+ },
287
+ }) as unknown as DisplayHandle<C>;
288
+ },
289
+ };
290
+ }
@@ -0,0 +1,20 @@
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 { kind: "url", url: new URL(location.url).href, origin: location.origin };
20
+ }
@@ -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
+ /**
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
+ export interface DisplayEvents {
56
+ /** The frame finished its handshake. */
57
+ readonly initialized: undefined;
58
+ /** The content's size, after the config's limits. */
59
+ readonly resize: { readonly height: number; readonly width?: number };
60
+ /** What the content threw, or what the sandbox blocked (its own words: untrusted). */
61
+ readonly error: { readonly message: string };
62
+ /** The content navigated its frame away; the frame was started again from its content. */
63
+ readonly navigated: undefined;
64
+ /** A call didn't run. */
65
+ readonly refused: { readonly capability: CapabilityName; readonly refusal: Refusal };
66
+ /** Too many declines: the display stops asking until it runs again. */
67
+ readonly muted: undefined;
68
+ }
69
+
70
+ export interface DisplayControls {
71
+ readonly displayId: string;
72
+ /** The theme flipped: the frame is re-themed in place, without a reload. */
73
+ setTheme(scheme: ColorScheme): void;
74
+ on<E extends keyof DisplayEvents>(
75
+ event: E,
76
+ listener: (detail: DisplayEvents[E]) => void,
77
+ ): () => void;
78
+ /** Runs the content again from the start, in a new frame. */
79
+ reload(): void;
80
+ /** Tears the view down and removes the frame. */
81
+ destroy(): void;
82
+ }
83
+
84
+ /** A mounted display: the framework's controls plus the adapter's own operations. */
85
+ export type DisplayHandle<C extends AdapterConnection = ShellConnection> = DisplayControls &
86
+ Omit<C, keyof AdapterConnection>;
87
+
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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `@botiverse/porthole`: the framework-agnostic core. One config (a preset's) compiles into the
3
+ * frame's policies, the shell, and the agent manual; the host mounts displays under it.
4
+ */
5
+
6
+ // The config and its vocabulary.
7
+ export {
8
+ CAPABILITIES,
9
+ CAPABILITY_NAMES,
10
+ capabilitySpec,
11
+ isCapabilityName,
12
+ type CapabilityArgs,
13
+ type CapabilityName,
14
+ type CapabilityResults,
15
+ type CapabilitySpec,
16
+ type Gate,
17
+ type HandledCapability,
18
+ } from "./config/capabilities.ts";
19
+ export type {
20
+ CapabilityConfig,
21
+ CapabilityMap,
22
+ ColorScheme,
23
+ DisplayMode,
24
+ EgressConfig,
25
+ EmbedderConfig,
26
+ GuideConfig,
27
+ IsolationKind,
28
+ LayoutConfig,
29
+ Limits,
30
+ PortholeConfig,
31
+ ThemeConfig,
32
+ ThemeValue,
33
+ } from "./config/schema.ts";
34
+ export { BUILT_IN_TOPICS, ConfigError, isOriginSource, validateConfig } from "./config/validate.ts";
35
+
36
+ // Presets.
37
+ export {
38
+ LIBRARY_CDNS,
39
+ presets,
40
+ type PresetName,
41
+ type PresetOptions,
42
+ type UrlPresetOptions,
43
+ } from "./presets/presets.ts";
44
+
45
+ // The compiler.
46
+ export { compile, type CompiledConfig } from "./compile/compile.ts";
47
+ export {
48
+ allEgressOrigins,
49
+ declaredCapabilities,
50
+ effectiveGates,
51
+ egressOrigins,
52
+ embedderCsp,
53
+ type CspDirective,
54
+ type EgressKind,
55
+ } from "./compile/policy.ts";
56
+ export { shellPlan, type ShellPlan } from "./shell/plan.ts";
57
+ export type { ShellDocumentOptions } from "./shell/document.ts";
58
+ export {
59
+ generateGuide,
60
+ generateToolDescription,
61
+ type BuiltInTopic,
62
+ type GuideTopic,
63
+ } from "./guide/guide.ts";
64
+ export { GUIDE_EXAMPLES, type GuideExample } from "./guide/examples.ts";
65
+
66
+ // Theme.
67
+ export {
68
+ CHART_PALETTE,
69
+ DEFAULT_THEME,
70
+ DEFAULT_THEME_EVENT,
71
+ DEFAULT_THEME_VARIABLES,
72
+ resolveTheme,
73
+ themeDefault,
74
+ } from "./theme/variables.ts";
75
+
76
+ // The seams: isolation, platform, transport, protocol, bridge, host.
77
+ export type {
78
+ FrameLocation,
79
+ FrameSource,
80
+ InstanceLocator,
81
+ InstanceRef,
82
+ ServedDocument,
83
+ } from "./isolation/types.ts";
84
+ export type { PlatformHost, Surface, SurfaceSpec } from "./platform/types.ts";
85
+ export type { Transport } from "./transport/types.ts";
86
+ export type {
87
+ AdapterConnection,
88
+ AdapterEvent,
89
+ AdapterSession,
90
+ ProtocolAdapter,
91
+ ShellConnection,
92
+ } from "./protocol/adapter.ts";
93
+ export {
94
+ MCP_APPS_METHODS,
95
+ MCP_APPS_PROTOCOL_VERSION,
96
+ MCP_APPS_STYLE_VARIABLES,
97
+ PORTHOLE_STYLES,
98
+ type HostContext,
99
+ } from "./protocol/mcp-apps.ts";
100
+ export type {
101
+ CallContext,
102
+ CallOutcome,
103
+ CapabilityHandler,
104
+ CapabilityHandlers,
105
+ Confirm,
106
+ ConfirmRequest,
107
+ HostFunctionBridge,
108
+ Refusal,
109
+ } from "./bridge/types.ts";
110
+ export { createHost } from "./host/create-host.ts";
111
+ export type {
112
+ DisplayControls,
113
+ DisplayEvents,
114
+ DisplayHandle,
115
+ HostOptions,
116
+ MountOptions,
117
+ PortholeHost,
118
+ } from "./host/types.ts";
@@ -0,0 +1,127 @@
1
+ import type { FrameLocation } from "./types.ts";
2
+
3
+ /**
4
+ * Checks for served frames (domain and external isolation), shared by the host's mount and by
5
+ * `/server`: a served origin must be one the config names, and never of the host's own site
6
+ * (P016, P020). Pure, no DOM and no Node, so both sides run the same code.
7
+ */
8
+
9
+ const EXACT_ORIGIN = /^(https?):\/\/([a-z0-9.-]+|\[[0-9a-f:.]+\])(?::(\d{1,5}))?$/u;
10
+ const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/u;
11
+
12
+ interface ParsedOrigin {
13
+ readonly scheme: string;
14
+ readonly host: string;
15
+ readonly port: string;
16
+ }
17
+
18
+ const DEFAULT_PORTS: Readonly<Record<string, string>> = { http: "80", https: "443" };
19
+
20
+ /** `scheme://host[:port]`, lowercase, nothing else; null for anything that isn't exactly that. */
21
+ export function parseOrigin(origin: string): ParsedOrigin | null {
22
+ const match = EXACT_ORIGIN.exec(origin);
23
+ if (match === null) {
24
+ return null;
25
+ }
26
+ const [, scheme = "", host = "", port] = match;
27
+ return { scheme, host, port: port ?? DEFAULT_PORTS[scheme] ?? "" };
28
+ }
29
+
30
+ /** Whether `origin` is an exact origin (`https://host[:port]`, lowercase, no path). */
31
+ export function isExactOrigin(origin: unknown): origin is string {
32
+ return typeof origin === "string" && parseOrigin(origin) !== null;
33
+ }
34
+
35
+ /**
36
+ * Whether two hosts may be the same site. Without the public-suffix list this errs one way only:
37
+ * it says "same site" whenever the hosts are equal or share their last two labels, so a pair it
38
+ * calls cross-site always is (a refused pair, like `a.github.io` and `b.github.io`, may not be:
39
+ * a safe failure). An IP address is a site of its own; `localhost`'s subdomains are each one, as
40
+ * browsers treat an unlisted top-level name as a public suffix.
41
+ */
42
+ export function maybeSameSite(hostA: string, hostB: string): boolean {
43
+ const a = hostA.toLowerCase().replace(/\.$/u, "");
44
+ const b = hostB.toLowerCase().replace(/\.$/u, "");
45
+ if (a === b) {
46
+ return true;
47
+ }
48
+ if (IPV4.test(a) || IPV4.test(b) || a.startsWith("[") || b.startsWith("[")) {
49
+ return false;
50
+ }
51
+ const labelsA = a.split(".").toReversed();
52
+ const labelsB = b.split(".").toReversed();
53
+ let shared = 0;
54
+ while (
55
+ shared < labelsA.length &&
56
+ shared < labelsB.length &&
57
+ labelsA[shared] === labelsB[shared]
58
+ ) {
59
+ shared += 1;
60
+ }
61
+ return shared >= 2;
62
+ }
63
+
64
+ /**
65
+ * Whether `origin` matches a frame source as the config writes them (`https://*.example.com`,
66
+ * `http://*.localhost:8790`, or an exact origin): same scheme and port; `*.` matches one or more
67
+ * labels below the name, never the name itself.
68
+ */
69
+ export function originMatchesSource(origin: string, source: string): boolean {
70
+ const parsed = parseOrigin(origin);
71
+ const wildcard = /^(https?):\/\/\*\.([a-z0-9.-]+)(?::(\d{1,5}))?$/u.exec(source);
72
+ if (parsed === null) {
73
+ return false;
74
+ }
75
+ if (wildcard === null) {
76
+ const exact = parseOrigin(source);
77
+ return (
78
+ exact !== null &&
79
+ exact.scheme === parsed.scheme &&
80
+ exact.host === parsed.host &&
81
+ exact.port === parsed.port
82
+ );
83
+ }
84
+ const [, scheme = "", suffix = "", port] = wildcard;
85
+ return (
86
+ scheme === parsed.scheme &&
87
+ (port ?? DEFAULT_PORTS[scheme]) === parsed.port &&
88
+ parsed.host.endsWith(`.${suffix}`) &&
89
+ parsed.host.length > suffix.length + 1
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Throws unless `location` is a frame a host on `hostOrigin` may load for a config whose content
95
+ * is served from `contentOrigins`: its URL is on its stated origin, that origin is one the config
96
+ * names, and it is not of the host's own site (a same-site frame with `allow-same-origin` could
97
+ * reach cookies and storage the host shares with its site).
98
+ */
99
+ export function checkFrameLocation(
100
+ location: FrameLocation,
101
+ contentOrigins: readonly string[],
102
+ hostOrigin: string,
103
+ ): void {
104
+ const origin = parseOrigin(location.origin);
105
+ let url: URL;
106
+ try {
107
+ url = new URL(location.url);
108
+ } catch {
109
+ throw new TypeError(`The located URL ${JSON.stringify(location.url)} is not a URL`);
110
+ }
111
+ if (origin === null || url.origin !== location.origin) {
112
+ throw new TypeError(
113
+ `The located URL ${JSON.stringify(location.url)} is not on its origin ${JSON.stringify(location.origin)}`,
114
+ );
115
+ }
116
+ if (!contentOrigins.some((source) => originMatchesSource(location.origin, source))) {
117
+ throw new TypeError(
118
+ `The located origin ${location.origin} is not one of the config's content origins`,
119
+ );
120
+ }
121
+ const host = parseOrigin(hostOrigin);
122
+ if (host !== null && maybeSameSite(origin.host, host.host)) {
123
+ throw new TypeError(
124
+ `The located origin ${location.origin} may be the same site as the host (${hostOrigin}): content must be served from a registrable domain the host uses for nothing else`,
125
+ );
126
+ }
127
+ }