buninu-browser 0.0.2 → 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.
Files changed (61) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +30 -6
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +110 -9
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
  38. package/patches/wasmpeg/README.md +28 -0
  39. package/test/computed-style.test.js +52 -0
  40. package/test/document-types.test.js +111 -0
  41. package/test/download.test.js +192 -0
  42. package/test/fixtures/tone.mp3 +0 -0
  43. package/test/flex.test.js +11 -0
  44. package/test/happy-dom-matches.test.js +25 -0
  45. package/test/headless-shell.test.js +27 -0
  46. package/test/hit-test.test.js +37 -0
  47. package/test/page-scripts.test.js +388 -29
  48. package/test/piano.test.js +65 -0
  49. package/test/positioning.test.js +11 -0
  50. package/test/pulse-client.test.js +130 -0
  51. package/test/render-tree.test.js +14 -0
  52. package/test/resolver.test.js +12 -1
  53. package/test/screenshot-cli.test.js +19 -0
  54. package/test/text-layout.test.js +22 -0
  55. package/test/user-agent.test.js +42 -0
  56. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  57. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  58. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  59. package/vendor/ses/dist/ses.mjs +2 -0
  60. package/vendor/wasmpeg/VENDORING.md +7 -3
  61. package/vendor/wasmpeg/src/js/gpu.js +2 -1
@@ -1,14 +1,19 @@
1
- const INTERACTIVE_TAGS = new Set(["A", "BUTTON", "INPUT", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
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"]);
2
6
 
3
7
  export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
4
8
  const regions = [];
5
9
  const byElement = new Map();
10
+ const hittable = hitTestable(tree);
6
11
 
7
12
  for (const fragment of layout.fragments) {
8
13
  for (const run of fragment.runs) {
9
14
  const renderNode = tree.nodesById.get(run.nodeId);
10
15
  const element = closestInteractive(renderNode?.domNode);
11
- if (!element) continue;
16
+ if (!element || !hittable(renderNode)) continue;
12
17
  const top = fragment.y - scroll.y;
13
18
  const left = run.x - scroll.x;
14
19
  const right = left + run.width;
@@ -27,19 +32,43 @@ export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewpo
27
32
  }
28
33
  }
29
34
 
35
+ for (const box of layout.boxes ?? []) {
36
+ const renderNode = tree.nodesById.get(box.nodeId);
37
+ const element = renderNode?.domNode;
38
+ if (!element || element.nodeType !== 1 || !CONTROL_TAGS.has(element.tagName) || !hittable(renderNode)) continue;
39
+ const top = box.y - scroll.y;
40
+ const left = box.x - scroll.x;
41
+ const right = left + box.width;
42
+ const bottom = top + box.height;
43
+ if (box.width <= 0 || box.height <= 0 || right <= 0 || bottom <= 0
44
+ || left >= (viewport.width ?? Infinity) || top >= (viewport.height ?? Infinity)) {
45
+ continue;
46
+ }
47
+ const rect = Object.freeze({ left, top, right, bottom, width: box.width, height: box.height });
48
+ let region = byElement.get(element);
49
+ if (!region) {
50
+ region = { element, rects: [] };
51
+ byElement.set(element, region);
52
+ regions.push(region);
53
+ }
54
+ region.rects.push(rect);
55
+ }
56
+
30
57
  return regions.map((region) => Object.freeze({
31
58
  element: region.element,
32
59
  rects: Object.freeze(region.rects),
33
60
  }));
34
61
  }
35
62
 
63
+ /** The innermost interactive element under a point (a checkbox before its label). */
36
64
  export function hitTest(tree, layout, x, y, scroll, viewport) {
37
- for (const region of interactiveRegions(tree, layout, scroll, viewport)) {
38
- if (region.rects.some((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)) {
39
- return region;
40
- }
41
- }
42
- return null;
65
+ const hits = interactiveRegions(tree, layout, scroll, viewport).map((region) => ({
66
+ ...region,
67
+ area: Math.min(...region.rects.filter((rect) => x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom)
68
+ .map((rect) => rect.width * rect.height)),
69
+ })).filter((region) => Number.isFinite(region.area));
70
+ const deepest = hits.filter((region) => !hits.some((other) => other !== region && region.element.contains(other.element)));
71
+ return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
43
72
  }
44
73
 
45
74
  /** Returns the content-coordinate bounds contributed by an element's text descendants. */
@@ -68,6 +97,26 @@ export function elementBounds(tree, layout, element) {
68
97
  return Object.freeze({ left, top, right, bottom, width: right - left, height: bottom - top });
69
98
  }
70
99
 
100
+ /**
101
+ * Whether a render node takes clicks: not when its element is
102
+ * visibility: hidden or pointer-events: none (both inherited, so a visible
103
+ * or pointer-events: auto descendant still does). Text uses its element's style.
104
+ */
105
+ function hitTestable(tree) {
106
+ const styles = new Map();
107
+ for (const node of tree.nodesById.values()) {
108
+ if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
109
+ styles.set(node.domNode, node.style);
110
+ }
111
+ }
112
+ return (renderNode) => {
113
+ const domNode = renderNode?.domNode;
114
+ const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
115
+ const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
116
+ return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
117
+ };
118
+ }
119
+
71
120
  function closestInteractive(node) {
72
121
  for (let current = node?.nodeType === 1 ? node : node?.parentElement; current; current = current.parentElement) {
73
122
  if (INTERACTIVE_TAGS.has(current.tagName) || current.hasAttribute("onclick") || current.hasAttribute("tabindex")) {
@@ -123,10 +123,13 @@ function buildBox(node, flags, blockified = false) {
123
123
  if (node.type === "image") {
124
124
  return { kind: "block", node, style, replaced: true, bfc: true, children: [], absolutes: [] };
125
125
  }
126
- const flex = ["flex", "inline-flex"].includes(node.type)
126
+ // The root (<body>) keeps its own node type, but lays its children out
127
+ // by its display: body { display: grid } or flex is common.
128
+ const display = node.type === "root" ? style?.display : node.type;
129
+ const flex = ["flex", "inline-flex"].includes(display)
127
130
  ? style?.flexDirection === "column" ? "column" : "row"
128
131
  : null;
129
- const grid = ["grid", "inline-grid"].includes(node.type);
132
+ const grid = ["grid", "inline-grid"].includes(display);
130
133
  const table = ["table", "inline-table"].includes(node.type);
131
134
  const box = {
132
135
  kind: "block",
@@ -338,7 +341,7 @@ function layoutAbsolutes(ctx, box, record) {
338
341
  if (specifiedWidth == null && left != null && right != null) {
339
342
  width = Math.max(0, containing.width - left - right - marginLeft - marginRight);
340
343
  } else {
341
- width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true).width;
344
+ width = resolveHorizontal(ctx, absolute, 0, containing.width - (left ?? 0) - (right ?? 0), true, containing.width).width;
342
345
  }
343
346
  const staticPosition = absolute.staticPosition ?? containing;
344
347
  const x = left != null ? containing.x + left + marginLeft
@@ -357,27 +360,57 @@ function layoutAbsolutes(ctx, box, record) {
357
360
  }
358
361
  }
359
362
 
363
+ /**
364
+ * justify-content in a column flex container (Flexbox §8.2): `free` is the
365
+ * height left over once the items are stacked, as with a min-height or height
366
+ * taller than them. Each item's output moves by its share; shifts are applied
367
+ * item by item because shiftOutput moves everything after a start index.
368
+ */
369
+ function justifyColumn(ctx, items, justify = "normal", free) {
370
+ if (!(free > 0) || ["normal", "start", "flex-start", "stretch"].includes(justify)) return;
371
+ const count = items.length;
372
+ const offset = (index) => {
373
+ switch (justify) {
374
+ case "center": return free / 2;
375
+ case "end": case "flex-end": return free;
376
+ case "space-between": return count > 1 ? free * index / (count - 1) : 0;
377
+ case "space-around": return free / count * (index + 0.5);
378
+ case "space-evenly": return free / (count + 1) * (index + 1);
379
+ default: return 0;
380
+ }
381
+ };
382
+ let applied = 0;
383
+ items.forEach((item, index) => {
384
+ const delta = offset(index) - applied;
385
+ if (delta) shiftOutput(ctx, item.boxStart, item.fragmentStart, 0, delta);
386
+ applied += delta;
387
+ });
388
+ }
389
+
360
390
  function shiftOutput(ctx, boxStart, fragmentStart, dx, dy) {
361
391
  for (let index = boxStart; index < ctx.boxes.length; index++) {
362
392
  ctx.boxes[index].x += dx;
363
393
  ctx.boxes[index].y += dy;
364
394
  }
365
395
  for (let index = fragmentStart; index < ctx.fragments.length; index++) {
366
- const fragment = ctx.fragments[index];
367
- ctx.fragments[index] = {
368
- ...fragment,
369
- x: fragment.x + dx,
370
- y: fragment.y + dy,
371
- baseline: fragment.baseline == null ? null : fragment.baseline + dy,
372
- runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
373
- ...run,
374
- x: run.x + dx,
375
- y: run.y == null ? run.y : run.y + dy,
376
- }))),
377
- };
396
+ ctx.fragments[index] = shiftedFragment(ctx.fragments[index], dx, dy);
378
397
  }
379
398
  }
380
399
 
400
+ function shiftedFragment(fragment, dx, dy) {
401
+ return {
402
+ ...fragment,
403
+ x: fragment.x + dx,
404
+ y: fragment.y + dy,
405
+ baseline: fragment.baseline == null ? null : fragment.baseline + dy,
406
+ runs: Object.freeze(fragment.runs.map((run) => Object.freeze({
407
+ ...run,
408
+ x: run.x + dx,
409
+ y: run.y == null ? run.y : run.y + dy,
410
+ }))),
411
+ };
412
+ }
413
+
381
414
  function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeight = null) {
382
415
  const style = box.style;
383
416
  const border = borderWidths(style);
@@ -401,6 +434,7 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
401
434
  height: dimensions.height,
402
435
  resource: box.node.resource,
403
436
  video: box.node.tagName === "VIDEO",
437
+ audio: box.node.tagName === "AUDIO",
404
438
  svg: box.node.svg ?? null,
405
439
  }], imageX, imageY, dimensions.width, dimensions.height));
406
440
  record.height = dimensions.height + padding[0] + padding[2] + border[0] + border[2];
@@ -412,6 +446,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
412
446
  const contentTop = y + border[0] + padding[0];
413
447
  const firstFragment = ctx.fragments.length;
414
448
  let contentBottom = contentTop;
449
+ // Column flex items and grid rows, aligned once the height is known.
450
+ let columnItems = null;
451
+ let gridResult = null;
415
452
  let baseline = null;
416
453
 
417
454
  if (box.table) {
@@ -419,9 +456,9 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
419
456
  contentBottom = result.bottom;
420
457
  baseline = result.baseline;
421
458
  } else if (box.grid) {
422
- const result = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
423
- contentBottom = result.bottom;
424
- baseline = result.baseline;
459
+ gridResult = layoutGrid(ctx, box, contentX, contentWidth, contentTop);
460
+ contentBottom = gridResult.bottom;
461
+ baseline = gridResult.baseline;
425
462
  } else if (box.flex === "row") {
426
463
  const result = layoutFlexRow(ctx, box, contentX, contentWidth, contentTop);
427
464
  contentBottom = result.bottom;
@@ -429,8 +466,10 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
429
466
  } else if (box.flex === "column") {
430
467
  const gap = style?.rowGap ?? style?.gap ?? 0;
431
468
  let cursor = contentTop;
469
+ columnItems = [];
432
470
  box.children.forEach((child, index) => {
433
471
  if (index > 0) cursor += gap;
472
+ columnItems.push({ boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length });
434
473
  if (child.kind === "ifc") {
435
474
  const result = layoutInline(ctx, child, contentX, contentWidth, cursor);
436
475
  cursor = result.bottom;
@@ -499,6 +538,8 @@ function layoutBlockContent(ctx, box, x, width, y, absorbTop = false, forcedHeig
499
538
  if (maxHeight != null) contentHeight = Math.min(contentHeight, toContentHeight(maxHeight));
500
539
  const minHeight = numberOrNull(style?.minHeight);
501
540
  if (minHeight != null) contentHeight = Math.max(contentHeight, toContentHeight(minHeight));
541
+ if (columnItems?.length) justifyColumn(ctx, columnItems, style?.justifyContent, contentHeight - (contentBottom - contentTop));
542
+ if (gridResult) alignGridContent(ctx, gridResult, style?.alignContent, contentHeight - (contentBottom - contentTop));
502
543
  record.height = forcedHeight ?? contentHeight + padding[0] + padding[2] + border[0] + border[2];
503
544
  if (style?.display === "list-item") placeListMarker(ctx, box, contentX, contentTop, firstFragment);
504
545
  return { height: record.height, baseline };
@@ -812,7 +853,9 @@ function layoutGrid(ctx, box, x, width, y) {
812
853
  const rowStarts = trackStarts(rows, [], rowGap, y);
813
854
 
814
855
  let baseline = null;
856
+ const placements = [];
815
857
  for (const item of grid.items) {
858
+ const placement = { row: item.row, boxStart: ctx.boxes.length, fragmentStart: ctx.fragments.length };
816
859
  const areaY = rowStarts[item.row];
817
860
  const areaHeight = spanSize(rows, [], rowGap, item.row, item.rowSpan);
818
861
  const itemStyle = item.box.style;
@@ -832,9 +875,38 @@ function layoutGrid(ctx, box, x, width, y) {
832
875
  }
833
876
  const result = layoutBlock(ctx, item.box, item.horizontal.x, item.horizontal.width, itemY, false, forcedHeight);
834
877
  baseline ??= result.baseline;
878
+ placements.push(Object.assign(placement, { boxEnd: ctx.boxes.length, fragmentEnd: ctx.fragments.length }));
835
879
  }
836
880
  const height = rows.reduce((sum, size) => sum + size, 0) + rowGap * Math.max(0, rows.length - 1);
837
- return { bottom: y + height, baseline };
881
+ return { bottom: y + height, baseline, placements, rowCount: rows.length };
882
+ }
883
+
884
+ /**
885
+ * align-content for a grid taller than its rows (CSS Box Alignment 3 §5.3):
886
+ * the free space goes before, between or around the rows. Each item moves
887
+ * with its (first) row.
888
+ */
889
+ function alignGridContent(ctx, grid, alignment = "normal", free) {
890
+ if (!(free > 0) || !grid.rowCount || ["normal", "start", "stretch"].includes(alignment)) return;
891
+ const count = grid.rowCount;
892
+ const offset = (row) => {
893
+ switch (alignment) {
894
+ case "center": return free / 2;
895
+ case "end": return free;
896
+ case "space-between": return count > 1 ? free * row / (count - 1) : 0;
897
+ case "space-around": return free / count * (row + 0.5);
898
+ case "space-evenly": return free / (count + 1) * (row + 1);
899
+ default: return 0;
900
+ }
901
+ };
902
+ for (const placement of grid.placements) {
903
+ const dy = offset(placement.row);
904
+ if (!dy) continue;
905
+ for (let index = placement.boxStart; index < placement.boxEnd; index++) ctx.boxes[index].y += dy;
906
+ for (let index = placement.fragmentStart; index < placement.fragmentEnd; index++) {
907
+ ctx.fragments[index] = shiftedFragment(ctx.fragments[index], 0, dy);
908
+ }
909
+ }
838
910
  }
839
911
 
840
912
  // ---------------------------------------------------------------------------
@@ -1426,7 +1498,7 @@ function dryLayout(ctx, box, width) {
1426
1498
  }
1427
1499
 
1428
1500
  /** Resolves the used border-box width and horizontal margins of a block-level box. */
1429
- function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
1501
+ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink, percentageBasis = containingWidth) {
1430
1502
  const style = box.style;
1431
1503
  const padding = style?.padding ?? ZERO_BOX;
1432
1504
  const extra = padding[1] + padding[3] + borderWidths(style)[1] + borderWidths(style)[3];
@@ -1435,15 +1507,15 @@ function resolveHorizontal(ctx, box, containingX, containingWidth, shrink) {
1435
1507
  let marginLeft = numberOrZero(margin[3]);
1436
1508
  let marginRight = numberOrZero(margin[1]);
1437
1509
  const available = Math.max(0, containingWidth - marginLeft - marginRight);
1438
- const specified = numberOrNull(resolveLength(style?.width, containingWidth));
1510
+ const specified = numberOrNull(resolveLength(style?.width, percentageBasis));
1439
1511
  let width;
1440
1512
  if (box.replaced) width = imageDimensions(box.node, containingWidth).width + extra;
1441
1513
  else if (specified != null) width = borderBox(specified);
1442
1514
  else if (shrink) width = Math.min(maxContentWidth(ctx, box), available);
1443
1515
  else width = available;
1444
- const maxWidth = numberOrNull(resolveLength(style?.maxWidth, containingWidth));
1516
+ const maxWidth = numberOrNull(resolveLength(style?.maxWidth, percentageBasis));
1445
1517
  if (maxWidth != null) width = Math.min(width, borderBox(maxWidth));
1446
- const minWidth = numberOrNull(resolveLength(style?.minWidth, containingWidth));
1518
+ const minWidth = numberOrNull(resolveLength(style?.minWidth, percentageBasis));
1447
1519
  if (minWidth != null) width = Math.max(width, borderBox(minWidth));
1448
1520
  width = Math.max(width, extra);
1449
1521
 
@@ -1724,7 +1796,7 @@ function buildParagraph(ctx, ifc) {
1724
1796
  const runAt = new Array(text.length);
1725
1797
  for (const run of visibleRuns) for (let index = run.start; index < run.end; index++) runAt[index] = run;
1726
1798
  const opportunities = new Set();
1727
- const graphemes = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)];
1799
+ const graphemes = [...GRAPHEME_SEGMENTER.segment(text)];
1728
1800
  for (const { segment, index } of graphemes) {
1729
1801
  const run = runAt[index];
1730
1802
  const end = index + segment.length;
@@ -2005,6 +2077,7 @@ function placeLine(ctx, ifc, paragraph, line, x, width, y, atomFor) {
2005
2077
  height: segment.dimensions.height,
2006
2078
  resource: run.node.resource,
2007
2079
  video: run.node.tagName === "VIDEO",
2080
+ audio: run.node.tagName === "AUDIO",
2008
2081
  svg: run.node.svg ?? null,
2009
2082
  });
2010
2083
  } else if (run.kind === "atomic") {
@@ -2124,13 +2197,15 @@ function borderWidths(style) {
2124
2197
  return style?.borderWidths ?? ZERO_BOX;
2125
2198
  }
2126
2199
 
2200
+ // Not frozen: intrinsic sizing lays lines out many times over, and freezing
2201
+ // every run was the largest single cost of layout. Nothing mutates them.
2127
2202
  function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline = null) {
2128
2203
  return {
2129
2204
  type: "line",
2130
2205
  baseline,
2131
2206
  text,
2132
- nodeIds: Object.freeze(nodeIds),
2133
- runs: Object.freeze(runs.map((run) => Object.freeze(run))),
2207
+ nodeIds,
2208
+ runs,
2134
2209
  x,
2135
2210
  y,
2136
2211
  width,
@@ -2142,6 +2217,8 @@ function lineFragment(style, text, nodeIds, runs, x, y, width, height, baseline
2142
2217
  // ---------------------------------------------------------------------------
2143
2218
  // Shared helpers
2144
2219
 
2220
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
2221
+
2145
2222
  function cachedMeasure(measureText) {
2146
2223
  const byStyle = new Map();
2147
2224
  return (text, style) => {
@@ -2156,7 +2233,9 @@ function cachedMeasure(measureText) {
2156
2233
 
2157
2234
  function cachedMetrics(fontMetrics) {
2158
2235
  const cache = new Map();
2159
- return (style) => {
2236
+ // Computed styles are frozen and shared, so most lookups hit by identity.
2237
+ const byStyle = new WeakMap();
2238
+ const metricsFor = (style) => {
2160
2239
  const fontSize = style?.fontSize ?? 16;
2161
2240
  const key = `${fontSize}/${style?.fontWeight ?? 400}/${style?.fontFamily?.join(",")}`;
2162
2241
  let metrics = cache.get(key);
@@ -2169,6 +2248,12 @@ function cachedMetrics(fontMetrics) {
2169
2248
  }
2170
2249
  return metrics;
2171
2250
  };
2251
+ return (style) => {
2252
+ if (style === null || typeof style !== "object") return metricsFor(style);
2253
+ let metrics = byStyle.get(style);
2254
+ if (!metrics) byStyle.set(style, metrics = metricsFor(style));
2255
+ return metrics;
2256
+ };
2172
2257
  }
2173
2258
 
2174
2259
  function imageDimensions(node, referenceWidth) {
@@ -59,10 +59,10 @@ export class NetworkService {
59
59
  }
60
60
 
61
61
  /** A top-level document load. */
62
- async navigate(url) {
62
+ async navigate(url, { onResponse } = {}) {
63
63
  return this.#send({ url, method: "GET", headers: [], body: null }, {
64
64
  credentials: "include", site: null, topLevelNavigation: true,
65
- });
65
+ }, { onResponse });
66
66
  }
67
67
 
68
68
  /** A subresource the renderer needs to render (no-cors, credentials included). */
@@ -192,7 +192,7 @@ export class NetworkService {
192
192
  }
193
193
 
194
194
  /** Sends a request, following redirects by hand so cookies apply to every hop. */
195
- async #send(request, context, { redirect = "follow", corsOrigin = null } = {}) {
195
+ async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null } = {}) {
196
196
  let { url, method, body } = request;
197
197
  const baseHeaders = request.headers;
198
198
  let redirected = false;
@@ -223,6 +223,10 @@ export class NetworkService {
223
223
  redirected = true;
224
224
  continue;
225
225
  }
226
+ if (onResponse) {
227
+ const handled = await onResponse(response, url);
228
+ if (handled) return handled;
229
+ }
226
230
  return this.#result(response, url, redirected, "basic");
227
231
  }
228
232
  throw new TypeError("Too many redirects");
@@ -12,11 +12,20 @@
12
12
  * each with its own socket), and requests connect to an address directly
13
13
  * while keeping the host name for the Host header and for TLS (SNI and
14
14
  * certificate verification). This also covers musl, which has no such option.
15
+ *
16
+ * `localhost` and `*.localhost` are loopback without asking anyone (RFC 6761
17
+ * §6.3, as Chrome and Bun's own socket resolver do): node:dns hands them to
18
+ * the system getaddrinfo(), which needs an /etc/hosts entry that minimal
19
+ * systems (an initramfs, containers) may not have.
15
20
  */
16
21
  import { lookup as systemLookup } from "node:dns/promises";
17
22
  import { isIP } from "node:net";
18
23
 
19
24
  const DEFAULT_TTL_MS = 60_000;
25
+ const LOOPBACK = Object.freeze([
26
+ Object.freeze({ address: "127.0.0.1", family: 4 }),
27
+ Object.freeze({ address: "::1", family: 6 }),
28
+ ]);
20
29
  // Errors that mean "this address did not answer": try the next one.
21
30
  const CONNECT_ERRORS = new Set([
22
31
  "ECONNREFUSED", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL",
@@ -41,6 +50,7 @@ export class HostResolver {
41
50
  /** Addresses for a host name, IPv4 first, then IPv6. */
42
51
  addresses(hostname) {
43
52
  const name = hostname.toLowerCase();
53
+ if (isLocalhostName(name)) return Promise.resolve(LOOPBACK);
44
54
  const cached = this.#cache.get(name);
45
55
  if (cached && cached.expires > this.#now()) return cached.addresses;
46
56
  const addresses = this.#resolve(name);
@@ -70,6 +80,12 @@ export class HostResolver {
70
80
  }
71
81
  }
72
82
 
83
+ /** `localhost` or a name under `.localhost` (one trailing dot allowed), lowercase. */
84
+ export function isLocalhostName(name) {
85
+ const host = name.endsWith(".") ? name.slice(0, -1) : name;
86
+ return host === "localhost" || host.endsWith(".localhost");
87
+ }
88
+
73
89
  /**
74
90
  * fetch() that connects to a resolved address, keeping the host name for the
75
91
  * Host header and TLS. IP literals and non-HTTP URLs go to `fetchImpl` as is.
@@ -156,10 +156,10 @@ export function buildDisplayList(layout, tree = null) {
156
156
  return;
157
157
  }
158
158
  if (run.type === "image") {
159
- if (run.video) {
159
+ if (run.video || run.audio) {
160
160
  const rect = { x: run.x, y, width: run.width, height: run.height };
161
161
  images.push(run.resource);
162
- items.push({ op: "drawVideo", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
162
+ items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
163
163
  return;
164
164
  }
165
165
  if (!run.resource?.image) return;
@@ -185,6 +185,19 @@ export class RenderTreeBuilder {
185
185
  children: Object.freeze([]),
186
186
  });
187
187
  }
188
+ // Audio is a 300x32 player (painted like video's controls) with the
189
+ // controls attribute, and not rendered without it (HTML §4.8.11.13).
190
+ if (element.tagName === "AUDIO") {
191
+ if (!element.hasAttribute("controls")) return null;
192
+ return this.#node(element, "image", nodesById, {
193
+ tagName: "AUDIO",
194
+ style,
195
+ resource: resources?.get(element) ?? null,
196
+ widthAttribute: positiveDimension(element.getAttribute("width")) ?? 300,
197
+ heightAttribute: 32,
198
+ children: Object.freeze([]),
199
+ });
200
+ }
188
201
  if (element.tagName === "IMG" || element.tagName === "VIDEO") {
189
202
  return this.#node(element, "image", nodesById, {
190
203
  tagName: element.tagName,