buninu-browser 0.5.1 → 0.6.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 (133) hide show
  1. package/README.md +9 -3
  2. package/lib/audio/pcm.js +11 -0
  3. package/lib/audio/pulse-client.js +7 -2
  4. package/lib/cdp-server.js +31 -7
  5. package/lib/happy-dom/document-base.js +29 -1
  6. package/lib/happy-dom/mutation-observer.js +157 -0
  7. package/lib/happy-dom/parser.js +2 -0
  8. package/lib/headless-shell.js +49 -9
  9. package/lib/layout/repaint.js +89 -0
  10. package/lib/media/mp4-keyframes.js +144 -0
  11. package/lib/media/video-keyframe.js +29 -1
  12. package/lib/media/webm-snapshots.js +282 -0
  13. package/lib/network/cookie-jar.js +18 -2
  14. package/lib/paint/display-list.js +4 -3
  15. package/lib/paint/frame.js +12 -5
  16. package/lib/render-tree/index.js +3 -0
  17. package/lib/renderer/animation-frames.js +76 -0
  18. package/lib/renderer/bindings.js +2 -0
  19. package/lib/renderer/compositor-channel.js +95 -10
  20. package/lib/renderer/compositor.js +54 -18
  21. package/lib/renderer/document-cookies.js +87 -0
  22. package/lib/renderer/font-loading.js +60 -16
  23. package/lib/renderer/host.js +48 -19
  24. package/lib/renderer/idle-callbacks.js +109 -0
  25. package/lib/renderer/main.js +5 -1
  26. package/lib/renderer/media-source.js +4 -0
  27. package/lib/renderer/page-realm.js +159 -49
  28. package/lib/renderer/page-renderer.js +567 -197
  29. package/lib/renderer/resource-elements.js +88 -0
  30. package/lib/renderer/script-rewrite.js +18 -1
  31. package/lib/renderer/seccomp.js +6 -4
  32. package/lib/renderer/timers.js +71 -0
  33. package/lib/renderer/video-channel.js +94 -0
  34. package/lib/renderer/video-mask.js +115 -0
  35. package/lib/renderer/video-presentation.js +304 -39
  36. package/lib/renderer/video-worker.js +46 -0
  37. package/lib/renderer/workers.js +7 -17
  38. package/lib/style/computed-style.js +257 -61
  39. package/package.json +1 -1
  40. package/test/animation-frames.test.js +67 -0
  41. package/test/canvas-style-snapshot.test.js +18 -0
  42. package/test/cdp-stream.test.js +96 -3
  43. package/test/compositor-channel-ownership.test.js +194 -0
  44. package/test/compositor.test.js +27 -1
  45. package/test/computed-style-binding.test.js +49 -0
  46. package/test/computed-style-connectivity.test.js +13 -0
  47. package/test/computed-style.test.js +86 -0
  48. package/test/cookie-sync.test.js +82 -0
  49. package/test/document-base-cache.test.js +103 -0
  50. package/test/document-cookies.test.js +117 -0
  51. package/test/duplicate-style-keys.test.js +11 -0
  52. package/test/fixtures/animation-frames.js +56 -0
  53. package/test/fixtures/canvas-style-snapshot.js +81 -0
  54. package/test/fixtures/computed-style-connectivity.js +101 -0
  55. package/test/fixtures/computed-style-lazy.js +135 -0
  56. package/test/fixtures/duplicate-style-keys.js +97 -0
  57. package/test/fixtures/fake-renderer.js +12 -0
  58. package/test/fixtures/font-definition-invalidation.js +97 -0
  59. package/test/fixtures/font-metrics-invalidation.js +87 -0
  60. package/test/fixtures/full-style-identity.js +36 -0
  61. package/test/fixtures/hidden-style-engine.js +260 -0
  62. package/test/fixtures/hidden-style-reentry.js +232 -0
  63. package/test/fixtures/hidden-style-renderer.js +203 -0
  64. package/test/fixtures/layout-repaint.js +69 -0
  65. package/test/fixtures/media-presentation-continuation.js +141 -0
  66. package/test/fixtures/media-presentation-refill.js +113 -0
  67. package/test/fixtures/media-probe.js +148 -0
  68. package/test/fixtures/media-refill-cancel.js +79 -0
  69. package/test/fixtures/media-repaint.js +48 -0
  70. package/test/fixtures/media-resource-reselection.js +94 -0
  71. package/test/fixtures/media-source-attachment.js +136 -0
  72. package/test/fixtures/media-source-no-decoder.js +17 -0
  73. package/test/fixtures/media-track-refill.js +132 -0
  74. package/test/fixtures/media-webm-seek.js +51 -0
  75. package/test/fixtures/mutation-observer.js +201 -0
  76. package/test/fixtures/observer-invalidation.js +105 -0
  77. package/test/fixtures/prepaint-scheduling.js +73 -0
  78. package/test/fixtures/resource-discovery.js +32 -0
  79. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  80. package/test/fixtures/style-propagation.js +26 -0
  81. package/test/fixtures/svg-node-reuse.js +83 -0
  82. package/test/fixtures/video-black-replay.js +140 -0
  83. package/test/fixtures/video-controls-invalidation.js +71 -0
  84. package/test/fixtures/viewport-bounds.js +148 -0
  85. package/test/font-loading.test.js +77 -0
  86. package/test/font-metrics-invalidation.test.js +60 -0
  87. package/test/full-style-identity.test.js +191 -0
  88. package/test/hidden-style-lazy.test.js +31 -0
  89. package/test/hidden-style-reentry.test.js +20 -0
  90. package/test/idle-callback.test.js +89 -0
  91. package/test/idle-scheduler.test.js +148 -0
  92. package/test/inline-style-cascade.test.js +63 -0
  93. package/test/layout-repaint-renderer.test.js +9 -0
  94. package/test/layout-repaint.test.js +222 -0
  95. package/test/media-presentation-continuation.test.js +14 -0
  96. package/test/media-presentation-refill.test.js +8 -0
  97. package/test/media-probe.test.js +16 -0
  98. package/test/media-repaint.test.js +8 -0
  99. package/test/media-source-attachment.test.js +34 -0
  100. package/test/media-track-refill.test.js +26 -0
  101. package/test/media-webm-seek.test.js +11 -0
  102. package/test/mp4-keyframes.test.js +36 -0
  103. package/test/mutation-observer.test.js +91 -0
  104. package/test/observer-invalidation.test.js +12 -0
  105. package/test/page-scripts.test.js +35 -0
  106. package/test/page-timers.test.js +67 -0
  107. package/test/paint-frame.test.js +18 -1
  108. package/test/pcm.test.js +63 -0
  109. package/test/prepaint-scheduling.test.js +13 -0
  110. package/test/pulse-client.test.js +165 -0
  111. package/test/raw-capture.test.js +34 -5
  112. package/test/renderer-host.test.js +51 -0
  113. package/test/resource-discovery.test.js +12 -0
  114. package/test/resource-elements.test.js +234 -0
  115. package/test/root-self-style.test.js +104 -0
  116. package/test/script-rewrite.test.js +35 -0
  117. package/test/seccomp.test.js +45 -0
  118. package/test/spare-renderer-scheduling.test.js +8 -0
  119. package/test/style-propagation.test.js +225 -0
  120. package/test/style-sheets.test.js +19 -0
  121. package/test/svg.test.js +45 -0
  122. package/test/timers.test.js +58 -0
  123. package/test/video-black-replay.test.js +34 -0
  124. package/test/video-capture.test.js +170 -0
  125. package/test/video-channel.test.js +253 -0
  126. package/test/video-continuation.test.js +57 -0
  127. package/test/video-controls-invalidation.test.js +12 -0
  128. package/test/video-keyframe.test.js +104 -4
  129. package/test/video-mask.test.js +94 -0
  130. package/test/video-presentation.test.js +239 -1
  131. package/test/video-timing-presentation.test.js +115 -0
  132. package/test/viewport-bounds.test.js +13 -0
  133. package/test/webm-snapshots.test.js +372 -0
package/README.md CHANGED
@@ -291,9 +291,9 @@ borrows a contiguous raster view instead of rescaling; encoded output uses
291
291
  the same crop. `Page.getLayoutMetrics` provides the viewport position so
292
292
  Casty can follow document scrolling.
293
293
 
294
- Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP.
294
+ Casty also sends a private `clip` on `Page.startScreencast`. It takes precedence over fallback max limits and crops at native pixel density, without resizing. Width/height are CSS units; output pixels still follow device scale. This extension is not standard CDP. A clip with both width and height equal to zero requests update notifications only: the server sends a fixed 1×1 PNG placeholder without rendering or encoding a preview. Ordinary captureScreenshot still rejects zero-sized clips.
295
295
 
296
- Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。
296
+ Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。寬高都為零時只發送更新通知:server 回傳固定 1×1 PNG 佔位資料,不渲染或編碼預覽;一般 captureScreenshot 仍拒絕零尺寸。
297
297
 
298
298
  標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
299
299
  避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
@@ -469,7 +469,13 @@ it to `buninu-browser.log` in the working directory, or set
469
469
 
470
470
  - page errors and page `console` output;
471
471
  - per-phase load timings;
472
- - renderer crashes and watchdog kills.
472
+ - renderer crashes and explicitly enabled watchdog kills.
473
+
474
+ Renderer and video-worker startup have no deadline. Document navigation also
475
+ waits until completion, failure, or explicit close. The browser does not enable
476
+ the renderer method watchdog by default: slow initialization or page work must
477
+ not kill the renderer. Internal callers can opt into a method watchdog through
478
+ `rendererTimeout`; this does not impose a startup or navigation deadline.
473
479
 
474
480
  Renderers cannot open files under seccomp, so the controller collects their
475
481
  output and writes the log.
package/lib/audio/pcm.js CHANGED
@@ -1,6 +1,17 @@
1
+ // Typed-array stores use host byte order; the wire format is always s16le.
2
+ const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;
3
+
1
4
  /** Interleaved Float32 samples in [-1, 1] (as audio decoders produce) as s16le bytes. */
2
5
  export function floatToS16(samples) {
3
6
  const out = new Uint8Array(samples.length * 2);
7
+ if (LITTLE_ENDIAN) {
8
+ const view = new Int16Array(out.buffer);
9
+ for (let index = 0; index < samples.length; index++) {
10
+ const value = Math.max(-1, Math.min(1, samples[index]));
11
+ view[index] = Math.round(value < 0 ? value * 32768 : value * 32767);
12
+ }
13
+ return out;
14
+ }
4
15
  const view = new DataView(out.buffer);
5
16
  for (let index = 0; index < samples.length; index++) {
6
17
  const value = Math.max(-1, Math.min(1, samples[index]));
@@ -339,6 +339,9 @@ export class PulseClient {
339
339
  */
340
340
  const LEAD_SECONDS = 0.5;
341
341
  const PACE_MS = 50;
342
+ // Video follows this measured clock. Sharing the 50 ms PCM timer would cap
343
+ // presentation at 20 FPS even when decoding and output can keep up.
344
+ const TIMING_MS = 16;
342
345
  const MIN_REQUEST_SECONDS = 0.02;
343
346
  const MIN_PACKET_SECONDS = 0.04;
344
347
  const MAX_PACKET_SECONDS = 0.1;
@@ -355,6 +358,7 @@ class PlaybackStream {
355
358
  #outstanding = 0;
356
359
  #started = null;
357
360
  #timer = null;
361
+ #timingTimer = null;
358
362
  #leadSeconds;
359
363
  #onTiming;
360
364
  #onTimingError;
@@ -376,7 +380,8 @@ class PlaybackStream {
376
380
  this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
377
381
  this.#onTiming = onTiming;
378
382
  this.#onTimingError = onTimingError;
379
- this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
383
+ this.#timer = setInterval(() => this.#pace(), PACE_MS);
384
+ if (onTiming) this.#timingTimer = setInterval(() => { void this.updateTiming(); }, TIMING_MS);
380
385
  }
381
386
 
382
387
  async updateTiming() {
@@ -393,7 +398,6 @@ class PlaybackStream {
393
398
  this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
394
399
  sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
395
400
  bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
396
- this.#pace();
397
401
  } catch (error) {
398
402
  if (!this.killed) this.#onTimingError?.(error);
399
403
  } finally {
@@ -527,5 +531,6 @@ class PlaybackStream {
527
531
  this.#queue = [];
528
532
  this.#queuedBytes = 0;
529
533
  clearInterval(this.#timer);
534
+ clearInterval(this.#timingTimer);
530
535
  }
531
536
  }
package/lib/cdp-server.js CHANGED
@@ -40,11 +40,20 @@ export class CdpServer {
40
40
  // Pages change on their own (loading stages, scripts, compositor
41
41
  // scrolling): push a screencast frame whenever the context reports one,
42
42
  // and send lifecycle events when the document really reaches them.
43
- context.onFrame?.(() => {
43
+ context.onFrame?.((frame) => {
44
+ const offset = frame?.scrollOffset;
45
+ const scrollOffset = Number.isFinite(offset?.x) && Number.isFinite(offset?.y)
46
+ ? { x: offset.x, y: offset.y } : null;
44
47
  for (const target of state.targets.values()) {
45
- if (target.screencast && target.screencastEmit) pushScreencastFrame(context, state, target);
48
+ if (target.screencast && target.screencastEmit) {
49
+ target.screencastScrollOffset = scrollOffset;
50
+ pushScreencastFrame(context, state, target);
51
+ }
52
+ }
53
+ if (state.implicitTarget?.screencast) {
54
+ state.implicitTarget.screencastScrollOffset = scrollOffset;
55
+ pushScreencastFrame(context, state, state.implicitTarget);
46
56
  }
47
- if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
48
57
  });
49
58
  context.onLifecycle?.((name) => {
50
59
  for (const target of state.targets.values()) emitLifecycle(target, name);
@@ -844,6 +853,7 @@ function createTarget(state, url = "about:blank", options = {}) {
844
853
  deviceScaleFactor: 1,
845
854
  },
846
855
  screencast: null,
856
+ screencastScrollOffset: null,
847
857
  screencastFrames: new Set(),
848
858
  };
849
859
  state.targets.set(targetId, target);
@@ -892,6 +902,7 @@ function stopScreencast(target) {
892
902
  target.screencast = null;
893
903
  target.screencastEmit = null;
894
904
  target.screencastPending = false;
905
+ target.screencastScrollOffset = null;
895
906
  target.screencastFrames.clear();
896
907
  }
897
908
 
@@ -918,10 +929,21 @@ async function pushScreencastFrame(ctx, state, target) {
918
929
 
919
930
  async function emitScreencastFrame(ctx, state, target) {
920
931
  const stream = target.screencast, emit = target.screencastEmit;
921
- const data = await screenshotData(ctx, target, stream);
922
- if (!data || stream !== target.screencast || emit !== target.screencastEmit) return;
923
- const scrollOffset = await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
924
- if (stream !== target.screencast || emit !== target.screencastEmit) return;
932
+ const loaderId = target.loaderId;
933
+ // This belongs to one invalidation, not a lasting cache. Pending updates
934
+ // replace it with the latest position; start/restart without one still query.
935
+ const notifiedOffset = target.screencastScrollOffset;
936
+ target.screencastScrollOffset = null;
937
+ // Private zero-area screencast: signal invalidation without rendering pixels.
938
+ // captureScreenshot keeps its ordinary clip validation.
939
+ const zeroArea = stream.clip?.width === 0 && stream.clip?.height === 0;
940
+ const data = zeroArea
941
+ ? "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6SAAAAABJRU5ErkJggg=="
942
+ : await screenshotData(ctx, target, stream);
943
+ if (!data || stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
944
+ const scrollOffset = zeroArea && notifiedOffset ? notifiedOffset
945
+ : await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
946
+ if (stream !== target.screencast || emit !== target.screencastEmit || loaderId !== target.loaderId) return;
925
947
  const sessionId = state.nextScreencastFrameId++;
926
948
  target.screencastFrames.add(sessionId);
927
949
  emit("Page.screencastFrame", {
@@ -950,6 +972,7 @@ function getPendingInitialNavigationTarget(state) {
950
972
 
951
973
  function navigateTarget(state, target, url, emit, options = {}) {
952
974
  const deferLifecycle = typeof state.context?.onLifecycle === "function";
975
+ target.screencastScrollOffset = null;
953
976
  target.lifecycleEmit = null;
954
977
  target.url = url;
955
978
  loadDocument(target, url);
@@ -1045,6 +1068,7 @@ function getImplicitTarget(state) {
1045
1068
  state.implicitTarget = {
1046
1069
  targetId: "implicit-target",
1047
1070
  screencast: null,
1071
+ screencastScrollOffset: null,
1048
1072
  screencastFrames: new Set(),
1049
1073
  frameId: "implicit-target",
1050
1074
  loaderId: "implicit-loader",
@@ -1,12 +1,40 @@
1
1
  import Node from "../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts";
2
2
  import Document from "../../vendor/happy-dom/packages/happy-dom/src/nodes/document/Document.ts";
3
+ import HTMLBaseElement from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-base-element/HTMLBaseElement.ts";
3
4
  import HTMLHyperlinkElementUtility from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementUtility.ts";
5
+ import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
6
+
7
+ // Happy DOM reads baseURI on every attribute set, so the parser looked up
8
+ // `base[href]` once per attribute: a whole-document scan each time when there
9
+ // is no base. The first base[href] of each document is cached until one of
10
+ // that document's <base> elements is connected, disconnected or has an
11
+ // attribute changed. An adopted <base> disconnects from its old document
12
+ // first, so both documents are invalidated.
13
+ const baseCache = new WeakMap();
14
+ {
15
+ const prototype = HTMLBaseElement.prototype;
16
+ const inherited = Object.getPrototypeOf(prototype);
17
+ for (const symbol of [PropertySymbol.connectedToDocument, PropertySymbol.disconnectedFromDocument,
18
+ PropertySymbol.onSetAttribute, PropertySymbol.onRemoveAttribute]) {
19
+ prototype[symbol] = function(...args) {
20
+ baseCache.delete(this[PropertySymbol.ownerDocument]);
21
+ return inherited[symbol].apply(this, args);
22
+ };
23
+ }
24
+ }
25
+
26
+ function firstBase(document) {
27
+ if (baseCache.has(document)) return baseCache.get(document);
28
+ const base = document.querySelector?.("base[href]") ?? null;
29
+ baseCache.set(document, base);
30
+ return base;
31
+ }
4
32
 
5
33
  /** HTML document base URL, shared by the inert DOM and its page bindings. */
6
34
  export function documentBaseURL(node) {
7
35
  const document = node?.nodeType === 9 ? node : node?.ownerDocument;
8
36
  const fallback = document?.URL ?? "about:blank";
9
- const base = document?.querySelector?.("base[href]");
37
+ const base = document ? firstBase(document) : null;
10
38
  if (!base) return fallback;
11
39
  try {
12
40
  const url = new URL(base.getAttribute("href"), fallback);
@@ -0,0 +1,157 @@
1
+ import * as PropertySymbol from '../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts';
2
+
3
+ // One notification microtask for this renderer agent, with one ordered record
4
+ // queue per observer. Target registrations must not schedule their own delivery.
5
+ const enqueueMicrotask = globalThis.queueMicrotask;
6
+ const pending = new Set();
7
+ let queued = false;
8
+ let creationIndex = 0;
9
+
10
+ function activate(observer) {
11
+ pending.add(observer);
12
+ if (queued) return;
13
+ queued = true;
14
+ enqueueMicrotask(() => {
15
+ queued = false;
16
+ const observers = [...pending].sort((left, right) => left.order - right.order);
17
+ pending.clear();
18
+ for (const observer of observers) observer.deliver();
19
+ });
20
+ }
21
+
22
+ function optionsOf(window, input = {}) {
23
+ const attributes = input.attributes === undefined
24
+ ? input.attributeOldValue !== undefined || input.attributeFilter !== undefined
25
+ : Boolean(input.attributes);
26
+ const characterData = input.characterData === undefined
27
+ ? input.characterDataOldValue !== undefined : Boolean(input.characterData);
28
+ const options = {
29
+ childList: Boolean(input.childList), attributes, characterData,
30
+ subtree: Boolean(input.subtree),
31
+ attributeOldValue: Boolean(input.attributeOldValue),
32
+ characterDataOldValue: Boolean(input.characterDataOldValue),
33
+ attributeFilter: input.attributeFilter === undefined ? null : Array.from(input.attributeFilter, String),
34
+ };
35
+ if ((!options.childList && !attributes && !characterData) ||
36
+ (!attributes && (options.attributeOldValue || options.attributeFilter !== null)) ||
37
+ (!characterData && options.characterDataOldValue)) {
38
+ throw new window.TypeError('Invalid MutationObserver options');
39
+ }
40
+ return options;
41
+ }
42
+
43
+ export function installMutationObservers(window) {
44
+ window.MutationObserver = class MutationObserver {
45
+ #callback;
46
+ #registrations = new Map();
47
+ #records = [];
48
+ #seen = new WeakMap();
49
+ #destroyed = false;
50
+ #delivery;
51
+
52
+ constructor(callback) {
53
+ if (typeof callback !== 'function') throw new window.TypeError('MutationObserver callback must be a function');
54
+ this.#callback = callback;
55
+ this.#delivery = { order: creationIndex++, deliver: () => this.#deliver() };
56
+ }
57
+
58
+ observe(target, input) {
59
+ if (this.#destroyed || window.closed) return;
60
+ if (!target || typeof target[PropertySymbol.observeMutations] !== 'function') {
61
+ throw new window.TypeError('MutationObserver.observe requires a Node');
62
+ }
63
+ const options = optionsOf(window, input);
64
+ const previous = this.#registrations.get(target);
65
+ if (previous) this.#remove(previous);
66
+ const registration = { target, options, transientRoots: new Set(), callback: null, listener: null };
67
+ registration.callback = (record) => this.#report(registration, record);
68
+ registration.listener = {
69
+ // Removals establish temporary registrations even if childList records
70
+ // were not requested. These survive until this observer is notified.
71
+ options: { ...options, childList: options.childList || options.subtree },
72
+ callback: new WeakRef(registration.callback),
73
+ };
74
+ this.#registrations.set(target, registration);
75
+ target[PropertySymbol.observeMutations](registration.listener);
76
+ if (!window[PropertySymbol.mutationObservers].includes(this)) {
77
+ window[PropertySymbol.mutationObservers].push(this);
78
+ }
79
+ }
80
+
81
+ #clearTransient(registration) {
82
+ for (const root of registration.transientRoots) {
83
+ root[PropertySymbol.unobserveMutations](registration.listener);
84
+ }
85
+ registration.transientRoots.clear();
86
+ }
87
+
88
+ #remove(registration) {
89
+ this.#clearTransient(registration);
90
+ registration.target[PropertySymbol.unobserveMutations](registration.listener);
91
+ }
92
+
93
+ #report(registration, record) {
94
+ const { options } = registration;
95
+ if (options.subtree && record.type === 'childList') {
96
+ for (const root of record.removedNodes) {
97
+ if (!registration.transientRoots.has(root)) {
98
+ registration.transientRoots.add(root);
99
+ root[PropertySymbol.observeMutations](registration.listener);
100
+ }
101
+ }
102
+ if (record.removedNodes.length) activate(this.#delivery);
103
+ }
104
+ if (record.type === 'childList' && !options.childList) return;
105
+ const wantsOld = record.type === 'attributes' ? options.attributeOldValue
106
+ : record.type === 'characterData' && options.characterDataOldValue;
107
+ const existing = this.#seen.get(record);
108
+ if (existing) {
109
+ if (wantsOld) existing.oldValue = record.oldValue;
110
+ return;
111
+ }
112
+ const copy = Object.assign(Object.create(Object.getPrototypeOf(record)), record, {
113
+ oldValue: wantsOld ? record.oldValue : null,
114
+ });
115
+ this.#seen.set(record, copy);
116
+ this.#records.push(copy);
117
+ activate(this.#delivery);
118
+ }
119
+
120
+ #deliver() {
121
+ const records = this.takeRecords();
122
+ for (const registration of this.#registrations.values()) this.#clearTransient(registration);
123
+ if (!records.length || this.#destroyed || window.closed) return;
124
+ try {
125
+ this.#callback.call(this, records, this);
126
+ } catch (error) {
127
+ window[PropertySymbol.dispatchError](error);
128
+ }
129
+ }
130
+
131
+ takeRecords() {
132
+ const records = this.#records;
133
+ this.#records = [];
134
+ this.#seen = new WeakMap();
135
+ return records;
136
+ }
137
+
138
+ disconnect() {
139
+ for (const registration of this.#registrations.values()) this.#remove(registration);
140
+ this.#registrations.clear();
141
+ this.takeRecords();
142
+ // Window destruction iterates this array. Do not splice it during that
143
+ // iteration, otherwise the next observer would survive teardown.
144
+ if (!window.closed) {
145
+ const observers = window[PropertySymbol.mutationObservers];
146
+ const index = observers.indexOf(this);
147
+ if (index >= 0) observers.splice(index, 1);
148
+ }
149
+ }
150
+
151
+ [PropertySymbol.destroy]() {
152
+ this.#destroyed = true;
153
+ this.disconnect();
154
+ pending.delete(this.#delivery);
155
+ }
156
+ };
157
+ }
@@ -1,4 +1,5 @@
1
1
  import { makeLinksInert } from './inert-link.js';
2
+ import { installMutationObservers } from './mutation-observer.js';
2
3
  import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
3
4
  import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
4
5
  import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
@@ -41,6 +42,7 @@ export function createInertWindow(url = "about:blank") {
41
42
  navigation: { disableFallbackToSetURL: true },
42
43
  },
43
44
  });
45
+ installMutationObservers(window);
44
46
  makeLinksInert(window);
45
47
  return window;
46
48
  }
@@ -90,7 +90,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
90
90
  * WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
91
91
  */
92
92
  export async function createBrowser({
93
- allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
93
+ allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 0, mobile = false,
94
94
  waitForRenderer = true, webkit = true,
95
95
  clipboard = null,
96
96
  } = {}) {
@@ -105,6 +105,7 @@ export async function createBrowser({
105
105
  let current = null;
106
106
  let currentKey = null;
107
107
  let spare = null;
108
+ let sparePreparation = null;
108
109
  let closed = false;
109
110
  // Settles when the first renderer shows about:blank (assigned below).
110
111
  let rendererReady = null;
@@ -195,8 +196,8 @@ export async function createBrowser({
195
196
  if (host !== current) return;
196
197
  void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
197
198
  },
198
- onFrame: () => {
199
- if (host === current) notify(frameListeners);
199
+ onFrame: (frame) => {
200
+ if (host === current) notify(frameListeners, frame);
200
201
  },
201
202
  onLifecycle: (name, generation) => {
202
203
  let generations = lifecycleSeen.get(host);
@@ -207,7 +208,11 @@ export async function createBrowser({
207
208
  generations.set(generation, names);
208
209
  if (names.has(name)) return;
209
210
  names.add(name);
210
- if (committed.host === host && committed.generation === generation) notify(lifecycleListeners, name);
211
+ if (committed.host === host && committed.generation === generation) {
212
+ if (name === "load" && sparePreparation?.host === host && sparePreparation.generation === generation)
213
+ armSparePreparation(sparePreparation, 1_000);
214
+ notify(lifecycleListeners, name);
215
+ }
211
216
  },
212
217
  });
213
218
  return host;
@@ -232,12 +237,45 @@ export async function createBrowser({
232
237
 
233
238
  function prepareSpare() {
234
239
  if (!spareRenderer || closed || spare) return;
235
- spare = createHost();
236
- spare.start().catch(() => {
237
- spare = null;
240
+ const preparing = createHost();
241
+ spare = preparing;
242
+ preparing.start().catch(() => {
243
+ // An acquired spare may fail after its successor has been scheduled.
244
+ // Only this host owns its slot; never clear a newer spare's reference.
245
+ if (spare === preparing) {
246
+ spare = null;
247
+ preparing.close();
248
+ }
238
249
  });
239
250
  }
240
251
 
252
+ function cancelSparePreparation() {
253
+ if (sparePreparation) clearTimeout(sparePreparation.timer);
254
+ sparePreparation = null;
255
+ }
256
+
257
+ function armSparePreparation(request, delay) {
258
+ clearTimeout(request.timer);
259
+ request.timer = setTimeout(() => {
260
+ if (sparePreparation !== request) return;
261
+ sparePreparation = null;
262
+ if (current === request.host && committed.host === request.host
263
+ && committed.generation === request.generation) prepareSpare();
264
+ }, delay);
265
+ request.timer.unref?.();
266
+ }
267
+
268
+ function scheduleSparePreparation(host, generation) {
269
+ cancelSparePreparation();
270
+ if (!spareRenderer || closed || spare || host !== current) return;
271
+ const request = { host, generation, timer: null };
272
+ sparePreparation = request;
273
+ // Delay after load, rather than competing with the new page's initial
274
+ // scripts/layout. This is not CPU-idle detection. A bounded fallback
275
+ // still warms a spare for pages whose load event never arrives.
276
+ armSparePreparation(request, reachedLifecycle(host, generation).has("load") ? 1_000 : 30_000);
277
+ }
278
+
241
279
  /** The renderer for a URL's origin, swapping processes on an origin change. */
242
280
  async function rendererFor(url) {
243
281
  let key = originKey(url);
@@ -264,7 +302,6 @@ export async function createBrowser({
264
302
  current = next;
265
303
  currentKey = key;
266
304
  previous?.close();
267
- prepareSpare();
268
305
  return current;
269
306
  }
270
307
 
@@ -345,12 +382,14 @@ export async function createBrowser({
345
382
  selectRenderer = url => rendererFor(url, { qmlHost });
346
383
  // qml handle end
347
384
 
385
+ cancelSparePreparation();
348
386
  const host = invoke ? current : await selectRenderer(document.url);
349
387
  let generation;
350
388
  try {
351
389
  generation = await (invoke ?? ((method, ...params) => host.call(method, ...params)))("loadDocument", document);
352
390
  } finally { documentReady.get(host)?.resolve(); }
353
391
  committed = { host, generation };
392
+ scheduleSparePreparation(host, generation);
354
393
  for (const name of reachedLifecycle(host, generation)) notify(lifecycleListeners, name);
355
394
  writeLog("navigation", `${document.url}: first paint in ${Math.round(performance.now() - fetched)}ms`);
356
395
  }
@@ -502,7 +541,7 @@ export async function createBrowser({
502
541
  press: (key, options) => call("press", key, options),
503
542
  type: (text) => call("type", text),
504
543
  scrollOffset: () => composite("scrollOffset"),
505
- /** Calls `listener()` whenever what the viewport shows changes; returns an unsubscribe function. */
544
+ /** Calls `listener(frame?)` when the viewport changes, with its measured scroll offset when available. */
506
545
  onFrame(listener) {
507
546
  frameListeners.add(listener);
508
547
  return () => frameListeners.delete(listener);
@@ -573,6 +612,7 @@ export async function createBrowser({
573
612
  },
574
613
  close() {
575
614
  closed = true;
615
+ cancelSparePreparation();
576
616
  current?.close();
577
617
  spare?.close();
578
618
  current = null;
@@ -0,0 +1,89 @@
1
+ import { sameLayoutInput } from "../render-tree/index.js";
2
+
3
+ // These properties change raster output, not box/text metrics. Inline
4
+ // decoration boxes still need the guard below. Keep this list small.
5
+ const PAINT_PROPERTIES = new Set([
6
+ "opacity", "backgroundColor",
7
+ "backgroundSize", "backgroundPosition", "backgroundRepeat", "backgroundOrigin", "backgroundClip",
8
+ "transformOrigin",
9
+ ]);
10
+
11
+ /** Retain immutable geometry when paint changes preserve layout records. */
12
+ export function repaintLayout(layout, previous, next) {
13
+ if (!sameLayoutInput(previous.root, next.root, { ignoreStyles: true })) return null;
14
+ const changes = new Map();
15
+ const styles = new Map();
16
+ for (const [id, node] of next.nodesById) {
17
+ const before = previous.nodesById.get(id)?.style, after = node.style;
18
+ // Anonymous fragments have no node ID and must be restyled by their old
19
+ // style identity. If that identity split into different new styles, only
20
+ // layout can determine which fragment belongs to which new owner.
21
+ if (styles.has(before) && !Bun.deepEquals(styles.get(before), after)) return null;
22
+ if (before === after) {
23
+ styles.set(before, before);
24
+ continue;
25
+ }
26
+ if (!before || !after) return null;
27
+ const paint = {};
28
+ for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
29
+ if (Bun.deepEquals(before[key], after[key])) continue;
30
+ if (key === "transform") {
31
+ // Translation is currently baked into layout coordinates. Only scale
32
+ // changes within an existing transform can reuse those coordinates.
33
+ const a = before[key], b = after[key];
34
+ if (!a || !b || !Bun.deepEquals(a.x, b.x) || !Bun.deepEquals(a.y, b.y)
35
+ || Object.keys(a).some(k => !["x", "y", "scaleX", "scaleY"].includes(k))
36
+ || Object.keys(b).some(k => !["x", "y", "scaleX", "scaleY"].includes(k))) return null;
37
+ } else if (!PAINT_PROPERTIES.has(key)) return null;
38
+ paint[key] = after[key];
39
+ }
40
+ if (Object.keys(paint).length) {
41
+ // Layout only emits inline/replaced decoration boxes when a background
42
+ // or border is present. Adding/removing one requires those boxes to be
43
+ // regenerated, even though the text/image metrics have not changed.
44
+ if ((node.type === "inline" || node.type === "image")
45
+ && hasDecoration(before) !== hasDecoration(after)) return null;
46
+ changes.set(id, paint);
47
+ styles.set(before, after);
48
+ } else styles.set(before, before);
49
+ }
50
+ const restyle = record => {
51
+ const paint = changes.get(record.nodeId);
52
+ let style = styles.get(record.style);
53
+ // Some layout records contain resolved/adjusted styles (e.g. tables).
54
+ // Preserve all of those metrics instead of replacing the complete style.
55
+ if (!style && paint && record.style) {
56
+ style = Object.freeze({ ...record.style, ...paint });
57
+ styles.set(record.style, style);
58
+ }
59
+ // Boxes, runs and anonymous line fragments can share one computed style.
60
+ // Keep that identity: background-clip:text finds its gradient by style.
61
+ return style && style !== record.style ? Object.freeze({ ...record, style }) : record;
62
+ };
63
+ return Object.freeze({ ...layout, generation: next.generation,
64
+ boxes: mapChanged(layout.boxes, restyle),
65
+ fragments: mapChanged(layout.fragments, fragment => {
66
+ const updated = restyle(fragment);
67
+ const runs = fragment.runs && mapChanged(fragment.runs, restyle);
68
+ return runs === fragment.runs ? updated : Object.freeze({ ...updated, runs });
69
+ }),
70
+ });
71
+ }
72
+
73
+ // Keep in sync with the inline decoration predicate in text-layout.js.
74
+ function hasDecoration(style) {
75
+ return Boolean((style.backgroundColor && style.backgroundColor !== "rgba(0, 0, 0, 0)")
76
+ || style.backgroundImage || style.borderWidths?.some(width => width > 0));
77
+ }
78
+
79
+ /** Copy immutable arrays only when a record actually changed. */
80
+ function mapChanged(records, update) {
81
+ let changed = null;
82
+ for (let index = 0; index < records.length; index++) {
83
+ const record = records[index], next = update(record);
84
+ if (next === record) continue;
85
+ changed ??= records.slice();
86
+ changed[index] = next;
87
+ }
88
+ return changed ? Object.freeze(changed) : records;
89
+ }