buninu-browser 0.2.1 → 0.2.3
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/ai-debug.log +5537 -0
- 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/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- 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 +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- 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 +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -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 +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- 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 +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- 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/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- 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
|
+
}
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { ScrollViewport } from "../layout/scroll-viewport.js";
|
|
25
25
|
import { cropViewport, encodeImage } from "../paint/frame.js";
|
|
26
|
+
import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
|
|
27
|
+
import { VideoPresentation, compositeVideo } from "./video-presentation.js";
|
|
26
28
|
|
|
27
29
|
const CHANNEL_FD = 3;
|
|
28
30
|
const MAX_LINE_BYTES = 1024 * 1024;
|
|
@@ -32,6 +34,10 @@ let tile = null;
|
|
|
32
34
|
let scrollEpoch = -1;
|
|
33
35
|
let frameSerial = 0;
|
|
34
36
|
let cached = { key: "", data: "" };
|
|
37
|
+
const video = new VideoPresentation(wasmpeg, () => {
|
|
38
|
+
frameSerial++;
|
|
39
|
+
send({ type: "frame" });
|
|
40
|
+
}, report => postMessage({ type: "video-report", ...report }));
|
|
35
41
|
|
|
36
42
|
const channel = Bun.file(CHANNEL_FD).writer();
|
|
37
43
|
|
|
@@ -41,6 +47,9 @@ function send(message) {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
self.onmessage = ({ data }) => {
|
|
50
|
+
if (data?.type === "video-open") { void video.open(data); return; }
|
|
51
|
+
if (data?.type === "video-stop") { video.stop(data.id); frameSerial++; return; }
|
|
52
|
+
if (data?.type === "video-bind") { video.bind(data.id, data.audioId, data.audioStart, data.time); return; }
|
|
44
53
|
if (data?.type !== "frame") return;
|
|
45
54
|
tile = data.tile;
|
|
46
55
|
frameSerial++;
|
|
@@ -68,7 +77,8 @@ const methods = {
|
|
|
68
77
|
if (!tile) return "";
|
|
69
78
|
const key = `${frameSerial}:${viewport.x}:${viewport.y}:${format}:${quality}`;
|
|
70
79
|
if (cached.key !== key) {
|
|
71
|
-
const
|
|
80
|
+
const composited = tile.videoLayer?.id === video.config?.id ? compositeVideo(tile, video.frame) : tile;
|
|
81
|
+
const { pixels, width, height } = cropViewport(composited, viewport.x, viewport.y, viewport.width, viewport.height);
|
|
72
82
|
const bytes = await encodeImage(pixels, width, height, { format, quality });
|
|
73
83
|
cached = { key, data: Buffer.from(bytes).toString("base64") };
|
|
74
84
|
}
|
|
@@ -93,6 +103,10 @@ async function handleLine(line) {
|
|
|
93
103
|
} catch {
|
|
94
104
|
return;
|
|
95
105
|
}
|
|
106
|
+
if (request?.type === "audio-timing" && Number.isInteger(request.audioId)) {
|
|
107
|
+
video.timing(request.audioId, request.seconds);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
96
110
|
if (!Number.isInteger(request?.id) || !Object.hasOwn(methods, request.method)) return;
|
|
97
111
|
try {
|
|
98
112
|
const params = Array.isArray(request.params) ? request.params : [];
|
|
@@ -120,4 +134,5 @@ async function handleLine(line) {
|
|
|
120
134
|
// renderer installs seccomp, which forbids opening files.
|
|
121
135
|
await encodeImage(new Uint8Array(4), 1, 1);
|
|
122
136
|
await encodeImage(new Uint8Array(4), 1, 1, { format: "jpeg" });
|
|
137
|
+
await wasmpeg.load();
|
|
123
138
|
postMessage({ type: "ready" });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Bridge attribute reactions from the host DOM into the page's realm.
|
|
2
|
+
* Capture the callback and observed names at registration, as browsers do.
|
|
3
|
+
*/
|
|
4
|
+
export function installAttributeReactions(Adapter, constructor, realm, callPage) {
|
|
5
|
+
const callback = constructor.prototype.attributeChangedCallback;
|
|
6
|
+
if (typeof callback !== "function") return;
|
|
7
|
+
const observed = constructor.observedAttributes;
|
|
8
|
+
Object.defineProperty(Adapter, "observedAttributes", {
|
|
9
|
+
value: observed === undefined ? [] : Array.from(observed, String),
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(Adapter.prototype, "attributeChangedCallback", {
|
|
12
|
+
value(name, oldValue, newValue, namespace = null) {
|
|
13
|
+
callPage(callback, realm.wrap(this), [name, oldValue, newValue, namespace]);
|
|
14
|
+
},
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Methods borrowed from another window's History.prototype must operate on
|
|
2
|
+
// their receiver, not the window in which the function was created.
|
|
3
|
+
const histories = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function historyBinding(location, setHref) {
|
|
6
|
+
const state = { location, setHref, value: null, length: 1 };
|
|
7
|
+
const receiver = (object) => {
|
|
8
|
+
const record = histories.get(object);
|
|
9
|
+
if (!record) throw new TypeError("Illegal invocation");
|
|
10
|
+
return record;
|
|
11
|
+
};
|
|
12
|
+
const change = (object, data, url, push) => {
|
|
13
|
+
const record = receiver(object);
|
|
14
|
+
if (url !== undefined && url !== null) {
|
|
15
|
+
const target = new URL(String(url), record.location.href);
|
|
16
|
+
if (target.origin !== new URL(record.location.href).origin) {
|
|
17
|
+
throw new DOMException("History state URL must have the document's origin.", "SecurityError");
|
|
18
|
+
}
|
|
19
|
+
record.setHref(target.href);
|
|
20
|
+
}
|
|
21
|
+
record.value = data ?? null;
|
|
22
|
+
if (push) record.length++;
|
|
23
|
+
};
|
|
24
|
+
function History() { throw new TypeError("Illegal constructor"); }
|
|
25
|
+
History.prototype = {
|
|
26
|
+
constructor: History,
|
|
27
|
+
get length() { return receiver(this).length; },
|
|
28
|
+
get state() { return receiver(this).value; },
|
|
29
|
+
scrollRestoration: "auto",
|
|
30
|
+
back() {},
|
|
31
|
+
forward() {},
|
|
32
|
+
go() {},
|
|
33
|
+
pushState(data, _title, url = undefined) { change(this, data, url, true); },
|
|
34
|
+
replaceState(data, _title, url = undefined) { change(this, data, url, false); },
|
|
35
|
+
};
|
|
36
|
+
const history = Object.create(History.prototype);
|
|
37
|
+
histories.set(history, state);
|
|
38
|
+
return { History, history, pushEntry(replace) {
|
|
39
|
+
state.value = null;
|
|
40
|
+
if (!replace) state.length++;
|
|
41
|
+
} };
|
|
42
|
+
}
|
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) {
|
|
@@ -459,7 +489,14 @@ export class RendererHost {
|
|
|
459
489
|
/** A renderer's audio element starts playing: a PulseAudio stream relays its PCM. */
|
|
460
490
|
#openAudio(child, { id, rate, channels, name }) {
|
|
461
491
|
const send = (payload) => {
|
|
462
|
-
if (this.#child === child)
|
|
492
|
+
if (this.#child === child) {
|
|
493
|
+
child.send({ type: "audio-event", id, ...payload });
|
|
494
|
+
// The compositor's A/V clock must not wait behind page JavaScript.
|
|
495
|
+
if (payload.event === "timing" && this.#channel && !this.#channel.closed) {
|
|
496
|
+
this.#channel.writer.write(`${JSON.stringify({ type: "audio-timing", audioId: id, seconds: payload.seconds })}\n`);
|
|
497
|
+
this.#channel.writer.flush();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
463
500
|
};
|
|
464
501
|
const owned = [...this.#audio.values()].filter((entry) => entry.child === child).length;
|
|
465
502
|
if (this.#audio.has(id) || owned >= MAX_AUDIO_STREAMS
|
|
@@ -473,11 +510,14 @@ export class RendererHost {
|
|
|
473
510
|
rate, channels, name: String(name ?? "audio").slice(0, 200),
|
|
474
511
|
leadSeconds: name === "piano" ? 0.12 : undefined,
|
|
475
512
|
onRequest: (bytes) => send({ event: "request", bytes }),
|
|
513
|
+
onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
|
|
514
|
+
onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
|
|
476
515
|
}))
|
|
477
516
|
.then((stream) => {
|
|
478
517
|
if (this.#audio.get(id) !== entry) return stream.close();
|
|
479
518
|
entry.stream = stream;
|
|
480
519
|
for (const data of entry.early.splice(0)) stream.write(data);
|
|
520
|
+
send({ event: "ready" });
|
|
481
521
|
}, (error) => {
|
|
482
522
|
this.#audio.delete(id);
|
|
483
523
|
send({ event: "error", message: String(error?.message ?? error) });
|
|
@@ -493,6 +533,14 @@ export class RendererHost {
|
|
|
493
533
|
}
|
|
494
534
|
}
|
|
495
535
|
|
|
536
|
+
#closePageFetches(child) {
|
|
537
|
+
for (const [id, entry] of this.#pageFetches) {
|
|
538
|
+
if (entry.child !== child) continue;
|
|
539
|
+
this.#pageFetches.delete(id);
|
|
540
|
+
entry.controller.abort();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
496
544
|
#closeSockets(child) {
|
|
497
545
|
for (const [id, entry] of this.#sockets) {
|
|
498
546
|
if (entry.child !== child) continue;
|
|
@@ -516,15 +564,59 @@ export class RendererHost {
|
|
|
516
564
|
if (!ALLOWED_SCHEMES.has(target.protocol) || !RESOURCE_KINDS.has(kind)) {
|
|
517
565
|
return reply({ error: `blocked ${kind} request for ${target.protocol}` });
|
|
518
566
|
}
|
|
567
|
+
// A renderer only ever holds documents of its own origin (site isolation
|
|
568
|
+
// is by origin): an iframe's document is given to it only when it is
|
|
569
|
+
// same-origin with the page, before and after redirects, and allows
|
|
570
|
+
// being framed.
|
|
571
|
+
const frame = kind === "iframe";
|
|
572
|
+
if (frame && !sameOrigin(target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
573
|
+
// Video and audio over http(s) stream. data: stays one response: it is
|
|
574
|
+
// already in hand, and the renderer wraps it in a finished pipe.
|
|
575
|
+
if ((kind === "video" || kind === "audio") && target.protocol !== "data:") {
|
|
576
|
+
const send = (payload) => {
|
|
577
|
+
if (this.#child === child) child.send(payload);
|
|
578
|
+
};
|
|
579
|
+
try {
|
|
580
|
+
await this.#network.streamSubresource(target.href, this.#documentUrl, kind, (event) => {
|
|
581
|
+
if (event.type === "head") {
|
|
582
|
+
send({
|
|
583
|
+
type: "fetch-result", id,
|
|
584
|
+
result: {
|
|
585
|
+
streaming: true,
|
|
586
|
+
ok: event.ok,
|
|
587
|
+
status: event.status,
|
|
588
|
+
statusText: event.statusText ?? "",
|
|
589
|
+
url: event.url || target.href,
|
|
590
|
+
contentType: event.contentType ?? "",
|
|
591
|
+
contentLength: event.contentLength ?? null,
|
|
592
|
+
documentCookie: this.#network.documentCookie(this.#documentUrl),
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
} else if (event.type === "chunk" && event.data instanceof Uint8Array) {
|
|
596
|
+
send({ type: "fetch-chunk", id, data: event.data });
|
|
597
|
+
} else if (event.type === "end") {
|
|
598
|
+
send({ type: "fetch-end", id });
|
|
599
|
+
} else if (event.type === "error") {
|
|
600
|
+
send({ type: "fetch-error", id, error: String(event.error ?? "media fetch failed") });
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
} catch (error) {
|
|
604
|
+
send({ type: "fetch-error", id, error: String(error?.message ?? error) });
|
|
605
|
+
}
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
519
608
|
try {
|
|
520
609
|
const response = target.protocol === "data:"
|
|
521
610
|
? await dataResponse(target)
|
|
522
611
|
: await this.#network.subresource(target.href, this.#documentUrl, kind);
|
|
523
612
|
if (response.body.byteLength > MAX_RESOURCE_BYTES) return reply({ error: "resource too large" });
|
|
613
|
+
if (frame && !sameOrigin(response.url || target.href, this.#documentUrl)) return reply({ error: "cross-origin frame" });
|
|
614
|
+
if (frame && framingDenied(response.rawHeaders ?? [])) return reply({ error: "framing denied" });
|
|
524
615
|
reply({
|
|
525
616
|
result: {
|
|
526
617
|
ok: response.ok,
|
|
527
618
|
status: response.status,
|
|
619
|
+
statusText: response.statusText ?? "",
|
|
528
620
|
url: response.url || target.href,
|
|
529
621
|
contentType: response.contentType,
|
|
530
622
|
body: response.body,
|
|
@@ -541,17 +633,54 @@ export class RendererHost {
|
|
|
541
633
|
const reply = (payload) => {
|
|
542
634
|
if (this.#child === child) child.send({ type: "fetch-result", id, ...payload });
|
|
543
635
|
};
|
|
636
|
+
const started = performance.now();
|
|
637
|
+
const controller = new AbortController();
|
|
638
|
+
const entry = { child, controller };
|
|
639
|
+
this.#pageFetches.set(id, entry);
|
|
640
|
+
let streamHead = false;
|
|
641
|
+
let sanitized = null;
|
|
544
642
|
try {
|
|
545
|
-
|
|
546
|
-
const response = await this.#network.pageFetch(sanitized, this.#documentUrl
|
|
643
|
+
sanitized = sanitizePageRequest(request);
|
|
644
|
+
const response = await this.#network.pageFetch(sanitized, this.#documentUrl, {
|
|
645
|
+
signal: controller.signal,
|
|
646
|
+
onChunk: (event) => {
|
|
647
|
+
if (this.#child !== child) return;
|
|
648
|
+
if (event.type === "head") {
|
|
649
|
+
streamHead = true;
|
|
650
|
+
child.send({ type: "page-fetch-head", id, result: {
|
|
651
|
+
...event.result, documentCookie: this.#network.documentCookie(this.#documentUrl),
|
|
652
|
+
} });
|
|
653
|
+
}
|
|
654
|
+
else if (event.type === "chunk") child.send({ type: "page-fetch-chunk", id, data: event.data });
|
|
655
|
+
else if (event.type === "end") child.send({ type: "page-fetch-end", id });
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
if (response.streaming) {
|
|
659
|
+
logFetch(sanitized, `${response.status} streaming`, started);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
547
662
|
const { rawHeaders, ...result } = response;
|
|
663
|
+
logFetch(sanitized, `${response.status} ${response.body?.byteLength ?? 0}B`, started);
|
|
548
664
|
reply({ result: { ...result, documentCookie: this.#network.documentCookie(this.#documentUrl) } });
|
|
549
665
|
} catch (error) {
|
|
550
|
-
|
|
666
|
+
logFetch(sanitized ?? request, `failed: ${error?.message ?? error}`, started);
|
|
667
|
+
if (streamHead && this.#child === child) {
|
|
668
|
+
child.send({ type: "page-fetch-error", id, error: String(error?.message ?? error) });
|
|
669
|
+
} else reply({ error: String(error?.message ?? error) });
|
|
670
|
+
} finally {
|
|
671
|
+
if (this.#pageFetches.get(id) === entry) this.#pageFetches.delete(id);
|
|
551
672
|
}
|
|
552
673
|
}
|
|
553
674
|
}
|
|
554
675
|
|
|
676
|
+
/** With BUNINU_LOG, one line per page fetch()/XMLHttpRequest: method, URL (shortened), outcome, time. */
|
|
677
|
+
function logFetch(request, outcome, started) {
|
|
678
|
+
if (!logEnabled()) return;
|
|
679
|
+
const url = String(request?.url ?? "");
|
|
680
|
+
const shown = url.length > 160 ? `${url.slice(0, 160)}…` : url;
|
|
681
|
+
writeLog("fetch", `${request?.method ?? "GET"} ${shown} -> ${outcome} in ${Math.round(performance.now() - started)}ms`);
|
|
682
|
+
}
|
|
683
|
+
|
|
555
684
|
/** Keeps only the expected shape of a renderer's (untrusted) sandbox report. */
|
|
556
685
|
function sanitizeSandboxReport(report) {
|
|
557
686
|
const layer = (value) => ({
|
|
@@ -602,3 +731,31 @@ function sanitizeCompositorResult(method, result) {
|
|
|
602
731
|
const y = Number(result?.y);
|
|
603
732
|
return { x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 };
|
|
604
733
|
}
|
|
734
|
+
|
|
735
|
+
function sameOrigin(left, right) {
|
|
736
|
+
try {
|
|
737
|
+
const a = new URL(left);
|
|
738
|
+
const b = new URL(right);
|
|
739
|
+
return ["http:", "https:"].includes(a.protocol) && a.origin === b.origin;
|
|
740
|
+
} catch {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Whether a response refuses to be framed (by its own origin, the only case
|
|
747
|
+
* here): X-Frame-Options: DENY, or Content-Security-Policy frame-ancestors 'none'.
|
|
748
|
+
*/
|
|
749
|
+
function framingDenied(rawHeaders) {
|
|
750
|
+
for (const [name, value] of rawHeaders) {
|
|
751
|
+
const header = String(name).toLowerCase();
|
|
752
|
+
if (header === "x-frame-options" && String(value).trim().toLowerCase() === "deny") return true;
|
|
753
|
+
if (header === "content-security-policy") {
|
|
754
|
+
for (const directive of String(value).split(";")) {
|
|
755
|
+
const [directiveName, ...sources] = directive.trim().toLowerCase().split(/\s+/);
|
|
756
|
+
if (directiveName === "frame-ancestors" && sources.length === 1 && sources[0] === "'none'") return true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Image bytes are decoded by our renderer, not Happy DOM's disabled loader.
|
|
2
|
+
// Share their state with HTMLImageElement's page-facing IDL attributes.
|
|
3
|
+
const states = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function beginImageLoad(element, src) {
|
|
6
|
+
states.set(element, { src, complete: false, width: 0, height: 0 });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function finishImageLoad(element, src, resource, dispatch = () => {}) {
|
|
10
|
+
if (element.getAttribute('src') !== src) return;
|
|
11
|
+
states.set(element, { src, complete: true, width: resource?.width ?? 0, height: resource?.height ?? 0 });
|
|
12
|
+
dispatch(resource ? 'load' : 'error');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function installImageLoadingAttributes(prototype, nodeOf) {
|
|
16
|
+
for (const [property, field] of [['naturalWidth', 'width'], ['naturalHeight', 'height'], ['complete', 'complete']]) {
|
|
17
|
+
Object.defineProperty(prototype, property, {
|
|
18
|
+
configurable: true, enumerable: true,
|
|
19
|
+
get() {
|
|
20
|
+
const node = nodeOf(this), src = node.getAttribute('src');
|
|
21
|
+
const state = states.get(node);
|
|
22
|
+
if (state && state.src === src) return state[field];
|
|
23
|
+
return field === 'complete' ? !src : 0;
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|