@botiverse/porthole 0.0.0-stage → 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 (150) 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 +68 -3
  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
  150. package/README.md +0 -3
@@ -0,0 +1,422 @@
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
+ ConfirmRequest,
22
+ Refusal,
23
+ } from "../bridge/types.ts";
24
+ import type { CapabilityName } from "../config/capabilities.ts";
25
+ import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
26
+ import { createHost } from "../host/create-host.ts";
27
+ import type { DisplayEvents, DisplayHandle } from "../host/types.ts";
28
+ import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
29
+ import { ConfirmDialog } from "./dialog.tsx";
30
+ import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
31
+
32
+ /**
33
+ * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
34
+ * run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
35
+ * display mounts it when it nears the screen, streams its content, flips its theme, and remembers
36
+ * its height. All the isolation is the core's (`createHost`); this is mounting and lifecycle.
37
+ */
38
+
39
+ /** How far off screen a frame starts (and keeps) running. */
40
+ const NEAR_SCREEN = "800px 0px";
41
+ /** The height a display holds before it has reported its own. */
42
+ export const PLACEHOLDER_HEIGHT_PX = 96;
43
+
44
+ interface PageState {
45
+ readonly live: LiveFrames;
46
+ /** Heights displays reported, by display id: a frame mounting again starts at its last. */
47
+ readonly heights: Map<string, number>;
48
+ readonly confirm?: Confirm;
49
+ readonly hostInfo?: { readonly name: string; readonly version: string };
50
+ readonly locator?: InstanceLocator;
51
+ }
52
+
53
+ /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
54
+ const PAGE: PageState = { live: createLiveFrames(), heights: new Map() };
55
+
56
+ const Page = createContext<PageState>(PAGE);
57
+
58
+ export interface PortholeProviderProps {
59
+ /**
60
+ * Who confirms gated calls for every frame below. Omitted: the default dialog (`ConfirmDialog`).
61
+ * Pass your own function to replace it.
62
+ */
63
+ readonly confirm?: Confirm;
64
+ /** How the host names itself in the handshake. */
65
+ readonly hostInfo?: { readonly name: string; readonly version: string };
66
+ /** Where domain and external frames load from (see `InstanceLocator`). */
67
+ readonly locator?: InstanceLocator;
68
+ /** A class for the default dialog, to restyle it. */
69
+ readonly dialogClassName?: string;
70
+ readonly children?: ReactNode;
71
+ }
72
+
73
+ interface Pending {
74
+ readonly request: ConfirmRequest;
75
+ readonly resolve: (accepted: boolean) => void;
76
+ }
77
+
78
+ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
79
+ const live = useMemo(() => createLiveFrames(), []);
80
+ const heights = useMemo(() => new Map<string, number>(), []);
81
+ const [queue, setQueue] = useState<readonly Pending[]>([]);
82
+ // The host's confirm as of the last render, read when a frame asks (never during render).
83
+ const own = useRef(props.confirm);
84
+ useLayoutEffect(() => {
85
+ own.current = props.confirm;
86
+ });
87
+ const confirm = useCallback<Confirm>(
88
+ (request) =>
89
+ own.current !== undefined
90
+ ? own.current(request)
91
+ : new Promise<boolean>((resolve) =>
92
+ setQueue((waiting) => [...waiting, { request, resolve }]),
93
+ ),
94
+ [],
95
+ );
96
+ const { hostInfo, locator } = props;
97
+ const value = useMemo<PageState>(
98
+ () => ({
99
+ live,
100
+ heights,
101
+ confirm,
102
+ ...(hostInfo === undefined ? {} : { hostInfo }),
103
+ ...(locator === undefined ? {} : { locator }),
104
+ }),
105
+ [live, heights, confirm, hostInfo, locator],
106
+ );
107
+ const current = queue[0] ?? null;
108
+ return (
109
+ <Page.Provider value={value}>
110
+ {props.children}
111
+ {props.confirm === undefined ? (
112
+ <ConfirmDialog
113
+ request={current?.request ?? null}
114
+ {...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName })}
115
+ onAnswer={(accepted) => {
116
+ current?.resolve(accepted);
117
+ setQueue((waiting) => waiting.slice(1));
118
+ }}
119
+ />
120
+ ) : null}
121
+ </Page.Provider>
122
+ );
123
+ }
124
+
125
+ /** What a placeholder shows: a display not yet running, loading, or stopped for others. */
126
+ export interface PlaceholderProps {
127
+ readonly state: "waiting" | "loading" | "stopped";
128
+ readonly title: string;
129
+ /** Runs the display again (a stopped one), stopping another if the page is at its most. */
130
+ readonly run: () => void;
131
+ }
132
+
133
+ export interface PortholeFrameProps {
134
+ /** A preset's config. Keep it stable (module scope or memoized): a new one remounts the frame. */
135
+ readonly config: PortholeConfig;
136
+ /** The host's id for this display: what its height is remembered by, and calls name. */
137
+ readonly displayId: string;
138
+ /** The frame's accessible name, and the title its confirmations show (the host's words). */
139
+ readonly title: string;
140
+ /** The content so far (inline and artifact modes). */
141
+ readonly content?: string;
142
+ /** The content is still being written: drawn inert as it grows, run once when whole. */
143
+ readonly partial?: boolean;
144
+ /** A whole document to start with (an MCP Apps view). */
145
+ readonly document?: string;
146
+ /** Domain and external isolation: the instance to show. */
147
+ readonly instance?: InstanceRef;
148
+ readonly scheme: ColorScheme;
149
+ /** The host's live values for the config's theme variables (from its design tokens). */
150
+ readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
151
+ /** Handlers for the declared capabilities (the latest ones are called). */
152
+ readonly handlers?: CapabilityHandlers;
153
+ /** Who confirms this frame's gated calls, over the provider's. */
154
+ readonly confirm?: Confirm;
155
+ /** Bump to run the content again from the start. */
156
+ readonly generation?: number;
157
+ /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
158
+ readonly eager?: boolean;
159
+ readonly onInitialized?: () => void;
160
+ readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
161
+ readonly onError?: (message: string) => void;
162
+ /** The content navigated its frame away; it was started again (or stopped, after a few). */
163
+ readonly onNavigated?: () => void;
164
+ readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
165
+ /** Too many declines: the display stops asking until it runs again. */
166
+ readonly onMuted?: () => void;
167
+ /** Draws the placeholder instead of the default one. */
168
+ readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
169
+ readonly className?: string;
170
+ readonly style?: CSSProperties;
171
+ }
172
+
173
+ const PLACEHOLDER_STYLE: CSSProperties = {
174
+ position: "absolute",
175
+ inset: 0,
176
+ display: "flex",
177
+ flexDirection: "column",
178
+ alignItems: "center",
179
+ justifyContent: "center",
180
+ gap: 8,
181
+ fontSize: "0.8em",
182
+ opacity: 0.7,
183
+ };
184
+
185
+ function DefaultPlaceholder({ state, title, run }: PlaceholderProps): ReactElement {
186
+ return (
187
+ <div
188
+ data-porthole-placeholder={state}
189
+ style={{ ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }}
190
+ >
191
+ <span>{state === "stopped" ? "Stopped while others run" : title}</span>
192
+ {state === "stopped" ? (
193
+ <button type="button" data-porthole-run="" onClick={run}>
194
+ Run again
195
+ </button>
196
+ ) : null}
197
+ </div>
198
+ );
199
+ }
200
+
201
+ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
202
+ const page = useContext(Page);
203
+ const key = useId();
204
+ const box = useRef<HTMLDivElement>(null);
205
+ const max = props.config.limits.maxLiveFrames;
206
+ const state = useSyncExternalStore(
207
+ (listener) => page.live.subscribe(listener),
208
+ () => page.live.state(key),
209
+ (): FrameState => "waiting",
210
+ );
211
+ const { eager } = props;
212
+
213
+ useEffect(() => {
214
+ const element = box.current;
215
+ if (element === null) {
216
+ return;
217
+ }
218
+ if (eager === true || typeof IntersectionObserver !== "function") {
219
+ page.live.near(key, true);
220
+ page.live.admit(key, max);
221
+ return () => page.live.release(key);
222
+ }
223
+ const observer = new IntersectionObserver(
224
+ (entries) => {
225
+ const entry = entries.at(-1);
226
+ if (entry === undefined) {
227
+ return;
228
+ }
229
+ page.live.near(key, entry.isIntersecting);
230
+ if (entry.isIntersecting) {
231
+ page.live.admit(key, max);
232
+ }
233
+ },
234
+ { rootMargin: NEAR_SCREEN },
235
+ );
236
+ observer.observe(element);
237
+ return () => {
238
+ observer.disconnect();
239
+ page.live.release(key);
240
+ };
241
+ }, [page, key, max, eager]);
242
+
243
+ const height = page.heights.get(props.displayId) ?? 0;
244
+ const run = () => page.live.admit(key, max);
245
+ const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
246
+ return (
247
+ <div
248
+ ref={box}
249
+ className={props.className}
250
+ data-porthole-display={props.displayId}
251
+ data-porthole-state={state}
252
+ style={{
253
+ position: "relative",
254
+ width: "100%",
255
+ ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
256
+ ...props.style,
257
+ }}
258
+ >
259
+ {state === "running" ? (
260
+ <Mount
261
+ key={String(props.generation ?? 0)}
262
+ frame={props}
263
+ page={page}
264
+ initialHeightPx={height}
265
+ placeholder={placeholder}
266
+ run={run}
267
+ />
268
+ ) : (
269
+ placeholder({ state, title: props.title, run })
270
+ )}
271
+ </div>
272
+ );
273
+ }
274
+
275
+ interface MountProps {
276
+ readonly frame: PortholeFrameProps;
277
+ readonly page: PageState;
278
+ readonly initialHeightPx: number;
279
+ readonly placeholder: (props: PlaceholderProps) => ReactNode;
280
+ readonly run: () => void;
281
+ }
282
+
283
+ type AnyHandler = (args: unknown, context: CallContext) => unknown;
284
+
285
+ /** One running display: created when mounted, torn down when unmounted, fed as props change. */
286
+ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps): ReactElement {
287
+ const holder = useRef<HTMLDivElement>(null);
288
+ const handle = useRef<DisplayHandle | null>(null);
289
+ const [ready, setReady] = useState(false);
290
+ // The height this display started at: later reports don't make it start again.
291
+ const [startHeight] = useState(initialHeightPx);
292
+ const { config, document: view, instance, content, partial, scheme } = frame;
293
+
294
+ // The latest props, read when the display calls back (never during render).
295
+ const latest = useEffectEvent(() => frame);
296
+ const notify = useEffectEvent(
297
+ <E extends keyof DisplayEvents>(event: E, detail: DisplayEvents[E]): void => {
298
+ switch (event) {
299
+ case "initialized":
300
+ setReady(true);
301
+ frame.onInitialized?.();
302
+ return;
303
+ case "resize": {
304
+ const size = detail as DisplayEvents["resize"];
305
+ page.heights.set(frame.displayId, size.height);
306
+ frame.onResize?.(size);
307
+ return;
308
+ }
309
+ case "error":
310
+ frame.onError?.((detail as DisplayEvents["error"]).message);
311
+ return;
312
+ case "navigated":
313
+ frame.onNavigated?.();
314
+ return;
315
+ case "refused": {
316
+ const { capability, refusal } = detail as DisplayEvents["refused"];
317
+ frame.onRefused?.(capability, refusal);
318
+ return;
319
+ }
320
+ case "muted":
321
+ frame.onMuted?.();
322
+ return;
323
+ }
324
+ },
325
+ );
326
+
327
+ useEffect(() => {
328
+ const container = holder.current;
329
+ if (container === null) {
330
+ return;
331
+ }
332
+ const first = latest();
333
+ // A display keeps running when the host renames it (a streamed display's id and title can
334
+ // settle after it starts): calls and confirmations name it as the latest props do.
335
+ const named = (context: CallContext): CallContext => ({
336
+ ...context,
337
+ displayId: latest().displayId,
338
+ title: latest().title,
339
+ });
340
+ // The handlers present now (a declared one must be), each calling the latest prop's.
341
+ const handlers: Record<string, AnyHandler> = {};
342
+ for (const name of Object.keys(first.handlers ?? {})) {
343
+ handlers[name] = (args, context) =>
344
+ (latest().handlers as Record<string, AnyHandler | undefined> | undefined)?.[name]?.(
345
+ args,
346
+ named(context),
347
+ );
348
+ }
349
+ const asks = first.confirm !== undefined || page.confirm !== undefined;
350
+ let display: DisplayHandle;
351
+ try {
352
+ display = createHost(config, {
353
+ handlers,
354
+ ...(asks
355
+ ? {
356
+ confirm: (request: ConfirmRequest) =>
357
+ (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
358
+ ...request,
359
+ display: { id: latest().displayId, title: latest().title },
360
+ }),
361
+ }
362
+ : {}),
363
+ theme: (each) => latest().theme?.(each) ?? {},
364
+ ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
365
+ ...(page.locator === undefined ? {} : { locator: page.locator }),
366
+ }).mount(container, {
367
+ displayId: first.displayId,
368
+ title: first.title,
369
+ scheme: first.scheme,
370
+ initialHeightPx: startHeight,
371
+ ...(view === undefined ? {} : { document: view }),
372
+ ...(instance === undefined ? {} : { instance }),
373
+ });
374
+ } catch (error) {
375
+ first.onError?.(error instanceof Error ? error.message : String(error));
376
+ return;
377
+ }
378
+ handle.current = display;
379
+ const events: (keyof DisplayEvents)[] = [
380
+ "initialized",
381
+ "resize",
382
+ "error",
383
+ "navigated",
384
+ "refused",
385
+ "muted",
386
+ ];
387
+ for (const event of events) {
388
+ display.on(event, (detail) => notify(event, detail));
389
+ }
390
+ if (first.content !== undefined) {
391
+ display.setContent(first.content, { partial: first.partial === true });
392
+ }
393
+ return () => {
394
+ display.destroy();
395
+ handle.current = null;
396
+ };
397
+ // A new display only for a new config, document, instance or page (or generation, by key);
398
+ // content, theme, handlers, id and title are fed to the running one.
399
+ }, [config, view, instance, page, startHeight]);
400
+
401
+ useEffect(() => {
402
+ if (content !== undefined) {
403
+ handle.current?.setContent(content, { partial: partial === true });
404
+ }
405
+ }, [content, partial]);
406
+ useEffect(() => {
407
+ handle.current?.setTheme(scheme);
408
+ }, [scheme]);
409
+
410
+ return (
411
+ <div
412
+ data-porthole-ready={String(ready)}
413
+ style={{
414
+ position: "relative",
415
+ ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
416
+ }}
417
+ >
418
+ <div ref={holder} data-porthole-mount="" style={{ opacity: ready ? 1 : 0 }} />
419
+ {ready ? null : placeholder({ state: "loading", title: frame.title, run })}
420
+ </div>
421
+ );
422
+ }
@@ -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";
@@ -0,0 +1,100 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * The server's secrets, derived from the one it is given: one key per use, so a value made for
5
+ * one (a label) can never stand in for another (a cookie's signature).
6
+ */
7
+
8
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
9
+
10
+ /** Lowercase base32 (RFC 4648 alphabet, no padding): DNS-safe, case-insensitive. */
11
+ export function base32(bytes: Uint8Array): string {
12
+ let out = "";
13
+ let buffer = 0;
14
+ let bits = 0;
15
+ for (const byte of bytes) {
16
+ buffer = (buffer << 8) | byte;
17
+ bits += 8;
18
+ while (bits >= 5) {
19
+ out += ALPHABET[(buffer >>> (bits - 5)) & 31];
20
+ bits -= 5;
21
+ }
22
+ buffer &= (1 << bits) - 1;
23
+ }
24
+ if (bits > 0) {
25
+ out += ALPHABET[(buffer << (5 - bits)) & 31];
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const base64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
31
+
32
+ /** JSON with object keys in a fixed order, so equal values always serialize alike. */
33
+ export function canonicalJson(value: unknown): string {
34
+ if (Array.isArray(value)) {
35
+ return `[${value.map(canonicalJson).join(",")}]`;
36
+ }
37
+ if (value !== null && typeof value === "object") {
38
+ const entries = Object.entries(value as Record<string, unknown>)
39
+ .filter(([, each]) => each !== undefined)
40
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
41
+ return `{${entries.map(([key, each]) => `${JSON.stringify(key)}:${canonicalJson(each)}`).join(",")}}`;
42
+ }
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ export interface Keys {
47
+ /** The DNS label for a storage scope: 24 base32 characters (120 bits), stable, not guessable. */
48
+ label(scope: string): string;
49
+ /** A short name for an instance (16 base32 characters): its document's path and cookie name. */
50
+ instanceKey(instance: unknown): string;
51
+ /** `value` signed: `<payload>.<signature>`, both base64url. */
52
+ sign(value: unknown): string;
53
+ /** The signed value, or null when the signature doesn't hold. */
54
+ verify(token: string): unknown;
55
+ }
56
+
57
+ export function createKeys(secret: Uint8Array): Keys {
58
+ if (secret.byteLength < 32) {
59
+ throw new TypeError("The server's secret must be at least 32 random bytes");
60
+ }
61
+ const derive = (use: string): Buffer =>
62
+ createHmac("sha256", secret).update(`porthole/${use}`).digest();
63
+ const labelKey = derive("label");
64
+ const instanceKey = derive("instance");
65
+ const signKey = derive("session");
66
+ const mac = (key: Buffer, text: string): Buffer =>
67
+ createHmac("sha256", key).update(text).digest();
68
+
69
+ return {
70
+ label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
71
+ instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
72
+ sign: (value) => {
73
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
74
+ return `${payload}.${base64url(mac(signKey, payload))}`;
75
+ },
76
+ verify: (token) => {
77
+ const [payload, signature, ...rest] = token.split(".");
78
+ if (payload === undefined || signature === undefined || rest.length > 0) {
79
+ return null;
80
+ }
81
+ const expected = mac(signKey, payload);
82
+ const given = Buffer.from(signature, "base64url");
83
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
84
+ return null;
85
+ }
86
+ try {
87
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as unknown;
88
+ } catch {
89
+ return null;
90
+ }
91
+ },
92
+ };
93
+ }
94
+
95
+ /** A fresh one-time ticket: 160 random bits. */
96
+ export const newTicket = (): string => `pt_${base32(randomBytes(20))}`;
97
+
98
+ /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
99
+ export const ticketId = (ticket: string): string =>
100
+ createHash("sha256").update(ticket).digest("base64url");
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The server's own pages on a content origin: why it shows nothing of the content. Plain, no
3
+ * script, framable only by the host origins, never cached. Their words are the host-agnostic
4
+ * mechanism's; a host that wants its own wording serves its own page in front.
5
+ */
6
+
7
+ const escape = (text: string): string =>
8
+ text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
9
+
10
+ const STYLE =
11
+ ":root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;min-height:100vh;display:grid;place-items:center;background:Canvas;color:CanvasText}main{max-width:420px;padding:24px}h1{font-size:17px;margin:0 0 8px}p{margin:0;opacity:.75;line-height:1.5}";
12
+
13
+ export function statusPage(
14
+ status: number,
15
+ title: string,
16
+ detail: string,
17
+ frameAncestors: string,
18
+ method = "GET",
19
+ ): Response {
20
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>${STYLE}</style></head><body><main data-porthole-status="${status}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main></body></html>`;
21
+ return new Response(method === "HEAD" ? null : body, {
22
+ status,
23
+ headers: {
24
+ "content-type": "text/html; charset=utf-8",
25
+ "cache-control": "no-store",
26
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
27
+ "referrer-policy": "no-referrer",
28
+ "x-content-type-options": "nosniff",
29
+ },
30
+ });
31
+ }