buninu-browser 0.2.3 → 0.2.4

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 (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
@@ -16,9 +16,12 @@ import { fetchResolved, HostResolver } from "./resolver.js";
16
16
  const MAX_REDIRECTS = 20;
17
17
  const MAX_BODY_BYTES = 64 * 1024 * 1024;
18
18
  const MAX_CACHED_SUBRESOURCES = 256;
19
+ const MAX_CACHED_SUBRESOURCE_BYTES = 64 * 1024 * 1024;
20
+ const MAX_CACHED_PREFLIGHTS = 256;
21
+ const MAX_PREFLIGHT_AGE = 7200;
19
22
  const SIMPLE_METHODS = new Set(["GET", "HEAD", "POST"]);
20
23
  const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
21
- // Fetch §2.2.2 CORS-safelisted request headers (value checks simplified).
24
+ // Fetch §2.2.2 CORS-safelisted request headers.
22
25
  const SAFELISTED_REQUEST_HEADERS = new Set(["accept", "accept-language", "content-language", "content-type", "range"]);
23
26
  const SIMPLE_CONTENT_TYPES = new Set(["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"]);
24
27
  // Headers a page may not set (Fetch §2.2.2 forbidden request headers).
@@ -45,7 +48,10 @@ export class NetworkService {
45
48
  // credentials that affect a subresource response; concurrent identical
46
49
  // requests share one transfer.
47
50
  #subresourceCache = new Map();
51
+ #subresourceCacheBytes = 0;
52
+ #maxSubresourceCacheBytes;
48
53
  #subresourceInflight = new Map();
54
+ #preflightCache = new Map();
49
55
  #hosts = new HostThrottle(MAX_REQUESTS_PER_HOST);
50
56
 
51
57
  /**
@@ -55,7 +61,9 @@ export class NetworkService {
55
61
  constructor({
56
62
  fetch: fetchImpl = fetch, cookies = new CookieJar(), userAgent = null,
57
63
  resolver = fetchImpl === globalThis.fetch ? new HostResolver() : null,
64
+ maxSubresourceCacheBytes = MAX_CACHED_SUBRESOURCE_BYTES,
58
65
  } = {}) {
66
+ this.#maxSubresourceCacheBytes = Math.max(0, maxSubresourceCacheBytes);
59
67
  this.#fetch = fetchImpl;
60
68
  this.#resolver = resolver;
61
69
  this.#cookies = cookies;
@@ -68,7 +76,7 @@ export class NetworkService {
68
76
 
69
77
  set userAgent(value) {
70
78
  const next = typeof value === "string" && value ? value : null;
71
- if (next !== this.#userAgent) this.#subresourceCache.clear();
79
+ if (next !== this.#userAgent) { this.#subresourceCache.clear(); this.#subresourceCacheBytes = 0; }
72
80
  this.#userAgent = next;
73
81
  }
74
82
 
@@ -78,7 +86,7 @@ export class NetworkService {
78
86
  * with Origin and a Referer trimmed to the origin across origins
79
87
  * (strict-origin-when-cross-origin); a redirect after it turns into a GET.
80
88
  */
81
- async navigate(url, { onResponse, post = null } = {}) {
89
+ async navigate(url, { onResponse, post = null, referrer = post?.referrer ?? null } = {}) {
82
90
  const headers = [];
83
91
  let method = "GET";
84
92
  let body = null;
@@ -87,18 +95,14 @@ export class NetworkService {
87
95
  body = post.body;
88
96
  headers.push(["content-type", post.contentType]);
89
97
  let from = null;
90
- try { from = new URL(post.referrer); } catch {}
98
+ try { from = new URL(referrer); } catch {}
91
99
  if (from && ["http:", "https:"].includes(from.protocol)) {
92
- const target = new URL(url);
93
100
  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
101
  }
99
102
  }
100
103
  return this.#send({ url, method, headers, body }, {
101
- credentials: "include", site: null, topLevelNavigation: true,
104
+ credentials: "include", site: referrer ? siteOf(referrer) : null, topLevelNavigation: true,
105
+ referrer, fetchMode: "navigate", fetchDest: "document", directNavigation: !referrer,
102
106
  }, { onResponse, priority: PRIORITY.document });
103
107
  }
104
108
 
@@ -106,19 +110,30 @@ export class NetworkService {
106
110
  * A subresource the renderer needs to render (no-cors, credentials included).
107
111
  * `kind` ("stylesheet", "script", "image"...) decides its turn at a busy host.
108
112
  */
109
- async subresource(url, documentUrl, kind = "metadata") {
113
+ async subresource(url, documentUrl, kind = "metadata", { signal } = {}) {
114
+ signal?.throwIfAborted();
110
115
  const site = siteOf(documentUrl);
111
116
  const cookie = this.#cookies.header(url, { site });
112
- const key = `${url}\n${site ?? ""}\n${cookie}\n${this.#userAgent ?? ""}`;
113
- const cached = this.#subresourceCache.get(key);
114
- if (cached && cached.expires > Date.now()) return cached.result;
115
- const pending = this.#subresourceInflight.get(key);
116
- if (pending) return pending;
117
+ const metadata = subresourceMetadata(kind);
118
+ const key = JSON.stringify([url, new URL(documentUrl).origin, cookie, this.#userAgent, metadata.fetchMode, metadata.fetchDest]);
119
+ const entry = this.#subresourceCache.get(key);
120
+ const cached = entry && (!entry.varyReferer || entry.referrer === documentUrl) ? entry : null;
121
+ if (cached && cached.expires > Date.now()) {
122
+ this.#subresourceCache.delete(key); this.#subresourceCache.set(key, cached);
123
+ return cached.result;
124
+ }
125
+ // Concurrent transfers must have identical referrers, even before the
126
+ // response tells us whether its representation varies with that header.
127
+ const inflightKey = JSON.stringify([key, documentUrl]);
128
+ const pending = this.#subresourceInflight.get(inflightKey);
129
+ if (pending && !pending.controller.signal.aborted) return shareResource(pending, signal);
130
+ const transfer = { controller: new AbortController(), users: 0, done: false, promise: null };
117
131
  const request = (async () => {
118
- const headers = cached?.etag ? [["if-none-match", cached.etag]] : [];
132
+ const headers = cached?.etag ? [["if-none-match", cached.etag]]
133
+ : cached?.lastModified ? [["if-modified-since", cached.lastModified]] : [];
119
134
  let result = await this.#send({ url, method: "GET", headers, body: null }, {
120
- credentials: "include", site,
121
- }, { priority: PRIORITY[kind] ?? PRIORITY.metadata });
135
+ credentials: "include", site, referrer: documentUrl, ...metadata,
136
+ }, { priority: PRIORITY[kind] ?? PRIORITY.metadata, signal: transfer.controller.signal });
122
137
  if (result.status === 304 && cached) {
123
138
  const rawHeaders = [...new Map([...cached.result.rawHeaders, ...result.rawHeaders])];
124
139
  result = {
@@ -126,27 +141,38 @@ export class NetworkService {
126
141
  rawHeaders,
127
142
  headers: [...new Map([...cached.result.headers, ...result.headers])],
128
143
  };
129
- const policy = cachePolicy(rawHeaders);
130
- cached.expires = policy.expires;
131
- return result;
132
144
  }
133
145
  const policy = cachePolicy(result.rawHeaders);
134
- if (result.ok && policy.cacheable) {
135
- if (!this.#subresourceCache.has(key) && this.#subresourceCache.size >= MAX_CACHED_SUBRESOURCES) {
136
- this.#subresourceCache.delete(this.#subresourceCache.keys().next().value);
146
+ this.#forgetSubresource(key);
147
+ const bytes = result.body.byteLength;
148
+ // A final response cannot establish freshness for redirects that led
149
+ // to it. Their policies and cookies may differ from the initial URL.
150
+ // Keep coalescing transfers, but cache direct responses until each
151
+ // redirect hop has its own HTTP cache entry.
152
+ if (result.ok && !result.redirected && policy.cacheable && bytes <= this.#maxSubresourceCacheBytes) {
153
+ while (this.#subresourceCache.size && (this.#subresourceCache.size >= MAX_CACHED_SUBRESOURCES
154
+ || this.#subresourceCacheBytes + bytes > this.#maxSubresourceCacheBytes)) {
155
+ this.#forgetSubresource(this.#subresourceCache.keys().next().value);
137
156
  }
138
- this.#subresourceCache.set(key, { result, expires: policy.expires, etag: policy.etag });
139
- } else {
140
- this.#subresourceCache.delete(key);
157
+ this.#subresourceCache.set(key, { result, ...policy, bytes, referrer: documentUrl });
158
+ this.#subresourceCacheBytes += bytes;
141
159
  }
142
160
  return result;
143
161
  })();
144
- this.#subresourceInflight.set(key, request);
145
- try {
146
- return await request;
147
- } finally {
148
- if (this.#subresourceInflight.get(key) === request) this.#subresourceInflight.delete(key);
149
- }
162
+ transfer.promise = request;
163
+ this.#subresourceInflight.set(inflightKey, transfer);
164
+ const finish = () => {
165
+ transfer.done = true;
166
+ if (this.#subresourceInflight.get(inflightKey) === transfer) this.#subresourceInflight.delete(inflightKey);
167
+ };
168
+ request.then(finish, finish);
169
+ return shareResource(transfer, signal);
170
+ }
171
+
172
+ #forgetSubresource(key) {
173
+ const cached = this.#subresourceCache.get(key);
174
+ if (cached) this.#subresourceCacheBytes -= cached.bytes;
175
+ this.#subresourceCache.delete(key);
150
176
  }
151
177
 
152
178
  /**
@@ -156,12 +182,13 @@ export class NetworkService {
156
182
  * a growing body is not a reusable response. The host slot stays held until
157
183
  * the body has been read, same as a buffered subresource.
158
184
  */
159
- streamSubresource(url, documentUrl, kind, emit) {
185
+ streamSubresource(url, documentUrl, kind, emit, { signal } = {}) {
160
186
  const site = siteOf(documentUrl);
161
187
  return this.#send({ url, method: "GET", headers: [], body: null }, {
162
- credentials: "include", site,
188
+ credentials: "include", site, referrer: documentUrl, ...subresourceMetadata(kind),
163
189
  }, {
164
190
  priority: PRIORITY[kind] ?? PRIORITY.metadata,
191
+ signal,
165
192
  onResponse: async (response, finalUrl) => {
166
193
  const contentLength = Number(response.headers.get("content-length"));
167
194
  try {
@@ -190,9 +217,7 @@ export class NetworkService {
190
217
  await reader.cancel().catch(() => {});
191
218
  return { streamed: true };
192
219
  }
193
- const copy = new Uint8Array(value.byteLength);
194
- copy.set(value);
195
- emit({ type: "chunk", data: copy });
220
+ emit({ type: "chunk", data: value });
196
221
  }
197
222
  emit({ type: "end" });
198
223
  } catch (error) {
@@ -248,6 +273,8 @@ export class NetworkService {
248
273
  const method = String(request.method ?? "GET").toUpperCase();
249
274
  const mode = request.mode ?? "cors";
250
275
  const credentials = request.credentials ?? "same-origin";
276
+ if (!["cors", "no-cors", "same-origin"].includes(mode)) throw new TypeError(`Invalid request mode ${mode}`);
277
+ if (!["omit", "same-origin", "include"].includes(credentials)) throw new TypeError(`Invalid credentials mode ${credentials}`);
251
278
  if (FORBIDDEN_METHODS.has(method)) throw new TypeError(`Forbidden method ${method}`);
252
279
  if (!["http:", "https:"].includes(url.protocol)) throw new TypeError(`Fetch API cannot load ${url.protocol} URLs`);
253
280
  const headers = (request.headers ?? [])
@@ -257,70 +284,52 @@ export class NetworkService {
257
284
  if (mode === "same-origin" && !sameOrigin) throw new TypeError("Request was blocked: mode is same-origin");
258
285
  if (mode === "no-cors" && !SIMPLE_METHODS.has(method)) throw new TypeError(`${method} is not allowed in no-cors mode`);
259
286
 
260
- const sendCredentials = credentials === "include" || (credentials === "same-origin" && sameOrigin);
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;
264
-
265
- if (sameOrigin) {
266
- return this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
267
- redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch, signal,
268
- onResponse: stream ? (response, finalUrl, redirected) => stream(response, finalUrl, redirected, "basic") : null,
269
- });
270
- }
271
- if (mode === "no-cors") {
272
- const response = await this.#send({ url: url.href, method, headers: headers.filter(isSafelistedHeader), body: request.body ?? null },
273
- context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch, signal });
274
- return { ...response, type: "opaque", status: 0, statusText: "", url: "", headers: [], body: new Uint8Array(0), redirected: false };
275
- }
276
- // CORS: preflight unless the request is "simple" (Fetch §4.8).
277
- const needsPreflight = !SIMPLE_METHODS.has(method) || headers.some((header) => !isSafelistedHeader(header));
278
- if (needsPreflight) await this.#preflight(url, method, headers, origin, context);
279
- const response = await this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
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,
287
+ const context = { pageRequest: true, credentials, site: siteOf(documentUrl), origin,
288
+ referrer: documentUrl, fetchMode: mode, fetchDest: "empty" };
289
+ return this.#send({ url: url.href, method,
290
+ headers: mode === "no-cors" ? headers.filter(entry => entry[0] !== "range" && isSafelistedHeader(entry)) : headers,
291
+ body: request.body ?? null }, context, {
292
+ redirect: request.redirect ?? "follow", priority: PRIORITY.fetch, signal,
293
+ onResponse: onChunk ? (response, finalUrl, redirected, type, visibleHeaders) =>
294
+ this.#streamResult(response, finalUrl, redirected, type, visibleHeaders, onChunk) : null,
290
295
  });
291
- if (response.streaming) return response;
292
- const allowed = corsAllowed(response.rawHeaders, origin, context.credentials === "include");
293
- if (!allowed) throw new TypeError(`CORS: ${url.href} did not allow origin ${origin}`);
294
- if (response.type === "opaqueredirect") return response;
295
- const exposed = new Set((header(response.rawHeaders, "access-control-expose-headers") ?? "")
296
- .split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
297
- const exposeAll = exposed.has("*") && context.credentials !== "include";
298
- return {
299
- ...response,
300
- type: "cors",
301
- headers: response.headers.filter(([name]) => SAFELISTED_RESPONSE_HEADERS.has(name) || exposed.has(name) || exposeAll),
302
- };
303
296
  }
304
297
 
305
- async #preflight(url, method, headers, origin, context) {
306
- const requestHeaders = headers.filter((entry) => !isSafelistedHeader(entry)).map(([name]) => name).sort();
307
- const release = await this.#hosts.acquire(url.href, PRIORITY.fetch);
298
+ async #preflight(url, method, headers, origin, context, signal) {
299
+ signal?.throwIfAborted();
300
+ const requestHeaders = [...new Set(headers.filter((entry) => !isSafelistedHeader(entry)).map(([name]) => name))].sort();
301
+ // Cache only the checked permission tuple, never the response body. Exact
302
+ // tuples keep credentials, origins, URLs and header-name sets isolated;
303
+ // header values do not change the server's permission to send that name.
304
+ // Opaque origins serialize to the same string, so do not share them here.
305
+ const cacheKey = origin === "null" ? null
306
+ : JSON.stringify([context.site, origin, withoutFragment(url.href), context.credentials, method, requestHeaders]);
307
+ if (cacheKey && (this.#preflightCache.get(cacheKey) ?? 0) > Date.now()) {
308
+ const expires = this.#preflightCache.get(cacheKey);
309
+ this.#preflightCache.delete(cacheKey); this.#preflightCache.set(cacheKey, expires);
310
+ return;
311
+ }
312
+ this.#preflightCache.delete(cacheKey);
313
+ const release = await this.#hosts.acquire(url.href, PRIORITY.fetch, signal);
308
314
  let response;
309
315
  try {
316
+ const preflightHeaders = new Headers({ origin, "access-control-request-method": method,
317
+ ...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
318
+ ...(this.#userAgent ? { "user-agent": this.#userAgent } : {}) });
319
+ setFetchMetadata(preflightHeaders, url.href, { fetchMode: "cors", fetchDest: "empty" },
320
+ fetchMetadataSite(context.referrer, url.href, "same-origin"));
310
321
  response = await this.#request(url.href, {
311
322
  method: "OPTIONS",
312
323
  redirect: "manual",
313
- headers: {
314
- origin,
315
- "access-control-request-method": method,
316
- ...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
317
- ...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
318
- },
324
+ signal,
325
+ headers: preflightHeaders,
319
326
  });
320
327
  await response.arrayBuffer().catch(() => {});
321
328
  } finally {
322
329
  release();
323
330
  }
331
+ // A cancelled body read must not turn into a reusable permission grant.
332
+ signal?.throwIfAborted();
324
333
  const raw = [...response.headers];
325
334
  if (response.status < 200 || response.status > 299 || !corsAllowed(raw, origin, context.credentials === "include")) {
326
335
  throw new TypeError(`CORS preflight for ${url.href} was rejected`);
@@ -332,10 +341,18 @@ export class NetworkService {
332
341
  throw new TypeError(`CORS preflight for ${url.href} does not allow method ${method}`);
333
342
  }
334
343
  for (const name of requestHeaders) {
335
- if (!allowHeaders.includes(name) && !(wildcard && allowHeaders.includes("*"))) {
344
+ // Authorization is a CORS non-wildcard request-header name.
345
+ if (!allowHeaders.includes(name) && !(wildcard && name !== "authorization" && allowHeaders.includes("*"))) {
336
346
  throw new TypeError(`CORS preflight for ${url.href} does not allow header ${name}`);
337
347
  }
338
348
  }
349
+ const value = header(raw, "access-control-max-age")?.trim();
350
+ const maxAge = value !== undefined && value !== null && /^-?\d+$/.test(value)
351
+ ? Math.max(0, Math.min(Number(value), MAX_PREFLIGHT_AGE)) : 5;
352
+ if (cacheKey && maxAge > 0) {
353
+ if (this.#preflightCache.size >= MAX_CACHED_PREFLIGHTS) this.#preflightCache.delete(this.#preflightCache.keys().next().value);
354
+ this.#preflightCache.set(cacheKey, Date.now() + maxAge * 1000);
355
+ }
339
356
  }
340
357
 
341
358
  #request(url, init) {
@@ -347,13 +364,29 @@ export class NetworkService {
347
364
  * Each hop holds one of its host's request slots until its body is read.
348
365
  */
349
366
  async #send(request, context, {
350
- redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata, signal = undefined,
367
+ redirect = "follow", onResponse = null, priority = PRIORITY.metadata, signal = undefined,
351
368
  } = {}) {
352
369
  let { url, method, body } = request;
353
- const baseHeaders = request.headers;
370
+ const baseHeaders = new Headers(request.headers);
354
371
  let redirected = false;
372
+ let requestReferrer = context.referrer ?? "";
373
+ let requestOrigin = context.origin;
374
+ let responseType = "basic";
375
+ let metadataSite = context.directNavigation ? "none" : "same-origin";
355
376
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
377
+ const target = new URL(url);
378
+ const crossOrigin = context.pageRequest && (requestOrigin !== target.origin || responseType !== "basic");
379
+ if (crossOrigin && context.fetchMode === "same-origin") throw new TypeError("Request was blocked: mode is same-origin");
380
+ if (crossOrigin) responseType = context.fetchMode === "no-cors" ? "opaque" : "cors";
381
+ const withCredentials = context.credentials === "include"
382
+ || (context.pageRequest && context.credentials === "same-origin" && responseType === "basic");
383
+ if (responseType === "cors" && (!SIMPLE_METHODS.has(method) || [...baseHeaders].some(entry => !isSafelistedHeader(entry)))) {
384
+ await this.#preflight(target, method, [...baseHeaders], requestOrigin,
385
+ { ...context, credentials: withCredentials ? "include" : "omit" }, signal);
386
+ }
356
387
  const headers = new Headers(baseHeaders);
388
+ metadataSite = fetchMetadataSite(context.referrer, url, metadataSite);
389
+ setFetchMetadata(headers, url, context, metadataSite);
357
390
  if (this.#userAgent) {
358
391
  headers.set("user-agent", this.#userAgent);
359
392
  const chrome = /(?:Chrome|Chromium)\/(\d+)/.exec(this.#userAgent)?.[1];
@@ -363,23 +396,28 @@ export class NetworkService {
363
396
  headers.set("sec-ch-ua-platform", `"${/Android/.test(this.#userAgent) ? "Android" : /Windows/.test(this.#userAgent) ? "Windows" : /Macintosh/.test(this.#userAgent) ? "macOS" : "Linux"}"`);
364
397
  }
365
398
  }
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}/`);
399
+ if (responseType === "cors" || (context.pageRequest && !["GET", "HEAD"].includes(method))) {
400
+ headers.set("origin", requestOrigin);
401
+ }
402
+ if (!headers.has("referer")) {
403
+ requestReferrer = defaultReferrer(requestReferrer, url);
404
+ if (requestReferrer) headers.set("referer", requestReferrer);
372
405
  }
373
- if (context.credentials === "include") {
406
+ if (withCredentials) {
374
407
  const cookie = this.#cookies.header(url, {
375
408
  site: context.site, topLevelNavigation: Boolean(context.topLevelNavigation), method,
376
409
  });
377
410
  if (cookie) headers.set("cookie", cookie);
378
411
  }
379
- const release = await this.#hosts.acquire(url, priority);
412
+ const release = await this.#hosts.acquire(url, priority, signal);
380
413
  try {
381
414
  const response = await this.#request(url, { method, headers, body, redirect: "manual", signal });
382
- if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
415
+ if (withCredentials) this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
416
+ const rawHeaders = [...response.headers];
417
+ if (responseType === "cors" && !corsAllowed(rawHeaders, requestOrigin, withCredentials)) {
418
+ await response.body?.cancel().catch(() => {});
419
+ throw new TypeError(`CORS: ${url} did not allow origin ${requestOrigin}`);
420
+ }
383
421
  const location = response.headers.get("location");
384
422
  if ([301, 302, 303, 307, 308].includes(response.status) && location) {
385
423
  await response.arrayBuffer().catch(() => {});
@@ -387,19 +425,33 @@ export class NetworkService {
387
425
  if (redirect === "manual") return opaqueRedirect(response);
388
426
  const next = new URL(location, url);
389
427
  if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
390
- if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
428
+ if (context.pageRequest && target.origin !== next.origin) {
429
+ baseHeaders.delete("authorization");
430
+ if (requestOrigin !== target.origin) requestOrigin = "null";
431
+ }
432
+ if ((response.status === 303 && method !== "GET" && method !== "HEAD")
433
+ || ((response.status === 301 || response.status === 302) && method === "POST")) {
391
434
  method = method === "HEAD" ? "HEAD" : "GET";
392
435
  body = null;
436
+ for (const name of ["content-encoding", "content-language", "content-location", "content-type"]) baseHeaders.delete(name);
393
437
  }
394
438
  url = next.href;
395
439
  redirected = true;
396
440
  continue;
397
441
  }
442
+ if (responseType === "opaque") {
443
+ await response.body?.cancel().catch(() => {});
444
+ return { ok: false, type: "opaque", status: 0, statusText: "", url: "", headers: [], rawHeaders,
445
+ contentType: "", body: new Uint8Array(0), redirected: false };
446
+ }
447
+ const visibleHeaders = responseType === "cors" ? corsResponseHeaders(rawHeaders, withCredentials) : rawHeaders;
398
448
  if (onResponse) {
399
- const handled = await onResponse(response, url, redirected);
449
+ const handled = await onResponse(response, url, redirected, responseType, visibleHeaders);
400
450
  if (handled) return handled;
401
451
  }
402
- return await this.#result(response, url, redirected, "basic");
452
+ const result = await this.#result(response, url, redirected, responseType);
453
+ if (responseType === "cors") result.headers = corsResponseHeaders(result.headers, withCredentials, rawHeaders);
454
+ return context.topLevelNavigation ? { ...result, referrer: requestReferrer } : result;
403
455
  } finally {
404
456
  release();
405
457
  }
@@ -440,7 +492,7 @@ export class NetworkService {
440
492
  while (true) {
441
493
  const { done, value } = await reader.read();
442
494
  if (done) break;
443
- if (value?.byteLength) onChunk({ type: "chunk", data: new Uint8Array(value) });
495
+ if (value?.byteLength) onChunk({ type: "chunk", data: value });
444
496
  }
445
497
  }
446
498
  onChunk({ type: "end" });
@@ -468,10 +520,61 @@ function withoutFragment(href) {
468
520
  return url.href;
469
521
  }
470
522
 
523
+ function subresourceMetadata(kind) {
524
+ return { fetchMode: kind === "iframe" ? "navigate" : "no-cors",
525
+ fetchDest: ({ stylesheet: "style", script: "script", image: "image", video: "video", audio: "audio", iframe: "iframe" })[kind] ?? "empty" };
526
+ }
527
+
528
+ // Fetch Metadata belongs to the trusted network process. Page-supplied sec-*
529
+ // headers are filtered above. Do not claim user activation without tracking it.
530
+ function setFetchMetadata(headers, href, context, site) {
531
+ const url = new URL(href);
532
+ if (!trustworthyHttpUrl(url)) return;
533
+ headers.set("sec-fetch-mode", context.fetchMode ?? "cors");
534
+ headers.set("sec-fetch-dest", context.fetchDest ?? "empty");
535
+ if (site) headers.set("sec-fetch-site", site);
536
+ }
537
+
538
+ function trustworthyHttpUrl(url) {
539
+ const local = url.hostname === "localhost" || url.hostname.endsWith(".localhost")
540
+ || url.hostname === "[::1]" || /^127\./.test(url.hostname);
541
+ return url.protocol === "https:" || (url.protocol === "http:" && local);
542
+ }
543
+
544
+ /** The default strict-origin-when-cross-origin referrer policy. */
545
+ function defaultReferrer(referrer, href) {
546
+ if (!referrer) return "";
547
+ const from = new URL(referrer), target = new URL(href);
548
+ if (!["http:", "https:"].includes(from.protocol)) return "";
549
+ if (trustworthyHttpUrl(from) && !trustworthyHttpUrl(target)) return "";
550
+ from.username = ""; from.password = ""; from.hash = "";
551
+ return from.origin === target.origin && from.href.length <= 4096 ? from.href : `${from.origin}/`;
552
+ }
553
+
554
+ function fetchMetadataSite(referrer, href, previous) {
555
+ if (previous === "none" || previous === "cross-site") return previous;
556
+ if (!referrer) return "cross-site";
557
+ const from = new URL(referrer), target = new URL(href);
558
+ if (from.origin === "null" || from.protocol !== target.protocol) return "cross-site";
559
+ if (previous === null) return null;
560
+ if (from.origin === target.origin) return previous;
561
+ if (from.hostname === target.hostname) return "same-site";
562
+ // Different hosts require a public-suffix list to distinguish same-site
563
+ // from cross-site. Omit this optional header for that redirect chain until
564
+ // we have one; guessing from the last two labels falsely trusts e.g. co.uk.
565
+ return null;
566
+ }
567
+
471
568
  function isSafelistedHeader([name, value]) {
472
- if (!SAFELISTED_REQUEST_HEADERS.has(name)) return false;
473
- if (name === "content-type") return SIMPLE_CONTENT_TYPES.has(value.split(";")[0].trim().toLowerCase());
474
- return value.length <= 128;
569
+ if (!SAFELISTED_REQUEST_HEADERS.has(name) || value.length > 128) return false;
570
+ if (name === "accept-language" || name === "content-language") return /^[0-9A-Za-z *,\-.;=]*$/.test(value);
571
+ if (name === "range") {
572
+ const range = /^bytes=(\d+)-(\d*)$/.exec(value);
573
+ return !!range && (!range[2] || BigInt(range[1]) <= BigInt(range[2]));
574
+ }
575
+ // CORS-unsafe request-header bytes, excluding HT (0x09).
576
+ if (/[\x00-\x08\x0a-\x1f\x7f"():<>?@\[\\\]{}]/.test(value)) return false;
577
+ return name !== "content-type" || SIMPLE_CONTENT_TYPES.has(value.split(";")[0].trim().toLowerCase());
475
578
  }
476
579
 
477
580
  function header(rawHeaders, name) {
@@ -481,15 +584,22 @@ function header(rawHeaders, name) {
481
584
  /** Freshness and validator data used by the in-process HTTP cache. */
482
585
  function cachePolicy(rawHeaders, now = Date.now()) {
483
586
  const control = (header(rawHeaders, "cache-control") ?? "").toLowerCase();
484
- if (/(?:^|,)\s*no-store(?:\s|,|$)/.test(control)) return { cacheable: false, expires: 0, etag: null };
485
- const age = control.match(/(?:^|,)\s*max-age\s*=\s*(\d+)/)?.[1];
486
- const expiresHeader = header(rawHeaders, "expires");
487
- const expires = age !== undefined
488
- ? now + Number(age) * 1000
489
- : expiresHeader && Number.isFinite(Date.parse(expiresHeader)) ? Date.parse(expiresHeader)
490
- : /(?:^|,)\s*immutable(?:\s|,|$)/.test(control) ? now + 365 * 24 * 60 * 60 * 1000 : 0;
587
+ const varies = (header(rawHeaders, "vary") ?? "").toLowerCase().split(",").map(name => name.trim());
588
+ const variesEveryRequest = varies.includes("*");
589
+ if (variesEveryRequest || /(?:^|,)\s*no-store(?:\s|,|$)/.test(control)) return { cacheable: false };
590
+ const maxAge = control.match(/(?:^|,)\s*max-age\s*=\s*"?(\d+)"?/)?.[1];
591
+ const date = Date.parse(header(rawHeaders, "date") ?? "");
592
+ const age = Math.max(0, Number(header(rawHeaders, "age")) || 0) * 1000;
593
+ const apparentAge = Number.isFinite(date) ? Math.max(0, now - date) : 0;
594
+ const currentAge = Math.max(age, apparentAge);
595
+ const expiresHeader = Date.parse(header(rawHeaders, "expires") ?? "");
596
+ let expires = maxAge !== undefined ? now + Number(maxAge) * 1000 - currentAge
597
+ : Number.isFinite(expiresHeader) ? now + Math.max(0, expiresHeader - (Number.isFinite(date) ? date : now)) - currentAge : 0;
598
+ if (/(?:^|,)\s*no-cache(?:\s|=|,|$)/.test(control)) expires = 0;
491
599
  const etag = header(rawHeaders, "etag");
492
- return { cacheable: expires > now || etag !== null, expires, etag };
600
+ const lastModified = header(rawHeaders, "last-modified");
601
+ return { cacheable: expires > now || etag !== null || lastModified !== null, expires, etag, lastModified,
602
+ varyReferer: varies.includes("referer") };
493
603
  }
494
604
 
495
605
  /** Fetch §4.9 CORS check. */
@@ -501,6 +611,32 @@ function corsAllowed(rawHeaders, origin, withCredentials) {
501
611
  return !withCredentials || header(rawHeaders, "access-control-allow-credentials") === "true";
502
612
  }
503
613
 
614
+ function corsResponseHeaders(headers, withCredentials, rawHeaders = headers) {
615
+ const exposed = new Set((header(rawHeaders, "access-control-expose-headers") ?? "")
616
+ .split(",").map(name => name.trim().toLowerCase()).filter(Boolean));
617
+ const all = exposed.has("*") && !withCredentials;
618
+ return headers.filter(([name]) => SAFELISTED_RESPONSE_HEADERS.has(name) || exposed.has(name) || all);
619
+ }
620
+
621
+ /** Cancel one document's interest without canceling other users of a transfer. */
622
+ function shareResource(transfer, signal) {
623
+ transfer.users++;
624
+ return new Promise((resolve, reject) => {
625
+ let settled = false;
626
+ const finish = (callback, value) => {
627
+ if (settled) return;
628
+ settled = true;
629
+ signal?.removeEventListener("abort", abort);
630
+ if (--transfer.users === 0 && !transfer.done) transfer.controller.abort();
631
+ callback(value);
632
+ };
633
+ const abort = () => finish(reject, signal.reason ?? new DOMException("Aborted", "AbortError"));
634
+ transfer.promise.then(value => finish(resolve, value), error => finish(reject, error));
635
+ signal?.addEventListener("abort", abort, { once: true });
636
+ if (signal?.aborted) abort();
637
+ });
638
+ }
639
+
504
640
  /**
505
641
  * Limits the requests in flight to each host (scheme, host and port). A burst
506
642
  * of requests that each open a connection slows every transfer, and servers
@@ -516,7 +652,8 @@ class HostThrottle {
516
652
  }
517
653
 
518
654
  /** Resolves with a function that gives the slot back (once) when the request is done. */
519
- acquire(url, priority) {
655
+ acquire(url, priority, signal) {
656
+ if (signal?.aborted) return Promise.reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
520
657
  let key;
521
658
  try {
522
659
  const target = new URL(url);
@@ -531,11 +668,22 @@ class HostThrottle {
531
668
  host.active++;
532
669
  return Promise.resolve(release);
533
670
  }
534
- return new Promise((resolve) => {
535
- const entry = { priority, start: () => resolve(release) };
671
+ return new Promise((resolve, reject) => {
672
+ const entry = { priority, start: () => {
673
+ signal?.removeEventListener("abort", abort);
674
+ resolve(release);
675
+ } };
676
+ const abort = () => {
677
+ const index = host.waiting.indexOf(entry);
678
+ if (index >= 0) host.waiting.splice(index, 1);
679
+ signal.removeEventListener("abort", abort);
680
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
681
+ };
536
682
  const index = host.waiting.findIndex((other) => other.priority > priority);
537
683
  if (index === -1) host.waiting.push(entry);
538
684
  else host.waiting.splice(index, 0, entry);
685
+ signal?.addEventListener("abort", abort, { once: true });
686
+ if (signal?.aborted) abort();
539
687
  });
540
688
  }
541
689