buninu-browser 0.1.0 → 0.2.1
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 +28 -7
- package/lib/document-types.js +308 -0
- package/lib/download-directory.js +28 -0
- package/lib/download.js +63 -0
- package/lib/happy-dom/selector-matcher.js +83 -0
- package/lib/headless-shell.js +165 -26
- package/lib/input/hit-test.js +57 -8
- package/lib/layout/text-layout.js +290 -41
- package/lib/network/network-service.js +177 -40
- package/lib/network/resolver.js +16 -0
- package/lib/paint/display-list.js +25 -10
- package/lib/render-tree/index.js +58 -0
- package/lib/renderer/bindings.js +543 -55
- package/lib/renderer/flags.js +8 -5
- package/lib/renderer/host.js +103 -7
- package/lib/renderer/main.js +35 -3
- package/lib/renderer/module-transform.js +30 -23
- package/lib/renderer/page-realm.js +228 -24
- package/lib/renderer/page-renderer.js +1264 -221
- package/lib/screenshot-cli.js +61 -0
- package/lib/style/computed-style.js +558 -229
- package/lib/style/selector-keys.js +302 -0
- package/lib/style/selector-traits.js +240 -0
- 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 +113 -1
- 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 +56 -0
- package/test/hit-test.test.js +37 -0
- package/test/log.test.js +4 -1
- package/test/network-service.test.js +67 -0
- package/test/page-scripts.test.js +437 -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 +36 -1
- package/test/resolver.test.js +12 -1
- package/test/screenshot-cli.test.js +19 -0
- package/test/selector-keys.test.js +39 -0
- package/test/selector-traits.test.js +19 -0
- package/test/text-layout.test.js +91 -1
- 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
|
@@ -15,6 +15,7 @@ import { fetchResolved, HostResolver } from "./resolver.js";
|
|
|
15
15
|
|
|
16
16
|
const MAX_REDIRECTS = 20;
|
|
17
17
|
const MAX_BODY_BYTES = 64 * 1024 * 1024;
|
|
18
|
+
const MAX_CACHED_SUBRESOURCES = 256;
|
|
18
19
|
const SIMPLE_METHODS = new Set(["GET", "HEAD", "POST"]);
|
|
19
20
|
const FORBIDDEN_METHODS = new Set(["CONNECT", "TRACE", "TRACK"]);
|
|
20
21
|
// Fetch §2.2.2 CORS-safelisted request headers (value checks simplified).
|
|
@@ -29,12 +30,23 @@ const FORBIDDEN_REQUEST_HEADERS = new Set([
|
|
|
29
30
|
const SAFELISTED_RESPONSE_HEADERS = new Set([
|
|
30
31
|
"cache-control", "content-language", "content-length", "content-type", "expires", "last-modified", "pragma",
|
|
31
32
|
]);
|
|
33
|
+
// Requests in flight to one host at a time, as browsers allow over HTTP/1.1.
|
|
34
|
+
const MAX_REQUESTS_PER_HOST = 6;
|
|
35
|
+
// Which waiting request to a busy host goes next (lower first): documents,
|
|
36
|
+
// then what blocks rendering or that a script waits for, then the rest.
|
|
37
|
+
const PRIORITY = { document: 0, stylesheet: 1, fetch: 1, script: 2, metadata: 2, image: 3, video: 3, audio: 3 };
|
|
32
38
|
|
|
33
39
|
export class NetworkService {
|
|
34
40
|
#fetch;
|
|
35
41
|
#resolver;
|
|
36
42
|
#cookies;
|
|
37
43
|
#userAgent;
|
|
44
|
+
// Browser-process HTTP cache. Entries are partitioned by the request
|
|
45
|
+
// credentials that affect a subresource response; concurrent identical
|
|
46
|
+
// requests share one transfer.
|
|
47
|
+
#subresourceCache = new Map();
|
|
48
|
+
#subresourceInflight = new Map();
|
|
49
|
+
#hosts = new HostThrottle(MAX_REQUESTS_PER_HOST);
|
|
38
50
|
|
|
39
51
|
/**
|
|
40
52
|
* `resolver` looks host names up itself (see resolver.js); it is on for the
|
|
@@ -55,21 +67,63 @@ export class NetworkService {
|
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
set userAgent(value) {
|
|
58
|
-
|
|
70
|
+
const next = typeof value === "string" && value ? value : null;
|
|
71
|
+
if (next !== this.#userAgent) this.#subresourceCache.clear();
|
|
72
|
+
this.#userAgent = next;
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
/** A top-level document load. */
|
|
62
|
-
async navigate(url) {
|
|
76
|
+
async navigate(url, { onResponse } = {}) {
|
|
63
77
|
return this.#send({ url, method: "GET", headers: [], body: null }, {
|
|
64
78
|
credentials: "include", site: null, topLevelNavigation: true,
|
|
65
|
-
});
|
|
79
|
+
}, { onResponse, priority: PRIORITY.document });
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
82
|
+
/**
|
|
83
|
+
* A subresource the renderer needs to render (no-cors, credentials included).
|
|
84
|
+
* `kind` ("stylesheet", "script", "image"...) decides its turn at a busy host.
|
|
85
|
+
*/
|
|
86
|
+
async subresource(url, documentUrl, kind = "metadata") {
|
|
87
|
+
const site = siteOf(documentUrl);
|
|
88
|
+
const cookie = this.#cookies.header(url, { site });
|
|
89
|
+
const key = `${url}\n${site ?? ""}\n${cookie}\n${this.#userAgent ?? ""}`;
|
|
90
|
+
const cached = this.#subresourceCache.get(key);
|
|
91
|
+
if (cached && cached.expires > Date.now()) return cached.result;
|
|
92
|
+
const pending = this.#subresourceInflight.get(key);
|
|
93
|
+
if (pending) return pending;
|
|
94
|
+
const request = (async () => {
|
|
95
|
+
const headers = cached?.etag ? [["if-none-match", cached.etag]] : [];
|
|
96
|
+
let result = await this.#send({ url, method: "GET", headers, body: null }, {
|
|
97
|
+
credentials: "include", site,
|
|
98
|
+
}, { priority: PRIORITY[kind] ?? PRIORITY.metadata });
|
|
99
|
+
if (result.status === 304 && cached) {
|
|
100
|
+
const rawHeaders = [...new Map([...cached.result.rawHeaders, ...result.rawHeaders])];
|
|
101
|
+
result = {
|
|
102
|
+
...cached.result,
|
|
103
|
+
rawHeaders,
|
|
104
|
+
headers: [...new Map([...cached.result.headers, ...result.headers])],
|
|
105
|
+
};
|
|
106
|
+
const policy = cachePolicy(rawHeaders);
|
|
107
|
+
cached.expires = policy.expires;
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
const policy = cachePolicy(result.rawHeaders);
|
|
111
|
+
if (result.ok && policy.cacheable) {
|
|
112
|
+
if (!this.#subresourceCache.has(key) && this.#subresourceCache.size >= MAX_CACHED_SUBRESOURCES) {
|
|
113
|
+
this.#subresourceCache.delete(this.#subresourceCache.keys().next().value);
|
|
114
|
+
}
|
|
115
|
+
this.#subresourceCache.set(key, { result, expires: policy.expires, etag: policy.etag });
|
|
116
|
+
} else {
|
|
117
|
+
this.#subresourceCache.delete(key);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
})();
|
|
121
|
+
this.#subresourceInflight.set(key, request);
|
|
122
|
+
try {
|
|
123
|
+
return await request;
|
|
124
|
+
} finally {
|
|
125
|
+
if (this.#subresourceInflight.get(key) === request) this.#subresourceInflight.delete(key);
|
|
126
|
+
}
|
|
73
127
|
}
|
|
74
128
|
|
|
75
129
|
/**
|
|
@@ -131,19 +185,19 @@ export class NetworkService {
|
|
|
131
185
|
|
|
132
186
|
if (sameOrigin) {
|
|
133
187
|
return this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
|
|
134
|
-
redirect: request.redirect ?? "follow", corsOrigin: null,
|
|
188
|
+
redirect: request.redirect ?? "follow", corsOrigin: null, priority: PRIORITY.fetch,
|
|
135
189
|
});
|
|
136
190
|
}
|
|
137
191
|
if (mode === "no-cors") {
|
|
138
192
|
const response = await this.#send({ url: url.href, method, headers: headers.filter(isSafelistedHeader), body: request.body ?? null },
|
|
139
|
-
context, { redirect: "follow", corsOrigin: null });
|
|
193
|
+
context, { redirect: "follow", corsOrigin: null, priority: PRIORITY.fetch });
|
|
140
194
|
return { ...response, type: "opaque", status: 0, statusText: "", url: "", headers: [], body: new Uint8Array(0), redirected: false };
|
|
141
195
|
}
|
|
142
196
|
// CORS: preflight unless the request is "simple" (Fetch §4.8).
|
|
143
197
|
const needsPreflight = !SIMPLE_METHODS.has(method) || headers.some((header) => !isSafelistedHeader(header));
|
|
144
198
|
if (needsPreflight) await this.#preflight(url, method, headers, origin, context);
|
|
145
199
|
const response = await this.#send({ url: url.href, method, headers, body: request.body ?? null }, context, {
|
|
146
|
-
redirect: request.redirect ?? "follow", corsOrigin: origin,
|
|
200
|
+
redirect: request.redirect ?? "follow", corsOrigin: origin, priority: PRIORITY.fetch,
|
|
147
201
|
});
|
|
148
202
|
const allowed = corsAllowed(response.rawHeaders, origin, context.credentials === "include");
|
|
149
203
|
if (!allowed) throw new TypeError(`CORS: ${url.href} did not allow origin ${origin}`);
|
|
@@ -159,17 +213,23 @@ export class NetworkService {
|
|
|
159
213
|
|
|
160
214
|
async #preflight(url, method, headers, origin, context) {
|
|
161
215
|
const requestHeaders = headers.filter((entry) => !isSafelistedHeader(entry)).map(([name]) => name).sort();
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
216
|
+
const release = await this.#hosts.acquire(url.href, PRIORITY.fetch);
|
|
217
|
+
let response;
|
|
218
|
+
try {
|
|
219
|
+
response = await this.#request(url.href, {
|
|
220
|
+
method: "OPTIONS",
|
|
221
|
+
redirect: "manual",
|
|
222
|
+
headers: {
|
|
223
|
+
origin,
|
|
224
|
+
"access-control-request-method": method,
|
|
225
|
+
...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
|
|
226
|
+
...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
await response.arrayBuffer().catch(() => {});
|
|
230
|
+
} finally {
|
|
231
|
+
release();
|
|
232
|
+
}
|
|
173
233
|
const raw = [...response.headers];
|
|
174
234
|
if (response.status < 200 || response.status > 299 || !corsAllowed(raw, origin, context.credentials === "include")) {
|
|
175
235
|
throw new TypeError(`CORS preflight for ${url.href} was rejected`);
|
|
@@ -191,8 +251,11 @@ export class NetworkService {
|
|
|
191
251
|
return this.#resolver ? fetchResolved(this.#fetch, this.#resolver, url, init) : this.#fetch(url, init);
|
|
192
252
|
}
|
|
193
253
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Sends a request, following redirects by hand so cookies apply to every hop.
|
|
256
|
+
* Each hop holds one of its host's request slots until its body is read.
|
|
257
|
+
*/
|
|
258
|
+
async #send(request, context, { redirect = "follow", corsOrigin = null, onResponse = null, priority = PRIORITY.metadata } = {}) {
|
|
196
259
|
let { url, method, body } = request;
|
|
197
260
|
const baseHeaders = request.headers;
|
|
198
261
|
let redirected = false;
|
|
@@ -206,24 +269,33 @@ export class NetworkService {
|
|
|
206
269
|
});
|
|
207
270
|
if (cookie) headers.set("cookie", cookie);
|
|
208
271
|
}
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
272
|
+
const release = await this.#hosts.acquire(url, priority);
|
|
273
|
+
try {
|
|
274
|
+
const response = await this.#request(url, { method, headers, body, redirect: "manual" });
|
|
275
|
+
if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
|
|
276
|
+
const location = response.headers.get("location");
|
|
277
|
+
if ([301, 302, 303, 307, 308].includes(response.status) && location) {
|
|
278
|
+
await response.arrayBuffer().catch(() => {});
|
|
279
|
+
if (redirect === "error") throw new TypeError("Redirect was not allowed");
|
|
280
|
+
if (redirect === "manual") return await this.#result(response, url, redirected, "opaqueredirect");
|
|
281
|
+
const next = new URL(location, url);
|
|
282
|
+
if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
|
|
283
|
+
if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
|
|
284
|
+
method = method === "HEAD" ? "HEAD" : "GET";
|
|
285
|
+
body = null;
|
|
286
|
+
}
|
|
287
|
+
url = next.href;
|
|
288
|
+
redirected = true;
|
|
289
|
+
continue;
|
|
221
290
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
291
|
+
if (onResponse) {
|
|
292
|
+
const handled = await onResponse(response, url);
|
|
293
|
+
if (handled) return handled;
|
|
294
|
+
}
|
|
295
|
+
return await this.#result(response, url, redirected, "basic");
|
|
296
|
+
} finally {
|
|
297
|
+
release();
|
|
225
298
|
}
|
|
226
|
-
return this.#result(response, url, redirected, "basic");
|
|
227
299
|
}
|
|
228
300
|
throw new TypeError("Too many redirects");
|
|
229
301
|
}
|
|
@@ -258,6 +330,20 @@ function header(rawHeaders, name) {
|
|
|
258
330
|
return rawHeaders.find(([key]) => key === name)?.[1] ?? null;
|
|
259
331
|
}
|
|
260
332
|
|
|
333
|
+
/** Freshness and validator data used by the in-process HTTP cache. */
|
|
334
|
+
function cachePolicy(rawHeaders, now = Date.now()) {
|
|
335
|
+
const control = (header(rawHeaders, "cache-control") ?? "").toLowerCase();
|
|
336
|
+
if (/(?:^|,)\s*no-store(?:\s|,|$)/.test(control)) return { cacheable: false, expires: 0, etag: null };
|
|
337
|
+
const age = control.match(/(?:^|,)\s*max-age\s*=\s*(\d+)/)?.[1];
|
|
338
|
+
const expiresHeader = header(rawHeaders, "expires");
|
|
339
|
+
const expires = age !== undefined
|
|
340
|
+
? now + Number(age) * 1000
|
|
341
|
+
: expiresHeader && Number.isFinite(Date.parse(expiresHeader)) ? Date.parse(expiresHeader)
|
|
342
|
+
: /(?:^|,)\s*immutable(?:\s|,|$)/.test(control) ? now + 365 * 24 * 60 * 60 * 1000 : 0;
|
|
343
|
+
const etag = header(rawHeaders, "etag");
|
|
344
|
+
return { cacheable: expires > now || etag !== null, expires, etag };
|
|
345
|
+
}
|
|
346
|
+
|
|
261
347
|
/** Fetch §4.9 CORS check. */
|
|
262
348
|
function corsAllowed(rawHeaders, origin, withCredentials) {
|
|
263
349
|
const allowOrigin = header(rawHeaders, "access-control-allow-origin");
|
|
@@ -266,3 +352,54 @@ function corsAllowed(rawHeaders, origin, withCredentials) {
|
|
|
266
352
|
if (allowOrigin !== origin) return false;
|
|
267
353
|
return !withCredentials || header(rawHeaders, "access-control-allow-credentials") === "true";
|
|
268
354
|
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Limits the requests in flight to each host (scheme, host and port). A burst
|
|
358
|
+
* of requests that each open a connection slows every transfer, and servers
|
|
359
|
+
* reset some; with a few connections kept alive and reused, a page's
|
|
360
|
+
* resources arrive sooner. Waiting requests go by priority, then in order.
|
|
361
|
+
*/
|
|
362
|
+
class HostThrottle {
|
|
363
|
+
#limit;
|
|
364
|
+
#hosts = new Map();
|
|
365
|
+
|
|
366
|
+
constructor(limit) {
|
|
367
|
+
this.#limit = limit;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Resolves with a function that gives the slot back (once) when the request is done. */
|
|
371
|
+
acquire(url, priority) {
|
|
372
|
+
let key;
|
|
373
|
+
try {
|
|
374
|
+
const target = new URL(url);
|
|
375
|
+
key = `${target.protocol}//${target.host}`;
|
|
376
|
+
} catch {
|
|
377
|
+
key = String(url);
|
|
378
|
+
}
|
|
379
|
+
let host = this.#hosts.get(key);
|
|
380
|
+
if (!host) this.#hosts.set(key, host = { active: 0, waiting: [] });
|
|
381
|
+
const release = this.#releaser(key, host);
|
|
382
|
+
if (host.active < this.#limit) {
|
|
383
|
+
host.active++;
|
|
384
|
+
return Promise.resolve(release);
|
|
385
|
+
}
|
|
386
|
+
return new Promise((resolve) => {
|
|
387
|
+
const entry = { priority, start: () => resolve(release) };
|
|
388
|
+
const index = host.waiting.findIndex((other) => other.priority > priority);
|
|
389
|
+
if (index === -1) host.waiting.push(entry);
|
|
390
|
+
else host.waiting.splice(index, 0, entry);
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#releaser(key, host) {
|
|
395
|
+
let released = false;
|
|
396
|
+
return () => {
|
|
397
|
+
if (released) return;
|
|
398
|
+
released = true;
|
|
399
|
+
const next = host.waiting.shift();
|
|
400
|
+
if (next) next.start(); // the slot passes on
|
|
401
|
+
else if (--host.active === 0) this.#hosts.delete(key);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
package/lib/network/resolver.js
CHANGED
|
@@ -12,11 +12,20 @@
|
|
|
12
12
|
* each with its own socket), and requests connect to an address directly
|
|
13
13
|
* while keeping the host name for the Host header and for TLS (SNI and
|
|
14
14
|
* certificate verification). This also covers musl, which has no such option.
|
|
15
|
+
*
|
|
16
|
+
* `localhost` and `*.localhost` are loopback without asking anyone (RFC 6761
|
|
17
|
+
* §6.3, as Chrome and Bun's own socket resolver do): node:dns hands them to
|
|
18
|
+
* the system getaddrinfo(), which needs an /etc/hosts entry that minimal
|
|
19
|
+
* systems (an initramfs, containers) may not have.
|
|
15
20
|
*/
|
|
16
21
|
import { lookup as systemLookup } from "node:dns/promises";
|
|
17
22
|
import { isIP } from "node:net";
|
|
18
23
|
|
|
19
24
|
const DEFAULT_TTL_MS = 60_000;
|
|
25
|
+
const LOOPBACK = Object.freeze([
|
|
26
|
+
Object.freeze({ address: "127.0.0.1", family: 4 }),
|
|
27
|
+
Object.freeze({ address: "::1", family: 6 }),
|
|
28
|
+
]);
|
|
20
29
|
// Errors that mean "this address did not answer": try the next one.
|
|
21
30
|
const CONNECT_ERRORS = new Set([
|
|
22
31
|
"ECONNREFUSED", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EADDRNOTAVAIL",
|
|
@@ -41,6 +50,7 @@ export class HostResolver {
|
|
|
41
50
|
/** Addresses for a host name, IPv4 first, then IPv6. */
|
|
42
51
|
addresses(hostname) {
|
|
43
52
|
const name = hostname.toLowerCase();
|
|
53
|
+
if (isLocalhostName(name)) return Promise.resolve(LOOPBACK);
|
|
44
54
|
const cached = this.#cache.get(name);
|
|
45
55
|
if (cached && cached.expires > this.#now()) return cached.addresses;
|
|
46
56
|
const addresses = this.#resolve(name);
|
|
@@ -70,6 +80,12 @@ export class HostResolver {
|
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
|
|
83
|
+
/** `localhost` or a name under `.localhost` (one trailing dot allowed), lowercase. */
|
|
84
|
+
export function isLocalhostName(name) {
|
|
85
|
+
const host = name.endsWith(".") ? name.slice(0, -1) : name;
|
|
86
|
+
return host === "localhost" || host.endsWith(".localhost");
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
/**
|
|
74
90
|
* fetch() that connects to a resolved address, keeping the host name for the
|
|
75
91
|
* Host header and TLS. IP literals and non-HTTP URLs go to `fetchImpl` as is.
|
|
@@ -24,7 +24,7 @@ const TRANSPARENT = "rgba(0, 0, 0, 0)";
|
|
|
24
24
|
* @param tree the render tree it was laid out from; without it, visibility,
|
|
25
25
|
* opacity and overflow clipping of ancestors are not applied.
|
|
26
26
|
*/
|
|
27
|
-
export function buildDisplayList(layout, tree = null) {
|
|
27
|
+
export function buildDisplayList(layout, tree = null, resources = null) {
|
|
28
28
|
const items = [];
|
|
29
29
|
const images = [];
|
|
30
30
|
const effects = paintEffects(layout, tree);
|
|
@@ -156,15 +156,18 @@ export function buildDisplayList(layout, tree = null) {
|
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
if (run.type === "image") {
|
|
159
|
-
|
|
159
|
+
const currentResource = resources && tree
|
|
160
|
+
? resources.get(tree.nodesById.get(run.nodeId)?.domNode) ?? run.resource
|
|
161
|
+
: run.resource;
|
|
162
|
+
if (run.video || run.audio) {
|
|
160
163
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
161
|
-
images.push(
|
|
162
|
-
items.push({ op: "drawVideo", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
164
|
+
images.push(currentResource);
|
|
165
|
+
items.push({ op: run.video ? "drawVideo" : "drawAudio", nodeId: run.nodeId, image: images.length - 1, rect, bounds: rect });
|
|
163
166
|
return;
|
|
164
167
|
}
|
|
165
|
-
if (!
|
|
168
|
+
if (!currentResource?.image) return;
|
|
166
169
|
const rect = { x: run.x, y, width: run.width, height: run.height };
|
|
167
|
-
images.push(
|
|
170
|
+
images.push(currentResource);
|
|
168
171
|
items.push({
|
|
169
172
|
op: "drawImage", nodeId: run.nodeId, image: images.length - 1, rect, radii: cornerRadii(run.style, rect), bounds: rect,
|
|
170
173
|
});
|
|
@@ -223,17 +226,27 @@ function paintEffects(layout, tree) {
|
|
|
223
226
|
const boxes = new Map();
|
|
224
227
|
for (const box of layout.boxes) if (!box.inline && !boxes.has(box.nodeId)) boxes.set(box.nodeId, box);
|
|
225
228
|
const cache = new Map();
|
|
229
|
+
// Each element's own clips, worked out once for all the items below it.
|
|
230
|
+
const memo = (compute) => {
|
|
231
|
+
const results = new Map();
|
|
232
|
+
return (node) => {
|
|
233
|
+
if (results.has(node)) return results.get(node);
|
|
234
|
+
const result = compute(node);
|
|
235
|
+
results.set(node, result === null ? null : Object.freeze(result));
|
|
236
|
+
return results.get(node);
|
|
237
|
+
};
|
|
238
|
+
};
|
|
226
239
|
|
|
227
240
|
// The clip an element applies to its descendants (and, for clip/clip-path, to itself).
|
|
228
|
-
const overflowClip = (node) => {
|
|
241
|
+
const overflowClip = memo((node) => {
|
|
229
242
|
const style = node.style;
|
|
230
243
|
const box = boxes.get(node.id);
|
|
231
244
|
if (!style || !box || node === tree.root) return null;
|
|
232
245
|
if (style.overflowX !== "clip" && style.overflowY !== "clip") return null;
|
|
233
246
|
const [top, right, bottom, left] = style.borderWidths ?? [0, 0, 0, 0];
|
|
234
247
|
return { x: box.x + left, y: box.y + top, width: box.width - left - right, height: box.height - top - bottom };
|
|
235
|
-
};
|
|
236
|
-
const shapeClip = (node) => {
|
|
248
|
+
});
|
|
249
|
+
const shapeClip = memo((node) => {
|
|
237
250
|
const style = node.style;
|
|
238
251
|
const box = boxes.get(node.id);
|
|
239
252
|
if (!style || !box) return null;
|
|
@@ -253,7 +266,7 @@ function paintEffects(layout, tree) {
|
|
|
253
266
|
rect = intersectRects(rect, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
|
|
254
267
|
}
|
|
255
268
|
return rect;
|
|
256
|
-
};
|
|
269
|
+
});
|
|
257
270
|
|
|
258
271
|
return (nodeId, kind) => {
|
|
259
272
|
const key = `${kind}:${nodeId}`;
|
|
@@ -351,7 +364,9 @@ function inflate(rect, amount) {
|
|
|
351
364
|
return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 };
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
/** Freezes an item and what it holds; frozen values (shared clips, style data) are already done. */
|
|
354
368
|
function deepFreeze(value) {
|
|
369
|
+
if (Object.isFrozen(value)) return value;
|
|
355
370
|
for (const child of Object.values(value)) {
|
|
356
371
|
if (child && typeof child === "object") deepFreeze(child);
|
|
357
372
|
}
|
package/lib/render-tree/index.js
CHANGED
|
@@ -30,6 +30,7 @@ const BLOCK_ELEMENTS = new Set([
|
|
|
30
30
|
*/
|
|
31
31
|
export class RenderTreeBuilder {
|
|
32
32
|
#ids = new WeakMap();
|
|
33
|
+
#previous = new WeakMap();
|
|
33
34
|
#pseudoKeys = new WeakMap();
|
|
34
35
|
#nextId = 1;
|
|
35
36
|
#generation = 0;
|
|
@@ -71,12 +72,18 @@ export class RenderTreeBuilder {
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
#node(domNode, type, nodesById, properties, idKey = domNode) {
|
|
75
|
+
const previous = this.#previous.get(idKey);
|
|
76
|
+
if (previous && sameNodeProperties(previous, type, properties)) {
|
|
77
|
+
nodesById.set(previous.id, previous);
|
|
78
|
+
return previous;
|
|
79
|
+
}
|
|
74
80
|
const node = Object.freeze({
|
|
75
81
|
id: this.#id(idKey),
|
|
76
82
|
type,
|
|
77
83
|
domNode,
|
|
78
84
|
...properties,
|
|
79
85
|
});
|
|
86
|
+
this.#previous.set(idKey, node);
|
|
80
87
|
nodesById.set(node.id, node);
|
|
81
88
|
return node;
|
|
82
89
|
}
|
|
@@ -185,6 +192,19 @@ export class RenderTreeBuilder {
|
|
|
185
192
|
children: Object.freeze([]),
|
|
186
193
|
});
|
|
187
194
|
}
|
|
195
|
+
// Audio is a 300x32 player (painted like video's controls) with the
|
|
196
|
+
// controls attribute, and not rendered without it (HTML §4.8.11.13).
|
|
197
|
+
if (element.tagName === "AUDIO") {
|
|
198
|
+
if (!element.hasAttribute("controls")) return null;
|
|
199
|
+
return this.#node(element, "image", nodesById, {
|
|
200
|
+
tagName: "AUDIO",
|
|
201
|
+
style,
|
|
202
|
+
resource: resources?.get(element) ?? null,
|
|
203
|
+
widthAttribute: positiveDimension(element.getAttribute("width")) ?? 300,
|
|
204
|
+
heightAttribute: 32,
|
|
205
|
+
children: Object.freeze([]),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
188
208
|
if (element.tagName === "IMG" || element.tagName === "VIDEO") {
|
|
189
209
|
return this.#node(element, "image", nodesById, {
|
|
190
210
|
tagName: element.tagName,
|
|
@@ -229,6 +249,44 @@ export class RenderTreeBuilder {
|
|
|
229
249
|
}
|
|
230
250
|
}
|
|
231
251
|
|
|
252
|
+
function sameNodeProperties(previous, type, properties) {
|
|
253
|
+
if (previous.type !== type || Object.keys(previous).length !== Object.keys(properties).length + 3) return false;
|
|
254
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
255
|
+
const old = previous[key];
|
|
256
|
+
if (old === value) continue;
|
|
257
|
+
if (key !== "children" || old.length !== value.length) return false;
|
|
258
|
+
for (let index = 0; index < old.length; index++) if (old[index] !== value[index]) return false;
|
|
259
|
+
}
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether two generations provide identical input to layout. Node IDs are
|
|
265
|
+
* stable for retained DOM nodes, while computed styles and decoded resources
|
|
266
|
+
* are immutable values shared between generations. Callers which separately
|
|
267
|
+
* track whether resource dimensions changed may ignore resource identity.
|
|
268
|
+
*/
|
|
269
|
+
export function sameLayoutInput(left, right, options = {}) {
|
|
270
|
+
if (left === right) return true;
|
|
271
|
+
if (!left || !right || left.id !== right.id || left.type !== right.type
|
|
272
|
+
|| (!options.ignoreStyles && left.style !== right.style) || left.tagName !== right.tagName
|
|
273
|
+
|| left.text !== right.text || left.pseudo !== right.pseudo
|
|
274
|
+
|| (!options.ignoreResources && left.resource !== right.resource)
|
|
275
|
+
|| !sameSvg(left.svg, right.svg)
|
|
276
|
+
|| left.widthAttribute !== right.widthAttribute || left.heightAttribute !== right.heightAttribute
|
|
277
|
+
|| left.children.length !== right.children.length) return false;
|
|
278
|
+
for (let index = 0; index < left.children.length; index++) {
|
|
279
|
+
if (!sameLayoutInput(left.children[index], right.children[index], options)) return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sameSvg(left, right) {
|
|
285
|
+
if (left === right) return true;
|
|
286
|
+
if (!left || !right) return false;
|
|
287
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
288
|
+
}
|
|
289
|
+
|
|
232
290
|
function boxType(style, tagName = null) {
|
|
233
291
|
const display = style?.display;
|
|
234
292
|
if (["flex", "inline-flex", "inline-block", "grid", "inline-grid"].includes(display)) return display;
|