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.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/ai-debug.log +5537 -0
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
package/bunfig.toml
CHANGED
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
|
package/lib/audio/piano.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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())
|
|
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.
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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.#
|
|
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
|
-
|
|
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
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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 {
|
|
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:
|
|
127
|
+
Browser: BROWSER_PRODUCT,
|
|
128
128
|
"Protocol-Version": CDP_PROTOCOL_VERSION,
|
|
129
|
-
"User-Agent": DESKTOP_USER_AGENT,
|
|
130
|
-
"
|
|
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:
|
|
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 =
|
|
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") {
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -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
|
+
};
|