@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/server/naming.ts
CHANGED
|
@@ -40,3 +40,44 @@ export function prefixedName(prefix: string): (scope: string) => string {
|
|
|
40
40
|
return label;
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
|
|
48
|
+
* (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
|
|
49
|
+
* reserves the label (fail closed: the request goes to the host's routes, never served as ours).
|
|
50
|
+
*/
|
|
51
|
+
export function reservedLabels(
|
|
52
|
+
reserved: readonly string[] | ((label: string) => boolean) | undefined,
|
|
53
|
+
): (label: string) => boolean {
|
|
54
|
+
if (reserved === undefined) {
|
|
55
|
+
return () => false;
|
|
56
|
+
}
|
|
57
|
+
if (typeof reserved === "function") {
|
|
58
|
+
return (label) => {
|
|
59
|
+
try {
|
|
60
|
+
return reserved(label);
|
|
61
|
+
} catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(reserved)) {
|
|
67
|
+
throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
|
|
68
|
+
}
|
|
69
|
+
const whole = new Set<string>();
|
|
70
|
+
const prefixes: string[] = [];
|
|
71
|
+
for (const entry of reserved) {
|
|
72
|
+
if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
|
|
73
|
+
prefixes.push(entry.slice(0, -1));
|
|
74
|
+
} else if (isHostLabel(entry)) {
|
|
75
|
+
whole.add(entry);
|
|
76
|
+
} else {
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
`content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
|
|
83
|
+
}
|
package/src/server/pages.ts
CHANGED
|
@@ -93,6 +93,8 @@ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
|
|
|
93
93
|
"content-security-policy": policy(frameAncestors, connect),
|
|
94
94
|
"referrer-policy": "no-referrer",
|
|
95
95
|
"x-content-type-options": "nosniff",
|
|
96
|
+
// Content origins are behind tickets: nothing on them is for search engines.
|
|
97
|
+
"x-robots-tag": "noindex, nofollow",
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
export function statusPage(
|
package/src/server/server.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import type { FrameLocation, InstanceRef, ServedDocument } from "../isolation/types.ts";
|
|
10
10
|
import { sandboxProxyDocument } from "../mcp/proxy.ts";
|
|
11
11
|
import { createKeys, newTicket, ticketId } from "./keys.ts";
|
|
12
|
-
import { isHostLabel } from "./naming.ts";
|
|
12
|
+
import { isHostLabel, reservedLabels } from "./naming.ts";
|
|
13
13
|
import { enterPage, pageScript, SESSION_PATH, statusPage } from "./pages.ts";
|
|
14
14
|
import type { FrameBlock } from "../host/types.ts";
|
|
15
15
|
import { memoryTicketStore } from "./tickets.ts";
|
|
@@ -46,6 +46,18 @@ const MAX_SESSION_SECONDS = 30 * 24 * 3_600;
|
|
|
46
46
|
/** Browsers keep a cookie of up to 4 KB, name and attributes included. */
|
|
47
47
|
const MAX_COOKIE_BYTES = 3_800;
|
|
48
48
|
|
|
49
|
+
/** A host (and port) as it compares: lowercase, no trailing dot, no default port. */
|
|
50
|
+
function normalHost(value: string, protocol: string): string {
|
|
51
|
+
const host = value
|
|
52
|
+
.trim()
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.replace(/\.(?=:\d+$|$)/u, "");
|
|
55
|
+
const defaultPort = protocol === "https:" ? ":443" : protocol === "http:" ? ":80" : "";
|
|
56
|
+
return defaultPort !== "" && host.endsWith(defaultPort)
|
|
57
|
+
? host.slice(0, -defaultPort.length)
|
|
58
|
+
: host;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
/**
|
|
50
62
|
* What a session cookie carries, signed: the instance, its viewer (or anonymous), the host's
|
|
51
63
|
* opaque subject, and when it stops.
|
|
@@ -190,21 +202,14 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
190
202
|
throw new TypeError("content.port must be a port number");
|
|
191
203
|
}
|
|
192
204
|
const keys = createKeys(options.secret);
|
|
193
|
-
const
|
|
194
|
-
for (const label of reserved) {
|
|
195
|
-
if (!isHostLabel(label)) {
|
|
196
|
-
throw new TypeError(
|
|
197
|
-
`content.reserved: ${JSON.stringify(label)} is not a lowercase DNS label`,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
205
|
+
const isReserved = reservedLabels(content.reserved);
|
|
201
206
|
/** The label a scope is served on: the host's naming, checked, or the keyed default. */
|
|
202
207
|
const nameOf = (scope: string): string => {
|
|
203
208
|
if (content.name === undefined) {
|
|
204
209
|
return keys.label(scope);
|
|
205
210
|
}
|
|
206
211
|
const label = content.name(scope);
|
|
207
|
-
if (!isHostLabel(label) ||
|
|
212
|
+
if (!isHostLabel(label) || isReserved(label)) {
|
|
208
213
|
throw new TypeError(
|
|
209
214
|
`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`,
|
|
210
215
|
);
|
|
@@ -213,7 +218,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
213
218
|
};
|
|
214
219
|
/** Whether a request's label may be one this server named. */
|
|
215
220
|
const ours = (label: string): boolean =>
|
|
216
|
-
!
|
|
221
|
+
!isReserved(label) && (content.name === undefined ? LABEL.test(label) : isHostLabel(label));
|
|
217
222
|
const secure = content.scheme === "https";
|
|
218
223
|
const defaultPort = secure ? 443 : 80;
|
|
219
224
|
const portPart =
|
|
@@ -520,6 +525,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
520
525
|
"cache-control": "no-store",
|
|
521
526
|
"referrer-policy": "no-referrer",
|
|
522
527
|
"x-content-type-options": "nosniff",
|
|
528
|
+
"x-robots-tag": "noindex, nofollow",
|
|
523
529
|
});
|
|
524
530
|
}
|
|
525
531
|
|
|
@@ -686,8 +692,20 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
686
692
|
}
|
|
687
693
|
}
|
|
688
694
|
|
|
689
|
-
|
|
690
|
-
|
|
695
|
+
const underDomain = (host: string): boolean => host === domain || host.endsWith(`.${domain}`);
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The label a request is for: null when it isn't under the content domain at all (or is in a
|
|
699
|
+
* reserved namespace), "mismatch" when its Host header names another host than its URL does
|
|
700
|
+
* and either is under the content domain. A Node adapter may build the URL from an
|
|
701
|
+
* absolute-form request target (`GET http://other/…`) while a host routes by the Host header
|
|
702
|
+
* (and a cache keys on it): the two must agree before anything is served. A Host that is a
|
|
703
|
+
* local server address (an IP literal, `localhost`), as a test runner or a dev proxy sends, is
|
|
704
|
+
* not a name and isn't compared.
|
|
705
|
+
*/
|
|
706
|
+
function labelOf(
|
|
707
|
+
request: Request,
|
|
708
|
+
): { readonly url: URL; readonly label: string } | "mismatch" | null {
|
|
691
709
|
let url: URL;
|
|
692
710
|
try {
|
|
693
711
|
url = new URL(request.url);
|
|
@@ -695,13 +713,27 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
695
713
|
return null;
|
|
696
714
|
}
|
|
697
715
|
const host = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
698
|
-
|
|
716
|
+
const header = request.headers.get("host");
|
|
717
|
+
if (header !== null) {
|
|
718
|
+
const named = normalHost(header, url.protocol);
|
|
719
|
+
const name = named.replace(/:\d+$/u, "");
|
|
720
|
+
const local = name === "localhost" || /^[\d.]+$/u.test(name) || name.startsWith("[");
|
|
721
|
+
if (
|
|
722
|
+
!local &&
|
|
723
|
+
named !== normalHost(url.host, url.protocol) &&
|
|
724
|
+
(underDomain(host) || underDomain(name))
|
|
725
|
+
) {
|
|
726
|
+
return "mismatch";
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (!underDomain(host)) {
|
|
699
730
|
return null;
|
|
700
731
|
}
|
|
701
732
|
const label = host.slice(0, Math.max(0, host.length - domain.length - 1));
|
|
702
|
-
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`)
|
|
703
|
-
// under it go to the host's own routes. Any other deeper name is ours
|
|
704
|
-
|
|
733
|
+
// A reserved label is another server's namespace (`f` for `<x>.f.<domain>`), or the host's
|
|
734
|
+
// own: it and the names under it go to the host's own routes. Any other deeper name is ours
|
|
735
|
+
// to refuse.
|
|
736
|
+
if (isReserved(label.split(".").at(-1) ?? "")) {
|
|
705
737
|
return null;
|
|
706
738
|
}
|
|
707
739
|
return { url, label };
|
|
@@ -779,7 +811,7 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
779
811
|
|
|
780
812
|
async access(request) {
|
|
781
813
|
const at = labelOf(request);
|
|
782
|
-
if (at === null || !ours(at.label)) {
|
|
814
|
+
if (at === null || at === "mismatch" || !ours(at.label)) {
|
|
783
815
|
return null;
|
|
784
816
|
}
|
|
785
817
|
const access = accessOf(request, at.label, null);
|
|
@@ -791,8 +823,16 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
791
823
|
if (at === null) {
|
|
792
824
|
return null;
|
|
793
825
|
}
|
|
794
|
-
const { url, label } = at;
|
|
795
826
|
const method = request.method.toUpperCase();
|
|
827
|
+
if (at === "mismatch") {
|
|
828
|
+
return page(
|
|
829
|
+
400,
|
|
830
|
+
"Bad request",
|
|
831
|
+
"The request's Host header and its URL name different hosts.",
|
|
832
|
+
method,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
const { url, label } = at;
|
|
796
836
|
try {
|
|
797
837
|
if (!ours(label)) {
|
|
798
838
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
@@ -815,7 +855,8 @@ export function createServer(options: ServerOptions): PortholeServer {
|
|
|
815
855
|
if (document?.[1] !== undefined) {
|
|
816
856
|
return await serveDocument(request, url, label, document[1]);
|
|
817
857
|
}
|
|
818
|
-
|
|
858
|
+
// Without an upstream, only Porthole's own paths are served here: any other is nothing.
|
|
859
|
+
if (url.pathname.startsWith(PREFIX) || options.upstream === undefined) {
|
|
819
860
|
return page(404, "Nothing here", "This address names no content.", method);
|
|
820
861
|
}
|
|
821
862
|
return await serveApp(request, label);
|
package/src/server/types.ts
CHANGED
|
@@ -19,10 +19,13 @@ export interface ContentDomain {
|
|
|
19
19
|
*/
|
|
20
20
|
readonly name?: (scope: string) => string;
|
|
21
21
|
/**
|
|
22
|
-
* Labels this server never serves or names
|
|
23
|
-
* `f` for `<instance>.f.<domain>`,
|
|
22
|
+
* Labels this server never serves or names: another server's namespace under the same domain
|
|
23
|
+
* (`f` for `<instance>.f.<domain>`, `www`), or the host's own (`p-*`: every label starting
|
|
24
|
+
* `p-`). Each entry is a whole label, or a prefix ending in `*`; or a function that says
|
|
25
|
+
* whether a label is reserved. Requests for them, and for the names under them, get null (the
|
|
26
|
+
* host's routes), so a host routes them without wrapping `handle`.
|
|
24
27
|
*/
|
|
25
|
-
readonly reserved?: readonly string[];
|
|
28
|
+
readonly reserved?: readonly string[] | ((label: string) => boolean);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
/**
|
package/src/shell/annotate.ts
CHANGED
|
@@ -369,6 +369,9 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
|
|
|
369
369
|
const root = ensureLayer();
|
|
370
370
|
const parts = [];
|
|
371
371
|
const style = createElement.call(document, "style");
|
|
372
|
+
if (nonce !== "") {
|
|
373
|
+
style.nonce = nonce;
|
|
374
|
+
}
|
|
372
375
|
style.textContent = STYLE;
|
|
373
376
|
parts.push(style);
|
|
374
377
|
const flashOn = Date.now() < flashUntil;
|
|
@@ -439,20 +442,41 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
|
|
|
439
442
|
let anchorTimer = 0;
|
|
440
443
|
let deadline = 0;
|
|
441
444
|
let reportAll = false;
|
|
445
|
+
// Nothing is reported before the content is in place (an empty shell would orphan every
|
|
446
|
+
// mark), nor before a run of changes settles; a focus asked for meanwhile waits for it.
|
|
447
|
+
let waitingForContent = false;
|
|
448
|
+
let pendingFocus = null;
|
|
449
|
+
const settling = () => anchorTimer !== 0 || waitingForContent || marks.some((mark) => mark.state === null);
|
|
442
450
|
const scheduleAnchor = (all) => {
|
|
443
451
|
if (all) {
|
|
444
452
|
reportAll = true;
|
|
445
453
|
}
|
|
454
|
+
if (!contentIsIn()) {
|
|
455
|
+
if (!waitingForContent) {
|
|
456
|
+
waitingForContent = true;
|
|
457
|
+
whenContentIsIn(() => {
|
|
458
|
+
waitingForContent = false;
|
|
459
|
+
scheduleAnchor(false);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
446
464
|
const now = Date.now();
|
|
447
465
|
if (deadline === 0) {
|
|
448
466
|
deadline = now + 1000;
|
|
449
467
|
}
|
|
450
468
|
clearTimeout(anchorTimer);
|
|
451
469
|
anchorTimer = setTimeout(() => {
|
|
470
|
+
anchorTimer = 0;
|
|
452
471
|
deadline = 0;
|
|
453
472
|
const every = reportAll;
|
|
454
473
|
reportAll = false;
|
|
455
474
|
anchorAll(every);
|
|
475
|
+
if (pendingFocus !== null) {
|
|
476
|
+
const id = pendingFocus;
|
|
477
|
+
pendingFocus = null;
|
|
478
|
+
focus(id);
|
|
479
|
+
}
|
|
456
480
|
}, Math.max(0, Math.min(150, deadline - now)));
|
|
457
481
|
};
|
|
458
482
|
new MutationObserver((records) => {
|
|
@@ -475,6 +499,12 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
|
|
|
475
499
|
if (!mark) {
|
|
476
500
|
return;
|
|
477
501
|
}
|
|
502
|
+
if (settling()) {
|
|
503
|
+
// Where it is isn't known yet: focus it once anchoring has settled, then say where.
|
|
504
|
+
pendingFocus = id;
|
|
505
|
+
active = id;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
478
508
|
active = id;
|
|
479
509
|
if (mark.found !== null) {
|
|
480
510
|
const rect = rectOf(mark.found);
|
|
@@ -657,6 +687,7 @@ export const ANNOTATE_RUNTIME = String.raw`if (declared("annotate")) {
|
|
|
657
687
|
focus(String(params.id));
|
|
658
688
|
break;
|
|
659
689
|
case M.clear:
|
|
690
|
+
pendingFocus = null;
|
|
660
691
|
marks = [];
|
|
661
692
|
active = null;
|
|
662
693
|
draw();
|
package/src/shell/document.ts
CHANGED
|
@@ -25,8 +25,17 @@ export interface ShellDocumentOptions {
|
|
|
25
25
|
* both apply, and the stricter wins.
|
|
26
26
|
*/
|
|
27
27
|
readonly cspMeta?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The embedding page's CSP nonce, where its `script-src` is nonce-based (P054): a `srcdoc` frame
|
|
30
|
+
* inherits the embedding page's policy, so the shell's script and style carry it, and so does
|
|
31
|
+
* every script the shell runs for the content. The frame can read it.
|
|
32
|
+
*/
|
|
33
|
+
readonly nonce?: string;
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/** A CSP nonce: base64 or base64url characters, 8 to 256 of them. */
|
|
37
|
+
const NONCE = /^[A-Za-z0-9+/_=-]{8,256}$/u;
|
|
38
|
+
|
|
30
39
|
const escapeAttribute = (value: string): string =>
|
|
31
40
|
value.replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<");
|
|
32
41
|
|
|
@@ -86,6 +95,10 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
|
|
|
86
95
|
throw new TypeError(`shellDocument: ${problem}`);
|
|
87
96
|
}
|
|
88
97
|
}
|
|
98
|
+
if (options.nonce !== undefined && !NONCE.test(options.nonce)) {
|
|
99
|
+
throw new TypeError("shellDocument: a nonce is 8 to 256 base64 characters");
|
|
100
|
+
}
|
|
101
|
+
const nonce = options.nonce === undefined ? "" : ` nonce="${options.nonce}"`;
|
|
89
102
|
// The annotation runtime only where it is declared; spliced in before the plan, which may carry
|
|
90
103
|
// any text (a document) and must not be searched.
|
|
91
104
|
const runtime = SHELL_RUNTIME.replace("__ANNOTATE__", () =>
|
|
@@ -113,13 +126,13 @@ export function shellDocument(plan: ShellPlan, csp: string, options: ShellDocume
|
|
|
113
126
|
${head.join("\n")}
|
|
114
127
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
115
128
|
<title>${escapeText(options.title ?? "Content")}</title>
|
|
116
|
-
<style>
|
|
129
|
+
<style${nonce}>
|
|
117
130
|
${BASE_STYLE}
|
|
118
131
|
</style>
|
|
119
132
|
</head>
|
|
120
133
|
<body>
|
|
121
134
|
<div id="porthole-root"></div>
|
|
122
|
-
<script>${runtime}</script>
|
|
135
|
+
<script${nonce}>${runtime}</script>
|
|
123
136
|
</body>
|
|
124
137
|
</html>
|
|
125
138
|
`;
|
package/src/shell/runtime.ts
CHANGED
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
export const SHELL_RUNTIME = String.raw`(() => {
|
|
32
32
|
"use strict";
|
|
33
33
|
const plan = __PLAN__;
|
|
34
|
+
// The embedding page's nonce, where it gave one (a srcdoc frame inherits its policy): every
|
|
35
|
+
// script the shell runs for the content carries it.
|
|
36
|
+
const nonce = document.currentScript && document.currentScript.nonce ? document.currentScript.nonce : "";
|
|
34
37
|
const declared = (name) => Object.prototype.hasOwnProperty.call(plan.capabilities, name);
|
|
35
38
|
// Fixed in place: content can neither restore what is removed nor replace what is wrapped.
|
|
36
39
|
const lock = (target, name, value) => {
|
|
@@ -485,6 +488,9 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
485
488
|
}
|
|
486
489
|
// A script made by a script is async unless told otherwise: keep the page's order.
|
|
487
490
|
live.async = script.hasAttribute("async");
|
|
491
|
+
if (nonce !== "") {
|
|
492
|
+
live.nonce = nonce;
|
|
493
|
+
}
|
|
488
494
|
live.textContent = script.textContent;
|
|
489
495
|
const external = live.hasAttribute("src") && !live.async && live.type !== "module";
|
|
490
496
|
const loaded = external
|
|
@@ -509,6 +515,27 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
509
515
|
window.dispatchEvent(new Event("load"));
|
|
510
516
|
};
|
|
511
517
|
|
|
518
|
+
// Whether the content is in place: a fragment or page the shell draws, once drawn whole; a
|
|
519
|
+
// view the plan carries, once rendered; a view that is the document itself, from the start.
|
|
520
|
+
const contentHooks = [];
|
|
521
|
+
let contentIn = typeof plan.document !== "string" && plan.protocol !== "shell";
|
|
522
|
+
const contentIsIn = () => contentIn;
|
|
523
|
+
const whenContentIsIn = (run) => {
|
|
524
|
+
if (contentIn) {
|
|
525
|
+
run();
|
|
526
|
+
} else {
|
|
527
|
+
contentHooks.push(run);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
const contentArrived = () => {
|
|
531
|
+
if (!contentIn) {
|
|
532
|
+
contentIn = true;
|
|
533
|
+
for (const run of contentHooks.splice(0)) {
|
|
534
|
+
run();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
512
539
|
// A fragment takes the person's scheme; a whole document only if it declares that it supports
|
|
513
540
|
// both (or the config says always), else it shows as its author styled it (P039).
|
|
514
541
|
let lastContext = null;
|
|
@@ -544,6 +571,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
544
571
|
schemeFollows();
|
|
545
572
|
await runScripts(scripts);
|
|
546
573
|
announceLoaded();
|
|
574
|
+
contentArrived();
|
|
547
575
|
};
|
|
548
576
|
|
|
549
577
|
// A document the plan carries (a served page, an MCP Apps view) is rendered once the shell's own
|
|
@@ -672,7 +700,10 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
672
700
|
window.addEventListener("error", (event) => {
|
|
673
701
|
const error = event.error;
|
|
674
702
|
const where = event.lineno ? " (line " + event.lineno + ")" : "";
|
|
675
|
-
|
|
703
|
+
// V8's stack starts with the message; WebKit's and Firefox's don't, so it goes first there.
|
|
704
|
+
const stack = error && error.stack ? String(error.stack).split("\n").slice(0, 3).join(" ") : "";
|
|
705
|
+
const said = error && error.message !== undefined ? String(error.name || "Error") + ": " + String(error.message) : event.message;
|
|
706
|
+
report(stack === "" ? said + where : stack.indexOf(String(error.message)) === -1 ? said + " " + stack : stack);
|
|
676
707
|
});
|
|
677
708
|
window.addEventListener("unhandledrejection", (event) => {
|
|
678
709
|
const reason = event.reason;
|
|
@@ -804,6 +835,7 @@ export const SHELL_RUNTIME = String.raw`(() => {
|
|
|
804
835
|
schedule();
|
|
805
836
|
await runScripts(scripts);
|
|
806
837
|
announceLoaded();
|
|
838
|
+
contentArrived();
|
|
807
839
|
schedule();
|
|
808
840
|
};
|
|
809
841
|
|