@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.
Files changed (152) hide show
  1. package/LICENSE +217 -0
  2. package/dist/bridge/bridge.d.ts +39 -0
  3. package/dist/bridge/bridge.js +198 -0
  4. package/dist/bridge/types.d.ts +83 -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 +103 -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 +152 -0
  13. package/dist/config/schema.js +11 -0
  14. package/dist/config/validate.d.ts +22 -0
  15. package/dist/config/validate.js +261 -0
  16. package/dist/conformance/behavior.d.ts +2 -0
  17. package/dist/conformance/behavior.js +426 -0
  18. package/dist/conformance/index.d.ts +101 -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 +33 -0
  25. package/dist/conformance/probes.js +876 -0
  26. package/dist/conformance/served.d.ts +75 -0
  27. package/dist/conformance/served.js +335 -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 +265 -0
  32. package/dist/host/create-host.d.ts +8 -0
  33. package/dist/host/create-host.js +292 -0
  34. package/dist/host/locate.d.ts +8 -0
  35. package/dist/host/locate.js +16 -0
  36. package/dist/host/outcome.d.ts +24 -0
  37. package/dist/host/outcome.js +134 -0
  38. package/dist/host/types.d.ts +127 -0
  39. package/dist/host/types.js +1 -0
  40. package/dist/index.d.ts +25 -0
  41. package/dist/index.js +21 -0
  42. package/dist/isolation/served.d.ts +35 -0
  43. package/dist/isolation/served.js +117 -0
  44. package/dist/isolation/types.d.ts +96 -0
  45. package/dist/isolation/types.js +2 -0
  46. package/dist/mcp/adapter.d.ts +43 -0
  47. package/dist/mcp/adapter.js +327 -0
  48. package/dist/mcp/index.d.ts +27 -0
  49. package/dist/mcp/index.js +25 -0
  50. package/dist/mcp/proxy.d.ts +25 -0
  51. package/dist/mcp/proxy.js +106 -0
  52. package/dist/mcp/view.d.ts +31 -0
  53. package/dist/mcp/view.js +72 -0
  54. package/dist/platform/types.d.ts +51 -0
  55. package/dist/platform/types.js +1 -0
  56. package/dist/platform/web.d.ts +8 -0
  57. package/dist/platform/web.js +85 -0
  58. package/dist/presets/presets.d.ts +71 -0
  59. package/dist/presets/presets.js +140 -0
  60. package/dist/protocol/adapter.d.ts +73 -0
  61. package/dist/protocol/adapter.js +1 -0
  62. package/dist/protocol/jsonrpc.d.ts +43 -0
  63. package/dist/protocol/jsonrpc.js +50 -0
  64. package/dist/protocol/mcp-apps.d.ts +79 -0
  65. package/dist/protocol/mcp-apps.js +99 -0
  66. package/dist/protocol/shell-adapter.d.ts +5 -0
  67. package/dist/protocol/shell-adapter.js +228 -0
  68. package/dist/react/dialog.d.ts +20 -0
  69. package/dist/react/dialog.js +98 -0
  70. package/dist/react/frame.d.ts +91 -0
  71. package/dist/react/frame.js +255 -0
  72. package/dist/react/index.d.ts +13 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/live.d.ts +24 -0
  75. package/dist/react/live.js +57 -0
  76. package/dist/server/index.d.ts +27 -0
  77. package/dist/server/index.js +26 -0
  78. package/dist/server/keys.d.ts +19 -0
  79. package/dist/server/keys.js +78 -0
  80. package/dist/server/pages.d.ts +11 -0
  81. package/dist/server/pages.js +86 -0
  82. package/dist/server/proxy.d.ts +21 -0
  83. package/dist/server/proxy.js +107 -0
  84. package/dist/server/server.d.ts +7 -0
  85. package/dist/server/server.js +542 -0
  86. package/dist/server/tickets.d.ts +8 -0
  87. package/dist/server/tickets.js +28 -0
  88. package/dist/server/types.d.ts +97 -0
  89. package/dist/server/types.js +1 -0
  90. package/dist/shell/document.d.ts +29 -0
  91. package/dist/shell/document.js +85 -0
  92. package/dist/shell/plan.d.ts +37 -0
  93. package/dist/shell/plan.js +35 -0
  94. package/dist/shell/runtime.d.ts +28 -0
  95. package/dist/shell/runtime.js +848 -0
  96. package/dist/theme/variables.d.ts +73 -0
  97. package/dist/theme/variables.js +171 -0
  98. package/dist/transport/post-message.d.ts +9 -0
  99. package/dist/transport/post-message.js +38 -0
  100. package/dist/transport/types.d.ts +15 -0
  101. package/dist/transport/types.js +1 -0
  102. package/package.json +63 -1
  103. package/src/bridge/bridge.ts +278 -0
  104. package/src/bridge/types.ts +95 -0
  105. package/src/compile/compile.ts +68 -0
  106. package/src/compile/policy.ts +130 -0
  107. package/src/config/capabilities.ts +138 -0
  108. package/src/config/schema.ts +168 -0
  109. package/src/config/validate.ts +318 -0
  110. package/src/conformance/behavior.ts +485 -0
  111. package/src/conformance/index.ts +116 -0
  112. package/src/conformance/kit.ts +63 -0
  113. package/src/conformance/oracles.ts +126 -0
  114. package/src/conformance/probes.ts +1129 -0
  115. package/src/conformance/served.ts +501 -0
  116. package/src/guide/examples.ts +74 -0
  117. package/src/guide/guide.ts +430 -0
  118. package/src/host/create-host.ts +335 -0
  119. package/src/host/locate.ts +25 -0
  120. package/src/host/outcome.ts +159 -0
  121. package/src/host/types.ts +126 -0
  122. package/src/index.ts +128 -0
  123. package/src/isolation/served.ts +151 -0
  124. package/src/isolation/types.ts +91 -0
  125. package/src/mcp/adapter.ts +441 -0
  126. package/src/mcp/index.ts +50 -0
  127. package/src/mcp/proxy.ts +117 -0
  128. package/src/mcp/view.ts +97 -0
  129. package/src/platform/types.ts +55 -0
  130. package/src/platform/web.ts +91 -0
  131. package/src/presets/presets.ts +217 -0
  132. package/src/protocol/adapter.ts +67 -0
  133. package/src/protocol/jsonrpc.ts +83 -0
  134. package/src/protocol/mcp-apps.ts +138 -0
  135. package/src/protocol/shell-adapter.ts +261 -0
  136. package/src/react/dialog.tsx +158 -0
  137. package/src/react/frame.tsx +486 -0
  138. package/src/react/index.ts +20 -0
  139. package/src/react/live.ts +76 -0
  140. package/src/server/index.ts +37 -0
  141. package/src/server/keys.ts +100 -0
  142. package/src/server/pages.ts +117 -0
  143. package/src/server/proxy.ts +131 -0
  144. package/src/server/server.ts +657 -0
  145. package/src/server/tickets.ts +30 -0
  146. package/src/server/types.ts +104 -0
  147. package/src/shell/document.ts +121 -0
  148. package/src/shell/plan.ts +69 -0
  149. package/src/shell/runtime.ts +848 -0
  150. package/src/theme/variables.ts +190 -0
  151. package/src/transport/post-message.ts +45 -0
  152. package/src/transport/types.ts +16 -0
@@ -0,0 +1,486 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useEffectEvent,
7
+ useId,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ useSyncExternalStore,
13
+ type CSSProperties,
14
+ type ReactElement,
15
+ type ReactNode,
16
+ } from "react";
17
+ import type {
18
+ CallContext,
19
+ CapabilityHandlers,
20
+ Confirm,
21
+ ConfirmAnswer,
22
+ ConfirmRequest,
23
+ Proceed,
24
+ Refusal,
25
+ } from "../bridge/types.ts";
26
+ import type { CapabilityName } from "../config/capabilities.ts";
27
+ import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
28
+ import { createHost } from "../host/create-host.ts";
29
+ import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
30
+ import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
31
+ import { ConfirmDialog } from "./dialog.tsx";
32
+ import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
33
+
34
+ /**
35
+ * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
36
+ * run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
37
+ * display mounts it when it nears the screen, streams its content, flips its theme, and remembers
38
+ * its height. All the isolation is the core's (`createHost`); this is mounting and lifecycle.
39
+ */
40
+
41
+ /** How far off screen a frame starts (and keeps) running. */
42
+ const NEAR_SCREEN = "800px 0px";
43
+ /** The height a display holds before it has reported its own. */
44
+ export const PLACEHOLDER_HEIGHT_PX = 96;
45
+
46
+ interface PageState {
47
+ readonly live: LiveFrames;
48
+ /** Heights displays reported, by display id: a frame mounting again starts at its last. */
49
+ readonly heights: Map<string, number>;
50
+ readonly confirm?: Confirm;
51
+ readonly hostInfo?: { readonly name: string; readonly version: string };
52
+ readonly locator?: InstanceLocator;
53
+ }
54
+
55
+ /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
56
+ const PAGE: PageState = { live: createLiveFrames(), heights: new Map() };
57
+
58
+ const Page = createContext<PageState>(PAGE);
59
+
60
+ export interface PortholeProviderProps {
61
+ /**
62
+ * Who confirms gated calls for every frame below. Omitted: the default dialog (`ConfirmDialog`).
63
+ * Pass your own function to replace it.
64
+ */
65
+ readonly confirm?: Confirm;
66
+ /** How the host names itself in the handshake. */
67
+ readonly hostInfo?: { readonly name: string; readonly version: string };
68
+ /** Where domain and external frames load from (see `InstanceLocator`). */
69
+ readonly locator?: InstanceLocator;
70
+ /** A class for the default dialog, to restyle it. */
71
+ readonly dialogClassName?: string;
72
+ readonly children?: ReactNode;
73
+ }
74
+
75
+ interface Pending {
76
+ readonly request: ConfirmRequest;
77
+ readonly resolve: (answer: ConfirmAnswer) => void;
78
+ readonly proceed: Proceed;
79
+ }
80
+
81
+ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
82
+ const live = useMemo(() => createLiveFrames(), []);
83
+ const heights = useMemo(() => new Map<string, number>(), []);
84
+ const [queue, setQueue] = useState<readonly Pending[]>([]);
85
+ // The host's confirm as of the last render, read when a frame asks (never during render).
86
+ const own = useRef(props.confirm);
87
+ useLayoutEffect(() => {
88
+ own.current = props.confirm;
89
+ });
90
+ const confirm = useCallback<Confirm>(
91
+ (request, proceed) =>
92
+ own.current !== undefined
93
+ ? own.current(request, proceed)
94
+ : new Promise<ConfirmAnswer>((resolve) =>
95
+ setQueue((waiting) => [...waiting, { request, resolve, proceed }]),
96
+ ),
97
+ [],
98
+ );
99
+ const { hostInfo, locator } = props;
100
+ const value = useMemo<PageState>(
101
+ () => ({
102
+ live,
103
+ heights,
104
+ confirm,
105
+ ...(hostInfo === undefined ? {} : { hostInfo }),
106
+ ...(locator === undefined ? {} : { locator }),
107
+ }),
108
+ [live, heights, confirm, hostInfo, locator],
109
+ );
110
+ const current = queue[0] ?? null;
111
+ return (
112
+ <Page.Provider value={value}>
113
+ {props.children}
114
+ {props.confirm === undefined ? (
115
+ <ConfirmDialog
116
+ request={current?.request ?? null}
117
+ {...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName })}
118
+ onAnswer={(accepted) => {
119
+ // A yes runs the effect inside this very click (a window it opens is the person's).
120
+ if (accepted) {
121
+ current?.proceed();
122
+ }
123
+ current?.resolve(accepted ? "accepted" : "declined");
124
+ setQueue((waiting) => waiting.slice(1));
125
+ }}
126
+ />
127
+ ) : null}
128
+ </Page.Provider>
129
+ );
130
+ }
131
+
132
+ /** What a placeholder shows: a display not yet running, loading, or stopped for others. */
133
+ export interface PlaceholderProps {
134
+ readonly state: "waiting" | "loading" | "stopped";
135
+ readonly title: string;
136
+ /** Runs the display again (a stopped one), stopping another if the page is at its most. */
137
+ readonly run: () => void;
138
+ }
139
+
140
+ export interface PortholeFrameProps {
141
+ /** A preset's config. Keep it stable (module scope or memoized): a new one remounts the frame. */
142
+ readonly config: PortholeConfig;
143
+ /** The host's id for this display: what its height is remembered by, and calls name. */
144
+ readonly displayId: string;
145
+ /** The frame's accessible name, and the title its confirmations show (the host's words). */
146
+ readonly title: string;
147
+ /** The content so far (inline and artifact modes). */
148
+ readonly content?: string;
149
+ /** The content is still being written: drawn inert as it grows, run once when whole. */
150
+ readonly partial?: boolean;
151
+ /** A whole document to start with (an MCP Apps view). */
152
+ readonly document?: string;
153
+ /** Domain and external isolation: the instance to show. */
154
+ readonly instance?: InstanceRef;
155
+ readonly scheme: ColorScheme;
156
+ /** The host's live values for the config's theme variables (from its design tokens). */
157
+ readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
158
+ /** Handlers for the declared capabilities (the latest ones are called). */
159
+ readonly handlers?: CapabilityHandlers;
160
+ /** Who confirms this frame's gated calls, over the provider's. */
161
+ readonly confirm?: Confirm;
162
+ /** Bump to run the content again from the start. */
163
+ readonly generation?: number;
164
+ /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
165
+ readonly eager?: boolean;
166
+ readonly onInitialized?: () => void;
167
+ readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
168
+ readonly onError?: (message: string) => void;
169
+ /** The content navigated its frame away; it was started again (or stopped, after a few). */
170
+ readonly onNavigated?: () => void;
171
+ readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
172
+ /** Too many declines: the display stops asking until it runs again. */
173
+ readonly onMuted?: () => void;
174
+ /**
175
+ * How the frame's first document went: loaded, blocked (and why), or a timeout. A host shows its
176
+ * own fallback (a url-mode app "Open in a new tab") on anything but loaded.
177
+ */
178
+ readonly onOutcome?: (outcome: FrameOutcome) => void;
179
+ /**
180
+ * Other ids this display had (the call's id, before the display's own settled): its remembered
181
+ * height is found under them, for a frame that mounts again under its new id.
182
+ */
183
+ readonly aliases?: readonly string[];
184
+ /**
185
+ * Change it when the host's theme values change without a scheme flip (or after an async
186
+ * restyle): the running frame reads them again.
187
+ */
188
+ readonly themeKey?: string | number;
189
+ /** Draws the placeholder instead of the default one. */
190
+ readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
191
+ readonly className?: string;
192
+ readonly style?: CSSProperties;
193
+ }
194
+
195
+ const PLACEHOLDER_STYLE: CSSProperties = {
196
+ position: "absolute",
197
+ inset: 0,
198
+ display: "flex",
199
+ flexDirection: "column",
200
+ alignItems: "center",
201
+ justifyContent: "center",
202
+ gap: 8,
203
+ fontSize: "0.8em",
204
+ opacity: 0.7,
205
+ };
206
+
207
+ function DefaultPlaceholder({ state, title, run }: PlaceholderProps): ReactElement {
208
+ return (
209
+ <div
210
+ data-porthole-placeholder={state}
211
+ style={{ ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }}
212
+ >
213
+ <span>{state === "stopped" ? "Stopped while others run" : title}</span>
214
+ {state === "stopped" ? (
215
+ <button type="button" data-porthole-run="" onClick={run}>
216
+ Run again
217
+ </button>
218
+ ) : null}
219
+ </div>
220
+ );
221
+ }
222
+
223
+ /** A display's remembered height: under its id, else under the first of its other ids that has one. */
224
+ function rememberedHeight(
225
+ heights: ReadonlyMap<string, number>,
226
+ displayId: string,
227
+ aliases: readonly string[] | undefined,
228
+ ): number {
229
+ for (const id of [displayId, ...(aliases ?? [])]) {
230
+ const height = heights.get(id);
231
+ if (height !== undefined) {
232
+ return height;
233
+ }
234
+ }
235
+ return 0;
236
+ }
237
+
238
+ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
239
+ const page = useContext(Page);
240
+ const key = useId();
241
+ const box = useRef<HTMLDivElement>(null);
242
+ const max = props.config.limits.maxLiveFrames;
243
+ const state = useSyncExternalStore(
244
+ (listener) => page.live.subscribe(listener),
245
+ () => page.live.state(key),
246
+ (): FrameState => "waiting",
247
+ );
248
+ const { eager } = props;
249
+
250
+ useEffect(() => {
251
+ const element = box.current;
252
+ if (element === null) {
253
+ return;
254
+ }
255
+ if (eager === true || typeof IntersectionObserver !== "function") {
256
+ page.live.near(key, true);
257
+ page.live.admit(key, max);
258
+ return () => page.live.release(key);
259
+ }
260
+ const observer = new IntersectionObserver(
261
+ (entries) => {
262
+ const entry = entries.at(-1);
263
+ if (entry === undefined) {
264
+ return;
265
+ }
266
+ page.live.near(key, entry.isIntersecting);
267
+ if (entry.isIntersecting) {
268
+ page.live.admit(key, max);
269
+ }
270
+ },
271
+ { rootMargin: NEAR_SCREEN },
272
+ );
273
+ observer.observe(element);
274
+ return () => {
275
+ observer.disconnect();
276
+ page.live.release(key);
277
+ };
278
+ }, [page, key, max, eager]);
279
+
280
+ const height = rememberedHeight(page.heights, props.displayId, props.aliases);
281
+ // A display renamed while it runs (its id settled) keeps its height under its new id.
282
+ const previousId = useRef(props.displayId);
283
+ useLayoutEffect(() => {
284
+ const before = previousId.current;
285
+ previousId.current = props.displayId;
286
+ const kept = page.heights.get(before);
287
+ if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
288
+ page.heights.set(props.displayId, kept);
289
+ }
290
+ }, [page, props.displayId]);
291
+ const run = () => page.live.admit(key, max);
292
+ const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
293
+ return (
294
+ <div
295
+ ref={box}
296
+ className={props.className}
297
+ data-porthole-display={props.displayId}
298
+ data-porthole-state={state}
299
+ style={{
300
+ position: "relative",
301
+ width: "100%",
302
+ ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
303
+ ...props.style,
304
+ }}
305
+ >
306
+ {state === "running" ? (
307
+ <Mount
308
+ key={String(props.generation ?? 0)}
309
+ frame={props}
310
+ page={page}
311
+ initialHeightPx={height}
312
+ placeholder={placeholder}
313
+ run={run}
314
+ />
315
+ ) : (
316
+ placeholder({ state, title: props.title, run })
317
+ )}
318
+ </div>
319
+ );
320
+ }
321
+
322
+ interface MountProps {
323
+ readonly frame: PortholeFrameProps;
324
+ readonly page: PageState;
325
+ readonly initialHeightPx: number;
326
+ readonly placeholder: (props: PlaceholderProps) => ReactNode;
327
+ readonly run: () => void;
328
+ }
329
+
330
+ type AnyHandler = (args: unknown, context: CallContext) => unknown;
331
+
332
+ /** One running display: created when mounted, torn down when unmounted, fed as props change. */
333
+ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps): ReactElement {
334
+ const holder = useRef<HTMLDivElement>(null);
335
+ const handle = useRef<DisplayHandle | null>(null);
336
+ const [ready, setReady] = useState(false);
337
+ // The height this display started at: later reports don't make it start again.
338
+ const [startHeight] = useState(initialHeightPx);
339
+ const { config, document: view, instance, content, partial, scheme } = frame;
340
+ const fills = config.mode === "url";
341
+
342
+ // The latest props, read when the display calls back (never during render).
343
+ const latest = useEffectEvent(() => frame);
344
+ const notify = useEffectEvent(
345
+ <E extends keyof DisplayEvents>(event: E, detail: DisplayEvents[E]): void => {
346
+ switch (event) {
347
+ case "initialized":
348
+ setReady(true);
349
+ frame.onInitialized?.();
350
+ return;
351
+ case "resize": {
352
+ const size = detail as DisplayEvents["resize"];
353
+ page.heights.set(frame.displayId, size.height);
354
+ frame.onResize?.(size);
355
+ return;
356
+ }
357
+ case "error":
358
+ frame.onError?.((detail as DisplayEvents["error"]).message);
359
+ return;
360
+ case "navigated":
361
+ frame.onNavigated?.();
362
+ return;
363
+ case "refused": {
364
+ const { capability, refusal } = detail as DisplayEvents["refused"];
365
+ frame.onRefused?.(capability, refusal);
366
+ return;
367
+ }
368
+ case "muted":
369
+ frame.onMuted?.();
370
+ return;
371
+ case "outcome":
372
+ frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
+ return;
374
+ }
375
+ },
376
+ );
377
+
378
+ useEffect(() => {
379
+ const container = holder.current;
380
+ if (container === null) {
381
+ return;
382
+ }
383
+ const first = latest();
384
+ // A display keeps running when the host renames it (a streamed display's id and title can
385
+ // settle after it starts): calls and confirmations name it as the latest props do.
386
+ const named = (context: CallContext): CallContext => ({
387
+ ...context,
388
+ displayId: latest().displayId,
389
+ title: latest().title,
390
+ });
391
+ // The handlers present now (a declared one must be), each calling the latest prop's.
392
+ const handlers: Record<string, AnyHandler> = {};
393
+ for (const name of Object.keys(first.handlers ?? {})) {
394
+ handlers[name] = (args, context) =>
395
+ (latest().handlers as Record<string, AnyHandler | undefined> | undefined)?.[name]?.(
396
+ args,
397
+ named(context),
398
+ );
399
+ }
400
+ const asks = first.confirm !== undefined || page.confirm !== undefined;
401
+ let display: DisplayHandle;
402
+ try {
403
+ display = createHost(config, {
404
+ handlers,
405
+ ...(asks
406
+ ? {
407
+ confirm: (request: ConfirmRequest, proceed: Proceed) =>
408
+ (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))(
409
+ { ...request, display: { id: latest().displayId, title: latest().title } },
410
+ proceed,
411
+ ),
412
+ }
413
+ : {}),
414
+ theme: (each) => latest().theme?.(each) ?? {},
415
+ ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
416
+ ...(page.locator === undefined ? {} : { locator: page.locator }),
417
+ }).mount(container, {
418
+ displayId: first.displayId,
419
+ title: first.title,
420
+ scheme: first.scheme,
421
+ initialHeightPx: startHeight,
422
+ ...(view === undefined ? {} : { document: view }),
423
+ ...(instance === undefined ? {} : { instance }),
424
+ });
425
+ } catch (error) {
426
+ first.onError?.(error instanceof Error ? error.message : String(error));
427
+ return;
428
+ }
429
+ handle.current = display;
430
+ const events: (keyof DisplayEvents)[] = [
431
+ "initialized",
432
+ "resize",
433
+ "error",
434
+ "navigated",
435
+ "refused",
436
+ "muted",
437
+ "outcome",
438
+ ];
439
+ for (const event of events) {
440
+ display.on(event, (detail) => notify(event, detail));
441
+ }
442
+ if (first.content !== undefined) {
443
+ display.setContent(first.content, { partial: first.partial === true });
444
+ }
445
+ return () => {
446
+ display.destroy();
447
+ handle.current = null;
448
+ };
449
+ // A new display only for a new config, document, instance or page (or generation, by key);
450
+ // content, theme, handlers, id and title are fed to the running one.
451
+ }, [config, view, instance, page, startHeight]);
452
+
453
+ useEffect(() => {
454
+ if (content !== undefined) {
455
+ handle.current?.setContent(content, { partial: partial === true });
456
+ }
457
+ }, [content, partial]);
458
+ useEffect(() => {
459
+ handle.current?.setTheme(scheme);
460
+ }, [scheme]);
461
+ const { themeKey } = frame;
462
+ useEffect(() => {
463
+ if (themeKey !== undefined) {
464
+ handle.current?.refreshTheme();
465
+ }
466
+ }, [themeKey]);
467
+
468
+ return (
469
+ <div
470
+ data-porthole-ready={String(ready)}
471
+ style={{
472
+ position: "relative",
473
+ // A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
474
+ ...(fills ? { height: "100%" } : {}),
475
+ ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
476
+ }}
477
+ >
478
+ <div
479
+ ref={holder}
480
+ data-porthole-mount=""
481
+ style={{ opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) }}
482
+ />
483
+ {ready ? null : placeholder({ state: "loading", title: frame.title, run })}
484
+ </div>
485
+ );
486
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
+ * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
+ *
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog
6
+ * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
+ * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
+ * theme={tokensFor} onError={report} />
9
+ * </PortholeProvider>
10
+ */
11
+ export {
12
+ PLACEHOLDER_HEIGHT_PX,
13
+ PortholeFrame,
14
+ PortholeProvider,
15
+ type PlaceholderProps,
16
+ type PortholeFrameProps,
17
+ type PortholeProviderProps,
18
+ } from "./frame.tsx";
19
+ export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
20
+ export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Which frames run on a page: each runs a display's scripts, so a page runs at most a few at once.
3
+ * A frame runs when it comes near the screen (or is asked to run); past the most, the frames that
4
+ * went off screen longest ago stop, and show a placeholder at their last height until they come
5
+ * near again. Framework-free, so it is tested on its own.
6
+ */
7
+
8
+ export type FrameState = "waiting" | "running" | "stopped";
9
+
10
+ /**
11
+ * Which frames run once `key` comes near the screen or is asked to run. `live` is the frames
12
+ * running, the least recently admitted first. `key` runs; past `max`, the frames that are not near
13
+ * the screen stop first, oldest first (one near the screen stops only when all are near).
14
+ */
15
+ export function admitFrame(
16
+ live: readonly string[],
17
+ near: ReadonlySet<string>,
18
+ key: string,
19
+ max: number,
20
+ ): string[] {
21
+ const next = [...live.filter((other) => other !== key), key];
22
+ while (next.length > Math.max(1, max)) {
23
+ const far = next.findIndex((other) => other !== key && !near.has(other));
24
+ next.splice(far === -1 ? 0 : far, 1);
25
+ }
26
+ return next;
27
+ }
28
+
29
+ export interface LiveFrames {
30
+ subscribe(listener: () => void): () => void;
31
+ state(key: string): FrameState;
32
+ /** The frame came near the screen, or left it. */
33
+ near(key: string, isNear: boolean): void;
34
+ /** `key` runs (near the screen, or asked to run again); others past `max` stop. */
35
+ admit(key: string, max: number): void;
36
+ /** The frame is gone. */
37
+ release(key: string): void;
38
+ }
39
+
40
+ export function createLiveFrames(): LiveFrames {
41
+ let live: readonly string[] = [];
42
+ const nearScreen = new Set<string>();
43
+ const ran = new Set<string>();
44
+ const listeners = new Set<() => void>();
45
+ const changed = (next: readonly string[]) => {
46
+ live = next;
47
+ for (const listener of listeners) {
48
+ listener();
49
+ }
50
+ };
51
+ return {
52
+ subscribe(listener) {
53
+ listeners.add(listener);
54
+ return () => listeners.delete(listener);
55
+ },
56
+ state: (key) => (live.includes(key) ? "running" : ran.has(key) ? "stopped" : "waiting"),
57
+ near(key, isNear) {
58
+ if (isNear) {
59
+ nearScreen.add(key);
60
+ } else {
61
+ nearScreen.delete(key);
62
+ }
63
+ },
64
+ admit(key, max) {
65
+ ran.add(key);
66
+ changed(admitFrame(live, nearScreen, key, max));
67
+ },
68
+ release(key) {
69
+ nearScreen.delete(key);
70
+ ran.delete(key);
71
+ if (live.includes(key)) {
72
+ changed(live.filter((other) => other !== key));
73
+ }
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@botiverse/porthole/server`: serving content on origins of its own (domain mode, artifact
3
+ * pages, the MCP Apps sandbox proxy, and url mode's access gate). A host's backend mounts it as a
4
+ * fetch-style handler in front of its own routes, for every request whose host is under the
5
+ * content domain:
6
+ *
7
+ * const porthole = createServer({ content: { domain: "example-usercontent.com", scheme: "https" }, … });
8
+ * app.all("*", async (c, next) => (await porthole.handle(c.req.raw)) ?? next()); // Hono
9
+ *
10
+ * and calls `issue` from its own authenticated API when a viewer opens served content.
11
+ *
12
+ * Origins: each storage scope gets `<label>.<domain>`, the label keyed from the scope by the
13
+ * secret (stable, DNS-safe, not guessable from the scope). The domain is a registrable domain the
14
+ * host uses for nothing else, so no content origin is ever same-site with the host. Documents go
15
+ * out with their CSP (from the config) plus `frame-ancestors` (the host origins) in headers.
16
+ *
17
+ * Tickets: `issue` mints a one-time ticket for an instance and a viewer, good for a short time;
18
+ * the frame's first request carries it, and the content origin takes it (once). A served document
19
+ * comes back in that same answer, with a cookie of that origin only (`__Host-`, `Secure`,
20
+ * `HttpOnly`, `SameSite=None; Partitioned` in a frame) for what follows; a url-mode app's ticket
21
+ * buys the cookie and redirects to the app's landing path without the ticket (P025). Private
22
+ * content without a valid ticket or cookie gets nothing of the content.
23
+ */
24
+ export { createServer } from "./server.ts";
25
+ export { memoryTicketStore } from "./tickets.ts";
26
+ export { proxyTo, type ProxyOptions } from "./proxy.ts";
27
+ export type {
28
+ Access,
29
+ ArtifactStore,
30
+ ContentDomain,
31
+ Grant,
32
+ PortholeServer,
33
+ ServerOptions,
34
+ TicketRecord,
35
+ TicketStore,
36
+ Upstream,
37
+ } from "./types.ts";