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.
Files changed (80) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +19 -12
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/happy-dom/selector-matcher.js +83 -0
  11. package/lib/headless-shell.js +120 -28
  12. package/lib/input/hit-test.js +25 -7
  13. package/lib/layout/text-layout.js +365 -54
  14. package/lib/log.js +5 -2
  15. package/lib/media/byte-pipe.js +82 -0
  16. package/lib/media/position-audio.js +42 -0
  17. package/lib/network/network-service.js +326 -46
  18. package/lib/paint/display-list.js +322 -21
  19. package/lib/paint/gradient.js +11 -5
  20. package/lib/render-tree/index.js +62 -2
  21. package/lib/render-tree/svg.js +2 -1
  22. package/lib/renderer/bindings.js +989 -72
  23. package/lib/renderer/canvas-2d.js +92 -0
  24. package/lib/renderer/host.js +159 -9
  25. package/lib/renderer/interfaces.js +215 -0
  26. package/lib/renderer/main.js +160 -12
  27. package/lib/renderer/media-source.js +819 -0
  28. package/lib/renderer/messaging.js +411 -0
  29. package/lib/renderer/native-functions.js +113 -0
  30. package/lib/renderer/page-realm.js +1407 -200
  31. package/lib/renderer/page-renderer.js +1899 -394
  32. package/lib/renderer/rejections.js +31 -0
  33. package/lib/renderer/rewrite-worker.js +22 -0
  34. package/lib/renderer/script-rewrite.js +805 -57
  35. package/lib/renderer/trusted-types.js +129 -0
  36. package/lib/renderer/workers.js +327 -0
  37. package/lib/screenshot-cli.js +2 -0
  38. package/lib/style/computed-style.js +463 -242
  39. package/lib/style/selector-keys.js +302 -0
  40. package/lib/style/selector-traits.js +240 -0
  41. package/lib/user-agent.js +14 -2
  42. package/package.json +4 -1
  43. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  44. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  45. package/patches/happy-dom/README.md +26 -0
  46. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  47. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  48. package/patches/ses/README.md +4 -0
  49. package/test/canvas-2d.test.js +55 -0
  50. package/test/compositor.test.js +3 -1
  51. package/test/computed-style.test.js +98 -1
  52. package/test/display-list.test.js +74 -1
  53. package/test/download.test.js +35 -0
  54. package/test/fixtures/long-video.mp4 +0 -0
  55. package/test/fixtures/media-sync.js +57 -0
  56. package/test/happy-dom-platform-adapters.test.js +25 -0
  57. package/test/headless-shell.test.js +29 -0
  58. package/test/hit-test.test.js +25 -1
  59. package/test/log.test.js +4 -1
  60. package/test/media-source.test.js +156 -0
  61. package/test/media-sync.test.js +12 -0
  62. package/test/mobile.test.js +2 -2
  63. package/test/navigation.test.js +61 -0
  64. package/test/network-service.test.js +81 -0
  65. package/test/page-scripts.test.js +1094 -7
  66. package/test/position-audio.test.js +67 -0
  67. package/test/positioning.test.js +95 -1
  68. package/test/pulse-client.test.js +69 -0
  69. package/test/render-tree.test.js +22 -1
  70. package/test/renderer-host.test.js +5 -2
  71. package/test/screenshot-cli.test.js +25 -1
  72. package/test/script-rewrite.test.js +111 -6
  73. package/test/selector-keys.test.js +39 -0
  74. package/test/selector-traits.test.js +19 -0
  75. package/test/setup.js +5 -0
  76. package/test/text-layout.test.js +69 -1
  77. package/test/user-agent.test.js +60 -4
  78. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  79. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  80. package/vendor/ses/dist/ses.mjs +52 -6
@@ -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
- * controller -> renderer { type: "audio-event", id, event, ... } (request with bytes, ended, error)
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
- process.on("unhandledRejection", (reason) => {
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
- pendingFetches.set(id, { resolve, reject });
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
- return new Promise((resolve, reject) => {
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
- pendingFetches.set(id, { resolve, reject });
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
- requestNavigation: (url, download = null, fragmentFrom = null) => process.send({ type: "navigate", url, download, fragmentFrom }),
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
- onLifecycle: (name, generation) => process.send({ type: "lifecycle", name, generation }),
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 (message.error) pending.reject(new Error(message.error));
232
- else pending.resolve(message.result);
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);