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.
Files changed (64) hide show
  1. package/AGENTS.md +17 -0
  2. package/LICENSES/brotli.txt +19 -0
  3. package/README.md +41 -7
  4. package/THIRD_PARTY_NOTICES.md +26 -4
  5. package/buninu-browser.js +10 -2
  6. package/lib/audio/pcm.js +16 -0
  7. package/lib/audio/piano.js +109 -0
  8. package/lib/audio/pulse-client.js +413 -0
  9. package/lib/cdp-server.js +28 -7
  10. package/lib/document-types.js +308 -0
  11. package/lib/download-directory.js +28 -0
  12. package/lib/download.js +63 -0
  13. package/lib/happy-dom/selector-matcher.js +83 -0
  14. package/lib/headless-shell.js +165 -26
  15. package/lib/input/hit-test.js +57 -8
  16. package/lib/layout/text-layout.js +290 -41
  17. package/lib/network/network-service.js +177 -40
  18. package/lib/network/resolver.js +16 -0
  19. package/lib/paint/display-list.js +25 -10
  20. package/lib/render-tree/index.js +58 -0
  21. package/lib/renderer/bindings.js +543 -55
  22. package/lib/renderer/flags.js +8 -5
  23. package/lib/renderer/host.js +103 -7
  24. package/lib/renderer/main.js +35 -3
  25. package/lib/renderer/module-transform.js +30 -23
  26. package/lib/renderer/page-realm.js +228 -24
  27. package/lib/renderer/page-renderer.js +1264 -221
  28. package/lib/screenshot-cli.js +61 -0
  29. package/lib/style/computed-style.js +558 -229
  30. package/lib/style/selector-keys.js +302 -0
  31. package/lib/style/selector-traits.js +240 -0
  32. package/lib/user-agent.js +12 -0
  33. package/package.json +1 -1
  34. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  35. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  36. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  37. package/patches/happy-dom/README.md +12 -0
  38. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  39. package/patches/ses/README.md +16 -0
  40. package/test/computed-style.test.js +113 -1
  41. package/test/document-types.test.js +111 -0
  42. package/test/download.test.js +192 -0
  43. package/test/fixtures/tone.mp3 +0 -0
  44. package/test/flex.test.js +11 -0
  45. package/test/happy-dom-matches.test.js +25 -0
  46. package/test/headless-shell.test.js +56 -0
  47. package/test/hit-test.test.js +37 -0
  48. package/test/log.test.js +4 -1
  49. package/test/network-service.test.js +67 -0
  50. package/test/page-scripts.test.js +437 -29
  51. package/test/piano.test.js +65 -0
  52. package/test/positioning.test.js +11 -0
  53. package/test/pulse-client.test.js +130 -0
  54. package/test/render-tree.test.js +36 -1
  55. package/test/resolver.test.js +12 -1
  56. package/test/screenshot-cli.test.js +19 -0
  57. package/test/selector-keys.test.js +39 -0
  58. package/test/selector-traits.test.js +19 -0
  59. package/test/text-layout.test.js +91 -1
  60. package/test/user-agent.test.js +42 -0
  61. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  62. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  63. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  64. 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
- this.#userAgent = typeof value === "string" && value ? value : null;
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
- /** A subresource the renderer needs to render (no-cors, credentials included). */
69
- async subresource(url, documentUrl) {
70
- return this.#send({ url, method: "GET", headers: [], body: null }, {
71
- credentials: "include", site: siteOf(documentUrl),
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 response = await this.#request(url.href, {
163
- method: "OPTIONS",
164
- redirect: "manual",
165
- headers: {
166
- origin,
167
- "access-control-request-method": method,
168
- ...(requestHeaders.length ? { "access-control-request-headers": requestHeaders.join(",") } : {}),
169
- ...(this.#userAgent ? { "user-agent": this.#userAgent } : {}),
170
- },
171
- });
172
- await response.arrayBuffer().catch(() => {});
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
- /** Sends a request, following redirects by hand so cookies apply to every hop. */
195
- async #send(request, context, { redirect = "follow", corsOrigin = null } = {}) {
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 response = await this.#request(url, { method, headers, body, redirect: "manual" });
210
- if (context.credentials === "include") this.#cookies.store(url, response.headers.getSetCookie?.() ?? []);
211
- const location = response.headers.get("location");
212
- if ([301, 302, 303, 307, 308].includes(response.status) && location) {
213
- await response.arrayBuffer().catch(() => {});
214
- if (redirect === "error") throw new TypeError("Redirect was not allowed");
215
- if (redirect === "manual") return this.#result(response, url, redirected, "opaqueredirect");
216
- const next = new URL(location, url);
217
- if (!["http:", "https:"].includes(next.protocol)) throw new TypeError(`Redirect to ${next.protocol} is not allowed`);
218
- if (response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")) {
219
- method = method === "HEAD" ? "HEAD" : "GET";
220
- body = null;
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
- url = next.href;
223
- redirected = true;
224
- continue;
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
+
@@ -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
- if (run.video) {
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(run.resource);
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 (!run.resource?.image) return;
168
+ if (!currentResource?.image) return;
166
169
  const rect = { x: run.x, y, width: run.width, height: run.height };
167
- images.push(run.resource);
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
  }
@@ -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;