buninu-browser 0.2.1 → 0.2.3

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 (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
package/bunfig.toml CHANGED
@@ -1,2 +1,3 @@
1
1
  [test]
2
2
  pathIgnorePatterns = ["vendor/**"]
3
+ preload = ["./test/setup.js"]
package/buninu-browser.js CHANGED
@@ -45,6 +45,10 @@ Options:
45
45
  --mobile Present as a phone: mobile user agent and touch media
46
46
  features (hover: none, pointer: coarse); user agent
47
47
  overrides over CDP are ignored
48
+ --webkit Use a desktop WebKit/Safari compatibility identity (default)
49
+ for JavaScriptCore; CDP user agent overrides are ignored
50
+ --chromium Use the previous desktop Chromium compatibility identity
51
+ Last desktop identity flag wins; --mobile takes priority
48
52
  --screenshot[=FILE] Capture one PNG and exit (default screenshot.png)
49
53
  --window-size=WIDTH,HEIGHT Viewport size for the screenshot (default 800,600)
50
54
  --screenshot-wait=MILLISECONDS Wait after page load before capturing
package/index.js CHANGED
@@ -84,6 +84,11 @@ Frontends (first argument only):
84
84
  --win32, --gtk, --appkit, --qt
85
85
  Native windowed frontends (reserved, not implemented yet)
86
86
 
87
+ Browser identity:
88
+ WebKit/Safari compatibility identity is the engine default (not native WebKit).
89
+ Pass -- --webkit or -- --chromium through casty; with --headless, omit --.
90
+ Last desktop identity flag wins; --mobile takes priority.
91
+
87
92
  Information:
88
93
  --help, -h, -help
89
94
  Show this help & exit
@@ -46,7 +46,30 @@ export function pianoNote(key, code = "") {
46
46
  export function pianoTone(note, rate = 48000) {
47
47
  const frames = Math.round(rate * 0.7);
48
48
  const samples = new Float32Array(frames);
49
- const frequency = 440 * 2 ** ((note - 69) / 12);
49
+
50
+
51
+ let frequency = 432
52
+
53
+ if(0)
54
+ {
55
+ frequency = 432 * 2 ** ((note - 69) / 12);
56
+ }
57
+ else
58
+ {
59
+ const note_diff = note - 69
60
+
61
+ const ratios = [
62
+ 1, 16/15, 9/8, 6/5, 5/4, 4/3,
63
+ 45/32, 3/2, 8/5, 5/3, 9/5, 15/8
64
+ ]
65
+ const ratio = ratios[
66
+ (note_diff + 432) % 12
67
+ ]
68
+ const octave = Math.floor(note_diff/12)
69
+
70
+ frequency = 432 * ratio * 2**octave ;
71
+ }
72
+
50
73
  // The original left-hand register is quiet on small speakers. Raise only
51
74
  // its level; leave pitch and relative harmonic balance untouched.
52
75
  const gain = 1 + Math.max(0, Math.min(1, (60 - note) / 24));
@@ -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
 
@@ -22,11 +23,11 @@ const PROTOCOL_VERSION = 8;
22
23
  const SAMPLE_S16LE = 3;
23
24
  const COMMAND = {
24
25
  ERROR: 0, REPLY: 2, CREATE_PLAYBACK_STREAM: 3, DELETE_PLAYBACK_STREAM: 4, AUTH: 8, SET_CLIENT_NAME: 9,
25
- DRAIN_PLAYBACK_STREAM: 12, REQUEST: 61, UNDERFLOW: 63, PLAYBACK_STREAM_KILLED: 32,
26
+ DRAIN_PLAYBACK_STREAM: 12, GET_PLAYBACK_LATENCY: 14, REQUEST: 61, UNDERFLOW: 63, PLAYBACK_STREAM_KILLED: 32,
26
27
  };
27
28
  const TAG = {
28
29
  STRING: 0x74, NULL: 0x4e, U32: 0x4c, U8: 0x42, SAMPLE_SPEC: 0x61, ARBITRARY: 0x78, TRUE: 0x31, FALSE: 0x30,
29
- CHANNEL_MAP: 0x6d, CVOLUME: 0x76,
30
+ CHANNEL_MAP: 0x6d, CVOLUME: 0x76, TIMEVAL: 0x54, USEC: 0x55, S64: 0x72,
30
31
  };
31
32
  // PulseAudio channel positions: mono, front-left, front-right, then aux channels.
32
33
  const POSITION = { MONO: 0, FRONT_LEFT: 1, FRONT_RIGHT: 2, AUX0: 12 };
@@ -52,6 +53,11 @@ class TagWriter {
52
53
  return this;
53
54
  }
54
55
 
56
+ timeval(milliseconds) {
57
+ this.#bytes.push(TAG.TIMEVAL, ...u32be(Math.floor(milliseconds / 1000)), ...u32be((milliseconds % 1000) * 1000));
58
+ return this;
59
+ }
60
+
55
61
  arbitrary(bytes) {
56
62
  this.#bytes.push(TAG.ARBITRARY, ...u32be(bytes.length), ...bytes);
57
63
  return this;
@@ -95,6 +101,27 @@ class TagReader {
95
101
  return this.#rawU32();
96
102
  }
97
103
 
104
+ bool() {
105
+ const tag = this.#data[this.#index++];
106
+ if (tag !== TAG.TRUE && tag !== TAG.FALSE) throw new Error("pulse: invalid boolean");
107
+ return tag === TAG.TRUE;
108
+ }
109
+
110
+ timeval() {
111
+ this.#expect(TAG.TIMEVAL);
112
+ return this.#rawU32() + this.#rawU32() / 1e6;
113
+ }
114
+
115
+ integer64(tag, signed = false) {
116
+ this.#expect(tag);
117
+ if (this.#index + 8 > this.#data.length) throw new Error("pulse: truncated timing value");
118
+ const view = new DataView(this.#data.buffer, this.#data.byteOffset + this.#index, 8);
119
+ const value = Number(signed ? view.getBigInt64(0) : view.getBigUint64(0));
120
+ this.#index += 8;
121
+ if (!Number.isSafeInteger(value)) throw new Error("pulse: timing value out of range");
122
+ return value;
123
+ }
124
+
98
125
  string() {
99
126
  if (this.#data[this.#index] === TAG.NULL) {
100
127
  this.#index++;
@@ -175,13 +202,17 @@ export class PulseClient {
175
202
  }
176
203
 
177
204
  /** Sends a command packet; resolves with the reply's reader. */
178
- #command(command, fill = () => {}) {
205
+ #command(command, fill = () => {}, timeout = 0) {
179
206
  if (this.#closed) return Promise.reject(new Error("pulse: connection closed"));
180
207
  const tag = this.#nextTag++;
181
208
  const writer = new TagWriter().u32(command).u32(tag);
182
209
  fill(writer);
183
210
  return new Promise((resolve, reject) => {
184
- this.#pending.set(tag, { resolve, reject, command });
211
+ const timer = timeout ? setTimeout(() => {
212
+ this.#pending.delete(tag);
213
+ reject(new Error("pulse: timing request timed out"));
214
+ }, timeout) : null;
215
+ this.#pending.set(tag, { resolve, reject, command, timer });
185
216
  this.#socket.write(frame(PACKET_CHANNEL, writer.finish()));
186
217
  });
187
218
  }
@@ -210,6 +241,7 @@ export class PulseClient {
210
241
  const pending = this.#pending.get(tag);
211
242
  this.#pending.delete(tag);
212
243
  if (!pending) return;
244
+ clearTimeout(pending.timer);
213
245
  if (command === COMMAND.REPLY) pending.resolve(reader);
214
246
  else pending.reject(new Error(`pulse: command ${pending.command} server error ${reader.u32()}`));
215
247
  } else if (command === COMMAND.REQUEST) {
@@ -217,6 +249,8 @@ export class PulseClient {
217
249
  stream?.grant(reader.u32());
218
250
  } else if (command === COMMAND.PLAYBACK_STREAM_KILLED) {
219
251
  this.#streams.get(reader.u32())?.kill();
252
+ } else if (command === COMMAND.UNDERFLOW) {
253
+ this.#streams.get(reader.u32())?.updateTiming();
220
254
  }
221
255
  }
222
256
 
@@ -225,7 +259,7 @@ export class PulseClient {
225
259
  * `onRequest(bytes)` asks for about that much more audio, paced to real time
226
260
  * (see PlaybackStream).
227
261
  */
228
- async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, leadSeconds = LEAD_SECONDS }) {
262
+ async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, onTiming = null, onTimingError = null, leadSeconds = LEAD_SECONDS }) {
229
263
  const spec = { format: SAMPLE_S16LE, channels, rate };
230
264
  const frameBytes = channels * 2;
231
265
  const bytesForSeconds = (seconds) => Math.max(frameBytes, Math.round(rate * seconds) * frameBytes);
@@ -245,7 +279,7 @@ export class PulseClient {
245
279
  const index = reply.u32();
246
280
  reply.u32(); // sink input index
247
281
  const requested = reply.u32();
248
- const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds);
282
+ const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds, onTiming, onTimingError);
249
283
  this.#streams.set(index, stream);
250
284
  stream.grant(requested);
251
285
  return stream;
@@ -261,6 +295,20 @@ export class PulseClient {
261
295
  return this.#command(COMMAND.DRAIN_PLAYBACK_STREAM, (w) => w.u32(index));
262
296
  }
263
297
 
298
+ /** Protocol v8 timing: the read cursor minus the sink's measured device queue. */
299
+ async playbackTiming(index) {
300
+ const reply = await this.#command(COMMAND.GET_PLAYBACK_LATENCY,
301
+ (w) => w.u32(index).timeval(Date.now()), 2000);
302
+ const sinkSeconds = reply.integer64(TAG.USEC) / 1e6;
303
+ reply.integer64(TAG.USEC); // source latency (recording only)
304
+ const playing = reply.bool();
305
+ reply.timeval(); // echoed request time
306
+ reply.timeval(); // server measurement time; don't assume synchronized wall clocks
307
+ const writeIndex = reply.integer64(TAG.S64, true);
308
+ const readIndex = reply.integer64(TAG.S64, true);
309
+ return { sinkSeconds, playing, writeIndex, readIndex };
310
+ }
311
+
264
312
  async deleteStream(index) {
265
313
  this.#streams.delete(index);
266
314
  await this.#command(COMMAND.DELETE_PLAYBACK_STREAM, (w) => w.u32(index)).catch(() => {});
@@ -269,7 +317,10 @@ export class PulseClient {
269
317
  close() {
270
318
  if (this.#closed) return;
271
319
  this.#closed = true;
272
- for (const { reject } of this.#pending.values()) reject(new Error("pulse: connection closed"));
320
+ for (const { reject, timer } of this.#pending.values()) {
321
+ clearTimeout(timer);
322
+ reject(new Error("pulse: connection closed"));
323
+ }
273
324
  this.#pending.clear();
274
325
  for (const stream of this.#streams.values()) stream.kill();
275
326
  this.#streams.clear();
@@ -282,9 +333,9 @@ export class PulseClient {
282
333
  * to LEAD_SECONDS ahead of the playback clock, whatever the server asks for.
283
334
  * Some servers ask for more at once and queue it themselves (jspulse's
284
335
  * OpenSL ES backend requests again as soon as it receives), and then a pause
285
- * would leave everything already sent still playing. `onRequest(bytes)` asks
286
- * the producer for about that much more; audio it has not been asked for is
287
- * 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.
288
339
  */
289
340
  const LEAD_SECONDS = 0.5;
290
341
  const PACE_MS = 50;
@@ -305,9 +356,14 @@ class PlaybackStream {
305
356
  #started = null;
306
357
  #timer = null;
307
358
  #leadSeconds;
359
+ #onTiming;
360
+ #onTimingError;
361
+ #timingPending = false;
362
+ #playedBytes = 0;
363
+ #producerBuffer;
308
364
  killed = false;
309
365
 
310
- constructor(client, index, { rate, channels }, onRequest, leadSeconds) {
366
+ constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
311
367
  this.#client = client;
312
368
  this.index = index;
313
369
  // Audio is only ever cut at whole frames (one sample of every channel):
@@ -317,7 +373,32 @@ class PlaybackStream {
317
373
  this.#bytesPerSecond = rate * this.#frameBytes;
318
374
  this.#onRequest = onRequest;
319
375
  this.#leadSeconds = leadSeconds;
320
- this.#timer = setInterval(() => this.#pace(), PACE_MS);
376
+ this.#producerBuffer = new PlaybackBuffer(this.#bytesPerSecond, this.#frameBytes, leadSeconds);
377
+ this.#onTiming = onTiming;
378
+ this.#onTimingError = onTimingError;
379
+ this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
380
+ }
381
+
382
+ async updateTiming() {
383
+ if (!this.#onTiming || this.killed || this.#timingPending) return;
384
+ this.#timingPending = true;
385
+ try {
386
+ const timing = await this.#client.playbackTiming(this.index);
387
+ if (this.killed) return;
388
+ // Never count queued PCM or underrun silence as played media. No wall
389
+ // clock extrapolation: an absent/stale report freezes the master clock.
390
+ const end = Math.min(this.#offset, Math.max(0, timing.writeIndex));
391
+ const played = Math.min(end, Math.max(0, Math.min(end, timing.readIndex) - timing.sinkSeconds * this.#bytesPerSecond));
392
+ this.#playedBytes = Math.max(this.#playedBytes, this.#frameAligned(played));
393
+ this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
394
+ sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing,
395
+ bufferedSeconds: this.#queuedBytes / this.#bytesPerSecond });
396
+ this.#pace();
397
+ } catch (error) {
398
+ if (!this.killed) this.#onTimingError?.(error);
399
+ } finally {
400
+ this.#timingPending = false;
401
+ }
321
402
  }
322
403
 
323
404
  /** The server has room for `bytes` more. */
@@ -343,6 +424,11 @@ class PlaybackStream {
343
424
  #allowed() {
344
425
  const elapsed = this.#started === null ? 0 : (performance.now() - this.#started) / 1000;
345
426
  const bytes = Math.floor(this.#bytesPerSecond * (elapsed + this.#leadSeconds));
427
+ // Device progress is authoritative, with real-time pacing as a second
428
+ // ceiling: a server incorrectly reporting a zero queue cannot flood the
429
+ // device faster than real time. This is a queue limit, not an A/V offset.
430
+ if (this.#onTiming) return this.#frameAligned(Math.min(bytes,
431
+ this.#playedBytes + this.#bytesPerSecond * this.#leadSeconds));
346
432
  return bytes - (bytes % this.#frameBytes);
347
433
  }
348
434
 
@@ -354,7 +440,11 @@ class PlaybackStream {
354
440
  // (a few at a time), so small packets leave them little to play from.
355
441
  const minimum = this.#frameAligned(this.#bytesPerSecond * MIN_PACKET_SECONDS);
356
442
  const maximum = this.#frameAligned(this.#bytesPerSecond * MAX_PACKET_SECONDS);
357
- 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) {
358
448
  const room = this.#frameAligned(Math.min(this.#credit, allowed - this.#offset, maximum, this.#queuedBytes));
359
449
  if (room < this.#frameBytes || (room < minimum && !this.finishing && this.#queuedBytes < minimum)) break;
360
450
  const packet = new Uint8Array(room);
@@ -372,15 +462,34 @@ class PlaybackStream {
372
462
  this.#credit -= room;
373
463
  this.#queuedBytes -= room;
374
464
  }
375
- // Ask for what the clock will allow next that is neither queued nor already asked for.
376
- const need = this.#frameAligned(Math.min(this.#offset + this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000)
377
- - 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);
378
470
  if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
379
471
  this.#outstanding += need;
380
472
  this.#onRequest(need);
381
473
  }
382
474
  }
383
475
 
476
+ /**
477
+ * Restarts the pacing clock from what was sent so far. After the writer
478
+ * paused (the output ran dry), sending resumes at the playback rate
479
+ * instead of catching up with the time that passed, which would queue
480
+ * seconds on the server and delay what plays from then on.
481
+ */
482
+ resync() {
483
+ if (this.killed || this.#started === null) return;
484
+ this.#started = performance.now() - this.#offset / this.#bytesPerSecond * 1000;
485
+ this.#pace();
486
+ }
487
+
488
+ /** Bytes handed to the server so far: once there, the server may play them even after the stream closes. */
489
+ get sentBytes() {
490
+ return this.#offset;
491
+ }
492
+
384
493
  /** Bytes queued but not yet sent. */
385
494
  get queued() {
386
495
  return this.#queuedBytes;
@@ -392,9 +501,18 @@ class PlaybackStream {
392
501
  while (this.#queue.length && !this.killed) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
393
502
  if (!this.killed) {
394
503
  await this.#client.drain(this.index).catch(() => {});
395
- // A server that queued ahead may answer the drain early: wait for the clock too.
396
- const end = (this.#started ?? performance.now()) + this.#offset / this.#bytesPerSecond * 1000;
397
- while (!this.killed && performance.now() < end) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
504
+ if (this.#onTiming) {
505
+ // Some compatible servers acknowledge drain before their device queue
506
+ // empties. Only a measured playback cursor may end synchronized media.
507
+ while (!this.killed && this.#playedBytes < this.#offset) {
508
+ await this.updateTiming();
509
+ await new Promise((resolve) => setTimeout(resolve, PACE_MS));
510
+ }
511
+ }
512
+ if (!this.#onTiming) {
513
+ const end = (this.#started ?? performance.now()) + this.#offset / this.#bytesPerSecond * 1000;
514
+ while (!this.killed && performance.now() < end) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
515
+ }
398
516
  }
399
517
  await this.close();
400
518
  }
package/lib/cdp-server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CHROMIUM_VERSION, DESKTOP_USER_AGENT } from "./user-agent.js";
1
+ import { BROWSER_PRODUCT, DESKTOP_USER_AGENT } from "./user-agent.js";
2
2
 
3
3
  const CDP_DEFAULT_PORT = 9222;
4
4
  const CDP_PROTOCOL_VERSION = "1.3";
@@ -124,11 +124,10 @@ export class CdpServer {
124
124
 
125
125
  if (pathname === "/json/version") {
126
126
  return respond(jsonResponse({
127
- Browser: `Chrome/${CHROMIUM_VERSION}`,
127
+ Browser: BROWSER_PRODUCT,
128
128
  "Protocol-Version": CDP_PROTOCOL_VERSION,
129
- "User-Agent": DESKTOP_USER_AGENT,
130
- "V8-Version": Bun.version,
131
- "WebKit-Version": "537.36",
129
+ "User-Agent": context.userAgent ?? DESKTOP_USER_AGENT,
130
+ "Bun-Version": Bun.version,
132
131
  webSocketDebuggerUrl,
133
132
  }));
134
133
  }
@@ -283,9 +282,9 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
283
282
  case "Browser.getVersion":
284
283
  return {
285
284
  protocolVersion: CDP_PROTOCOL_VERSION,
286
- product: `Chrome/${CHROMIUM_VERSION}`,
285
+ product: BROWSER_PRODUCT,
287
286
  revision: "cdp-server",
288
- userAgent: DESKTOP_USER_AGENT,
287
+ userAgent: ctx.userAgent ?? DESKTOP_USER_AGENT,
289
288
  jsVersion: Bun.version,
290
289
  };
291
290
  case "Browser.setDownloadBehavior":
@@ -381,11 +380,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
381
380
  }
382
381
  case "Page.getFrameTree": {
383
382
  const target = getSessionTarget(state, sessionId);
383
+ // Script/form navigations can replace the document without Page.navigate.
384
+ // Report the committed response, not the last URL requested over CDP.
385
+ const resource = await ctx.resourceContent?.() ?? await ctx.resourceContent?.(target.url);
384
386
  // The document's own type (an image, TOML...), not the page shown for it.
385
- const type = (await ctx.resourceContent?.(target.url))?.contentType?.split(";")[0].trim().toLowerCase();
387
+ const type = resource?.contentType?.split(";")[0].trim().toLowerCase();
386
388
  return {
387
389
  frameTree: {
388
- frame: { ...createFrame(target), ...(type ? { mimeType: type } : {}) },
390
+ frame: { ...createFrame(resource ? { ...target, url: resource.url ?? target.url } : target), ...(type ? { mimeType: type } : {}) },
389
391
  },
390
392
  };
391
393
  }
@@ -398,8 +400,11 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
398
400
  return {};
399
401
  case "Page.addScriptToEvaluateOnNewDocument":
400
402
  return {
401
- identifier: `script-${state.nextObjectId++}`,
403
+ identifier: ctx.addInitScript?.(params.source ?? "") ?? `script-${state.nextObjectId++}`,
402
404
  };
405
+ case "Page.removeScriptToEvaluateOnNewDocument":
406
+ ctx.removeInitScript?.(params.identifier);
407
+ return {};
403
408
  case "Page.createIsolatedWorld": {
404
409
  const target = getSessionTarget(state, sessionId);
405
410
  const executionContextId = state.nextExecutionContextId++;
@@ -681,8 +686,12 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
681
686
  return {};
682
687
  case "Input.dispatchMouseEvent": {
683
688
  const target = getSessionTarget(state, sessionId);
689
+ if (params.type === "mouseMoved") {
690
+ await ctx.move?.(params.x, params.y);
691
+ return {};
692
+ }
684
693
  if (params.type === "mouseWheel") {
685
- await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0);
694
+ await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0, { x: params.x, y: params.y });
686
695
  return {};
687
696
  }
688
697
 
@@ -714,6 +723,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
714
723
  text: params.text ?? "",
715
724
  code: params.code ?? "",
716
725
  repeat: !!params.autoRepeat,
726
+ windowsVirtualKeyCode: params.windowsVirtualKeyCode,
717
727
  });
718
728
  target.cdpKeyDown = signature;
719
729
  }
@@ -724,6 +734,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
724
734
  text: params.text ?? "",
725
735
  code: params.code ?? "",
726
736
  repeat: !!params.autoRepeat,
737
+ windowsVirtualKeyCode: params.windowsVirtualKeyCode,
727
738
  });
728
739
  }
729
740
  } else if (params.type === "keyUp") {
@@ -1,5 +1,6 @@
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 "./selector-pseudo-adapter.js";
3
4
 
4
5
  const IMPLICIT_HEAD_ELEMENTS = new Set([
5
6
  "BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
@@ -20,25 +21,48 @@ export function parseHTMLDocument(html, url = "about:blank") {
20
21
  disableCSSFileLoading: true,
21
22
  disableIframePageLoading: true,
22
23
  enableImageFileLoading: false,
24
+ // This DOM is only the tree builder. Link activation must not change
25
+ // its URL before our controller commits a navigation (downloads never
26
+ // commit). Otherwise later #links resolve against the download URL.
27
+ navigation: { disableFallbackToSetURL: true },
23
28
  },
24
29
  });
25
30
 
26
31
  new HTMLParser(window).parse(String(html), window.document);
27
- normalizeImplicitHead(window.document);
32
+ normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
28
33
  return { window, document: window.document };
29
34
  }
30
35
 
36
+ /**
37
+ * How many leading metadata elements belong in the implicit head: with no
38
+ * <head> tag, those before an explicit <body> tag (all of them without one).
39
+ * With a <head> tag Happy DOM places them itself. Comments and the contents
40
+ * of raw-text elements are skipped so that text in them is not counted.
41
+ */
42
+ function implicitHeadCount(html) {
43
+ const source = html
44
+ .replace(/<!--[\s\S]*?-->/g, "")
45
+ .replace(/<(script|style|textarea|title|template|noframes)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi, "<$1$2></$1>");
46
+ if (/<head[\s>\/]/i.test(source)) return 0;
47
+ const bodyAt = source.search(/<body[\s>\/]/i);
48
+ if (bodyAt < 0) return Infinity;
49
+ return (source.slice(0, bodyAt).match(/<(base|basefont|bgsound|link|meta|noframes|script|style|template|title)\b/gi) ?? []).length;
50
+ }
51
+
31
52
  /**
32
53
  * Happy DOM 20's parser places metadata in body when source omits explicit
33
54
  * head/body tags. HTML's "before head" and "in head" insertion modes instead
34
- * put leading metadata elements into the implicit head.
55
+ * put leading metadata elements into the implicit head; once the <body> tag
56
+ * has been seen, metadata stays in body (`limit` counts those before it).
35
57
  */
36
- function normalizeImplicitHead(document) {
58
+ function normalizeImplicitHead(document, limit) {
37
59
  const { head, body } = document;
38
- if (!head || !body) return;
60
+ if (!head || !body || limit <= 0) return;
39
61
 
62
+ let moved = 0;
40
63
  let bodyContentStarted = false;
41
64
  for (const node of [...body.childNodes]) {
65
+ if (moved >= limit) break;
42
66
  if (!bodyContentStarted && node.nodeType === 3 && !node.textContent.trim()) {
43
67
  node.remove();
44
68
  continue;
@@ -46,6 +70,7 @@ function normalizeImplicitHead(document) {
46
70
  if (node.nodeType === 8) continue;
47
71
  if (!bodyContentStarted && node.nodeType === 1 && IMPLICIT_HEAD_ELEMENTS.has(node.tagName)) {
48
72
  head.appendChild(node);
73
+ moved++;
49
74
  continue;
50
75
  }
51
76
  bodyContentStarted = true;
@@ -0,0 +1,14 @@
1
+ import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
2
+
3
+ // Happy DOM's :empty checks only element children. CSS also excludes elements
4
+ // with nonempty text nodes (including whitespace); comments do not count.
5
+ const original = SelectorItem.prototype.matchPseudoItem;
6
+ SelectorItem.prototype.matchPseudoItem = function (scope, element, siblings, pseudo, ignoreErrors) {
7
+ if (pseudo.name === "empty") {
8
+ for (const child of element.childNodes) {
9
+ if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
10
+ }
11
+ return { priorityWeight: 10 };
12
+ }
13
+ return original.call(this, scope, element, siblings, pseudo, ignoreErrors);
14
+ };