buninu-browser 0.1.0 → 0.2.0
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 +17 -0
- package/LICENSES/brotli.txt +19 -0
- package/README.md +41 -7
- package/THIRD_PARTY_NOTICES.md +26 -4
- package/buninu-browser.js +10 -2
- package/lib/audio/pcm.js +16 -0
- package/lib/audio/piano.js +109 -0
- package/lib/audio/pulse-client.js +413 -0
- package/lib/cdp-server.js +25 -6
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/headless-shell.js +108 -14
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +112 -27
- package/lib/network/network-service.js +7 -3
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +2 -2
- package/lib/render-tree/index.js +13 -0
- package/lib/renderer/bindings.js +541 -53
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +102 -6
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +226 -22
- package/lib/renderer/page-renderer.js +1293 -212
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +268 -20
- package/lib/user-agent.js +12 -0
- package/package.json +1 -1
- package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
- package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
- package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
- package/patches/happy-dom/README.md +12 -0
- package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
- package/patches/ses/README.md +16 -0
- package/test/computed-style.test.js +52 -0
- package/test/document-types.test.js +111 -0
- package/test/download.test.js +192 -0
- package/test/fixtures/tone.mp3 +0 -0
- package/test/flex.test.js +11 -0
- package/test/happy-dom-matches.test.js +25 -0
- package/test/headless-shell.test.js +27 -0
- package/test/hit-test.test.js +37 -0
- package/test/page-scripts.test.js +388 -29
- package/test/piano.test.js +65 -0
- package/test/positioning.test.js +11 -0
- package/test/pulse-client.test.js +130 -0
- package/test/render-tree.test.js +14 -0
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/text-layout.test.js +22 -0
- package/test/user-agent.test.js +42 -0
- package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
- package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
- package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
- package/vendor/ses/dist/ses.mjs +2 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a top-level response looks like in the viewport. HTML is shown as
|
|
3
|
+
* itself; anything else gets a small generated HTML document, as browsers do
|
|
4
|
+
* for "standalone" resources (HTML §7.4.4-§7.4.7):
|
|
5
|
+
*
|
|
6
|
+
* - images: the image alone on a dark background, centered and fitted to the
|
|
7
|
+
* width (SVG inline, other formats as a data: URL so they are not fetched
|
|
8
|
+
* twice);
|
|
9
|
+
* - video and audio: a <video> or <audio> element for the URL on the same
|
|
10
|
+
* background, with the file's name and type under it;
|
|
11
|
+
* - the formats bunmsh's `serve` previews, handled the way it does: Markdown
|
|
12
|
+
* rendered with Bun.markdown.html, and JSON, JSON5, JSONC, JSON Lines, YAML,
|
|
13
|
+
* TOML and XML parsed with Bun's parsers and printed as highlighted JSON,
|
|
14
|
+
* under a "Pretty-print" checkbox that is on by default and, unticked,
|
|
15
|
+
* shows the text as sent;
|
|
16
|
+
* - everything else, whatever its bytes: the text in a <pre>. There is no
|
|
17
|
+
* "cannot be shown" page (see AGENTS.md).
|
|
18
|
+
*
|
|
19
|
+
* Large files would stall layout (on a phone, 1 MB of text takes seconds and
|
|
20
|
+
* 4 MB never finished): past DISPLAY_LIMIT only the start of the text is
|
|
21
|
+
* shown, and past PRETTY_LIMIT the preview is skipped for the text, each
|
|
22
|
+
* with a bar at the top that says so and points to casty's `download`.
|
|
23
|
+
*
|
|
24
|
+
* The bytes kept for downloads are the response's own, not this HTML. Text is
|
|
25
|
+
* decoded with the Content-Type charset, UTF-8 when there is none.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const HTML_TYPES = new Set(["text/html", "application/xhtml+xml"]);
|
|
29
|
+
// Measured on a phone: 256 KiB of text lays out in about 2 s; a 32 KiB
|
|
30
|
+
// preview (highlighted JSON, many spans) in about 2.5 s.
|
|
31
|
+
export const DISPLAY_LIMIT = 256 * 1024;
|
|
32
|
+
export const PRETTY_LIMIT = 32 * 1024;
|
|
33
|
+
|
|
34
|
+
// From bunmsh serve.js (MIT, same author): the formats it previews, by file
|
|
35
|
+
// extension, with its parser names, pretty() and highlightJson().
|
|
36
|
+
const PARSERS = new Map([
|
|
37
|
+
["json", { name: "JSON.parse", parse: JSON.parse }],
|
|
38
|
+
["json5", { name: "Bun.JSON5.parse", parse: Bun.JSON5.parse }],
|
|
39
|
+
["jsonc", { name: "Bun.JSONC.parse", parse: Bun.JSONC.parse }],
|
|
40
|
+
["jsonl", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
|
|
41
|
+
["ndjson", { name: "Bun.JSONL.parse", parse: Bun.JSONL.parse }],
|
|
42
|
+
["yaml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
|
|
43
|
+
["yml", { name: "Bun.YAML.parse", parse: Bun.YAML.parse }],
|
|
44
|
+
["toml", { name: "Bun.TOML.parse", parse: Bun.TOML.parse }],
|
|
45
|
+
]);
|
|
46
|
+
if (typeof Bun.XML?.parse === "function") {
|
|
47
|
+
PARSERS.set("xml", { name: "Bun.XML.parse", parse: Bun.XML.parse });
|
|
48
|
+
}
|
|
49
|
+
const MARKDOWN = "Bun.markdown.html";
|
|
50
|
+
|
|
51
|
+
// A URL without one of those extensions: its Content-Type names the format.
|
|
52
|
+
const EXTENSION_BY_TYPE = new Map([
|
|
53
|
+
["text/markdown", "md"], ["text/x-markdown", "md"],
|
|
54
|
+
["application/json", "json"], ["text/json", "json"], ["application/json5", "json5"], ["application/jsonc", "jsonc"],
|
|
55
|
+
["application/jsonl", "jsonl"], ["application/x-ndjson", "ndjson"], ["application/jsonlines", "jsonl"],
|
|
56
|
+
["text/yaml", "yaml"], ["application/yaml", "yaml"], ["application/x-yaml", "yaml"], ["text/x-yaml", "yaml"],
|
|
57
|
+
["application/toml", "toml"], ["text/x-toml", "toml"],
|
|
58
|
+
["application/xml", "xml"], ["text/xml", "xml"],
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/** "md", a PARSERS key, or null: what bunmsh serve would preview this as. */
|
|
62
|
+
function previewKind(name, type) {
|
|
63
|
+
const byName = extension(name);
|
|
64
|
+
if (byName === "md" || PARSERS.has(byName)) return byName;
|
|
65
|
+
const byType = EXTENSION_BY_TYPE.get(type) ?? (type.endsWith("+json") ? "json" : type.endsWith("+xml") ? "xml" : null);
|
|
66
|
+
return byType === "md" || PARSERS.has(byType) ? byType : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {{ url: string, contentType?: string, body: Uint8Array }} response
|
|
71
|
+
* @returns {{ source: string, contentType: string }}
|
|
72
|
+
*/
|
|
73
|
+
export function displayDocument({ url, contentType = "", body }) {
|
|
74
|
+
const { type, charset } = parseContentType(contentType);
|
|
75
|
+
const name = fileName(url);
|
|
76
|
+
if (HTML_TYPES.has(type) || (type === "" && looksLikeHtml(body))) {
|
|
77
|
+
return { source: decode(body, charset), contentType: contentType || "text/html" };
|
|
78
|
+
}
|
|
79
|
+
if (type === "image/svg+xml") return html(name, IMAGE_STYLE, `<div class="image">${stripProlog(decode(body, charset))}</div>`);
|
|
80
|
+
if (type.startsWith("image/")) {
|
|
81
|
+
const src = `data:${type};base64,${Buffer.from(body).toString("base64")}`;
|
|
82
|
+
return html(name, IMAGE_STYLE, `<div class="image"><img src="${src}" alt="${Bun.escapeHTML(name)}"></div>`);
|
|
83
|
+
}
|
|
84
|
+
const media = mediaKind(type, name);
|
|
85
|
+
if (media) return mediaDocument(url, name, type || media, media, body.byteLength);
|
|
86
|
+
const kind = previewKind(name, type);
|
|
87
|
+
if (kind && body.byteLength <= PRETTY_LIMIT) return previewDocument(url, name, kind, decode(body, charset));
|
|
88
|
+
// Everything else is shown as text, whatever the bytes are: no file is unviewable.
|
|
89
|
+
const note = kind
|
|
90
|
+
? `Too large to pretty-print quickly (${formatSize(body.byteLength)})${body.byteLength > DISPLAY_LIMIT ? `; showing the first ${formatSize(DISPLAY_LIMIT)}` : ""}.`
|
|
91
|
+
: body.byteLength > DISPLAY_LIMIT ? `Large file (${formatSize(body.byteLength)}); showing the first ${formatSize(DISPLAY_LIMIT)}.` : null;
|
|
92
|
+
const shown = body.byteLength > DISPLAY_LIMIT ? body.subarray(0, DISPLAY_LIMIT) : body;
|
|
93
|
+
const bar = `<div class="bar"><span>${note ? `${note} The whole file: casty's <code>download</code> (Ctrl+E).` : ""}</span>${downloadButton(url)}</div>`;
|
|
94
|
+
return html(name, TEXT_STYLE, `${bar}<pre>${Bun.escapeHTML(decode(shown, charset))}</pre>`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Built-in audio diagnostics page; CSS keys work without SVG or page scripts. */
|
|
98
|
+
export function audioDemoDocument() {
|
|
99
|
+
const whites = [["A4", 69], ["B4", 71], ["C5", 72], ["D5", 74],
|
|
100
|
+
["E5", 76], ["F5", 77], ["G5", 79], ["A5", 81]]
|
|
101
|
+
.map(([name, note]) => `<button type="button" class="white-key" data-piano-note="${note}"><span>${name}</span></button>`).join("");
|
|
102
|
+
const blacks = [["A♯4", 70, "8.75"], ["C♯5", 73, "33.75"], ["D♯5", 75, "46.25"],
|
|
103
|
+
["F♯5", 78, "71.25"], ["G♯5", 80, "83.75"]]
|
|
104
|
+
.map(([name, note, left]) => `<button type="button" class="black-key" data-piano-note="${note}" style="left:${left}%"><span>${name}</span></button>`).join("");
|
|
105
|
+
const style = `:root { color-scheme: dark }
|
|
106
|
+
body { margin: 0; min-height: 100vh; background: #151820; color: #f4f5f7; font: 15px sans-serif }
|
|
107
|
+
.demo { max-width: 560px; margin: 0 auto; padding: 42px 18px 24px; text-align: center }
|
|
108
|
+
h1 { margin: 0 0 8px; font-size: 24px }
|
|
109
|
+
.note { color: #abb1c0; font-size: 13px; line-height: 1.5 }
|
|
110
|
+
.keyboard { display: flex; position: relative; width: 100%; max-width: 420px; height: 150px; margin: 32px auto 20px; box-shadow: 0 8px 22px #0008 }
|
|
111
|
+
.white-key { flex: 1; position: relative; box-sizing: border-box; padding: 0; border: 1px solid #6f7683; border-radius: 0 0 6px 6px; background: #f5f3ed; color: #2c3039; cursor: pointer; font: inherit }
|
|
112
|
+
.white-key span { position: absolute; bottom: 9px; left: 0; right: 0; font-size: 12px }
|
|
113
|
+
.black-key { position: absolute; z-index: 1; top: 0; width: 8.6%; height: 92px; padding: 0; border: 0; border-radius: 0 0 5px 5px; background: #262b35; box-shadow: 0 4px 5px #0008; color: #e8e8e8; cursor: pointer; font: inherit }
|
|
114
|
+
.black-key span { position: absolute; bottom: 8px; left: 0; right: 0; font-size: 10px }
|
|
115
|
+
input { box-sizing: border-box; width: 100%; max-width: 420px; padding: 10px 12px; border: 1px solid #858da0; border-radius: 7px; background: #222735; color: white; font: 15px sans-serif }
|
|
116
|
+
input:focus { outline: 2px solid #90b4ff; outline-offset: 2px }`;
|
|
117
|
+
return html("Audio demo", style, `<main class="demo"><h1>Audio demo</h1>`
|
|
118
|
+
+ `<p class="note">Tap a key, or click the field and play with Space, J, K and the LazyI keyboard bindings.</p>`
|
|
119
|
+
+ `<div class="keyboard" aria-label="Piano keyboard">${whites}${blacks}</div>`
|
|
120
|
+
+ `<input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Play Piano · 彈鋼琴 · ピアノを弾く" aria-label="Play Piano">`
|
|
121
|
+
+ `<p class="note">Space = A4 · J = B4 · K = C♯5</p></main>`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatSize(bytes) {
|
|
125
|
+
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Media the renderer's decoder (wasmpeg's FFmpeg) handles: by type, or by
|
|
129
|
+
// extension when the server's type says nothing.
|
|
130
|
+
const VIDEO_TYPES = new Set(["application/ogg", "application/mp4", "application/x-matroska", "application/mxf"]);
|
|
131
|
+
const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "mkv", "avi", "ogv", "ogg", "ts", "mts", "m2ts",
|
|
132
|
+
"mpg", "mpeg", "3gp", "3g2", "wmv", "flv", "mxf", "y4m"]);
|
|
133
|
+
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "flac", "oga", "opus", "m4a", "aac", "wma", "aiff", "aif", "amr", "ac3", "mka"]);
|
|
134
|
+
|
|
135
|
+
/** "video", "audio" or null. */
|
|
136
|
+
function mediaKind(type, name) {
|
|
137
|
+
if (type.startsWith("video/") || VIDEO_TYPES.has(type)) return "video";
|
|
138
|
+
if (type.startsWith("audio/")) return "audio";
|
|
139
|
+
if (!["", "application/octet-stream", "binary/octet-stream"].includes(type)) return null;
|
|
140
|
+
const ext = extension(name);
|
|
141
|
+
return VIDEO_EXTENSIONS.has(ext) ? "video" : AUDIO_EXTENSIONS.has(ext) ? "audio" : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A media document (HTML §7.4.6): the element alone, playing the URL itself. */
|
|
145
|
+
function mediaDocument(url, name, type, kind, size) {
|
|
146
|
+
const src = Bun.escapeHTML(url);
|
|
147
|
+
const about = `${Bun.escapeHTML(name)} (${Bun.escapeHTML(type)}, ${size} bytes)`;
|
|
148
|
+
const element = kind === "video"
|
|
149
|
+
? `<video controls autoplay src="${src}"></video><p class="note">${about}. Click the video to play.</p>`
|
|
150
|
+
: `<audio controls src="${src}"></audio><p class="note">${about}. Click to play; `
|
|
151
|
+
+ "sound goes to a PulseAudio server on 127.0.0.1:4713, such as jspulse.</p>";
|
|
152
|
+
const piano = kind === "audio" && (type === "audio/mpeg" || extension(name) === "mp3")
|
|
153
|
+
? `<label class="piano">鋼琴 <input id="buninu-piano-input" type="text" autocomplete="off" placeholder="Space J K · F1–F12" aria-label="鋼琴鍵盤"></label>`
|
|
154
|
+
: "";
|
|
155
|
+
return html(name, IMAGE_STYLE, `${piano}<div class="image">${element}</div>`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** bunmsh serve's preview (its previewResponse), under a Pretty-print checkbox over the text as sent. */
|
|
159
|
+
function previewDocument(url, name, kind, source) {
|
|
160
|
+
const raw = (hidden) => `<pre id="raw"${hidden ? " hidden" : ""}>${Bun.escapeHTML(source)}</pre>`;
|
|
161
|
+
const parser = PARSERS.get(kind);
|
|
162
|
+
const previewer = kind === "md" ? MARKDOWN : parser.name;
|
|
163
|
+
let formatted;
|
|
164
|
+
try {
|
|
165
|
+
formatted = kind === "md"
|
|
166
|
+
? `<div id="formatted">${Bun.markdown.html(source, { headings: true })}</div>`
|
|
167
|
+
: `<pre id="formatted"><code>${highlightJson(pretty(parser.parse(source)))}</code></pre>`;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return html(`Cannot preview ${name}`, TEXT_STYLE, `<div class="bar"><span></span>${downloadButton(url)}</div><h1>Cannot preview ${Bun.escapeHTML(name)}</h1>`
|
|
170
|
+
+ `<pre>${Bun.escapeHTML(error?.stack ?? String(error))}</pre>${raw(false)}`);
|
|
171
|
+
}
|
|
172
|
+
return html(`${name} — ${previewer}`, TEXT_STYLE, `<div class="bar"><span><label><input type="checkbox" id="pretty-print" checked> Pretty-print</label>`
|
|
173
|
+
+ ` <span class="note">(${previewer})</span></span>${downloadButton(url)}</div>${formatted}${raw(true)}`
|
|
174
|
+
+ `<script>(() => {
|
|
175
|
+
const box = document.getElementById("pretty-print");
|
|
176
|
+
const show = () => {
|
|
177
|
+
document.getElementById("formatted").hidden = !box.checked;
|
|
178
|
+
document.getElementById("raw").hidden = box.checked;
|
|
179
|
+
};
|
|
180
|
+
box.addEventListener("change", show);
|
|
181
|
+
})();</script>`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function downloadButton(url) {
|
|
185
|
+
return `<a class="download" href="${Bun.escapeHTML(url)}" download>Download</a>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Centered in the viewport both ways, as browsers show a standalone image.
|
|
189
|
+
const IMAGE_STYLE = `body { margin: 0; background: #0e0e0e }
|
|
190
|
+
.image { min-height: 100vh; display: flex; align-items: center; justify-content: center }
|
|
191
|
+
.image { flex-direction: column }
|
|
192
|
+
.image img, .image svg { max-width: 100%; height: auto; background: white }
|
|
193
|
+
.image video { max-width: 100% }
|
|
194
|
+
.image .note { color: #bbb; font-family: sans-serif; font-size: 13px }
|
|
195
|
+
.piano { position: fixed; z-index: 2; top: 8px; left: 8px; display: flex; align-items: center; gap: 5px; color: #eee; font: 13px sans-serif }
|
|
196
|
+
.piano input { width: 128px; padding: 3px 5px; font: 13px sans-serif }`;
|
|
197
|
+
// The link style and highlight colors are bunmsh serve's.
|
|
198
|
+
const TEXT_STYLE = `:root { color-scheme: light dark }
|
|
199
|
+
body { margin: 8px }
|
|
200
|
+
a { color: inherit; text-decoration: none }
|
|
201
|
+
pre { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font-family: monospace }
|
|
202
|
+
code { font: inherit }
|
|
203
|
+
.bar { display: flex; justify-content: space-between; align-items: center; gap: 16px; font-family: sans-serif; margin-bottom: 8px }
|
|
204
|
+
.download { margin-left: auto; padding: 4px 9px; border: 1px solid currentColor; border-radius: 4px; white-space: nowrap }
|
|
205
|
+
.note { font-family: sans-serif; color: #666 }
|
|
206
|
+
.json-statement { color: #b0005a }
|
|
207
|
+
.json-string { color: #6b5d00 }
|
|
208
|
+
.json-special { color: #087f5b }
|
|
209
|
+
.json-number, .json-constant { color: #5f3dc4 }
|
|
210
|
+
@media (prefers-color-scheme: dark) {
|
|
211
|
+
.json-statement { color: #f92672 }
|
|
212
|
+
.json-string { color: #e6db74 }
|
|
213
|
+
.json-special { color: #a6e22e }
|
|
214
|
+
.json-number, .json-constant { color: #ae81ff }
|
|
215
|
+
}`;
|
|
216
|
+
|
|
217
|
+
function html(title, style, body) {
|
|
218
|
+
return {
|
|
219
|
+
source: `<!doctype html><html><head><meta charset="utf-8"><title>${Bun.escapeHTML(title)}</title>`
|
|
220
|
+
+ `<style>${style}</style></head><body>${body}</body></html>`,
|
|
221
|
+
contentType: "text/html",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// bunmsh serve.js pretty(): BigInts (TOML integers) keep their digits as "123n".
|
|
226
|
+
const pretty = (value) => {
|
|
227
|
+
try {
|
|
228
|
+
return JSON.stringify(value, (_key, item) =>
|
|
229
|
+
typeof item === "bigint" ? `${item}n` : item, 2) ?? String(value);
|
|
230
|
+
} catch {
|
|
231
|
+
return Bun.inspect(value, { colors: false, depth: Infinity });
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// bunmsh serve.js highlightJson(): statement keys, strings and escapes,
|
|
236
|
+
// numbers, and true/false/null constants of the JSON pretty() emits.
|
|
237
|
+
export const highlightJson = (source) => {
|
|
238
|
+
const input = String(source);
|
|
239
|
+
const token = /"(?:\\(?:u[\da-fA-F]{4}|.)|[^"\\])*"|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
|
|
240
|
+
let output = "", cursor = 0, match;
|
|
241
|
+
const stringHtml = (value) => {
|
|
242
|
+
let html = "", offset = 0;
|
|
243
|
+
for (const escape of value.matchAll(/\\(?:u[\da-fA-F]{4}|.)/g)) {
|
|
244
|
+
html += Bun.escapeHTML(value.slice(offset, escape.index));
|
|
245
|
+
html += `<span class="json-special">${Bun.escapeHTML(escape[0])}</span>`;
|
|
246
|
+
offset = escape.index + escape[0].length;
|
|
247
|
+
}
|
|
248
|
+
return html + Bun.escapeHTML(value.slice(offset));
|
|
249
|
+
};
|
|
250
|
+
while ((match = token.exec(input))) {
|
|
251
|
+
output += Bun.escapeHTML(input.slice(cursor, match.index));
|
|
252
|
+
const value = match[0];
|
|
253
|
+
let end = token.lastIndex;
|
|
254
|
+
if (value.startsWith('"')) {
|
|
255
|
+
const propertyEnd = /^\s*:/.exec(input.slice(end));
|
|
256
|
+
if (propertyEnd) {
|
|
257
|
+
end += propertyEnd[0].length;
|
|
258
|
+
output += `<span class="json-statement">${Bun.escapeHTML(input.slice(match.index, end))}</span>`;
|
|
259
|
+
} else {
|
|
260
|
+
output += `<span class="json-string">${stringHtml(value)}</span>`;
|
|
261
|
+
}
|
|
262
|
+
} else if (value === "true" || value === "false" || value === "null") {
|
|
263
|
+
output += `<span class="json-constant">${value}</span>`;
|
|
264
|
+
} else {
|
|
265
|
+
output += `<span class="json-number">${value}</span>`;
|
|
266
|
+
}
|
|
267
|
+
cursor = end;
|
|
268
|
+
token.lastIndex = end;
|
|
269
|
+
}
|
|
270
|
+
return output + Bun.escapeHTML(input.slice(cursor));
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export function parseContentType(value) {
|
|
274
|
+
const [type = "", ...parameters] = String(value).split(";");
|
|
275
|
+
const charset = parameters.map((part) => part.trim().match(/^charset=["']?([^"';]+)/i)?.[1]).find(Boolean) ?? null;
|
|
276
|
+
return { type: type.trim().toLowerCase(), charset };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function decode(body, charset) {
|
|
280
|
+
try {
|
|
281
|
+
return new TextDecoder(charset ?? "utf-8").decode(body);
|
|
282
|
+
} catch {
|
|
283
|
+
return new TextDecoder().decode(body);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function fileName(url) {
|
|
288
|
+
try {
|
|
289
|
+
const segment = new URL(url).pathname.split("/").filter(Boolean).pop() ?? "";
|
|
290
|
+
return decodeURIComponent(segment) || new URL(url).hostname || url;
|
|
291
|
+
} catch {
|
|
292
|
+
return String(url);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function extension(name) {
|
|
297
|
+
const dot = name.lastIndexOf(".");
|
|
298
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function stripProlog(svg) {
|
|
302
|
+
return svg.replace(/^\s*<\?xml[^>]*>\s*/i, "").replace(/<!DOCTYPE[^>]*>\s*/i, "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function looksLikeHtml(body) {
|
|
306
|
+
const head = new TextDecoder().decode(body.subarray(0, 512)).trimStart().toLowerCase();
|
|
307
|
+
return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head") || head.startsWith("<body") || head === "";
|
|
308
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, win32 } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Windows can move the Downloads known folder away from %USERPROFILE%. Query
|
|
5
|
+
// its registered location through PowerShell so non-ASCII paths stay UTF-8.
|
|
6
|
+
const WINDOWS_DOWNLOADS = [
|
|
7
|
+
"$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)",
|
|
8
|
+
"$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders')",
|
|
9
|
+
"if ($key) { $path = $key.GetValue('{374DE290-123F-4565-9164-39C4925E467B}'); if ($path) { [Console]::Write([Environment]::ExpandEnvironmentVariables($path)) } }",
|
|
10
|
+
].join("; ");
|
|
11
|
+
|
|
12
|
+
function windowsDownloads() {
|
|
13
|
+
try {
|
|
14
|
+
const result = Bun.spawnSync({ cmd: ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_DOWNLOADS],
|
|
15
|
+
stdout: "pipe", stderr: "ignore" });
|
|
16
|
+
return result.exitCode === 0 ? new TextDecoder().decode(result.stdout).trim() : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function downloadsDirectory({ platform = process.platform, home = homedir(), queryWindows = windowsDownloads } = {}) {
|
|
23
|
+
if (platform === "win32") {
|
|
24
|
+
const known = queryWindows();
|
|
25
|
+
return known && win32.isAbsolute(known) ? known : win32.join(home, "Downloads");
|
|
26
|
+
}
|
|
27
|
+
return join(home, "Downloads");
|
|
28
|
+
}
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, open, unlink } from "node:fs/promises";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
|
+
import { Readable, Transform } from "node:stream";
|
|
4
|
+
import { pipeline } from "node:stream/promises";
|
|
5
|
+
import { downloadsDirectory } from "./download-directory.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_DOWNLOAD_DIR = downloadsDirectory();
|
|
8
|
+
|
|
9
|
+
export function isAttachment(value) {
|
|
10
|
+
return /^\s*attachment\s*(?:;|$)/i.test(value ?? "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function suggestedFilename(url, disposition = "", attributeName = "") {
|
|
14
|
+
const encoded = /(?:^|;)\s*filename\*\s*=\s*(?:UTF-8''|utf-8'[^']*')([^;]+)/i.exec(disposition)?.[1];
|
|
15
|
+
const plain = /(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;]*))/i.exec(disposition);
|
|
16
|
+
let name = "";
|
|
17
|
+
if (encoded) { try { name = decodeURIComponent(encoded.trim().replace(/^"|"$/g, "")); } catch {} }
|
|
18
|
+
if (!name) name = plain?.[1] ?? plain?.[2]?.trim() ?? attributeName;
|
|
19
|
+
if (!name) {
|
|
20
|
+
const target = new URL(url);
|
|
21
|
+
const segment = target.pathname.split("/").filter(Boolean).at(-1);
|
|
22
|
+
try { name = decodeURIComponent(segment ?? ""); } catch { name = segment ?? ""; }
|
|
23
|
+
if (!name) name = target.hostname || "download";
|
|
24
|
+
}
|
|
25
|
+
name = name.split(/[\\/]/).at(-1).replace(/[<>:"|?*\x00-\x1f\x7f]/g, "_").replace(/^\.+/, "_").replace(/[. ]+$/, "").slice(0, 200);
|
|
26
|
+
return name || "download";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Save a response stream or already held bytes without replacing an existing file. */
|
|
30
|
+
export async function saveDownload({ url, disposition = "", attributeName = "", filename = null, body, dir = DEFAULT_DOWNLOAD_DIR }) {
|
|
31
|
+
await mkdir(dir, { recursive: true });
|
|
32
|
+
const name = filename ?? suggestedFilename(url, disposition, attributeName);
|
|
33
|
+
const extension = extname(name);
|
|
34
|
+
const stem = name.slice(0, name.length - extension.length);
|
|
35
|
+
let file;
|
|
36
|
+
let path;
|
|
37
|
+
for (let number = 1; ; number++) {
|
|
38
|
+
path = join(dir, number === 1 ? name : `${stem} (${number})${extension}`);
|
|
39
|
+
try { file = await open(path, "wx"); break; }
|
|
40
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
if (body instanceof Uint8Array) {
|
|
45
|
+
bytes = body.byteLength;
|
|
46
|
+
await file.writeFile(body);
|
|
47
|
+
} else if (body) {
|
|
48
|
+
const count = new Transform({ transform(chunk, _encoding, done) {
|
|
49
|
+
bytes += chunk.byteLength;
|
|
50
|
+
done(null, chunk);
|
|
51
|
+
} });
|
|
52
|
+
await pipeline(Readable.fromWeb(body), count, file.createWriteStream());
|
|
53
|
+
}
|
|
54
|
+
else await file.close();
|
|
55
|
+
return { path, name: path.slice(dir.length + 1), bytes };
|
|
56
|
+
} catch (error) {
|
|
57
|
+
await file.close().catch(() => {});
|
|
58
|
+
await unlink(path).catch(() => {});
|
|
59
|
+
throw error;
|
|
60
|
+
} finally {
|
|
61
|
+
await file.close().catch(() => {});
|
|
62
|
+
}
|
|
63
|
+
}
|
package/lib/headless-shell.js
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
* the renderer sandbox, for every page.
|
|
12
12
|
*/
|
|
13
13
|
import { CdpServer } from "./cdp-server.js";
|
|
14
|
+
import { DEFAULT_DOWNLOAD_DIR, isAttachment, saveDownload, suggestedFilename } from "./download.js";
|
|
15
|
+
import { audioDemoDocument, displayDocument } from "./document-types.js";
|
|
14
16
|
import { installLog, logFileFromEnv, writeLog } from "./log.js";
|
|
15
17
|
import { NetworkService } from "./network/network-service.js";
|
|
16
18
|
import { RendererHost } from "./renderer/host.js";
|
|
17
19
|
import { MOBILE_FLAG, MOBILE_LAYOUT_WIDTH, MOBILE_USER_AGENT } from "./renderer/flags.js";
|
|
20
|
+
import { DESKTOP_USER_AGENT } from "./user-agent.js";
|
|
18
21
|
|
|
19
22
|
/** Opt-in phone presentation: mobile user agent and touch media features. */
|
|
20
23
|
export const MOBILE = "--mobile";
|
|
@@ -70,10 +73,12 @@ export async function createBrowser({
|
|
|
70
73
|
allowHostJs = false, spareRenderer = true, network = new NetworkService(), rendererTimeout = 30_000, mobile = false,
|
|
71
74
|
} = {}) {
|
|
72
75
|
// --mobile fixes the user agent for page requests and navigator.userAgent.
|
|
73
|
-
|
|
76
|
+
network.userAgent = mobile ? MOBILE_USER_AGENT : DESKTOP_USER_AGENT;
|
|
74
77
|
const history = [{ url: "about:blank", x: 0, y: 0 }];
|
|
75
78
|
let historyIndex = 0;
|
|
76
79
|
let currentUrl = "about:blank";
|
|
80
|
+
// The current document's response as fetched (raw bytes), for downloads.
|
|
81
|
+
let resource = null;
|
|
77
82
|
let current = null;
|
|
78
83
|
let currentKey = null;
|
|
79
84
|
let spare = null;
|
|
@@ -84,6 +89,8 @@ export async function createBrowser({
|
|
|
84
89
|
// Listeners for frame and lifecycle notifications (the CDP server).
|
|
85
90
|
const frameListeners = new Set();
|
|
86
91
|
const lifecycleListeners = new Set();
|
|
92
|
+
const downloadListeners = new Set();
|
|
93
|
+
let downloadBehavior = { behavior: "allow", downloadPath: DEFAULT_DOWNLOAD_DIR, eventsEnabled: false };
|
|
87
94
|
// The committed document: its renderer and load generation. Lifecycle
|
|
88
95
|
// messages can arrive before loadDocument returns, so each host records
|
|
89
96
|
// what every generation has reached.
|
|
@@ -104,6 +111,30 @@ export async function createBrowser({
|
|
|
104
111
|
}
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
async function download({ url, body, disposition = "", attributeName = "", totalBytes = 0 }) {
|
|
115
|
+
const guid = crypto.randomUUID();
|
|
116
|
+
const filename = suggestedFilename(url, disposition, attributeName);
|
|
117
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "willBegin", { guid, url, suggestedFilename: filename });
|
|
118
|
+
if (downloadBehavior.behavior === "deny") {
|
|
119
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
|
|
120
|
+
return { downloaded: true };
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "inProgress", receivedBytes: 0, totalBytes });
|
|
124
|
+
const saved = await saveDownload({ url, body, disposition, attributeName,
|
|
125
|
+
filename: downloadBehavior.behavior === "allowAndName" ? guid : null, dir: downloadBehavior.downloadPath });
|
|
126
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", {
|
|
127
|
+
guid, state: "completed", receivedBytes: saved.bytes, totalBytes: totalBytes || saved.bytes, filePath: saved.path,
|
|
128
|
+
});
|
|
129
|
+
writeLog("download", `${url} -> ${saved.path}`);
|
|
130
|
+
return { downloaded: true, path: saved.path };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (downloadBehavior.eventsEnabled) notify(downloadListeners, "progress", { guid, state: "canceled", receivedBytes: 0, totalBytes });
|
|
133
|
+
console.error(`buninu-browser: download failed: ${error?.message ?? error}`);
|
|
134
|
+
return { downloaded: true, error };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
function createHost() {
|
|
108
139
|
const host = new RendererHost({
|
|
109
140
|
network,
|
|
@@ -115,9 +146,17 @@ export async function createBrowser({
|
|
|
115
146
|
await load(currentUrl, invoke);
|
|
116
147
|
},
|
|
117
148
|
// Only the renderer showing the current page may navigate it.
|
|
118
|
-
onNavigate: (url) => {
|
|
149
|
+
onNavigate: (url, downloadName, fragmentFrom) => {
|
|
150
|
+
if (host !== current) return;
|
|
151
|
+
if (fragmentFrom) {
|
|
152
|
+
recordFragment(url, fragmentFrom);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
navigate(url, { downloadName }).catch((error) => console.error(`buninu-browser: navigation failed: ${error.message}`));
|
|
156
|
+
},
|
|
157
|
+
onDownload: ({ url, name, data }) => {
|
|
119
158
|
if (host !== current) return;
|
|
120
|
-
|
|
159
|
+
void download({ url, body: data, attributeName: name, totalBytes: data.byteLength });
|
|
121
160
|
},
|
|
122
161
|
onFrame: () => {
|
|
123
162
|
if (host === current) notify(frameListeners);
|
|
@@ -184,22 +223,36 @@ export async function createBrowser({
|
|
|
184
223
|
* final origin. `invoke` pins the renderer (used when restoring a
|
|
185
224
|
* restarted renderer).
|
|
186
225
|
*/
|
|
187
|
-
async function load(url, invoke = null) {
|
|
226
|
+
async function load(url, invoke = null, downloadName = null) {
|
|
188
227
|
const started = performance.now();
|
|
189
|
-
|
|
228
|
+
if (downloadName !== null && resource && withoutHash(url) === withoutHash(resource.url)) {
|
|
229
|
+
return download({ url: resource.url, body: resource.body, attributeName: downloadName, totalBytes: resource.body.byteLength });
|
|
230
|
+
}
|
|
190
231
|
let document;
|
|
191
232
|
if (url === "about:blank") {
|
|
192
233
|
document = { url };
|
|
234
|
+
} else if (url === "about:audio") {
|
|
235
|
+
document = { url, ...audioDemoDocument() };
|
|
193
236
|
} else {
|
|
194
237
|
try {
|
|
195
238
|
const target = new URL(url);
|
|
196
239
|
const response = ["http:", "https:"].includes(target.protocol)
|
|
197
|
-
? await network.navigate(target.href)
|
|
240
|
+
? await network.navigate(target.href, { onResponse: (raw, finalUrl) => {
|
|
241
|
+
if (!raw.ok || (downloadName === null && !isAttachment(raw.headers.get("content-disposition")))) return null;
|
|
242
|
+
return download({ url: finalUrl, body: raw.body, disposition: raw.headers.get("content-disposition") ?? "",
|
|
243
|
+
attributeName: downloadName ?? "", totalBytes: Number(raw.headers.get("content-length")) || 0 });
|
|
244
|
+
} })
|
|
198
245
|
: await localDocument(target);
|
|
246
|
+
if (response.downloaded) return response;
|
|
247
|
+
if (downloadName !== null) return download({ url: response.url, body: response.body,
|
|
248
|
+
attributeName: downloadName, totalBytes: response.body.byteLength });
|
|
249
|
+
resource = { url: response.url, contentType: response.contentType ?? "", body: response.body };
|
|
250
|
+
// Non-HTML responses (images, JSON, TOML, text...) are shown through a generated page.
|
|
251
|
+
const shown = displayDocument({ url: response.url, contentType: response.contentType, body: response.body });
|
|
199
252
|
document = {
|
|
200
253
|
url: response.url,
|
|
201
|
-
source:
|
|
202
|
-
contentType:
|
|
254
|
+
source: shown.source,
|
|
255
|
+
contentType: shown.contentType,
|
|
203
256
|
status: response.status,
|
|
204
257
|
statusText: response.statusText,
|
|
205
258
|
cookie: network.documentCookie(response.url),
|
|
@@ -208,6 +261,7 @@ export async function createBrowser({
|
|
|
208
261
|
document = { url, error: String(error) };
|
|
209
262
|
}
|
|
210
263
|
}
|
|
264
|
+
if (document.url === "about:blank" || document.url === "about:audio" || document.error) resource = null;
|
|
211
265
|
currentUrl = document.url;
|
|
212
266
|
const fetched = performance.now();
|
|
213
267
|
writeLog("navigation", `${url}: ${document.error ?? `HTTP ${document.status ?? 200}`}`
|
|
@@ -228,7 +282,7 @@ export async function createBrowser({
|
|
|
228
282
|
Object.assign(history[historyIndex], { x: offset.x, y: offset.y });
|
|
229
283
|
}
|
|
230
284
|
|
|
231
|
-
async function navigate(url) {
|
|
285
|
+
async function navigate(url, { downloadName = null } = {}) {
|
|
232
286
|
const resolved = new URL(url, currentUrl).href;
|
|
233
287
|
// A javascript: URL runs in the current document; history does not change.
|
|
234
288
|
if (resolved.startsWith("javascript:")) {
|
|
@@ -236,10 +290,11 @@ export async function createBrowser({
|
|
|
236
290
|
return;
|
|
237
291
|
}
|
|
238
292
|
await rememberScroll();
|
|
239
|
-
if (withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
293
|
+
if (downloadName === null && withoutHash(resolved) === withoutHash(currentUrl)) {
|
|
240
294
|
currentUrl = resolved;
|
|
241
295
|
} else {
|
|
242
|
-
await load(resolved);
|
|
296
|
+
const result = await load(resolved, null, downloadName);
|
|
297
|
+
if (result?.downloaded) return null;
|
|
243
298
|
}
|
|
244
299
|
await call("showFragment", resolved);
|
|
245
300
|
history.splice(historyIndex + 1);
|
|
@@ -247,6 +302,16 @@ export async function createBrowser({
|
|
|
247
302
|
historyIndex = history.length - 1;
|
|
248
303
|
}
|
|
249
304
|
|
|
305
|
+
/** The renderer already navigated to a fragment of its document; only history changes. */
|
|
306
|
+
function recordFragment(url, from) {
|
|
307
|
+
if (withoutHash(url) !== withoutHash(currentUrl)) return;
|
|
308
|
+
Object.assign(history[historyIndex], { x: from.x, y: from.y });
|
|
309
|
+
currentUrl = url;
|
|
310
|
+
history.splice(historyIndex + 1);
|
|
311
|
+
history.push({ url, x: 0, y: 0 });
|
|
312
|
+
historyIndex = history.length - 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
250
315
|
async function traverseHistory(index) {
|
|
251
316
|
if (index < 0 || index >= history.length || index === historyIndex) return;
|
|
252
317
|
await rememberScroll();
|
|
@@ -258,6 +323,14 @@ export async function createBrowser({
|
|
|
258
323
|
|
|
259
324
|
const context = {
|
|
260
325
|
navigate,
|
|
326
|
+
setDownloadBehavior({ behavior = "allow", downloadPath = DEFAULT_DOWNLOAD_DIR, eventsEnabled = false } = {}) {
|
|
327
|
+
if (!["allow", "allowAndName", "default", "deny"].includes(behavior)) throw new TypeError(`Unsupported download behavior ${behavior}`);
|
|
328
|
+
downloadBehavior = { behavior, downloadPath, eventsEnabled: Boolean(eventsEnabled) };
|
|
329
|
+
},
|
|
330
|
+
onDownload(listener) {
|
|
331
|
+
downloadListeners.add(listener);
|
|
332
|
+
return () => downloadListeners.delete(listener);
|
|
333
|
+
},
|
|
261
334
|
reload: () => load(currentUrl),
|
|
262
335
|
goBack: () => traverseHistory(historyIndex - 1),
|
|
263
336
|
goForward: () => traverseHistory(historyIndex + 1),
|
|
@@ -277,6 +350,22 @@ export async function createBrowser({
|
|
|
277
350
|
quality: Number.isFinite(options.quality) ? options.quality : 80,
|
|
278
351
|
}),
|
|
279
352
|
title: () => call("title"),
|
|
353
|
+
/**
|
|
354
|
+
* The document's response body as it arrived (CDP Page.getResourceContent),
|
|
355
|
+
* for the current document's URL with or without its fragment; else null.
|
|
356
|
+
*/
|
|
357
|
+
resourceContent(url = currentUrl) {
|
|
358
|
+
if (!resource) return null;
|
|
359
|
+
let wanted;
|
|
360
|
+
try {
|
|
361
|
+
wanted = withoutHash(String(url));
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return wanted === withoutHash(resource.url) || wanted === withoutHash(currentUrl)
|
|
366
|
+
? { url: resource.url, contentType: resource.contentType, body: resource.body }
|
|
367
|
+
: null;
|
|
368
|
+
},
|
|
280
369
|
scroll: (deltaX, deltaY) => composite("scroll", deltaX / pageScale(), deltaY / pageScale()),
|
|
281
370
|
press: (key, options) => call("press", key, options),
|
|
282
371
|
type: (text) => call("type", text),
|
|
@@ -296,10 +385,15 @@ export async function createBrowser({
|
|
|
296
385
|
return [...reachedLifecycle(committed.host, committed.generation)];
|
|
297
386
|
},
|
|
298
387
|
async click(x, y) {
|
|
299
|
-
const
|
|
388
|
+
const clicked = await call("click", x / pageScale(), y / pageScale());
|
|
389
|
+
const href = typeof clicked === "string" ? clicked : clicked?.url;
|
|
300
390
|
if (typeof href !== "string") return null;
|
|
301
|
-
|
|
302
|
-
|
|
391
|
+
if (clicked.fragmentFrom) {
|
|
392
|
+
recordFragment(href, clicked.fragmentFrom);
|
|
393
|
+
return { url: href, title: await call("title") };
|
|
394
|
+
}
|
|
395
|
+
const result = await navigate(href, { downloadName: typeof clicked === "object" ? clicked.download ?? null : null });
|
|
396
|
+
return result === null ? null : { url: href, title: await call("title") };
|
|
303
397
|
},
|
|
304
398
|
evaluate: (expression) => call("evaluate", String(expression)),
|
|
305
399
|
cdp() {
|