buninu-browser 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -0
- package/README.md +74 -9
- package/ai-debug.log +5537 -0
- 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/playback-buffer.js +27 -0
- package/lib/audio/pulse-client.js +137 -19
- package/lib/cdp-server.js +22 -11
- package/lib/happy-dom/parser.js +29 -4
- package/lib/happy-dom/selector-pseudo-adapter.js +14 -0
- package/lib/headless-shell.js +69 -19
- package/lib/input/hit-test.js +25 -7
- package/lib/layout/text-layout.js +268 -85
- package/lib/log.js +5 -2
- package/lib/media/byte-pipe.js +82 -0
- package/lib/media/position-audio.js +42 -0
- package/lib/media/video-keyframe.js +29 -0
- package/lib/network/network-service.js +161 -14
- package/lib/paint/display-list.js +316 -15
- package/lib/paint/gradient.js +11 -5
- package/lib/render-tree/index.js +19 -2
- package/lib/render-tree/svg.js +2 -1
- package/lib/renderer/bindings.js +995 -69
- package/lib/renderer/canvas-2d.js +92 -0
- package/lib/renderer/compositor.js +16 -1
- package/lib/renderer/custom-element-reactions.js +18 -0
- package/lib/renderer/history.js +42 -0
- package/lib/renderer/host.js +166 -9
- package/lib/renderer/image-loading.js +27 -0
- package/lib/renderer/interfaces.js +215 -0
- package/lib/renderer/keyboard-input.js +12 -0
- package/lib/renderer/main.js +162 -12
- package/lib/renderer/media-playback-events.js +13 -0
- 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 +1408 -242
- package/lib/renderer/page-renderer.js +1893 -223
- package/lib/renderer/piano-input.js +22 -0
- package/lib/renderer/pointer-move.js +29 -0
- 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/video-presentation.js +126 -0
- package/lib/renderer/workers.js +327 -0
- package/lib/screenshot-cli.js +2 -0
- package/lib/style/computed-style.js +186 -29
- 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 +41 -1
- package/test/computed-style.test.js +59 -1
- package/test/display-list.test.js +88 -1
- package/test/download.test.js +43 -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 +23 -1
- package/test/hit-test.test.js +25 -1
- package/test/media-playback-events.test.js +22 -0
- 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 +1203 -7
- package/test/piano-input.test.js +44 -0
- package/test/playback-buffer.test.js +31 -0
- package/test/position-audio.test.js +67 -0
- package/test/positioning.test.js +95 -1
- package/test/pulse-client.test.js +84 -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/text-layout.test.js +62 -0
- package/test/user-agent.test.js +60 -4
- package/test/video-presentation.test.js +67 -0
- 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
package/lib/headless-shell.js
CHANGED
|
@@ -17,7 +17,7 @@ import { installLog, logFileFromEnv, writeLog } from "./log.js";
|
|
|
17
17
|
import { NetworkService } from "./network/network-service.js";
|
|
18
18
|
import { RendererHost } from "./renderer/host.js";
|
|
19
19
|
import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
|
|
20
|
-
import { DESKTOP_USER_AGENT } from "./user-agent.js";
|
|
20
|
+
import { DESKTOP_USER_AGENT, WEBKIT_USER_AGENT, webkitFromArgs } from "./user-agent.js";
|
|
21
21
|
|
|
22
22
|
/** Opt-in phone presentation: mobile user agent and touch media features. */
|
|
23
23
|
export const MOBILE = "--mobile";
|
|
@@ -39,7 +39,7 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
39
39
|
|
|
40
40
|
// The endpoint opens while the first renderer starts: a client's first
|
|
41
41
|
// navigation fetches its document meanwhile.
|
|
42
|
-
const browser = await createBrowser({ allowHostJs, mobile, waitForRenderer: false });
|
|
42
|
+
const browser = await createBrowser({ allowHostJs, mobile, webkit: webkitFromArgs(args), waitForRenderer: false });
|
|
43
43
|
const portArgument = args.find((argument) => argument.startsWith("--remote-debugging-port="));
|
|
44
44
|
const requestedPort = Number(portArgument?.slice("--remote-debugging-port=".length) ?? 9222);
|
|
45
45
|
const server = CdpServer.create(browser.context).listen(requestedPort, "127.0.0.1");
|
|
@@ -80,14 +80,16 @@ export async function runHeadlessShell(args = Bun.argv.slice(2)) {
|
|
|
80
80
|
* anything that needs a renderer waits for it.
|
|
81
81
|
*
|
|
82
82
|
* @param {{ allowHostJs?: boolean, spareRenderer?: boolean, network?: NetworkService, rendererTimeout?: number,
|
|
83
|
-
* waitForRenderer?: boolean }} options
|
|
83
|
+
* waitForRenderer?: boolean, mobile?: boolean, webkit?: boolean }} options
|
|
84
|
+
* WebKit identity defaults on; webkit: false selects Chromium. Mobile takes priority.
|
|
84
85
|
*/
|
|
85
86
|
export async function createBrowser({
|
|
86
87
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
|
|
87
|
-
waitForRenderer = true,
|
|
88
|
+
waitForRenderer = true, webkit = true,
|
|
88
89
|
} = {}) {
|
|
89
|
-
//
|
|
90
|
-
|
|
90
|
+
// Explicit presentation profiles fix the identity for requests and scripts.
|
|
91
|
+
const initialUserAgent = mobile ? MOBILE_USER_AGENT : webkit ? WEBKIT_USER_AGENT : DESKTOP_USER_AGENT;
|
|
92
|
+
network.userAgent = initialUserAgent;
|
|
91
93
|
const history = [{ url: "about:blank", x: 0, y: 0 }];
|
|
92
94
|
let historyIndex = 0;
|
|
93
95
|
let currentUrl = "about:blank";
|
|
@@ -101,7 +103,10 @@ export async function createBrowser({
|
|
|
101
103
|
let rendererReady = null;
|
|
102
104
|
// Renderer-independent state replayed into every new renderer.
|
|
103
105
|
const viewport = { width: 800, height: 600, deviceScaleFactor: 1 };
|
|
104
|
-
let userAgent = null;
|
|
106
|
+
let userAgent = webkit ? initialUserAgent : null;
|
|
107
|
+
// Page.addScriptToEvaluateOnNewDocument: source by identifier, run in every new document before its scripts.
|
|
108
|
+
const initScripts = new Map();
|
|
109
|
+
let nextInitScript = 1;
|
|
105
110
|
// Listeners for frame and lifecycle notifications (the CDP server).
|
|
106
111
|
const frameListeners = new Set();
|
|
107
112
|
const lifecycleListeners = new Set();
|
|
@@ -162,13 +167,13 @@ export async function createBrowser({
|
|
|
162
167
|
await load(currentUrl, invoke);
|
|
163
168
|
},
|
|
164
169
|
// Only the renderer showing the current page may navigate it.
|
|
165
|
-
onNavigate: (url, downloadName, fragmentFrom) => {
|
|
170
|
+
onNavigate: (url, downloadName, fragmentFrom, post = null) => {
|
|
166
171
|
if (host !== current) return;
|
|
167
172
|
if (fragmentFrom) {
|
|
168
173
|
recordFragment(url, fragmentFrom);
|
|
169
174
|
return;
|
|
170
175
|
}
|
|
171
|
-
navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
176
|
+
navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
172
177
|
},
|
|
173
178
|
onDownload: ({ url, name, data }) => {
|
|
174
179
|
if (host !== current) return;
|
|
@@ -206,6 +211,7 @@ export async function createBrowser({
|
|
|
206
211
|
async function replayState(invoke) {
|
|
207
212
|
await invoke("resize", ...resizeArguments());
|
|
208
213
|
if (userAgent) await invoke("setUserAgent", userAgent);
|
|
214
|
+
if (initScripts.size) await invoke("setInitScripts", [...initScripts.values()]);
|
|
209
215
|
}
|
|
210
216
|
|
|
211
217
|
function prepareSpare() {
|
|
@@ -253,7 +259,7 @@ export async function createBrowser({
|
|
|
253
259
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
254
260
|
* restarted renderer).
|
|
255
261
|
*/
|
|
256
|
-
async function load(url, invoke = null, downloadName = null) {
|
|
262
|
+
async function load(url, invoke = null, downloadName = null, post = null) {
|
|
257
263
|
const started = performance.now();
|
|
258
264
|
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
259
265
|
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
@@ -267,7 +273,7 @@ export async function createBrowser({
|
|
|
267
273
|
try {
|
|
268
274
|
const target = new URL(url);
|
|
269
275
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
270
|
-
? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
|
|
276
|
+
? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
|
|
271
277
|
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
272
278
|
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
273
279
|
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
@@ -318,7 +324,7 @@ export async function createBrowser({
|
|
|
318
324
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
319
325
|
}
|
|
320
326
|
|
|
321
|
-
async function navigate(url, { downloadName = null } = {}) {
|
|
327
|
+
async function navigate(url, { downloadName = null, post = null } = {}) {
|
|
322
328
|
const resolved = new URL(url, currentUrl).href;
|
|
323
329
|
// A javascript: URL runs in the current document; history does not change.
|
|
324
330
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -326,15 +332,20 @@ export async function createBrowser({
|
|
|
326
332
|
return;
|
|
327
333
|
}
|
|
328
334
|
await rememberScroll();
|
|
329
|
-
|
|
335
|
+
let shown = resolved;
|
|
336
|
+
if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
330
337
|
currentUrl = resolved;
|
|
331
338
|
} else {
|
|
332
|
-
const result = await load(resolved, null, downloadName);
|
|
339
|
+
const result = await load(resolved, null, downloadName, post);
|
|
333
340
|
if (result?.downloaded) return null;
|
|
341
|
+
// The document is at the URL redirects ended on; the request's fragment
|
|
342
|
+
// carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
|
|
343
|
+
shown = withFragment(currentUrl, resolved);
|
|
344
|
+
currentUrl = shown;
|
|
334
345
|
}
|
|
335
|
-
await call("showFragment",
|
|
346
|
+
await call("showFragment", shown);
|
|
336
347
|
history.splice(historyIndex + 1);
|
|
337
|
-
history.push({ url:
|
|
348
|
+
history.push({ url: shown, x: 0, y: 0 });
|
|
338
349
|
historyIndex = history.length - 1;
|
|
339
350
|
}
|
|
340
351
|
|
|
@@ -359,6 +370,7 @@ export async function createBrowser({
|
|
|
359
370
|
|
|
360
371
|
const context = {
|
|
361
372
|
navigate,
|
|
373
|
+
get userAgent() { return userAgent ?? initialUserAgent; },
|
|
362
374
|
setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
|
|
363
375
|
if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
|
|
364
376
|
downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
|
|
@@ -375,12 +387,22 @@ export async function createBrowser({
|
|
|
375
387
|
return applyState("resize", resizeArguments);
|
|
376
388
|
},
|
|
377
389
|
setUserAgent(value) {
|
|
378
|
-
//
|
|
379
|
-
if (mobile) return undefined;
|
|
390
|
+
// A client's default override must not replace an explicit CLI profile.
|
|
391
|
+
if (mobile || webkit) return undefined;
|
|
380
392
|
userAgent = typeof value === "string" && value ? value : null;
|
|
381
393
|
network.userAgent = userAgent;
|
|
382
394
|
return userAgent ? applyState("setUserAgent", () => [userAgent]) : undefined;
|
|
383
395
|
},
|
|
396
|
+
addInitScript(source) {
|
|
397
|
+
const identifier = String(nextInitScript++);
|
|
398
|
+
initScripts.set(identifier, String(source));
|
|
399
|
+
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
400
|
+
return identifier;
|
|
401
|
+
},
|
|
402
|
+
removeInitScript(identifier) {
|
|
403
|
+
if (!initScripts.delete(String(identifier))) return;
|
|
404
|
+
applyState("setInitScripts", () => [[...initScripts.values()]]);
|
|
405
|
+
},
|
|
384
406
|
screenshot: (options = {}) => composite("screenshot", {
|
|
385
407
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
386
408
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
@@ -402,7 +424,20 @@ export async function createBrowser({
|
|
|
402
424
|
? { url: resource.url, contentType: resource.contentType, body: resource.body }
|
|
403
425
|
: null;
|
|
404
426
|
},
|
|
405
|
-
|
|
427
|
+
/**
|
|
428
|
+
* Scrolls by a wheel delta. With the pointer's viewport position, the
|
|
429
|
+
* scroll container under it (an element, an iframe's document) scrolls
|
|
430
|
+
* first; the page scrolls when nothing there takes it.
|
|
431
|
+
*/
|
|
432
|
+
async scroll(deltaX, deltaY, at = null) {
|
|
433
|
+
const scale = pageScale();
|
|
434
|
+
let left = { x: deltaX / scale, y: deltaY / scale };
|
|
435
|
+
if (at && Number.isFinite(at.x) && Number.isFinite(at.y)) {
|
|
436
|
+
left = await call("wheel", at.x / scale, at.y / scale, left.x, left.y) ?? left;
|
|
437
|
+
if (!left.x && !left.y) return undefined;
|
|
438
|
+
}
|
|
439
|
+
return composite("scroll", left.x, left.y);
|
|
440
|
+
},
|
|
406
441
|
press: (key, options) => call("press", key, options),
|
|
407
442
|
type: (text) => call("type", text),
|
|
408
443
|
scrollOffset: () => composite("scrollOffset"),
|
|
@@ -420,6 +455,9 @@ export async function createBrowser({
|
|
|
420
455
|
lifecycleState() {
|
|
421
456
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
422
457
|
},
|
|
458
|
+
move(x, y) {
|
|
459
|
+
return call("move", x / pageScale(), y / pageScale());
|
|
460
|
+
},
|
|
423
461
|
async click(x, y) {
|
|
424
462
|
const clicked = await call("click", x / pageScale(), y / pageScale());
|
|
425
463
|
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
@@ -494,6 +532,18 @@ async function localDocument(url) {
|
|
|
494
532
|
};
|
|
495
533
|
}
|
|
496
534
|
|
|
535
|
+
/** `url`, with the fragment of `requested` when it has none of its own. */
|
|
536
|
+
function withFragment(url, requested) {
|
|
537
|
+
try {
|
|
538
|
+
const target = new URL(url);
|
|
539
|
+
const hash = new URL(requested).hash;
|
|
540
|
+
if (!target.hash && hash) target.hash = hash;
|
|
541
|
+
return target.href;
|
|
542
|
+
} catch {
|
|
543
|
+
return url;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
497
547
|
function withoutHash(href) {
|
|
498
548
|
const copy = new URL(href);
|
|
499
549
|
copy.hash = "";
|
package/lib/input/hit-test.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { hitTarget } from "../paint/display-list.js";
|
|
2
|
+
|
|
3
|
+
// An iframe takes clicks for its own document (the renderer forwards them).
|
|
4
|
+
const INTERACTIVE_TAGS = new Set(["A", "AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "SUMMARY", "TEXTAREA", "VIDEO"]);
|
|
5
|
+
// Form controls, labels and iframes are hit over their whole box, not only
|
|
6
|
+
// where text is drawn (an unchecked checkbox or an empty field has none; a
|
|
7
|
+
// block label is clickable beside its text).
|
|
8
|
+
const CONTROL_TAGS = new Set(["AUDIO", "BUTTON", "IFRAME", "INPUT", "LABEL", "SELECT", "TEXTAREA"]);
|
|
6
9
|
|
|
7
10
|
export function interactiveRegions(tree, layout, scroll = { x: 0, y: 0 }, viewport = {}) {
|
|
8
11
|
const regions = [];
|
|
@@ -71,6 +74,18 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
|
|
|
71
74
|
return deepest.sort((left, right) => left.area - right.area)[0] ?? null;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The element a pointer event at a viewport point targets: the topmost one
|
|
79
|
+
* painted there (its own box or its text), skipping those that take no
|
|
80
|
+
* pointer events. Null when the point hits nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function elementAtPoint(tree, displayList, x, y, scroll) {
|
|
83
|
+
const hittable = hitTestable(tree);
|
|
84
|
+
const nodeId = hitTarget(displayList, x, y, scroll, (id) => !hittable(tree.nodesById.get(id)));
|
|
85
|
+
const domNode = nodeId == null ? null : tree.nodesById.get(nodeId)?.domNode;
|
|
86
|
+
return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
/** Returns the content-coordinate bounds contributed by an element's text descendants. */
|
|
75
90
|
export function elementBounds(tree, layout, element) {
|
|
76
91
|
const rects = [];
|
|
@@ -81,11 +96,14 @@ export function elementBounds(tree, layout, element) {
|
|
|
81
96
|
? renderNode.domNode
|
|
82
97
|
: renderNode?.domNode?.parentElement;
|
|
83
98
|
if (!owner || (owner !== element && !element.contains(owner))) continue;
|
|
99
|
+
// A replaced element (an image) spans its own box, not its line's height.
|
|
100
|
+
const replaced = run.type === "image" && run.y != null;
|
|
101
|
+
const top = replaced ? run.y : fragment.y;
|
|
84
102
|
rects.push({
|
|
85
103
|
left: run.x,
|
|
86
|
-
top
|
|
104
|
+
top,
|
|
87
105
|
right: run.x + run.width,
|
|
88
|
-
bottom:
|
|
106
|
+
bottom: top + (replaced ? run.height : fragment.height),
|
|
89
107
|
});
|
|
90
108
|
}
|
|
91
109
|
}
|