@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,75 @@
1
+ import { type CSSProperties, type ReactElement, type ReactNode } from "react";
2
+ import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
3
+ import type { CapabilityName } from "../config/capabilities.ts";
4
+ import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
5
+ import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
6
+ /** The height a display holds before it has reported its own. */
7
+ export declare const PLACEHOLDER_HEIGHT_PX = 96;
8
+ export interface PortholeProviderProps {
9
+ /**
10
+ * Who confirms gated calls for every frame below. Omitted: the default dialog (`ConfirmDialog`).
11
+ * Pass your own function to replace it.
12
+ */
13
+ readonly confirm?: Confirm;
14
+ /** How the host names itself in the handshake. */
15
+ readonly hostInfo?: {
16
+ readonly name: string;
17
+ readonly version: string;
18
+ };
19
+ /** Where domain and external frames load from (see `InstanceLocator`). */
20
+ readonly locator?: InstanceLocator;
21
+ /** A class for the default dialog, to restyle it. */
22
+ readonly dialogClassName?: string;
23
+ readonly children?: ReactNode;
24
+ }
25
+ export declare function PortholeProvider(props: PortholeProviderProps): ReactElement;
26
+ /** What a placeholder shows: a display not yet running, loading, or stopped for others. */
27
+ export interface PlaceholderProps {
28
+ readonly state: "waiting" | "loading" | "stopped";
29
+ readonly title: string;
30
+ /** Runs the display again (a stopped one), stopping another if the page is at its most. */
31
+ readonly run: () => void;
32
+ }
33
+ export interface PortholeFrameProps {
34
+ /** A preset's config. Keep it stable (module scope or memoized): a new one remounts the frame. */
35
+ readonly config: PortholeConfig;
36
+ /** The host's id for this display: what its height is remembered by, and calls name. */
37
+ readonly displayId: string;
38
+ /** The frame's accessible name, and the title its confirmations show (the host's words). */
39
+ readonly title: string;
40
+ /** The content so far (inline and artifact modes). */
41
+ readonly content?: string;
42
+ /** The content is still being written: drawn inert as it grows, run once when whole. */
43
+ readonly partial?: boolean;
44
+ /** A whole document to start with (an MCP Apps view). */
45
+ readonly document?: string;
46
+ /** Domain and external isolation: the instance to show. */
47
+ readonly instance?: InstanceRef;
48
+ readonly scheme: ColorScheme;
49
+ /** The host's live values for the config's theme variables (from its design tokens). */
50
+ readonly theme?: (scheme: ColorScheme) => Readonly<Record<string, string | null | undefined>>;
51
+ /** Handlers for the declared capabilities (the latest ones are called). */
52
+ readonly handlers?: CapabilityHandlers;
53
+ /** Who confirms this frame's gated calls, over the provider's. */
54
+ readonly confirm?: Confirm;
55
+ /** Bump to run the content again from the start. */
56
+ readonly generation?: number;
57
+ /** Run at once, without waiting to near the screen (it still counts toward the page's most). */
58
+ readonly eager?: boolean;
59
+ readonly onInitialized?: () => void;
60
+ readonly onResize?: (size: {
61
+ readonly height: number;
62
+ readonly width?: number;
63
+ }) => void;
64
+ readonly onError?: (message: string) => void;
65
+ /** The content navigated its frame away; it was started again (or stopped, after a few). */
66
+ readonly onNavigated?: () => void;
67
+ readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
68
+ /** Too many declines: the display stops asking until it runs again. */
69
+ readonly onMuted?: () => void;
70
+ /** Draws the placeholder instead of the default one. */
71
+ readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
72
+ readonly className?: string;
73
+ readonly style?: CSSProperties;
74
+ }
75
+ export declare function PortholeFrame(props: PortholeFrameProps): ReactElement;
@@ -0,0 +1,221 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useCallback, useContext, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
3
+ import { createHost } from "../host/create-host.js";
4
+ import { ConfirmDialog } from "./dialog.js";
5
+ import { createLiveFrames } from "./live.js";
6
+ /**
7
+ * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
8
+ * run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
9
+ * display mounts it when it nears the screen, streams its content, flips its theme, and remembers
10
+ * its height. All the isolation is the core's (`createHost`); this is mounting and lifecycle.
11
+ */
12
+ /** How far off screen a frame starts (and keeps) running. */
13
+ const NEAR_SCREEN = "800px 0px";
14
+ /** The height a display holds before it has reported its own. */
15
+ export const PLACEHOLDER_HEIGHT_PX = 96;
16
+ /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
17
+ const PAGE = { live: createLiveFrames(), heights: new Map() };
18
+ const Page = createContext(PAGE);
19
+ export function PortholeProvider(props) {
20
+ const live = useMemo(() => createLiveFrames(), []);
21
+ const heights = useMemo(() => new Map(), []);
22
+ const [queue, setQueue] = useState([]);
23
+ // The host's confirm as of the last render, read when a frame asks (never during render).
24
+ const own = useRef(props.confirm);
25
+ useLayoutEffect(() => {
26
+ own.current = props.confirm;
27
+ });
28
+ const confirm = useCallback((request) => own.current !== undefined
29
+ ? own.current(request)
30
+ : new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve }])), []);
31
+ const { hostInfo, locator } = props;
32
+ const value = useMemo(() => ({
33
+ live,
34
+ heights,
35
+ confirm,
36
+ ...(hostInfo === undefined ? {} : { hostInfo }),
37
+ ...(locator === undefined ? {} : { locator }),
38
+ }), [live, heights, confirm, hostInfo, locator]);
39
+ const current = queue[0] ?? null;
40
+ return (_jsxs(Page.Provider, { value: value, children: [props.children, props.confirm === undefined ? (_jsx(ConfirmDialog, { request: current?.request ?? null, ...(props.dialogClassName === undefined ? {} : { className: props.dialogClassName }), onAnswer: (accepted) => {
41
+ current?.resolve(accepted);
42
+ setQueue((waiting) => waiting.slice(1));
43
+ } })) : null] }));
44
+ }
45
+ const PLACEHOLDER_STYLE = {
46
+ position: "absolute",
47
+ inset: 0,
48
+ display: "flex",
49
+ flexDirection: "column",
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ gap: 8,
53
+ fontSize: "0.8em",
54
+ opacity: 0.7,
55
+ };
56
+ function DefaultPlaceholder({ state, title, run }) {
57
+ return (_jsxs("div", { "data-porthole-placeholder": state, style: { ...PLACEHOLDER_STYLE, pointerEvents: state === "stopped" ? "auto" : "none" }, children: [_jsx("span", { children: state === "stopped" ? "Stopped while others run" : title }), state === "stopped" ? (_jsx("button", { type: "button", "data-porthole-run": "", onClick: run, children: "Run again" })) : null] }));
58
+ }
59
+ export function PortholeFrame(props) {
60
+ const page = useContext(Page);
61
+ const key = useId();
62
+ const box = useRef(null);
63
+ const max = props.config.limits.maxLiveFrames;
64
+ const state = useSyncExternalStore((listener) => page.live.subscribe(listener), () => page.live.state(key), () => "waiting");
65
+ const { eager } = props;
66
+ useEffect(() => {
67
+ const element = box.current;
68
+ if (element === null) {
69
+ return;
70
+ }
71
+ if (eager === true || typeof IntersectionObserver !== "function") {
72
+ page.live.near(key, true);
73
+ page.live.admit(key, max);
74
+ return () => page.live.release(key);
75
+ }
76
+ const observer = new IntersectionObserver((entries) => {
77
+ const entry = entries.at(-1);
78
+ if (entry === undefined) {
79
+ return;
80
+ }
81
+ page.live.near(key, entry.isIntersecting);
82
+ if (entry.isIntersecting) {
83
+ page.live.admit(key, max);
84
+ }
85
+ }, { rootMargin: NEAR_SCREEN });
86
+ observer.observe(element);
87
+ return () => {
88
+ observer.disconnect();
89
+ page.live.release(key);
90
+ };
91
+ }, [page, key, max, eager]);
92
+ const height = page.heights.get(props.displayId) ?? 0;
93
+ const run = () => page.live.admit(key, max);
94
+ const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
95
+ return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
96
+ position: "relative",
97
+ width: "100%",
98
+ ...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
99
+ ...props.style,
100
+ }, children: state === "running" ? (_jsx(Mount, { frame: props, page: page, initialHeightPx: height, placeholder: placeholder, run: run }, String(props.generation ?? 0))) : (placeholder({ state, title: props.title, run })) }));
101
+ }
102
+ /** One running display: created when mounted, torn down when unmounted, fed as props change. */
103
+ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
104
+ const holder = useRef(null);
105
+ const handle = useRef(null);
106
+ const [ready, setReady] = useState(false);
107
+ // The height this display started at: later reports don't make it start again.
108
+ const [startHeight] = useState(initialHeightPx);
109
+ const { config, document: view, instance, content, partial, scheme } = frame;
110
+ // The latest props, read when the display calls back (never during render).
111
+ const latest = useEffectEvent(() => frame);
112
+ const notify = useEffectEvent((event, detail) => {
113
+ switch (event) {
114
+ case "initialized":
115
+ setReady(true);
116
+ frame.onInitialized?.();
117
+ return;
118
+ case "resize": {
119
+ const size = detail;
120
+ page.heights.set(frame.displayId, size.height);
121
+ frame.onResize?.(size);
122
+ return;
123
+ }
124
+ case "error":
125
+ frame.onError?.(detail.message);
126
+ return;
127
+ case "navigated":
128
+ frame.onNavigated?.();
129
+ return;
130
+ case "refused": {
131
+ const { capability, refusal } = detail;
132
+ frame.onRefused?.(capability, refusal);
133
+ return;
134
+ }
135
+ case "muted":
136
+ frame.onMuted?.();
137
+ return;
138
+ }
139
+ });
140
+ useEffect(() => {
141
+ const container = holder.current;
142
+ if (container === null) {
143
+ return;
144
+ }
145
+ const first = latest();
146
+ // A display keeps running when the host renames it (a streamed display's id and title can
147
+ // settle after it starts): calls and confirmations name it as the latest props do.
148
+ const named = (context) => ({
149
+ ...context,
150
+ displayId: latest().displayId,
151
+ title: latest().title,
152
+ });
153
+ // The handlers present now (a declared one must be), each calling the latest prop's.
154
+ const handlers = {};
155
+ for (const name of Object.keys(first.handlers ?? {})) {
156
+ handlers[name] = (args, context) => latest().handlers?.[name]?.(args, named(context));
157
+ }
158
+ const asks = first.confirm !== undefined || page.confirm !== undefined;
159
+ let display;
160
+ try {
161
+ display = createHost(config, {
162
+ handlers,
163
+ ...(asks
164
+ ? {
165
+ confirm: (request) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({
166
+ ...request,
167
+ display: { id: latest().displayId, title: latest().title },
168
+ }),
169
+ }
170
+ : {}),
171
+ theme: (each) => latest().theme?.(each) ?? {},
172
+ ...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
173
+ ...(page.locator === undefined ? {} : { locator: page.locator }),
174
+ }).mount(container, {
175
+ displayId: first.displayId,
176
+ title: first.title,
177
+ scheme: first.scheme,
178
+ initialHeightPx: startHeight,
179
+ ...(view === undefined ? {} : { document: view }),
180
+ ...(instance === undefined ? {} : { instance }),
181
+ });
182
+ }
183
+ catch (error) {
184
+ first.onError?.(error instanceof Error ? error.message : String(error));
185
+ return;
186
+ }
187
+ handle.current = display;
188
+ const events = [
189
+ "initialized",
190
+ "resize",
191
+ "error",
192
+ "navigated",
193
+ "refused",
194
+ "muted",
195
+ ];
196
+ for (const event of events) {
197
+ display.on(event, (detail) => notify(event, detail));
198
+ }
199
+ if (first.content !== undefined) {
200
+ display.setContent(first.content, { partial: first.partial === true });
201
+ }
202
+ return () => {
203
+ display.destroy();
204
+ handle.current = null;
205
+ };
206
+ // A new display only for a new config, document, instance or page (or generation, by key);
207
+ // content, theme, handlers, id and title are fed to the running one.
208
+ }, [config, view, instance, page, startHeight]);
209
+ useEffect(() => {
210
+ if (content !== undefined) {
211
+ handle.current?.setContent(content, { partial: partial === true });
212
+ }
213
+ }, [content, partial]);
214
+ useEffect(() => {
215
+ handle.current?.setTheme(scheme);
216
+ }, [scheme]);
217
+ return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
218
+ position: "relative",
219
+ ...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
220
+ }, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0 } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
221
+ }
@@ -0,0 +1,13 @@
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 { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, type PlaceholderProps, type PortholeFrameProps, type PortholeProviderProps, } from "./frame.tsx";
12
+ export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
13
+ export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -0,0 +1,13 @@
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 { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, } from "./frame.js";
12
+ export { ARM_DELAY_MS, ConfirmDialog } from "./dialog.js";
13
+ export { admitFrame, createLiveFrames } from "./live.js";
@@ -0,0 +1,24 @@
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
+ export type FrameState = "waiting" | "running" | "stopped";
8
+ /**
9
+ * Which frames run once `key` comes near the screen or is asked to run. `live` is the frames
10
+ * running, the least recently admitted first. `key` runs; past `max`, the frames that are not near
11
+ * the screen stop first, oldest first (one near the screen stops only when all are near).
12
+ */
13
+ export declare function admitFrame(live: readonly string[], near: ReadonlySet<string>, key: string, max: number): string[];
14
+ export interface LiveFrames {
15
+ subscribe(listener: () => void): () => void;
16
+ state(key: string): FrameState;
17
+ /** The frame came near the screen, or left it. */
18
+ near(key: string, isNear: boolean): void;
19
+ /** `key` runs (near the screen, or asked to run again); others past `max` stop. */
20
+ admit(key: string, max: number): void;
21
+ /** The frame is gone. */
22
+ release(key: string): void;
23
+ }
24
+ export declare function createLiveFrames(): LiveFrames;
@@ -0,0 +1,57 @@
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
+ * Which frames run once `key` comes near the screen or is asked to run. `live` is the frames
9
+ * running, the least recently admitted first. `key` runs; past `max`, the frames that are not near
10
+ * the screen stop first, oldest first (one near the screen stops only when all are near).
11
+ */
12
+ export function admitFrame(live, near, key, max) {
13
+ const next = [...live.filter((other) => other !== key), key];
14
+ while (next.length > Math.max(1, max)) {
15
+ const far = next.findIndex((other) => other !== key && !near.has(other));
16
+ next.splice(far === -1 ? 0 : far, 1);
17
+ }
18
+ return next;
19
+ }
20
+ export function createLiveFrames() {
21
+ let live = [];
22
+ const nearScreen = new Set();
23
+ const ran = new Set();
24
+ const listeners = new Set();
25
+ const changed = (next) => {
26
+ live = next;
27
+ for (const listener of listeners) {
28
+ listener();
29
+ }
30
+ };
31
+ return {
32
+ subscribe(listener) {
33
+ listeners.add(listener);
34
+ return () => listeners.delete(listener);
35
+ },
36
+ state: (key) => (live.includes(key) ? "running" : ran.has(key) ? "stopped" : "waiting"),
37
+ near(key, isNear) {
38
+ if (isNear) {
39
+ nearScreen.add(key);
40
+ }
41
+ else {
42
+ nearScreen.delete(key);
43
+ }
44
+ },
45
+ admit(key, max) {
46
+ ran.add(key);
47
+ changed(admitFrame(live, nearScreen, key, max));
48
+ },
49
+ release(key) {
50
+ nearScreen.delete(key);
51
+ ran.delete(key);
52
+ if (live.includes(key)) {
53
+ changed(live.filter((other) => other !== key));
54
+ }
55
+ },
56
+ };
57
+ }
@@ -0,0 +1,27 @@
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 { Access, ArtifactStore, ContentDomain, Grant, PortholeServer, ServerOptions, TicketRecord, TicketStore, Upstream, } from "./types.ts";
@@ -0,0 +1,26 @@
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.js";
25
+ export { memoryTicketStore } from "./tickets.js";
26
+ export { proxyTo } from "./proxy.js";
@@ -0,0 +1,19 @@
1
+ /** Lowercase base32 (RFC 4648 alphabet, no padding): DNS-safe, case-insensitive. */
2
+ export declare function base32(bytes: Uint8Array): string;
3
+ /** JSON with object keys in a fixed order, so equal values always serialize alike. */
4
+ export declare function canonicalJson(value: unknown): string;
5
+ export interface Keys {
6
+ /** The DNS label for a storage scope: 24 base32 characters (120 bits), stable, not guessable. */
7
+ label(scope: string): string;
8
+ /** A short name for an instance (16 base32 characters): its document's path and cookie name. */
9
+ instanceKey(instance: unknown): string;
10
+ /** `value` signed: `<payload>.<signature>`, both base64url. */
11
+ sign(value: unknown): string;
12
+ /** The signed value, or null when the signature doesn't hold. */
13
+ verify(token: string): unknown;
14
+ }
15
+ export declare function createKeys(secret: Uint8Array): Keys;
16
+ /** A fresh one-time ticket: 160 random bits. */
17
+ export declare const newTicket: () => string;
18
+ /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
19
+ export declare const ticketId: (ticket: string) => string;
@@ -0,0 +1,78 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ /**
3
+ * The server's secrets, derived from the one it is given: one key per use, so a value made for
4
+ * one (a label) can never stand in for another (a cookie's signature).
5
+ */
6
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
7
+ /** Lowercase base32 (RFC 4648 alphabet, no padding): DNS-safe, case-insensitive. */
8
+ export function base32(bytes) {
9
+ let out = "";
10
+ let buffer = 0;
11
+ let bits = 0;
12
+ for (const byte of bytes) {
13
+ buffer = (buffer << 8) | byte;
14
+ bits += 8;
15
+ while (bits >= 5) {
16
+ out += ALPHABET[(buffer >>> (bits - 5)) & 31];
17
+ bits -= 5;
18
+ }
19
+ buffer &= (1 << bits) - 1;
20
+ }
21
+ if (bits > 0) {
22
+ out += ALPHABET[(buffer << (5 - bits)) & 31];
23
+ }
24
+ return out;
25
+ }
26
+ const base64url = (bytes) => Buffer.from(bytes).toString("base64url");
27
+ /** JSON with object keys in a fixed order, so equal values always serialize alike. */
28
+ export function canonicalJson(value) {
29
+ if (Array.isArray(value)) {
30
+ return `[${value.map(canonicalJson).join(",")}]`;
31
+ }
32
+ if (value !== null && typeof value === "object") {
33
+ const entries = Object.entries(value)
34
+ .filter(([, each]) => each !== undefined)
35
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
36
+ return `{${entries.map(([key, each]) => `${JSON.stringify(key)}:${canonicalJson(each)}`).join(",")}}`;
37
+ }
38
+ return JSON.stringify(value);
39
+ }
40
+ export function createKeys(secret) {
41
+ if (secret.byteLength < 32) {
42
+ throw new TypeError("The server's secret must be at least 32 random bytes");
43
+ }
44
+ const derive = (use) => createHmac("sha256", secret).update(`porthole/${use}`).digest();
45
+ const labelKey = derive("label");
46
+ const instanceKey = derive("instance");
47
+ const signKey = derive("session");
48
+ const mac = (key, text) => createHmac("sha256", key).update(text).digest();
49
+ return {
50
+ label: (scope) => base32(mac(labelKey, scope).subarray(0, 15)),
51
+ instanceKey: (instance) => base32(mac(instanceKey, canonicalJson(instance)).subarray(0, 10)),
52
+ sign: (value) => {
53
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
54
+ return `${payload}.${base64url(mac(signKey, payload))}`;
55
+ },
56
+ verify: (token) => {
57
+ const [payload, signature, ...rest] = token.split(".");
58
+ if (payload === undefined || signature === undefined || rest.length > 0) {
59
+ return null;
60
+ }
61
+ const expected = mac(signKey, payload);
62
+ const given = Buffer.from(signature, "base64url");
63
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
64
+ return null;
65
+ }
66
+ try {
67
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ },
73
+ };
74
+ }
75
+ /** A fresh one-time ticket: 160 random bits. */
76
+ export const newTicket = () => `pt_${base32(randomBytes(20))}`;
77
+ /** What a ticket is stored under: its hash, so a leaked store holds no usable ticket. */
78
+ export const ticketId = (ticket) => createHash("sha256").update(ticket).digest("base64url");
@@ -0,0 +1,6 @@
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
+ export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string): Response;
@@ -0,0 +1,20 @@
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
+ const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
7
+ const STYLE = ":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}";
8
+ export function statusPage(status, title, detail, frameAncestors, method = "GET") {
9
+ 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>`;
10
+ return new Response(method === "HEAD" ? null : body, {
11
+ status,
12
+ headers: {
13
+ "content-type": "text/html; charset=utf-8",
14
+ "cache-control": "no-store",
15
+ "content-security-policy": `default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`,
16
+ "referrer-policy": "no-referrer",
17
+ "x-content-type-options": "nosniff",
18
+ },
19
+ });
20
+ }
@@ -0,0 +1,21 @@
1
+ import type { Access, Upstream } from "./types.ts";
2
+ /**
3
+ * A url-mode upstream that relays each request to the running app by HTTP: the app believes it is
4
+ * on its own origin (Host, Origin and Referer name it; dev servers check Host), learns nothing of
5
+ * the proxy (no forwarding headers), and its answer comes back on the content origin (its
6
+ * redirects to itself, and its cookies, stay there). WebSockets (a dev server's hot reload) don't
7
+ * go through a fetch-style handler: the host relays those on its own upgrade path, after
8
+ * `PortholeServer.access` lets the request in.
9
+ */
10
+ export interface ProxyOptions {
11
+ /** Where the app runs. Default: the external instance's own `url` (its origin; paths map 1:1). */
12
+ readonly target?: (access: Access) => string;
13
+ /** The fetch to relay with. Default: the global one. */
14
+ readonly fetch?: typeof fetch;
15
+ }
16
+ /** The browser's request headers as the app at `target` gets them. */
17
+ export declare function upstreamRequestHeaders(headers: Headers, origin: string, target: string): Headers;
18
+ /** The app's answer headers as the browser on `origin` gets them. */
19
+ export declare function upstreamResponseHeaders(headers: Headers, origin: string, target: string): Headers;
20
+ /** An `Upstream` that relays to the app by HTTP. */
21
+ export declare function proxyTo(options?: ProxyOptions): Upstream;