buninu-browser 0.2.0 → 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.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- 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/pulse-client.js +120 -12
- package/lib/cdp-server.js +19 -12
- package/lib/happy-dom/parser.js +24 -4
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +120 -28
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +365 -54
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +326 -46
- package/lib/paint/display-list.js +322 -21
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +62 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +989 -72
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +159 -9
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- 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 +1407 -200
- package/lib/renderer/page-renderer.js +1899 -394
- 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/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +463 -242
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- 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 +3 -1
- package/test/computed-style.test.js +98 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -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 +29 -0
- package/test/hit-test.test.js +25 -1
- package/test/log.test.js +4 -1
- 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 +81 -0
- package/test/page-scripts.test.js +1094 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/render-tree.test.js +22 -1
- 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/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/setup.js +5 -0
- package/test/text-layout.test.js +69 -1
- package/test/user-agent.test.js +60 -4
- 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/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
|
|
151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
357
|
+
BUNINU_LOG=1 npx buninu-browser github.com
|
|
293
358
|
```
|
|
294
359
|
|
|
295
360
|
## Open-source components
|
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));
|
|
@@ -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
|
-
|
|
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())
|
|
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.#
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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 {
|
|
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++;
|
|
@@ -509,7 +514,9 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
509
514
|
const target = getSessionTarget(state, sessionId);
|
|
510
515
|
target.screencast = { ...params };
|
|
511
516
|
target.screencastEmit = emit;
|
|
512
|
-
|
|
517
|
+
// As in Chrome, the reply does not wait for the first frame (nor the
|
|
518
|
+
// renderer, which may still be starting); frames follow as events.
|
|
519
|
+
void pushScreencastFrame(ctx, state, target);
|
|
513
520
|
return {};
|
|
514
521
|
}
|
|
515
522
|
case "Page.screencastFrameAck":
|
|
@@ -680,7 +687,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
680
687
|
case "Input.dispatchMouseEvent": {
|
|
681
688
|
const target = getSessionTarget(state, sessionId);
|
|
682
689
|
if (params.type === "mouseWheel") {
|
|
683
|
-
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 });
|
|
684
691
|
return {};
|
|
685
692
|
}
|
|
686
693
|
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
2
|
+
import QuerySelector from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts";
|
|
3
|
+
import SelectorCombinatorEnum from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts";
|
|
4
|
+
import SelectorParser from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Selector matching for the style engine, which matches tens of thousands of
|
|
8
|
+
* stylesheet selectors against every element. It gives the answers of Happy
|
|
9
|
+
* DOM's Element.matches() (same parser, same per-compound matching), without
|
|
10
|
+
* what that pays on every call: re-validating and looking up the selector
|
|
11
|
+
* string, and recording a cached result on the element and the elements it
|
|
12
|
+
* visited. The style engine keeps its own results.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Element.matches() rejects these before parsing (Happy DOM's QuerySelector).
|
|
16
|
+
const INVALID_SELECTOR = /^[.#\[]?\d|[.#]$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A selector compiled for matchesCompiled(), or null when it is invalid.
|
|
20
|
+
* `element` is any element of the document (it provides the window). The
|
|
21
|
+
* result does not depend on the document and may be kept with the selector.
|
|
22
|
+
* Returns undefined for objects that are not Happy DOM elements.
|
|
23
|
+
*/
|
|
24
|
+
export function compileSelector(element, selector) {
|
|
25
|
+
const window = element?.[PropertySymbol.window];
|
|
26
|
+
if (!window) return undefined;
|
|
27
|
+
selector = String(selector);
|
|
28
|
+
if (INVALID_SELECTOR.test(selector)) return null;
|
|
29
|
+
try {
|
|
30
|
+
const groups = new SelectorParser({ window, scope: element, globalMatchFunction: QuerySelector.globalMatchFunction })
|
|
31
|
+
.getSelectorGroups(selector);
|
|
32
|
+
// Matching walks from the subject (the last compound) leftwards.
|
|
33
|
+
return groups.map((items) => items.slice().reverse());
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether `element` matches a compiled selector, as element.matches(selector) would. */
|
|
40
|
+
export function matchesCompiled(element, compiled) {
|
|
41
|
+
for (const items of compiled) {
|
|
42
|
+
if (matchItems(element, element, items, 0, null)) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Happy DOM's QuerySelector.matchSelector, reduced to a yes or no. */
|
|
48
|
+
function matchItems(scope, element, items, index, previous) {
|
|
49
|
+
const item = items[index];
|
|
50
|
+
if (!item) return false;
|
|
51
|
+
if (item.match(scope, element)) {
|
|
52
|
+
if (index === items.length - 1) return true;
|
|
53
|
+
switch (item.combinator) {
|
|
54
|
+
case SelectorCombinatorEnum.adjacentSibling: {
|
|
55
|
+
const sibling = element.previousElementSibling;
|
|
56
|
+
if (sibling && matchItems(scope, sibling, items, index + 1, item)) return true;
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
case SelectorCombinatorEnum.none:
|
|
60
|
+
case SelectorCombinatorEnum.child:
|
|
61
|
+
case SelectorCombinatorEnum.descendant: {
|
|
62
|
+
const parent = element.parentNode;
|
|
63
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument] && matchItems(scope, parent, items, index + 1, item)) return true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case SelectorCombinatorEnum.subsequentSibling: {
|
|
67
|
+
const parent = element.parentNode;
|
|
68
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument]) {
|
|
69
|
+
const siblings = parent[PropertySymbol.elementArray];
|
|
70
|
+
for (let i = siblings.indexOf(element) - 1; i >= 0; i--) {
|
|
71
|
+
if (matchItems(scope, siblings[i], items, index + 1, item)) return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (previous?.combinator === SelectorCombinatorEnum.none || previous?.combinator === SelectorCombinatorEnum.descendant) {
|
|
79
|
+
const parent = element.parentNode;
|
|
80
|
+
if (parent && parent !== element[PropertySymbol.ownerDocument]) return matchItems(scope, parent, items, index, previous);
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|