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.
Files changed (90) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +74 -9
  3. package/ai-debug.log +5537 -0
  4. package/bunfig.toml +1 -0
  5. package/buninu-browser.js +4 -0
  6. package/index.js +5 -0
  7. package/lib/audio/piano.js +24 -1
  8. package/lib/audio/playback-buffer.js +27 -0
  9. package/lib/audio/pulse-client.js +137 -19
  10. package/lib/cdp-server.js +22 -11
  11. package/lib/happy-dom/parser.js +29 -4
  12. package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
  13. package/lib/headless-shell.js +69 -19
  14. package/lib/input/hit-test.js +25 -7
  15. package/lib/layout/text-layout.js +268 -85
  16. package/lib/log.js +5 -2
  17. package/lib/media/byte-pipe.js +82 -0
  18. package/lib/media/position-audio.js +42 -0
  19. package/lib/media/video-keyframe.js +29 -0
  20. package/lib/network/network-service.js +161 -14
  21. package/lib/paint/display-list.js +316 -15
  22. package/lib/paint/gradient.js +11 -5
  23. package/lib/render-tree/index.js +19 -2
  24. package/lib/render-tree/svg.js +2 -1
  25. package/lib/renderer/bindings.js +995 -69
  26. package/lib/renderer/canvas-2d.js +92 -0
  27. package/lib/renderer/compositor.js +16 -1
  28. package/lib/renderer/custom-element-reactions.js +18 -0
  29. package/lib/renderer/history.js +42 -0
  30. package/lib/renderer/host.js +166 -9
  31. package/lib/renderer/image-loading.js +27 -0
  32. package/lib/renderer/interfaces.js +215 -0
  33. package/lib/renderer/keyboard-input.js +12 -0
  34. package/lib/renderer/main.js +162 -12
  35. package/lib/renderer/media-playback-events.js +13 -0
  36. package/lib/renderer/media-source.js +819 -0
  37. package/lib/renderer/messaging.js +411 -0
  38. package/lib/renderer/native-functions.js +113 -0
  39. package/lib/renderer/page-realm.js +1408 -242
  40. package/lib/renderer/page-renderer.js +1893 -223
  41. package/lib/renderer/piano-input.js +22 -0
  42. package/lib/renderer/pointer-move.js +29 -0
  43. package/lib/renderer/rejections.js +31 -0
  44. package/lib/renderer/rewrite-worker.js +22 -0
  45. package/lib/renderer/script-rewrite.js +805 -57
  46. package/lib/renderer/trusted-types.js +129 -0
  47. package/lib/renderer/video-presentation.js +126 -0
  48. package/lib/renderer/workers.js +327 -0
  49. package/lib/screenshot-cli.js +2 -0
  50. package/lib/style/computed-style.js +186 -29
  51. package/lib/user-agent.js +14 -2
  52. package/package.json +4 -1
  53. package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
  54. package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
  55. package/patches/happy-dom/README.md +26 -0
  56. package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
  57. package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
  58. package/patches/ses/README.md +4 -0
  59. package/test/canvas-2d.test.js +55 -0
  60. package/test/compositor.test.js +41 -1
  61. package/test/computed-style.test.js +59 -1
  62. package/test/display-list.test.js +88 -1
  63. package/test/download.test.js +43 -0
  64. package/test/fixtures/long-video.mp4 +0 -0
  65. package/test/fixtures/media-sync.js +57 -0
  66. package/test/happy-dom-platform-adapters.test.js +25 -0
  67. package/test/headless-shell.test.js +23 -1
  68. package/test/hit-test.test.js +25 -1
  69. package/test/media-playback-events.test.js +22 -0
  70. package/test/media-source.test.js +156 -0
  71. package/test/media-sync.test.js +12 -0
  72. package/test/mobile.test.js +2 -2
  73. package/test/navigation.test.js +61 -0
  74. package/test/network-service.test.js +14 -0
  75. package/test/page-scripts.test.js +1203 -7
  76. package/test/piano-input.test.js +44 -0
  77. package/test/playback-buffer.test.js +31 -0
  78. package/test/position-audio.test.js +67 -0
  79. package/test/positioning.test.js +95 -1
  80. package/test/pulse-client.test.js +84 -0
  81. package/test/renderer-host.test.js +5 -2
  82. package/test/screenshot-cli.test.js +25 -1
  83. package/test/script-rewrite.test.js +111 -6
  84. package/test/setup.js +5 -0
  85. package/test/text-layout.test.js +62 -0
  86. package/test/user-agent.test.js +60 -4
  87. package/test/video-presentation.test.js +67 -0
  88. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
  89. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
  90. package/vendor/ses/dist/ses.mjs +52 -6
package/lib/log.js CHANGED
@@ -34,9 +34,11 @@ export function installLog(path) {
34
34
  logPath = path;
35
35
  for (const level of ["log", "info", "warn", "error", "debug", "trace"]) {
36
36
  const original = console[level].bind(console);
37
+ // The file first: whoever sees a line on the console (and then stops
38
+ // this process) finds it in the log too.
37
39
  console[level] = (...args) => {
38
- original(...args);
39
40
  writeLog(level, formatWithOptions({ colors: false }, ...args));
41
+ original(...args);
40
42
  };
41
43
  }
42
44
  writeLog("log", `started pid ${process.pid}, bun ${Bun.version}, cwd ${process.cwd()}, args ${JSON.stringify(Bun.argv.slice(2))}`);
@@ -69,13 +71,14 @@ export async function pipeToLog(stream, source, echo) {
69
71
  let pending = "";
70
72
  try {
71
73
  for await (const chunk of stream) {
72
- echo(chunk);
73
74
  pending += decoder.decode(chunk, { stream: true });
74
75
  const end = pending.lastIndexOf("\n");
75
76
  if (end >= 0) {
76
77
  writeLog(source, pending.slice(0, end));
77
78
  pending = pending.slice(end + 1);
78
79
  }
80
+ // Echoed after the complete lines are in the log (see installLog).
81
+ echo(chunk);
79
82
  }
80
83
  } catch {
81
84
  // The child went away mid-read.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A growing media buffer shared by a progressive download and by MSE appends.
3
+ *
4
+ * wasmpeg can only open a decoder on a complete snapshot, so playback keeps
5
+ * this concatenation, reopens when the decoder runs off the end of what it
6
+ * was opened on, and seeks back. `generation` changes on every append and
7
+ * on finish/fail, which is how a decoder knows its snapshot is stale.
8
+ * `waitFor` resolves when `size` reaches the mark or the pipe is finished.
9
+ */
10
+ export function createBytePipe() {
11
+ const chunks = [];
12
+ let size = 0;
13
+ let generation = 0;
14
+ let done = false;
15
+ let error = null;
16
+ /** @type {Array<() => void>} */
17
+ const waiters = [];
18
+
19
+ const wake = () => {
20
+ for (const waiter of waiters.splice(0)) waiter();
21
+ };
22
+
23
+ return {
24
+ append(bytes) {
25
+ if (done || bytes == null) return;
26
+ const chunk = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
27
+ if (!chunk.byteLength) return;
28
+ chunks.push(chunk);
29
+ size += chunk.byteLength;
30
+ generation += 1;
31
+ wake();
32
+ },
33
+ finish() {
34
+ if (done) return;
35
+ done = true;
36
+ generation += 1;
37
+ wake();
38
+ },
39
+ fail(reason) {
40
+ if (done) return;
41
+ error = reason instanceof Error ? reason : new Error(String(reason ?? "media fetch failed"));
42
+ done = true;
43
+ generation += 1;
44
+ wake();
45
+ },
46
+ /** One contiguous snapshot. Collapses chunks so the next call is cheap. */
47
+ bytes() {
48
+ if (chunks.length === 1) return chunks[0];
49
+ const out = new Uint8Array(size);
50
+ let offset = 0;
51
+ for (const chunk of chunks) {
52
+ out.set(chunk, offset);
53
+ offset += chunk.byteLength;
54
+ }
55
+ chunks.length = 0;
56
+ if (out.byteLength) chunks.push(out);
57
+ return out;
58
+ },
59
+ get size() {
60
+ return size;
61
+ },
62
+ get generation() {
63
+ return generation;
64
+ },
65
+ get done() {
66
+ return done;
67
+ },
68
+ get error() {
69
+ return error;
70
+ },
71
+ waitFor(mark) {
72
+ if (size >= mark || done) return Promise.resolve();
73
+ return new Promise((resolve) => {
74
+ const check = () => {
75
+ if (size >= mark || done) resolve();
76
+ else waiters.push(check);
77
+ };
78
+ waiters.push(check);
79
+ });
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,42 @@
1
+ /** Advance a PCM decoder to an exact sample cursor, never playing discarded PCM.
2
+ * Reopened snapshots start at sample zero, including their pending first chunk.
3
+ * The caller holds output requests until this transaction has completed.
4
+ */
5
+ export async function advanceAudio(getSound, seconds, { reopen, needsMore, active = () => true }) {
6
+ while (active()) {
7
+ const sound = getSound();
8
+ const { sampleRate, channels } = sound.decoder;
9
+ const target = Math.max(0, Math.round(seconds * sampleRate));
10
+ if (sound.position === target) return true;
11
+ if (sound.position > target) {
12
+ if (!await reopen()) return false;
13
+ continue;
14
+ }
15
+ // This is also the first chunk of EVERY replacement decoder. Skipping it
16
+ // used to leave the opening sound queued at exact chunk boundaries.
17
+ let samples = sound.pending;
18
+ sound.pending = null;
19
+ if (!samples) {
20
+ try { samples = sound.decoder.nextSamples(); }
21
+ catch (error) {
22
+ if (!needsMore(sound)) throw error;
23
+ }
24
+ }
25
+ if (samples?.length) {
26
+ const take = Math.min(target - sound.position, samples.length / channels);
27
+ sound.position += take;
28
+ if (take * channels < samples.length) sound.pending = samples.subarray(take * channels);
29
+ continue;
30
+ }
31
+ if (!needsMore(sound)) return false;
32
+ const pipe = sound.pipe;
33
+ if (pipe.error) throw pipe.error;
34
+ // Data may already have arrived while this snapshot was being decoded.
35
+ if (pipe.generation === sound.openedGeneration) await pipe.waitFor(pipe.size + 1);
36
+ if (!active()) return false;
37
+ if (pipe.error) throw pipe.error;
38
+ if (pipe.generation === sound.openedGeneration) return false;
39
+ if (!await reopen()) return false;
40
+ }
41
+ return false;
42
+ }
@@ -0,0 +1,29 @@
1
+ // wasmpeg seeks to a keyframe without returning its timestamp. Locate it by
2
+ // comparing tiny decoded probes, then rewind before delivering full frames.
3
+ export function seekVideoKeyframe(decoder, seconds) {
4
+ const seek = time => decoder.seek(Math.max(1, time * 1000));
5
+ const frame = 1 / (decoder.fps || 30);
6
+ if (!(seconds >= frame)) { seek(0); return 0; }
7
+ const probe = time => {
8
+ seek(time);
9
+ const pixels = decoder.nextFrame(32, 18);
10
+ if (!pixels) return null;
11
+ let hash = 0x811c9dc5;
12
+ for (let i = 0; i < pixels.length; i += 3) hash = Math.imul(hash ^ pixels[i], 0x01000193);
13
+ return hash >>> 0;
14
+ };
15
+ const target = probe(seconds);
16
+ let keyframe = seconds;
17
+ if (target !== null) {
18
+ let earlier = Math.max(0, seconds - 10);
19
+ if (earlier > 0 && probe(earlier) !== target) {
20
+ while (keyframe - earlier > frame) {
21
+ const middle = (earlier + keyframe) / 2;
22
+ if (probe(middle) === target) keyframe = middle;
23
+ else earlier = middle;
24
+ }
25
+ } else keyframe = earlier;
26
+ }
27
+ seek(keyframe);
28
+ return keyframe;
29
+ }
@@ -34,7 +34,7 @@ const SAFELISTED_RESPONSE_HEADERS = new Set([
34
34
  const MAX_REQUESTS_PER_HOST = 6;
35
35
  // Which waiting request to a busy host goes next (lower first): documents,
36
36
  // then what blocks rendering or that a script waits for, then the rest.
37
- const PRIORITY = { document: 0, stylesheet: 1, fetch: 1, script: 2, metadata: 2, image: 3, video: 3, audio: 3 };
37
+ const PRIORITY = { document: 0, iframe: 1, stylesheet: 1, fetch: 1, script: 2, metadata: 2, image: 3, video: 3, audio: 3 };
38
38
 
39
39
  export class NetworkService {
40
40
  #fetch;
@@ -73,8 +73,31 @@ export class NetworkService {
73
73
  }
74
74
 
75
75
  /** A top-level document load. */
76
- async navigate(url, { onResponse } = {}) {
77
- return this.#send({ url, method: "GET", headers: [], body: null }, {
76
+ /**
77
+ * Fetches a document to navigate to. A form POST (`post`) sends its body,
78
+ * with Origin and a Referer trimmed to the origin across origins
79
+ * (strict-origin-when-cross-origin); a redirect after it turns into a GET.
80
+ */
81
+ async navigate(url, { onResponse, post = null } = {}) {
82
+ const headers = [];
83
+ let method = "GET";
84
+ let body = null;
85
+ if (post) {
86
+ method = "POST";
87
+ body = post.body;
88
+ headers.push(["content-type", post.contentType]);
89
+ let from = null;
90
+ try { from = new URL(post.referrer); } catch {}
91
+ if (from && ["http:", "https:"].includes(from.protocol)) {
92
+ const target = new URL(url);
93
+ headers.push(["origin", from.origin]);
94
+ const downgrade = from.protocol === "https:" && target.protocol === "http:";
95
+ if (!downgrade) {
96
+ headers.push(["referer", from.origin === target.origin ? withoutFragment(from.href) : `${from.origin}/`]);
97
+ }
98
+ }
99
+ }
100
+ return this.#send({ url, method, headers, body }, {
78
101
  credentials: "include", site: null, topLevelNavigation: true,
79
102
  }, { onResponse, priority: PRIORITY.document });
80
103
  }
@@ -126,6 +149,60 @@ export class NetworkService {
126
149
  }
127
150
  }
128
151
 
152
+ /**
153
+ * A video or audio subresource whose body is delivered as it arrives.
154
+ * `emit` is called with `{ type: "head", ... }`, then `{ type: "chunk", data }`,
155
+ * then `{ type: "end" }` or `{ type: "error", error }`. Nothing is cached:
156
+ * a growing body is not a reusable response. The host slot stays held until
157
+ * the body has been read, same as a buffered subresource.
158
+ */
159
+ streamSubresource(url, documentUrl, kind, emit) {
160
+ const site = siteOf(documentUrl);
161
+ return this.#send({ url, method: "GET", headers: [], body: null }, {
162
+ credentials: "include", site,
163
+ }, {
164
+ priority: PRIORITY[kind] ?? PRIORITY.metadata,
165
+ onResponse: async (response, finalUrl) => {
166
+ const contentLength = Number(response.headers.get("content-length"));
167
+ try {
168
+ emit({
169
+ type: "head",
170
+ ok: response.status >= 200 && response.status <= 299,
171
+ status: response.status,
172
+ statusText: response.statusText ?? "",
173
+ url: finalUrl,
174
+ contentType: response.headers.get("content-type") ?? "",
175
+ contentLength: Number.isFinite(contentLength) ? contentLength : null,
176
+ });
177
+ const reader = response.body?.getReader?.();
178
+ if (!reader) {
179
+ emit({ type: "end" });
180
+ return { streamed: true };
181
+ }
182
+ let total = 0;
183
+ for (;;) {
184
+ const { done, value } = await reader.read();
185
+ if (done) break;
186
+ if (!value?.byteLength) continue;
187
+ total += value.byteLength;
188
+ if (total > MAX_BODY_BYTES) {
189
+ emit({ type: "error", error: "Response body too large" });
190
+ await reader.cancel().catch(() => {});
191
+ return { streamed: true };
192
+ }
193
+ const copy = new Uint8Array(value.byteLength);
194
+ copy.set(value);
195
+ emit({ type: "chunk", data: copy });
196
+ }
197
+ emit({ type: "end" });
198
+ } catch (error) {
199
+ emit({ type: "error", error: String(error?.message ?? error) });
200
+ }
201
+ return { streamed: true };
202
+ },
203
+ });
204
+ }
205
+
129
206
  /**
130
207
  * A WebSocket opened by page script, using Bun's WebSocket: the handshake
131
208
  * carries the document's Origin, the target's cookies (as for an https/http
@@ -165,7 +242,7 @@ export class NetworkService {
165
242
  * redirect?: "follow" | "error" | "manual" }} request
166
243
  * @param {string} documentUrl the requesting document (controller's record)
167
244
  */
168
- async pageFetch(request, documentUrl) {
245
+ async pageFetch(request, documentUrl, { signal, onChunk = null } = {}) {
169
246
  const origin = new URL(documentUrl).origin;
170
247
  const url = new URL(request.url);
171
248
  const method = String(request.method ?? "GET").toUpperCase();
@@ -181,26 +258,40 @@ export class NetworkService {
181
258
  if (mode === "no-cors" && !SIMPLE_METHODS.has(method)) throw new TypeError(`${method} is not allowed in no-cors mode`);
182
259
 
183
260
  const sendCredentials = credentials === "include" || (credentials === "same-origin" && sameOrigin);
184
- const context = { credentials: sendCredentials ? "include" : "omit", site: siteOf(documentUrl), origin };
261
+ const context = { credentials: sendCredentials ? "include" : "omit", site: siteOf(documentUrl), origin, referrer: documentUrl };
262
+ const stream = onChunk ? (response, finalUrl, redirected, type, responseHeaders = [...response.headers]) =>
263
+ this.#streamResult(response, finalUrl, redirected, type, responseHeaders, onChunk) : null;
185
264
 
186
265
  if (sameOrigin) {
187
266
  return this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
188
- redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch,
267
+ redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch, signal,
268
+ onResponse: stream ? (response, finalUrl, redirected) => stream(response, finalUrl, redirected, "basic") : null,
189
269
  });
190
270
  }
191
271
  if (mode === "no-cors") {
192
272
  const response = await this.#send({ url: url.href, method, headers: headers.filter(isSafelistedHeader), body: request.body ?? null },
193
- context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch });
273
+ context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch, signal });
194
274
  return { ...response, type: "opaque", status: 0, statusText: "", url: "", headers: [], body: new Uint8Array(0), redirected: false };
195
275
  }
196
276
  // CORS: preflight unless the request is "simple" (Fetch §4.8).
197
277
  const needsPreflight = !SIMPLE_METHODS.has(method) || headers.some((header) => !isSafelistedHeader(header));
198
278
  if (needsPreflight) await this.#preflight(url, method, headers, origin, context);
199
279
  const response = await this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
200
- redirect: request.redirect ?? "follow", corsOrigin: origin, priority: PRIORITY.fetch,
280
+ redirect: request.redirect ?? "follow", corsOrigin: origin, priority: PRIORITY.fetch, signal,
281
+ onResponse: stream ? async (incoming, finalUrl, redirected) => {
282
+ const raw = [...incoming.headers];
283
+ if (!corsAllowed(raw, origin, context.credentials === "include")) return null;
284
+ const exposed = new Set((header(raw, "access-control-expose-headers") ?? "")
285
+ .split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
286
+ const exposeAll = exposed.has("*") && context.credentials !== "include";
287
+ const visible = raw.filter(([name]) => SAFELISTED_RESPONSE_HEADERS.has(name) || exposed.has(name) || exposeAll);
288
+ return stream(incoming, finalUrl, redirected, "cors", visible);
289
+ } : null,
201
290
  });
291
+ if (response.streaming) return response;
202
292
  const allowed = corsAllowed(response.rawHeaders, origin, context.credentials === "include");
203
293
  if (!allowed) throw new TypeError(`CORS: ${url.href} did not allow origin ${origin}`);
294
+ if (response.type === "opaqueredirect") return response;
204
295
  const exposed = new Set((header(response.rawHeaders, "access-control-expose-headers") ?? "")
205
296
  .split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
206
297
  const exposeAll = exposed.has("*") && context.credentials !== "include";
@@ -255,14 +346,30 @@ export class NetworkService {
255
346
  * Sends a request, following redirects by hand so cookies apply to every hop.
256
347
  * Each hop holds one of its host's request slots until its body is read.
257
348
  */
258
- async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata } = {}) {
349
+ async #send(request, context, {
350
+ redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata, signal = undefined,
351
+ } = {}) {
259
352
  let { url, method, body } = request;
260
353
  const baseHeaders = request.headers;
261
354
  let redirected = false;
262
355
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
263
356
  const headers = new Headers(baseHeaders);
264
- if (this.#userAgent) headers.set("user-agent", this.#userAgent);
357
+ if (this.#userAgent) {
358
+ headers.set("user-agent", this.#userAgent);
359
+ const chrome = /(?:Chrome|Chromium)\/(\d+)/.exec(this.#userAgent)?.[1];
360
+ if (chrome) {
361
+ headers.set("sec-ch-ua", `"Not:A-Brand";v="99", "Chromium";v="${chrome}", "Google Chrome";v="${chrome}"`);
362
+ headers.set("sec-ch-ua-mobile", /\bMobile\b/.test(this.#userAgent) ? "?1" : "?0");
363
+ headers.set("sec-ch-ua-platform", `"${/Android/.test(this.#userAgent) ? "Android" : /Windows/.test(this.#userAgent) ? "Windows" : /Macintosh/.test(this.#userAgent) ? "macOS" : "Linux"}"`);
364
+ }
365
+ }
265
366
  if (corsOrigin) headers.set("origin", corsOrigin);
367
+ if (context.referrer && !headers.has("referer")) {
368
+ const from = new URL(context.referrer);
369
+ const target = new URL(url);
370
+ const downgrade = from.protocol === "https:" && target.protocol === "http:";
371
+ if (!downgrade) headers.set("referer", from.origin === target.origin ? withoutFragment(from.href) : `${from.origin}/`);
372
+ }
266
373
  if (context.credentials === "include") {
267
374
  const cookie = this.#cookies.header(url, {
268
375
  site: context.site, topLevelNavigation: Boolean(context.topLevelNavigation), method,
@@ -271,13 +378,13 @@ export class NetworkService {
271
378
  }
272
379
  const release = await this.#hosts.acquire(url, priority);
273
380
  try {
274
- const response = await this.#request(url, { method, headers, body, redirect: "manual" });
381
+ const response = await this.#request(url, { method, headers, body, redirect: "manual", signal });
275
382
  if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
276
383
  const location = response.headers.get("location");
277
384
  if ([301, 302, 303, 307, 308].includes(response.status) && location) {
278
385
  await response.arrayBuffer().catch(() => {});
279
386
  if (redirect === "error") throw new TypeError("Redirect was not allowed");
280
- if (redirect === "manual") return await this.#result(response, url, redirected, "opaqueredirect");
387
+ if (redirect === "manual") return opaqueRedirect(response);
281
388
  const next = new URL(location, url);
282
389
  if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
283
390
  if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
@@ -289,7 +396,7 @@ export class NetworkService {
289
396
  continue;
290
397
  }
291
398
  if (onResponse) {
292
- const handled = await onResponse(response, url);
399
+ const handled = await onResponse(response, url, redirected);
293
400
  if (handled) return handled;
294
401
  }
295
402
  return await this.#result(response, url, redirected, "basic");
@@ -318,6 +425,47 @@ export class NetworkService {
318
425
  body: buffer,
319
426
  };
320
427
  }
428
+
429
+ async #streamResult(response, url, redirected, type, visibleHeaders, onChunk) {
430
+ const rawHeaders = [...response.headers];
431
+ const result = {
432
+ ok: response.status >= 200 && response.status <= 299,
433
+ status: response.status, statusText: response.statusText, url, redirected, type,
434
+ headers: visibleHeaders.filter(([name]) => name !== "set-cookie" && name !== "set-cookie2"),
435
+ contentType: response.headers.get("content-type") ?? "",
436
+ };
437
+ onChunk({ type: "head", result });
438
+ const reader = response.body?.getReader?.();
439
+ if (reader) {
440
+ while (true) {
441
+ const { done, value } = await reader.read();
442
+ if (done) break;
443
+ if (value?.byteLength) onChunk({ type: "chunk", data: new Uint8Array(value) });
444
+ }
445
+ }
446
+ onChunk({ type: "end" });
447
+ return { ...result, rawHeaders, body: new Uint8Array(0), streaming: true };
448
+ }
449
+ }
450
+
451
+ /**
452
+ * The opaque-redirect filtered response a `redirect: "manual"` fetch gets
453
+ * (Fetch §2.2.6): status 0 and no headers or body for the page. The redirect's
454
+ * body was already drained to free the connection; the raw headers stay for
455
+ * the CORS check.
456
+ */
457
+ function opaqueRedirect(response) {
458
+ return {
459
+ ok: false, status: 0, statusText: "", url: "", redirected: false, type: "opaqueredirect",
460
+ rawHeaders: [...response.headers], headers: [], contentType: "", body: new Uint8Array(0),
461
+ };
462
+ }
463
+
464
+ /** A URL without its fragment, as a Referer carries it. */
465
+ function withoutFragment(href) {
466
+ const url = new URL(href);
467
+ url.hash = "";
468
+ return url.href;
321
469
  }
322
470
 
323
471
  function isSafelistedHeader([name, value]) {
@@ -402,4 +550,3 @@ class HostThrottle {
402
550
  };
403
551
  }
404
552
  }
405
-