buninu-browser 0.5.1 → 0.7.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 (136) hide show
  1. package/README.md +13 -6
  2. package/index.js +10 -6
  3. package/lib/audio/pcm.js +11 -0
  4. package/lib/audio/pulse-client.js +7 -2
  5. package/lib/cdp-server.js +94 -46
  6. package/lib/happy-dom/document-base.js +29 -1
  7. package/lib/happy-dom/mutation-observer.js +157 -0
  8. package/lib/happy-dom/parser.js +2 -0
  9. package/lib/headless-shell.js +49 -9
  10. package/lib/layout/repaint.js +89 -0
  11. package/lib/media/mp4-keyframes.js +144 -0
  12. package/lib/media/video-keyframe.js +29 -1
  13. package/lib/media/webm-snapshots.js +282 -0
  14. package/lib/network/cookie-jar.js +18 -2
  15. package/lib/paint/display-list.js +4 -3
  16. package/lib/paint/frame.js +12 -5
  17. package/lib/render-tree/index.js +3 -0
  18. package/lib/renderer/animation-frames.js +76 -0
  19. package/lib/renderer/bindings.js +2 -0
  20. package/lib/renderer/compositor-channel.js +95 -10
  21. package/lib/renderer/compositor.js +54 -18
  22. package/lib/renderer/document-cookies.js +87 -0
  23. package/lib/renderer/font-loading.js +60 -16
  24. package/lib/renderer/host.js +48 -19
  25. package/lib/renderer/idle-callbacks.js +109 -0
  26. package/lib/renderer/main.js +5 -1
  27. package/lib/renderer/media-source.js +4 -0
  28. package/lib/renderer/page-realm.js +159 -49
  29. package/lib/renderer/page-renderer.js +567 -197
  30. package/lib/renderer/resource-elements.js +88 -0
  31. package/lib/renderer/script-rewrite.js +18 -1
  32. package/lib/renderer/seccomp.js +6 -4
  33. package/lib/renderer/timers.js +71 -0
  34. package/lib/renderer/video-channel.js +94 -0
  35. package/lib/renderer/video-mask.js +115 -0
  36. package/lib/renderer/video-presentation.js +304 -39
  37. package/lib/renderer/video-worker.js +46 -0
  38. package/lib/renderer/workers.js +7 -17
  39. package/lib/style/computed-style.js +257 -61
  40. package/package.json +1 -1
  41. package/test/animation-frames.test.js +67 -0
  42. package/test/canvas-style-snapshot.test.js +18 -0
  43. package/test/cdp-stream.test.js +96 -3
  44. package/test/cdp-viewers.test.js +43 -0
  45. package/test/compositor-channel-ownership.test.js +194 -0
  46. package/test/compositor.test.js +27 -1
  47. package/test/computed-style-binding.test.js +49 -0
  48. package/test/computed-style-connectivity.test.js +13 -0
  49. package/test/computed-style.test.js +86 -0
  50. package/test/cookie-sync.test.js +82 -0
  51. package/test/document-base-cache.test.js +103 -0
  52. package/test/document-cookies.test.js +117 -0
  53. package/test/duplicate-style-keys.test.js +11 -0
  54. package/test/fixtures/animation-frames.js +56 -0
  55. package/test/fixtures/canvas-style-snapshot.js +81 -0
  56. package/test/fixtures/computed-style-connectivity.js +101 -0
  57. package/test/fixtures/computed-style-lazy.js +135 -0
  58. package/test/fixtures/duplicate-style-keys.js +97 -0
  59. package/test/fixtures/fake-renderer.js +12 -0
  60. package/test/fixtures/font-definition-invalidation.js +97 -0
  61. package/test/fixtures/font-metrics-invalidation.js +87 -0
  62. package/test/fixtures/full-style-identity.js +36 -0
  63. package/test/fixtures/hidden-style-engine.js +260 -0
  64. package/test/fixtures/hidden-style-reentry.js +232 -0
  65. package/test/fixtures/hidden-style-renderer.js +203 -0
  66. package/test/fixtures/layout-repaint.js +69 -0
  67. package/test/fixtures/media-presentation-continuation.js +141 -0
  68. package/test/fixtures/media-presentation-refill.js +113 -0
  69. package/test/fixtures/media-probe.js +148 -0
  70. package/test/fixtures/media-refill-cancel.js +79 -0
  71. package/test/fixtures/media-repaint.js +48 -0
  72. package/test/fixtures/media-resource-reselection.js +94 -0
  73. package/test/fixtures/media-source-attachment.js +136 -0
  74. package/test/fixtures/media-source-no-decoder.js +17 -0
  75. package/test/fixtures/media-track-refill.js +132 -0
  76. package/test/fixtures/media-webm-seek.js +51 -0
  77. package/test/fixtures/mutation-observer.js +201 -0
  78. package/test/fixtures/observer-invalidation.js +105 -0
  79. package/test/fixtures/prepaint-scheduling.js +73 -0
  80. package/test/fixtures/resource-discovery.js +32 -0
  81. package/test/fixtures/spare-renderer-scheduling.js +124 -0
  82. package/test/fixtures/style-propagation.js +26 -0
  83. package/test/fixtures/svg-node-reuse.js +83 -0
  84. package/test/fixtures/video-black-replay.js +140 -0
  85. package/test/fixtures/video-controls-invalidation.js +71 -0
  86. package/test/fixtures/viewport-bounds.js +148 -0
  87. package/test/font-loading.test.js +77 -0
  88. package/test/font-metrics-invalidation.test.js +60 -0
  89. package/test/full-style-identity.test.js +191 -0
  90. package/test/hidden-style-lazy.test.js +31 -0
  91. package/test/hidden-style-reentry.test.js +20 -0
  92. package/test/idle-callback.test.js +89 -0
  93. package/test/idle-scheduler.test.js +148 -0
  94. package/test/index.test.js +1 -0
  95. package/test/inline-style-cascade.test.js +63 -0
  96. package/test/layout-repaint-renderer.test.js +9 -0
  97. package/test/layout-repaint.test.js +222 -0
  98. package/test/media-presentation-continuation.test.js +14 -0
  99. package/test/media-presentation-refill.test.js +8 -0
  100. package/test/media-probe.test.js +16 -0
  101. package/test/media-repaint.test.js +8 -0
  102. package/test/media-source-attachment.test.js +34 -0
  103. package/test/media-track-refill.test.js +26 -0
  104. package/test/media-webm-seek.test.js +11 -0
  105. package/test/mp4-keyframes.test.js +36 -0
  106. package/test/mutation-observer.test.js +91 -0
  107. package/test/observer-invalidation.test.js +12 -0
  108. package/test/page-scripts.test.js +35 -0
  109. package/test/page-timers.test.js +67 -0
  110. package/test/paint-frame.test.js +18 -1
  111. package/test/pcm.test.js +63 -0
  112. package/test/prepaint-scheduling.test.js +13 -0
  113. package/test/pulse-client.test.js +165 -0
  114. package/test/raw-capture.test.js +34 -5
  115. package/test/renderer-host.test.js +51 -0
  116. package/test/resource-discovery.test.js +12 -0
  117. package/test/resource-elements.test.js +234 -0
  118. package/test/root-self-style.test.js +104 -0
  119. package/test/script-rewrite.test.js +35 -0
  120. package/test/seccomp.test.js +45 -0
  121. package/test/spare-renderer-scheduling.test.js +8 -0
  122. package/test/style-propagation.test.js +225 -0
  123. package/test/style-sheets.test.js +19 -0
  124. package/test/svg.test.js +45 -0
  125. package/test/timers.test.js +58 -0
  126. package/test/video-black-replay.test.js +34 -0
  127. package/test/video-capture.test.js +170 -0
  128. package/test/video-channel.test.js +253 -0
  129. package/test/video-continuation.test.js +57 -0
  130. package/test/video-controls-invalidation.test.js +12 -0
  131. package/test/video-keyframe.test.js +104 -4
  132. package/test/video-mask.test.js +94 -0
  133. package/test/video-presentation.test.js +239 -1
  134. package/test/video-timing-presentation.test.js +115 -0
  135. package/test/viewport-bounds.test.js +13 -0
  136. package/test/webm-snapshots.test.js +372 -0
package/README.md CHANGED
@@ -96,9 +96,10 @@ the rest is passed on:
96
96
  argument goes to casty)
97
97
  - `--headless`: the engine alone, as
98
98
  a CDP endpoint
99
- - `--win32`, `--gtk`, `--appkit`,
100
- `--qt`: native windowed frontends
101
- (reserved, not implemented yet)
99
+ - `--win32`: casty with a native Windows
100
+ window (Windows only)
101
+ - `--gtk`, `--appkit`, `--qt`: native
102
+ windowed frontends (reserved, not implemented yet)
102
103
  - `--help`/`-h`, `--version`/`-V`,
103
104
  `--readme`: this launcher's help,
104
105
  version and backend details, and
@@ -291,9 +292,9 @@ borrows a contiguous raster view instead of rescaling; encoded output uses
291
292
  the same crop. `Page.getLayoutMetrics` provides the viewport position so
292
293
  Casty can follow document scrolling.
293
294
 
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.
295
+ 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
296
 
296
- Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。
297
+ Casty 也會在 `Page.startScreencast` 傳入私有 `clip`;優先於相容用的 max 限制,以原像素密度裁切、不縮放。寬高是 CSS 單位,實體像素仍隨 device scale 換算。此擴充不是標準 CDP。寬高都為零時只發送更新通知:server 回傳固定 1×1 PNG 佔位資料,不渲染或編碼預覽;一般 captureScreenshot 仍拒絕零尺寸。
297
298
 
298
299
  標準 CDP `clip` 也適用於 raw 截圖;小數 CSS 尺寸保留到最後才換算成實體像素,
299
300
  避免字體大小改變後差一列而觸發 bunterm 縮放。`clip.scale: 1` 的全寬裁切
@@ -469,7 +470,13 @@ it to `buninu-browser.log` in the working directory, or set
469
470
 
470
471
  - page errors and page `console` output;
471
472
  - per-phase load timings;
472
- - renderer crashes and watchdog kills.
473
+ - renderer crashes and explicitly enabled watchdog kills.
474
+
475
+ Renderer and video-worker startup have no deadline. Document navigation also
476
+ waits until completion, failure, or explicit close. The browser does not enable
477
+ the renderer method watchdog by default: slow initialization or page work must
478
+ not kill the renderer. Internal callers can opt into a method watchdog through
479
+ `rendererTimeout`; this does not impose a startup or navigation deadline.
473
480
 
474
481
  Renderers cannot open files under seccomp, so the controller collects their
475
482
  output and writes the log.
package/index.js CHANGED
@@ -9,8 +9,9 @@
9
9
  * --casty the terminal frontend (default): runs casty with
10
10
  * CASTY_BROWSER set to this package's buninu-browser.js
11
11
  * --headless the engine alone (buninu-browser.js, a CDP endpoint)
12
- * --win32, --gtk, --appkit, --qt
13
- * reserved for the native windowed frontends (not implemented)
12
+ * --win32 casty's native Windows frontend
13
+ * --gtk, --appkit, --qt
14
+ * reserved for native windowed frontends (not implemented)
14
15
  *
15
16
  * --help, -h / --version, -V / --readme, --docs
16
17
  * this launcher's help, version and README
@@ -33,7 +34,7 @@ export * from "./buninu-browser.js";
33
34
 
34
35
  const CASTY_PACKAGE = "@drxiaozhi/casty";
35
36
  const ENGINE = join(import.meta.dir, "buninu-browser.js");
36
- const RESERVED_FRONTENDS = new Set(["--win32", "--gtk", "--appkit", "--qt"]);
37
+ const RESERVED_FRONTENDS = new Set(["--gtk", "--appkit", "--qt"]);
37
38
  const INFORMATION = new Map([
38
39
  ["--help", "help"], ["-h", "help"], ["-help", "help"],
39
40
  ["--version", "version"], ["-V", "version"], ["-version", "version"],
@@ -52,6 +53,7 @@ export function chooseFrontend(argv) {
52
53
  return { frontend: first.slice(2), args: rest };
53
54
 
54
55
  if (first === "--casty") return { frontend: "casty", args: rest };
56
+ if (first === "--win32") return { frontend: "win32", args: rest };
55
57
  if (first === "--headless") return { frontend: "headless", args: rest };
56
58
  if (RESERVED_FRONTENDS.has(first)) return { frontend: first.slice(2), args: rest, reserved: true };
57
59
  if (INFORMATION.has(first)) return { frontend: INFORMATION.get(first), args: rest, information: true };
@@ -96,7 +98,9 @@ ${typeof waylandHelp === "undefined" ? "" : waylandHelp} --casty
96
98
  --quickshell-wayland, --quickshell-headless, --quickshell-casty
97
99
  Experimental Quickshell CLI (-p FILE_OR_DIRECTORY / -c CONFIG)
98
100
  Use -- before backend arguments; each entry supports --help
99
- --win32, --gtk, --appkit, --qt
101
+ --win32
102
+ Native Windows window driven by casty (Windows only)
103
+ --gtk, --appkit, --qt
100
104
  Native windowed frontends (reserved, not implemented yet)
101
105
 
102
106
  Browser identity:
@@ -129,7 +133,7 @@ function printVersion() {
129
133
  console.log("Platform:", `${process.platform}-${process.arch}`);
130
134
  console.log("Engine:", ENGINE);
131
135
  console.log("casty launcher:", castyCommand([]).join(" "));
132
- console.log("Frontends: casty (default), headless; reserved: win32, gtk, appkit, qt");
136
+ console.log("Frontends: casty (default), headless, win32; reserved: gtk, appkit, qt");
133
137
  }
134
138
 
135
139
  async function printReadme() {
@@ -176,6 +180,6 @@ if (import.meta.main) {
176
180
 
177
181
  const code = frontend === "headless"
178
182
  ? await run([process.execPath, ENGINE, ...args])
179
- : await run(castyCommand(args), { ...process.env, CASTY_BROWSER: ENGINE });
183
+ : await run(castyCommand(frontend === "win32" ? ["--win32", ...args] : args), { ...process.env, CASTY_BROWSER: ENGINE });
180
184
  process.exit(code ?? 1);
181
185
  }
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,15 @@ 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?.(() => {
44
- for (const target of state.targets.values()) {
45
- if (target.screencast && target.screencastEmit) pushScreencastFrame(context, state, target);
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;
47
+ for (const target of [...state.targets.values(), state.implicitTarget]) {
48
+ if (!target) continue;
49
+ for (const stream of screencasts(target)) stream.scrollOffset = scrollOffset;
50
+ void pushScreencastFrame(context, state, target);
46
51
  }
47
- if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
48
52
  });
49
53
  context.onLifecycle?.((name) => {
50
54
  for (const target of state.targets.values()) emitLifecycle(target, name);
@@ -164,6 +168,8 @@ export class CdpServer {
164
168
  const msg = JSON.parse(text);
165
169
  id = msg.id;
166
170
  sessionId = msg.sessionId;
171
+ if (sessionId && state.sessionOwners.get(sessionId) !== ws)
172
+ throw Error("No session owned by this connection");
167
173
  let dispatchSessionId = sessionId;
168
174
  if (!dispatchSessionId && ws.data?.targetId) {
169
175
  dispatchSessionId = `page-${ws.data.targetId}`;
@@ -210,12 +216,17 @@ export class CdpServer {
210
216
  },
211
217
  close(ws) {
212
218
  for (const target of state.targets.values()) {
213
- if (target.screencastEmit?.owner === ws) stopScreencast(target);
219
+ stopScreencast(target, ws);
214
220
  if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
215
221
  }
216
222
  const implicit = state.implicitTarget;
217
- if (implicit?.screencastEmit?.owner === ws) stopScreencast(implicit);
223
+ if (implicit) stopScreencast(implicit, ws);
218
224
  if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
225
+ for (const [id, owner] of state.sessionOwners) {
226
+ if (owner !== ws) continue;
227
+ state.sessionOwners.delete(id);
228
+ state.sessions.delete(id);
229
+ }
219
230
  serverLog("[CdpServer] client disconnected");
220
231
  },
221
232
  },
@@ -279,6 +290,7 @@ function createCdpState() {
279
290
  nextScreencastFrameId: 1,
280
291
  targets: new Map(),
281
292
  sessions: new Map(),
293
+ sessionOwners: new Map(),
282
294
  objects: new Map(),
283
295
  initialNavigations: new Map(),
284
296
  implicitTarget: null,
@@ -320,6 +332,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
320
332
  const target = state.targets.get(targetId);
321
333
  target.attached = true;
322
334
  state.sessions.set(attachedSessionId, targetId);
335
+ state.sessionOwners.set(attachedSessionId, emit.owner);
323
336
  emit("Target.attachedToTarget", {
324
337
  sessionId: attachedSessionId,
325
338
  targetInfo: createTargetInfo(target),
@@ -336,6 +349,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
336
349
  const sessionId = `session-${state.nextSessionId++}`;
337
350
  target.attached = true;
338
351
  state.sessions.set(sessionId, params.targetId);
352
+ state.sessionOwners.set(sessionId, emit.owner);
339
353
  return { sessionId };
340
354
  }
341
355
  case "Target.getTargets":
@@ -369,6 +383,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
369
383
  for (const [attachedSessionId, targetId] of state.sessions) {
370
384
  if (targetId !== params.targetId) continue;
371
385
  state.sessions.delete(attachedSessionId);
386
+ state.sessionOwners.delete(attachedSessionId);
372
387
  emit("Target.detachedFromTarget", {
373
388
  sessionId: attachedSessionId,
374
389
  targetId,
@@ -378,9 +393,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
378
393
  emit("Target.targetDestroyed", { targetId: params.targetId });
379
394
  return { success: true };
380
395
  }
381
- case "Target.detachFromTarget":
396
+ case "Target.detachFromTarget": {
397
+ const target = state.targets.get(state.sessions.get(params.sessionId));
398
+ if (state.sessionOwners.get(params.sessionId) !== emit.owner) throw Error("No session owned by this connection");
399
+ if (target) stopScreencast(target, emit.owner, params.sessionId);
382
400
  state.sessions.delete(params.sessionId);
401
+ state.sessionOwners.delete(params.sessionId);
383
402
  return {};
403
+ }
384
404
  case "Page.enable":
385
405
  return {};
386
406
  case "Page.getResourceContent": {
@@ -461,7 +481,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
461
481
  // A javascript: URL runs in the current document: no new loader or history entry.
462
482
  if (/^\s*javascript:/i.test(String(params.url))) {
463
483
  await ctx.navigate?.(String(params.url).trim());
464
- if (target.screencast) await pushScreencastFrame(ctx, state, target);
484
+ if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
465
485
  return { frameId: target.frameId, loaderId: target.loaderId };
466
486
  }
467
487
  // Events of the next document belong to its new loader.
@@ -470,7 +490,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
470
490
  navigateTarget(state, target, params.url, emit, { addHistory: true });
471
491
  target.title = await getContextTitle(ctx, target);
472
492
  target.history[target.historyIndex].title = target.title;
473
- if (target.screencast) {
493
+ if (target.screencasts.size) {
474
494
  await pushScreencastFrame(ctx, state, target);
475
495
  }
476
496
  state.initialNavigations.get(target.targetId)?.navigated.resolve();
@@ -523,22 +543,26 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
523
543
  }
524
544
  case "Page.startScreencast": {
525
545
  const target = getSessionTarget(state, sessionId);
526
- stopScreencast(target);
527
- target.screencast = { ...params };
528
- target.screencastEmit = emit;
529
- // As in Chrome, the reply does not wait for the first frame (nor the
530
- // renderer, which may still be starting); frames follow as events.
531
- void pushScreencastFrame(ctx, state, target);
546
+ stopScreencast(target, emit.owner, sessionId ?? null);
547
+ let sessions = target.screencasts.get(emit.owner);
548
+ if (!sessions) target.screencasts.set(emit.owner, sessions = new Map());
549
+ const stream = { params: { ...params }, emit, active: true, busy: false,
550
+ pending: false, scrollOffset: null, frames: new Set() };
551
+ sessions.set(sessionId ?? null, stream);
552
+ // Do not wait for a capture; each viewer owns its own backpressure.
553
+ void pushScreencastFrame(ctx, state, target, stream);
532
554
  return {};
533
555
  }
534
556
  case "Page.screencastFrameAck": {
535
557
  const target = getSessionTarget(state, sessionId);
536
- if (target.screencastFrames.delete(params.sessionId) && target.screencastPending) void pushScreencastFrame(ctx, state, target);
558
+ const stream = target.screencasts.get(emit.owner)?.get(sessionId ?? null);
559
+ if (stream?.frames.delete(params.sessionId) && stream.pending)
560
+ void pushScreencastFrame(ctx, state, target, stream);
537
561
  return {};
538
562
  }
539
563
  case "Page.stopScreencast": {
540
564
  const target = getSessionTarget(state, sessionId);
541
- stopScreencast(target);
565
+ stopScreencast(target, emit.owner, sessionId ?? null);
542
566
  return {};
543
567
  }
544
568
  case "Page.reload": {
@@ -718,7 +742,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
718
742
  navigateTarget(state,target,navigation.url,emit,{addHistory:true});
719
743
  target.title=navigation.title ?? await getContextTitle(ctx,target);
720
744
  target.history[target.historyIndex].title=target.title;
721
- if(target.screencast)await pushScreencastFrame(ctx,state,target);
745
+ if(target.screencasts.size)await pushScreencastFrame(ctx,state,target);
722
746
  }
723
747
  return {};
724
748
  }
@@ -734,7 +758,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
734
758
  navigateTarget(state, target, navigation.url, emit, { addHistory: true });
735
759
  target.title = navigation.title ?? await getContextTitle(ctx, target);
736
760
  target.history[target.historyIndex].title = target.title;
737
- if (target.screencast) await pushScreencastFrame(ctx, state, target);
761
+ if (target.screencasts.size) await pushScreencastFrame(ctx, state, target);
738
762
  }
739
763
  }
740
764
  return {};
@@ -843,8 +867,7 @@ function createTarget(state, url = "about:blank", options = {}) {
843
867
  height: options.height ?? 600,
844
868
  deviceScaleFactor: 1,
845
869
  },
846
- screencast: null,
847
- screencastFrames: new Set(),
870
+ screencasts: new Map(),
848
871
  };
849
872
  state.targets.set(targetId, target);
850
873
  state.initialNavigations.set(targetId, {
@@ -888,42 +911,67 @@ function emitLifecycle(target, name) {
888
911
  // Bound the amount of image data queued to a slow CDP client. Two outstanding
889
912
  // frames keep the pipe moving while acknowledging one; later updates coalesce.
890
913
  const MAX_SCREENCAST_FRAMES = 2;
891
- function stopScreencast(target) {
892
- target.screencast = null;
893
- target.screencastEmit = null;
894
- target.screencastPending = false;
895
- target.screencastFrames.clear();
914
+ function* screencasts(target) {
915
+ for (const sessions of target.screencasts.values()) yield* sessions.values();
916
+ }
917
+
918
+ // No owner means target destruction. An owner without a session means disconnect.
919
+ function stopScreencast(target, owner, sessionId) {
920
+ const owners = owner === undefined ? target.screencasts : new Map([[owner, target.screencasts.get(owner)]]);
921
+ for (const [key, sessions] of owners) {
922
+ if (!sessions) continue;
923
+ for (const [id, stream] of sessions) {
924
+ if (sessionId !== undefined && id !== sessionId) continue;
925
+ stream.active = false;
926
+ stream.pending = false;
927
+ stream.scrollOffset = null;
928
+ stream.frames.clear();
929
+ sessions.delete(id);
930
+ }
931
+ if (!sessions.size) target.screencasts.delete(key);
932
+ }
896
933
  }
897
934
 
898
- /** One capture at a time, with bounded unacknowledged frames. */
899
- async function pushScreencastFrame(ctx, state, target) {
900
- if (!target.screencast || !target.screencastEmit) return;
901
- if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
902
- target.screencastPending = true;
935
+ /** One capture at a time per viewer, with bounded unacknowledged frames. */
936
+ async function pushScreencastFrame(ctx, state, target, stream) {
937
+ if (!stream) {
938
+ await Promise.all([...screencasts(target)].map(s => pushScreencastFrame(ctx, state, target, s)));
939
+ return;
940
+ }
941
+ if (!stream.active) return;
942
+ if (stream.busy || stream.frames.size >= MAX_SCREENCAST_FRAMES) {
943
+ stream.pending = true;
903
944
  return;
904
945
  }
905
- target.screencastBusy = true;
946
+ stream.busy = true;
906
947
  try {
907
948
  do {
908
- target.screencastPending = false;
909
- await emitScreencastFrame(ctx, state, target);
910
- } while (target.screencastPending && target.screencast && target.screencastEmit
911
- && target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
949
+ stream.pending = false;
950
+ await emitScreencastFrame(ctx, state, target, stream);
951
+ } while (stream.pending && stream.active && stream.frames.size < MAX_SCREENCAST_FRAMES);
912
952
  } catch (error) {
913
953
  serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
914
954
  } finally {
915
- target.screencastBusy = false;
955
+ stream.busy = false;
916
956
  }
917
957
  }
918
958
 
919
- async function emitScreencastFrame(ctx, state, target) {
920
- 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;
959
+ async function emitScreencastFrame(ctx, state, target, stream) {
960
+ const options = stream.params, emit = stream.emit;
961
+ const loaderId = target.loaderId;
962
+ // Pending updates replace this snapshot with the latest position.
963
+ const notifiedOffset = stream.scrollOffset;
964
+ stream.scrollOffset = null;
965
+ const zeroArea = options.clip?.width === 0 && options.clip?.height === 0;
966
+ const data = zeroArea
967
+ ? "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6SAAAAABJRU5ErkJggg=="
968
+ : await screenshotData(ctx, target, options);
969
+ if (!data || !stream.active || loaderId !== target.loaderId) return;
970
+ const scrollOffset = zeroArea && notifiedOffset ? notifiedOffset
971
+ : await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
972
+ if (!stream.active || loaderId !== target.loaderId) return;
925
973
  const sessionId = state.nextScreencastFrameId++;
926
- target.screencastFrames.add(sessionId);
974
+ stream.frames.add(sessionId);
927
975
  emit("Page.screencastFrame", {
928
976
  data,
929
977
  metadata: {
@@ -950,6 +998,7 @@ function getPendingInitialNavigationTarget(state) {
950
998
 
951
999
  function navigateTarget(state, target, url, emit, options = {}) {
952
1000
  const deferLifecycle = typeof state.context?.onLifecycle === "function";
1001
+ for (const stream of screencasts(target)) stream.scrollOffset = null;
953
1002
  target.lifecycleEmit = null;
954
1003
  target.url = url;
955
1004
  loadDocument(target, url);
@@ -1044,8 +1093,7 @@ function getImplicitTarget(state) {
1044
1093
 
1045
1094
  state.implicitTarget = {
1046
1095
  targetId: "implicit-target",
1047
- screencast: null,
1048
- screencastFrames: new Set(),
1096
+ screencasts: new Map(),
1049
1097
  frameId: "implicit-target",
1050
1098
  loaderId: "implicit-loader",
1051
1099
  type: "page",
@@ -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
  }