buninu-browser 0.2.0 → 0.2.2
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/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page `<canvas>` 2d contexts, drawn by this process's CanvasKit.
|
|
3
|
+
*
|
|
4
|
+
* `loadGraphics` calls `setCanvasKit` with the instance the page is rasterized
|
|
5
|
+
* with. A second CanvasKit cannot share images with that one, so a page canvas
|
|
6
|
+
* is `CanvasKit.MakeCanvas`, not a separate software bitmap.
|
|
7
|
+
*
|
|
8
|
+
* MakeCanvas has no width or height. Setting either on the element disposes
|
|
9
|
+
* that surface and opens another (HTML resets the bitmap and the drawing
|
|
10
|
+
* state). The object the page holds stays the same and forwards to whichever
|
|
11
|
+
* context is current.
|
|
12
|
+
*
|
|
13
|
+
* `getImageData` builds its array on `readPixels().buffer`, which can be the
|
|
14
|
+
* whole WASM heap. The copy handed back is only the requested rectangle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let CanvasKit = null;
|
|
18
|
+
|
|
19
|
+
/** The CanvasKit `loadGraphics` instantiated. Page canvases share it. */
|
|
20
|
+
export function setCanvasKit(kit) {
|
|
21
|
+
CanvasKit = kit;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const surfaces = new WeakMap();
|
|
25
|
+
|
|
26
|
+
/** The element's 2d context, or null for any other context id or before CanvasKit loads. */
|
|
27
|
+
export function canvasContext(node, type) {
|
|
28
|
+
if (type !== "2d" || !CanvasKit) return null;
|
|
29
|
+
let entry = surfaces.get(node);
|
|
30
|
+
if (!entry) {
|
|
31
|
+
entry = open(node);
|
|
32
|
+
if (!entry) return null;
|
|
33
|
+
surfaces.set(node, entry);
|
|
34
|
+
}
|
|
35
|
+
return entry.proxy;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Setting width or height clears the bitmap and the drawing state. */
|
|
39
|
+
export function resetCanvas(node) {
|
|
40
|
+
surfaces.get(node)?.reopen();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A zero bitmap makes MakeSurface fail and getImageData reject the rectangle.
|
|
44
|
+
// The element can still report 0; drawing uses one pixel until it grows.
|
|
45
|
+
function bitmapSize(node) {
|
|
46
|
+
return [Math.max(1, node.width >>> 0), Math.max(1, node.height >>> 0)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function open(node) {
|
|
50
|
+
let surface = makeSurface(node);
|
|
51
|
+
if (!surface) return null;
|
|
52
|
+
let context = surface.getContext("2d");
|
|
53
|
+
const proxy = new Proxy(Object.create(null), {
|
|
54
|
+
get(_target, property) {
|
|
55
|
+
if (property === "getImageData") return (sx, sy, sw, sh) => copyImageData(context.getImageData(sx, sy, sw, sh));
|
|
56
|
+
const value = context[property];
|
|
57
|
+
return typeof value === "function" ? value.bind(context) : value;
|
|
58
|
+
},
|
|
59
|
+
set(_target, property, value) {
|
|
60
|
+
context[property] = value;
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
has(_target, property) {
|
|
64
|
+
return property in context;
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
proxy,
|
|
69
|
+
reopen() {
|
|
70
|
+
const next = makeSurface(node);
|
|
71
|
+
if (!next) return;
|
|
72
|
+
const previous = surface;
|
|
73
|
+
surface = next;
|
|
74
|
+
context = surface.getContext("2d");
|
|
75
|
+
previous.dispose?.();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeSurface(node) {
|
|
81
|
+
const [width, height] = bitmapSize(node);
|
|
82
|
+
return CanvasKit.MakeCanvas(width, height);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function copyImageData(image) {
|
|
86
|
+
if (!image?.data) throw new DOMException("The source rectangle is empty.", "IndexSizeError");
|
|
87
|
+
const count = image.width * image.height * 4;
|
|
88
|
+
const data = new Uint8ClampedArray(count);
|
|
89
|
+
const source = new Uint8ClampedArray(image.data.buffer, image.data.byteOffset, Math.min(count, image.data.length));
|
|
90
|
+
data.set(source);
|
|
91
|
+
return { width: image.width, height: image.height, data, colorSpace: "srgb" };
|
|
92
|
+
}
|
package/lib/renderer/host.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { closeSync } from "node:fs";
|
|
16
16
|
import { PulseClient } from "../audio/pulse-client.js";
|
|
17
|
-
import { logEnabled, pipeToLog, RENDERER_LOG_FLAG } from "../log.js";
|
|
17
|
+
import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
|
|
18
18
|
import { NetworkService } from "../network/network-service.js";
|
|
19
19
|
import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
|
|
20
20
|
|
|
@@ -23,8 +23,10 @@ const FETCH_MODES = new Set(["cors", "no-cors", "same-origin"]);
|
|
|
23
23
|
const CREDENTIALS_MODES = new Set(["omit", "same-origin", "include"]);
|
|
24
24
|
const REDIRECT_MODES = new Set(["follow", "error", "manual"]);
|
|
25
25
|
const MAX_REQUEST_HEADERS = 100;
|
|
26
|
-
const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio"]);
|
|
26
|
+
const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio", "iframe"]);
|
|
27
27
|
const NAVIGATION_SCHEMES = new Set(["http:", "https:", "about:", "data:"]);
|
|
28
|
+
// The largest form body a page may POST in a navigation.
|
|
29
|
+
const MAX_POST_BYTES = 16 * 1024 * 1024;
|
|
28
30
|
const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
|
|
29
31
|
// Audio output: one PulseAudio native-protocol connection (jspulse or a
|
|
30
32
|
// PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
|
|
@@ -69,6 +71,8 @@ export class RendererHost {
|
|
|
69
71
|
#sockets = new Map();
|
|
70
72
|
// Audio streams: id -> { child, stream } (PulseAudio playback streams)
|
|
71
73
|
#audio = new Map();
|
|
74
|
+
// In-flight page fetches, so AbortController reaches the actual HTTP request.
|
|
75
|
+
#pageFetches = new Map();
|
|
72
76
|
#nextId = 1;
|
|
73
77
|
#queue = Promise.resolve();
|
|
74
78
|
#restarting = false;
|
|
@@ -86,7 +90,8 @@ export class RendererHost {
|
|
|
86
90
|
* args?: string[],
|
|
87
91
|
* timeout?: number,
|
|
88
92
|
* onRestart?: (call: (method: string, ...params: unknown[]) => Promise<unknown>) => Promise<void>,
|
|
89
|
-
* onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null
|
|
93
|
+
* onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null,
|
|
94
|
+
* post: { method: "POST", body: Uint8Array, contentType: string, referrer: string } | null) => void,
|
|
90
95
|
* onDownload?: (payload: { url: string, name: string, data: Uint8Array }) => void,
|
|
91
96
|
* onFrame?: () => void,
|
|
92
97
|
* onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
|
|
@@ -149,6 +154,7 @@ export class RendererHost {
|
|
|
149
154
|
this.#closeChannel(channel);
|
|
150
155
|
this.#closeSockets(child);
|
|
151
156
|
this.#closeAudio(child);
|
|
157
|
+
this.#closePageFetches(child);
|
|
152
158
|
if (this.#child !== child) return;
|
|
153
159
|
console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
|
|
154
160
|
this.#child = null;
|
|
@@ -301,6 +307,7 @@ export class RendererHost {
|
|
|
301
307
|
const child = this.#child;
|
|
302
308
|
this.#closeSockets(child);
|
|
303
309
|
this.#closeAudio(child);
|
|
310
|
+
this.#closePageFetches(child);
|
|
304
311
|
this.#child = null;
|
|
305
312
|
this.#ready = null;
|
|
306
313
|
child?.kill();
|
|
@@ -360,6 +367,9 @@ export class RendererHost {
|
|
|
360
367
|
this.#fetchForRenderer(child, message);
|
|
361
368
|
} else if (message.type === "page-fetch" && Number.isInteger(message.id)) {
|
|
362
369
|
this.#pageFetch(child, message);
|
|
370
|
+
} else if (message.type === "page-fetch-abort" && Number.isInteger(message.id)) {
|
|
371
|
+
const entry = this.#pageFetches.get(message.id);
|
|
372
|
+
if (entry?.child === child) entry.controller.abort();
|
|
363
373
|
} else if (message.type === "ws-open" && Number.isInteger(message.id)) {
|
|
364
374
|
this.#openSocket(child, message);
|
|
365
375
|
} else if (message.type === "ws-send" && Number.isInteger(message.id)) {
|
|
@@ -391,11 +401,22 @@ export class RendererHost {
|
|
|
391
401
|
this.#audio.delete(message.id);
|
|
392
402
|
send({ event: "ended" });
|
|
393
403
|
}, () => this.#audio.delete(message.id));
|
|
404
|
+
} else if (message.type === "audio-resync" && Number.isInteger(message.id)) {
|
|
405
|
+
const entry = this.#audio.get(message.id);
|
|
406
|
+
if (entry?.child === child) (entry.opened ?? Promise.resolve()).then(() => entry.stream?.resync()).catch(() => {});
|
|
394
407
|
} else if (message.type === "audio-close" && Number.isInteger(message.id)) {
|
|
395
408
|
const entry = this.#audio.get(message.id);
|
|
396
409
|
if (entry?.child !== child) return;
|
|
397
410
|
this.#audio.delete(message.id);
|
|
398
|
-
|
|
411
|
+
// Tell the renderer how much PCM reached the server: a server may
|
|
412
|
+
// still play what it holds, so the renderer resumes after it.
|
|
413
|
+
(entry.opened ?? Promise.resolve()).then(() => {
|
|
414
|
+
const sentBytes = entry.stream?.sentBytes ?? 0;
|
|
415
|
+
if (this.#child === child) child.send({ type: "audio-event", id: message.id, event: "closed", sentBytes });
|
|
416
|
+
return entry.stream?.close();
|
|
417
|
+
}).catch(() => {
|
|
418
|
+
if (this.#child === child) child.send({ type: "audio-event", id: message.id, event: "closed", sentBytes: 0 });
|
|
419
|
+
});
|
|
399
420
|
} else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
|
|
400
421
|
this.#onLifecycle?.(message.name, message.generation);
|
|
401
422
|
} else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
|
|
@@ -412,7 +433,16 @@ export class RendererHost {
|
|
|
412
433
|
const download = typeof message.download === "string" ? message.download.slice(0, 200) : null;
|
|
413
434
|
const from = message.fragmentFrom;
|
|
414
435
|
const fragmentFrom = from && Number.isFinite(from.x) && Number.isFinite(from.y) ? { x: from.x, y: from.y } : null;
|
|
415
|
-
|
|
436
|
+
// A form POST: its body and type from the page; Origin and Referer
|
|
437
|
+
// from the document the renderer shows, which the page cannot choose.
|
|
438
|
+
const post = message.post;
|
|
439
|
+
const request = post && post.method === "POST" && post.body instanceof Uint8Array
|
|
440
|
+
&& post.body.byteLength <= MAX_POST_BYTES && typeof post.contentType === "string" && post.contentType.length <= 200
|
|
441
|
+
&& !/[\r\n]/.test(post.contentType) && ["http:", "https:"].includes(target.protocol)
|
|
442
|
+
? { method: "POST", body: post.body, contentType: post.contentType, referrer: this.#documentUrl }
|
|
443
|
+
: null;
|
|
444
|
+
if (post && !request) return;
|
|
445
|
+
this.#onNavigate?.(target.href, download, fragmentFrom, request);
|
|
416
446
|
}
|
|
417
447
|
} else if (message.type === "download-data" && typeof message.url === "string"
|
|
418
448
|
&& message.data instanceof Uint8Array && message.data.byteLength <= 64 * 1024 * 1024) {
|
|
@@ -473,11 +503,14 @@ export class RendererHost {
|
|
|
473
503
|
rate, channels, name: String(name ?? "audio").slice(0, 200),
|
|
474
504
|
leadSeconds: name === "piano" ? 0.12 : undefined,
|
|
475
505
|
onRequest: (bytes) => send({ event: "request", bytes }),
|
|
506
|
+
onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
|
|
507
|
+
onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
|
|
476
508
|
}))
|
|
477
509
|
.then((stream) => {
|
|
478
510
|
if (this.#audio.get(id) !== entry) return stream.close();
|
|
479
511
|
entry.stream = stream;
|
|
480
512
|
for (const data of entry.early.splice(0)) stream.write(data);
|
|
513
|
+
send({ event: "ready" });
|
|
481
514
|
}, (error) => {
|
|
482
515
|
this.#audio.delete(id);
|
|
483
516
|
send({ event: "error", message: String(error?.message ?? error) });
|
|
@@ -493,6 +526,14 @@ export class RendererHost {
|
|
|
493
526
|
}
|
|
494
527
|
}
|
|
495
528
|
|
|
529
|
+
#closePageFetches(child) {
|
|
530
|
+
for (const [id, entry] of this.#pageFetches) {
|
|
531
|
+
if (entry.child !== child) continue;
|
|
532
|
+
this.#pageFetches.delete(id);
|
|
533
|
+
entry.controller.abort();
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
496
537
|
#closeSockets(child) {
|
|
497
538
|
for (const [id, entry] of this.#sockets) {
|
|
498
539
|
if (entry.child !== child) continue;
|
|
@@ -516,15 +557,59 @@ export class RendererHost {
|
|
|
516
557
|
if (!ALLOWED_SCHEMES.has(target.protocol) || !RESOURCE_KINDS.has(kind)) {
|
|
517
558
|
return reply({ error: `blocked ${kind} request for ${target.protocol}` });
|
|
518
559
|
}
|
|
560
|
+
// A renderer only ever holds documents of its own origin (site isolation
|
|
561
|
+
// is by origin): an iframe's document is given to it only when it is
|
|
562
|
+
// same-origin with the page, before and after redirects, and allows
|
|
563
|
+
// being framed.
|
|
564
|
+
const frame = kind === "iframe";
|
|
565
|
+
if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
566
|
+
// Video and audio over http(s) stream. data: stays one response: it is
|
|
567
|
+
// already in hand, and the renderer wraps it in a finished pipe.
|
|
568
|
+
if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
|
|
569
|
+
const send = (payload) => {
|
|
570
|
+
if (this.#child === child) child.send(payload);
|
|
571
|
+
};
|
|
572
|
+
try {
|
|
573
|
+
await this.#network.streamSubresource(target.href, this.#documentUrl, kind, (event) => {
|
|
574
|
+
if (event.type === "head") {
|
|
575
|
+
send({
|
|
576
|
+
type: "fetch-result", id,
|
|
577
|
+
result: {
|
|
578
|
+
streaming: true,
|
|
579
|
+
ok: event.ok,
|
|
580
|
+
status: event.status,
|
|
581
|
+
statusText: event.statusText ?? "",
|
|
582
|
+
url: event.url || target.href,
|
|
583
|
+
contentType: event.contentType ?? "",
|
|
584
|
+
contentLength: event.contentLength ?? null,
|
|
585
|
+
documentCookie: this.#network.documentCookie(this.#documentUrl),
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
} else if (event.type === "chunk" && event.data instanceof Uint8Array) {
|
|
589
|
+
send({ type: "fetch-chunk", id, data: event.data });
|
|
590
|
+
} else if (event.type === "end") {
|
|
591
|
+
send({ type: "fetch-end", id });
|
|
592
|
+
} else if (event.type === "error") {
|
|
593
|
+
send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
} catch (error) {
|
|
597
|
+
send({ type: "fetch-error", id, error: String(error?.message ?? error) });
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
519
601
|
try {
|
|
520
602
|
const response = target.protocol === "data:"
|
|
521
603
|
? await dataResponse(target)
|
|
522
|
-
: await this.#network.subresource(target.href, this.#documentUrl);
|
|
604
|
+
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
523
605
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
606
|
+
if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
607
|
+
if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
|
|
524
608
|
reply({
|
|
525
609
|
result: {
|
|
526
610
|
ok: response.ok,
|
|
527
611
|
status: response.status,
|
|
612
|
+
statusText: response.statusText ?? "",
|
|
528
613
|
url: response.url || target.href,
|
|
529
614
|
contentType: response.contentType,
|
|
530
615
|
body: response.body,
|
|
@@ -541,17 +626,54 @@ export class RendererHost {
|
|
|
541
626
|
const reply = (payload) => {
|
|
542
627
|
if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
|
|
543
628
|
};
|
|
629
|
+
const started = performance.now();
|
|
630
|
+
const controller = new AbortController();
|
|
631
|
+
const entry = { child, controller };
|
|
632
|
+
this.#pageFetches.set(id, entry);
|
|
633
|
+
let streamHead = false;
|
|
634
|
+
let sanitized = null;
|
|
544
635
|
try {
|
|
545
|
-
|
|
546
|
-
const response = await this.#network.pageFetch(sanitized, this.#documentUrl
|
|
636
|
+
sanitized = sanitizePageRequest(request);
|
|
637
|
+
const response = await this.#network.pageFetch(sanitized, this.#documentUrl, {
|
|
638
|
+
signal: controller.signal,
|
|
639
|
+
onChunk: (event) => {
|
|
640
|
+
if (this.#child !== child) return;
|
|
641
|
+
if (event.type === "head") {
|
|
642
|
+
streamHead = true;
|
|
643
|
+
child.send({ type: "page-fetch-head", id, result: {
|
|
644
|
+
...event.result, documentCookie: this.#network.documentCookie(this.#documentUrl),
|
|
645
|
+
} });
|
|
646
|
+
}
|
|
647
|
+
else if (event.type === "chunk") child.send({ type: "page-fetch-chunk", id, data: event.data });
|
|
648
|
+
else if (event.type === "end") child.send({ type: "page-fetch-end", id });
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
if (response.streaming) {
|
|
652
|
+
logFetch(sanitized, `${response.status} streaming`, started);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
547
655
|
const { rawHeaders, ...result } = response;
|
|
656
|
+
logFetch(sanitized, `${response.status} ${response.body?.byteLength ?? 0}B`, started);
|
|
548
657
|
reply({ result: { ...result, documentCookie: this.#network.documentCookie(this.#documentUrl) } });
|
|
549
658
|
} catch (error) {
|
|
550
|
-
|
|
659
|
+
logFetch(sanitized ?? request, `failed: ${error?.message ?? error}`, started);
|
|
660
|
+
if (streamHead && this.#child === child) {
|
|
661
|
+
child.send({ type: "page-fetch-error", id, error: String(error?.message ?? error) });
|
|
662
|
+
} else reply({ error: String(error?.message ?? error) });
|
|
663
|
+
} finally {
|
|
664
|
+
if (this.#pageFetches.get(id) === entry) this.#pageFetches.delete(id);
|
|
551
665
|
}
|
|
552
666
|
}
|
|
553
667
|
}
|
|
554
668
|
|
|
669
|
+
/** With BUNINU_LOG, one line per page fetch()/XMLHttpRequest: method, URL (shortened), outcome, time. */
|
|
670
|
+
function logFetch(request, outcome, started) {
|
|
671
|
+
if (!logEnabled()) return;
|
|
672
|
+
const url = String(request?.url ?? "");
|
|
673
|
+
const shown = url.length > 160 ? `${url.slice(0, 160)}…` : url;
|
|
674
|
+
writeLog("fetch", `${request?.method ?? "GET"} ${shown} -> ${outcome} in ${Math.round(performance.now() - started)}ms`);
|
|
675
|
+
}
|
|
676
|
+
|
|
555
677
|
/** Keeps only the expected shape of a renderer's (untrusted) sandbox report. */
|
|
556
678
|
function sanitizeSandboxReport(report) {
|
|
557
679
|
const layer = (value) => ({
|
|
@@ -602,3 +724,31 @@ function sanitizeCompositorResult(method, result) {
|
|
|
602
724
|
const y = Number(result?.y);
|
|
603
725
|
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 };
|
|
604
726
|
}
|
|
727
|
+
|
|
728
|
+
function sameOrigin(left, right) {
|
|
729
|
+
try {
|
|
730
|
+
const a = new URL(left);
|
|
731
|
+
const b = new URL(right);
|
|
732
|
+
return ["http:", "https:"].includes(a.protocol) && a.origin === b.origin;
|
|
733
|
+
} catch {
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Whether a response refuses to be framed (by its own origin, the only case
|
|
740
|
+
* here): X-Frame-Options: DENY, or Content-Security-Policy frame-ancestors 'none'.
|
|
741
|
+
*/
|
|
742
|
+
function framingDenied(rawHeaders) {
|
|
743
|
+
for (const [name, value] of rawHeaders) {
|
|
744
|
+
const header = String(name).toLowerCase();
|
|
745
|
+
if (header === "x-frame-options" && String(value).trim().toLowerCase() === "deny") return true;
|
|
746
|
+
if (header === "content-security-policy") {
|
|
747
|
+
for (const directive of String(value).split(";")) {
|
|
748
|
+
const [directiveName, ...sources] = directive.trim().toLowerCase().split(/\s+/);
|
|
749
|
+
if (directiveName === "frame-ancestors" && sources.length === 1 && sources[0] === "'none'") return true;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return false;
|
|
754
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web IDL interface objects for the engine's singleton platform objects
|
|
3
|
+
* (navigator, screen, performance...).
|
|
4
|
+
*
|
|
5
|
+
* In a browser these objects have no own properties: attributes are
|
|
6
|
+
* accessors and operations are methods on the interface prototype
|
|
7
|
+
* (Navigator.prototype.userAgent is a getter), each throwing
|
|
8
|
+
* "Illegal invocation" for a receiver that is not a platform object of that
|
|
9
|
+
* interface, and Object.prototype.toString reads the interface name from
|
|
10
|
+
* Symbol.toStringTag. The interface object itself throws when called or
|
|
11
|
+
* constructed. Pages and integrity checks inspect exactly this shape.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} name interface name
|
|
16
|
+
* @param {object} template members as an object literal: accessors stay
|
|
17
|
+
* accessors, functions become operations, other values read-only attributes
|
|
18
|
+
* @param {{ parent?: object | null, base?: (prototype: object) => object }} [options]
|
|
19
|
+
* `parent` is the prototype's prototype; `base` makes an instance with the
|
|
20
|
+
* given prototype (an event target's constructor, for one)
|
|
21
|
+
* Accessors and operations run with `this` set to the instance's slots
|
|
22
|
+
* object (the template itself unless `create` was given one), so a template
|
|
23
|
+
* may read its other members, or per-instance data, through `this`.
|
|
24
|
+
*
|
|
25
|
+
* @returns {{ Interface: Function, prototype: object, instance: object, create: (slots?: object) => object }}
|
|
26
|
+
*/
|
|
27
|
+
export function platformObject(name, template, { parent = Object.prototype, base = (prototype) => Object.create(prototype) } = {}) {
|
|
28
|
+
const instances = new WeakMap();
|
|
29
|
+
const check = (self) => {
|
|
30
|
+
const slots = instances.get(self);
|
|
31
|
+
if (!slots) throw new TypeError("Illegal invocation");
|
|
32
|
+
return slots;
|
|
33
|
+
};
|
|
34
|
+
// An object literal method has no prototype and is not a constructor,
|
|
35
|
+
// as built-in operations and accessors are.
|
|
36
|
+
// Interface objects are constructors that refuse to construct.
|
|
37
|
+
const Interface = { [name]: function () { throw new TypeError("Illegal constructor"); } }[name];
|
|
38
|
+
const prototype = Object.create(parent);
|
|
39
|
+
for (const key of Reflect.ownKeys(template)) {
|
|
40
|
+
const descriptor = Object.getOwnPropertyDescriptor(template, key);
|
|
41
|
+
if (!descriptor) continue;
|
|
42
|
+
if (descriptor.get || descriptor.set) {
|
|
43
|
+
const { get, set } = descriptor;
|
|
44
|
+
const member = {};
|
|
45
|
+
Object.defineProperty(member, key, {
|
|
46
|
+
get: get ? accessor({ get [key]() { return get.call(check(this)); } }, key).get : undefined,
|
|
47
|
+
set: set ? accessor({ set [key](value) { set.call(check(this), value); } }, key).set : undefined,
|
|
48
|
+
});
|
|
49
|
+
Object.defineProperty(prototype, key, { ...Object.getOwnPropertyDescriptor(member, key), enumerable: true, configurable: true });
|
|
50
|
+
} else if (typeof descriptor.value === "function") {
|
|
51
|
+
const operation = descriptor.value;
|
|
52
|
+
const method = { [key](...args) { return Reflect.apply(operation, check(this), args); } }[key];
|
|
53
|
+
Object.defineProperty(method, "length", { value: operation.length });
|
|
54
|
+
Object.defineProperty(prototype, key, { value: method, writable: true, enumerable: true, configurable: true });
|
|
55
|
+
} else {
|
|
56
|
+
const { value } = descriptor;
|
|
57
|
+
Object.defineProperty(prototype, key, {
|
|
58
|
+
get: accessor({ get [key]() { check(this); return value; } }, key).get,
|
|
59
|
+
enumerable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
Object.defineProperty(prototype, "constructor", { value: Interface, writable: true, configurable: true });
|
|
65
|
+
Object.defineProperty(prototype, Symbol.toStringTag, { value: name, configurable: true });
|
|
66
|
+
Object.defineProperty(Interface, "prototype", { value: prototype, writable: false, enumerable: false, configurable: false });
|
|
67
|
+
const create = (slots = template) => {
|
|
68
|
+
const object = base(prototype);
|
|
69
|
+
instances.set(object, slots);
|
|
70
|
+
return object;
|
|
71
|
+
};
|
|
72
|
+
return { Interface, prototype, instance: create(), create };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const accessor = (object, key) => Object.getOwnPropertyDescriptor(object, key);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* window.localStorage and sessionStorage (HTML §12.2.1): the methods on
|
|
79
|
+
* Storage.prototype, and the stored items as each storage's own properties
|
|
80
|
+
* (`localStorage.theme` reads an item; assigning one stores a string).
|
|
81
|
+
*
|
|
82
|
+
* @param {object} parent Object.prototype of the page
|
|
83
|
+
* @returns {{ Storage: Function, prototype: object, create: (map: Map<string, string>) => object }}
|
|
84
|
+
*/
|
|
85
|
+
export function storageInterface(parent = Object.prototype) {
|
|
86
|
+
const maps = new WeakMap();
|
|
87
|
+
const mapOf = (self) => {
|
|
88
|
+
const map = maps.get(self);
|
|
89
|
+
if (!map) throw new TypeError("Illegal invocation");
|
|
90
|
+
return map;
|
|
91
|
+
};
|
|
92
|
+
const Storage = { Storage: function () { throw new TypeError("Illegal constructor"); } }.Storage;
|
|
93
|
+
const members = {
|
|
94
|
+
get length() { return mapOf(this).size; },
|
|
95
|
+
key(index) { return [...mapOf(this).keys()][Number(index)] ?? null; },
|
|
96
|
+
getItem(key) { const map = mapOf(this); return map.has(String(key)) ? map.get(String(key)) : null; },
|
|
97
|
+
setItem(key, value) { mapOf(this).set(String(key), String(value)); },
|
|
98
|
+
removeItem(key) { mapOf(this).delete(String(key)); },
|
|
99
|
+
clear() { mapOf(this).clear(); },
|
|
100
|
+
};
|
|
101
|
+
const prototype = Object.create(parent);
|
|
102
|
+
for (const key of Reflect.ownKeys(members)) {
|
|
103
|
+
const descriptor = Object.getOwnPropertyDescriptor(members, key);
|
|
104
|
+
if (!descriptor) continue;
|
|
105
|
+
Object.defineProperty(prototype, key, { ...descriptor, enumerable: true, configurable: true });
|
|
106
|
+
}
|
|
107
|
+
Object.defineProperty(prototype, "constructor", { value: Storage, writable: true, configurable: true });
|
|
108
|
+
Object.defineProperty(prototype, Symbol.toStringTag, { value: "Storage", configurable: true });
|
|
109
|
+
Object.defineProperty(Storage, "prototype", { value: prototype });
|
|
110
|
+
const create = (map) => {
|
|
111
|
+
const target = Object.create(prototype);
|
|
112
|
+
const named = (key) => typeof key === "string" && !(key in prototype);
|
|
113
|
+
const proxy = new Proxy(target, {
|
|
114
|
+
get(object, key) {
|
|
115
|
+
if (named(key) && map.has(key)) return map.get(key);
|
|
116
|
+
return Reflect.get(object, key, proxy);
|
|
117
|
+
},
|
|
118
|
+
set(object, key, value) {
|
|
119
|
+
if (!named(key)) return Reflect.set(object, key, value, proxy);
|
|
120
|
+
map.set(key, String(value));
|
|
121
|
+
return true;
|
|
122
|
+
},
|
|
123
|
+
has(object, key) {
|
|
124
|
+
return (named(key) && map.has(key)) || Reflect.has(object, key);
|
|
125
|
+
},
|
|
126
|
+
deleteProperty(object, key) {
|
|
127
|
+
if (named(key)) map.delete(key);
|
|
128
|
+
return Reflect.deleteProperty(object, key);
|
|
129
|
+
},
|
|
130
|
+
ownKeys(object) {
|
|
131
|
+
return [...map.keys(), ...Reflect.ownKeys(object)];
|
|
132
|
+
},
|
|
133
|
+
getOwnPropertyDescriptor(object, key) {
|
|
134
|
+
if (named(key) && map.has(key)) return { value: map.get(key), writable: true, enumerable: true, configurable: true };
|
|
135
|
+
return Reflect.getOwnPropertyDescriptor(object, key);
|
|
136
|
+
},
|
|
137
|
+
defineProperty(object, key, descriptor) {
|
|
138
|
+
if (!named(key) || !("value" in descriptor)) return Reflect.defineProperty(object, key, descriptor);
|
|
139
|
+
map.set(key, String(descriptor.value));
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
maps.set(proxy, map);
|
|
144
|
+
return proxy;
|
|
145
|
+
};
|
|
146
|
+
return { Storage, prototype, create };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** ECMAScript's own global properties: not enumerable on a browser's window. */
|
|
150
|
+
const LANGUAGE_GLOBALS = new Set(["globalThis", "Infinity", "NaN", "undefined", "eval", "isFinite", "isNaN",
|
|
151
|
+
"parseFloat", "parseInt", "decodeURI", "decodeURIComponent", "encodeURI", "encodeURIComponent", "escape", "unescape"]);
|
|
152
|
+
|
|
153
|
+
/** Namespace objects (Web IDL §3.13) that are lowercase, so not caught by the capitalized rule. */
|
|
154
|
+
const NAMESPACE_GLOBALS = new Set(["console"]);
|
|
155
|
+
|
|
156
|
+
/** An interface constant such as Node.ELEMENT_NODE. */
|
|
157
|
+
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The Web IDL shape of a page's global object, applied before page script:
|
|
161
|
+
*
|
|
162
|
+
* - every interface prototype names its interface for Object.prototype.toString
|
|
163
|
+
* (`[object Window]`, `[object HTMLDivElement]`),
|
|
164
|
+
* - language globals and interface objects (constructors and namespaces,
|
|
165
|
+
* which are capitalized) are not enumerable, while the window's own
|
|
166
|
+
* attributes and operations (document, setTimeout, onload...) are,
|
|
167
|
+
* - an interface prototype's constructor is not enumerable (§3.7.3), and
|
|
168
|
+
* - constants are read-only and non-configurable on both the interface
|
|
169
|
+
* object and its prototype (§3.7.6).
|
|
170
|
+
*
|
|
171
|
+
* @param {object} global the page global
|
|
172
|
+
* @param {object[]} intrinsicPrototypes prototypes that belong to the
|
|
173
|
+
* language (Object.prototype...), left without a tag
|
|
174
|
+
*/
|
|
175
|
+
function applyInterfaceShape(Interface, prototype) {
|
|
176
|
+
const constructor = Object.getOwnPropertyDescriptor(prototype, "constructor");
|
|
177
|
+
if (constructor?.enumerable && constructor.configurable) {
|
|
178
|
+
Object.defineProperty(prototype, "constructor", { ...constructor, enumerable: false });
|
|
179
|
+
}
|
|
180
|
+
for (const object of [Interface, prototype]) {
|
|
181
|
+
for (const key of Object.getOwnPropertyNames(object)) {
|
|
182
|
+
if (!CONSTANT_NAME.test(key)) continue;
|
|
183
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
184
|
+
if (!descriptor.configurable || typeof descriptor.value !== "number") continue;
|
|
185
|
+
Object.defineProperty(object, key, { value: descriptor.value, enumerable: true, writable: false, configurable: false });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function applyGlobalShape(global, intrinsicPrototypes = []) {
|
|
191
|
+
const skip = new Set(intrinsicPrototypes);
|
|
192
|
+
for (const key of Reflect.ownKeys(global)) {
|
|
193
|
+
if (typeof key !== "string") continue;
|
|
194
|
+
const descriptor = Object.getOwnPropertyDescriptor(global, key);
|
|
195
|
+
if (!descriptor) continue;
|
|
196
|
+
const value = descriptor.value;
|
|
197
|
+
const isInterface = /^[A-Z]/.test(key) && (typeof value === "function" || (value && typeof value === "object"));
|
|
198
|
+
if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
|
|
199
|
+
&& !skip.has(value.prototype) && !Object.hasOwn(value.prototype, Symbol.toStringTag)
|
|
200
|
+
&& Object.isExtensible(value.prototype)) {
|
|
201
|
+
Object.defineProperty(value.prototype, Symbol.toStringTag, { value: key, configurable: true });
|
|
202
|
+
}
|
|
203
|
+
if ((isInterface || LANGUAGE_GLOBALS.has(key) || NAMESPACE_GLOBALS.has(key)) && descriptor.enumerable && descriptor.configurable) {
|
|
204
|
+
Object.defineProperty(global, key, { ...descriptor, enumerable: false });
|
|
205
|
+
}
|
|
206
|
+
if (typeof value === "function" && /^[A-Z]/.test(key) && value.prototype && typeof value.prototype === "object"
|
|
207
|
+
&& !skip.has(value.prototype)) {
|
|
208
|
+
applyInterfaceShape(value, value.prototype);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const windowPrototype = Object.getPrototypeOf(global);
|
|
212
|
+
if (windowPrototype && !Object.hasOwn(windowPrototype, Symbol.toStringTag) && Object.isExtensible(windowPrototype)) {
|
|
213
|
+
Object.defineProperty(windowPrototype, Symbol.toStringTag, { value: "Window", configurable: true });
|
|
214
|
+
}
|
|
215
|
+
}
|