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
package/lib/renderer/main.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* Protocol (structured-clone messages):
|
|
9
9
|
* controller -> renderer { type: "call", id, method, params }
|
|
10
10
|
* { type: "fetch-result", id, result | error }
|
|
11
|
+
* { type: "fetch-chunk", id, data } (video/audio stream)
|
|
12
|
+
* { type: "fetch-end", id } / { type: "fetch-error", id, error }
|
|
11
13
|
* renderer -> controller { type: "ready", sandbox: { seccomp, ses } } (each { installed, reason })
|
|
12
14
|
* { type: "result", id, result | error }
|
|
13
15
|
* { type: "fetch", id, url, kind }
|
|
@@ -21,7 +23,9 @@
|
|
|
21
23
|
* controller -> renderer { type: "ws-event", id, event, ... } (open, message, error, close)
|
|
22
24
|
* renderer -> controller { type: "audio-open", id, rate, channels, name } / { type: "audio-data", id, data }
|
|
23
25
|
* / { type: "audio-finish", id } / { type: "audio-close", id } (audio output)
|
|
24
|
-
*
|
|
26
|
+
* / { type: "audio-resync", id } (paced from now on, after the writer paused)
|
|
27
|
+
* controller -> renderer { type: "audio-event", id, event, ... } (ready, request with bytes, ended, error,
|
|
28
|
+
* closed with sentBytes: the PCM that reached the output before audio-close)
|
|
25
29
|
*
|
|
26
30
|
* With --compositor, a compositor Worker (compositor.js) answers screenshots
|
|
27
31
|
* and scrolling on fd 3 while this thread is busy; frames go to it with
|
|
@@ -40,13 +44,15 @@ import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, MOBILE_FLAG, VERIFY_STYLE_FLAG } from
|
|
|
40
44
|
import { installSeccomp } from "./seccomp.js";
|
|
41
45
|
import { RENDERER_LOG_FLAG } from "../log.js";
|
|
42
46
|
import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
|
|
47
|
+
import { createBytePipe } from "../media/byte-pipe.js";
|
|
48
|
+
import { notifyRejectionHandled, notifyUnhandledRejection } from "./rejections.js";
|
|
43
49
|
|
|
44
50
|
const DANGEROUSLY_ALLOW_HOST_JS = "--dangerously-allow-host-js";
|
|
45
51
|
|
|
46
52
|
/** Methods the controller may invoke; anything else is ignored. */
|
|
47
53
|
const METHODS = new Set([
|
|
48
|
-
"loadDocument", "showFragment", "runJavaScriptURL", "url", "scrollTo", "resize", "screenshot", "title",
|
|
49
|
-
"scroll", "press", "type", "scrollOffset", "click", "evaluate", "setUserAgent", "consoleMessages", "whenLoaded",
|
|
54
|
+
"loadDocument", "showFragment", "wheel", "runJavaScriptURL", "url", "scrollTo", "resize", "screenshot", "title",
|
|
55
|
+
"scroll", "press", "type", "scrollOffset", "click", "evaluate", "setUserAgent", "setInitScripts", "consoleMessages", "whenLoaded",
|
|
50
56
|
"applyCompositorScroll",
|
|
51
57
|
]);
|
|
52
58
|
|
|
@@ -57,29 +63,48 @@ const logging = process.argv.includes(RENDERER_LOG_FLAG);
|
|
|
57
63
|
const stderr = process.stderr;
|
|
58
64
|
const report = (text) => stderr.write(`${text}\n`);
|
|
59
65
|
|
|
60
|
-
// As in browsers, a page's unhandled rejection is logged, not fatal
|
|
61
|
-
|
|
66
|
+
// As in browsers, a page's unhandled rejection is logged, not fatal, unless
|
|
67
|
+
// its unhandledrejection event is canceled.
|
|
68
|
+
process.on("unhandledRejection", (reason, promise) => {
|
|
69
|
+
if (notifyUnhandledRejection(reason, promise)) return;
|
|
62
70
|
const text = reason?.stack || (reason?.name ? `${reason.name}: ${reason.message}` : String(reason));
|
|
63
71
|
report(`buninu-browser: page error: unhandled rejection: ${logging ? text : text.split("\n")[0]}`);
|
|
64
72
|
});
|
|
65
73
|
|
|
74
|
+
process.on("rejectionHandled", (promise) => notifyRejectionHandled(promise));
|
|
75
|
+
|
|
66
76
|
const pendingFetches = new Map();
|
|
67
77
|
let nextFetchId = 1;
|
|
68
78
|
|
|
69
79
|
function fetchResource(url, kind) {
|
|
70
80
|
return new Promise((resolve, reject) => {
|
|
71
81
|
const id = nextFetchId++;
|
|
72
|
-
|
|
82
|
+
// The pipe exists before the request is sent, so a chunk that arrives
|
|
83
|
+
// before the head is still part of the body the caller receives.
|
|
84
|
+
const pipe = kind === "video" || kind === "audio" ? createBytePipe() : null;
|
|
85
|
+
pendingFetches.set(id, { resolve, reject, pipe, head: false });
|
|
73
86
|
process.send({ type: "fetch", id, url, kind });
|
|
74
87
|
});
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
function pageFetch(request) {
|
|
78
|
-
|
|
91
|
+
let fetchId;
|
|
92
|
+
let streamController;
|
|
93
|
+
const body = new ReadableStream({
|
|
94
|
+
start(controller) { streamController = controller; },
|
|
95
|
+
cancel() { promise.abort(); },
|
|
96
|
+
});
|
|
97
|
+
const promise = new Promise((resolve, reject) => {
|
|
79
98
|
const id = nextFetchId++;
|
|
80
|
-
|
|
99
|
+
fetchId = id;
|
|
100
|
+
pendingFetches.set(id, { resolve, reject, page: true, body, streamController, head: false });
|
|
81
101
|
process.send({ type: "page-fetch", id, request });
|
|
82
102
|
});
|
|
103
|
+
promise.abort = () => {
|
|
104
|
+
if (!pendingFetches.delete(fetchId)) return;
|
|
105
|
+
process.send({ type: "page-fetch-abort", id: fetchId });
|
|
106
|
+
};
|
|
107
|
+
return promise;
|
|
83
108
|
}
|
|
84
109
|
|
|
85
110
|
// Page WebSockets live in the controller (Bun's WebSocket); events come back as ws-event.
|
|
@@ -98,6 +123,8 @@ const webSockets = {
|
|
|
98
123
|
|
|
99
124
|
// Audio output: PCM goes to the controller, which plays it through PulseAudio.
|
|
100
125
|
const audioListeners = new Map();
|
|
126
|
+
// audio.close() promises, settled by the controller's "closed" event.
|
|
127
|
+
const audioClosing = new Map();
|
|
101
128
|
let nextAudioId = 1;
|
|
102
129
|
const audio = {
|
|
103
130
|
open({ rate, channels, name }, onEvent) {
|
|
@@ -108,14 +135,19 @@ const audio = {
|
|
|
108
135
|
},
|
|
109
136
|
write: (id, data) => process.send({ type: "audio-data", id, data }),
|
|
110
137
|
finish: (id) => process.send({ type: "audio-finish", id }),
|
|
138
|
+
resync: (id) => process.send({ type: "audio-resync", id }),
|
|
139
|
+
/** Closes a stream; resolves to the bytes of PCM that reached the output first. */
|
|
111
140
|
close(id) {
|
|
112
141
|
audioListeners.delete(id);
|
|
113
142
|
process.send({ type: "audio-close", id });
|
|
143
|
+
return new Promise((resolve) => audioClosing.set(id, resolve));
|
|
114
144
|
},
|
|
115
145
|
};
|
|
116
146
|
|
|
117
147
|
// Workers load their module from disk, so this happens before seccomp.
|
|
118
148
|
const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
|
|
149
|
+
// Started before the sandbox closes: the worker loads its module from disk.
|
|
150
|
+
const rewriteScript = await startScriptRewriter();
|
|
119
151
|
|
|
120
152
|
// Frames sent to the compositor and the latest one it confirmed.
|
|
121
153
|
let framesSent = 0;
|
|
@@ -149,9 +181,48 @@ async function startCompositor() {
|
|
|
149
181
|
return worker;
|
|
150
182
|
}
|
|
151
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A worker thread that rewrites classic scripts for the page realm (see
|
|
186
|
+
* rewrite-worker.js); null when it cannot start, and scripts are then
|
|
187
|
+
* rewritten on the main thread as before.
|
|
188
|
+
*/
|
|
189
|
+
async function startScriptRewriter() {
|
|
190
|
+
let worker;
|
|
191
|
+
try {
|
|
192
|
+
worker = new Worker(new URL("./rewrite-worker.js", import.meta.url).href);
|
|
193
|
+
await new Promise((resolve, reject) => {
|
|
194
|
+
worker.addEventListener("message", resolve, { once: true });
|
|
195
|
+
worker.addEventListener("error", reject, { once: true });
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
worker?.terminate();
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const pending = new Map();
|
|
202
|
+
let nextId = 0;
|
|
203
|
+
worker.addEventListener("message", ({ data }) => {
|
|
204
|
+
const request = pending.get(data?.id);
|
|
205
|
+
if (!request) return;
|
|
206
|
+
pending.delete(data.id);
|
|
207
|
+
if (data.error !== undefined) request.reject(new Error(data.error));
|
|
208
|
+
else request.resolve(data.result);
|
|
209
|
+
});
|
|
210
|
+
worker.addEventListener("error", (event) => {
|
|
211
|
+
for (const request of pending.values()) request.reject(new Error(String(event?.message ?? "script rewriter failed")));
|
|
212
|
+
pending.clear();
|
|
213
|
+
});
|
|
214
|
+
worker.unref?.();
|
|
215
|
+
return (source) => new Promise((resolve, reject) => {
|
|
216
|
+
const id = ++nextId;
|
|
217
|
+
pending.set(id, { resolve, reject });
|
|
218
|
+
worker.postMessage({ id, source });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
152
222
|
const renderer = await createPageRenderer({
|
|
153
223
|
fetchResource,
|
|
154
|
-
|
|
224
|
+
rewriteScript,
|
|
225
|
+
requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
|
|
155
226
|
requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
|
|
156
227
|
pageFetch,
|
|
157
228
|
webSockets,
|
|
@@ -161,7 +232,11 @@ const renderer = await createPageRenderer({
|
|
|
161
232
|
consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
|
|
162
233
|
trace: logging ? (text) => report(`timing: ${text}`) : null,
|
|
163
234
|
onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
|
|
164
|
-
|
|
235
|
+
// Whoever waits for load (a screenshot, say) sees the frame painted at load.
|
|
236
|
+
onLifecycle: (name, generation) => {
|
|
237
|
+
if (name !== "load") process.send({ type: "lifecycle", name, generation });
|
|
238
|
+
else compositorCaughtUp().then(() => process.send({ type: "lifecycle", name, generation }));
|
|
239
|
+
},
|
|
165
240
|
mobile: process.argv.includes(MOBILE_FLAG),
|
|
166
241
|
verifyStyle: process.argv.includes(VERIFY_STYLE_FLAG),
|
|
167
242
|
fullStyle: process.argv.includes(FULL_STYLE_FLAG),
|
|
@@ -224,15 +299,88 @@ async function warmUp() {
|
|
|
224
299
|
}
|
|
225
300
|
|
|
226
301
|
process.on("message", async (message) => {
|
|
302
|
+
if (message?.type === "page-fetch-head") {
|
|
303
|
+
const pending = pendingFetches.get(message.id);
|
|
304
|
+
if (!pending?.page || pending.head) return;
|
|
305
|
+
pending.head = true;
|
|
306
|
+
pending.resolve({ ...(message.result ?? {}), body: pending.body });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (message?.type === "page-fetch-chunk") {
|
|
310
|
+
pendingFetches.get(message.id)?.streamController?.enqueue(message.data);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (message?.type === "page-fetch-end") {
|
|
314
|
+
const pending = pendingFetches.get(message.id);
|
|
315
|
+
if (!pending?.page) return;
|
|
316
|
+
pendingFetches.delete(message.id);
|
|
317
|
+
pending.streamController.close();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (message?.type === "page-fetch-error") {
|
|
321
|
+
const pending = pendingFetches.get(message.id);
|
|
322
|
+
if (!pending?.page) return;
|
|
323
|
+
pendingFetches.delete(message.id);
|
|
324
|
+
const error = new Error(message.error || "fetch failed");
|
|
325
|
+
if (pending.head) pending.streamController.error(error);
|
|
326
|
+
else pending.reject(error);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (message?.type === "fetch-chunk") {
|
|
330
|
+
const pending = pendingFetches.get(message.id);
|
|
331
|
+
if (message.data) pending?.pipe?.append(message.data);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (message?.type === "fetch-end") {
|
|
335
|
+
const pending = pendingFetches.get(message.id);
|
|
336
|
+
if (!pending) return;
|
|
337
|
+
pendingFetches.delete(message.id);
|
|
338
|
+
pending.pipe?.finish();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (message?.type === "fetch-error") {
|
|
342
|
+
const pending = pendingFetches.get(message.id);
|
|
343
|
+
if (!pending) return;
|
|
344
|
+
pendingFetches.delete(message.id);
|
|
345
|
+
const error = new Error(message.error || "media fetch failed");
|
|
346
|
+
pending.pipe?.fail(error);
|
|
347
|
+
if (!pending.head) pending.reject(error);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
227
350
|
if (message?.type === "fetch-result") {
|
|
228
351
|
const pending = pendingFetches.get(message.id);
|
|
229
352
|
if (!pending) return;
|
|
353
|
+
if (message.error) {
|
|
354
|
+
pendingFetches.delete(message.id);
|
|
355
|
+
const error = new Error(message.error);
|
|
356
|
+
pending.pipe?.fail(error);
|
|
357
|
+
if (!pending.head) pending.reject(error);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const result = message.result ?? {};
|
|
361
|
+
if (result.streaming && pending.pipe) {
|
|
362
|
+
pending.head = true;
|
|
363
|
+
pending.resolve({ ...result, body: pending.pipe });
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
230
366
|
pendingFetches.delete(message.id);
|
|
231
|
-
if (
|
|
232
|
-
|
|
367
|
+
if (pending.pipe) {
|
|
368
|
+
if (result.body) pending.pipe.append(result.body);
|
|
369
|
+
pending.pipe.finish();
|
|
370
|
+
pending.head = true;
|
|
371
|
+
pending.resolve({ ...result, body: pending.pipe });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
pending.resolve(result);
|
|
233
375
|
return;
|
|
234
376
|
}
|
|
235
377
|
if (message?.type === "audio-event") {
|
|
378
|
+
if (message.event === "closed") {
|
|
379
|
+
const resolve = audioClosing.get(message.id);
|
|
380
|
+
audioClosing.delete(message.id);
|
|
381
|
+
resolve?.(Number.isFinite(message.sentBytes) ? message.sentBytes : 0);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
236
384
|
const listener = audioListeners.get(message.id);
|
|
237
385
|
if (message.event === "ended" || message.event === "error") audioListeners.delete(message.id);
|
|
238
386
|
listener?.(message);
|