buninu-browser 0.2.2 → 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 (118) hide show
  1. package/lib/audio/playback-buffer.js +27 -0
  2. package/lib/audio/pulse-client.js +18 -8
  3. package/lib/cdp-server.js +53 -14
  4. package/lib/happy-dom/document-base.js +33 -0
  5. package/lib/happy-dom/parser.js +20 -6
  6. package/lib/happy-dom/selector-matcher.js +2 -2
  7. package/lib/happy-dom/selector-pseudo-adapter.js +33 -0
  8. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  9. package/lib/headless-shell.js +12 -6
  10. package/lib/input/hit-test.js +67 -19
  11. package/lib/layout/text-layout.js +289 -131
  12. package/lib/media/byte-pipe.js +2 -2
  13. package/lib/media/position-audio.js +10 -1
  14. package/lib/media/source-snapshots.js +126 -0
  15. package/lib/media/video-keyframe.js +42 -0
  16. package/lib/media/webm-stream.js +106 -0
  17. package/lib/network/network-service.js +268 -120
  18. package/lib/paint/display-list.js +22 -27
  19. package/lib/paint/frame-cache.js +25 -0
  20. package/lib/paint/frame.js +42 -13
  21. package/lib/paint/paragraph-text.js +24 -0
  22. package/lib/paint/raster-readback.js +41 -0
  23. package/lib/paint/rounded-rect.js +82 -0
  24. package/lib/paint/shared-frame-pool.js +105 -0
  25. package/lib/render-tree/index.js +40 -6
  26. package/lib/render-tree/svg.js +15 -7
  27. package/lib/renderer/bindings.js +244 -82
  28. package/lib/renderer/broadcast-channel.js +76 -0
  29. package/lib/renderer/canvas-2d.js +257 -14
  30. package/lib/renderer/canvas-text.js +120 -0
  31. package/lib/renderer/compositor.js +27 -10
  32. package/lib/renderer/custom-element-reactions.js +42 -0
  33. package/lib/renderer/font-loading.js +227 -0
  34. package/lib/renderer/history.js +42 -0
  35. package/lib/renderer/host.js +36 -5
  36. package/lib/renderer/image-loading.js +27 -0
  37. package/lib/renderer/image-resources.js +65 -0
  38. package/lib/renderer/interfaces.js +28 -0
  39. package/lib/renderer/keyboard-input.js +12 -0
  40. package/lib/renderer/layout-observers.js +236 -0
  41. package/lib/renderer/main.js +33 -5
  42. package/lib/renderer/media-playback-events.js +13 -0
  43. package/lib/renderer/media-source.js +126 -55
  44. package/lib/renderer/page-realm.js +278 -147
  45. package/lib/renderer/page-renderer.js +840 -301
  46. package/lib/renderer/piano-input.js +22 -0
  47. package/lib/renderer/pointer-move.js +29 -0
  48. package/lib/renderer/prepared-script-cache.js +50 -0
  49. package/lib/renderer/rendered-text.js +108 -0
  50. package/lib/renderer/rewrite-worker.js +4 -3
  51. package/lib/renderer/script-rewrite.js +13 -2
  52. package/lib/renderer/scroll-into-view.js +28 -0
  53. package/lib/renderer/slot-assignment.js +60 -0
  54. package/lib/renderer/video-presentation.js +145 -0
  55. package/lib/renderer/workers.js +2 -2
  56. package/lib/style/computed-style.js +632 -108
  57. package/lib/style/selector-traits.js +49 -5
  58. package/package.json +1 -1
  59. package/test/broadcast-channel.test.js +26 -0
  60. package/test/canvas-2d.test.js +83 -2
  61. package/test/canvas-paint.test.js +9 -0
  62. package/test/canvas-text.test.js +41 -0
  63. package/test/cdp-stream.test.js +56 -0
  64. package/test/compositor.test.js +38 -0
  65. package/test/computed-style-binding.test.js +151 -0
  66. package/test/computed-style.test.js +279 -1
  67. package/test/display-list.test.js +26 -0
  68. package/test/document-base.test.js +87 -0
  69. package/test/dom-platform.test.js +461 -0
  70. package/test/download.test.js +8 -0
  71. package/test/fetch-lifecycle.test.js +161 -0
  72. package/test/fixtures/canvas-paint.js +117 -0
  73. package/test/fixtures/canvas-text.js +42 -0
  74. package/test/fixtures/image-lifetime.js +90 -0
  75. package/test/fixtures/media-seek-snapshot.js +77 -0
  76. package/test/fixtures/mse-audio-chirp.webm +0 -0
  77. package/test/fixtures/mse-switch-large.mp4 +0 -0
  78. package/test/fixtures/mse-switch-small.mp4 +0 -0
  79. package/test/fixtures/renderer-memory.js +110 -0
  80. package/test/fixtures/summary-style.js +28 -0
  81. package/test/font-loading.test.js +97 -0
  82. package/test/grid.test.js +58 -0
  83. package/test/headless-shell.test.js +22 -0
  84. package/test/hit-test.test.js +21 -1
  85. package/test/image-lifetime.test.js +12 -0
  86. package/test/image-resources.test.js +94 -0
  87. package/test/local-media.test.js +84 -0
  88. package/test/media-playback-events.test.js +22 -0
  89. package/test/media-seek-snapshot.test.js +11 -0
  90. package/test/media-source-config.test.js +98 -0
  91. package/test/media-source-webm-accounting.test.js +98 -0
  92. package/test/media-source-webm.test.js +108 -0
  93. package/test/navigation.test.js +1 -0
  94. package/test/network-service.test.js +388 -0
  95. package/test/page-scripts.test.js +160 -2
  96. package/test/paint-frame.test.js +103 -0
  97. package/test/paint-paragraph-cache.test.js +87 -0
  98. package/test/piano-input.test.js +44 -0
  99. package/test/playback-buffer.test.js +31 -0
  100. package/test/position-audio.test.js +24 -0
  101. package/test/positioning.test.js +22 -0
  102. package/test/prepared-script-cache.test.js +45 -0
  103. package/test/pulse-client.test.js +27 -5
  104. package/test/raster-readback.test.js +40 -0
  105. package/test/render-tree.test.js +28 -0
  106. package/test/renderer-memory.test.js +11 -0
  107. package/test/selector-traits.test.js +24 -0
  108. package/test/shared-frame-pool.test.js +63 -0
  109. package/test/style-sheets.test.js +51 -0
  110. package/test/svg.test.js +29 -0
  111. package/test/text-layout.test.js +168 -0
  112. package/test/video-keyframe.test.js +61 -0
  113. package/test/video-presentation.test.js +136 -0
  114. package/tools/browser-lab.js +78 -0
  115. package/tools/browser-soak.js +75 -0
  116. package/tools/network-reference.js +49 -0
  117. package/tools/process-memory.js +38 -0
  118. package/tools/script-benchmark.js +25 -0
@@ -0,0 +1,27 @@
1
+ // Decoded PCM kept in the controller is independent of the device queue.
2
+ // It can bridge renderer work without increasing pause/seek output latency.
3
+ export class PlaybackBuffer {
4
+ constructor(bytesPerSecond, frameBytes, leadSeconds) {
5
+ this.bytesPerSecond = bytesPerSecond;
6
+ this.frameBytes = frameBytes;
7
+ this.reserve = leadSeconds <= 0.15 ? 0 : this.align(bytesPerSecond * 2);
8
+ this.threshold = this.reserve ? this.align(bytesPerSecond * 0.4) : 0;
9
+ this.buffering = Boolean(this.reserve);
10
+ }
11
+
12
+ align(bytes) {
13
+ return Math.max(0, Math.floor(bytes / this.frameBytes) * this.frameBytes);
14
+ }
15
+
16
+ ready(queued, inDevice, finishing) {
17
+ if (!this.reserve || finishing) return true;
18
+ if (queued < this.frameBytes && inDevice < this.frameBytes) this.buffering = true;
19
+ if (this.buffering && queued >= this.threshold) this.buffering = false;
20
+ return !this.buffering;
21
+ }
22
+
23
+ request(queued, outstanding, inDevice, immediateNeed) {
24
+ // Requests stay bounded even when the producer or network stops replying.
25
+ return this.align(Math.max(immediateNeed, this.reserve - inDevice) - queued - outstanding);
26
+ }
27
+ }
@@ -13,6 +13,7 @@
13
13
  * other channels carry a stream's audio. Audio is sent as s16le.
14
14
  */
15
15
  import { connect } from "node:net";
16
+ import { PlaybackBuffer } from "./playback-buffer.js";
16
17
 
17
18
  export { floatToS16 } from "./pcm.js";
18
19
 
@@ -332,9 +333,9 @@ export class PulseClient {
332
333
  * to LEAD_SECONDS ahead of the playback clock, whatever the server asks for.
333
334
  * Some servers ask for more at once and queue it themselves (jspulse's
334
335
  * OpenSL ES backend requests again as soon as it receives), and then a pause
335
- * would leave everything already sent still playing. `onRequest(bytes)` asks
336
- * the producer for about that much more; audio it has not been asked for is
337
- * never requested twice.
336
+ * would leave everything already sent still playing. The producer prepares a
337
+ * separate bounded PCM reserve here so renderer work cannot immediately starve
338
+ * the output. That reserve is discarded on close, not sent to the device.
338
339
  */
339
340
  const LEAD_SECONDS = 0.5;
340
341
  const PACE_MS = 50;
@@ -359,6 +360,7 @@ class PlaybackStream {
359
360
  #onTimingError;
360
361
  #timingPending = false;
361
362
  #playedBytes = 0;
363
+ #producerBuffer;
362
364
  killed = false;
363
365
 
364
366
  constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
@@ -371,6 +373,7 @@ class PlaybackStream {
371
373
  this.#bytesPerSecond = rate * this.#frameBytes;
372
374
  this.#onRequest = onRequest;
373
375
  this.#leadSeconds = leadSeconds;
376
+ this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
374
377
  this.#onTiming = onTiming;
375
378
  this.#onTimingError = onTimingError;
376
379
  this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
@@ -388,7 +391,8 @@ class PlaybackStream {
388
391
  const played = Math.min(end, Math.max(0, Math.min(end, timing.readIndex) - timing.sinkSeconds * this.#bytesPerSecond));
389
392
  this.#playedBytes = Math.max(this.#playedBytes, this.#frameAligned(played));
390
393
  this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
391
- sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing });
394
+ sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
395
+ bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
392
396
  this.#pace();
393
397
  } catch (error) {
394
398
  if (!this.killed) this.#onTimingError?.(error);
@@ -436,7 +440,11 @@ class PlaybackStream {
436
440
  // (a few at a time), so small packets leave them little to play from.
437
441
  const minimum = this.#frameAligned(this.#bytesPerSecond * MIN_PACKET_SECONDS);
438
442
  const maximum = this.#frameAligned(this.#bytesPerSecond * MAX_PACKET_SECONDS);
439
- while (this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
443
+ const inDevice = this.#onTiming ? Math.max(0, this.#offset - this.#playedBytes)
444
+ : this.#started === null ? 0 : Math.max(0, this.#offset - (performance.now() - this.#started) / 1000 * this.#bytesPerSecond);
445
+ const ready = this.#producerBuffer.ready(this.#queuedBytes, inDevice, this.finishing);
446
+ const previousOffset = this.#offset;
447
+ while (ready && this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
440
448
  const room = this.#frameAligned(Math.min(this.#credit, allowed - this.#offset, maximum, this.#queuedBytes));
441
449
  if (room < this.#frameBytes || (room < minimum && !this.finishing && this.#queuedBytes < minimum)) break;
442
450
  const packet = new Uint8Array(room);
@@ -454,9 +462,11 @@ class PlaybackStream {
454
462
  this.#credit -= room;
455
463
  this.#queuedBytes -= room;
456
464
  }
457
- // Ask for what the clock will allow next that is neither queued nor already asked for.
458
- const need = this.#frameAligned(Math.min(this.#offset + this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000)
459
- - this.#offset - this.#queuedBytes - this.#outstanding);
465
+ // Predecode independently of server credits; only transmission uses the
466
+ // small device budget above. Keep outstanding requests in that budget too.
467
+ const immediateNeed = Math.min(this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000 - this.#offset);
468
+ const sentNow = inDevice + this.#offset - previousOffset;
469
+ const need = this.#producerBuffer.request(this.#queuedBytes, this.#outstanding, sentNow, immediateNeed);
460
470
  if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
461
471
  this.#outstanding += need;
462
472
  this.#onRequest(need);
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": {
@@ -686,6 +700,10 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
686
700
  return {};
687
701
  case "Input.dispatchMouseEvent": {
688
702
  const target = getSessionTarget(state, sessionId);
703
+ if (params.type === "mouseMoved") {
704
+ await ctx.move?.(params.x, params.y);
705
+ return {};
706
+ }
689
707
  if (params.type === "mouseWheel") {
690
708
  await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0, { x: params.x, y: params.y });
691
709
  return {};
@@ -703,7 +721,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
703
721
  navigateTarget(state, target, navigation.url, emit, { addHistory: true });
704
722
  target.title = navigation.title ?? await getContextTitle(ctx, target);
705
723
  target.history[target.historyIndex].title = target.title;
706
- if (target.screencast) await emitScreencastFrame(ctx, state, target, emit);
724
+ if (target.screencast) await pushScreencastFrame(ctx, state, target);
707
725
  }
708
726
  }
709
727
  return {};
@@ -719,6 +737,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
719
737
  text: params.text ?? "",
720
738
  code: params.code ?? "",
721
739
  repeat: !!params.autoRepeat,
740
+ windowsVirtualKeyCode: params.windowsVirtualKeyCode,
722
741
  });
723
742
  target.cdpKeyDown = signature;
724
743
  }
@@ -729,6 +748,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
729
748
  text: params.text ?? "",
730
749
  code: params.code ?? "",
731
750
  repeat: !!params.autoRepeat,
751
+ windowsVirtualKeyCode: params.windowsVirtualKeyCode,
732
752
  });
733
753
  }
734
754
  } else if (params.type === "keyUp") {
@@ -811,6 +831,7 @@ function createTarget(state, url = "about:blank", options = {}) {
811
831
  deviceScaleFactor: 1,
812
832
  },
813
833
  screencast: null,
834
+ screencastFrames: new Set(),
814
835
  };
815
836
  state.targets.set(targetId, target);
816
837
  state.initialNavigations.set(targetId, {
@@ -851,9 +872,20 @@ function emitLifecycle(target, name) {
851
872
  emit("Page.lifecycleEvent", { frameId: target.frameId, loaderId: target.loaderId, name, timestamp });
852
873
  }
853
874
 
854
- /** 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. */
855
886
  async function pushScreencastFrame(ctx, state, target) {
856
- if (target.screencastBusy) {
887
+ if (!target.screencast || !target.screencastEmit) return;
888
+ if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
857
889
  target.screencastPending = true;
858
890
  return;
859
891
  }
@@ -861,8 +893,9 @@ async function pushScreencastFrame(ctx, state, target) {
861
893
  try {
862
894
  do {
863
895
  target.screencastPending = false;
864
- await emitScreencastFrame(ctx, state, target, target.screencastEmit);
865
- } 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);
866
899
  } catch (error) {
867
900
  serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
868
901
  } finally {
@@ -870,10 +903,14 @@ async function pushScreencastFrame(ctx, state, target) {
870
903
  }
871
904
  }
872
905
 
873
- async function emitScreencastFrame(ctx, state, target, emit) {
874
- const data = await screenshotData(ctx, target, target.screencast ?? {});
875
- 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;
876
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);
877
914
  emit("Page.screencastFrame", {
878
915
  data,
879
916
  metadata: {
@@ -885,7 +922,7 @@ async function emitScreencastFrame(ctx, state, target, emit) {
885
922
  scrollOffsetY: scrollOffset.y,
886
923
  timestamp: performance.now() / 1000,
887
924
  },
888
- sessionId: state.nextScreencastFrameId++,
925
+ sessionId,
889
926
  });
890
927
  }
891
928
 
@@ -994,6 +1031,8 @@ function getImplicitTarget(state) {
994
1031
 
995
1032
  state.implicitTarget = {
996
1033
  targetId: "implicit-target",
1034
+ screencast: null,
1035
+ screencastFrames: new Set(),
997
1036
  frameId: "implicit-target",
998
1037
  loaderId: "implicit-loader",
999
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,5 +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";
4
+ import "./selector-pseudo-adapter.js";
5
+ import "./svg-attribute-adapter.js";
6
+ import "./document-base.js";
3
7
 
4
8
  const IMPLICIT_HEAD_ELEMENTS = new Set([
5
9
  "BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
@@ -11,8 +15,18 @@ const IMPLICIT_HEAD_ELEMENTS = new Set([
11
15
  * Page JavaScript and subresource loading stay disabled here. They belong to
12
16
  * the renderer scheduler and network service rather than the tree builder.
13
17
  */
14
- export function parseHTMLDocument(html, url = "about:blank") {
15
- 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({
16
30
  url,
17
31
  settings: {
18
32
  disableJavaScriptEvaluation: true,
@@ -20,12 +34,12 @@ export function parseHTMLDocument(html, url = "about:blank") {
20
34
  disableCSSFileLoading: true,
21
35
  disableIframePageLoading: true,
22
36
  enableImageFileLoading: false,
37
+ // This DOM is only the tree builder. Link activation must not change
38
+ // its URL before our controller commits a navigation (downloads never
39
+ // commit). Otherwise later #links resolve against the download URL.
40
+ navigation: { disableFallbackToSetURL: true },
23
41
  },
24
42
  });
25
-
26
- new HTMLParser(window).parse(String(html), window.document);
27
- normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
28
- return { window, document: window.document };
29
43
  }
30
44
 
31
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
  }
@@ -0,0 +1,33 @@
1
+ import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
2
+ import { compileSelector, matchesCompiled } from "./selector-matcher.js";
3
+
4
+ // Happy DOM's :empty checks only element children. CSS also excludes elements
5
+ // with nonempty text nodes (including whitespace); comments do not count.
6
+ const original = SelectorItem.prototype.matchPseudoItem;
7
+ const selectorLists = new WeakMap();
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
+ }
26
+ if (pseudo.name === "empty") {
27
+ for (const child of element.childNodes) {
28
+ if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
29
+ }
30
+ return { priorityWeight: 10 };
31
+ }
32
+ return original.call(this, scope, element, siblings, pseudo, ignoreErrors);
33
+ };
@@ -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
  /**
@@ -455,6 +457,9 @@ export async function createBrowser({
455
457
  lifecycleState() {
456
458
  return [...reachedLifecycle(committed.host, committed.generation)];
457
459
  },
460
+ move(x, y) {
461
+ return call("move", x / pageScale(), y / pageScale());
462
+ },
458
463
  async click(x, y) {
459
464
  const clicked = await call("click", x / pageScale(), y / pageScale());
460
465
  const href = typeof clicked === "string" ? clicked : clicked?.url;
@@ -463,7 +468,8 @@ export async function createBrowser({
463
468
  recordFragment(href, clicked.fragmentFrom);
464
469
  return { url: href, title: await call("title") };
465
470
  }
466
- 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 });
467
473
  return result === null ? null : { url: href, title: await call("title") };
468
474
  },
469
475
  evaluate: (expression) => call("evaluate", String(expression)),