buninu-browser 0.1.0 → 0.2.0
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 +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +102 -6
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
package/lib/renderer/flags.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/** Command-line flags the controller passes to renderer processes. */
|
|
2
|
+
import { CHROMIUM_VERSION } from "../user-agent.js";
|
|
2
3
|
|
|
3
4
|
/** fd 3 is the controller's compositor channel; start the compositor Worker. */
|
|
4
5
|
export const COMPOSITOR_FLAG = "--compositor";
|
|
5
6
|
|
|
7
|
+
/** BUNINU_VERIFY_STYLE: check every incremental restyle against a full one (development). */
|
|
8
|
+
export const VERIFY_STYLE_FLAG = "--buninu-verify-style";
|
|
9
|
+
/** BUNINU_FULL_STYLE: restyle the whole document every time (to compare with incremental restyles). */
|
|
10
|
+
export const FULL_STYLE_FLAG = "--buninu-full-style";
|
|
11
|
+
|
|
6
12
|
/** --mobile: the renderer presents itself as a phone (user agent, touch media features). */
|
|
7
13
|
export const MOBILE_FLAG = "--buninu-mobile";
|
|
8
14
|
|
|
9
|
-
/**
|
|
10
|
-
* A current Android WebView user agent, as sent with --mobile. Sites such as
|
|
11
|
-
* Google reject the frozen "Android 10; K" string of reduced user agents.
|
|
12
|
-
*/
|
|
15
|
+
/** Chrome for Android on an Android 16 phone, as sent with --mobile. */
|
|
13
16
|
export const MOBILE_USER_AGENT =
|
|
14
|
-
|
|
17
|
+
`Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Mobile Safari/537.36`;
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* --mobile lays pages out no wider than a phone (CSS pixels; a Pixel-class
|
package/lib/renderer/host.js
CHANGED
|
@@ -13,18 +13,33 @@
|
|
|
13
13
|
* scrolling even while the main thread is busy (see compositor.js).
|
|
14
14
|
*/
|
|
15
15
|
import { closeSync } from "node:fs";
|
|
16
|
+
import { PulseClient } from "../audio/pulse-client.js";
|
|
16
17
|
import { logEnabled, pipeToLog, RENDERER_LOG_FLAG } from "../log.js";
|
|
17
18
|
import { NetworkService } from "../network/network-service.js";
|
|
18
|
-
import { COMPOSITOR_FLAG } from "./flags.js";
|
|
19
|
+
import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
|
|
19
20
|
|
|
20
21
|
const ALLOWED_SCHEMES = new Set(["http:", "https:", "data:"]);
|
|
21
22
|
const FETCH_MODES = new Set(["cors", "no-cors", "same-origin"]);
|
|
22
23
|
const CREDENTIALS_MODES = new Set(["omit", "same-origin", "include"]);
|
|
23
24
|
const REDIRECT_MODES = new Set(["follow", "error", "manual"]);
|
|
24
25
|
const MAX_REQUEST_HEADERS = 100;
|
|
25
|
-
const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video"]);
|
|
26
|
+
const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio"]);
|
|
26
27
|
const NAVIGATION_SCHEMES = new Set(["http:", "https:", "about:", "data:"]);
|
|
27
28
|
const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
|
|
29
|
+
// Audio output: one PulseAudio native-protocol connection (jspulse or a
|
|
30
|
+
// PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
|
|
31
|
+
// first use. Renderers cannot open sockets, so their audio comes through here.
|
|
32
|
+
let pulse = null;
|
|
33
|
+
function pulseClient() {
|
|
34
|
+
pulse ??= PulseClient.connect().catch((error) => {
|
|
35
|
+
pulse = null;
|
|
36
|
+
throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
|
|
37
|
+
});
|
|
38
|
+
return pulse;
|
|
39
|
+
}
|
|
40
|
+
const MAX_AUDIO_STREAMS = 8;
|
|
41
|
+
const MAX_AUDIO_CHUNK = 4 * 1024 * 1024;
|
|
42
|
+
|
|
28
43
|
// WebSockets page script may hold open per renderer, and the largest message either way.
|
|
29
44
|
const MAX_WEB_SOCKETS = 64;
|
|
30
45
|
const MAX_WEB_SOCKET_MESSAGE = 16 * 1024 * 1024;
|
|
@@ -42,6 +57,7 @@ export class RendererHost {
|
|
|
42
57
|
#timeout;
|
|
43
58
|
#onRestart;
|
|
44
59
|
#onNavigate;
|
|
60
|
+
#onDownload;
|
|
45
61
|
#network;
|
|
46
62
|
// The document this host asked its renderer to load: the trusted origin
|
|
47
63
|
// for everything the renderer requests.
|
|
@@ -51,6 +67,8 @@ export class RendererHost {
|
|
|
51
67
|
#pending = new Map();
|
|
52
68
|
// Page WebSockets: id -> { child, socket } (Bun's WebSocket, opened here)
|
|
53
69
|
#sockets = new Map();
|
|
70
|
+
// Audio streams: id -> { child, stream } (PulseAudio playback streams)
|
|
71
|
+
#audio = new Map();
|
|
54
72
|
#nextId = 1;
|
|
55
73
|
#queue = Promise.resolve();
|
|
56
74
|
#restarting = false;
|
|
@@ -68,7 +86,8 @@ export class RendererHost {
|
|
|
68
86
|
* args?: string[],
|
|
69
87
|
* timeout?: number,
|
|
70
88
|
* onRestart?: (call: (method: string, ...params: unknown[]) => Promise<unknown>) => Promise<void>,
|
|
71
|
-
* onNavigate?: (url: string) => void,
|
|
89
|
+
* onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null) => void,
|
|
90
|
+
* onDownload?: (payload: { url: string, name: string, data: Uint8Array }) => void,
|
|
72
91
|
* onFrame?: () => void,
|
|
73
92
|
* onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
|
|
74
93
|
* compositor?: boolean,
|
|
@@ -77,7 +96,8 @@ export class RendererHost {
|
|
|
77
96
|
* }} options
|
|
78
97
|
*/
|
|
79
98
|
constructor({
|
|
80
|
-
entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null,
|
|
99
|
+
entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
|
|
100
|
+
fetch: fetchImpl = fetch,
|
|
81
101
|
network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
|
|
82
102
|
// The compositor's channel is an extra stdio pipe used as fd 3, which
|
|
83
103
|
// Windows does not have (Bun hands out libuv pipes there, not file
|
|
@@ -88,6 +108,7 @@ export class RendererHost {
|
|
|
88
108
|
this.#onLifecycle = onLifecycle;
|
|
89
109
|
this.#compositor = compositor;
|
|
90
110
|
this.#onNavigate = onNavigate;
|
|
111
|
+
this.#onDownload = onDownload;
|
|
91
112
|
this.#entry = entry;
|
|
92
113
|
this.#args = args;
|
|
93
114
|
this.#timeout = timeout;
|
|
@@ -108,7 +129,8 @@ export class RendererHost {
|
|
|
108
129
|
const compositor = this.#compositor;
|
|
109
130
|
const child = Bun.spawn({
|
|
110
131
|
cmd: [process.execPath, this.#entry, ...this.#args,
|
|
111
|
-
...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : [])
|
|
132
|
+
...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : []),
|
|
133
|
+
...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
|
|
112
134
|
// No inherited environment: nothing from the controller leaks in.
|
|
113
135
|
// Windows programs need SystemRoot to load system libraries.
|
|
114
136
|
env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
|
|
@@ -126,6 +148,7 @@ export class RendererHost {
|
|
|
126
148
|
onExit: (_process, exitCode, signalCode) => {
|
|
127
149
|
this.#closeChannel(channel);
|
|
128
150
|
this.#closeSockets(child);
|
|
151
|
+
this.#closeAudio(child);
|
|
129
152
|
if (this.#child !== child) return;
|
|
130
153
|
console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
|
|
131
154
|
this.#child = null;
|
|
@@ -277,6 +300,7 @@ export class RendererHost {
|
|
|
277
300
|
this.#closeChannel(this.#channel);
|
|
278
301
|
const child = this.#child;
|
|
279
302
|
this.#closeSockets(child);
|
|
303
|
+
this.#closeAudio(child);
|
|
280
304
|
this.#child = null;
|
|
281
305
|
this.#ready = null;
|
|
282
306
|
child?.kill();
|
|
@@ -350,6 +374,28 @@ export class RendererHost {
|
|
|
350
374
|
if (entry?.child !== child) return;
|
|
351
375
|
const code = Number.isInteger(message.code) ? message.code : undefined;
|
|
352
376
|
entry.socket.close(code, typeof message.reason === "string" ? message.reason.slice(0, 123) : undefined);
|
|
377
|
+
} else if (message.type === "audio-open" && Number.isInteger(message.id)) {
|
|
378
|
+
this.#openAudio(child, message);
|
|
379
|
+
} else if (message.type === "audio-data" && Number.isInteger(message.id)) {
|
|
380
|
+
const entry = this.#audio.get(message.id);
|
|
381
|
+
if (entry?.child === child && message.data instanceof Uint8Array && message.data.byteLength <= MAX_AUDIO_CHUNK) {
|
|
382
|
+
// Audio that arrives before the stream is open waits for it.
|
|
383
|
+
if (entry.stream) entry.stream.write(message.data);
|
|
384
|
+
else entry.early.push(message.data);
|
|
385
|
+
}
|
|
386
|
+
} else if (message.type === "audio-finish" && Number.isInteger(message.id)) {
|
|
387
|
+
const entry = this.#audio.get(message.id);
|
|
388
|
+
if (entry?.child !== child) return;
|
|
389
|
+
const send = (payload) => this.#child === child && child.send({ type: "audio-event", id: message.id, ...payload });
|
|
390
|
+
(entry.opened ?? Promise.resolve()).then(() => entry.stream?.finish()).then(() => {
|
|
391
|
+
this.#audio.delete(message.id);
|
|
392
|
+
send({ event: "ended" });
|
|
393
|
+
}, () => this.#audio.delete(message.id));
|
|
394
|
+
} else if (message.type === "audio-close" && Number.isInteger(message.id)) {
|
|
395
|
+
const entry = this.#audio.get(message.id);
|
|
396
|
+
if (entry?.child !== child) return;
|
|
397
|
+
this.#audio.delete(message.id);
|
|
398
|
+
(entry.opened ?? Promise.resolve()).then(() => entry.stream?.close()).catch(() => {});
|
|
353
399
|
} else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
|
|
354
400
|
this.#onLifecycle?.(message.name, message.generation);
|
|
355
401
|
} else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
|
|
@@ -362,7 +408,19 @@ export class RendererHost {
|
|
|
362
408
|
} catch {
|
|
363
409
|
return;
|
|
364
410
|
}
|
|
365
|
-
if (NAVIGATION_SCHEMES.has(target.protocol))
|
|
411
|
+
if (NAVIGATION_SCHEMES.has(target.protocol)) {
|
|
412
|
+
const download = typeof message.download === "string" ? message.download.slice(0, 200) : null;
|
|
413
|
+
const from = message.fragmentFrom;
|
|
414
|
+
const fragmentFrom = from && Number.isFinite(from.x) && Number.isFinite(from.y) ? { x: from.x, y: from.y } : null;
|
|
415
|
+
this.#onNavigate?.(target.href, download, fragmentFrom);
|
|
416
|
+
}
|
|
417
|
+
} else if (message.type === "download-data" && typeof message.url === "string"
|
|
418
|
+
&& message.data instanceof Uint8Array && message.data.byteLength <= 64 * 1024 * 1024) {
|
|
419
|
+
let target;
|
|
420
|
+
try { target = new URL(message.url); } catch { return; }
|
|
421
|
+
if (target.protocol !== "blob:") return;
|
|
422
|
+
this.#onDownload?.({ url: target.href, name: typeof message.name === "string" ? message.name.slice(0, 200) : "",
|
|
423
|
+
data: message.data });
|
|
366
424
|
}
|
|
367
425
|
}
|
|
368
426
|
|
|
@@ -398,6 +456,43 @@ export class RendererHost {
|
|
|
398
456
|
});
|
|
399
457
|
}
|
|
400
458
|
|
|
459
|
+
/** A renderer's audio element starts playing: a PulseAudio stream relays its PCM. */
|
|
460
|
+
#openAudio(child, { id, rate, channels, name }) {
|
|
461
|
+
const send = (payload) => {
|
|
462
|
+
if (this.#child === child) child.send({ type: "audio-event", id, ...payload });
|
|
463
|
+
};
|
|
464
|
+
const owned = [...this.#audio.values()].filter((entry) => entry.child === child).length;
|
|
465
|
+
if (this.#audio.has(id) || owned >= MAX_AUDIO_STREAMS
|
|
466
|
+
|| !(Number.isInteger(rate) && rate >= 1000 && rate <= 384000) || !(Number.isInteger(channels) && channels >= 1 && channels <= 8)) {
|
|
467
|
+
send({ event: "error", message: "invalid audio stream" });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const entry = { child, stream: null, early: [] };
|
|
471
|
+
entry.opened = pulseClient()
|
|
472
|
+
.then((client) => client.openPlayback({
|
|
473
|
+
rate, channels, name: String(name ?? "audio").slice(0, 200),
|
|
474
|
+
leadSeconds: name === "piano" ? 0.12 : undefined,
|
|
475
|
+
onRequest: (bytes) => send({ event: "request", bytes }),
|
|
476
|
+
}))
|
|
477
|
+
.then((stream) => {
|
|
478
|
+
if (this.#audio.get(id) !== entry) return stream.close();
|
|
479
|
+
entry.stream = stream;
|
|
480
|
+
for (const data of entry.early.splice(0)) stream.write(data);
|
|
481
|
+
}, (error) => {
|
|
482
|
+
this.#audio.delete(id);
|
|
483
|
+
send({ event: "error", message: String(error?.message ?? error) });
|
|
484
|
+
});
|
|
485
|
+
this.#audio.set(id, entry);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#closeAudio(child) {
|
|
489
|
+
for (const [id, entry] of this.#audio) {
|
|
490
|
+
if (entry.child !== child) continue;
|
|
491
|
+
this.#audio.delete(id);
|
|
492
|
+
(entry.opened ?? Promise.resolve()).then(() => entry.stream?.close()).catch(() => {});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
401
496
|
#closeSockets(child) {
|
|
402
497
|
for (const [id, entry] of this.#sockets) {
|
|
403
498
|
if (entry.child !== child) continue;
|
|
@@ -433,6 +528,7 @@ export class RendererHost {
|
|
|
433
528
|
url: response.url || target.href,
|
|
434
529
|
contentType: response.contentType,
|
|
435
530
|
body: response.body,
|
|
531
|
+
documentCookie: this.#network.documentCookie(this.#documentUrl),
|
|
436
532
|
},
|
|
437
533
|
});
|
|
438
534
|
} catch (error) {
|
package/lib/renderer/main.js
CHANGED
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
* renderer -> controller { type: "ready", sandbox: { seccomp, ses } } (each { installed, reason })
|
|
12
12
|
* { type: "result", id, result | error }
|
|
13
13
|
* { type: "fetch", id, url, kind }
|
|
14
|
-
* { type: "navigate", url }
|
|
14
|
+
* { type: "navigate", url, download, fragmentFrom } (fragmentFrom: the renderer
|
|
15
|
+
* already showed this fragment; the scroll offset it left)
|
|
15
16
|
* { type: "page-fetch", id, request } (fetch/XHR, answered by fetch-result)
|
|
16
17
|
* { type: "cookie", value } (document.cookie = value)
|
|
17
18
|
* { type: "lifecycle", name, generation } (DOMContentLoaded, load)
|
|
18
19
|
* { type: "ws-open", id, url, protocols } / { type: "ws-send", id, data }
|
|
19
20
|
* / { type: "ws-close", id, code, reason } (page WebSockets)
|
|
20
21
|
* controller -> renderer { type: "ws-event", id, event, ... } (open, message, error, close)
|
|
22
|
+
* renderer -> controller { type: "audio-open", id, rate, channels, name } / { type: "audio-data", id, data }
|
|
23
|
+
* / { type: "audio-finish", id } / { type: "audio-close", id } (audio output)
|
|
24
|
+
* controller -> renderer { type: "audio-event", id, event, ... } (request with bytes, ended, error)
|
|
21
25
|
*
|
|
22
26
|
* With --compositor, a compositor Worker (compositor.js) answers screenshots
|
|
23
27
|
* and scrolling on fd 3 while this thread is busy; frames go to it with
|
|
@@ -32,7 +36,7 @@
|
|
|
32
36
|
*/
|
|
33
37
|
import "../../vendor/ses/dist/ses.mjs";
|
|
34
38
|
import { createPageRenderer } from "./page-renderer.js";
|
|
35
|
-
import { COMPOSITOR_FLAG, MOBILE_FLAG } from "./flags.js";
|
|
39
|
+
import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, MOBILE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
|
|
36
40
|
import { installSeccomp } from "./seccomp.js";
|
|
37
41
|
import { RENDERER_LOG_FLAG } from "../log.js";
|
|
38
42
|
import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
|
|
@@ -92,6 +96,24 @@ const webSockets = {
|
|
|
92
96
|
close: (id, code, reason) => process.send({ type: "ws-close", id, code, reason }),
|
|
93
97
|
};
|
|
94
98
|
|
|
99
|
+
// Audio output: PCM goes to the controller, which plays it through PulseAudio.
|
|
100
|
+
const audioListeners = new Map();
|
|
101
|
+
let nextAudioId = 1;
|
|
102
|
+
const audio = {
|
|
103
|
+
open({ rate, channels, name }, onEvent) {
|
|
104
|
+
const id = nextAudioId++;
|
|
105
|
+
audioListeners.set(id, onEvent);
|
|
106
|
+
process.send({ type: "audio-open", id, rate, channels, name });
|
|
107
|
+
return id;
|
|
108
|
+
},
|
|
109
|
+
write: (id, data) => process.send({ type: "audio-data", id, data }),
|
|
110
|
+
finish: (id) => process.send({ type: "audio-finish", id }),
|
|
111
|
+
close(id) {
|
|
112
|
+
audioListeners.delete(id);
|
|
113
|
+
process.send({ type: "audio-close", id });
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
95
117
|
// Workers load their module from disk, so this happens before seccomp.
|
|
96
118
|
const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
|
|
97
119
|
|
|
@@ -129,9 +151,11 @@ async function startCompositor() {
|
|
|
129
151
|
|
|
130
152
|
const renderer = await createPageRenderer({
|
|
131
153
|
fetchResource,
|
|
132
|
-
requestNavigation: (url) => process.send({ type: "navigate", url }),
|
|
154
|
+
requestNavigation: (url, download = null, fragmentFrom = null) => process.send({ type: "navigate", url, download, fragmentFrom }),
|
|
155
|
+
requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
|
|
133
156
|
pageFetch,
|
|
134
157
|
webSockets,
|
|
158
|
+
audio,
|
|
135
159
|
setCookie: (value) => process.send({ type: "cookie", value }),
|
|
136
160
|
reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
|
|
137
161
|
consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
|
|
@@ -139,6 +163,8 @@ const renderer = await createPageRenderer({
|
|
|
139
163
|
onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
|
|
140
164
|
onLifecycle: (name, generation) => process.send({ type: "lifecycle", name, generation }),
|
|
141
165
|
mobile: process.argv.includes(MOBILE_FLAG),
|
|
166
|
+
verifyStyle: process.argv.includes(VERIFY_STYLE_FLAG),
|
|
167
|
+
fullStyle: process.argv.includes(FULL_STYLE_FLAG),
|
|
142
168
|
videoDecoder: wasmpeg,
|
|
143
169
|
});
|
|
144
170
|
await warmUp();
|
|
@@ -206,6 +232,12 @@ process.on("message", async (message) => {
|
|
|
206
232
|
else pending.resolve(message.result);
|
|
207
233
|
return;
|
|
208
234
|
}
|
|
235
|
+
if (message?.type === "audio-event") {
|
|
236
|
+
const listener = audioListeners.get(message.id);
|
|
237
|
+
if (message.event === "ended" || message.event === "error") audioListeners.delete(message.id);
|
|
238
|
+
listener?.(message);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
209
241
|
if (message?.type === "ws-event") {
|
|
210
242
|
const listener = socketListeners.get(message.id);
|
|
211
243
|
if (message.event === "close") socketListeners.delete(message.id);
|
|
@@ -124,8 +124,9 @@ export function transformModule(source) {
|
|
|
124
124
|
|
|
125
125
|
rewriteImportReferences(program, importExpressions, edits);
|
|
126
126
|
|
|
127
|
-
// import.meta and import() can appear anywhere in the module
|
|
128
|
-
|
|
127
|
+
// import.meta and import() can appear anywhere in the module; most
|
|
128
|
+
// modules have neither, and then the extra walk is skipped.
|
|
129
|
+
if (/\bimport\s*[.(]/.test(source)) walk(program, (node) => {
|
|
129
130
|
if (node.type === "MetaProperty" && node.meta.name === "import") {
|
|
130
131
|
edits.push({ start: node.start, end: node.end, text: `${MODULE}.meta` });
|
|
131
132
|
} else if (node.type === "ImportExpression") {
|
|
@@ -135,14 +136,17 @@ export function transformModule(source) {
|
|
|
135
136
|
return true;
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
const offset = source.
|
|
139
|
+
// Applied in one pass: re-slicing the whole source per edit was quadratic.
|
|
140
|
+
edits.sort((left, right) => left.start - right.start);
|
|
141
|
+
const offset = source.startsWith("#!") ? source.indexOf("\n") + 1 : 0;
|
|
142
|
+
const pieces = [];
|
|
143
|
+
let cursor = offset;
|
|
141
144
|
for (const edit of edits) {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
body = body.slice(0, start) + edit.text + body.slice(end);
|
|
145
|
+
pieces.push(source.slice(cursor, edit.start), edit.text);
|
|
146
|
+
cursor = edit.end;
|
|
145
147
|
}
|
|
148
|
+
pieces.push(source.slice(cursor));
|
|
149
|
+
const body = pieces.join("");
|
|
146
150
|
const stars = starExports.map((index) => `${MODULE}.exportStar(${namespace(index)});`).join("\n");
|
|
147
151
|
const code = `(async function (${MODULE}) {
|
|
148
152
|
"use strict";
|
|
@@ -319,12 +323,7 @@ function rewriteImportReferences(program, imports, edits) {
|
|
|
319
323
|
case "ExportAllDeclaration":
|
|
320
324
|
return;
|
|
321
325
|
default:
|
|
322
|
-
|
|
323
|
-
if (key === "type" || key === "start" || key === "end") continue;
|
|
324
|
-
for (const child of Array.isArray(value) ? value : [value]) {
|
|
325
|
-
if (child && typeof child === "object") expression(child);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
326
|
+
forEachChild(node, expression);
|
|
328
327
|
}
|
|
329
328
|
}
|
|
330
329
|
|
|
@@ -353,12 +352,7 @@ function collectVarNames(node, names) {
|
|
|
353
352
|
if (node.type === "VariableDeclaration" && node.kind === "var") {
|
|
354
353
|
for (const declarator of node.declarations) for (const name of boundNames(declarator.id)) names.add(name);
|
|
355
354
|
}
|
|
356
|
-
|
|
357
|
-
if (key === "type" || key === "start" || key === "end") continue;
|
|
358
|
-
for (const child of Array.isArray(value) ? value : [value]) {
|
|
359
|
-
if (child && typeof child === "object") collectVarNames(child, names);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
355
|
+
forEachChild(node, (child) => collectVarNames(child, names));
|
|
362
356
|
}
|
|
363
357
|
|
|
364
358
|
function importType(node) {
|
|
@@ -371,10 +365,23 @@ function importType(node) {
|
|
|
371
365
|
function walk(node, visit) {
|
|
372
366
|
if (!node || typeof node.type !== "string") return;
|
|
373
367
|
if (visit(node) === false) return;
|
|
374
|
-
|
|
368
|
+
forEachChild(node, (child) => walk(child, visit));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Calls `each` with every child object of an AST node. A for-in loop with
|
|
373
|
+
* no temporary arrays: Object.entries and [value] wrappers per node were
|
|
374
|
+
* most of the time spent transforming large modules.
|
|
375
|
+
*/
|
|
376
|
+
function forEachChild(node, each) {
|
|
377
|
+
for (const key in node) {
|
|
375
378
|
if (key === "type" || key === "start" || key === "end") continue;
|
|
376
|
-
|
|
377
|
-
|
|
379
|
+
const value = node[key];
|
|
380
|
+
if (value === null || typeof value !== "object") continue;
|
|
381
|
+
if (Array.isArray(value)) {
|
|
382
|
+
for (const child of value) if (child !== null && typeof child === "object") each(child);
|
|
383
|
+
} else {
|
|
384
|
+
each(value);
|
|
378
385
|
}
|
|
379
386
|
}
|
|
380
387
|
}
|