@botiverse/porthole 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +198 -0
- package/dist/bridge/types.d.ts +83 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +103 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +152 -0
- package/dist/config/schema.js +11 -0
- package/dist/config/validate.d.ts +22 -0
- package/dist/config/validate.js +261 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +426 -0
- package/dist/conformance/index.d.ts +101 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +33 -0
- package/dist/conformance/probes.js +876 -0
- package/dist/conformance/served.d.ts +75 -0
- package/dist/conformance/served.js +335 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +265 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +292 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +16 -0
- package/dist/host/outcome.d.ts +24 -0
- package/dist/host/outcome.js +134 -0
- package/dist/host/types.d.ts +127 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +21 -0
- package/dist/isolation/served.d.ts +35 -0
- package/dist/isolation/served.js +117 -0
- package/dist/isolation/types.d.ts +96 -0
- package/dist/isolation/types.js +2 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +51 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +85 -0
- package/dist/presets/presets.d.ts +71 -0
- package/dist/presets/presets.js +140 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +91 -0
- package/dist/react/frame.js +255 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +11 -0
- package/dist/server/pages.js +86 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +542 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +29 -0
- package/dist/shell/document.js +85 -0
- package/dist/shell/plan.d.ts +37 -0
- package/dist/shell/plan.js +35 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +848 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +63 -1
- package/src/bridge/bridge.ts +278 -0
- package/src/bridge/types.ts +95 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +130 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +168 -0
- package/src/config/validate.ts +318 -0
- package/src/conformance/behavior.ts +485 -0
- package/src/conformance/index.ts +116 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1129 -0
- package/src/conformance/served.ts +501 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +430 -0
- package/src/host/create-host.ts +335 -0
- package/src/host/locate.ts +25 -0
- package/src/host/outcome.ts +159 -0
- package/src/host/types.ts +126 -0
- package/src/index.ts +128 -0
- package/src/isolation/served.ts +151 -0
- package/src/isolation/types.ts +91 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +55 -0
- package/src/platform/web.ts +91 -0
- package/src/presets/presets.ts +217 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +486 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +117 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +657 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +121 -0
- package/src/shell/plan.ts +69 -0
- package/src/shell/runtime.ts +848 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,255 @@
|
|
|
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, proceed) => own.current !== undefined
|
|
29
|
+
? own.current(request, proceed)
|
|
30
|
+
: new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
|
|
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
|
+
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
42
|
+
if (accepted) {
|
|
43
|
+
current?.proceed();
|
|
44
|
+
}
|
|
45
|
+
current?.resolve(accepted ? "accepted" : "declined");
|
|
46
|
+
setQueue((waiting) => waiting.slice(1));
|
|
47
|
+
} })) : null] }));
|
|
48
|
+
}
|
|
49
|
+
const PLACEHOLDER_STYLE = {
|
|
50
|
+
position: "absolute",
|
|
51
|
+
inset: 0,
|
|
52
|
+
display: "flex",
|
|
53
|
+
flexDirection: "column",
|
|
54
|
+
alignItems: "center",
|
|
55
|
+
justifyContent: "center",
|
|
56
|
+
gap: 8,
|
|
57
|
+
fontSize: "0.8em",
|
|
58
|
+
opacity: 0.7,
|
|
59
|
+
};
|
|
60
|
+
function DefaultPlaceholder({ state, title, run }) {
|
|
61
|
+
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] }));
|
|
62
|
+
}
|
|
63
|
+
/** A display's remembered height: under its id, else under the first of its other ids that has one. */
|
|
64
|
+
function rememberedHeight(heights, displayId, aliases) {
|
|
65
|
+
for (const id of [displayId, ...(aliases ?? [])]) {
|
|
66
|
+
const height = heights.get(id);
|
|
67
|
+
if (height !== undefined) {
|
|
68
|
+
return height;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
export function PortholeFrame(props) {
|
|
74
|
+
const page = useContext(Page);
|
|
75
|
+
const key = useId();
|
|
76
|
+
const box = useRef(null);
|
|
77
|
+
const max = props.config.limits.maxLiveFrames;
|
|
78
|
+
const state = useSyncExternalStore((listener) => page.live.subscribe(listener), () => page.live.state(key), () => "waiting");
|
|
79
|
+
const { eager } = props;
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const element = box.current;
|
|
82
|
+
if (element === null) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (eager === true || typeof IntersectionObserver !== "function") {
|
|
86
|
+
page.live.near(key, true);
|
|
87
|
+
page.live.admit(key, max);
|
|
88
|
+
return () => page.live.release(key);
|
|
89
|
+
}
|
|
90
|
+
const observer = new IntersectionObserver((entries) => {
|
|
91
|
+
const entry = entries.at(-1);
|
|
92
|
+
if (entry === undefined) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
page.live.near(key, entry.isIntersecting);
|
|
96
|
+
if (entry.isIntersecting) {
|
|
97
|
+
page.live.admit(key, max);
|
|
98
|
+
}
|
|
99
|
+
}, { rootMargin: NEAR_SCREEN });
|
|
100
|
+
observer.observe(element);
|
|
101
|
+
return () => {
|
|
102
|
+
observer.disconnect();
|
|
103
|
+
page.live.release(key);
|
|
104
|
+
};
|
|
105
|
+
}, [page, key, max, eager]);
|
|
106
|
+
const height = rememberedHeight(page.heights, props.displayId, props.aliases);
|
|
107
|
+
// A display renamed while it runs (its id settled) keeps its height under its new id.
|
|
108
|
+
const previousId = useRef(props.displayId);
|
|
109
|
+
useLayoutEffect(() => {
|
|
110
|
+
const before = previousId.current;
|
|
111
|
+
previousId.current = props.displayId;
|
|
112
|
+
const kept = page.heights.get(before);
|
|
113
|
+
if (before !== props.displayId && kept !== undefined && !page.heights.has(props.displayId)) {
|
|
114
|
+
page.heights.set(props.displayId, kept);
|
|
115
|
+
}
|
|
116
|
+
}, [page, props.displayId]);
|
|
117
|
+
const run = () => page.live.admit(key, max);
|
|
118
|
+
const placeholder = props.renderPlaceholder ?? DefaultPlaceholder;
|
|
119
|
+
return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
|
|
120
|
+
position: "relative",
|
|
121
|
+
width: "100%",
|
|
122
|
+
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
123
|
+
...props.style,
|
|
124
|
+
}, 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 })) }));
|
|
125
|
+
}
|
|
126
|
+
/** One running display: created when mounted, torn down when unmounted, fed as props change. */
|
|
127
|
+
function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
128
|
+
const holder = useRef(null);
|
|
129
|
+
const handle = useRef(null);
|
|
130
|
+
const [ready, setReady] = useState(false);
|
|
131
|
+
// The height this display started at: later reports don't make it start again.
|
|
132
|
+
const [startHeight] = useState(initialHeightPx);
|
|
133
|
+
const { config, document: view, instance, content, partial, scheme } = frame;
|
|
134
|
+
const fills = config.mode === "url";
|
|
135
|
+
// The latest props, read when the display calls back (never during render).
|
|
136
|
+
const latest = useEffectEvent(() => frame);
|
|
137
|
+
const notify = useEffectEvent((event, detail) => {
|
|
138
|
+
switch (event) {
|
|
139
|
+
case "initialized":
|
|
140
|
+
setReady(true);
|
|
141
|
+
frame.onInitialized?.();
|
|
142
|
+
return;
|
|
143
|
+
case "resize": {
|
|
144
|
+
const size = detail;
|
|
145
|
+
page.heights.set(frame.displayId, size.height);
|
|
146
|
+
frame.onResize?.(size);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
case "error":
|
|
150
|
+
frame.onError?.(detail.message);
|
|
151
|
+
return;
|
|
152
|
+
case "navigated":
|
|
153
|
+
frame.onNavigated?.();
|
|
154
|
+
return;
|
|
155
|
+
case "refused": {
|
|
156
|
+
const { capability, refusal } = detail;
|
|
157
|
+
frame.onRefused?.(capability, refusal);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "muted":
|
|
161
|
+
frame.onMuted?.();
|
|
162
|
+
return;
|
|
163
|
+
case "outcome":
|
|
164
|
+
frame.onOutcome?.(detail);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const container = holder.current;
|
|
170
|
+
if (container === null) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const first = latest();
|
|
174
|
+
// A display keeps running when the host renames it (a streamed display's id and title can
|
|
175
|
+
// settle after it starts): calls and confirmations name it as the latest props do.
|
|
176
|
+
const named = (context) => ({
|
|
177
|
+
...context,
|
|
178
|
+
displayId: latest().displayId,
|
|
179
|
+
title: latest().title,
|
|
180
|
+
});
|
|
181
|
+
// The handlers present now (a declared one must be), each calling the latest prop's.
|
|
182
|
+
const handlers = {};
|
|
183
|
+
for (const name of Object.keys(first.handlers ?? {})) {
|
|
184
|
+
handlers[name] = (args, context) => latest().handlers?.[name]?.(args, named(context));
|
|
185
|
+
}
|
|
186
|
+
const asks = first.confirm !== undefined || page.confirm !== undefined;
|
|
187
|
+
let display;
|
|
188
|
+
try {
|
|
189
|
+
display = createHost(config, {
|
|
190
|
+
handlers,
|
|
191
|
+
...(asks
|
|
192
|
+
? {
|
|
193
|
+
confirm: (request, proceed) => (latest().confirm ?? page.confirm ?? (() => Promise.resolve(false)))({ ...request, display: { id: latest().displayId, title: latest().title } }, proceed),
|
|
194
|
+
}
|
|
195
|
+
: {}),
|
|
196
|
+
theme: (each) => latest().theme?.(each) ?? {},
|
|
197
|
+
...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
|
|
198
|
+
...(page.locator === undefined ? {} : { locator: page.locator }),
|
|
199
|
+
}).mount(container, {
|
|
200
|
+
displayId: first.displayId,
|
|
201
|
+
title: first.title,
|
|
202
|
+
scheme: first.scheme,
|
|
203
|
+
initialHeightPx: startHeight,
|
|
204
|
+
...(view === undefined ? {} : { document: view }),
|
|
205
|
+
...(instance === undefined ? {} : { instance }),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
first.onError?.(error instanceof Error ? error.message : String(error));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
handle.current = display;
|
|
213
|
+
const events = [
|
|
214
|
+
"initialized",
|
|
215
|
+
"resize",
|
|
216
|
+
"error",
|
|
217
|
+
"navigated",
|
|
218
|
+
"refused",
|
|
219
|
+
"muted",
|
|
220
|
+
"outcome",
|
|
221
|
+
];
|
|
222
|
+
for (const event of events) {
|
|
223
|
+
display.on(event, (detail) => notify(event, detail));
|
|
224
|
+
}
|
|
225
|
+
if (first.content !== undefined) {
|
|
226
|
+
display.setContent(first.content, { partial: first.partial === true });
|
|
227
|
+
}
|
|
228
|
+
return () => {
|
|
229
|
+
display.destroy();
|
|
230
|
+
handle.current = null;
|
|
231
|
+
};
|
|
232
|
+
// A new display only for a new config, document, instance or page (or generation, by key);
|
|
233
|
+
// content, theme, handlers, id and title are fed to the running one.
|
|
234
|
+
}, [config, view, instance, page, startHeight]);
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (content !== undefined) {
|
|
237
|
+
handle.current?.setContent(content, { partial: partial === true });
|
|
238
|
+
}
|
|
239
|
+
}, [content, partial]);
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
handle.current?.setTheme(scheme);
|
|
242
|
+
}, [scheme]);
|
|
243
|
+
const { themeKey } = frame;
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (themeKey !== undefined) {
|
|
246
|
+
handle.current?.refreshTheme();
|
|
247
|
+
}
|
|
248
|
+
}, [themeKey]);
|
|
249
|
+
return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
|
|
250
|
+
position: "relative",
|
|
251
|
+
// A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
|
|
252
|
+
...(fills ? { height: "100%" } : {}),
|
|
253
|
+
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
254
|
+
}, children: [_jsx("div", { ref: holder, "data-porthole-mount": "", style: { opacity: ready ? 1 : 0, ...(fills ? { height: "100%" } : {}) } }), ready ? null : placeholder({ state: "loading", title: frame.title, run })] }));
|
|
255
|
+
}
|
|
@@ -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,11 @@
|
|
|
1
|
+
import type { FrameBlock } from "../host/types.ts";
|
|
2
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
3
|
+
export declare const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
4
|
+
export declare const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
5
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
6
|
+
export declare const SESSION_PATH = "/__porthole/session";
|
|
7
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
8
|
+
export declare function pageScript(pathname: string): Response | null;
|
|
9
|
+
export declare function statusPage(status: number, title: string, detail: string, frameAncestors: string, method?: string, reason?: FrameBlock, hostOrigins?: readonly string[]): Response;
|
|
10
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
11
|
+
export declare function enterPage(target: string, frameAncestors: string, hostOrigins: readonly string[], cookie: string): Response;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { FRAME_STATUS_METHOD } from "../isolation/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The server's own pages on a content origin: why it shows nothing of the content, and the page a
|
|
4
|
+
* framed app is entered through. Plain, framable only by the host origins, never cached. Each tells
|
|
5
|
+
* the page that frames it how loading went (`porthole/notifications/frame-status`, P040), posted to
|
|
6
|
+
* the host origins only, so a host can show its own fallback ("Open in a new tab"). Their words are
|
|
7
|
+
* the host-agnostic mechanism's; a host that wants its own wording serves its own page in front.
|
|
8
|
+
*/
|
|
9
|
+
const escape = (text) => text.replace(/[&<>"']/gu, (char) => `&#${char.charCodeAt(0)};`);
|
|
10
|
+
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}";
|
|
11
|
+
/** Where the pages' scripts are served, on every content origin. */
|
|
12
|
+
export const STATUS_SCRIPT_PATH = "/__porthole/status.js";
|
|
13
|
+
export const ENTER_SCRIPT_PATH = "/__porthole/enter.js";
|
|
14
|
+
/** Answers 204 when the request carries a good session for its origin's app, 401 otherwise. */
|
|
15
|
+
export const SESSION_PATH = "/__porthole/session";
|
|
16
|
+
const say = () => `const say = (params) => { if (window.parent === window) return; for (const origin of hosts) window.parent.postMessage({ jsonrpc: "2.0", method: ${JSON.stringify(FRAME_STATUS_METHOD)}, params }, origin); };`;
|
|
17
|
+
/** Tells the host the page is blocked, and why (from the page's data attributes). */
|
|
18
|
+
const STATUS_SCRIPT = `(() => {
|
|
19
|
+
const main = document.querySelector("[data-porthole-status]");
|
|
20
|
+
if (main === null) return;
|
|
21
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
22
|
+
${say()}
|
|
23
|
+
say({ state: "blocked", reason: main.dataset.portholeReason || "error", status: Number(main.dataset.portholeStatus) });
|
|
24
|
+
})();`;
|
|
25
|
+
/**
|
|
26
|
+
* Enters a framed app: says so, checks that the browser kept the session cookie this page's own
|
|
27
|
+
* answer set (WebKit keeps none in a cross-site frame, P025), then goes on to the app, or says it
|
|
28
|
+
* is blocked and why.
|
|
29
|
+
*/
|
|
30
|
+
const ENTER_SCRIPT = `(async () => {
|
|
31
|
+
const main = document.querySelector("[data-porthole-enter]");
|
|
32
|
+
if (main === null) return;
|
|
33
|
+
const hosts = JSON.parse(main.dataset.portholeHosts || "[]");
|
|
34
|
+
${say()}
|
|
35
|
+
say({ state: "entering" });
|
|
36
|
+
let kept = false;
|
|
37
|
+
try {
|
|
38
|
+
kept = (await fetch(${JSON.stringify(SESSION_PATH)}, { credentials: "same-origin", cache: "no-store" })).ok;
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
if (kept) {
|
|
41
|
+
location.replace(main.dataset.portholeTarget || "/");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
say({ state: "blocked", reason: "cookies", status: 401 });
|
|
45
|
+
main.querySelector("h1").textContent = "Open this app in a new tab";
|
|
46
|
+
main.querySelector("p").textContent = "This browser keeps no sign-in for it inside a frame.";
|
|
47
|
+
})();`;
|
|
48
|
+
/** The pages' scripts, by path: served with the pages' own policy in mind (`script-src 'self'`). */
|
|
49
|
+
export function pageScript(pathname) {
|
|
50
|
+
const body = pathname === STATUS_SCRIPT_PATH
|
|
51
|
+
? STATUS_SCRIPT
|
|
52
|
+
: pathname === ENTER_SCRIPT_PATH
|
|
53
|
+
? ENTER_SCRIPT
|
|
54
|
+
: null;
|
|
55
|
+
return body === null
|
|
56
|
+
? null
|
|
57
|
+
: new Response(body, {
|
|
58
|
+
headers: {
|
|
59
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
60
|
+
"cache-control": "no-store",
|
|
61
|
+
"x-content-type-options": "nosniff",
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const policy = (frameAncestors, connect) => `default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; ${connect ? "connect-src 'self'; " : ""}base-uri 'none'; form-action 'none'; frame-ancestors ${frameAncestors}`;
|
|
66
|
+
const pageHeaders = (frameAncestors, connect) => ({
|
|
67
|
+
"content-type": "text/html; charset=utf-8",
|
|
68
|
+
"cache-control": "no-store",
|
|
69
|
+
"content-security-policy": policy(frameAncestors, connect),
|
|
70
|
+
"referrer-policy": "no-referrer",
|
|
71
|
+
"x-content-type-options": "nosniff",
|
|
72
|
+
});
|
|
73
|
+
export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = []) {
|
|
74
|
+
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}" data-porthole-reason="${escape(reason)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
|
75
|
+
return new Response(method === "HEAD" ? null : body, {
|
|
76
|
+
status,
|
|
77
|
+
headers: pageHeaders(frameAncestors, false),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** The page a framed app is entered through (its answer also sets the session cookie). */
|
|
81
|
+
export function enterPage(target, frameAncestors, hostOrigins, cookie) {
|
|
82
|
+
const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Opening the app</title><style>${STYLE}</style></head><body><main data-porthole-enter="" data-porthole-target="${escape(target)}" data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>Opening the app…</h1><p></p></main><script src="${ENTER_SCRIPT_PATH}"></script></body></html>`;
|
|
83
|
+
const headers = new Headers(pageHeaders(frameAncestors, true));
|
|
84
|
+
headers.set("set-cookie", cookie);
|
|
85
|
+
return new Response(body, { status: 200, headers });
|
|
86
|
+
}
|
|
@@ -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;
|