@botiverse/porthole 0.1.3 → 0.1.5
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/compile/policy.d.ts +10 -2
- package/dist/compile/policy.js +18 -4
- package/dist/config/schema.d.ts +20 -0
- package/dist/config/schema.js +2 -0
- package/dist/config/validate.js +174 -2
- package/dist/conformance/behavior.js +14 -1
- package/dist/conformance/kit.d.ts +5 -0
- package/dist/conformance/kit.js +11 -0
- package/dist/conformance/probes.js +19 -2
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.d.ts +2 -0
- package/dist/guide/guide.js +22 -5
- package/dist/host/create-host.js +24 -7
- package/dist/host/types.d.ts +44 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +5 -0
- package/dist/react/frame.d.ts +34 -4
- package/dist/react/frame.js +91 -22
- 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 +53 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.js +31 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +8 -2
- package/dist/shell/runtime.js +47 -2
- package/package.json +1 -1
- package/src/compile/policy.ts +22 -6
- package/src/config/schema.ts +23 -0
- package/src/config/validate.ts +193 -2
- package/src/conformance/behavior.ts +20 -1
- package/src/conformance/kit.ts +16 -0
- package/src/conformance/probes.ts +21 -2
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +25 -4
- package/src/host/create-host.ts +26 -7
- package/src/host/types.ts +39 -2
- package/src/index.ts +4 -0
- package/src/presets/presets.ts +8 -0
- package/src/react/frame.tsx +136 -27
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +65 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +31 -0
- package/src/shell/document.ts +15 -2
- package/src/shell/runtime.ts +47 -2
package/dist/host/create-host.js
CHANGED
|
@@ -57,6 +57,16 @@ export function createHost(config, options = {}) {
|
|
|
57
57
|
listener(detail);
|
|
58
58
|
}
|
|
59
59
|
};
|
|
60
|
+
// What the content did wrong (the agent's to hear and fix), and what failed on the host's
|
|
61
|
+
// side (its own to handle), each on its own event and on `error` with its source.
|
|
62
|
+
const contentError = (message) => {
|
|
63
|
+
emit("contentError", { message });
|
|
64
|
+
emit("error", { message, source: "content" });
|
|
65
|
+
};
|
|
66
|
+
const hostError = (message, kind) => {
|
|
67
|
+
emit("hostError", { message, kind });
|
|
68
|
+
emit("error", { message, source: "host" });
|
|
69
|
+
};
|
|
60
70
|
let scheme = mount.scheme;
|
|
61
71
|
let height = mount.initialHeightPx ?? 0;
|
|
62
72
|
let surface = null;
|
|
@@ -228,7 +238,7 @@ export function createHost(config, options = {}) {
|
|
|
228
238
|
return;
|
|
229
239
|
}
|
|
230
240
|
case "error":
|
|
231
|
-
|
|
241
|
+
contentError(event.message);
|
|
232
242
|
return;
|
|
233
243
|
case "refused":
|
|
234
244
|
emit("refused", { capability: event.capability, refusal: event.refusal });
|
|
@@ -260,8 +270,9 @@ export function createHost(config, options = {}) {
|
|
|
260
270
|
allow: compiled.allow,
|
|
261
271
|
referrerPolicy: compiled.referrerPolicy,
|
|
262
272
|
colorScheme: scheme,
|
|
263
|
-
// An app reports no size: its frame fills the host's container, at least the minimum
|
|
264
|
-
|
|
273
|
+
// An app reports no size: its frame fills the host's container, at least the minimum;
|
|
274
|
+
// so does any display the host mounts to fill (its content scrolls inside).
|
|
275
|
+
...(speaks && mount.fill !== true
|
|
265
276
|
? { initialHeightPx: height }
|
|
266
277
|
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
267
278
|
});
|
|
@@ -327,9 +338,7 @@ export function createHost(config, options = {}) {
|
|
|
327
338
|
}
|
|
328
339
|
else {
|
|
329
340
|
stop();
|
|
330
|
-
|
|
331
|
-
message: "The content kept navigating its frame away; it was stopped.",
|
|
332
|
-
});
|
|
341
|
+
contentError("The content kept navigating its frame away; it was stopped.");
|
|
333
342
|
}
|
|
334
343
|
});
|
|
335
344
|
};
|
|
@@ -345,6 +354,7 @@ export function createHost(config, options = {}) {
|
|
|
345
354
|
html: compiled.shellDocument({
|
|
346
355
|
origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
|
|
347
356
|
title: mount.title,
|
|
357
|
+
...nonceOf(options.nonce),
|
|
348
358
|
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
349
359
|
}),
|
|
350
360
|
});
|
|
@@ -365,7 +375,9 @@ export function createHost(config, options = {}) {
|
|
|
365
375
|
}
|
|
366
376
|
}, (error) => {
|
|
367
377
|
if (mine === generation && !controller.signal.aborted) {
|
|
368
|
-
|
|
378
|
+
// The host's own failure (its backend down, access gone): never the content's.
|
|
379
|
+
hostError(error instanceof Error ? error.message : String(error), "locate");
|
|
380
|
+
emit("outcome", { state: "blocked", reason: "locate" });
|
|
369
381
|
}
|
|
370
382
|
});
|
|
371
383
|
};
|
|
@@ -431,3 +443,8 @@ export function createHost(config, options = {}) {
|
|
|
431
443
|
},
|
|
432
444
|
};
|
|
433
445
|
}
|
|
446
|
+
/** The host's nonce option, read now (`HostOptions.nonce`). */
|
|
447
|
+
function nonceOf(nonce) {
|
|
448
|
+
const value = typeof nonce === "function" ? nonce() : nonce;
|
|
449
|
+
return value === undefined || value === "" ? {} : { nonce: value };
|
|
450
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -35,6 +35,13 @@ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
|
|
|
35
35
|
readonly platform?: PlatformHost;
|
|
36
36
|
/** Required for domain and external isolation: where an instance's frame loads from. */
|
|
37
37
|
readonly locator?: InstanceLocator;
|
|
38
|
+
/**
|
|
39
|
+
* The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): an opaque frame
|
|
40
|
+
* (`srcdoc`) inherits the page's policy, so the shell and the content's scripts carry it. A
|
|
41
|
+
* function is read each time a frame starts (a page whose nonce changes). Served frames inherit
|
|
42
|
+
* nothing and need none.
|
|
43
|
+
*/
|
|
44
|
+
readonly nonce?: string | (() => string | undefined);
|
|
38
45
|
}
|
|
39
46
|
export interface MountOptions {
|
|
40
47
|
/** The host's id for this display: what its height is remembered by, and calls name. */
|
|
@@ -50,7 +57,21 @@ export interface MountOptions {
|
|
|
50
57
|
readonly document?: string;
|
|
51
58
|
/** The height to start at, before the content reports its own. */
|
|
52
59
|
readonly initialHeightPx?: number;
|
|
60
|
+
/**
|
|
61
|
+
* Fill the container (give it a height) instead of taking the content's height: the content
|
|
62
|
+
* scrolls inside its frame (an artifact viewer's page). The content's sizes are still reported
|
|
63
|
+
* (`resize`). A url-mode app always fills.
|
|
64
|
+
*/
|
|
65
|
+
readonly fill?: boolean;
|
|
53
66
|
}
|
|
67
|
+
/** What failed on the host's side (`hostError`). */
|
|
68
|
+
export type HostErrorKind =
|
|
69
|
+
/** The locator rejected: the host's backend didn't give a location. */
|
|
70
|
+
"locate"
|
|
71
|
+
/** A display couldn't be made (the React binding: a bad config, a missing handler). */
|
|
72
|
+
| "mount"
|
|
73
|
+
/** An annotation call was refused (an invalid mark, an unknown id). */
|
|
74
|
+
| "annotations";
|
|
54
75
|
/** What a display reports, by event name. */
|
|
55
76
|
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
56
77
|
export type FrameBlock =
|
|
@@ -63,7 +84,9 @@ export type FrameBlock =
|
|
|
63
84
|
/** Its app didn't answer. */
|
|
64
85
|
| "upstream"
|
|
65
86
|
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
66
|
-
| "denied"
|
|
87
|
+
| "denied"
|
|
88
|
+
/** The host's locator failed (its backend down, access gone): see the `hostError` event. */
|
|
89
|
+
| "locate" | "not-found" | "error";
|
|
67
90
|
/**
|
|
68
91
|
* How a display's document went: it loaded (a shell or view finished its handshake; a url-mode
|
|
69
92
|
* app's document loaded), it was blocked (and why), or nothing settled in
|
|
@@ -92,9 +115,28 @@ export interface DisplayEvents {
|
|
|
92
115
|
readonly height: number;
|
|
93
116
|
readonly width?: number;
|
|
94
117
|
};
|
|
95
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* What the content did wrong: what it threw, what the sandbox blocked (its own words:
|
|
120
|
+
* untrusted), or that it kept navigating its frame away. The agent's to hear and fix.
|
|
121
|
+
*/
|
|
122
|
+
readonly contentError: {
|
|
123
|
+
readonly message: string;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* What failed on the host's side (`kind`): its locator, for now. The host's to handle; never
|
|
127
|
+
* the agent's. The display's `outcome` says `blocked: locate` too.
|
|
128
|
+
*/
|
|
129
|
+
readonly hostError: {
|
|
130
|
+
readonly message: string;
|
|
131
|
+
readonly kind: HostErrorKind;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* Both of the above, with their `source`: for a host that listened here before they were
|
|
135
|
+
* split (0.1.3). Prefer `contentError` and `hostError`.
|
|
136
|
+
*/
|
|
96
137
|
readonly error: {
|
|
97
138
|
readonly message: string;
|
|
139
|
+
readonly source: "content" | "host";
|
|
98
140
|
};
|
|
99
141
|
/** The content navigated its frame away; the frame was started again from its content. */
|
|
100
142
|
readonly navigated: undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
* frame's policies, the shell, and the agent manual; the host mounts displays under it.
|
|
4
4
|
*/
|
|
5
5
|
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, type CapabilityArgs, type CapabilityName, type CapabilityResults, type CapabilitySpec, type Gate, type HandledCapability, } from "./config/capabilities.ts";
|
|
6
|
-
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
6
|
+
export type { CapabilityConfig, CapabilityMap, ColorScheme, DisplayMode, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeConfig, ThemeValue, } from "./config/schema.ts";
|
|
7
|
+
export { PASSIVE_KINDS, type PassiveKind } from "./config/schema.ts";
|
|
7
8
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.ts";
|
|
8
9
|
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, type UrlPermission, type UrlSandboxExtra, } from "./config/schema.ts";
|
|
9
10
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.ts";
|
|
10
11
|
export { FRAME_STATUS_METHOD } from "./isolation/types.ts";
|
|
11
12
|
export { LIBRARY_CDNS, presets, type PresetName, type PresetOptions, type UrlPresetOptions, } from "./presets/presets.ts";
|
|
12
13
|
export { compile, type CompiledConfig } from "./compile/compile.ts";
|
|
13
|
-
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
14
|
+
export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, type CspDirective, type EgressKind, } from "./compile/policy.ts";
|
|
14
15
|
export { shellPlan, type ShellPlan } from "./shell/plan.ts";
|
|
15
16
|
export type { ShellDocumentOptions } from "./shell/document.ts";
|
|
16
17
|
export { generateGuide, generateToolDescription, type BuiltInTopic, type GuideTopic, } from "./guide/guide.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
// The config and its vocabulary.
|
|
6
6
|
export { CAPABILITIES, CAPABILITY_NAMES, MAX_COPY_CHARS, capabilitySpec, isCapabilityName, } from "./config/capabilities.js";
|
|
7
|
+
export { PASSIVE_KINDS } from "./config/schema.js";
|
|
7
8
|
export { BUILT_IN_TOPICS, ConfigError, isOriginSource, originSourceProblem, validateConfig, } from "./config/validate.js";
|
|
8
9
|
export { URL_PERMISSIONS, URL_SANDBOX_EXTRAS, } from "./config/schema.js";
|
|
9
10
|
export { hostOriginProblem, isHostOrigin } from "./isolation/served.js";
|
|
@@ -12,7 +13,7 @@ export { FRAME_STATUS_METHOD } from "./isolation/types.js";
|
|
|
12
13
|
export { LIBRARY_CDNS, presets, } from "./presets/presets.js";
|
|
13
14
|
// The compiler.
|
|
14
15
|
export { compile } from "./compile/compile.js";
|
|
15
|
-
export { allEgressOrigins, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, } from "./compile/policy.js";
|
|
16
|
+
export { allEgressOrigins, anyHttps, declaredCapabilities, effectiveGates, egressOrigins, embedderCsp, embedderFrameSourcesOf, } from "./compile/policy.js";
|
|
16
17
|
export { shellPlan } from "./shell/plan.js";
|
|
17
18
|
export { generateGuide, generateToolDescription, } from "./guide/guide.js";
|
|
18
19
|
export { GUIDE_EXAMPLES } from "./guide/examples.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
-
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
|
|
2
|
+
import type { CapabilityConfig, EgressConfig, EmbedderConfig, GuideConfig, IsolationKind, LayoutConfig, Limits, PortholeConfig, StorageConfig, ThemeValue, UrlPermission, UrlSandboxExtra } from "../config/schema.ts";
|
|
3
3
|
/**
|
|
4
4
|
* The preset policy layer (P001): named configs with safe defaults. Every preset starts with
|
|
5
5
|
* egress closed and only the capabilities that carry nothing out of the frame (its size, its
|
|
@@ -30,6 +30,8 @@ export interface PresetOptions {
|
|
|
30
30
|
readonly isolation?: Exclude<IsolationKind, "external">;
|
|
31
31
|
readonly contentOrigins?: readonly string[];
|
|
32
32
|
readonly embedder?: EmbedderConfig;
|
|
33
|
+
/** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
|
|
34
|
+
readonly storage?: StorageConfig;
|
|
33
35
|
}
|
|
34
36
|
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
35
37
|
export interface UrlPresetOptions {
|
package/dist/presets/presets.js
CHANGED
|
@@ -60,6 +60,10 @@ const mergeEgress = (base, extra) => {
|
|
|
60
60
|
merged[kind] = origins;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
const passive = [...new Set([...(base.anyHttps ?? []), ...(extra?.anyHttps ?? [])])];
|
|
64
|
+
if (passive.length > 0) {
|
|
65
|
+
merged.anyHttps = passive;
|
|
66
|
+
}
|
|
63
67
|
return merged;
|
|
64
68
|
};
|
|
65
69
|
/** The default variables, with the host's changes and additions, less those it dropped (null). */
|
|
@@ -102,6 +106,7 @@ function build(mode, defaults, options) {
|
|
|
102
106
|
guide: options.guide ?? {},
|
|
103
107
|
contentOrigins: options.contentOrigins ?? [],
|
|
104
108
|
embedder: options.embedder ?? {},
|
|
109
|
+
...(options.storage === undefined ? {} : { storage: options.storage }),
|
|
105
110
|
});
|
|
106
111
|
}
|
|
107
112
|
export const presets = {
|
package/dist/react/frame.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate
|
|
|
3
3
|
import type { CapabilityHandlers, Confirm, Refusal } from "../bridge/types.ts";
|
|
4
4
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
5
5
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
6
|
-
import type { FrameOutcome } from "../host/types.ts";
|
|
6
|
+
import type { FrameOutcome, HostErrorKind } from "../host/types.ts";
|
|
7
7
|
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
8
8
|
/** The height a display holds before it has reported its own. */
|
|
9
9
|
export declare const PLACEHOLDER_HEIGHT_PX = 96;
|
|
@@ -24,6 +24,11 @@ export interface PortholeProviderProps {
|
|
|
24
24
|
readonly dialogClassName?: string;
|
|
25
25
|
/** A class for the default copy toast, to restyle it. */
|
|
26
26
|
readonly toastClassName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
|
|
29
|
+
* policy, so their shell carries it (P054).
|
|
30
|
+
*/
|
|
31
|
+
readonly nonce?: string;
|
|
27
32
|
readonly children?: ReactNode;
|
|
28
33
|
}
|
|
29
34
|
export declare function PortholeProvider(props: PortholeProviderProps): ReactElement;
|
|
@@ -60,12 +65,31 @@ export interface PortholeFrameProps {
|
|
|
60
65
|
readonly generation?: number;
|
|
61
66
|
/** Run at once, without waiting to near the screen (it still counts toward the page's most). */
|
|
62
67
|
readonly eager?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
|
|
70
|
+
* growing with the content: an artifact viewer's page. A url-mode app always fills.
|
|
71
|
+
*/
|
|
72
|
+
readonly fill?: boolean;
|
|
63
73
|
readonly onInitialized?: () => void;
|
|
64
74
|
readonly onResize?: (size: {
|
|
65
75
|
readonly height: number;
|
|
66
76
|
readonly width?: number;
|
|
67
77
|
}) => void;
|
|
68
|
-
|
|
78
|
+
/**
|
|
79
|
+
* What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
|
|
80
|
+
* away): the agent's to hear and fix.
|
|
81
|
+
*/
|
|
82
|
+
readonly onContentError?: (message: string) => void;
|
|
83
|
+
/**
|
|
84
|
+
* What failed on the host's side: its locator (`locate`), a display that couldn't be made
|
|
85
|
+
* (`mount`), an annotation call refused (`annotations`). Never the agent's.
|
|
86
|
+
*/
|
|
87
|
+
readonly onHostError?: (error: {
|
|
88
|
+
readonly message: string;
|
|
89
|
+
readonly kind: HostErrorKind;
|
|
90
|
+
}) => void;
|
|
91
|
+
/** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
|
|
92
|
+
readonly onError?: (message: string, source: "content" | "host") => void;
|
|
69
93
|
/** The content navigated its frame away; it was started again (or stopped, after a few). */
|
|
70
94
|
readonly onNavigated?: () => void;
|
|
71
95
|
readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
|
|
@@ -94,8 +118,14 @@ export interface PortholeFrameProps {
|
|
|
94
118
|
readonly annotating?: AnnotateMode | null;
|
|
95
119
|
/** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
|
|
96
120
|
readonly annotations?: readonly AnnotationMark[];
|
|
97
|
-
/**
|
|
98
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The mark to bring into view and make the active one; each new id focuses it. To focus the
|
|
123
|
+
* same mark again (a second click on its comment), give `{ id, key }` with a new key.
|
|
124
|
+
*/
|
|
125
|
+
readonly focusedAnnotation?: string | {
|
|
126
|
+
readonly id: string;
|
|
127
|
+
readonly key: string | number;
|
|
128
|
+
} | null;
|
|
99
129
|
/**
|
|
100
130
|
* What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
|
|
101
131
|
* pick, where marks are. The content's word: UI hints, never permissions.
|
package/dist/react/frame.js
CHANGED
|
@@ -39,15 +39,27 @@ export function PortholeProvider(props) {
|
|
|
39
39
|
return copied;
|
|
40
40
|
}, []);
|
|
41
41
|
const closeNotice = useCallback(() => setNotice(null), []);
|
|
42
|
-
|
|
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;
|
|
43
54
|
const value = useMemo(() => ({
|
|
44
55
|
live,
|
|
45
56
|
heights,
|
|
46
57
|
confirm,
|
|
47
58
|
copyText,
|
|
48
59
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
49
|
-
...(
|
|
50
|
-
|
|
60
|
+
...(locates ? { locate } : {}),
|
|
61
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
62
|
+
}), [live, heights, confirm, copyText, hostInfo, locates, locate, nonce]);
|
|
51
63
|
const current = queue[0] ?? null;
|
|
52
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) => {
|
|
53
65
|
// A yes runs the effect inside this very click (a window it opens is the person's).
|
|
@@ -134,6 +146,8 @@ export function PortholeFrame(props) {
|
|
|
134
146
|
return (_jsx("div", { ref: box, className: props.className, "data-porthole-display": props.displayId, "data-porthole-state": state, style: {
|
|
135
147
|
position: "relative",
|
|
136
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%" } : {}),
|
|
137
151
|
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
138
152
|
...props.style,
|
|
139
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 })) }));
|
|
@@ -145,8 +159,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
145
159
|
const [ready, setReady] = useState(false);
|
|
146
160
|
// The height this display started at: later reports don't make it start again.
|
|
147
161
|
const [startHeight] = useState(initialHeightPx);
|
|
148
|
-
const {
|
|
149
|
-
|
|
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;
|
|
150
167
|
// The latest props, read when the display calls back (never during render).
|
|
151
168
|
const latest = useEffectEvent(() => frame);
|
|
152
169
|
const notify = useEffectEvent((event, detail) => {
|
|
@@ -161,9 +178,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
161
178
|
frame.onResize?.(size);
|
|
162
179
|
return;
|
|
163
180
|
}
|
|
164
|
-
case "
|
|
165
|
-
frame.
|
|
181
|
+
case "contentError":
|
|
182
|
+
frame.onContentError?.(detail.message);
|
|
183
|
+
return;
|
|
184
|
+
case "hostError":
|
|
185
|
+
frame.onHostError?.(detail);
|
|
166
186
|
return;
|
|
187
|
+
case "error": {
|
|
188
|
+
const { message, source } = detail;
|
|
189
|
+
frame.onError?.(message, source);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
167
192
|
case "navigated":
|
|
168
193
|
frame.onNavigated?.();
|
|
169
194
|
return;
|
|
@@ -220,7 +245,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
220
245
|
: {}),
|
|
221
246
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
222
247
|
...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
|
|
223
|
-
...(page.
|
|
248
|
+
...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
|
|
249
|
+
...(page.nonce === undefined ? {} : { nonce: page.nonce }),
|
|
224
250
|
}).mount(container, {
|
|
225
251
|
displayId: first.displayId,
|
|
226
252
|
title: first.title,
|
|
@@ -228,10 +254,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
228
254
|
initialHeightPx: startHeight,
|
|
229
255
|
...(view === undefined ? {} : { document: view }),
|
|
230
256
|
...(instance === undefined ? {} : { instance }),
|
|
257
|
+
...(fills ? { fill: true } : {}),
|
|
231
258
|
});
|
|
232
259
|
}
|
|
233
260
|
catch (error) {
|
|
234
|
-
first
|
|
261
|
+
hostFailed(first, error, "mount");
|
|
235
262
|
return;
|
|
236
263
|
}
|
|
237
264
|
handle.current = display;
|
|
@@ -244,6 +271,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
244
271
|
"muted",
|
|
245
272
|
"outcome",
|
|
246
273
|
"annotation",
|
|
274
|
+
"contentError",
|
|
275
|
+
"hostError",
|
|
247
276
|
];
|
|
248
277
|
for (const event of events) {
|
|
249
278
|
display.on(event, (detail) => notify(event, detail));
|
|
@@ -260,7 +289,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
260
289
|
};
|
|
261
290
|
// A new display only for a new config, document, instance or page (or generation, by key);
|
|
262
291
|
// content, theme, handlers, id and title are fed to the running one.
|
|
263
|
-
}, [config, view, instance, page, startHeight]);
|
|
292
|
+
}, [config, view, instance, page, startHeight, fills]);
|
|
264
293
|
useEffect(() => {
|
|
265
294
|
if (content !== undefined) {
|
|
266
295
|
handle.current?.setContent(content, { partial: partial === true });
|
|
@@ -268,7 +297,15 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
268
297
|
}, [content, partial]);
|
|
269
298
|
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
270
299
|
const annotates = config.capabilities.annotate !== undefined;
|
|
271
|
-
const { annotating
|
|
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);
|
|
272
309
|
const mode = annotating === undefined || annotating === null
|
|
273
310
|
? ""
|
|
274
311
|
: JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
|
|
@@ -290,19 +327,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
290
327
|
}, [annotates, annotations]);
|
|
291
328
|
useEffect(() => {
|
|
292
329
|
const display = handle.current;
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
const id = focusOf(latest().focusedAnnotation);
|
|
331
|
+
if (annotates && display !== null && focusToken !== "" && id !== null) {
|
|
332
|
+
attempt(() => display.annotations.focus(id), latest());
|
|
295
333
|
}
|
|
296
|
-
}, [annotates,
|
|
334
|
+
}, [annotates, focusToken]);
|
|
335
|
+
// A flip or a restyle after the display was made: its first scheme and values went with it.
|
|
336
|
+
const [mountScheme] = useState(scheme);
|
|
337
|
+
const flipped = useRef(false);
|
|
297
338
|
useEffect(() => {
|
|
298
|
-
|
|
299
|
-
|
|
339
|
+
if (flipped.current || scheme !== mountScheme) {
|
|
340
|
+
flipped.current = true;
|
|
341
|
+
handle.current?.setTheme(scheme);
|
|
342
|
+
}
|
|
343
|
+
}, [scheme, mountScheme]);
|
|
300
344
|
const { themeKey } = frame;
|
|
345
|
+
const [mountThemeKey] = useState(themeKey);
|
|
301
346
|
useEffect(() => {
|
|
302
|
-
if (themeKey !== undefined) {
|
|
347
|
+
if (themeKey !== undefined && themeKey !== mountThemeKey) {
|
|
303
348
|
handle.current?.refreshTheme();
|
|
304
349
|
}
|
|
305
|
-
}, [themeKey]);
|
|
350
|
+
}, [themeKey, mountThemeKey]);
|
|
306
351
|
return (_jsxs("div", { "data-porthole-ready": String(ready), style: {
|
|
307
352
|
position: "relative",
|
|
308
353
|
// A url-mode app fills the frame's box (give PortholeFrame a height); the rest size to content.
|
|
@@ -310,13 +355,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }) {
|
|
|
310
355
|
...(ready ? {} : { minHeight: Math.max(startHeight, PLACEHOLDER_HEIGHT_PX) }),
|
|
311
356
|
}, 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 })] }));
|
|
312
357
|
}
|
|
313
|
-
/**
|
|
358
|
+
/** Tells the frame's props what failed on the host's side. */
|
|
359
|
+
function hostFailed(frame, error, kind) {
|
|
360
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
361
|
+
frame.onHostError?.({ message, kind });
|
|
362
|
+
frame.onError?.(message, "host");
|
|
363
|
+
}
|
|
364
|
+
/** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
|
|
314
365
|
function attempt(run, frame) {
|
|
315
366
|
try {
|
|
316
367
|
run();
|
|
317
368
|
}
|
|
318
369
|
catch (error) {
|
|
319
|
-
frame
|
|
370
|
+
hostFailed(frame, error, "annotations");
|
|
320
371
|
}
|
|
321
372
|
}
|
|
322
373
|
/** A new display's annotations, from the props as they are. */
|
|
@@ -325,11 +376,29 @@ function applyAnnotations(display, frame) {
|
|
|
325
376
|
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
326
377
|
display.annotations.show(frame.annotations);
|
|
327
378
|
}
|
|
328
|
-
|
|
329
|
-
|
|
379
|
+
const focused = focusOf(frame.focusedAnnotation);
|
|
380
|
+
if (focused !== null) {
|
|
381
|
+
display.annotations.focus(focused);
|
|
330
382
|
}
|
|
331
383
|
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
332
384
|
display.annotations.start(frame.annotating);
|
|
333
385
|
}
|
|
334
386
|
}, frame);
|
|
335
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
|
|
390
|
+
* render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
|
|
391
|
+
* only when it really changes.
|
|
392
|
+
*/
|
|
393
|
+
function useByValue(value) {
|
|
394
|
+
const key = value === undefined ? "" : JSON.stringify(value);
|
|
395
|
+
return useMemo(() => (key === "" ? undefined : JSON.parse(key)), [key]);
|
|
396
|
+
}
|
|
397
|
+
/** The id `focusedAnnotation` names, if any. */
|
|
398
|
+
function focusOf(value) {
|
|
399
|
+
return typeof value === "string"
|
|
400
|
+
? value
|
|
401
|
+
: value === null || value === undefined
|
|
402
|
+
? null
|
|
403
|
+
: value.id;
|
|
404
|
+
}
|
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>`;
|