@botiverse/porthole 0.1.2 → 0.1.4
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/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +10 -3
- package/dist/compile/policy.js +16 -5
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -0
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +11 -5
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +165 -7
- package/dist/host/types.d.ts +68 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +50 -2
- package/dist/react/frame.js +153 -11
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +49 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +700 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +12 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +71 -3
- package/package.json +1 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +17 -5
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +22 -5
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +181 -7
- package/src/host/types.ts +64 -2
- package/src/index.ts +24 -0
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +231 -13
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +61 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +700 -0
- package/src/shell/document.ts +21 -3
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +71 -3
package/dist/react/frame.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from "react";
|
|
3
|
+
import { writeClipboard } from "../host/clipboard.js";
|
|
3
4
|
import { createHost } from "../host/create-host.js";
|
|
4
5
|
import { ConfirmDialog } from "./dialog.js";
|
|
5
6
|
import { createLiveFrames } from "./live.js";
|
|
7
|
+
import { CopyToast } from "./toast.js";
|
|
6
8
|
/**
|
|
7
9
|
* The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
|
|
8
10
|
* run, the heights displays reported, and the default confirmation dialog); a `<PortholeFrame>` per
|
|
@@ -28,14 +30,36 @@ export function PortholeProvider(props) {
|
|
|
28
30
|
const confirm = useCallback((request, proceed) => own.current !== undefined
|
|
29
31
|
? own.current(request, proceed)
|
|
30
32
|
: new Promise((resolve) => setQueue((waiting) => [...waiting, { request, resolve, proceed }])), []);
|
|
31
|
-
|
|
33
|
+
// The default copyText: the host's page writes the clipboard (in the frame's click, which
|
|
34
|
+
// activated this page too) and shows the person what went on it, or offers to copy it.
|
|
35
|
+
const [notice, setNotice] = useState(null);
|
|
36
|
+
const copyText = useCallback(async ({ text }, context) => {
|
|
37
|
+
const copied = await writeClipboard(text, globalThis.document);
|
|
38
|
+
setNotice({ text, copied, title: context.title });
|
|
39
|
+
return copied;
|
|
40
|
+
}, []);
|
|
41
|
+
const closeNotice = useCallback(() => setNotice(null), []);
|
|
42
|
+
// Compared by value, and read when used: an inline hostInfo or locator doesn't start every
|
|
43
|
+
// frame again on each render.
|
|
44
|
+
const hostInfo = useByValue(props.hostInfo);
|
|
45
|
+
const ownLocator = useRef(props.locator);
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
ownLocator.current = props.locator;
|
|
48
|
+
});
|
|
49
|
+
const locate = useCallback((instance, signal) => ownLocator.current === undefined
|
|
50
|
+
? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
|
|
51
|
+
: ownLocator.current.locate(instance, signal), []);
|
|
52
|
+
const locates = props.locator !== undefined;
|
|
53
|
+
const { nonce } = props;
|
|
32
54
|
const value = useMemo(() => ({
|
|
33
55
|
live,
|
|
34
56
|
heights,
|
|
35
57
|
confirm,
|
|
58
|
+
copyText,
|
|
36
59
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
37
|
-
...(
|
|
38
|
-
|
|
60
|
+
...(locates ? { locate } : {}),
|
|
61
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
62
|
+
}), [live, heights, confirm, copyText, hostInfo, locates, locate, nonce]);
|
|
39
63
|
const current = queue[0] ?? null;
|
|
40
64
|
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
65
|
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
@@ -44,7 +68,10 @@ export function PortholeProvider(props) {
|
|
|
44
68
|
}
|
|
45
69
|
current?.resolve(accepted ? "accepted" : "declined");
|
|
46
70
|
setQueue((waiting) => waiting.slice(1));
|
|
47
|
-
} })) : null
|
|
71
|
+
} })) : null, _jsx(CopyToast, { notice: notice, ...(props.toastClassName === undefined ? {} : { className: props.toastClassName }), onClose: closeNotice, onCopy: (failed) => {
|
|
72
|
+
// A click on the host's own page: the write has the gesture it needs now.
|
|
73
|
+
void writeClipboard(failed.text, globalThis.document).then((copied) => setNotice({ ...failed, copied }));
|
|
74
|
+
} })] }));
|
|
48
75
|
}
|
|
49
76
|
const PLACEHOLDER_STYLE = {
|
|
50
77
|
position: "absolute",
|
|
@@ -119,6 +146,8 @@ export function PortholeFrame(props) {
|
|
|
119
146
|
return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
|
|
120
147
|
position: "relative",
|
|
121
148
|
width: "100%",
|
|
149
|
+
// A display that fills takes its parent's height (the host sizes the parent, or this box).
|
|
150
|
+
...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
|
|
122
151
|
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
123
152
|
...props.style,
|
|
124
153
|
}, 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 })) }));
|
|
@@ -130,8 +159,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
130
159
|
const [ready, setReady] = useState(false);
|
|
131
160
|
// The height this display started at: later reports don't make it start again.
|
|
132
161
|
const [startHeight] = useState(initialHeightPx);
|
|
133
|
-
const {
|
|
134
|
-
|
|
162
|
+
const { document: view, content, partial, scheme } = frame;
|
|
163
|
+
// By value: a config or instance made inline (each render) is the same display.
|
|
164
|
+
const config = useByValue(frame.config);
|
|
165
|
+
const instance = useByValue(frame.instance);
|
|
166
|
+
const fills = config.mode === "url" || frame.fill === true;
|
|
135
167
|
// The latest props, read when the display calls back (never during render).
|
|
136
168
|
const latest = useEffectEvent(() => frame);
|
|
137
169
|
const notify = useEffectEvent((event, detail) => {
|
|
@@ -146,9 +178,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
146
178
|
frame.onResize?.(size);
|
|
147
179
|
return;
|
|
148
180
|
}
|
|
149
|
-
case "
|
|
150
|
-
frame.
|
|
181
|
+
case "contentError":
|
|
182
|
+
frame.onContentError?.(detail.message);
|
|
183
|
+
return;
|
|
184
|
+
case "hostError":
|
|
185
|
+
frame.onHostError?.(detail);
|
|
186
|
+
return;
|
|
187
|
+
case "error": {
|
|
188
|
+
const { message, source } = detail;
|
|
189
|
+
frame.onError?.(message, source);
|
|
151
190
|
return;
|
|
191
|
+
}
|
|
152
192
|
case "navigated":
|
|
153
193
|
frame.onNavigated?.();
|
|
154
194
|
return;
|
|
@@ -163,6 +203,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
163
203
|
case "outcome":
|
|
164
204
|
frame.onOutcome?.(detail);
|
|
165
205
|
return;
|
|
206
|
+
case "annotation":
|
|
207
|
+
frame.onAnnotation?.(detail);
|
|
208
|
+
return;
|
|
166
209
|
}
|
|
167
210
|
});
|
|
168
211
|
useEffect(() => {
|
|
@@ -183,6 +226,13 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
183
226
|
for (const name of Object.keys(first.handlers ?? {})) {
|
|
184
227
|
handlers[name] = (args, context) => latest().handlers?.[name]?.(args, named(context));
|
|
185
228
|
}
|
|
229
|
+
// Copy buttons without a handler of the host's: the provider's, which shows a toast.
|
|
230
|
+
if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
|
|
231
|
+
const fallback = page.copyText;
|
|
232
|
+
if (fallback !== undefined) {
|
|
233
|
+
handlers.copyText = (args, context) => fallback(args, named(context));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
186
236
|
const asks = first.confirm !== undefined || page.confirm !== undefined;
|
|
187
237
|
let display;
|
|
188
238
|
try {
|
|
@@ -195,7 +245,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
195
245
|
: {}),
|
|
196
246
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
197
247
|
...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
|
|
198
|
-
...(page.
|
|
248
|
+
...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
|
|
249
|
+
...(page.nonce === undefined ? {} : { nonce: page.nonce }),
|
|
199
250
|
}).mount(container, {
|
|
200
251
|
displayId: first.displayId,
|
|
201
252
|
title: first.title,
|
|
@@ -203,10 +254,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
203
254
|
initialHeightPx: startHeight,
|
|
204
255
|
...(view === undefined ? {} : { document: view }),
|
|
205
256
|
...(instance === undefined ? {} : { instance }),
|
|
257
|
+
...(fills ? { fill: true } : {}),
|
|
206
258
|
});
|
|
207
259
|
}
|
|
208
260
|
catch (error) {
|
|
209
|
-
first
|
|
261
|
+
hostFailed(first, error, "mount");
|
|
210
262
|
return;
|
|
211
263
|
}
|
|
212
264
|
handle.current = display;
|
|
@@ -218,10 +270,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
218
270
|
"refused",
|
|
219
271
|
"muted",
|
|
220
272
|
"outcome",
|
|
273
|
+
"annotation",
|
|
274
|
+
"contentError",
|
|
275
|
+
"hostError",
|
|
221
276
|
];
|
|
222
277
|
for (const event of events) {
|
|
223
278
|
display.on(event, (detail) => notify(event, detail));
|
|
224
279
|
}
|
|
280
|
+
if (config.capabilities.annotate !== undefined) {
|
|
281
|
+
applyAnnotations(display, first);
|
|
282
|
+
}
|
|
225
283
|
if (first.content !== undefined) {
|
|
226
284
|
display.setContent(first.content, { partial: first.partial === true });
|
|
227
285
|
}
|
|
@@ -231,12 +289,49 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
231
289
|
};
|
|
232
290
|
// A new display only for a new config, document, instance or page (or generation, by key);
|
|
233
291
|
// content, theme, handlers, id and title are fed to the running one.
|
|
234
|
-
}, [config, view, instance, page, startHeight]);
|
|
292
|
+
}, [config, view, instance, page, startHeight, fills]);
|
|
235
293
|
useEffect(() => {
|
|
236
294
|
if (content !== undefined) {
|
|
237
295
|
handle.current?.setContent(content, { partial: partial === true });
|
|
238
296
|
}
|
|
239
297
|
}, [content, partial]);
|
|
298
|
+
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
299
|
+
const annotates = config.capabilities.annotate !== undefined;
|
|
300
|
+
const { annotating } = frame;
|
|
301
|
+
const focusedAnnotation = focusOf(frame.focusedAnnotation);
|
|
302
|
+
const focusToken = focusedAnnotation === null
|
|
303
|
+
? ""
|
|
304
|
+
: typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
|
|
305
|
+
? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
|
|
306
|
+
: JSON.stringify([focusedAnnotation]);
|
|
307
|
+
// By value: the same marks given again don't mark them again.
|
|
308
|
+
const annotations = useByValue(frame.annotations);
|
|
309
|
+
const mode = annotating === undefined || annotating === null
|
|
310
|
+
? ""
|
|
311
|
+
: JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
const display = handle.current;
|
|
314
|
+
if (!annotates || display === null) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const wanted = mode === "" ? null : JSON.parse(mode);
|
|
318
|
+
attempt(() => wanted === null
|
|
319
|
+
? display.annotations.stop()
|
|
320
|
+
: display.annotations.start({ elements: wanted[0], text: wanted[1] }), latest());
|
|
321
|
+
}, [annotates, mode]);
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const display = handle.current;
|
|
324
|
+
if (annotates && display !== null) {
|
|
325
|
+
attempt(() => display.annotations.show(annotations ?? []), latest());
|
|
326
|
+
}
|
|
327
|
+
}, [annotates, annotations]);
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
const display = handle.current;
|
|
330
|
+
const id = focusOf(latest().focusedAnnotation);
|
|
331
|
+
if (annotates && display !== null && focusToken !== "" && id !== null) {
|
|
332
|
+
attempt(() => display.annotations.focus(id), latest());
|
|
333
|
+
}
|
|
334
|
+
}, [annotates, focusToken]);
|
|
240
335
|
useEffect(() => {
|
|
241
336
|
handle.current?.setTheme(scheme);
|
|
242
337
|
}, [scheme]);
|
|
@@ -253,3 +348,50 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
253
348
|
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
254
349
|
}, 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
350
|
}
|
|
351
|
+
/** Tells the frame's props what failed on the host's side. */
|
|
352
|
+
function hostFailed(frame, error, kind) {
|
|
353
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
354
|
+
frame.onHostError?.({ message, kind });
|
|
355
|
+
frame.onError?.(message, "host");
|
|
356
|
+
}
|
|
357
|
+
/** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
|
|
358
|
+
function attempt(run, frame) {
|
|
359
|
+
try {
|
|
360
|
+
run();
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
hostFailed(frame, error, "annotations");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** A new display's annotations, from the props as they are. */
|
|
367
|
+
function applyAnnotations(display, frame) {
|
|
368
|
+
attempt(() => {
|
|
369
|
+
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
370
|
+
display.annotations.show(frame.annotations);
|
|
371
|
+
}
|
|
372
|
+
const focused = focusOf(frame.focusedAnnotation);
|
|
373
|
+
if (focused !== null) {
|
|
374
|
+
display.annotations.focus(focused);
|
|
375
|
+
}
|
|
376
|
+
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
377
|
+
display.annotations.start(frame.annotating);
|
|
378
|
+
}
|
|
379
|
+
}, frame);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
|
|
383
|
+
* render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
|
|
384
|
+
* only when it really changes.
|
|
385
|
+
*/
|
|
386
|
+
function useByValue(value) {
|
|
387
|
+
const key = value === undefined ? "" : JSON.stringify(value);
|
|
388
|
+
return useMemo(() => (key === "" ? undefined : JSON.parse(key)), [key]);
|
|
389
|
+
}
|
|
390
|
+
/** The id `focusedAnnotation` names, if any. */
|
|
391
|
+
function focusOf(value) {
|
|
392
|
+
return typeof value === "string"
|
|
393
|
+
? value
|
|
394
|
+
: value === null || value === undefined
|
|
395
|
+
? null
|
|
396
|
+
: value.id;
|
|
397
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
|
|
3
3
|
* package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
|
|
4
4
|
*
|
|
5
|
-
* <PortholeProvider> // per page: live-frame cap, heights, the dialog
|
|
5
|
+
* <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
|
|
6
6
|
* <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
|
|
7
7
|
* content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
|
|
8
8
|
* theme={tokensFor} onError={report} />
|
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, type PlaceholderProps, type PortholeFrameProps, type PortholeProviderProps, } from "./frame.tsx";
|
|
12
12
|
export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
|
|
13
|
+
export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, type CopyNotice, type CopyToastProps, } from "./toast.tsx";
|
|
13
14
|
export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
package/dist/react/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
|
|
3
3
|
* package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
|
|
4
4
|
*
|
|
5
|
-
* <PortholeProvider> // per page: live-frame cap, heights, the dialog
|
|
5
|
+
* <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
|
|
6
6
|
* <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
|
|
7
7
|
* content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
|
|
8
8
|
* theme={tokensFor} onError={report} />
|
|
@@ -10,4 +10,5 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { PLACEHOLDER_HEIGHT_PX, PortholeFrame, PortholeProvider, } from "./frame.js";
|
|
12
12
|
export { ARM_DELAY_MS, ConfirmDialog } from "./dialog.js";
|
|
13
|
+
export { CopyToast, TOAST_CHARS, TOAST_MS, clipped, } from "./toast.js";
|
|
13
14
|
export { admitFrame, createLiveFrames } from "./live.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The default copy notice (P052): when content's `copyText` copied something, the host's page
|
|
4
|
+
* shows the person what went on their clipboard, clipped, in a small toast outside every frame.
|
|
5
|
+
* If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
|
|
6
|
+
* host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
|
|
7
|
+
* styles it through the `porthole-toast` class and data attributes.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a toast for a copy that worked stays. */
|
|
10
|
+
export declare const TOAST_MS = 5000;
|
|
11
|
+
/** The most of the copied text a toast shows. */
|
|
12
|
+
export declare const TOAST_CHARS = 280;
|
|
13
|
+
export interface CopyNotice {
|
|
14
|
+
/** The whole text copied (or to copy). */
|
|
15
|
+
readonly text: string;
|
|
16
|
+
/** Whether it is on the clipboard. */
|
|
17
|
+
readonly copied: boolean;
|
|
18
|
+
/** The display's title, as the host named it. */
|
|
19
|
+
readonly title: string;
|
|
20
|
+
}
|
|
21
|
+
export interface CopyToastProps {
|
|
22
|
+
readonly notice: CopyNotice | null;
|
|
23
|
+
/** The person asked to copy it again (it failed), from this click. */
|
|
24
|
+
readonly onCopy: (notice: CopyNotice) => void;
|
|
25
|
+
readonly onClose: () => void;
|
|
26
|
+
readonly className?: string;
|
|
27
|
+
}
|
|
28
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
29
|
+
export declare function clipped(text: string): {
|
|
30
|
+
readonly shown: string;
|
|
31
|
+
readonly clipped: boolean;
|
|
32
|
+
};
|
|
33
|
+
export declare function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* The default copy notice (P052): when content's `copyText` copied something, the host's page
|
|
5
|
+
* shows the person what went on their clipboard, clipped, in a small toast outside every frame.
|
|
6
|
+
* If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
|
|
7
|
+
* host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
|
|
8
|
+
* styles it through the `porthole-toast` class and data attributes.
|
|
9
|
+
*/
|
|
10
|
+
/** How long a toast for a copy that worked stays. */
|
|
11
|
+
export const TOAST_MS = 5_000;
|
|
12
|
+
/** The most of the copied text a toast shows. */
|
|
13
|
+
export const TOAST_CHARS = 280;
|
|
14
|
+
const TOAST_LINES = 4;
|
|
15
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
16
|
+
export function clipped(text) {
|
|
17
|
+
const lines = text.split("\n");
|
|
18
|
+
let shown = lines.slice(0, TOAST_LINES).join("\n");
|
|
19
|
+
if (shown.length > TOAST_CHARS) {
|
|
20
|
+
shown = shown.slice(0, TOAST_CHARS);
|
|
21
|
+
}
|
|
22
|
+
return shown.length < text.length
|
|
23
|
+
? { shown: `${shown}…`, clipped: true }
|
|
24
|
+
: { shown, clipped: false };
|
|
25
|
+
}
|
|
26
|
+
const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
|
|
27
|
+
.porthole-toast p{margin:0 0 6px}
|
|
28
|
+
.porthole-toast [data-porthole-source]{opacity:.72}
|
|
29
|
+
.porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
30
|
+
.porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
|
|
31
|
+
.porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
|
|
32
|
+
export function CopyToast({ notice, onCopy, onClose, className }) {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (notice === null || !notice.copied) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const timer = setTimeout(onClose, TOAST_MS);
|
|
38
|
+
return () => clearTimeout(timer);
|
|
39
|
+
}, [notice, onClose]);
|
|
40
|
+
const view = notice === null ? null : clipped(notice.text);
|
|
41
|
+
return (_jsxs("div", { role: "status", "aria-live": "polite", className: className === undefined ? "porthole-toast" : `porthole-toast ${className}`, "data-porthole-toast": "", "data-porthole-copied": notice === null ? "" : String(notice.copied), hidden: notice === null, children: [_jsx("style", { children: STYLE }), notice === null || view === null ? null : (_jsxs(_Fragment, { children: [_jsxs("p", { children: [_jsx("strong", { children: notice.copied ? "Copied" : "Not copied yet" }), " ", _jsx("span", { "data-porthole-source": "", children: `from “${notice.title}”` })] }), _jsx("pre", { "data-porthole-copied-text": "", children: view.shown }), view.clipped ? (_jsx("p", { "data-porthole-length": "", children: `${notice.text.length.toLocaleString("en-US")} characters in all` })) : null, _jsxs("menu", { children: [notice.copied ? null : (_jsx("button", { type: "button", "data-porthole-copy": "", onClick: () => onCopy(notice), children: "Copy" })), _jsx("button", { type: "button", "data-porthole-close": "", onClick: onClose, children: notice.copied ? "OK" : "Close" })] })] }))] }));
|
|
42
|
+
}
|
package/dist/server/naming.d.ts
CHANGED
|
@@ -15,3 +15,9 @@ export declare function isHostLabel(label: unknown): label is string;
|
|
|
15
15
|
* than being folded into another scope's name.
|
|
16
16
|
*/
|
|
17
17
|
export declare function prefixedName(prefix: string): (scope: string) => string;
|
|
18
|
+
/**
|
|
19
|
+
* Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
|
|
20
|
+
* (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
|
|
21
|
+
* reserves the label (fail closed: the request goes to the host's routes, never served as ours).
|
|
22
|
+
*/
|
|
23
|
+
export declare function reservedLabels(reserved: readonly string[] | ((label: string) => boolean) | undefined): (label: string) => boolean;
|
package/dist/server/naming.js
CHANGED
|
@@ -32,3 +32,41 @@ export function prefixedName(prefix) {
|
|
|
32
32
|
return label;
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
|
|
38
|
+
* (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
|
|
39
|
+
* reserves the label (fail closed: the request goes to the host's routes, never served as ours).
|
|
40
|
+
*/
|
|
41
|
+
export function reservedLabels(reserved) {
|
|
42
|
+
if (reserved === undefined) {
|
|
43
|
+
return () => false;
|
|
44
|
+
}
|
|
45
|
+
if (typeof reserved === "function") {
|
|
46
|
+
return (label) => {
|
|
47
|
+
try {
|
|
48
|
+
return reserved(label);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(reserved)) {
|
|
56
|
+
throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
|
|
57
|
+
}
|
|
58
|
+
const whole = new Set();
|
|
59
|
+
const prefixes = [];
|
|
60
|
+
for (const entry of reserved) {
|
|
61
|
+
if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
|
|
62
|
+
prefixes.push(entry.slice(0, -1));
|
|
63
|
+
}
|
|
64
|
+
else if (isHostLabel(entry)) {
|
|
65
|
+
whole.add(entry);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
throw new TypeError(`content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
|
|
72
|
+
}
|
package/dist/server/pages.js
CHANGED
|
@@ -77,6 +77,8 @@ const pageHeaders = (frameAncestors, connect) => ({
|
|
|
77
77
|
"content-security-policy": policy(frameAncestors, connect),
|
|
78
78
|
"referrer-policy": "no-referrer",
|
|
79
79
|
"x-content-type-options": "nosniff",
|
|
80
|
+
// Content origins are behind tickets: nothing on them is for search engines.
|
|
81
|
+
"x-robots-tag": "noindex, nofollow",
|
|
80
82
|
});
|
|
81
83
|
export function statusPage(status, title, detail, frameAncestors, method = "GET", reason = "error", hostOrigins = [], code) {
|
|
82
84
|
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)}"${code === undefined ? "" : ` data-porthole-code="${escape(code)}"`} data-porthole-hosts="${escape(JSON.stringify(hostOrigins))}"><h1>${escape(title)}</h1><p>${escape(detail)}</p></main><script src="${STATUS_SCRIPT_PATH}"></script></body></html>`;
|
package/dist/server/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { compile } from "../compile/compile.js";
|
|
|
2
2
|
import { hostOriginProblem, maybeSameSite, originMatchesSource, parseOrigin, } from "../isolation/served.js";
|
|
3
3
|
import { sandboxProxyDocument } from "../mcp/proxy.js";
|
|
4
4
|
import { createKeys, newTicket, ticketId } from "./keys.js";
|
|
5
|
-
import { isHostLabel } from "./naming.js";
|
|
5
|
+
import { isHostLabel, reservedLabels } from "./naming.js";
|
|
6
6
|
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.js";
|
|
7
7
|
import { memoryTicketStore } from "./tickets.js";
|
|
8
8
|
/**
|
|
@@ -26,6 +26,17 @@ const DEFAULT_SESSION_SECONDS = 3_600;
|
|
|
26
26
|
const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
|
|
27
27
|
/** Browsers keep a cookie of up to 4 KB, name and attributes included. */
|
|
28
28
|
const MAX_COOKIE_BYTES = 3_800;
|
|
29
|
+
/** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
|
|
30
|
+
function normalHost(value, protocol) {
|
|
31
|
+
const host = value
|
|
32
|
+
.trim()
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replace(/\.(?=:\d+$|$)/u, "");
|
|
35
|
+
const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
|
|
36
|
+
return defaultPort !== "" && host.endsWith(defaultPort)
|
|
37
|
+
? host.slice(0, -defaultPort.length)
|
|
38
|
+
: host;
|
|
39
|
+
}
|
|
29
40
|
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
30
41
|
/** Which configs a kind of served document may be served under. */
|
|
31
42
|
const MODES = {
|
|
@@ -148,25 +159,20 @@ export function createServer(options) {
|
|
|
148
159
|
throw new TypeError("content.port must be a port number");
|
|
149
160
|
}
|
|
150
161
|
const keys = createKeys(options.secret);
|
|
151
|
-
const
|
|
152
|
-
for (const label of reserved) {
|
|
153
|
-
if (!isHostLabel(label)) {
|
|
154
|
-
throw new TypeError(`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
162
|
+
const isReserved = reservedLabels(content.reserved);
|
|
157
163
|
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
158
164
|
const nameOf = (scope) => {
|
|
159
165
|
if (content.name === undefined) {
|
|
160
166
|
return keys.label(scope);
|
|
161
167
|
}
|
|
162
168
|
const label = content.name(scope);
|
|
163
|
-
if (!isHostLabel(label) ||
|
|
169
|
+
if (!isHostLabel(label) || isReserved(label)) {
|
|
164
170
|
throw new TypeError(`content.name gave ${JSON.stringify(label)} for a scope: it must be one lowercase DNS label (a-z 0-9 -, at most 63, not xn--), not reserved`);
|
|
165
171
|
}
|
|
166
172
|
return label;
|
|
167
173
|
};
|
|
168
174
|
/** Whether a request's label may be one this server named. */
|
|
169
|
-
const ours = (label) => !
|
|
175
|
+
const ours = (label) => !isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
170
176
|
const secure = content.scheme === "https";
|
|
171
177
|
const defaultPort = secure ? 443 : 80;
|
|
172
178
|
const portPart = content.port === undefined || content.port === defaultPort ? "" : `:${content.port}`;
|
|
@@ -431,6 +437,7 @@ export function createServer(options) {
|
|
|
431
437
|
"cache-control": "no-store",
|
|
432
438
|
"referrer-policy": "no-referrer",
|
|
433
439
|
"x-content-type-options": "nosniff",
|
|
440
|
+
"x-robots-tag": "noindex, nofollow",
|
|
434
441
|
});
|
|
435
442
|
}
|
|
436
443
|
async function serveDocument(request, url, label, key) {
|
|
@@ -563,7 +570,16 @@ export function createServer(options) {
|
|
|
563
570
|
return page(502, "The app didn't answer", "Its server couldn't be reached.", method);
|
|
564
571
|
}
|
|
565
572
|
}
|
|
566
|
-
|
|
573
|
+
const underDomain = (host) => host === domain || host.endsWith(`.${domain}`);
|
|
574
|
+
/**
|
|
575
|
+
* The label a request is for: null when it isn't under the content domain at all (or is in a
|
|
576
|
+
* reserved namespace), "mismatch" when its Host header names another host than its URL does
|
|
577
|
+
* and either is under the content domain. A Node adapter may build the URL from an
|
|
578
|
+
* absolute-form request target (`GET http://other/…`) while a host routes by the Host header
|
|
579
|
+
* (and a cache keys on it): the two must agree before anything is served. A Host that is a
|
|
580
|
+
* local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
|
|
581
|
+
* not a name and isn't compared.
|
|
582
|
+
*/
|
|
567
583
|
function labelOf(request) {
|
|
568
584
|
let url;
|
|
569
585
|
try {
|
|
@@ -573,13 +589,25 @@ export function createServer(options) {
|
|
|
573
589
|
return null;
|
|
574
590
|
}
|
|
575
591
|
const host = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
576
|
-
|
|
592
|
+
const header = request.headers.get("host");
|
|
593
|
+
if (header !== null) {
|
|
594
|
+
const named = normalHost(header, url.protocol);
|
|
595
|
+
const name = named.replace(/:\d+$/u, "");
|
|
596
|
+
const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
|
|
597
|
+
if (!local &&
|
|
598
|
+
named !== normalHost(url.host, url.protocol) &&
|
|
599
|
+
(underDomain(host) || underDomain(name))) {
|
|
600
|
+
return "mismatch";
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (!underDomain(host)) {
|
|
577
604
|
return null;
|
|
578
605
|
}
|
|
579
606
|
const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
|
|
580
|
-
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`)
|
|
581
|
-
// under it go to the host's own routes. Any other deeper name is ours
|
|
582
|
-
|
|
607
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
|
|
608
|
+
// own: it and the names under it go to the host's own routes. Any other deeper name is ours
|
|
609
|
+
// to refuse.
|
|
610
|
+
if (isReserved(label.split(".").at(-1) ?? "")) {
|
|
583
611
|
return null;
|
|
584
612
|
}
|
|
585
613
|
return { url, label };
|
|
@@ -639,7 +667,7 @@ export function createServer(options) {
|
|
|
639
667
|
},
|
|
640
668
|
async access(request) {
|
|
641
669
|
const at = labelOf(request);
|
|
642
|
-
if (at === null || !ours(at.label)) {
|
|
670
|
+
if (at === null || at === "mismatch" || !ours(at.label)) {
|
|
643
671
|
return null;
|
|
644
672
|
}
|
|
645
673
|
const access = accessOf(request, at.label, null);
|
|
@@ -650,8 +678,11 @@ export function createServer(options) {
|
|
|
650
678
|
if (at === null) {
|
|
651
679
|
return null;
|
|
652
680
|
}
|
|
653
|
-
const { url, label } = at;
|
|
654
681
|
const method = request.method.toUpperCase();
|
|
682
|
+
if (at === "mismatch") {
|
|
683
|
+
return page(400, "Bad request", "The request's Host header and its URL name different hosts.", method);
|
|
684
|
+
}
|
|
685
|
+
const { url, label } = at;
|
|
655
686
|
try {
|
|
656
687
|
if (!ours(label)) {
|
|
657
688
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
@@ -673,7 +704,8 @@ export function createServer(options) {
|
|
|
673
704
|
if (document?.[1] !== undefined) {
|
|
674
705
|
return await serveDocument(request, url, label, document[1]);
|
|
675
706
|
}
|
|
676
|
-
|
|
707
|
+
// Without an upstream, only Porthole's own paths are served here: any other is nothing.
|
|
708
|
+
if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
|
|
677
709
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
678
710
|
}
|
|
679
711
|
return await serveApp(request, label);
|
package/dist/server/types.d.ts
CHANGED
|
@@ -18,10 +18,13 @@ export interface ContentDomain {
|
|
|
18
18
|
*/
|
|
19
19
|
readonly name?: (scope: string) => string;
|
|
20
20
|
/**
|
|
21
|
-
* Labels this server never serves or names
|
|
22
|
-
* `f` for `<instance>.f.<domain>`,
|
|
21
|
+
* Labels this server never serves or names: another server's namespace under the same domain
|
|
22
|
+
* (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
|
|
23
|
+
* `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
|
|
24
|
+
* whether a label is reserved. Requests for them, and for the names under them, get null (the
|
|
25
|
+
* host's routes), so a host routes them without wrapping `handle`.
|
|
23
26
|
*/
|
|
24
|
-
readonly reserved?: readonly string[];
|
|
27
|
+
readonly reserved?: readonly string[] | ((label: string) => boolean);
|
|
25
28
|
}
|
|
26
29
|
/**
|
|
27
30
|
* Who may open an instance, decided by the host before it calls `issue`: a viewer the host
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell's annotation runtime (P053), spliced into the shell's runtime (`__ANNOTATE__`) when
|
|
3
|
+
* the config declares `annotate`, so it runs before any content does, with the DOM's own
|
|
4
|
+
* functions as they were. Plain ES2020 like the rest of the shell; no template literals (it sits
|
|
5
|
+
* in one).
|
|
6
|
+
*
|
|
7
|
+
* The host drives it (`porthole/annotate.start|stop|show|focus|clear`) and it reports
|
|
8
|
+
* (`porthole/annotate.selected|unselected|cancelled|anchored`):
|
|
9
|
+
* - picking an element: the element under the pointer (or finger) is outlined with its name; a
|
|
10
|
+
* click or tap picks it, and the content's own handlers never see that click; Escape cancels;
|
|
11
|
+
* - selecting text: each selection that settles is reported, with where it is, and its clearing;
|
|
12
|
+
* - marks: each anchor is found in the content as it is now (its CSS selector, its text where it
|
|
13
|
+
* was, its text anywhere, its words with any spacing) and drawn over it, in a layer of the
|
|
14
|
+
* shell's own (a closed shadow root), again whenever the content changes; what can't be found
|
|
15
|
+
* is reported `orphaned`.
|
|
16
|
+
*
|
|
17
|
+
* It shares the realm with the content, which can interfere with all of it: the host takes its
|
|
18
|
+
* reports as UI hints only.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ANNOTATE_RUNTIME: string;
|