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.
- package/lib/cdp-server.js +47 -14
- package/lib/happy-dom/document-base.js +33 -0
- package/lib/happy-dom/parser.js +15 -6
- package/lib/happy-dom/selector-matcher.js +2 -2
- package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
- package/lib/happy-dom/svg-attribute-adapter.js +50 -0
- package/lib/headless-shell.js +9 -6
- package/lib/input/hit-test.js +67 -19
- package/lib/layout/text-layout.js +213 -91
- package/lib/media/byte-pipe.js +2 -2
- package/lib/media/position-audio.js +10 -1
- package/lib/media/source-snapshots.js +126 -0
- package/lib/media/video-keyframe.js +17 -4
- package/lib/media/webm-stream.js +106 -0
- package/lib/network/network-service.js +268 -120
- package/lib/paint/display-list.js +8 -28
- package/lib/paint/frame-cache.js +25 -0
- package/lib/paint/frame.js +42 -13
- package/lib/paint/paragraph-text.js +24 -0
- package/lib/paint/raster-readback.js +41 -0
- package/lib/paint/rounded-rect.js +82 -0
- package/lib/paint/shared-frame-pool.js +105 -0
- package/lib/render-tree/index.js +38 -6
- package/lib/render-tree/svg.js +15 -7
- package/lib/renderer/bindings.js +233 -80
- package/lib/renderer/broadcast-channel.js +76 -0
- package/lib/renderer/canvas-2d.js +257 -14
- package/lib/renderer/canvas-text.js +120 -0
- package/lib/renderer/compositor.js +13 -11
- package/lib/renderer/custom-element-reactions.js +25 -1
- package/lib/renderer/font-loading.js +227 -0
- package/lib/renderer/host.js +28 -4
- package/lib/renderer/image-resources.js +65 -0
- package/lib/renderer/interfaces.js +28 -0
- package/lib/renderer/layout-observers.js +236 -0
- package/lib/renderer/main.js +30 -4
- package/lib/renderer/media-source.js +126 -55
- package/lib/renderer/page-realm.js +270 -98
- package/lib/renderer/page-renderer.js +647 -235
- package/lib/renderer/prepared-script-cache.js +50 -0
- package/lib/renderer/rendered-text.js +108 -0
- package/lib/renderer/rewrite-worker.js +4 -3
- package/lib/renderer/script-rewrite.js +13 -2
- package/lib/renderer/scroll-into-view.js +28 -0
- package/lib/renderer/slot-assignment.js +60 -0
- package/lib/renderer/video-presentation.js +23 -4
- package/lib/renderer/workers.js +2 -2
- package/lib/style/computed-style.js +611 -104
- package/lib/style/selector-traits.js +49 -5
- package/package.json +1 -1
- package/test/broadcast-channel.test.js +26 -0
- package/test/canvas-2d.test.js +83 -2
- package/test/canvas-paint.test.js +9 -0
- package/test/canvas-text.test.js +41 -0
- package/test/cdp-stream.test.js +56 -0
- package/test/computed-style-binding.test.js +151 -0
- package/test/computed-style.test.js +258 -1
- package/test/display-list.test.js +12 -0
- package/test/document-base.test.js +87 -0
- package/test/dom-platform.test.js +461 -0
- package/test/fetch-lifecycle.test.js +161 -0
- package/test/fixtures/canvas-paint.js +117 -0
- package/test/fixtures/canvas-text.js +42 -0
- package/test/fixtures/image-lifetime.js +90 -0
- package/test/fixtures/media-seek-snapshot.js +77 -0
- package/test/fixtures/mse-audio-chirp.webm +0 -0
- package/test/fixtures/mse-switch-large.mp4 +0 -0
- package/test/fixtures/mse-switch-small.mp4 +0 -0
- package/test/fixtures/renderer-memory.js +110 -0
- package/test/fixtures/summary-style.js +28 -0
- package/test/font-loading.test.js +97 -0
- package/test/grid.test.js +58 -0
- package/test/hit-test.test.js +21 -1
- package/test/image-lifetime.test.js +12 -0
- package/test/image-resources.test.js +94 -0
- package/test/local-media.test.js +84 -0
- package/test/media-seek-snapshot.test.js +11 -0
- package/test/media-source-config.test.js +98 -0
- package/test/media-source-webm-accounting.test.js +98 -0
- package/test/media-source-webm.test.js +108 -0
- package/test/navigation.test.js +1 -0
- package/test/network-service.test.js +388 -0
- package/test/page-scripts.test.js +1 -1
- package/test/paint-frame.test.js +103 -0
- package/test/paint-paragraph-cache.test.js +87 -0
- package/test/position-audio.test.js +24 -0
- package/test/positioning.test.js +22 -0
- package/test/prepared-script-cache.test.js +45 -0
- package/test/pulse-client.test.js +10 -3
- package/test/raster-readback.test.js +40 -0
- package/test/render-tree.test.js +28 -0
- package/test/renderer-memory.test.js +11 -0
- package/test/selector-traits.test.js +24 -0
- package/test/shared-frame-pool.test.js +63 -0
- package/test/style-sheets.test.js +51 -0
- package/test/svg.test.js +29 -0
- package/test/text-layout.test.js +106 -0
- package/test/video-keyframe.test.js +61 -0
- package/test/video-presentation.test.js +69 -0
- package/tools/browser-lab.js +78 -0
- package/tools/browser-soak.js +75 -0
- package/tools/network-reference.js +49 -0
- package/tools/process-memory.js +38 -0
- package/tools/script-benchmark.js +25 -0
- 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
|
|
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(
|
|
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
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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,
|
|
139
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (this.#subresourceInflight.get(
|
|
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
|
-
|
|
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
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
307
|
-
const
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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-
|
|
474
|
-
|
|
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
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|