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.
Files changed (57) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +25 -6
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. 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: "CdpServer/1.0",
127
+ Browser: `Chrome/${CHROMIUM_VERSION}`,
126
128
  "Protocol-Version": CDP_PROTOCOL_VERSION,
127
- "User-Agent": "CdpServer/1.0",
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: "Chrome/140.0.0.0",
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
  }