@botiverse/porthole 0.1.3 → 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/compile/policy.d.ts +6 -0
- package/dist/compile/policy.js +9 -1
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -0
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +3 -1
- package/dist/host/create-host.js +24 -7
- package/dist/host/types.d.ts +44 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/react/frame.d.ts +34 -4
- package/dist/react/frame.js +80 -18
- 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.js +31 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +8 -2
- package/dist/shell/runtime.js +33 -1
- package/package.json +1 -1
- package/src/compile/policy.ts +10 -1
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +3 -1
- package/src/host/create-host.ts +26 -7
- package/src/host/types.ts +39 -2
- package/src/index.ts +2 -0
- package/src/presets/presets.ts +4 -0
- package/src/react/frame.tsx +125 -23
- 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 +31 -0
- package/src/shell/document.ts +15 -2
- package/src/shell/runtime.ts +33 -1
package/src/config/schema.ts
CHANGED
|
@@ -175,4 +175,16 @@ export interface PortholeConfig {
|
|
|
175
175
|
readonly sandbox?: readonly UrlSandboxExtra[];
|
|
176
176
|
/** Url mode only: features delegated to the frame (`URL_PERMISSIONS`); none by default. */
|
|
177
177
|
readonly permissions?: readonly UrlPermission[];
|
|
178
|
+
/**
|
|
179
|
+
* Domain isolation: how far a display's storage reaches, for the manual. Storage is per origin,
|
|
180
|
+
* and the host picks each display's origin (`InstanceRef.scope`); a host that gives several
|
|
181
|
+
* displays one scope (all of a conversation's widgets) names it here (`{ shared:
|
|
182
|
+
* "conversation" }`), so the manual says storage is shared across it and keys need a prefix.
|
|
183
|
+
*/
|
|
184
|
+
readonly storage?: StorageConfig;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface StorageConfig {
|
|
188
|
+
/** What the displays sharing one origin have in common, as a noun ("conversation"). */
|
|
189
|
+
readonly shared?: string;
|
|
178
190
|
}
|
package/src/config/validate.ts
CHANGED
|
@@ -131,6 +131,7 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
131
131
|
"embedder",
|
|
132
132
|
"sandbox",
|
|
133
133
|
"permissions",
|
|
134
|
+
"storage",
|
|
134
135
|
],
|
|
135
136
|
"config",
|
|
136
137
|
);
|
|
@@ -315,6 +316,20 @@ export function validateConfig(input: unknown): PortholeConfig {
|
|
|
315
316
|
});
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
if (config.storage !== undefined) {
|
|
320
|
+
const storage = object(config.storage, "config.storage");
|
|
321
|
+
only(storage, ["shared"], "config.storage");
|
|
322
|
+
if (config.isolation !== "domain") {
|
|
323
|
+
fail("config.storage", "only domain isolation keeps storage across displays");
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
storage.shared !== undefined &&
|
|
327
|
+
(typeof storage.shared !== "string" || !/^[a-z][a-z -]{0,39}$/u.test(storage.shared))
|
|
328
|
+
) {
|
|
329
|
+
fail("config.storage.shared", 'must be a short lowercase noun ("conversation")');
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
318
333
|
if (config.permissions !== undefined) {
|
|
319
334
|
if (config.mode !== "url") {
|
|
320
335
|
fail("config.permissions", "only url mode may be delegated features");
|
|
@@ -38,8 +38,17 @@ export interface ServedConformanceHost {
|
|
|
38
38
|
* Shows `instance` in `page` as the host does: a fresh ticket from its backend, framed under
|
|
39
39
|
* the config's sandbox, in an embedding page that sends the config's `embedderCsp`. The same page
|
|
40
40
|
* may show several, one after another.
|
|
41
|
+
*
|
|
42
|
+
* For a served shell the suite gives `options.content`, a line of HTML: a host that speaks to
|
|
43
|
+
* the shell (`createHost`, the React binding) sends it as the display's content, since a shell
|
|
44
|
+
* draws nothing (and reports no height) until it has content, and the cases click inside it. A
|
|
45
|
+
* host whose frame has a height of its own may ignore it.
|
|
41
46
|
*/
|
|
42
|
-
show(
|
|
47
|
+
show(
|
|
48
|
+
page: Page,
|
|
49
|
+
instance: InstanceRef,
|
|
50
|
+
options?: { readonly content?: string },
|
|
51
|
+
): Promise<ServedDisplay>;
|
|
43
52
|
/** A fresh ticketed location for `instance`, as the host's backend issues it. */
|
|
44
53
|
locate(instance: InstanceRef): Promise<FrameLocation>;
|
|
45
54
|
/** Navigates `page` to a page on an origin that is not one of the host's, framing `url`. */
|
|
@@ -74,6 +83,9 @@ type Fixtures = { page: Page; browserName: string };
|
|
|
74
83
|
|
|
75
84
|
const SETTLE_MS = 800;
|
|
76
85
|
|
|
86
|
+
/** What a served shell is given to draw (`ServedConformanceHost.show`'s `content`). */
|
|
87
|
+
const SHELL_CONTENT = '<p id="porthole-probe-content">Porthole conformance probe</p>';
|
|
88
|
+
|
|
77
89
|
/** What the frame can see of its host: everything here must be closed to it. */
|
|
78
90
|
async function reachIntoHost(frame: Frame): Promise<Record<string, string>> {
|
|
79
91
|
return frame.evaluate(() => {
|
|
@@ -185,13 +197,16 @@ export function servedTests(
|
|
|
185
197
|
["domain", (scope: string) => host.shell(scope)],
|
|
186
198
|
["url", (scope: string) => host.app(scope)],
|
|
187
199
|
] as const;
|
|
200
|
+
// A served shell draws (and has a height to click in) once its host gives it content.
|
|
201
|
+
const showMode = (page: Page, mode: "domain" | "url", instance: InstanceRef) =>
|
|
202
|
+
host.show(page, instance, mode === "domain" ? { content: SHELL_CONTENT } : {});
|
|
188
203
|
|
|
189
204
|
for (const [mode, instance] of modes) {
|
|
190
205
|
define(
|
|
191
206
|
`served.${mode}.origin`,
|
|
192
207
|
"content runs on its own origin, never the host's",
|
|
193
208
|
async ({ page }) => {
|
|
194
|
-
const display = await
|
|
209
|
+
const display = await showMode(page, mode, instance(`${mode}-origin`));
|
|
195
210
|
const frame = await display.frame();
|
|
196
211
|
const origin = await frame.evaluate(() => window.origin);
|
|
197
212
|
expect(origin).toBe(display.origin);
|
|
@@ -203,7 +218,7 @@ export function servedTests(
|
|
|
203
218
|
`served.${mode}.no-reach`,
|
|
204
219
|
"content can't reach the host's DOM, storage, cookies or URL",
|
|
205
220
|
async ({ page }) => {
|
|
206
|
-
const display = await
|
|
221
|
+
const display = await showMode(page, mode, instance(`${mode}-reach`));
|
|
207
222
|
await plantHostSecrets(page);
|
|
208
223
|
expect(await reachIntoHost(await display.frame())).toEqual(CLOSED);
|
|
209
224
|
},
|
|
@@ -213,7 +228,7 @@ export function servedTests(
|
|
|
213
228
|
`served.${mode}.no-top-navigation`,
|
|
214
229
|
"content can't navigate the host's page, even after a click",
|
|
215
230
|
async ({ page }) => {
|
|
216
|
-
const display = await
|
|
231
|
+
const display = await showMode(page, mode, instance(`${mode}-top`));
|
|
217
232
|
const frame = await display.frame();
|
|
218
233
|
const before = page.url();
|
|
219
234
|
const target = await host.canary(`${mode}-top`);
|
|
@@ -246,7 +261,7 @@ export function servedTests(
|
|
|
246
261
|
`served.${mode}.no-popups`,
|
|
247
262
|
"content can't open a window, even after a click",
|
|
248
263
|
async ({ page }) => {
|
|
249
|
-
const display = await
|
|
264
|
+
const display = await showMode(page, mode, instance(`${mode}-popup`));
|
|
250
265
|
const frame = await display.frame();
|
|
251
266
|
const target = await host.canary(`${mode}-popup`);
|
|
252
267
|
const opened: Page[] = [];
|
|
@@ -283,13 +298,13 @@ export function servedTests(
|
|
|
283
298
|
"served.domain.storage-scope",
|
|
284
299
|
"storage persists within a scope and is never shared across scopes",
|
|
285
300
|
async ({ page }) => {
|
|
286
|
-
const first = await host.show(page, host.shell("scope-a"));
|
|
301
|
+
const first = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
287
302
|
await (await first.frame()).evaluate(() => localStorage.setItem("kept", "a"));
|
|
288
303
|
await first.destroy();
|
|
289
|
-
const again = await host.show(page, host.shell("scope-a"));
|
|
304
|
+
const again = await host.show(page, host.shell("scope-a"), { content: SHELL_CONTENT });
|
|
290
305
|
expect(await (await again.frame()).evaluate(() => localStorage.getItem("kept"))).toBe("a");
|
|
291
306
|
expect(again.origin).toBe(first.origin);
|
|
292
|
-
const other = await host.show(page, host.shell("scope-b"));
|
|
307
|
+
const other = await host.show(page, host.shell("scope-b"), { content: SHELL_CONTENT });
|
|
293
308
|
expect(other.origin).not.toBe(first.origin);
|
|
294
309
|
expect(await (await other.frame()).evaluate(() => localStorage.getItem("kept"))).toBeNull();
|
|
295
310
|
},
|
|
@@ -367,7 +382,7 @@ export function servedTests(
|
|
|
367
382
|
"served.domain.egress",
|
|
368
383
|
"the CSP in the document's headers closes egress",
|
|
369
384
|
async ({ page }) => {
|
|
370
|
-
const display = await host.show(page, host.shell("egress"));
|
|
385
|
+
const display = await host.show(page, host.shell("egress"), { content: SHELL_CONTENT });
|
|
371
386
|
const frame = await display.frame();
|
|
372
387
|
const fetched = await frame.evaluate(
|
|
373
388
|
async (url) => {
|
package/src/guide/guide.ts
CHANGED
|
@@ -368,7 +368,9 @@ function interactive(c: Context): string {
|
|
|
368
368
|
const state =
|
|
369
369
|
config.isolation === "opaque"
|
|
370
370
|
? `- State isn't saved: localStorage works but lasts only while the ${noun} is on screen, and a reload or another device starts fresh. Keep what matters in the ${noun}'s code.`
|
|
371
|
-
:
|
|
371
|
+
: config.storage?.shared === undefined
|
|
372
|
+
? `- localStorage is kept for this ${noun} in this browser; another device starts fresh.`
|
|
373
|
+
: `- localStorage is shared by every ${noun} in this ${config.storage.shared}, in this browser (another device starts fresh): prefix your keys with a name of this ${noun}'s own ("budget:filters", not "filters"), or ${noun}s overwrite each other.`;
|
|
372
374
|
const none =
|
|
373
375
|
entries.length === 0
|
|
374
376
|
? `- It can only draw: it has no way to send anything to you or the app.`
|
package/src/host/create-host.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { shellAdapter } from "../protocol/shell-adapter.ts";
|
|
|
26
26
|
import { resolveTheme } from "../theme/variables.ts";
|
|
27
27
|
import type {
|
|
28
28
|
AnnotationControls,
|
|
29
|
+
HostErrorKind,
|
|
29
30
|
DisplayEvents,
|
|
30
31
|
DisplayHandle,
|
|
31
32
|
HostOptions,
|
|
@@ -96,6 +97,16 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
96
97
|
(listener as (detail: DisplayEvents[E]) => void)(detail);
|
|
97
98
|
}
|
|
98
99
|
};
|
|
100
|
+
// What the content did wrong (the agent's to hear and fix), and what failed on the host's
|
|
101
|
+
// side (its own to handle), each on its own event and on `error` with its source.
|
|
102
|
+
const contentError = (message: string) => {
|
|
103
|
+
emit("contentError", { message });
|
|
104
|
+
emit("error", { message, source: "content" });
|
|
105
|
+
};
|
|
106
|
+
const hostError = (message: string, kind: HostErrorKind) => {
|
|
107
|
+
emit("hostError", { message, kind });
|
|
108
|
+
emit("error", { message, source: "host" });
|
|
109
|
+
};
|
|
99
110
|
|
|
100
111
|
let scheme: ColorScheme = mount.scheme;
|
|
101
112
|
let height = mount.initialHeightPx ?? 0;
|
|
@@ -277,7 +288,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
277
288
|
return;
|
|
278
289
|
}
|
|
279
290
|
case "error":
|
|
280
|
-
|
|
291
|
+
contentError(event.message);
|
|
281
292
|
return;
|
|
282
293
|
case "refused":
|
|
283
294
|
emit("refused", { capability: event.capability, refusal: event.refusal });
|
|
@@ -311,8 +322,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
311
322
|
allow: compiled.allow,
|
|
312
323
|
referrerPolicy: compiled.referrerPolicy,
|
|
313
324
|
colorScheme: scheme,
|
|
314
|
-
// An app reports no size: its frame fills the host's container, at least the minimum
|
|
315
|
-
|
|
325
|
+
// An app reports no size: its frame fills the host's container, at least the minimum;
|
|
326
|
+
// so does any display the host mounts to fill (its content scrolls inside).
|
|
327
|
+
...(speaks && mount.fill !== true
|
|
316
328
|
? { initialHeightPx: height }
|
|
317
329
|
: { fill: true, initialHeightPx: config.limits.minHeightPx }),
|
|
318
330
|
});
|
|
@@ -377,9 +389,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
377
389
|
run();
|
|
378
390
|
} else {
|
|
379
391
|
stop();
|
|
380
|
-
|
|
381
|
-
message: "The content kept navigating its frame away; it was stopped.",
|
|
382
|
-
});
|
|
392
|
+
contentError("The content kept navigating its frame away; it was stopped.");
|
|
383
393
|
}
|
|
384
394
|
});
|
|
385
395
|
};
|
|
@@ -396,6 +406,7 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
396
406
|
html: compiled.shellDocument({
|
|
397
407
|
origins: [container.ownerDocument.defaultView?.location.origin ?? ""],
|
|
398
408
|
title: mount.title,
|
|
409
|
+
...nonceOf(options.nonce),
|
|
399
410
|
...(mount.document === undefined ? {} : { document: mount.document }),
|
|
400
411
|
}),
|
|
401
412
|
});
|
|
@@ -418,7 +429,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
418
429
|
},
|
|
419
430
|
(error: unknown) => {
|
|
420
431
|
if (mine === generation && !controller.signal.aborted) {
|
|
421
|
-
|
|
432
|
+
// The host's own failure (its backend down, access gone): never the content's.
|
|
433
|
+
hostError(error instanceof Error ? error.message : String(error), "locate");
|
|
434
|
+
emit("outcome", { state: "blocked", reason: "locate" });
|
|
422
435
|
}
|
|
423
436
|
},
|
|
424
437
|
);
|
|
@@ -488,3 +501,9 @@ export function createHost<C extends AdapterConnection = ShellConnection>(
|
|
|
488
501
|
},
|
|
489
502
|
};
|
|
490
503
|
}
|
|
504
|
+
|
|
505
|
+
/** The host's nonce option, read now (`HostOptions.nonce`). */
|
|
506
|
+
function nonceOf(nonce: HostOptions["nonce"]): { nonce?: string } {
|
|
507
|
+
const value = typeof nonce === "function" ? nonce() : nonce;
|
|
508
|
+
return value === undefined || value === "" ? {} : { nonce: value };
|
|
509
|
+
}
|
package/src/host/types.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface HostOptions<C extends AdapterConnection = ShellConnection> {
|
|
|
34
34
|
readonly platform?: PlatformHost;
|
|
35
35
|
/** Required for domain and external isolation: where an instance's frame loads from. */
|
|
36
36
|
readonly locator?: InstanceLocator;
|
|
37
|
+
/**
|
|
38
|
+
* The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): an opaque frame
|
|
39
|
+
* (`srcdoc`) inherits the page's policy, so the shell and the content's scripts carry it. A
|
|
40
|
+
* function is read each time a frame starts (a page whose nonce changes). Served frames inherit
|
|
41
|
+
* nothing and need none.
|
|
42
|
+
*/
|
|
43
|
+
readonly nonce?: string | (() => string | undefined);
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface MountOptions {
|
|
@@ -50,8 +57,23 @@ 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
|
}
|
|
54
67
|
|
|
68
|
+
/** What failed on the host's side (`hostError`). */
|
|
69
|
+
export type HostErrorKind =
|
|
70
|
+
/** The locator rejected: the host's backend didn't give a location. */
|
|
71
|
+
| "locate"
|
|
72
|
+
/** A display couldn't be made (the React binding: a bad config, a missing handler). */
|
|
73
|
+
| "mount"
|
|
74
|
+
/** An annotation call was refused (an invalid mark, an unknown id). */
|
|
75
|
+
| "annotations";
|
|
76
|
+
|
|
55
77
|
/** What a display reports, by event name. */
|
|
56
78
|
/** Why a display's frame showed nothing of its content (`FrameOutcome`). */
|
|
57
79
|
export type FrameBlock =
|
|
@@ -65,6 +87,8 @@ export type FrameBlock =
|
|
|
65
87
|
| "upstream"
|
|
66
88
|
/** The host's own check refused it (revoked, no longer a member): `code` says which. */
|
|
67
89
|
| "denied"
|
|
90
|
+
/** The host's locator failed (its backend down, access gone): see the `hostError` event. */
|
|
91
|
+
| "locate"
|
|
68
92
|
| "not-found"
|
|
69
93
|
| "error";
|
|
70
94
|
|
|
@@ -93,8 +117,21 @@ export interface DisplayEvents {
|
|
|
93
117
|
readonly outcome: FrameOutcome;
|
|
94
118
|
/** The content's size, after the config's limits. */
|
|
95
119
|
readonly resize: { readonly height: number; readonly width?: number };
|
|
96
|
-
/**
|
|
97
|
-
|
|
120
|
+
/**
|
|
121
|
+
* What the content did wrong: what it threw, what the sandbox blocked (its own words:
|
|
122
|
+
* untrusted), or that it kept navigating its frame away. The agent's to hear and fix.
|
|
123
|
+
*/
|
|
124
|
+
readonly contentError: { readonly message: string };
|
|
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: { readonly message: string; readonly kind: HostErrorKind };
|
|
130
|
+
/**
|
|
131
|
+
* Both of the above, with their `source`: for a host that listened here before they were
|
|
132
|
+
* split (0.1.3). Prefer `contentError` and `hostError`.
|
|
133
|
+
*/
|
|
134
|
+
readonly error: { readonly message: string; readonly source: "content" | "host" };
|
|
98
135
|
/** The content navigated its frame away; the frame was started again from its content. */
|
|
99
136
|
readonly navigated: undefined;
|
|
100
137
|
/** A call didn't run. */
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export type {
|
|
|
29
29
|
LayoutConfig,
|
|
30
30
|
Limits,
|
|
31
31
|
PortholeConfig,
|
|
32
|
+
StorageConfig,
|
|
32
33
|
ThemeConfig,
|
|
33
34
|
ThemeValue,
|
|
34
35
|
} from "./config/schema.ts";
|
|
@@ -65,6 +66,7 @@ export {
|
|
|
65
66
|
effectiveGates,
|
|
66
67
|
egressOrigins,
|
|
67
68
|
embedderCsp,
|
|
69
|
+
embedderFrameSourcesOf,
|
|
68
70
|
type CspDirective,
|
|
69
71
|
type EgressKind,
|
|
70
72
|
} from "./compile/policy.ts";
|
package/src/presets/presets.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
LayoutConfig,
|
|
10
10
|
Limits,
|
|
11
11
|
PortholeConfig,
|
|
12
|
+
StorageConfig,
|
|
12
13
|
ThemeValue,
|
|
13
14
|
UrlPermission,
|
|
14
15
|
UrlSandboxExtra,
|
|
@@ -45,6 +46,8 @@ export interface PresetOptions {
|
|
|
45
46
|
readonly isolation?: Exclude<IsolationKind, "external">;
|
|
46
47
|
readonly contentOrigins?: readonly string[];
|
|
47
48
|
readonly embedder?: EmbedderConfig;
|
|
49
|
+
/** Domain isolation: what displays sharing an origin share (`StorageConfig`), for the manual. */
|
|
50
|
+
readonly storage?: StorageConfig;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/** The url preset's options: where the app is served, and the embedding page's extra frames. */
|
|
@@ -174,6 +177,7 @@ function build(
|
|
|
174
177
|
guide: options.guide ?? {},
|
|
175
178
|
contentOrigins: options.contentOrigins ?? [],
|
|
176
179
|
embedder: options.embedder ?? {},
|
|
180
|
+
...(options.storage === undefined ? {} : { storage: options.storage }),
|
|
177
181
|
});
|
|
178
182
|
}
|
|
179
183
|
|
package/src/react/frame.tsx
CHANGED
|
@@ -29,7 +29,7 @@ import type { CapabilityName } from "../config/capabilities.ts";
|
|
|
29
29
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
30
30
|
import { writeClipboard } from "../host/clipboard.ts";
|
|
31
31
|
import { createHost } from "../host/create-host.ts";
|
|
32
|
-
import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
|
|
32
|
+
import type { DisplayEvents, DisplayHandle, FrameOutcome, HostErrorKind } from "../host/types.ts";
|
|
33
33
|
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
34
34
|
import { ConfirmDialog } from "./dialog.tsx";
|
|
35
35
|
import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
@@ -53,7 +53,9 @@ interface PageState {
|
|
|
53
53
|
readonly heights: Map<string, number>;
|
|
54
54
|
readonly confirm?: Confirm;
|
|
55
55
|
readonly hostInfo?: { readonly name: string; readonly version: string };
|
|
56
|
-
readonly
|
|
56
|
+
readonly nonce?: string;
|
|
57
|
+
/** The provider's locator, read when a frame locates (so an inline one restarts nothing). */
|
|
58
|
+
readonly locate?: InstanceLocator["locate"];
|
|
57
59
|
/** The default `copyText`: copies on the host's page and shows the toast (P052). */
|
|
58
60
|
readonly copyText?: CapabilityHandler<"copyText">;
|
|
59
61
|
}
|
|
@@ -77,6 +79,11 @@ export interface PortholeProviderProps {
|
|
|
77
79
|
readonly dialogClassName?: string;
|
|
78
80
|
/** A class for the default copy toast, to restyle it. */
|
|
79
81
|
readonly toastClassName?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
|
|
84
|
+
* policy, so their shell carries it (P054).
|
|
85
|
+
*/
|
|
86
|
+
readonly nonce?: string;
|
|
80
87
|
readonly children?: ReactNode;
|
|
81
88
|
}
|
|
82
89
|
|
|
@@ -113,7 +120,22 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
113
120
|
return copied;
|
|
114
121
|
}, []);
|
|
115
122
|
const closeNotice = useCallback(() => setNotice(null), []);
|
|
116
|
-
|
|
123
|
+
// Compared by value, and read when used: an inline hostInfo or locator doesn't start every
|
|
124
|
+
// frame again on each render.
|
|
125
|
+
const hostInfo = useByValue(props.hostInfo);
|
|
126
|
+
const ownLocator = useRef(props.locator);
|
|
127
|
+
useLayoutEffect(() => {
|
|
128
|
+
ownLocator.current = props.locator;
|
|
129
|
+
});
|
|
130
|
+
const locate = useCallback<InstanceLocator["locate"]>(
|
|
131
|
+
(instance, signal) =>
|
|
132
|
+
ownLocator.current === undefined
|
|
133
|
+
? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
|
|
134
|
+
: ownLocator.current.locate(instance, signal),
|
|
135
|
+
[],
|
|
136
|
+
);
|
|
137
|
+
const locates = props.locator !== undefined;
|
|
138
|
+
const { nonce } = props;
|
|
117
139
|
const value = useMemo<PageState>(
|
|
118
140
|
() => ({
|
|
119
141
|
live,
|
|
@@ -121,9 +143,10 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
121
143
|
confirm,
|
|
122
144
|
copyText,
|
|
123
145
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
124
|
-
...(
|
|
146
|
+
...(locates ? { locate } : {}),
|
|
147
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
125
148
|
}),
|
|
126
|
-
[live, heights, confirm, copyText, hostInfo,
|
|
149
|
+
[live, heights, confirm, copyText, hostInfo, locates, locate, nonce],
|
|
127
150
|
);
|
|
128
151
|
const current = queue[0] ?? null;
|
|
129
152
|
return (
|
|
@@ -192,9 +215,28 @@ export interface PortholeFrameProps {
|
|
|
192
215
|
readonly generation?: number;
|
|
193
216
|
/** Run at once, without waiting to near the screen (it still counts toward the page's most). */
|
|
194
217
|
readonly eager?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
|
|
220
|
+
* growing with the content: an artifact viewer's page. A url-mode app always fills.
|
|
221
|
+
*/
|
|
222
|
+
readonly fill?: boolean;
|
|
195
223
|
readonly onInitialized?: () => void;
|
|
196
224
|
readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
|
|
197
|
-
|
|
225
|
+
/**
|
|
226
|
+
* What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
|
|
227
|
+
* away): the agent's to hear and fix.
|
|
228
|
+
*/
|
|
229
|
+
readonly onContentError?: (message: string) => void;
|
|
230
|
+
/**
|
|
231
|
+
* What failed on the host's side: its locator (`locate`), a display that couldn't be made
|
|
232
|
+
* (`mount`), an annotation call refused (`annotations`). Never the agent's.
|
|
233
|
+
*/
|
|
234
|
+
readonly onHostError?: (error: {
|
|
235
|
+
readonly message: string;
|
|
236
|
+
readonly kind: HostErrorKind;
|
|
237
|
+
}) => void;
|
|
238
|
+
/** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
|
|
239
|
+
readonly onError?: (message: string, source: "content" | "host") => void;
|
|
198
240
|
/** The content navigated its frame away; it was started again (or stopped, after a few). */
|
|
199
241
|
readonly onNavigated?: () => void;
|
|
200
242
|
readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
|
|
@@ -223,8 +265,14 @@ export interface PortholeFrameProps {
|
|
|
223
265
|
readonly annotating?: AnnotateMode | null;
|
|
224
266
|
/** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
|
|
225
267
|
readonly annotations?: readonly AnnotationMark[];
|
|
226
|
-
/**
|
|
227
|
-
|
|
268
|
+
/**
|
|
269
|
+
* The mark to bring into view and make the active one; each new id focuses it. To focus the
|
|
270
|
+
* same mark again (a second click on its comment), give `{ id, key }` with a new key.
|
|
271
|
+
*/
|
|
272
|
+
readonly focusedAnnotation?:
|
|
273
|
+
| string
|
|
274
|
+
| { readonly id: string; readonly key: string | number }
|
|
275
|
+
| null;
|
|
228
276
|
/**
|
|
229
277
|
* What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
|
|
230
278
|
* pick, where marks are. The content's word: UI hints, never permissions.
|
|
@@ -343,6 +391,8 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
|
|
|
343
391
|
style={{
|
|
344
392
|
position: "relative",
|
|
345
393
|
width: "100%",
|
|
394
|
+
// A display that fills takes its parent's height (the host sizes the parent, or this box).
|
|
395
|
+
...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
|
|
346
396
|
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
347
397
|
...props.style,
|
|
348
398
|
}}
|
|
@@ -380,8 +430,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
380
430
|
const [ready, setReady] = useState(false);
|
|
381
431
|
// The height this display started at: later reports don't make it start again.
|
|
382
432
|
const [startHeight] = useState(initialHeightPx);
|
|
383
|
-
const {
|
|
384
|
-
|
|
433
|
+
const { document: view, content, partial, scheme } = frame;
|
|
434
|
+
// By value: a config or instance made inline (each render) is the same display.
|
|
435
|
+
const config = useByValue(frame.config);
|
|
436
|
+
const instance = useByValue(frame.instance);
|
|
437
|
+
const fills = config.mode === "url" || frame.fill === true;
|
|
385
438
|
|
|
386
439
|
// The latest props, read when the display calls back (never during render).
|
|
387
440
|
const latest = useEffectEvent(() => frame);
|
|
@@ -398,9 +451,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
398
451
|
frame.onResize?.(size);
|
|
399
452
|
return;
|
|
400
453
|
}
|
|
401
|
-
case "
|
|
402
|
-
frame.
|
|
454
|
+
case "contentError":
|
|
455
|
+
frame.onContentError?.((detail as DisplayEvents["contentError"]).message);
|
|
456
|
+
return;
|
|
457
|
+
case "hostError":
|
|
458
|
+
frame.onHostError?.(detail as DisplayEvents["hostError"]);
|
|
403
459
|
return;
|
|
460
|
+
case "error": {
|
|
461
|
+
const { message, source } = detail as DisplayEvents["error"];
|
|
462
|
+
frame.onError?.(message, source);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
404
465
|
case "navigated":
|
|
405
466
|
frame.onNavigated?.();
|
|
406
467
|
return;
|
|
@@ -468,7 +529,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
468
529
|
: {}),
|
|
469
530
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
470
531
|
...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
|
|
471
|
-
...(page.
|
|
532
|
+
...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
|
|
533
|
+
...(page.nonce === undefined ? {} : { nonce: page.nonce }),
|
|
472
534
|
}).mount(container, {
|
|
473
535
|
displayId: first.displayId,
|
|
474
536
|
title: first.title,
|
|
@@ -476,9 +538,10 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
476
538
|
initialHeightPx: startHeight,
|
|
477
539
|
...(view === undefined ? {} : { document: view }),
|
|
478
540
|
...(instance === undefined ? {} : { instance }),
|
|
541
|
+
...(fills ? { fill: true } : {}),
|
|
479
542
|
});
|
|
480
543
|
} catch (error) {
|
|
481
|
-
first
|
|
544
|
+
hostFailed(first, error, "mount");
|
|
482
545
|
return;
|
|
483
546
|
}
|
|
484
547
|
handle.current = display;
|
|
@@ -491,6 +554,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
491
554
|
"muted",
|
|
492
555
|
"outcome",
|
|
493
556
|
"annotation",
|
|
557
|
+
"contentError",
|
|
558
|
+
"hostError",
|
|
494
559
|
];
|
|
495
560
|
for (const event of events) {
|
|
496
561
|
display.on(event, (detail) => notify(event, detail));
|
|
@@ -507,7 +572,7 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
507
572
|
};
|
|
508
573
|
// A new display only for a new config, document, instance or page (or generation, by key);
|
|
509
574
|
// content, theme, handlers, id and title are fed to the running one.
|
|
510
|
-
}, [config, view, instance, page, startHeight]);
|
|
575
|
+
}, [config, view, instance, page, startHeight, fills]);
|
|
511
576
|
|
|
512
577
|
useEffect(() => {
|
|
513
578
|
if (content !== undefined) {
|
|
@@ -516,7 +581,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
516
581
|
}, [content, partial]);
|
|
517
582
|
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
518
583
|
const annotates = config.capabilities.annotate !== undefined;
|
|
519
|
-
const { annotating
|
|
584
|
+
const { annotating } = frame;
|
|
585
|
+
const focusedAnnotation = focusOf(frame.focusedAnnotation);
|
|
586
|
+
const focusToken =
|
|
587
|
+
focusedAnnotation === null
|
|
588
|
+
? ""
|
|
589
|
+
: typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
|
|
590
|
+
? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
|
|
591
|
+
: JSON.stringify([focusedAnnotation]);
|
|
592
|
+
// By value: the same marks given again don't mark them again.
|
|
593
|
+
const annotations = useByValue(frame.annotations);
|
|
520
594
|
const mode =
|
|
521
595
|
annotating === undefined || annotating === null
|
|
522
596
|
? ""
|
|
@@ -543,10 +617,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
543
617
|
}, [annotates, annotations]);
|
|
544
618
|
useEffect(() => {
|
|
545
619
|
const display = handle.current;
|
|
546
|
-
|
|
547
|
-
|
|
620
|
+
const id = focusOf(latest().focusedAnnotation);
|
|
621
|
+
if (annotates && display !== null && focusToken !== "" && id !== null) {
|
|
622
|
+
attempt(() => display.annotations.focus(id), latest());
|
|
548
623
|
}
|
|
549
|
-
}, [annotates,
|
|
624
|
+
}, [annotates, focusToken]);
|
|
550
625
|
useEffect(() => {
|
|
551
626
|
handle.current?.setTheme(scheme);
|
|
552
627
|
}, [scheme]);
|
|
@@ -577,12 +652,19 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
577
652
|
);
|
|
578
653
|
}
|
|
579
654
|
|
|
580
|
-
/**
|
|
655
|
+
/** Tells the frame's props what failed on the host's side. */
|
|
656
|
+
function hostFailed(frame: PortholeFrameProps, error: unknown, kind: HostErrorKind): void {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
frame.onHostError?.({ message, kind });
|
|
659
|
+
frame.onError?.(message, "host");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
|
|
581
663
|
function attempt(run: () => void, frame: PortholeFrameProps): void {
|
|
582
664
|
try {
|
|
583
665
|
run();
|
|
584
666
|
} catch (error) {
|
|
585
|
-
frame
|
|
667
|
+
hostFailed(frame, error, "annotations");
|
|
586
668
|
}
|
|
587
669
|
}
|
|
588
670
|
|
|
@@ -592,11 +674,31 @@ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): vo
|
|
|
592
674
|
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
593
675
|
display.annotations.show(frame.annotations);
|
|
594
676
|
}
|
|
595
|
-
|
|
596
|
-
|
|
677
|
+
const focused = focusOf(frame.focusedAnnotation);
|
|
678
|
+
if (focused !== null) {
|
|
679
|
+
display.annotations.focus(focused);
|
|
597
680
|
}
|
|
598
681
|
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
599
682
|
display.annotations.start(frame.annotating);
|
|
600
683
|
}
|
|
601
684
|
}, frame);
|
|
602
685
|
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
|
|
689
|
+
* render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
|
|
690
|
+
* only when it really changes.
|
|
691
|
+
*/
|
|
692
|
+
function useByValue<T>(value: T): T {
|
|
693
|
+
const key = value === undefined ? "" : JSON.stringify(value);
|
|
694
|
+
return useMemo(() => (key === "" ? undefined : JSON.parse(key)) as T, [key]);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The id `focusedAnnotation` names, if any. */
|
|
698
|
+
function focusOf(value: PortholeFrameProps["focusedAnnotation"]): string | null {
|
|
699
|
+
return typeof value === "string"
|
|
700
|
+
? value
|
|
701
|
+
: value === null || value === undefined
|
|
702
|
+
? null
|
|
703
|
+
: value.id;
|
|
704
|
+
}
|