buninu-browser 0.2.1 → 0.2.2

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 (72) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/bunfig.toml +1 -0
  4. package/buninu-browser.js +4 -0
  5. package/index.js +5 -0
  6. package/lib/audio/piano.js +24 -1
  7. package/lib/audio/pulse-client.js +120 -12
  8. package/lib/cdp-server.js +16 -11
  9. package/lib/happy-dom/parser.js +24 -4
  10. package/lib/headless-shell.js +66 -19
  11. package/lib/input/hit-test.js +25 -7
  12. package/lib/layout/text-layout.js +196 -49
  13. package/lib/log.js +5 -2
  14. package/lib/media/byte-pipe.js +82 -0
  15. package/lib/media/position-audio.js +42 -0
  16. package/lib/network/network-service.js +161 -14
  17. package/lib/paint/display-list.js +300 -14
  18. package/lib/paint/gradient.js +11 -5
  19. package/lib/render-tree/index.js +17 -2
  20. package/lib/render-tree/svg.js +2 -1
  21. package/lib/renderer/bindings.js +985 -68
  22. package/lib/renderer/canvas-2d.js +92 -0
  23. package/lib/renderer/host.js +158 -8
  24. package/lib/renderer/interfaces.js +215 -0
  25. package/lib/renderer/main.js +160 -12
  26. package/lib/renderer/media-source.js +819 -0
  27. package/lib/renderer/messaging.js +411 -0
  28. package/lib/renderer/native-functions.js +113 -0
  29. package/lib/renderer/page-realm.js +1405 -198
  30. package/lib/renderer/page-renderer.js +1736 -193
  31. package/lib/renderer/rejections.js +31 -0
  32. package/lib/renderer/rewrite-worker.js +22 -0
  33. package/lib/renderer/script-rewrite.js +805 -57
  34. package/lib/renderer/trusted-types.js +129 -0
  35. package/lib/renderer/workers.js +327 -0
  36. package/lib/screenshot-cli.js +2 -0
  37. package/lib/style/computed-style.js +162 -22
  38. package/lib/user-agent.js +14 -2
  39. package/package.json +4 -1
  40. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  41. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  42. package/patches/happy-dom/README.md +26 -0
  43. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  44. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  45. package/patches/ses/README.md +4 -0
  46. package/test/canvas-2d.test.js +55 -0
  47. package/test/compositor.test.js +3 -1
  48. package/test/computed-style.test.js +38 -1
  49. package/test/display-list.test.js +74 -1
  50. package/test/download.test.js +35 -0
  51. package/test/fixtures/long-video.mp4 +0 -0
  52. package/test/fixtures/media-sync.js +57 -0
  53. package/test/happy-dom-platform-adapters.test.js +25 -0
  54. package/test/headless-shell.test.js +1 -1
  55. package/test/hit-test.test.js +25 -1
  56. package/test/media-source.test.js +156 -0
  57. package/test/media-sync.test.js +12 -0
  58. package/test/mobile.test.js +2 -2
  59. package/test/navigation.test.js +61 -0
  60. package/test/network-service.test.js +14 -0
  61. package/test/page-scripts.test.js +1045 -7
  62. package/test/position-audio.test.js +67 -0
  63. package/test/positioning.test.js +95 -1
  64. package/test/pulse-client.test.js +69 -0
  65. package/test/renderer-host.test.js +5 -2
  66. package/test/screenshot-cli.test.js +25 -1
  67. package/test/script-rewrite.test.js +111 -6
  68. package/test/setup.js +5 -0
  69. package/test/user-agent.test.js +60 -4
  70. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  71. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  72. package/vendor/ses/dist/ses.mjs +52 -6
package/AGENTS.md CHANGED
@@ -5,7 +5,11 @@
5
5
  - Native bindings are generally not allowed in the headless shell itself
6
6
  * In the future when it is wrapped into a full browser on each platform, the frontend should stay as thin wrappers just enough to draw rendered content from the headlesss shell
7
7
  * Specifically, able to work with ../casty through CDP is an important goal
8
+ - Working with casty requires setting the env CASTY_BROWSER to the absolute path of buninu-browser.js
9
+ * Then start casty by `bun ../casty/index.js [URL]`. If no URL is specified it starts casty's homepage.
10
+ - Enable logs by BUNINU_LOG
8
11
  - Only use Bun FFI to dlopen libc when other methods are clearly not enough
12
+ - Read README.md and `bun index.js --help` for a general understanding
9
13
 
10
14
  ## Implementing the browser
11
15
  - Do not treat any website specially when refining browser features like layout css js. Fix the actual problem that all websites can benefit from.
package/README.md CHANGED
@@ -24,6 +24,12 @@ npx buninu-browser
24
24
  # Open a URL
25
25
  npx buninu-browser buninu.org
26
26
 
27
+ # WebKit/Safari compatibility identity is the default
28
+ npx buninu-browser https://youtu.be/jf-pn0a-Twk
29
+
30
+ # Switch back to the Chromium compatibility identity
31
+ npx buninu-browser buninu.org -- --chromium
32
+
27
33
  # Arguments after -- go to the
28
34
  # browser engine; --mobile presents
29
35
  # it as a phone (mobile user agent,
@@ -60,6 +66,17 @@ this package's `buninu-browser.js`
60
66
  and starts casty with `npx` when npx
61
67
  and Bun are both installed, otherwise
62
68
  with `bunx`, or `bun x`.
69
+ No adjacent casty checkout is needed. From a local Buninu Browser checkout,
70
+ `bun index.js [URL] [-- BROWSER_FLAGS...]` uses the same package-based launcher.
71
+
72
+ The engine defaults to a WebKit/Safari compatibility identity in casty,
73
+ headless, and screenshot modes. `--webkit` explicitly selects this default;
74
+ `--chromium` selects the previous desktop Chromium identity. Pass these flags
75
+ after `--` through casty, or directly after `--headless`. If both desktop flags
76
+ are supplied, the last one wins. `--mobile` takes priority over either desktop
77
+ identity and retains the Android phone profile. These flags select browser
78
+ identities, not native WebKit or Chromium engines. API callers can use
79
+ `createBrowser({ webkit: false })` to select the Chromium identity.
63
80
 
64
81
  The first argument picks a frontend;
65
82
  the rest is passed on:
@@ -147,11 +164,11 @@ Compatibility with casty is therefore part of the initial vertical slice
147
164
  rather than a later layer: CDP discovery, target lifecycle, navigation,
148
165
  screenshots and screencast, viewport, and input. Early development and every
149
166
  screen test so far have been done in casty itself. casty launches the
150
- browser named by `CASTY_BROWSER` before looking for Chromium, so a checkout
151
- of this repository runs next to a casty checkout with:
167
+ browser named by `CASTY_BROWSER` before looking for Chromium. The package
168
+ launcher sets this automatically and obtains casty through the package runner:
152
169
 
153
170
  ```sh
154
- CASTY_BROWSER="$PWD/buninu-browser.js" bun ../casty/bin/casty.js buninu.org
171
+ npx buninu-browser buninu.org
155
172
  ```
156
173
 
157
174
  ## Design
@@ -247,7 +264,7 @@ pushes a screencast frame.
247
264
  ### Mobile presentation
248
265
 
249
266
  Pages see a desktop browser unless `--mobile` is given. With it, requests
250
- and `navigator.userAgent` use an Android 16 WebView user agent, and the
267
+ and `navigator.userAgent` use an Android 16 Chrome user agent, and the
251
268
  `hover`/`pointer` media features report a touch screen (`hover: none`,
252
269
  `pointer: coarse`). User agent overrides sent over CDP are ignored so the
253
270
  flag stays in effect under clients that set a desktop user agent, such as
@@ -257,11 +274,29 @@ asked for; input coordinates are scaled back, and pages see the factor as
257
274
  `visualViewport.scale`.
258
275
 
259
276
  ```sh
260
- CASTY_BROWSER="$PWD/buninu-browser.js" bun ../casty/bin/casty.js github.com -- --mobile
277
+ npx buninu-browser github.com -- --mobile
261
278
  ```
262
279
 
263
280
  ### Video and audio
264
281
 
282
+ The default desktop WebKit/Safari compatibility identity (also selectable with
283
+ `--webkit`) matches Bun's JavaScriptCore more closely than the optional
284
+ `--chromium` identity. It presents macOS Safari even on other operating systems, sets the
285
+ matching navigator vendor/platform, and omits Chromium User-Agent Client Hints.
286
+ This can help pages that compare their declared browser with JavaScript engine
287
+ behavior. The flag applies to every site and keeps the page sandbox enabled.
288
+
289
+ ```sh
290
+ npx buninu-browser https://youtu.be/jf-pn0a-Twk -- --webkit
291
+ ```
292
+
293
+ The casty package version must support non-Chromium CDP backends without
294
+ replacing their advertised identity or applying Chrome-specific page patches.
295
+ The default and explicit
296
+ `--webkit` profiles ignore CDP user-agent overrides. The same flag works with
297
+ `--headless` and screenshot mode. This is a compatibility workaround, not a
298
+ guarantee that a service will accept every session.
299
+
265
300
  `<video>` and `<audio>` share one pipeline; an `<audio controls>` is a
266
301
  300x32 control bar with no picture. Clicking plays or pauses, clicking the
267
302
  progress track seeks, and page script has `play()`, `pause()`,
@@ -271,9 +306,39 @@ MPEG-4, Theora, MPEG-1/2; MP3, AAC, FLAC, Vorbis, Opus, WAV).
271
306
 
272
307
  Sound, a video's included, plays through a PulseAudio native-protocol server
273
308
  on `127.0.0.1:4713`, such as [jspulse](https://github.com/jjtseng93/jspulse)
274
- (`npx @drxiaozhi/jspulse`); without one, video plays silently. The controller
275
- holds the connection, since renderers cannot open sockets, and paces the
276
- stream to real time, so pausing stops the sound within half a second.
309
+ or PulseAudio itself; without one, video plays silently. Start jspulse with
310
+ the backend appropriate for your platform:
311
+
312
+ ```sh
313
+ npx @drxiaozhi/jspulse --sles # Android OpenSL ES
314
+ npx @drxiaozhi/jspulse --alsa # Linux ALSA
315
+ npx @drxiaozhi/jspulse --win32 # Windows waveform audio
316
+ ```
317
+
318
+ Keep the server on its default port, `4713`, when using Buninu Browser.
319
+ Although jspulse supports `-p` / `--port`, the browser currently connects to
320
+ `127.0.0.1:4713` and does not read `PULSE_SERVER`.
321
+
322
+ The controller
323
+ holds the connection, since renderers cannot open sockets. For media playback
324
+ it polls the server's playback cursor and measured sink latency; video follows
325
+ the reported audio progress, not the amount of PCM submitted or elapsed wall
326
+ time. Missing timing feedback holds the clock and is logged, rather than being
327
+ replaced by a fixed A/V delay. Audio queued ahead is bounded, including across
328
+ network stalls and decoder reopens.
329
+
330
+ Synchronization requires the standard PulseAudio timing query and an output
331
+ backend that reports its measured queue latency. jspulse provides a common
332
+ timing interface across ALSA, Windows, and OpenSL ES: ALSA uses hardware delay
333
+ and software queue measurements; Windows and OpenSL ES track buffer completion.
334
+ The shared Windows/ALSA mixer accounts for each stream separately.
335
+ Use a jspulse version that includes this timing support; older OpenSL ES
336
+ backends lack it. A server reporting zero latency while retaining an
337
+ unreported device queue cannot provide accurate synchronization.
338
+ Buffer-completion timing has buffer-sized resolution and does not measure
339
+ additional unreported downstream latency, such as Bluetooth output latency.
340
+ With `BUNINU_LOG`, `media sync` records reported audio progress, the latest
341
+ rendered video position, and the output queue/latency for diagnosis.
277
342
 
278
343
  ### Diagnostics
279
344
 
@@ -289,7 +354,7 @@ Renderers cannot open files under seccomp, so the controller collects their
289
354
  output and writes the log.
290
355
 
291
356
  ```sh
292
- BUNINU_LOG=1 CASTY_BROWSER="$PWD/buninu-browser.js" bun ../casty/bin/casty.js github.com
357
+ BUNINU_LOG=1 npx buninu-browser github.com
293
358
  ```
294
359
 
295
360
  ## Open-source components
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));
@@ -22,11 +22,11 @@ const PROTOCOL_VERSION = 8;
22
22
  const SAMPLE_S16LE = 3;
23
23
  const COMMAND = {
24
24
  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,
25
+ DRAIN_PLAYBACK_STREAM: 12, GET_PLAYBACK_LATENCY: 14, REQUEST: 61, UNDERFLOW: 63, PLAYBACK_STREAM_KILLED: 32,
26
26
  };
27
27
  const TAG = {
28
28
  STRING: 0x74, NULL: 0x4e, U32: 0x4c, U8: 0x42, SAMPLE_SPEC: 0x61, ARBITRARY: 0x78, TRUE: 0x31, FALSE: 0x30,
29
- CHANNEL_MAP: 0x6d, CVOLUME: 0x76,
29
+ CHANNEL_MAP: 0x6d, CVOLUME: 0x76, TIMEVAL: 0x54, USEC: 0x55, S64: 0x72,
30
30
  };
31
31
  // PulseAudio channel positions: mono, front-left, front-right, then aux channels.
32
32
  const POSITION = { MONO: 0, FRONT_LEFT: 1, FRONT_RIGHT: 2, AUX0: 12 };
@@ -52,6 +52,11 @@ class TagWriter {
52
52
  return this;
53
53
  }
54
54
 
55
+ timeval(milliseconds) {
56
+ this.#bytes.push(TAG.TIMEVAL, ...u32be(Math.floor(milliseconds / 1000)), ...u32be((milliseconds % 1000) * 1000));
57
+ return this;
58
+ }
59
+
55
60
  arbitrary(bytes) {
56
61
  this.#bytes.push(TAG.ARBITRARY, ...u32be(bytes.length), ...bytes);
57
62
  return this;
@@ -95,6 +100,27 @@ class TagReader {
95
100
  return this.#rawU32();
96
101
  }
97
102
 
103
+ bool() {
104
+ const tag = this.#data[this.#index++];
105
+ if (tag !== TAG.TRUE && tag !== TAG.FALSE) throw new Error("pulse: invalid boolean");
106
+ return tag === TAG.TRUE;
107
+ }
108
+
109
+ timeval() {
110
+ this.#expect(TAG.TIMEVAL);
111
+ return this.#rawU32() + this.#rawU32() / 1e6;
112
+ }
113
+
114
+ integer64(tag, signed = false) {
115
+ this.#expect(tag);
116
+ if (this.#index + 8 > this.#data.length) throw new Error("pulse: truncated timing value");
117
+ const view = new DataView(this.#data.buffer, this.#data.byteOffset + this.#index, 8);
118
+ const value = Number(signed ? view.getBigInt64(0) : view.getBigUint64(0));
119
+ this.#index += 8;
120
+ if (!Number.isSafeInteger(value)) throw new Error("pulse: timing value out of range");
121
+ return value;
122
+ }
123
+
98
124
  string() {
99
125
  if (this.#data[this.#index] === TAG.NULL) {
100
126
  this.#index++;
@@ -175,13 +201,17 @@ export class PulseClient {
175
201
  }
176
202
 
177
203
  /** Sends a command packet; resolves with the reply's reader. */
178
- #command(command, fill = () => {}) {
204
+ #command(command, fill = () => {}, timeout = 0) {
179
205
  if (this.#closed) return Promise.reject(new Error("pulse: connection closed"));
180
206
  const tag = this.#nextTag++;
181
207
  const writer = new TagWriter().u32(command).u32(tag);
182
208
  fill(writer);
183
209
  return new Promise((resolve, reject) => {
184
- this.#pending.set(tag, { resolve, reject, command });
210
+ const timer = timeout ? setTimeout(() => {
211
+ this.#pending.delete(tag);
212
+ reject(new Error("pulse: timing request timed out"));
213
+ }, timeout) : null;
214
+ this.#pending.set(tag, { resolve, reject, command, timer });
185
215
  this.#socket.write(frame(PACKET_CHANNEL, writer.finish()));
186
216
  });
187
217
  }
@@ -210,6 +240,7 @@ export class PulseClient {
210
240
  const pending = this.#pending.get(tag);
211
241
  this.#pending.delete(tag);
212
242
  if (!pending) return;
243
+ clearTimeout(pending.timer);
213
244
  if (command === COMMAND.REPLY) pending.resolve(reader);
214
245
  else pending.reject(new Error(`pulse: command ${pending.command} server error ${reader.u32()}`));
215
246
  } else if (command === COMMAND.REQUEST) {
@@ -217,6 +248,8 @@ export class PulseClient {
217
248
  stream?.grant(reader.u32());
218
249
  } else if (command === COMMAND.PLAYBACK_STREAM_KILLED) {
219
250
  this.#streams.get(reader.u32())?.kill();
251
+ } else if (command === COMMAND.UNDERFLOW) {
252
+ this.#streams.get(reader.u32())?.updateTiming();
220
253
  }
221
254
  }
222
255
 
@@ -225,7 +258,7 @@ export class PulseClient {
225
258
  * `onRequest(bytes)` asks for about that much more audio, paced to real time
226
259
  * (see PlaybackStream).
227
260
  */
228
- async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, leadSeconds = LEAD_SECONDS }) {
261
+ async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, onTiming = null, onTimingError = null, leadSeconds = LEAD_SECONDS }) {
229
262
  const spec = { format: SAMPLE_S16LE, channels, rate };
230
263
  const frameBytes = channels * 2;
231
264
  const bytesForSeconds = (seconds) => Math.max(frameBytes, Math.round(rate * seconds) * frameBytes);
@@ -245,7 +278,7 @@ export class PulseClient {
245
278
  const index = reply.u32();
246
279
  reply.u32(); // sink input index
247
280
  const requested = reply.u32();
248
- const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds);
281
+ const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds, onTiming, onTimingError);
249
282
  this.#streams.set(index, stream);
250
283
  stream.grant(requested);
251
284
  return stream;
@@ -261,6 +294,20 @@ export class PulseClient {
261
294
  return this.#command(COMMAND.DRAIN_PLAYBACK_STREAM, (w) => w.u32(index));
262
295
  }
263
296
 
297
+ /** Protocol v8 timing: the read cursor minus the sink's measured device queue. */
298
+ async playbackTiming(index) {
299
+ const reply = await this.#command(COMMAND.GET_PLAYBACK_LATENCY,
300
+ (w) => w.u32(index).timeval(Date.now()), 2000);
301
+ const sinkSeconds = reply.integer64(TAG.USEC) / 1e6;
302
+ reply.integer64(TAG.USEC); // source latency (recording only)
303
+ const playing = reply.bool();
304
+ reply.timeval(); // echoed request time
305
+ reply.timeval(); // server measurement time; don't assume synchronized wall clocks
306
+ const writeIndex = reply.integer64(TAG.S64, true);
307
+ const readIndex = reply.integer64(TAG.S64, true);
308
+ return { sinkSeconds, playing, writeIndex, readIndex };
309
+ }
310
+
264
311
  async deleteStream(index) {
265
312
  this.#streams.delete(index);
266
313
  await this.#command(COMMAND.DELETE_PLAYBACK_STREAM, (w) => w.u32(index)).catch(() => {});
@@ -269,7 +316,10 @@ export class PulseClient {
269
316
  close() {
270
317
  if (this.#closed) return;
271
318
  this.#closed = true;
272
- for (const { reject } of this.#pending.values()) reject(new Error("pulse: connection closed"));
319
+ for (const { reject, timer } of this.#pending.values()) {
320
+ clearTimeout(timer);
321
+ reject(new Error("pulse: connection closed"));
322
+ }
273
323
  this.#pending.clear();
274
324
  for (const stream of this.#streams.values()) stream.kill();
275
325
  this.#streams.clear();
@@ -305,9 +355,13 @@ class PlaybackStream {
305
355
  #started = null;
306
356
  #timer = null;
307
357
  #leadSeconds;
358
+ #onTiming;
359
+ #onTimingError;
360
+ #timingPending = false;
361
+ #playedBytes = 0;
308
362
  killed = false;
309
363
 
310
- constructor(client, index, { rate, channels }, onRequest, leadSeconds) {
364
+ constructor(client, index, { rate, channels }, onRequest, leadSeconds, onTiming, onTimingError) {
311
365
  this.#client = client;
312
366
  this.index = index;
313
367
  // Audio is only ever cut at whole frames (one sample of every channel):
@@ -317,7 +371,30 @@ class PlaybackStream {
317
371
  this.#bytesPerSecond = rate * this.#frameBytes;
318
372
  this.#onRequest = onRequest;
319
373
  this.#leadSeconds = leadSeconds;
320
- this.#timer = setInterval(() => this.#pace(), PACE_MS);
374
+ this.#onTiming = onTiming;
375
+ this.#onTimingError = onTimingError;
376
+ this.#timer = setInterval(() => { this.#pace(); void this.updateTiming(); }, PACE_MS);
377
+ }
378
+
379
+ async updateTiming() {
380
+ if (!this.#onTiming || this.killed || this.#timingPending) return;
381
+ this.#timingPending = true;
382
+ try {
383
+ const timing = await this.#client.playbackTiming(this.index);
384
+ if (this.killed) return;
385
+ // Never count queued PCM or underrun silence as played media. No wall
386
+ // clock extrapolation: an absent/stale report freezes the master clock.
387
+ const end = Math.min(this.#offset, Math.max(0, timing.writeIndex));
388
+ const played = Math.min(end, Math.max(0, Math.min(end, timing.readIndex) - timing.sinkSeconds * this.#bytesPerSecond));
389
+ this.#playedBytes = Math.max(this.#playedBytes, this.#frameAligned(played));
390
+ this.#onTiming({ seconds: this.#playedBytes / this.#bytesPerSecond,
391
+ sentSeconds: this.#offset / this.#bytesPerSecond, sinkSeconds: timing.sinkSeconds, playing: timing.playing });
392
+ this.#pace();
393
+ } catch (error) {
394
+ if (!this.killed) this.#onTimingError?.(error);
395
+ } finally {
396
+ this.#timingPending = false;
397
+ }
321
398
  }
322
399
 
323
400
  /** The server has room for `bytes` more. */
@@ -343,6 +420,11 @@ class PlaybackStream {
343
420
  #allowed() {
344
421
  const elapsed = this.#started === null ? 0 : (performance.now() - this.#started) / 1000;
345
422
  const bytes = Math.floor(this.#bytesPerSecond * (elapsed + this.#leadSeconds));
423
+ // Device progress is authoritative, with real-time pacing as a second
424
+ // ceiling: a server incorrectly reporting a zero queue cannot flood the
425
+ // device faster than real time. This is a queue limit, not an A/V offset.
426
+ if (this.#onTiming) return this.#frameAligned(Math.min(bytes,
427
+ this.#playedBytes + this.#bytesPerSecond * this.#leadSeconds));
346
428
  return bytes - (bytes % this.#frameBytes);
347
429
  }
348
430
 
@@ -381,6 +463,23 @@ class PlaybackStream {
381
463
  }
382
464
  }
383
465
 
466
+ /**
467
+ * Restarts the pacing clock from what was sent so far. After the writer
468
+ * paused (the output ran dry), sending resumes at the playback rate
469
+ * instead of catching up with the time that passed, which would queue
470
+ * seconds on the server and delay what plays from then on.
471
+ */
472
+ resync() {
473
+ if (this.killed || this.#started === null) return;
474
+ this.#started = performance.now() - this.#offset / this.#bytesPerSecond * 1000;
475
+ this.#pace();
476
+ }
477
+
478
+ /** Bytes handed to the server so far: once there, the server may play them even after the stream closes. */
479
+ get sentBytes() {
480
+ return this.#offset;
481
+ }
482
+
384
483
  /** Bytes queued but not yet sent. */
385
484
  get queued() {
386
485
  return this.#queuedBytes;
@@ -392,9 +491,18 @@ class PlaybackStream {
392
491
  while (this.#queue.length && !this.killed) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
393
492
  if (!this.killed) {
394
493
  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));
494
+ if (this.#onTiming) {
495
+ // Some compatible servers acknowledge drain before their device queue
496
+ // empties. Only a measured playback cursor may end synchronized media.
497
+ while (!this.killed && this.#playedBytes < this.#offset) {
498
+ await this.updateTiming();
499
+ await new Promise((resolve) => setTimeout(resolve, PACE_MS));
500
+ }
501
+ }
502
+ if (!this.#onTiming) {
503
+ const end = (this.#started ?? performance.now()) + this.#offset / this.#bytesPerSecond * 1000;
504
+ while (!this.killed && performance.now() < end) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
505
+ }
398
506
  }
399
507
  await this.close();
400
508
  }
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++;
@@ -682,7 +687,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
682
687
  case "Input.dispatchMouseEvent": {
683
688
  const target = getSessionTarget(state, sessionId);
684
689
  if (params.type === "mouseWheel") {
685
- await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0);
690
+ await ctx.scroll?.(params.deltaX ?? 0, params.deltaY ?? 0, { x: params.x, y: params.y });
686
691
  return {};
687
692
  }
688
693
 
@@ -24,21 +24,40 @@ export function parseHTMLDocument(html, url = "about:blank") {
24
24
  });
25
25
 
26
26
  new HTMLParser(window).parse(String(html), window.document);
27
- normalizeImplicitHead(window.document);
27
+ normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
28
28
  return { window, document: window.document };
29
29
  }
30
30
 
31
+ /**
32
+ * How many leading metadata elements belong in the implicit head: with no
33
+ * <head> tag, those before an explicit <body> tag (all of them without one).
34
+ * With a <head> tag Happy DOM places them itself. Comments and the contents
35
+ * of raw-text elements are skipped so that text in them is not counted.
36
+ */
37
+ function implicitHeadCount(html) {
38
+ const source = html
39
+ .replace(/<!--[\s\S]*?-->/g, "")
40
+ .replace(/<(script|style|textarea|title|template|noframes)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi, "<$1$2></$1>");
41
+ if (/<head[\s>\/]/i.test(source)) return 0;
42
+ const bodyAt = source.search(/<body[\s>\/]/i);
43
+ if (bodyAt < 0) return Infinity;
44
+ return (source.slice(0, bodyAt).match(/<(base|basefont|bgsound|link|meta|noframes|script|style|template|title)\b/gi) ?? []).length;
45
+ }
46
+
31
47
  /**
32
48
  * Happy DOM 20's parser places metadata in body when source omits explicit
33
49
  * head/body tags. HTML's "before head" and "in head" insertion modes instead
34
- * put leading metadata elements into the implicit head.
50
+ * put leading metadata elements into the implicit head; once the <body> tag
51
+ * has been seen, metadata stays in body (`limit` counts those before it).
35
52
  */
36
- function normalizeImplicitHead(document) {
53
+ function normalizeImplicitHead(document, limit) {
37
54
  const { head, body } = document;
38
- if (!head || !body) return;
55
+ if (!head || !body || limit <= 0) return;
39
56
 
57
+ let moved = 0;
40
58
  let bodyContentStarted = false;
41
59
  for (const node of [...body.childNodes]) {
60
+ if (moved >= limit) break;
42
61
  if (!bodyContentStarted && node.nodeType === 3 && !node.textContent.trim()) {
43
62
  node.remove();
44
63
  continue;
@@ -46,6 +65,7 @@ function normalizeImplicitHead(document) {
46
65
  if (node.nodeType === 8) continue;
47
66
  if (!bodyContentStarted && node.nodeType === 1 && IMPLICIT_HEAD_ELEMENTS.has(node.tagName)) {
48
67
  head.appendChild(node);
68
+ moved++;
49
69
  continue;
50
70
  }
51
71
  bodyContentStarted = true;