buninu-browser 0.2.3 → 0.2.4

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 (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
package/lib/cdp-server.js CHANGED
@@ -44,9 +44,11 @@ export class CdpServer {
44
44
  for (const target of state.targets.values()) {
45
45
  if (target.screencast && target.screencastEmit) pushScreencastFrame(context, state, target);
46
46
  }
47
+ if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
47
48
  });
48
49
  context.onLifecycle?.((name) => {
49
50
  for (const target of state.targets.values()) emitLifecycle(target, name);
51
+ if (state.implicitTarget) emitLifecycle(state.implicitTarget, name);
50
52
  });
51
53
 
52
54
  const server = Bun.serve({
@@ -178,6 +180,7 @@ export class CdpServer {
178
180
  ...(sessionId ? { sessionId } : {}),
179
181
  });
180
182
  };
183
+ emit.owner = ws;
181
184
  const result = await dispatch(
182
185
  context,
183
186
  state,
@@ -206,6 +209,13 @@ export class CdpServer {
206
209
  }
207
210
  },
208
211
  close(ws) {
212
+ for (const target of state.targets.values()) {
213
+ if (target.screencastEmit?.owner === ws) stopScreencast(target);
214
+ if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
215
+ }
216
+ const implicit = state.implicitTarget;
217
+ if (implicit?.screencastEmit?.owner === ws) stopScreencast(implicit);
218
+ if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
209
219
  serverLog("[CdpServer] client disconnected");
210
220
  },
211
221
  },
@@ -350,6 +360,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
350
360
  const target = state.targets.get(params.targetId);
351
361
  if (!target) return { success: false };
352
362
 
363
+ stopScreencast(target);
353
364
  await ctx.close?.();
354
365
  state.initialNavigations.get(params.targetId)?.navigated.resolve();
355
366
  state.initialNavigations.get(params.targetId)?.settled.resolve();
@@ -450,7 +461,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
450
461
  // A javascript: URL runs in the current document: no new loader or history entry.
451
462
  if (/^\s*javascript:/i.test(String(params.url))) {
452
463
  await ctx.navigate?.(String(params.url).trim());
453
- if (target.screencast) await emitScreencastFrame(ctx, state, target, emit);
464
+ if (target.screencast) await pushScreencastFrame(ctx, state, target);
454
465
  return { frameId: target.frameId, loaderId: target.loaderId };
455
466
  }
456
467
  // Events of the next document belong to its new loader.
@@ -460,7 +471,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
460
471
  target.title = await getContextTitle(ctx, target);
461
472
  target.history[target.historyIndex].title = target.title;
462
473
  if (target.screencast) {
463
- await emitScreencastFrame(ctx, state, target, emit);
474
+ await pushScreencastFrame(ctx, state, target);
464
475
  }
465
476
  state.initialNavigations.get(target.targetId)?.navigated.resolve();
466
477
 
@@ -512,6 +523,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
512
523
  }
513
524
  case "Page.startScreencast": {
514
525
  const target = getSessionTarget(state, sessionId);
526
+ stopScreencast(target);
515
527
  target.screencast = { ...params };
516
528
  target.screencastEmit = emit;
517
529
  // As in Chrome, the reply does not wait for the first frame (nor the
@@ -519,12 +531,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
519
531
  void pushScreencastFrame(ctx, state, target);
520
532
  return {};
521
533
  }
522
- case "Page.screencastFrameAck":
534
+ case "Page.screencastFrameAck": {
535
+ const target = getSessionTarget(state, sessionId);
536
+ if (target.screencastFrames.delete(params.sessionId) && target.screencastPending) void pushScreencastFrame(ctx, state, target);
523
537
  return {};
538
+ }
524
539
  case "Page.stopScreencast": {
525
540
  const target = getSessionTarget(state, sessionId);
526
- target.screencast = null;
527
- target.screencastEmit = null;
541
+ stopScreencast(target);
528
542
  return {};
529
543
  }
530
544
  case "Page.reload": {
@@ -707,7 +721,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
707
721
  navigateTarget(state, target, navigation.url, emit, { addHistory: true });
708
722
  target.title = navigation.title ?? await getContextTitle(ctx, target);
709
723
  target.history[target.historyIndex].title = target.title;
710
- if (target.screencast) await emitScreencastFrame(ctx, state, target, emit);
724
+ if (target.screencast) await pushScreencastFrame(ctx, state, target);
711
725
  }
712
726
  }
713
727
  return {};
@@ -817,6 +831,7 @@ function createTarget(state, url = "about:blank", options = {}) {
817
831
  deviceScaleFactor: 1,
818
832
  },
819
833
  screencast: null,
834
+ screencastFrames: new Set(),
820
835
  };
821
836
  state.targets.set(targetId, target);
822
837
  state.initialNavigations.set(targetId, {
@@ -857,9 +872,20 @@ function emitLifecycle(target, name) {
857
872
  emit("Page.lifecycleEvent", { frameId: target.frameId, loaderId: target.loaderId, name, timestamp });
858
873
  }
859
874
 
860
- /** One screencast frame at a time; changes during a capture coalesce into one more. */
875
+ // Bound the amount of image data queued to a slow CDP client. Two outstanding
876
+ // frames keep the pipe moving while acknowledging one; later updates coalesce.
877
+ const MAX_SCREENCAST_FRAMES = 2;
878
+ function stopScreencast(target) {
879
+ target.screencast = null;
880
+ target.screencastEmit = null;
881
+ target.screencastPending = false;
882
+ target.screencastFrames.clear();
883
+ }
884
+
885
+ /** One capture at a time, with bounded unacknowledged frames. */
861
886
  async function pushScreencastFrame(ctx, state, target) {
862
- if (target.screencastBusy) {
887
+ if (!target.screencast || !target.screencastEmit) return;
888
+ if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
863
889
  target.screencastPending = true;
864
890
  return;
865
891
  }
@@ -867,8 +893,9 @@ async function pushScreencastFrame(ctx, state, target) {
867
893
  try {
868
894
  do {
869
895
  target.screencastPending = false;
870
- await emitScreencastFrame(ctx, state, target, target.screencastEmit);
871
- } while (target.screencastPending && target.screencast && target.screencastEmit);
896
+ await emitScreencastFrame(ctx, state, target);
897
+ } while (target.screencastPending && target.screencast && target.screencastEmit
898
+ && target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
872
899
  } catch (error) {
873
900
  serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
874
901
  } finally {
@@ -876,10 +903,14 @@ async function pushScreencastFrame(ctx, state, target) {
876
903
  }
877
904
  }
878
905
 
879
- async function emitScreencastFrame(ctx, state, target, emit) {
880
- const data = await screenshotData(ctx, target, target.screencast ?? {});
881
- if (!data) return;
906
+ async function emitScreencastFrame(ctx, state, target) {
907
+ const stream = target.screencast, emit = target.screencastEmit;
908
+ const data = await screenshotData(ctx, target, stream);
909
+ if (!data || stream !== target.screencast || emit !== target.screencastEmit) return;
882
910
  const scrollOffset = await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
911
+ if (stream !== target.screencast || emit !== target.screencastEmit) return;
912
+ const sessionId = state.nextScreencastFrameId++;
913
+ target.screencastFrames.add(sessionId);
883
914
  emit("Page.screencastFrame", {
884
915
  data,
885
916
  metadata: {
@@ -891,7 +922,7 @@ async function emitScreencastFrame(ctx, state, target, emit) {
891
922
  scrollOffsetY: scrollOffset.y,
892
923
  timestamp: performance.now() / 1000,
893
924
  },
894
- sessionId: state.nextScreencastFrameId++,
925
+ sessionId,
895
926
  });
896
927
  }
897
928
 
@@ -1000,6 +1031,8 @@ function getImplicitTarget(state) {
1000
1031
 
1001
1032
  state.implicitTarget = {
1002
1033
  targetId: "implicit-target",
1034
+ screencast: null,
1035
+ screencastFrames: new Set(),
1003
1036
  frameId: "implicit-target",
1004
1037
  loaderId: "implicit-loader",
1005
1038
  type: "page",
@@ -0,0 +1,33 @@
1
+ import Node from "../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts";
2
+ import Document from "../../vendor/happy-dom/packages/happy-dom/src/nodes/document/Document.ts";
3
+ import HTMLHyperlinkElementUtility from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementUtility.ts";
4
+
5
+ /** HTML document base URL, shared by the inert DOM and its page bindings. */
6
+ export function documentBaseURL(node) {
7
+ const document = node?.nodeType === 9 ? node : node?.ownerDocument;
8
+ const fallback = document?.URL ?? "about:blank";
9
+ const base = document?.querySelector?.("base[href]");
10
+ if (!base) return fallback;
11
+ try {
12
+ const url = new URL(base.getAttribute("href"), fallback);
13
+ return url.protocol === "data:" || url.protocol === "javascript:" ? fallback : url.href;
14
+ } catch { return fallback; }
15
+ }
16
+
17
+ // Happy DOM selects the first <base>, including ones with only a target.
18
+ // Correcting both accessors keeps native URL attributes and renderer loads
19
+ // on the same base, without modifying the vendored implementation.
20
+ for (const prototype of [Node.prototype, Document.prototype]) {
21
+ Object.defineProperty(prototype, "baseURI", {
22
+ configurable: true, get() { return documentBaseURL(this); },
23
+ });
24
+ }
25
+
26
+ // Hyperlink component accessors use this shared method, so pathname/search
27
+ // setters and origin/hash getters resolve consistently with href itself.
28
+ HTMLHyperlinkElementUtility.prototype.getHref = function () {
29
+ const value = this.element.getAttribute("href");
30
+ if (value === null) return "";
31
+ try { return new URL(value, documentBaseURL(this.element)).href; }
32
+ catch { return value; }
33
+ };
@@ -1,6 +1,9 @@
1
1
  import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
2
  import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
3
+ import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
3
4
  import "./selector-pseudo-adapter.js";
5
+ import "./svg-attribute-adapter.js";
6
+ import "./document-base.js";
4
7
 
5
8
  const IMPLICIT_HEAD_ELEMENTS = new Set([
6
9
  "BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
@@ -12,8 +15,18 @@ const IMPLICIT_HEAD_ELEMENTS = new Set([
12
15
  * Page JavaScript and subresource loading stay disabled here. They belong to
13
16
  * the renderer scheduler and network service rather than the tree builder.
14
17
  */
15
- export function parseHTMLDocument(html, url = "about:blank") {
16
- const window = new Window({
18
+ export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
19
+ const window = createInertWindow(url);
20
+ window.document[PropertySymbol.referrer] = referrer;
21
+
22
+ new HTMLParser(window).parse(String(html), window.document);
23
+ normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
24
+ return { window, document: window.document };
25
+ }
26
+
27
+ /** An isolated parser context: no script execution, resource loads or page custom elements. */
28
+ export function createInertWindow(url = "about:blank") {
29
+ return new Window({
17
30
  url,
18
31
  settings: {
19
32
  disableJavaScriptEvaluation: true,
@@ -27,10 +40,6 @@ export function parseHTMLDocument(html, url = "about:blank") {
27
40
  navigation: { disableFallbackToSetURL: true },
28
41
  },
29
42
  });
30
-
31
- new HTMLParser(window).parse(String(html), window.document);
32
- normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
33
- return { window, document: window.document };
34
43
  }
35
44
 
36
45
  /**
@@ -37,9 +37,9 @@ export function compileSelector(element, selector) {
37
37
  }
38
38
 
39
39
  /** Whether `element` matches a compiled selector, as element.matches(selector) would. */
40
- export function matchesCompiled(element, compiled) {
40
+ export function matchesCompiled(element, compiled, scope = element) {
41
41
  for (const items of compiled) {
42
- if (matchItems(element, element, items, 0, null)) return true;
42
+ if (matchItems(scope, element, items, 0, null)) return true;
43
43
  }
44
44
  return false;
45
45
  }
@@ -1,9 +1,28 @@
1
1
  import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
2
+ import { compileSelector, matchesCompiled } from "./selector-matcher.js";
2
3
 
3
4
  // Happy DOM's :empty checks only element children. CSS also excludes elements
4
5
  // with nonempty text nodes (including whitespace); comments do not count.
5
6
  const original = SelectorItem.prototype.matchPseudoItem;
7
+ const selectorLists = new WeakMap();
6
8
  SelectorItem.prototype.matchPseudoItem = function (scope, element, siblings, pseudo, ignoreErrors) {
9
+ if (pseudo.name === "focus-within") {
10
+ const focused = element.ownerDocument?.activeElement;
11
+ return focused && (focused === element || element.contains(focused)) ? {priorityWeight:10} : null;
12
+ }
13
+ // Functional selector lists match their complete complex selectors. The
14
+ // upstream per-item path tests only a group's first compound, so e.g.
15
+ // :where(h2 code) incorrectly matches the h2 instead of its code child.
16
+ if (["is", "where", "not"].includes(pseudo.name) && pseudo.arguments) {
17
+ let compiled = selectorLists.get(pseudo);
18
+ if (compiled === undefined) {
19
+ compiled = compileSelector(element, pseudo.arguments) ?? null;
20
+ selectorLists.set(pseudo, compiled);
21
+ }
22
+ const matches = compiled && matchesCompiled(element, compiled, scope);
23
+ return (pseudo.name === "not" ? !matches : matches)
24
+ ? { priorityWeight: pseudo.name === "where" ? 0 : 10 } : null;
25
+ }
7
26
  if (pseudo.name === "empty") {
8
27
  for (const child of element.childNodes) {
9
28
  if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
@@ -0,0 +1,50 @@
1
+ import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
2
+
3
+ // HTML tokenization ASCII-lowercases every attribute, including foreign
4
+ // content. Tree construction then restores the standard SVG spellings.
5
+ // https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
6
+ const adjustedNames = new Map(`attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits
7
+ diffuseConstant edgeMode filterUnits glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength
8
+ keyPoints keySplines keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits markerWidth
9
+ maskContentUnits maskUnits numOctaves pathLength patternContentUnits patternTransform patternUnits
10
+ pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits refX refY repeatCount repeatDur
11
+ requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset stdDeviation
12
+ stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox viewTarget
13
+ xChannelSelector yChannelSelector zoomAndPan`.trim().split(/\s+/).map(name => [name.toLowerCase(), name]));
14
+ const space = character => character === " " || character === "\t" || character === "\n" || character === "\r" || character === "\f";
15
+
16
+ function adjustNames(source) {
17
+ let index = 0, copied = 0, output = "";
18
+ while (index < source.length) {
19
+ while (space(source[index]) || source[index] === "/") index++;
20
+ const start = index;
21
+ while (index < source.length && !space(source[index]) && source[index] !== "=" && source[index] !== "/" && source[index] !== ">") index++;
22
+ if (index === start) { index++; continue; }
23
+ const name = source.slice(start, index);
24
+ const lower = name.replace(/[A-Z]/g, character => character.toLowerCase());
25
+ const adjusted = adjustedNames.get(lower) ?? lower;
26
+ if (name !== adjusted) {
27
+ output += source.slice(copied, start) + adjusted;
28
+ copied = index;
29
+ }
30
+ while (space(source[index])) index++;
31
+ if (source[index] !== "=") continue;
32
+ index++;
33
+ while (space(source[index])) index++;
34
+ const quote = source[index];
35
+ if (quote === '"' || quote === "'") {
36
+ index++;
37
+ while (index < source.length && source[index] !== quote) index++;
38
+ if (index < source.length) index++;
39
+ } else {
40
+ while (index < source.length && !space(source[index]) && source[index] !== ">") index++;
41
+ }
42
+ }
43
+ return copied ? output + source.slice(copied) : source;
44
+ }
45
+
46
+ const parseAttributes = HTMLParser.prototype.parseEndOfStartTag;
47
+ HTMLParser.prototype.parseEndOfStartTag = function (source, selfClosing) {
48
+ if (source && this.nextElement?.namespaceURI === "http://www.w3.org/2000/svg") source = adjustNames(source);
49
+ return parseAttributes.call(this, source, selfClosing);
50
+ };
@@ -173,7 +173,7 @@ export async function createBrowser({
173
173
  recordFragment(url, fragmentFrom);
174
174
  return;
175
175
  }
176
- navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
176
+ navigate(url, { downloadName, post, referrer: host.documentUrl }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
177
177
  },
178
178
  onDownload: ({ url, name, data }) => {
179
179
  if (host !== current) return;
@@ -259,7 +259,7 @@ export async function createBrowser({
259
259
  * final origin. `invoke` pins the renderer (used when restoring a
260
260
  * restarted renderer).
261
261
  */
262
- async function load(url, invoke = null, downloadName = null, post = null) {
262
+ async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
263
263
  const started = performance.now();
264
264
  if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
265
265
  return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
@@ -273,7 +273,7 @@ export async function createBrowser({
273
273
  try {
274
274
  const target = new URL(url);
275
275
  const response = ["http:", "https:"].includes(target.protocol)
276
- ? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
276
+ ? await network.navigate(target.href, { post, referrer: referrer ?? post?.referrer ?? null, onResponse: (raw, finalUrl) => {
277
277
  if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
278
278
  return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
279
279
  attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
@@ -292,6 +292,7 @@ export async function createBrowser({
292
292
  status: response.status,
293
293
  statusText: response.statusText,
294
294
  cookie: network.documentCookie(response.url),
295
+ referrer: response.referrer ?? "",
295
296
  };
296
297
  } catch (error) {
297
298
  document = { url, error: String(error) };
@@ -324,7 +325,7 @@ export async function createBrowser({
324
325
  Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
325
326
  }
326
327
 
327
- async function navigate(url, { downloadName = null, post = null } = {}) {
328
+ async function navigate(url, { downloadName = null, post = null, referrer = null } = {}) {
328
329
  const resolved = new URL(url, currentUrl).href;
329
330
  // A javascript: URL runs in the current document; history does not change.
330
331
  if (resolved.startsWith("javascript:")) {
@@ -336,7 +337,7 @@ export async function createBrowser({
336
337
  if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
337
338
  currentUrl = resolved;
338
339
  } else {
339
- const result = await load(resolved, null, downloadName, post);
340
+ const result = await load(resolved, null, downloadName, post, referrer);
340
341
  if (result?.downloaded) return null;
341
342
  // The document is at the URL redirects ended on; the request's fragment
342
343
  // carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
@@ -406,6 +407,7 @@ export async function createBrowser({
406
407
  screenshot: (options = {}) => composite("screenshot", {
407
408
  format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
408
409
  quality: Number.isFinite(options.quality) ? options.quality : 80,
410
+ optimizeForSpeed: options.optimizeForSpeed === true,
409
411
  }),
410
412
  title: () => call("title"),
411
413
  /**
@@ -466,7 +468,8 @@ export async function createBrowser({
466
468
  recordFragment(href, clicked.fragmentFrom);
467
469
  return { url: href, title: await call("title") };
468
470
  }
469
- const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
471
+ const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null,
472
+ referrer: current.documentUrl });
470
473
  return result === null ? null : { url: href, title: await call("title") };
471
474
  },
472
475
  evaluate: (expression) => call("evaluate", String(expression)),
@@ -80,39 +80,82 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
80
80
  * pointer events. Null when the point hits nothing.
81
81
  */
82
82
  export function elementAtPoint(tree, displayList, x, y, scroll) {
83
+ const domNode = renderNodeAtPoint(tree, displayList, x, y, scroll)?.domNode;
84
+ return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
85
+ }
86
+
87
+ /** Also preserves generated controls, whose owning DOM element differs from their render node. */
88
+ export function renderNodeAtPoint(tree, displayList, x, y, scroll) {
83
89
  const hittable = hitTestable(tree);
84
90
  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;
91
+ return nodeId == null ? null : tree.nodesById.get(nodeId) ?? null;
87
92
  }
88
93
 
89
- /** Returns the content-coordinate bounds contributed by an element's text descendants. */
90
- export function elementBounds(tree, layout, element) {
91
- const rects = [];
94
+ // Layout objects are immutable after the layout pass. Keeping their lookup
95
+ // tables under a weak key releases old document nodes when a layout is dropped.
96
+ const geometryIndexes = new WeakMap();
97
+
98
+ function geometryIndex(tree, layout) {
99
+ let index = geometryIndexes.get(layout);
100
+ if (index?.tree === tree) return index;
101
+ const byNode = new Map(), boxes = new WeakMap();
102
+ for (const box of layout.boxes) if (!box.inline && !byNode.has(box.nodeId)) byNode.set(box.nodeId, box);
103
+ for (const node of tree.nodesById.values()) {
104
+ if (node.pseudo || node.domNode?.nodeType !== 1 || boxes.has(node.domNode)) continue;
105
+ // The first actual render node is authoritative, even if it is inline.
106
+ const box = byNode.get(node.id);
107
+ boxes.set(node.domNode, box ? { node, box } : null);
108
+ }
109
+ index = { tree, boxes, textBounds: null };
110
+ geometryIndexes.set(layout, index);
111
+ return index;
112
+ }
113
+
114
+ /** The first real render node and block box belonging to an element. */
115
+ export function elementBox(tree, layout, element) {
116
+ return tree && layout ? geometryIndex(tree, layout).boxes.get(element) ?? null : null;
117
+ }
118
+
119
+ function textBoundsIndex(tree, layout) {
120
+ const bounds = new WeakMap();
92
121
  for (const fragment of layout.fragments) {
93
122
  for (const run of fragment.runs) {
94
123
  const renderNode = tree.nodesById.get(run.nodeId);
95
124
  const owner = renderNode?.domNode?.nodeType === 1
96
125
  ? renderNode.domNode
97
126
  : renderNode?.domNode?.parentElement;
98
- if (!owner || (owner !== element && !element.contains(owner))) continue;
127
+ if (!owner) continue;
99
128
  // A replaced element (an image) spans its own box, not its line's height.
100
129
  const replaced = run.type === "image" && run.y != null;
101
130
  const top = replaced ? run.y : fragment.y;
102
- rects.push({
103
- left: run.x,
104
- top,
105
- right: run.x + run.width,
106
- bottom: top + (replaced ? run.height : fragment.height),
107
- });
131
+ const right = run.x + run.width;
132
+ const bottom = top + (replaced ? run.height : fragment.height);
133
+ for (let ancestor = owner; ancestor; ancestor = ancestor.parentElement) {
134
+ const rect = bounds.get(ancestor);
135
+ if (rect) {
136
+ rect.left = Math.min(rect.left, run.x);
137
+ rect.top = Math.min(rect.top, top);
138
+ rect.right = Math.max(rect.right, right);
139
+ rect.bottom = Math.max(rect.bottom, bottom);
140
+ } else bounds.set(ancestor, { left: run.x, top, right, bottom });
141
+ }
108
142
  }
109
143
  }
110
- if (!rects.length) return null;
111
- const left = Math.min(...rects.map((rect) => rect.left));
112
- const top = Math.min(...rects.map((rect) => rect.top));
113
- const right = Math.max(...rects.map((rect) => rect.right));
114
- const bottom = Math.max(...rects.map((rect) => rect.bottom));
115
- return Object.freeze({ left, top, right, bottom, width: right - left, height: bottom - top });
144
+ return bounds;
145
+ }
146
+
147
+ /** Returns the content-coordinate bounds contributed by an element's text descendants. */
148
+ export function elementBounds(tree, layout, element) {
149
+ const index = geometryIndex(tree, layout);
150
+ index.textBounds ??= textBoundsIndex(tree, layout);
151
+ const rect = index.textBounds.get(element);
152
+ if (!rect) return null;
153
+ if (rect.width === undefined) {
154
+ rect.width = rect.right - rect.left;
155
+ rect.height = rect.bottom - rect.top;
156
+ Object.freeze(rect);
157
+ }
158
+ return rect;
116
159
  }
117
160
 
118
161
  /**
@@ -120,19 +163,24 @@ export function elementBounds(tree, layout, element) {
120
163
  * visibility: hidden or pointer-events: none (both inherited, so a visible
121
164
  * or pointer-events: auto descendant still does). Text uses its element's style.
122
165
  */
166
+ const hitTestableIndexes = new WeakMap();
123
167
  function hitTestable(tree) {
168
+ const cached = hitTestableIndexes.get(tree);
169
+ if (cached) return cached;
124
170
  const styles = new Map();
125
171
  for (const node of tree.nodesById.values()) {
126
172
  if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
127
173
  styles.set(node.domNode, node.style);
128
174
  }
129
175
  }
130
- return (renderNode) => {
176
+ const accepts = (renderNode) => {
131
177
  const domNode = renderNode?.domNode;
132
178
  const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
133
179
  const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
134
180
  return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
135
181
  };
182
+ hitTestableIndexes.set(tree, accepts);
183
+ return accepts;
136
184
  }
137
185
 
138
186
  function closestInteractive(node) {