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
@@ -17,7 +17,7 @@ import { installLog, logFileFromEnv, writeLog } from "./log.js";
17
17
  import { NetworkService } from "./network/network-service.js";
18
18
  import { RendererHost } from "./renderer/host.js";
19
19
  import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
20
- import { DESKTOP_USER_AGENT } from "./user-agent.js";
20
+ import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT, webkitFromArgs } from "./user-agent.js";
21
21
 
22
22
  /** Opt-in phone presentation: mobile user agent and touch media features. */
23
23
  export const MOBILE = "--mobile";
@@ -37,15 +37,23 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
37
37
  const mobile = args.includes(MOBILE);
38
38
  if (mobile) console.error(`buninu-browser: ${MOBILE}: mobile user agent and touch media features; user agent overrides are ignored`);
39
39
 
40
- const browser = await createBrowser({ allowHostJs, mobile });
41
- const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
42
- console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
43
- + `ses ${layerStatus(browser.sandbox?.ses)}`);
44
-
40
+ // The endpoint opens while the first renderer starts: a client's first
41
+ // navigation fetches its document meanwhile.
42
+ const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
45
43
  const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
46
44
  const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
47
45
  const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
48
46
  console.error(`DevTools listening on ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
47
+ try {
48
+ await browser.ready;
49
+ } catch (error) {
50
+ server.stop(true);
51
+ browser.close();
52
+ throw error;
53
+ }
54
+ const layerStatus = (layer) => layer?.installed ? "on" : `off (${layer?.reason})`;
55
+ console.error(`buninu-browser: renderer sandbox: seccomp ${layerStatus(browser.sandbox?.seccomp)}, `
56
+ + `ses ${layerStatus(browser.sandbox?.ses)}`);
49
57
 
50
58
  const shutdown = () => {
51
59
  server.stop(true);
@@ -67,13 +75,21 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
67
75
  * not wait for CanvasKit and fonts to load. Site isolation is by origin
68
76
  * because no Public Suffix List is bundled to compute sites.
69
77
  *
70
- * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number }} options
78
+ * With `waitForRenderer: false` it returns before the first renderer is up
79
+ * (`ready` settles then): navigations fetch their documents meanwhile, and
80
+ * anything that needs a renderer waits for it.
81
+ *
82
+ * @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
83
+ * waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean }} options
84
+ * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
71
85
  */
72
86
  export async function createBrowser({
73
87
  allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
88
+ waitForRenderer = true, webkit = true,
74
89
  } = {}) {
75
- // --mobile fixes the user agent for page requests and navigator.userAgent.
76
- network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
90
+ // Explicit presentation profiles fix the identity for requests and scripts.
91
+ const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
92
+ network.userAgent = initialUserAgent;
77
93
  const history = [{ url: "about:blank", x: 0, y: 0 }];
78
94
  let historyIndex = 0;
79
95
  let currentUrl = "about:blank";
@@ -83,9 +99,14 @@ export async function createBrowser({
83
99
  let currentKey = null;
84
100
  let spare = null;
85
101
  let closed = false;
102
+ // Settles when the first renderer shows about:blank (assigned below).
103
+ let rendererReady = null;
86
104
  // Renderer-independent state replayed into every new renderer.
87
105
  const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
88
- let userAgent = null;
106
+ let userAgent = webkit ? initialUserAgent : null;
107
+ // Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
108
+ const initScripts = new Map();
109
+ let nextInitScript = 1;
89
110
  // Listeners for frame and lifecycle notifications (the CDP server).
90
111
  const frameListeners = new Set();
91
112
  const lifecycleListeners = new Set();
@@ -146,13 +167,13 @@ export async function createBrowser({
146
167
  await load(currentUrl, invoke);
147
168
  },
148
169
  // Only the renderer showing the current page may navigate it.
149
- onNavigate: (url, downloadName, fragmentFrom) => {
170
+ onNavigate: (url, downloadName, fragmentFrom, post = null) => {
150
171
  if (host !== current) return;
151
172
  if (fragmentFrom) {
152
173
  recordFragment(url, fragmentFrom);
153
174
  return;
154
175
  }
155
- navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
176
+ navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
156
177
  },
157
178
  onDownload: ({ url, name, data }) => {
158
179
  if (host !== current) return;
@@ -190,6 +211,7 @@ export async function createBrowser({
190
211
  async function replayState(invoke) {
191
212
  await invoke("resize", ...resizeArguments());
192
213
  if (userAgent) await invoke("setUserAgent", userAgent);
214
+ if (initScripts.size) await invoke("setInitScripts", [...initScripts.values()]);
193
215
  }
194
216
 
195
217
  function prepareSpare() {
@@ -216,14 +238,28 @@ export async function createBrowser({
216
238
  return current;
217
239
  }
218
240
 
219
- const call = (method, ...params) => current.call(method, ...params);
241
+ const call = async (method, ...params) => {
242
+ await rendererReady;
243
+ return current.call(method, ...params);
244
+ };
245
+
246
+ /**
247
+ * Applies viewport or user agent state the renderer is told about. Before
248
+ * the first renderer is up there is nothing to wait for: it starts with the
249
+ * state (replayState), and the latest state is applied again once it is.
250
+ */
251
+ function applyState(method, args) {
252
+ if (current) return current.call(method, ...args());
253
+ rendererReady.then(() => current?.call(method, ...args())).catch(() => {});
254
+ return undefined;
255
+ }
220
256
 
221
257
  /**
222
258
  * Fetches a top-level document, then commits it in the renderer for its
223
259
  * final origin. `invoke` pins the renderer (used when restoring a
224
260
  * restarted renderer).
225
261
  */
226
- async function load(url, invoke = null, downloadName = null) {
262
+ async function load(url, invoke = null, downloadName = null, post = null) {
227
263
  const started = performance.now();
228
264
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
229
265
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -237,7 +273,7 @@ export async function createBrowser({
237
273
  try {
238
274
  const target = new URL(url);
239
275
  const response = ["http:", "https:"].includes(target.protocol)
240
- ? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
276
+ ? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
241
277
  if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
242
278
  return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
243
279
  attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
@@ -266,6 +302,7 @@ export async function createBrowser({
266
302
  const fetched = performance.now();
267
303
  writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
268
304
  + `${document.url === url ? "" : ` -> ${document.url}`} in ${Math.round(fetched - started)}ms`);
305
+ if (!invoke) await rendererReady;
269
306
  const host = invoke ? current : await rendererFor(document.url);
270
307
  const generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
271
308
  committed = { host, generation };
@@ -275,14 +312,19 @@ export async function createBrowser({
275
312
 
276
313
  // Screenshots and scrolling go to the compositor thread, so they are
277
314
  // answered even while the renderer's main thread is busy.
278
- const composite = (method, ...params) => current.composite(method, ...params);
315
+ const composite = async (method, ...params) => {
316
+ await rendererReady;
317
+ return current.composite(method, ...params);
318
+ };
279
319
 
280
320
  async function rememberScroll() {
321
+ // Before the first renderer is up nothing has been shown or scrolled.
322
+ if (!current) return;
281
323
  const offset = await composite("scrollOffset");
282
324
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
283
325
  }
284
326
 
285
- async function navigate(url, { downloadName = null } = {}) {
327
+ async function navigate(url, { downloadName = null, post = null } = {}) {
286
328
  const resolved = new URL(url, currentUrl).href;
287
329
  // A javascript: URL runs in the current document; history does not change.
288
330
  if (resolved.startsWith("javascript:")) {
@@ -290,15 +332,20 @@ export async function createBrowser({
290
332
  return;
291
333
  }
292
334
  await rememberScroll();
293
- if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
335
+ let shown = resolved;
336
+ if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
294
337
  currentUrl = resolved;
295
338
  } else {
296
- const result = await load(resolved, null, downloadName);
339
+ const result = await load(resolved, null, downloadName, post);
297
340
  if (result?.downloaded) return null;
341
+ // The document is at the URL redirects ended on; the request's fragment
342
+ // carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
343
+ shown = withFragment(currentUrl, resolved);
344
+ currentUrl = shown;
298
345
  }
299
- await call("showFragment", resolved);
346
+ await call("showFragment", shown);
300
347
  history.splice(historyIndex + 1);
301
- history.push({ url: resolved, x: 0, y: 0 });
348
+ history.push({ url: shown, x: 0, y: 0 });
302
349
  historyIndex = history.length - 1;
303
350
  }
304
351
 
@@ -323,6 +370,7 @@ export async function createBrowser({
323
370
 
324
371
  const context = {
325
372
  navigate,
373
+ get userAgent() { return userAgent ?? initialUserAgent; },
326
374
  setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
327
375
  if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
328
376
  downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
@@ -336,14 +384,24 @@ export async function createBrowser({
336
384
  goForward: () => traverseHistory(historyIndex + 1),
337
385
  resize(width, height, deviceScaleFactor = 1) {
338
386
  Object.assign(viewport, { width, height, deviceScaleFactor });
339
- return call("resize", ...resizeArguments());
387
+ return applyState("resize", resizeArguments);
340
388
  },
341
389
  setUserAgent(value) {
342
- // With --mobile the phone user agent stays (casty, for one, overrides it with a desktop one).
343
- if (mobile) return undefined;
390
+ // A client's default override must not replace an explicit CLI profile.
391
+ if (mobile || webkit) return undefined;
344
392
  userAgent = typeof value === "string" && value ? value : null;
345
393
  network.userAgent = userAgent;
346
- return userAgent ? call("setUserAgent", userAgent) : undefined;
394
+ return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
395
+ },
396
+ addInitScript(source) {
397
+ const identifier = String(nextInitScript++);
398
+ initScripts.set(identifier, String(source));
399
+ applyState("setInitScripts", () => [[...initScripts.values()]]);
400
+ return identifier;
401
+ },
402
+ removeInitScript(identifier) {
403
+ if (!initScripts.delete(String(identifier))) return;
404
+ applyState("setInitScripts", () => [[...initScripts.values()]]);
347
405
  },
348
406
  screenshot: (options = {}) => composite("screenshot", {
349
407
  format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
@@ -366,7 +424,20 @@ export async function createBrowser({
366
424
  ? { url: resource.url, contentType: resource.contentType, body: resource.body }
367
425
  : null;
368
426
  },
369
- scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
427
+ /**
428
+ * Scrolls by a wheel delta. With the pointer's viewport position, the
429
+ * scroll container under it (an element, an iframe's document) scrolls
430
+ * first; the page scrolls when nothing there takes it.
431
+ */
432
+ async scroll(deltaX, deltaY, at = null) {
433
+ const scale = pageScale();
434
+ let left = { x: deltaX / scale, y: deltaY / scale };
435
+ if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
436
+ left = await call("wheel", at.x / scale, at.y / scale, left.x, left.y) ?? left;
437
+ if (!left.x && !left.y) return undefined;
438
+ }
439
+ return composite("scroll", left.x, left.y);
440
+ },
370
441
  press: (key, options) => call("press", key, options),
371
442
  type: (text) => call("type", text),
372
443
  scrollOffset: () => composite("scrollOffset"),
@@ -401,12 +472,21 @@ export async function createBrowser({
401
472
  },
402
473
  };
403
474
 
404
- await rendererFor("about:blank");
405
- await current.call("loadDocument", { url: "about:blank" });
475
+ // The spare starts with the first renderer rather than after it, ready
476
+ // for the first navigation to an origin.
477
+ rendererReady = (async () => {
478
+ const first = rendererFor("about:blank");
479
+ prepareSpare();
480
+ await first;
481
+ await current.call("loadDocument", { url: "about:blank" });
482
+ })();
483
+ if (waitForRenderer) await rendererReady;
484
+ else rendererReady.catch(() => {}); // the caller awaits `ready`
406
485
 
407
486
  return {
408
487
  context,
409
488
  network,
489
+ ready: rendererReady,
410
490
  get sandbox() {
411
491
  return current?.sandbox ?? null;
412
492
  },
@@ -449,6 +529,18 @@ async function localDocument(url) {
449
529
  };
450
530
  }
451
531
 
532
+ /** `url`, with the fragment of `requested` when it has none of its own. */
533
+ function withFragment(url, requested) {
534
+ try {
535
+ const target = new URL(url);
536
+ const hash = new URL(requested).hash;
537
+ if (!target.hash && hash) target.hash = hash;
538
+ return target.href;
539
+ } catch {
540
+ return url;
541
+ }
542
+ }
543
+
452
544
  function withoutHash(href) {
453
545
  const copy = new URL(href);
454
546
  copy.hash = "";
@@ -1,8 +1,11 @@
1
- const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
2
- // Form controls and labels are hit over their whole box, not only where text
3
- // is drawn (an unchecked checkbox or an empty field has none; a block label
4
- // is clickable beside its text).
5
- const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
1
+ import { hitTarget } from "../paint/display-list.js";
2
+
3
+ // An iframe takes clicks for its own document (the renderer forwards them).
4
+ const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
5
+ // Form controls, labels and iframes are hit over their whole box, not only
6
+ // where text is drawn (an unchecked checkbox or an empty field has none; a
7
+ // block label is clickable beside its text).
8
+ const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
6
9
 
7
10
  export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
8
11
  const regions = [];
@@ -71,6 +74,18 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
71
74
  return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
72
75
  }
73
76
 
77
+ /**
78
+ * The element a pointer event at a viewport point targets: the topmost one
79
+ * painted there (its own box or its text), skipping those that take no
80
+ * pointer events. Null when the point hits nothing.
81
+ */
82
+ export function elementAtPoint(tree, displayList, x, y, scroll) {
83
+ const hittable = hitTestable(tree);
84
+ const nodeId = hitTarget(displayList, x, y, scroll, (id) => !hittable(tree.nodesById.get(id)));
85
+ const domNode = nodeId == null ? null : tree.nodesById.get(nodeId)?.domNode;
86
+ return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
87
+ }
88
+
74
89
  /** Returns the content-coordinate bounds contributed by an element's text descendants. */
75
90
  export function elementBounds(tree, layout, element) {
76
91
  const rects = [];
@@ -81,11 +96,14 @@ export function elementBounds(tree, layout, element) {
81
96
  ? renderNode.domNode
82
97
  : renderNode?.domNode?.parentElement;
83
98
  if (!owner || (owner !== element && !element.contains(owner))) continue;
99
+ // A replaced element (an image) spans its own box, not its line's height.
100
+ const replaced = run.type === "image" && run.y != null;
101
+ const top = replaced ? run.y : fragment.y;
84
102
  rects.push({
85
103
  left: run.x,
86
- top: fragment.y,
104
+ top,
87
105
  right: run.x + run.width,
88
- bottom: fragment.y + fragment.height,
106
+ bottom: top + (replaced ? run.height : fragment.height),
89
107
  });
90
108
  }
91
109
  }