buninu-browser 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/bunfig.toml +1 -0
- package/buninu-browser.js +4 -0
- package/index.js +5 -0
- package/lib/audio/piano.js +24 -1
- package/lib/audio/pulse-client.js +120 -12
- package/lib/cdp-server.js +16 -11
- package/lib/happy-dom/parser.js +24 -4
- package/lib/headless-shell.js +66 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +196 -49
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +300 -14
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +17 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +985 -68
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/host.js +158 -8
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/main.js +160 -12
- package/lib/renderer/media-source.js +819 -0
- package/lib/renderer/messaging.js +411 -0
- package/lib/renderer/native-functions.js +113 -0
- package/lib/renderer/page-realm.js +1405 -198
- package/lib/renderer/page-renderer.js +1736 -193
- package/lib/renderer/rejections.js +31 -0
- package/lib/renderer/rewrite-worker.js +22 -0
- package/lib/renderer/script-rewrite.js +805 -57
- package/lib/renderer/trusted-types.js +129 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +162 -22
- package/lib/user-agent.js +14 -2
- package/package.json +4 -1
- package/patches/happy-dom/0002-use-bun-platform-adapters.patch +139 -19
- package/patches/happy-dom/0006-parse-css-escapes-in-selectors.patch +115 -0
- package/patches/happy-dom/README.md +26 -0
- package/patches/ses/0002-embedder-native-functions-source-text-sloppy-code.patch +106 -0
- package/patches/ses/0003-cache-sloppy-evaluators.patch +47 -0
- package/patches/ses/README.md +4 -0
- package/test/canvas-2d.test.js +55 -0
- package/test/compositor.test.js +3 -1
- package/test/computed-style.test.js +38 -1
- package/test/display-list.test.js +74 -1
- package/test/download.test.js +35 -0
- package/test/fixtures/long-video.mp4 +0 -0
- package/test/fixtures/media-sync.js +57 -0
- package/test/happy-dom-platform-adapters.test.js +25 -0
- package/test/headless-shell.test.js +1 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-source.test.js +156 -0
- package/test/media-sync.test.js +12 -0
- package/test/mobile.test.js +2 -2
- package/test/navigation.test.js +61 -0
- package/test/network-service.test.js +14 -0
- package/test/page-scripts.test.js +1045 -7
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +69 -0
- package/test/renderer-host.test.js +5 -2
- package/test/screenshot-cli.test.js +25 -1
- package/test/script-rewrite.test.js +111 -6
- package/test/setup.js +5 -0
- package/test/user-agent.test.js +60 -4
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-image-element/HTMLImageElement.ts +52 -22
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +18 -17
- package/vendor/ses/dist/ses.mjs +52 -6
|
@@ -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
|
-
|
|
77
|
-
|
|
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, {
|
|
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)
|
|
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
|
|
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
|
-
|