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
package/lib/cdp-server.js
CHANGED
|
@@ -44,9 +44,11 @@ export class CdpServer {
|
|
|
44
44
|
for (const target of state.targets.values()) {
|
|
45
45
|
if (target.screencast && target.screencastEmit) pushScreencastFrame(context, state, target);
|
|
46
46
|
}
|
|
47
|
+
if (state.implicitTarget?.screencast) pushScreencastFrame(context, state, state.implicitTarget);
|
|
47
48
|
});
|
|
48
49
|
context.onLifecycle?.((name) => {
|
|
49
50
|
for (const target of state.targets.values()) emitLifecycle(target, name);
|
|
51
|
+
if (state.implicitTarget) emitLifecycle(state.implicitTarget, name);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
const server = Bun.serve({
|
|
@@ -178,6 +180,7 @@ export class CdpServer {
|
|
|
178
180
|
...(sessionId ? { sessionId } : {}),
|
|
179
181
|
});
|
|
180
182
|
};
|
|
183
|
+
emit.owner = ws;
|
|
181
184
|
const result = await dispatch(
|
|
182
185
|
context,
|
|
183
186
|
state,
|
|
@@ -206,6 +209,13 @@ export class CdpServer {
|
|
|
206
209
|
}
|
|
207
210
|
},
|
|
208
211
|
close(ws) {
|
|
212
|
+
for (const target of state.targets.values()) {
|
|
213
|
+
if (target.screencastEmit?.owner === ws) stopScreencast(target);
|
|
214
|
+
if (target.lifecycleEmit?.owner === ws) target.lifecycleEmit = null;
|
|
215
|
+
}
|
|
216
|
+
const implicit = state.implicitTarget;
|
|
217
|
+
if (implicit?.screencastEmit?.owner === ws) stopScreencast(implicit);
|
|
218
|
+
if (implicit?.lifecycleEmit?.owner === ws) implicit.lifecycleEmit = null;
|
|
209
219
|
serverLog("[CdpServer] client disconnected");
|
|
210
220
|
},
|
|
211
221
|
},
|
|
@@ -350,6 +360,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
350
360
|
const target = state.targets.get(params.targetId);
|
|
351
361
|
if (!target) return { success: false };
|
|
352
362
|
|
|
363
|
+
stopScreencast(target);
|
|
353
364
|
await ctx.close?.();
|
|
354
365
|
state.initialNavigations.get(params.targetId)?.navigated.resolve();
|
|
355
366
|
state.initialNavigations.get(params.targetId)?.settled.resolve();
|
|
@@ -450,7 +461,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
450
461
|
// A javascript: URL runs in the current document: no new loader or history entry.
|
|
451
462
|
if (/^\s*javascript:/i.test(String(params.url))) {
|
|
452
463
|
await ctx.navigate?.(String(params.url).trim());
|
|
453
|
-
if (target.screencast) await
|
|
464
|
+
if (target.screencast) await pushScreencastFrame(ctx, state, target);
|
|
454
465
|
return { frameId: target.frameId, loaderId: target.loaderId };
|
|
455
466
|
}
|
|
456
467
|
// Events of the next document belong to its new loader.
|
|
@@ -460,7 +471,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
460
471
|
target.title = await getContextTitle(ctx, target);
|
|
461
472
|
target.history[target.historyIndex].title = target.title;
|
|
462
473
|
if (target.screencast) {
|
|
463
|
-
await
|
|
474
|
+
await pushScreencastFrame(ctx, state, target);
|
|
464
475
|
}
|
|
465
476
|
state.initialNavigations.get(target.targetId)?.navigated.resolve();
|
|
466
477
|
|
|
@@ -512,6 +523,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
512
523
|
}
|
|
513
524
|
case "Page.startScreencast": {
|
|
514
525
|
const target = getSessionTarget(state, sessionId);
|
|
526
|
+
stopScreencast(target);
|
|
515
527
|
target.screencast = { ...params };
|
|
516
528
|
target.screencastEmit = emit;
|
|
517
529
|
// As in Chrome, the reply does not wait for the first frame (nor the
|
|
@@ -519,12 +531,14 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
519
531
|
void pushScreencastFrame(ctx, state, target);
|
|
520
532
|
return {};
|
|
521
533
|
}
|
|
522
|
-
case "Page.screencastFrameAck":
|
|
534
|
+
case "Page.screencastFrameAck": {
|
|
535
|
+
const target = getSessionTarget(state, sessionId);
|
|
536
|
+
if (target.screencastFrames.delete(params.sessionId) && target.screencastPending) void pushScreencastFrame(ctx, state, target);
|
|
523
537
|
return {};
|
|
538
|
+
}
|
|
524
539
|
case "Page.stopScreencast": {
|
|
525
540
|
const target = getSessionTarget(state, sessionId);
|
|
526
|
-
target
|
|
527
|
-
target.screencastEmit = null;
|
|
541
|
+
stopScreencast(target);
|
|
528
542
|
return {};
|
|
529
543
|
}
|
|
530
544
|
case "Page.reload": {
|
|
@@ -707,7 +721,7 @@ async function dispatch(ctx, state, method, params, sessionId, emit) {
|
|
|
707
721
|
navigateTarget(state, target, navigation.url, emit, { addHistory: true });
|
|
708
722
|
target.title = navigation.title ?? await getContextTitle(ctx, target);
|
|
709
723
|
target.history[target.historyIndex].title = target.title;
|
|
710
|
-
if (target.screencast) await
|
|
724
|
+
if (target.screencast) await pushScreencastFrame(ctx, state, target);
|
|
711
725
|
}
|
|
712
726
|
}
|
|
713
727
|
return {};
|
|
@@ -817,6 +831,7 @@ function createTarget(state, url = "about:blank", options = {}) {
|
|
|
817
831
|
deviceScaleFactor: 1,
|
|
818
832
|
},
|
|
819
833
|
screencast: null,
|
|
834
|
+
screencastFrames: new Set(),
|
|
820
835
|
};
|
|
821
836
|
state.targets.set(targetId, target);
|
|
822
837
|
state.initialNavigations.set(targetId, {
|
|
@@ -857,9 +872,20 @@ function emitLifecycle(target, name) {
|
|
|
857
872
|
emit("Page.lifecycleEvent", { frameId: target.frameId, loaderId: target.loaderId, name, timestamp });
|
|
858
873
|
}
|
|
859
874
|
|
|
860
|
-
|
|
875
|
+
// Bound the amount of image data queued to a slow CDP client. Two outstanding
|
|
876
|
+
// frames keep the pipe moving while acknowledging one; later updates coalesce.
|
|
877
|
+
const MAX_SCREENCAST_FRAMES = 2;
|
|
878
|
+
function stopScreencast(target) {
|
|
879
|
+
target.screencast = null;
|
|
880
|
+
target.screencastEmit = null;
|
|
881
|
+
target.screencastPending = false;
|
|
882
|
+
target.screencastFrames.clear();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** One capture at a time, with bounded unacknowledged frames. */
|
|
861
886
|
async function pushScreencastFrame(ctx, state, target) {
|
|
862
|
-
if (target.
|
|
887
|
+
if (!target.screencast || !target.screencastEmit) return;
|
|
888
|
+
if (target.screencastBusy || target.screencastFrames.size >= MAX_SCREENCAST_FRAMES) {
|
|
863
889
|
target.screencastPending = true;
|
|
864
890
|
return;
|
|
865
891
|
}
|
|
@@ -867,8 +893,9 @@ async function pushScreencastFrame(ctx, state, target) {
|
|
|
867
893
|
try {
|
|
868
894
|
do {
|
|
869
895
|
target.screencastPending = false;
|
|
870
|
-
await emitScreencastFrame(ctx, state, target
|
|
871
|
-
} while (target.screencastPending && target.screencast && target.screencastEmit
|
|
896
|
+
await emitScreencastFrame(ctx, state, target);
|
|
897
|
+
} while (target.screencastPending && target.screencast && target.screencastEmit
|
|
898
|
+
&& target.screencastFrames.size < MAX_SCREENCAST_FRAMES);
|
|
872
899
|
} catch (error) {
|
|
873
900
|
serverError(`[CdpServer] screencast frame failed: ${error?.message ?? error}`);
|
|
874
901
|
} finally {
|
|
@@ -876,10 +903,14 @@ async function pushScreencastFrame(ctx, state, target) {
|
|
|
876
903
|
}
|
|
877
904
|
}
|
|
878
905
|
|
|
879
|
-
async function emitScreencastFrame(ctx, state, target
|
|
880
|
-
const
|
|
881
|
-
|
|
906
|
+
async function emitScreencastFrame(ctx, state, target) {
|
|
907
|
+
const stream = target.screencast, emit = target.screencastEmit;
|
|
908
|
+
const data = await screenshotData(ctx, target, stream);
|
|
909
|
+
if (!data || stream !== target.screencast || emit !== target.screencastEmit) return;
|
|
882
910
|
const scrollOffset = await ctx.scrollOffset?.() ?? { x: 0, y: 0 };
|
|
911
|
+
if (stream !== target.screencast || emit !== target.screencastEmit) return;
|
|
912
|
+
const sessionId = state.nextScreencastFrameId++;
|
|
913
|
+
target.screencastFrames.add(sessionId);
|
|
883
914
|
emit("Page.screencastFrame", {
|
|
884
915
|
data,
|
|
885
916
|
metadata: {
|
|
@@ -891,7 +922,7 @@ async function emitScreencastFrame(ctx, state, target, emit) {
|
|
|
891
922
|
scrollOffsetY: scrollOffset.y,
|
|
892
923
|
timestamp: performance.now() / 1000,
|
|
893
924
|
},
|
|
894
|
-
sessionId
|
|
925
|
+
sessionId,
|
|
895
926
|
});
|
|
896
927
|
}
|
|
897
928
|
|
|
@@ -1000,6 +1031,8 @@ function getImplicitTarget(state) {
|
|
|
1000
1031
|
|
|
1001
1032
|
state.implicitTarget = {
|
|
1002
1033
|
targetId: "implicit-target",
|
|
1034
|
+
screencast: null,
|
|
1035
|
+
screencastFrames: new Set(),
|
|
1003
1036
|
frameId: "implicit-target",
|
|
1004
1037
|
loaderId: "implicit-loader",
|
|
1005
1038
|
type: "page",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import Node from "../../vendor/happy-dom/packages/happy-dom/src/nodes/node/Node.ts";
|
|
2
|
+
import Document from "../../vendor/happy-dom/packages/happy-dom/src/nodes/document/Document.ts";
|
|
3
|
+
import HTMLHyperlinkElementUtility from "../../vendor/happy-dom/packages/happy-dom/src/nodes/html-hyperlink-element/HTMLHyperlinkElementUtility.ts";
|
|
4
|
+
|
|
5
|
+
/** HTML document base URL, shared by the inert DOM and its page bindings. */
|
|
6
|
+
export function documentBaseURL(node) {
|
|
7
|
+
const document = node?.nodeType === 9 ? node : node?.ownerDocument;
|
|
8
|
+
const fallback = document?.URL ?? "about:blank";
|
|
9
|
+
const base = document?.querySelector?.("base[href]");
|
|
10
|
+
if (!base) return fallback;
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(base.getAttribute("href"), fallback);
|
|
13
|
+
return url.protocol === "data:" || url.protocol === "javascript:" ? fallback : url.href;
|
|
14
|
+
} catch { return fallback; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Happy DOM selects the first <base>, including ones with only a target.
|
|
18
|
+
// Correcting both accessors keeps native URL attributes and renderer loads
|
|
19
|
+
// on the same base, without modifying the vendored implementation.
|
|
20
|
+
for (const prototype of [Node.prototype, Document.prototype]) {
|
|
21
|
+
Object.defineProperty(prototype, "baseURI", {
|
|
22
|
+
configurable: true, get() { return documentBaseURL(this); },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Hyperlink component accessors use this shared method, so pathname/search
|
|
27
|
+
// setters and origin/hash getters resolve consistently with href itself.
|
|
28
|
+
HTMLHyperlinkElementUtility.prototype.getHref = function () {
|
|
29
|
+
const value = this.element.getAttribute("href");
|
|
30
|
+
if (value === null) return "";
|
|
31
|
+
try { return new URL(value, documentBaseURL(this.element)).href; }
|
|
32
|
+
catch { return value; }
|
|
33
|
+
};
|
package/lib/happy-dom/parser.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
|
|
2
2
|
import Window from "../../vendor/happy-dom/packages/happy-dom/src/window/Window.ts";
|
|
3
|
+
import * as PropertySymbol from "../../vendor/happy-dom/packages/happy-dom/src/PropertySymbol.ts";
|
|
3
4
|
import "./selector-pseudo-adapter.js";
|
|
5
|
+
import "./svg-attribute-adapter.js";
|
|
6
|
+
import "./document-base.js";
|
|
4
7
|
|
|
5
8
|
const IMPLICIT_HEAD_ELEMENTS = new Set([
|
|
6
9
|
"BASE", "BASEFONT", "BGSOUND", "LINK", "META", "NOFRAMES", "SCRIPT", "STYLE", "TEMPLATE", "TITLE",
|
|
@@ -12,8 +15,18 @@ const IMPLICIT_HEAD_ELEMENTS = new Set([
|
|
|
12
15
|
* Page JavaScript and subresource loading stay disabled here. They belong to
|
|
13
16
|
* the renderer scheduler and network service rather than the tree builder.
|
|
14
17
|
*/
|
|
15
|
-
export function parseHTMLDocument(html, url = "about:blank") {
|
|
16
|
-
const window =
|
|
18
|
+
export function parseHTMLDocument(html, url = "about:blank", referrer = "") {
|
|
19
|
+
const window = createInertWindow(url);
|
|
20
|
+
window.document[PropertySymbol.referrer] = referrer;
|
|
21
|
+
|
|
22
|
+
new HTMLParser(window).parse(String(html), window.document);
|
|
23
|
+
normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
|
|
24
|
+
return { window, document: window.document };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** An isolated parser context: no script execution, resource loads or page custom elements. */
|
|
28
|
+
export function createInertWindow(url = "about:blank") {
|
|
29
|
+
return new Window({
|
|
17
30
|
url,
|
|
18
31
|
settings: {
|
|
19
32
|
disableJavaScriptEvaluation: true,
|
|
@@ -27,10 +40,6 @@ export function parseHTMLDocument(html, url = "about:blank") {
|
|
|
27
40
|
navigation: { disableFallbackToSetURL: true },
|
|
28
41
|
},
|
|
29
42
|
});
|
|
30
|
-
|
|
31
|
-
new HTMLParser(window).parse(String(html), window.document);
|
|
32
|
-
normalizeImplicitHead(window.document, implicitHeadCount(String(html)));
|
|
33
|
-
return { window, document: window.document };
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
/**
|
|
@@ -37,9 +37,9 @@ export function compileSelector(element, selector) {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
/** Whether `element` matches a compiled selector, as element.matches(selector) would. */
|
|
40
|
-
export function matchesCompiled(element, compiled) {
|
|
40
|
+
export function matchesCompiled(element, compiled, scope = element) {
|
|
41
41
|
for (const items of compiled) {
|
|
42
|
-
if (matchItems(
|
|
42
|
+
if (matchItems(scope, element, items, 0, null)) return true;
|
|
43
43
|
}
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import SelectorItem from "../../vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts";
|
|
2
|
+
import { compileSelector, matchesCompiled } from "./selector-matcher.js";
|
|
2
3
|
|
|
3
4
|
// Happy DOM's :empty checks only element children. CSS also excludes elements
|
|
4
5
|
// with nonempty text nodes (including whitespace); comments do not count.
|
|
5
6
|
const original = SelectorItem.prototype.matchPseudoItem;
|
|
7
|
+
const selectorLists = new WeakMap();
|
|
6
8
|
SelectorItem.prototype.matchPseudoItem = function (scope, element, siblings, pseudo, ignoreErrors) {
|
|
9
|
+
if (pseudo.name === "focus-within") {
|
|
10
|
+
const focused = element.ownerDocument?.activeElement;
|
|
11
|
+
return focused && (focused === element || element.contains(focused)) ? {priorityWeight:10} : null;
|
|
12
|
+
}
|
|
13
|
+
// Functional selector lists match their complete complex selectors. The
|
|
14
|
+
// upstream per-item path tests only a group's first compound, so e.g.
|
|
15
|
+
// :where(h2 code) incorrectly matches the h2 instead of its code child.
|
|
16
|
+
if (["is", "where", "not"].includes(pseudo.name) && pseudo.arguments) {
|
|
17
|
+
let compiled = selectorLists.get(pseudo);
|
|
18
|
+
if (compiled === undefined) {
|
|
19
|
+
compiled = compileSelector(element, pseudo.arguments) ?? null;
|
|
20
|
+
selectorLists.set(pseudo, compiled);
|
|
21
|
+
}
|
|
22
|
+
const matches = compiled && matchesCompiled(element, compiled, scope);
|
|
23
|
+
return (pseudo.name === "not" ? !matches : matches)
|
|
24
|
+
? { priorityWeight: pseudo.name === "where" ? 0 : 10 } : null;
|
|
25
|
+
}
|
|
7
26
|
if (pseudo.name === "empty") {
|
|
8
27
|
for (const child of element.childNodes) {
|
|
9
28
|
if (child.nodeType === 1 || (child.nodeType === 3 || child.nodeType === 4) && child.data.length) return null;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import HTMLParser from "../../vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts";
|
|
2
|
+
|
|
3
|
+
// HTML tokenization ASCII-lowercases every attribute, including foreign
|
|
4
|
+
// content. Tree construction then restores the standard SVG spellings.
|
|
5
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#adjust-svg-attributes
|
|
6
|
+
const adjustedNames = new Map(`attributeName attributeType baseFrequency baseProfile calcMode clipPathUnits
|
|
7
|
+
diffuseConstant edgeMode filterUnits glyphRef gradientTransform gradientUnits kernelMatrix kernelUnitLength
|
|
8
|
+
keyPoints keySplines keyTimes lengthAdjust limitingConeAngle markerHeight markerUnits markerWidth
|
|
9
|
+
maskContentUnits maskUnits numOctaves pathLength patternContentUnits patternTransform patternUnits
|
|
10
|
+
pointsAtX pointsAtY pointsAtZ preserveAlpha preserveAspectRatio primitiveUnits refX refY repeatCount repeatDur
|
|
11
|
+
requiredExtensions requiredFeatures specularConstant specularExponent spreadMethod startOffset stdDeviation
|
|
12
|
+
stitchTiles surfaceScale systemLanguage tableValues targetX targetY textLength viewBox viewTarget
|
|
13
|
+
xChannelSelector yChannelSelector zoomAndPan`.trim().split(/\s+/).map(name => [name.toLowerCase(), name]));
|
|
14
|
+
const space = character => character === " " || character === "\t" || character === "\n" || character === "\r" || character === "\f";
|
|
15
|
+
|
|
16
|
+
function adjustNames(source) {
|
|
17
|
+
let index = 0, copied = 0, output = "";
|
|
18
|
+
while (index < source.length) {
|
|
19
|
+
while (space(source[index]) || source[index] === "/") index++;
|
|
20
|
+
const start = index;
|
|
21
|
+
while (index < source.length && !space(source[index]) && source[index] !== "=" && source[index] !== "/" && source[index] !== ">") index++;
|
|
22
|
+
if (index === start) { index++; continue; }
|
|
23
|
+
const name = source.slice(start, index);
|
|
24
|
+
const lower = name.replace(/[A-Z]/g, character => character.toLowerCase());
|
|
25
|
+
const adjusted = adjustedNames.get(lower) ?? lower;
|
|
26
|
+
if (name !== adjusted) {
|
|
27
|
+
output += source.slice(copied, start) + adjusted;
|
|
28
|
+
copied = index;
|
|
29
|
+
}
|
|
30
|
+
while (space(source[index])) index++;
|
|
31
|
+
if (source[index] !== "=") continue;
|
|
32
|
+
index++;
|
|
33
|
+
while (space(source[index])) index++;
|
|
34
|
+
const quote = source[index];
|
|
35
|
+
if (quote === '"' || quote === "'") {
|
|
36
|
+
index++;
|
|
37
|
+
while (index < source.length && source[index] !== quote) index++;
|
|
38
|
+
if (index < source.length) index++;
|
|
39
|
+
} else {
|
|
40
|
+
while (index < source.length && !space(source[index]) && source[index] !== ">") index++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return copied ? output + source.slice(copied) : source;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parseAttributes = HTMLParser.prototype.parseEndOfStartTag;
|
|
47
|
+
HTMLParser.prototype.parseEndOfStartTag = function (source, selfClosing) {
|
|
48
|
+
if (source && this.nextElement?.namespaceURI === "http://www.w3.org/2000/svg") source = adjustNames(source);
|
|
49
|
+
return parseAttributes.call(this, source, selfClosing);
|
|
50
|
+
};
|
package/lib/headless-shell.js
CHANGED
|
@@ -173,7 +173,7 @@ export async function createBrowser({
|
|
|
173
173
|
recordFragment(url, fragmentFrom);
|
|
174
174
|
return;
|
|
175
175
|
}
|
|
176
|
-
navigate(url, { downloadName, post }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
176
|
+
navigate(url, { downloadName, post, referrer: host.documentUrl }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
177
177
|
},
|
|
178
178
|
onDownload: ({ url, name, data }) => {
|
|
179
179
|
if (host !== current) return;
|
|
@@ -259,7 +259,7 @@ export async function createBrowser({
|
|
|
259
259
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
260
260
|
* restarted renderer).
|
|
261
261
|
*/
|
|
262
|
-
async function load(url, invoke = null, downloadName = null, post = null) {
|
|
262
|
+
async function load(url, invoke = null, downloadName = null, post = null, referrer = null) {
|
|
263
263
|
const started = performance.now();
|
|
264
264
|
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
265
265
|
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
@@ -273,7 +273,7 @@ export async function createBrowser({
|
|
|
273
273
|
try {
|
|
274
274
|
const target = new URL(url);
|
|
275
275
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
276
|
-
? await network.navigate(target.href, { post, onResponse: (raw, finalUrl) => {
|
|
276
|
+
? await network.navigate(target.href, { post, referrer: referrer ?? post?.referrer ?? null, onResponse: (raw, finalUrl) => {
|
|
277
277
|
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
278
278
|
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
279
279
|
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
@@ -292,6 +292,7 @@ export async function createBrowser({
|
|
|
292
292
|
status: response.status,
|
|
293
293
|
statusText: response.statusText,
|
|
294
294
|
cookie: network.documentCookie(response.url),
|
|
295
|
+
referrer: response.referrer ?? "",
|
|
295
296
|
};
|
|
296
297
|
} catch (error) {
|
|
297
298
|
document = { url, error: String(error) };
|
|
@@ -324,7 +325,7 @@ export async function createBrowser({
|
|
|
324
325
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
325
326
|
}
|
|
326
327
|
|
|
327
|
-
async function navigate(url, { downloadName = null, post = null } = {}) {
|
|
328
|
+
async function navigate(url, { downloadName = null, post = null, referrer = null } = {}) {
|
|
328
329
|
const resolved = new URL(url, currentUrl).href;
|
|
329
330
|
// A javascript: URL runs in the current document; history does not change.
|
|
330
331
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -336,7 +337,7 @@ export async function createBrowser({
|
|
|
336
337
|
if (!post && downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
337
338
|
currentUrl = resolved;
|
|
338
339
|
} else {
|
|
339
|
-
const result = await load(resolved, null, downloadName, post);
|
|
340
|
+
const result = await load(resolved, null, downloadName, post, referrer);
|
|
340
341
|
if (result?.downloaded) return null;
|
|
341
342
|
// The document is at the URL redirects ended on; the request's fragment
|
|
342
343
|
// carries over to it unless it has its own (Fetch §4.4, HTML §7.4.6).
|
|
@@ -406,6 +407,7 @@ export async function createBrowser({
|
|
|
406
407
|
screenshot: (options = {}) => composite("screenshot", {
|
|
407
408
|
format: options.format === "jpeg" || options.format === "webp" ? options.format : "png",
|
|
408
409
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
410
|
+
optimizeForSpeed: options.optimizeForSpeed === true,
|
|
409
411
|
}),
|
|
410
412
|
title: () => call("title"),
|
|
411
413
|
/**
|
|
@@ -466,7 +468,8 @@ export async function createBrowser({
|
|
|
466
468
|
recordFragment(href, clicked.fragmentFrom);
|
|
467
469
|
return { url: href, title: await call("title") };
|
|
468
470
|
}
|
|
469
|
-
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null
|
|
471
|
+
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null,
|
|
472
|
+
referrer: current.documentUrl });
|
|
470
473
|
return result === null ? null : { url: href, title: await call("title") };
|
|
471
474
|
},
|
|
472
475
|
evaluate: (expression) => call("evaluate", String(expression)),
|
package/lib/input/hit-test.js
CHANGED
|
@@ -80,39 +80,82 @@ export function hitTest(tree, layout, x, y, scroll, viewport) {
|
|
|
80
80
|
* pointer events. Null when the point hits nothing.
|
|
81
81
|
*/
|
|
82
82
|
export function elementAtPoint(tree, displayList, x, y, scroll) {
|
|
83
|
+
const domNode = renderNodeAtPoint(tree, displayList, x, y, scroll)?.domNode;
|
|
84
|
+
return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Also preserves generated controls, whose owning DOM element differs from their render node. */
|
|
88
|
+
export function renderNodeAtPoint(tree, displayList, x, y, scroll) {
|
|
83
89
|
const hittable = hitTestable(tree);
|
|
84
90
|
const nodeId = hitTarget(displayList, x, y, scroll, (id) => !hittable(tree.nodesById.get(id)));
|
|
85
|
-
|
|
86
|
-
return domNode?.nodeType === 1 ? domNode : domNode?.parentElement ?? null;
|
|
91
|
+
return nodeId == null ? null : tree.nodesById.get(nodeId) ?? null;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
// Layout objects are immutable after the layout pass. Keeping their lookup
|
|
95
|
+
// tables under a weak key releases old document nodes when a layout is dropped.
|
|
96
|
+
const geometryIndexes = new WeakMap();
|
|
97
|
+
|
|
98
|
+
function geometryIndex(tree, layout) {
|
|
99
|
+
let index = geometryIndexes.get(layout);
|
|
100
|
+
if (index?.tree === tree) return index;
|
|
101
|
+
const byNode = new Map(), boxes = new WeakMap();
|
|
102
|
+
for (const box of layout.boxes) if (!box.inline && !byNode.has(box.nodeId)) byNode.set(box.nodeId, box);
|
|
103
|
+
for (const node of tree.nodesById.values()) {
|
|
104
|
+
if (node.pseudo || node.domNode?.nodeType !== 1 || boxes.has(node.domNode)) continue;
|
|
105
|
+
// The first actual render node is authoritative, even if it is inline.
|
|
106
|
+
const box = byNode.get(node.id);
|
|
107
|
+
boxes.set(node.domNode, box ? { node, box } : null);
|
|
108
|
+
}
|
|
109
|
+
index = { tree, boxes, textBounds: null };
|
|
110
|
+
geometryIndexes.set(layout, index);
|
|
111
|
+
return index;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The first real render node and block box belonging to an element. */
|
|
115
|
+
export function elementBox(tree, layout, element) {
|
|
116
|
+
return tree && layout ? geometryIndex(tree, layout).boxes.get(element) ?? null : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function textBoundsIndex(tree, layout) {
|
|
120
|
+
const bounds = new WeakMap();
|
|
92
121
|
for (const fragment of layout.fragments) {
|
|
93
122
|
for (const run of fragment.runs) {
|
|
94
123
|
const renderNode = tree.nodesById.get(run.nodeId);
|
|
95
124
|
const owner = renderNode?.domNode?.nodeType === 1
|
|
96
125
|
? renderNode.domNode
|
|
97
126
|
: renderNode?.domNode?.parentElement;
|
|
98
|
-
if (!owner
|
|
127
|
+
if (!owner) continue;
|
|
99
128
|
// A replaced element (an image) spans its own box, not its line's height.
|
|
100
129
|
const replaced = run.type === "image" && run.y != null;
|
|
101
130
|
const top = replaced ? run.y : fragment.y;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
const right = run.x + run.width;
|
|
132
|
+
const bottom = top + (replaced ? run.height : fragment.height);
|
|
133
|
+
for (let ancestor = owner; ancestor; ancestor = ancestor.parentElement) {
|
|
134
|
+
const rect = bounds.get(ancestor);
|
|
135
|
+
if (rect) {
|
|
136
|
+
rect.left = Math.min(rect.left, run.x);
|
|
137
|
+
rect.top = Math.min(rect.top, top);
|
|
138
|
+
rect.right = Math.max(rect.right, right);
|
|
139
|
+
rect.bottom = Math.max(rect.bottom, bottom);
|
|
140
|
+
} else bounds.set(ancestor, { left: run.x, top, right, bottom });
|
|
141
|
+
}
|
|
108
142
|
}
|
|
109
143
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
return bounds;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Returns the content-coordinate bounds contributed by an element's text descendants. */
|
|
148
|
+
export function elementBounds(tree, layout, element) {
|
|
149
|
+
const index = geometryIndex(tree, layout);
|
|
150
|
+
index.textBounds ??= textBoundsIndex(tree, layout);
|
|
151
|
+
const rect = index.textBounds.get(element);
|
|
152
|
+
if (!rect) return null;
|
|
153
|
+
if (rect.width === undefined) {
|
|
154
|
+
rect.width = rect.right - rect.left;
|
|
155
|
+
rect.height = rect.bottom - rect.top;
|
|
156
|
+
Object.freeze(rect);
|
|
157
|
+
}
|
|
158
|
+
return rect;
|
|
116
159
|
}
|
|
117
160
|
|
|
118
161
|
/**
|
|
@@ -120,19 +163,24 @@ export function elementBounds(tree, layout, element) {
|
|
|
120
163
|
* visibility: hidden or pointer-events: none (both inherited, so a visible
|
|
121
164
|
* or pointer-events: auto descendant still does). Text uses its element's style.
|
|
122
165
|
*/
|
|
166
|
+
const hitTestableIndexes = new WeakMap();
|
|
123
167
|
function hitTestable(tree) {
|
|
168
|
+
const cached = hitTestableIndexes.get(tree);
|
|
169
|
+
if (cached) return cached;
|
|
124
170
|
const styles = new Map();
|
|
125
171
|
for (const node of tree.nodesById.values()) {
|
|
126
172
|
if (node.domNode?.nodeType === 1 && node.style && !node.pseudo && !styles.has(node.domNode)) {
|
|
127
173
|
styles.set(node.domNode, node.style);
|
|
128
174
|
}
|
|
129
175
|
}
|
|
130
|
-
|
|
176
|
+
const accepts = (renderNode) => {
|
|
131
177
|
const domNode = renderNode?.domNode;
|
|
132
178
|
const element = domNode?.nodeType === 1 ? domNode : domNode?.parentElement;
|
|
133
179
|
const style = (renderNode?.pseudo ? renderNode.style : null) ?? styles.get(element);
|
|
134
180
|
return !style || (style.visibility !== "hidden" && style.pointerEvents !== "none");
|
|
135
181
|
};
|
|
182
|
+
hitTestableIndexes.set(tree, accepts);
|
|
183
|
+
return accepts;
|
|
136
184
|
}
|
|
137
185
|
|
|
138
186
|
function closestInteractive(node) {
|