buninu-browser 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +102 -6
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal PulseAudio native-protocol client for playback, written for the
|
|
3
|
+
* browser's controller (renderers cannot open sockets). It speaks protocol
|
|
4
|
+
* version 8, the subset jspulse (github.com/jjtseng93/jspulse) serves on
|
|
5
|
+
* 127.0.0.1:4713 with anonymous access:
|
|
6
|
+
*
|
|
7
|
+
* AUTH -> SET_NAME -> CREATE_PLAYBACK -> audio frames as the server sends
|
|
8
|
+
* REQUEST credits -> DRAIN -> DELETE_PLAYBACK
|
|
9
|
+
*
|
|
10
|
+
* Wire format (PulseAudio's documented pstream/tagstruct): every frame has a
|
|
11
|
+
* 20-byte big-endian header (length, channel, offset hi/lo, flags); channel
|
|
12
|
+
* 0xffffffff carries command packets, which are tagstructs of typed values;
|
|
13
|
+
* other channels carry a stream's audio. Audio is sent as s16le.
|
|
14
|
+
*/
|
|
15
|
+
import { connect } from "node:net";
|
|
16
|
+
|
|
17
|
+
export { floatToS16 } from "./pcm.js";
|
|
18
|
+
|
|
19
|
+
const PACKET_CHANNEL = 0xffffffff;
|
|
20
|
+
const INVALID = 0xffffffff;
|
|
21
|
+
const PROTOCOL_VERSION = 8;
|
|
22
|
+
const SAMPLE_S16LE = 3;
|
|
23
|
+
const COMMAND = {
|
|
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,
|
|
26
|
+
};
|
|
27
|
+
const TAG = {
|
|
28
|
+
STRING: 0x74, NULL: 0x4e, U32: 0x4c, U8: 0x42, SAMPLE_SPEC: 0x61, ARBITRARY: 0x78, TRUE: 0x31, FALSE: 0x30,
|
|
29
|
+
CHANNEL_MAP: 0x6d, CVOLUME: 0x76,
|
|
30
|
+
};
|
|
31
|
+
// PulseAudio channel positions: mono, front-left, front-right, then aux channels.
|
|
32
|
+
const POSITION = { MONO: 0, FRONT_LEFT: 1, FRONT_RIGHT: 2, AUX0: 12 };
|
|
33
|
+
const VOLUME_NORM = 0x10000;
|
|
34
|
+
|
|
35
|
+
/** Builds a tagstruct. */
|
|
36
|
+
class TagWriter {
|
|
37
|
+
#bytes = [];
|
|
38
|
+
|
|
39
|
+
u32(value) {
|
|
40
|
+
this.#bytes.push(TAG.U32, ...u32be(value));
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
string(value) {
|
|
45
|
+
if (value == null) this.#bytes.push(TAG.NULL);
|
|
46
|
+
else this.#bytes.push(TAG.STRING, ...new TextEncoder().encode(value), 0);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
bool(value) {
|
|
51
|
+
this.#bytes.push(value ? TAG.TRUE : TAG.FALSE);
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
arbitrary(bytes) {
|
|
56
|
+
this.#bytes.push(TAG.ARBITRARY, ...u32be(bytes.length), ...bytes);
|
|
57
|
+
return this;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
sampleSpec({ format, channels, rate }) {
|
|
61
|
+
this.#bytes.push(TAG.SAMPLE_SPEC, format, channels, ...u32be(rate));
|
|
62
|
+
return this;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
channelMap(channels) {
|
|
66
|
+
const positions = channels === 1 ? [POSITION.MONO]
|
|
67
|
+
: Array.from({ length: channels }, (_, index) =>
|
|
68
|
+
index === 0 ? POSITION.FRONT_LEFT : index === 1 ? POSITION.FRONT_RIGHT : POSITION.AUX0 + index - 2);
|
|
69
|
+
this.#bytes.push(TAG.CHANNEL_MAP, channels, ...positions);
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cvolume(channels, volume = VOLUME_NORM) {
|
|
74
|
+
this.#bytes.push(TAG.CVOLUME, channels);
|
|
75
|
+
for (let index = 0; index < channels; index++) this.#bytes.push(...u32be(volume));
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
finish() {
|
|
80
|
+
return Uint8Array.from(this.#bytes);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Reads a tagstruct. */
|
|
85
|
+
class TagReader {
|
|
86
|
+
#data;
|
|
87
|
+
#index = 0;
|
|
88
|
+
|
|
89
|
+
constructor(data) {
|
|
90
|
+
this.#data = data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
u32() {
|
|
94
|
+
this.#expect(TAG.U32);
|
|
95
|
+
return this.#rawU32();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
string() {
|
|
99
|
+
if (this.#data[this.#index] === TAG.NULL) {
|
|
100
|
+
this.#index++;
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
this.#expect(TAG.STRING);
|
|
104
|
+
const end = this.#data.indexOf(0, this.#index);
|
|
105
|
+
if (end < 0) throw new Error("pulse: unterminated string");
|
|
106
|
+
const value = new TextDecoder().decode(this.#data.subarray(this.#index, end));
|
|
107
|
+
this.#index = end + 1;
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#expect(tag) {
|
|
112
|
+
if (this.#data[this.#index++] !== tag) throw new Error(`pulse: expected tag ${String.fromCharCode(tag)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#rawU32() {
|
|
116
|
+
if (this.#index + 4 > this.#data.length) throw new Error("pulse: truncated packet");
|
|
117
|
+
const value = new DataView(this.#data.buffer, this.#data.byteOffset + this.#index, 4).getUint32(0);
|
|
118
|
+
this.#index += 4;
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function u32be(value) {
|
|
124
|
+
return [(value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function frame(channel, payload, offset = 0) {
|
|
128
|
+
const header = new Uint8Array(20);
|
|
129
|
+
const view = new DataView(header.buffer);
|
|
130
|
+
view.setUint32(0, payload.length);
|
|
131
|
+
view.setUint32(4, channel >>> 0);
|
|
132
|
+
view.setUint32(8, Math.floor(offset / 2 ** 32));
|
|
133
|
+
view.setUint32(12, offset >>> 0);
|
|
134
|
+
view.setUint32(16, 0);
|
|
135
|
+
const out = new Uint8Array(20 + payload.length);
|
|
136
|
+
out.set(header);
|
|
137
|
+
out.set(payload, 20);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* One connection to a PulseAudio server, for playback streams.
|
|
143
|
+
* `connection` is { host, port } (default 127.0.0.1:4713).
|
|
144
|
+
*/
|
|
145
|
+
export class PulseClient {
|
|
146
|
+
#socket = null;
|
|
147
|
+
#buffer = new Uint8Array(0);
|
|
148
|
+
#nextTag = 1;
|
|
149
|
+
#nextSyncId = 1;
|
|
150
|
+
#pending = new Map();
|
|
151
|
+
#streams = new Map();
|
|
152
|
+
#closed = false;
|
|
153
|
+
|
|
154
|
+
static async connect({ host = "127.0.0.1", port = 4713, name = "buninu-browser" } = {}) {
|
|
155
|
+
const client = new PulseClient();
|
|
156
|
+
await client.#open(host, port);
|
|
157
|
+
await client.#command(COMMAND.AUTH, (w) => w.u32(PROTOCOL_VERSION).arbitrary(new Uint8Array(256)));
|
|
158
|
+
await client.#command(COMMAND.SET_CLIENT_NAME, (w) => w.string(name));
|
|
159
|
+
return client;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#open(host, port) {
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const socket = connect({ host, port });
|
|
165
|
+
socket.once("connect", () => {
|
|
166
|
+
socket.off("error", reject);
|
|
167
|
+
socket.on("error", () => this.close());
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
socket.once("error", reject);
|
|
171
|
+
socket.on("data", (chunk) => this.#receive(chunk));
|
|
172
|
+
socket.on("close", () => this.close());
|
|
173
|
+
this.#socket = socket;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Sends a command packet; resolves with the reply's reader. */
|
|
178
|
+
#command(command, fill = () => {}) {
|
|
179
|
+
if (this.#closed) return Promise.reject(new Error("pulse: connection closed"));
|
|
180
|
+
const tag = this.#nextTag++;
|
|
181
|
+
const writer = new TagWriter().u32(command).u32(tag);
|
|
182
|
+
fill(writer);
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
this.#pending.set(tag, { resolve, reject, command });
|
|
185
|
+
this.#socket.write(frame(PACKET_CHANNEL, writer.finish()));
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#receive(chunk) {
|
|
190
|
+
const joined = new Uint8Array(this.#buffer.length + chunk.length);
|
|
191
|
+
joined.set(this.#buffer);
|
|
192
|
+
joined.set(chunk, this.#buffer.length);
|
|
193
|
+
this.#buffer = joined;
|
|
194
|
+
while (this.#buffer.length >= 20) {
|
|
195
|
+
const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 20);
|
|
196
|
+
const length = view.getUint32(0);
|
|
197
|
+
const channel = view.getUint32(4);
|
|
198
|
+
if (this.#buffer.length < 20 + length) return;
|
|
199
|
+
const body = this.#buffer.slice(20, 20 + length);
|
|
200
|
+
this.#buffer = this.#buffer.slice(20 + length);
|
|
201
|
+
if (channel === PACKET_CHANNEL) this.#packet(body);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#packet(body) {
|
|
206
|
+
const reader = new TagReader(body);
|
|
207
|
+
const command = reader.u32();
|
|
208
|
+
const tag = reader.u32();
|
|
209
|
+
if (command === COMMAND.REPLY || command === COMMAND.ERROR) {
|
|
210
|
+
const pending = this.#pending.get(tag);
|
|
211
|
+
this.#pending.delete(tag);
|
|
212
|
+
if (!pending) return;
|
|
213
|
+
if (command === COMMAND.REPLY) pending.resolve(reader);
|
|
214
|
+
else pending.reject(new Error(`pulse: command ${pending.command} server error ${reader.u32()}`));
|
|
215
|
+
} else if (command === COMMAND.REQUEST) {
|
|
216
|
+
const stream = this.#streams.get(reader.u32());
|
|
217
|
+
stream?.grant(reader.u32());
|
|
218
|
+
} else if (command === COMMAND.PLAYBACK_STREAM_KILLED) {
|
|
219
|
+
this.#streams.get(reader.u32())?.kill();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Opens a playback stream of s16le at `rate` Hz with `channels` channels.
|
|
225
|
+
* `onRequest(bytes)` asks for about that much more audio, paced to real time
|
|
226
|
+
* (see PlaybackStream).
|
|
227
|
+
*/
|
|
228
|
+
async openPlayback({ rate, channels, name = "audio", onRequest = () => {}, leadSeconds = LEAD_SECONDS }) {
|
|
229
|
+
const spec = { format: SAMPLE_S16LE, channels, rate };
|
|
230
|
+
const frameBytes = channels * 2;
|
|
231
|
+
const bytesForSeconds = (seconds) => Math.max(frameBytes, Math.round(rate * seconds) * frameBytes);
|
|
232
|
+
// Native PulseAudio defaults tlength/prebuf to about two seconds. A piano
|
|
233
|
+
// stream that is deliberately kept only 120 ms ahead would not start until
|
|
234
|
+
// that buffer filled. Request an explicit, short start threshold instead.
|
|
235
|
+
const interactive = leadSeconds <= 0.15;
|
|
236
|
+
const targetLength = bytesForSeconds(interactive ? 0.16 : 0.4);
|
|
237
|
+
const prebuffer = bytesForSeconds(interactive ? 0.04 : 0.08);
|
|
238
|
+
const minimumRequest = bytesForSeconds(0.04);
|
|
239
|
+
const reply = await this.#command(COMMAND.CREATE_PLAYBACK_STREAM, (w) => w
|
|
240
|
+
.string(name).sampleSpec(spec).channelMap(channels)
|
|
241
|
+
.u32(INVALID).string(null) // default sink
|
|
242
|
+
.u32(INVALID).bool(false) // maxlength, not corked
|
|
243
|
+
.u32(targetLength).u32(prebuffer).u32(minimumRequest) // tlength, prebuf, minreq
|
|
244
|
+
.u32(this.#nextSyncId++).cvolume(channels)); // independent sync id, volume
|
|
245
|
+
const index = reply.u32();
|
|
246
|
+
reply.u32(); // sink input index
|
|
247
|
+
const requested = reply.u32();
|
|
248
|
+
const stream = new PlaybackStream(this, index, spec, onRequest, leadSeconds);
|
|
249
|
+
this.#streams.set(index, stream);
|
|
250
|
+
stream.grant(requested);
|
|
251
|
+
return stream;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
writeAudio(index, bytes) {
|
|
255
|
+
// pstream flags=0 means PA_SEEK_RELATIVE. Offset 0 appends at the current
|
|
256
|
+
// write index; a cumulative offset would create ever-growing gaps.
|
|
257
|
+
if (!this.#closed) this.#socket.write(frame(index, bytes));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
drain(index) {
|
|
261
|
+
return this.#command(COMMAND.DRAIN_PLAYBACK_STREAM, (w) => w.u32(index));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async deleteStream(index) {
|
|
265
|
+
this.#streams.delete(index);
|
|
266
|
+
await this.#command(COMMAND.DELETE_PLAYBACK_STREAM, (w) => w.u32(index)).catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
close() {
|
|
270
|
+
if (this.#closed) return;
|
|
271
|
+
this.#closed = true;
|
|
272
|
+
for (const { reject } of this.#pending.values()) reject(new Error("pulse: connection closed"));
|
|
273
|
+
this.#pending.clear();
|
|
274
|
+
for (const stream of this.#streams.values()) stream.kill();
|
|
275
|
+
this.#streams.clear();
|
|
276
|
+
this.#socket?.destroy();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A playback stream, paced to real time on this side: audio goes out only up
|
|
282
|
+
* to LEAD_SECONDS ahead of the playback clock, whatever the server asks for.
|
|
283
|
+
* Some servers ask for more at once and queue it themselves (jspulse's
|
|
284
|
+
* OpenSL ES backend requests again as soon as it receives), and then a pause
|
|
285
|
+
* would leave everything already sent still playing. `onRequest(bytes)` asks
|
|
286
|
+
* the producer for about that much more; audio it has not been asked for is
|
|
287
|
+
* never requested twice.
|
|
288
|
+
*/
|
|
289
|
+
const LEAD_SECONDS = 0.5;
|
|
290
|
+
const PACE_MS = 50;
|
|
291
|
+
const MIN_REQUEST_SECONDS = 0.02;
|
|
292
|
+
const MIN_PACKET_SECONDS = 0.04;
|
|
293
|
+
const MAX_PACKET_SECONDS = 0.1;
|
|
294
|
+
|
|
295
|
+
class PlaybackStream {
|
|
296
|
+
#client;
|
|
297
|
+
#onRequest;
|
|
298
|
+
#bytesPerSecond;
|
|
299
|
+
#frameBytes;
|
|
300
|
+
#offset = 0;
|
|
301
|
+
#credit = 0;
|
|
302
|
+
#queue = [];
|
|
303
|
+
#queuedBytes = 0;
|
|
304
|
+
#outstanding = 0;
|
|
305
|
+
#started = null;
|
|
306
|
+
#timer = null;
|
|
307
|
+
#leadSeconds;
|
|
308
|
+
killed = false;
|
|
309
|
+
|
|
310
|
+
constructor(client, index, { rate, channels }, onRequest, leadSeconds) {
|
|
311
|
+
this.#client = client;
|
|
312
|
+
this.index = index;
|
|
313
|
+
// Audio is only ever cut at whole frames (one sample of every channel):
|
|
314
|
+
// servers such as jspulse's OpenSL ES backend queue each piece on its
|
|
315
|
+
// own, and half a stereo frame would swap left and right from then on.
|
|
316
|
+
this.#frameBytes = channels * 2;
|
|
317
|
+
this.#bytesPerSecond = rate * this.#frameBytes;
|
|
318
|
+
this.#onRequest = onRequest;
|
|
319
|
+
this.#leadSeconds = leadSeconds;
|
|
320
|
+
this.#timer = setInterval(() => this.#pace(), PACE_MS);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The server has room for `bytes` more. */
|
|
324
|
+
grant(bytes) {
|
|
325
|
+
this.#credit += bytes;
|
|
326
|
+
this.#pace();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Queues s16le audio from the producer; it goes out as the clock allows. */
|
|
330
|
+
write(bytes) {
|
|
331
|
+
if (this.killed || !bytes.length) return;
|
|
332
|
+
this.#outstanding = Math.max(0, this.#outstanding - bytes.length);
|
|
333
|
+
this.#queue.push(bytes);
|
|
334
|
+
this.#queuedBytes += bytes.length;
|
|
335
|
+
this.#pace();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#frameAligned(bytes) {
|
|
339
|
+
return Math.floor(bytes) - (Math.floor(bytes) % this.#frameBytes);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Bytes the playback clock allows to have been sent by now. */
|
|
343
|
+
#allowed() {
|
|
344
|
+
const elapsed = this.#started === null ? 0 : (performance.now() - this.#started) / 1000;
|
|
345
|
+
const bytes = Math.floor(this.#bytesPerSecond * (elapsed + this.#leadSeconds));
|
|
346
|
+
return bytes - (bytes % this.#frameBytes);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#pace() {
|
|
350
|
+
if (this.killed) return;
|
|
351
|
+
const allowed = this.#allowed();
|
|
352
|
+
// Pieces are merged into packets of MIN..MAX_PACKET_SECONDS: servers
|
|
353
|
+
// such as jspulse's OpenSL ES backend queue each packet as one buffer
|
|
354
|
+
// (a few at a time), so small packets leave them little to play from.
|
|
355
|
+
const minimum = this.#frameAligned(this.#bytesPerSecond * MIN_PACKET_SECONDS);
|
|
356
|
+
const maximum = this.#frameAligned(this.#bytesPerSecond * MAX_PACKET_SECONDS);
|
|
357
|
+
while (this.#credit > 0 && this.#queuedBytes > 0 && this.#offset < allowed) {
|
|
358
|
+
const room = this.#frameAligned(Math.min(this.#credit, allowed - this.#offset, maximum, this.#queuedBytes));
|
|
359
|
+
if (room < this.#frameBytes || (room < minimum && !this.finishing && this.#queuedBytes < minimum)) break;
|
|
360
|
+
const packet = new Uint8Array(room);
|
|
361
|
+
for (let filled = 0; filled < room;) {
|
|
362
|
+
const piece = this.#queue[0];
|
|
363
|
+
const take = Math.min(piece.length, room - filled);
|
|
364
|
+
packet.set(piece.subarray(0, take), filled);
|
|
365
|
+
filled += take;
|
|
366
|
+
if (take === piece.length) this.#queue.shift();
|
|
367
|
+
else this.#queue[0] = piece.subarray(take);
|
|
368
|
+
}
|
|
369
|
+
this.#started ??= performance.now();
|
|
370
|
+
this.#client.writeAudio(this.index, packet);
|
|
371
|
+
this.#offset += room;
|
|
372
|
+
this.#credit -= room;
|
|
373
|
+
this.#queuedBytes -= room;
|
|
374
|
+
}
|
|
375
|
+
// Ask for what the clock will allow next that is neither queued nor already asked for.
|
|
376
|
+
const need = this.#frameAligned(Math.min(this.#offset + this.#credit, allowed + this.#bytesPerSecond * PACE_MS / 1000)
|
|
377
|
+
- this.#offset - this.#queuedBytes - this.#outstanding);
|
|
378
|
+
if (need >= this.#bytesPerSecond * MIN_REQUEST_SECONDS && !this.finishing) {
|
|
379
|
+
this.#outstanding += need;
|
|
380
|
+
this.#onRequest(need);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Bytes queued but not yet sent. */
|
|
385
|
+
get queued() {
|
|
386
|
+
return this.#queuedBytes;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Waits until everything written has played, then removes the stream. */
|
|
390
|
+
async finish() {
|
|
391
|
+
this.finishing = true;
|
|
392
|
+
while (this.#queue.length && !this.killed) await new Promise((resolve) => setTimeout(resolve, PACE_MS));
|
|
393
|
+
if (!this.killed) {
|
|
394
|
+
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));
|
|
398
|
+
}
|
|
399
|
+
await this.close();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async close() {
|
|
403
|
+
this.kill();
|
|
404
|
+
await this.#client.deleteStream(this.index);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
kill() {
|
|
408
|
+
this.killed = true;
|
|
409
|
+
this.#queue = [];
|
|
410
|
+
this.#queuedBytes = 0;
|
|
411
|
+
clearInterval(this.#timer);
|
|
412
|
+
}
|
|
413
|
+
}
|
package/lib/cdp-server.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { CHROMIUM_VERSION, DESKTOP_USER_AGENT } from "./user-agent.js";
|
|
2
|
+
|
|
1
3
|
const CDP_DEFAULT_PORT = 9222;
|
|
2
4
|
const CDP_PROTOCOL_VERSION = "1.3";
|
|
3
5
|
const CDP_BROWSER_ID = "cdp-server";
|
|
@@ -122,9 +124,9 @@ export class CdpServer {
|
|
|
122
124
|
|
|
123
125
|
if (pathname === "/json/version") {
|
|
124
126
|
return respond(jsonResponse({
|
|
125
|
-
Browser:
|
|
127
|
+
Browser: `Chrome/${CHROMIUM_VERSION}`,
|
|
126
128
|
"Protocol-Version": CDP_PROTOCOL_VERSION,
|
|
127
|
-
"User-Agent":
|
|
129
|
+
"User-Agent": DESKTOP_USER_AGENT,
|
|
128
130
|
"V8-Version": Bun.version,
|
|
129
131
|
"WebKit-Version": "537.36",
|
|
130
132
|
webSocketDebuggerUrl,
|
|
@@ -146,6 +148,7 @@ export class CdpServer {
|
|
|
146
148
|
websocket: {
|
|
147
149
|
open(ws) {
|
|
148
150
|
serverLog("[CdpServer] client connected");
|
|
151
|
+
ws.subscribe("downloads");
|
|
149
152
|
},
|
|
150
153
|
async message(ws, raw) {
|
|
151
154
|
let id;
|
|
@@ -209,6 +212,14 @@ export class CdpServer {
|
|
|
209
212
|
},
|
|
210
213
|
});
|
|
211
214
|
|
|
215
|
+
context.onDownload?.((event, params) => {
|
|
216
|
+
const method = event === "willBegin" ? "Browser.downloadWillBegin" : "Browser.downloadProgress";
|
|
217
|
+
const payload = event === "willBegin"
|
|
218
|
+
? { frameId: state.targets.values().next().value?.frameId ?? "", ...params }
|
|
219
|
+
: params;
|
|
220
|
+
server.publish("downloads", JSON.stringify({ method, params: payload }));
|
|
221
|
+
});
|
|
222
|
+
|
|
212
223
|
serverLog(`[CdpServer] listening on ws://${hostname}:${port}`);
|
|
213
224
|
return server;
|
|
214
225
|
}
|
|
@@ -272,13 +283,13 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
272
283
|
case "Browser.getVersion":
|
|
273
284
|
return {
|
|
274
285
|
protocolVersion: CDP_PROTOCOL_VERSION,
|
|
275
|
-
product:
|
|
286
|
+
product: `Chrome/${CHROMIUM_VERSION}`,
|
|
276
287
|
revision: "cdp-server",
|
|
277
|
-
userAgent:
|
|
278
|
-
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
|
288
|
+
userAgent: DESKTOP_USER_AGENT,
|
|
279
289
|
jsVersion: Bun.version,
|
|
280
290
|
};
|
|
281
291
|
case "Browser.setDownloadBehavior":
|
|
292
|
+
ctx.setDownloadBehavior?.(params);
|
|
282
293
|
return {};
|
|
283
294
|
case "Target.getBrowserContexts":
|
|
284
295
|
return {
|
|
@@ -362,11 +373,19 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
362
373
|
return {};
|
|
363
374
|
case "Page.enable":
|
|
364
375
|
return {};
|
|
376
|
+
case "Page.getResourceContent": {
|
|
377
|
+
// The main document only: its bytes exactly as they arrived.
|
|
378
|
+
const resource = await ctx.resourceContent?.(params.url);
|
|
379
|
+
if (!resource) throw new Error("No resource with given URL found");
|
|
380
|
+
return { content: Buffer.from(resource.body).toString("base64"), base64Encoded: true };
|
|
381
|
+
}
|
|
365
382
|
case "Page.getFrameTree": {
|
|
366
383
|
const target = getSessionTarget(state, sessionId);
|
|
384
|
+
// 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();
|
|
367
386
|
return {
|
|
368
387
|
frameTree: {
|
|
369
|
-
frame: createFrame(target),
|
|
388
|
+
frame: { ...createFrame(target), ...(type ? { mimeType: type } : {}) },
|
|
370
389
|
},
|
|
371
390
|
};
|
|
372
391
|
}
|