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.
Files changed (57) 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 +25 -6
  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/headless-shell.js +108 -14
  14. package/lib/input/hit-test.js +57 -8
  15. package/lib/layout/text-layout.js +112 -27
  16. package/lib/network/network-service.js +7 -3
  17. package/lib/network/resolver.js +16 -0
  18. package/lib/paint/display-list.js +2 -2
  19. package/lib/render-tree/index.js +13 -0
  20. package/lib/renderer/bindings.js +541 -53
  21. package/lib/renderer/flags.js +8 -5
  22. package/lib/renderer/host.js +102 -6
  23. package/lib/renderer/main.js +35 -3
  24. package/lib/renderer/module-transform.js +30 -23
  25. package/lib/renderer/page-realm.js +226 -22
  26. package/lib/renderer/page-renderer.js +1293 -212
  27. package/lib/screenshot-cli.js +61 -0
  28. package/lib/style/computed-style.js +268 -20
  29. package/lib/user-agent.js +12 -0
  30. package/package.json +1 -1
  31. package/patches/happy-dom/0003-defer-script-events-to-renderer.patch +40 -0
  32. package/patches/happy-dom/0004-insert-adjacent-html-in-order.patch +19 -0
  33. package/patches/happy-dom/0005-invalidate-matches-on-structure-changes.patch +43 -0
  34. package/patches/happy-dom/README.md +12 -0
  35. package/patches/ses/0001-allow-regexp-own-tostring.patch +13 -0
  36. package/patches/ses/README.md +16 -0
  37. package/test/computed-style.test.js +52 -0
  38. package/test/document-types.test.js +111 -0
  39. package/test/download.test.js +192 -0
  40. package/test/fixtures/tone.mp3 +0 -0
  41. package/test/flex.test.js +11 -0
  42. package/test/happy-dom-matches.test.js +25 -0
  43. package/test/headless-shell.test.js +27 -0
  44. package/test/hit-test.test.js +37 -0
  45. package/test/page-scripts.test.js +388 -29
  46. package/test/piano.test.js +65 -0
  47. package/test/positioning.test.js +11 -0
  48. package/test/pulse-client.test.js +130 -0
  49. package/test/render-tree.test.js +14 -0
  50. package/test/resolver.test.js +12 -1
  51. package/test/screenshot-cli.test.js +19 -0
  52. package/test/text-layout.test.js +22 -0
  53. package/test/user-agent.test.js +42 -0
  54. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  55. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  56. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  57. package/vendor/ses/dist/ses.mjs +2 -0
@@ -1,17 +1,20 @@
1
1
  /** Command-line flags the controller passes to renderer processes. */
2
+ import { CHROMIUM_VERSION } from "../user-agent.js";
2
3
 
3
4
  /** fd 3 is the controller's compositor channel; start the compositor Worker. */
4
5
  export const COMPOSITOR_FLAG = "--compositor";
5
6
 
7
+ /** BUNINU_VERIFY_STYLE: check every incremental restyle against a full one (development). */
8
+ export const VERIFY_STYLE_FLAG = "--buninu-verify-style";
9
+ /** BUNINU_FULL_STYLE: restyle the whole document every time (to compare with incremental restyles). */
10
+ export const FULL_STYLE_FLAG = "--buninu-full-style";
11
+
6
12
  /** --mobile: the renderer presents itself as a phone (user agent, touch media features). */
7
13
  export const MOBILE_FLAG = "--buninu-mobile";
8
14
 
9
- /**
10
- * A current Android WebView user agent, as sent with --mobile. Sites such as
11
- * Google reject the frozen "Android 10; K" string of reduced user agents.
12
- */
15
+ /** Chrome for Android on an Android 16 phone, as sent with --mobile. */
13
16
  export const MOBILE_USER_AGENT =
14
- "Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/153.0.8010.36 Mobile Safari/537.36";
17
+ `Mozilla/5.0 (Linux; Android 16; ASUSAI2501C Build/BQ2A.250525.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROMIUM_VERSION} Mobile Safari/537.36`;
15
18
 
16
19
  /**
17
20
  * --mobile lays pages out no wider than a phone (CSS pixels; a Pixel-class
@@ -13,18 +13,33 @@
13
13
  * scrolling even while the main thread is busy (see compositor.js).
14
14
  */
15
15
  import { closeSync } from "node:fs";
16
+ import { PulseClient } from "../audio/pulse-client.js";
16
17
  import { logEnabled, pipeToLog, RENDERER_LOG_FLAG } from "../log.js";
17
18
  import { NetworkService } from "../network/network-service.js";
18
- import { COMPOSITOR_FLAG } from "./flags.js";
19
+ import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
19
20
 
20
21
  const ALLOWED_SCHEMES = new Set(["http:", "https:", "data:"]);
21
22
  const FETCH_MODES = new Set(["cors", "no-cors", "same-origin"]);
22
23
  const CREDENTIALS_MODES = new Set(["omit", "same-origin", "include"]);
23
24
  const REDIRECT_MODES = new Set(["follow", "error", "manual"]);
24
25
  const MAX_REQUEST_HEADERS = 100;
25
- const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video"]);
26
+ const RESOURCE_KINDS = new Set(["stylesheet", "image", "script", "metadata", "video", "audio"]);
26
27
  const NAVIGATION_SCHEMES = new Set(["http:", "https:", "about:", "data:"]);
27
28
  const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
29
+ // Audio output: one PulseAudio native-protocol connection (jspulse or a
30
+ // PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
31
+ // first use. Renderers cannot open sockets, so their audio comes through here.
32
+ let pulse = null;
33
+ function pulseClient() {
34
+ pulse ??= PulseClient.connect().catch((error) => {
35
+ pulse = null;
36
+ throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
37
+ });
38
+ return pulse;
39
+ }
40
+ const MAX_AUDIO_STREAMS = 8;
41
+ const MAX_AUDIO_CHUNK = 4 * 1024 * 1024;
42
+
28
43
  // WebSockets page script may hold open per renderer, and the largest message either way.
29
44
  const MAX_WEB_SOCKETS = 64;
30
45
  const MAX_WEB_SOCKET_MESSAGE = 16 * 1024 * 1024;
@@ -42,6 +57,7 @@ export class RendererHost {
42
57
  #timeout;
43
58
  #onRestart;
44
59
  #onNavigate;
60
+ #onDownload;
45
61
  #network;
46
62
  // The document this host asked its renderer to load: the trusted origin
47
63
  // for everything the renderer requests.
@@ -51,6 +67,8 @@ export class RendererHost {
51
67
  #pending = new Map();
52
68
  // Page WebSockets: id -> { child, socket } (Bun's WebSocket, opened here)
53
69
  #sockets = new Map();
70
+ // Audio streams: id -> { child, stream } (PulseAudio playback streams)
71
+ #audio = new Map();
54
72
  #nextId = 1;
55
73
  #queue = Promise.resolve();
56
74
  #restarting = false;
@@ -68,7 +86,8 @@ export class RendererHost {
68
86
  * args?: string[],
69
87
  * timeout?: number,
70
88
  * onRestart?: (call: (method: string, ...params: unknown[]) => Promise<unknown>) => Promise<void>,
71
- * onNavigate?: (url: string) => void,
89
+ * onNavigate?: (url: string, downloadName: string | null, fragmentFrom: { x: number, y: number } | null) => void,
90
+ * onDownload?: (payload: { url: string, name: string, data: Uint8Array }) => void,
72
91
  * onFrame?: () => void,
73
92
  * onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
74
93
  * compositor?: boolean,
@@ -77,7 +96,8 @@ export class RendererHost {
77
96
  * }} options
78
97
  */
79
98
  constructor({
80
- entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, fetch: fetchImpl = fetch,
99
+ entry = DEFAULT_ENTRY, args = [], timeout = 30_000, onRestart = null, onNavigate = null, onDownload = null,
100
+ fetch: fetchImpl = fetch,
81
101
  network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
82
102
  // The compositor's channel is an extra stdio pipe used as fd 3, which
83
103
  // Windows does not have (Bun hands out libuv pipes there, not file
@@ -88,6 +108,7 @@ export class RendererHost {
88
108
  this.#onLifecycle = onLifecycle;
89
109
  this.#compositor = compositor;
90
110
  this.#onNavigate = onNavigate;
111
+ this.#onDownload = onDownload;
91
112
  this.#entry = entry;
92
113
  this.#args = args;
93
114
  this.#timeout = timeout;
@@ -108,7 +129,8 @@ export class RendererHost {
108
129
  const compositor = this.#compositor;
109
130
  const child = Bun.spawn({
110
131
  cmd: [process.execPath, this.#entry, ...this.#args,
111
- ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : [])],
132
+ ...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG] : []),
133
+ ...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
112
134
  // No inherited environment: nothing from the controller leaks in.
113
135
  // Windows programs need SystemRoot to load system libraries.
114
136
  env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
@@ -126,6 +148,7 @@ export class RendererHost {
126
148
  onExit: (_process, exitCode, signalCode) => {
127
149
  this.#closeChannel(channel);
128
150
  this.#closeSockets(child);
151
+ this.#closeAudio(child);
129
152
  if (this.#child !== child) return;
130
153
  console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
131
154
  this.#child = null;
@@ -277,6 +300,7 @@ export class RendererHost {
277
300
  this.#closeChannel(this.#channel);
278
301
  const child = this.#child;
279
302
  this.#closeSockets(child);
303
+ this.#closeAudio(child);
280
304
  this.#child = null;
281
305
  this.#ready = null;
282
306
  child?.kill();
@@ -350,6 +374,28 @@ export class RendererHost {
350
374
  if (entry?.child !== child) return;
351
375
  const code = Number.isInteger(message.code) ? message.code : undefined;
352
376
  entry.socket.close(code, typeof message.reason === "string" ? message.reason.slice(0, 123) : undefined);
377
+ } else if (message.type === "audio-open" && Number.isInteger(message.id)) {
378
+ this.#openAudio(child, message);
379
+ } else if (message.type === "audio-data" && Number.isInteger(message.id)) {
380
+ const entry = this.#audio.get(message.id);
381
+ if (entry?.child === child && message.data instanceof Uint8Array && message.data.byteLength <= MAX_AUDIO_CHUNK) {
382
+ // Audio that arrives before the stream is open waits for it.
383
+ if (entry.stream) entry.stream.write(message.data);
384
+ else entry.early.push(message.data);
385
+ }
386
+ } else if (message.type === "audio-finish" && Number.isInteger(message.id)) {
387
+ const entry = this.#audio.get(message.id);
388
+ if (entry?.child !== child) return;
389
+ const send = (payload) => this.#child === child && child.send({ type: "audio-event", id: message.id, ...payload });
390
+ (entry.opened ?? Promise.resolve()).then(() => entry.stream?.finish()).then(() => {
391
+ this.#audio.delete(message.id);
392
+ send({ event: "ended" });
393
+ }, () => this.#audio.delete(message.id));
394
+ } else if (message.type === "audio-close" && Number.isInteger(message.id)) {
395
+ const entry = this.#audio.get(message.id);
396
+ if (entry?.child !== child) return;
397
+ this.#audio.delete(message.id);
398
+ (entry.opened ?? Promise.resolve()).then(() => entry.stream?.close()).catch(() => {});
353
399
  } else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
354
400
  this.#onLifecycle?.(message.name, message.generation);
355
401
  } else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
@@ -362,7 +408,19 @@ export class RendererHost {
362
408
  } catch {
363
409
  return;
364
410
  }
365
- if (NAVIGATION_SCHEMES.has(target.protocol)) this.#onNavigate?.(target.href);
411
+ if (NAVIGATION_SCHEMES.has(target.protocol)) {
412
+ const download = typeof message.download === "string" ? message.download.slice(0, 200) : null;
413
+ const from = message.fragmentFrom;
414
+ const fragmentFrom = from && Number.isFinite(from.x) && Number.isFinite(from.y) ? { x: from.x, y: from.y } : null;
415
+ this.#onNavigate?.(target.href, download, fragmentFrom);
416
+ }
417
+ } else if (message.type === "download-data" && typeof message.url === "string"
418
+ && message.data instanceof Uint8Array && message.data.byteLength <= 64 * 1024 * 1024) {
419
+ let target;
420
+ try { target = new URL(message.url); } catch { return; }
421
+ if (target.protocol !== "blob:") return;
422
+ this.#onDownload?.({ url: target.href, name: typeof message.name === "string" ? message.name.slice(0, 200) : "",
423
+ data: message.data });
366
424
  }
367
425
  }
368
426
 
@@ -398,6 +456,43 @@ export class RendererHost {
398
456
  });
399
457
  }
400
458
 
459
+ /** A renderer's audio element starts playing: a PulseAudio stream relays its PCM. */
460
+ #openAudio(child, { id, rate, channels, name }) {
461
+ const send = (payload) => {
462
+ if (this.#child === child) child.send({ type: "audio-event", id, ...payload });
463
+ };
464
+ const owned = [...this.#audio.values()].filter((entry) => entry.child === child).length;
465
+ if (this.#audio.has(id) || owned >= MAX_AUDIO_STREAMS
466
+ || !(Number.isInteger(rate) && rate >= 1000 && rate <= 384000) || !(Number.isInteger(channels) && channels >= 1 && channels <= 8)) {
467
+ send({ event: "error", message: "invalid audio stream" });
468
+ return;
469
+ }
470
+ const entry = { child, stream: null, early: [] };
471
+ entry.opened = pulseClient()
472
+ .then((client) => client.openPlayback({
473
+ rate, channels, name: String(name ?? "audio").slice(0, 200),
474
+ leadSeconds: name === "piano" ? 0.12 : undefined,
475
+ onRequest: (bytes) => send({ event: "request", bytes }),
476
+ }))
477
+ .then((stream) => {
478
+ if (this.#audio.get(id) !== entry) return stream.close();
479
+ entry.stream = stream;
480
+ for (const data of entry.early.splice(0)) stream.write(data);
481
+ }, (error) => {
482
+ this.#audio.delete(id);
483
+ send({ event: "error", message: String(error?.message ?? error) });
484
+ });
485
+ this.#audio.set(id, entry);
486
+ }
487
+
488
+ #closeAudio(child) {
489
+ for (const [id, entry] of this.#audio) {
490
+ if (entry.child !== child) continue;
491
+ this.#audio.delete(id);
492
+ (entry.opened ?? Promise.resolve()).then(() => entry.stream?.close()).catch(() => {});
493
+ }
494
+ }
495
+
401
496
  #closeSockets(child) {
402
497
  for (const [id, entry] of this.#sockets) {
403
498
  if (entry.child !== child) continue;
@@ -433,6 +528,7 @@ export class RendererHost {
433
528
  url: response.url || target.href,
434
529
  contentType: response.contentType,
435
530
  body: response.body,
531
+ documentCookie: this.#network.documentCookie(this.#documentUrl),
436
532
  },
437
533
  });
438
534
  } catch (error) {
@@ -11,13 +11,17 @@
11
11
  * renderer -> controller { type: "ready", sandbox: { seccomp, ses } } (each { installed, reason })
12
12
  * { type: "result", id, result | error }
13
13
  * { type: "fetch", id, url, kind }
14
- * { type: "navigate", url }
14
+ * { type: "navigate", url, download, fragmentFrom } (fragmentFrom: the renderer
15
+ * already showed this fragment; the scroll offset it left)
15
16
  * { type: "page-fetch", id, request } (fetch/XHR, answered by fetch-result)
16
17
  * { type: "cookie", value } (document.cookie = value)
17
18
  * { type: "lifecycle", name, generation } (DOMContentLoaded, load)
18
19
  * { type: "ws-open", id, url, protocols } / { type: "ws-send", id, data }
19
20
  * / { type: "ws-close", id, code, reason } (page WebSockets)
20
21
  * controller -> renderer { type: "ws-event", id, event, ... } (open, message, error, close)
22
+ * renderer -> controller { type: "audio-open", id, rate, channels, name } / { type: "audio-data", id, data }
23
+ * / { type: "audio-finish", id } / { type: "audio-close", id } (audio output)
24
+ * controller -> renderer { type: "audio-event", id, event, ... } (request with bytes, ended, error)
21
25
  *
22
26
  * With --compositor, a compositor Worker (compositor.js) answers screenshots
23
27
  * and scrolling on fd 3 while this thread is busy; frames go to it with
@@ -32,7 +36,7 @@
32
36
  */
33
37
  import "../../vendor/ses/dist/ses.mjs";
34
38
  import { createPageRenderer } from "./page-renderer.js";
35
- import { COMPOSITOR_FLAG, MOBILE_FLAG } from "./flags.js";
39
+ import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, MOBILE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
36
40
  import { installSeccomp } from "./seccomp.js";
37
41
  import { RENDERER_LOG_FLAG } from "../log.js";
38
42
  import wasmpeg from "../../vendor/wasmpeg/src/js/index.js";
@@ -92,6 +96,24 @@ const webSockets = {
92
96
  close: (id, code, reason) => process.send({ type: "ws-close", id, code, reason }),
93
97
  };
94
98
 
99
+ // Audio output: PCM goes to the controller, which plays it through PulseAudio.
100
+ const audioListeners = new Map();
101
+ let nextAudioId = 1;
102
+ const audio = {
103
+ open({ rate, channels, name }, onEvent) {
104
+ const id = nextAudioId++;
105
+ audioListeners.set(id, onEvent);
106
+ process.send({ type: "audio-open", id, rate, channels, name });
107
+ return id;
108
+ },
109
+ write: (id, data) => process.send({ type: "audio-data", id, data }),
110
+ finish: (id) => process.send({ type: "audio-finish", id }),
111
+ close(id) {
112
+ audioListeners.delete(id);
113
+ process.send({ type: "audio-close", id });
114
+ },
115
+ };
116
+
95
117
  // Workers load their module from disk, so this happens before seccomp.
96
118
  const compositor = process.argv.includes(COMPOSITOR_FLAG) ? await startCompositor() : null;
97
119
 
@@ -129,9 +151,11 @@ async function startCompositor() {
129
151
 
130
152
  const renderer = await createPageRenderer({
131
153
  fetchResource,
132
- requestNavigation: (url) => process.send({ type: "navigate", url }),
154
+ requestNavigation: (url, download = null, fragmentFrom = null) => process.send({ type: "navigate", url, download, fragmentFrom }),
155
+ requestDownload: ({ url, name, data }) => process.send({ type: "download-data", url, name, data }),
133
156
  pageFetch,
134
157
  webSockets,
158
+ audio,
135
159
  setCookie: (value) => process.send({ type: "cookie", value }),
136
160
  reportError: (message) => report(`buninu-browser: page error: ${logging ? message : String(message).split("\n")[0]}`),
137
161
  consoleMessage: logging ? (level, text) => report(`page console.${level}: ${text}`) : null,
@@ -139,6 +163,8 @@ const renderer = await createPageRenderer({
139
163
  onFrame: compositor ? (frame) => compositor.postMessage({ type: "frame", serial: ++framesSent, ...frame }) : null,
140
164
  onLifecycle: (name, generation) => process.send({ type: "lifecycle", name, generation }),
141
165
  mobile: process.argv.includes(MOBILE_FLAG),
166
+ verifyStyle: process.argv.includes(VERIFY_STYLE_FLAG),
167
+ fullStyle: process.argv.includes(FULL_STYLE_FLAG),
142
168
  videoDecoder: wasmpeg,
143
169
  });
144
170
  await warmUp();
@@ -206,6 +232,12 @@ process.on("message", async (message) => {
206
232
  else pending.resolve(message.result);
207
233
  return;
208
234
  }
235
+ if (message?.type === "audio-event") {
236
+ const listener = audioListeners.get(message.id);
237
+ if (message.event === "ended" || message.event === "error") audioListeners.delete(message.id);
238
+ listener?.(message);
239
+ return;
240
+ }
209
241
  if (message?.type === "ws-event") {
210
242
  const listener = socketListeners.get(message.id);
211
243
  if (message.event === "close") socketListeners.delete(message.id);
@@ -124,8 +124,9 @@ export function transformModule(source) {
124
124
 
125
125
  rewriteImportReferences(program, importExpressions, edits);
126
126
 
127
- // import.meta and import() can appear anywhere in the module.
128
- walk(program, (node) => {
127
+ // import.meta and import() can appear anywhere in the module; most
128
+ // modules have neither, and then the extra walk is skipped.
129
+ if (/\bimport\s*[.(]/.test(source)) walk(program, (node) => {
129
130
  if (node.type === "MetaProperty" && node.meta.name === "import") {
130
131
  edits.push({ start: node.start, end: node.end, text: `${MODULE}.meta` });
131
132
  } else if (node.type === "ImportExpression") {
@@ -135,14 +136,17 @@ export function transformModule(source) {
135
136
  return true;
136
137
  });
137
138
 
138
- edits.sort((left, right) => right.start - left.start);
139
- let body = source.startsWith("#!") ? source.slice(source.indexOf("\n") + 1) : source;
140
- const offset = source.length - body.length;
139
+ // Applied in one pass: re-slicing the whole source per edit was quadratic.
140
+ edits.sort((left, right) => left.start - right.start);
141
+ const offset = source.startsWith("#!") ? source.indexOf("\n") + 1 : 0;
142
+ const pieces = [];
143
+ let cursor = offset;
141
144
  for (const edit of edits) {
142
- const start = edit.start - offset;
143
- const end = edit.end - offset;
144
- body = body.slice(0, start) + edit.text + body.slice(end);
145
+ pieces.push(source.slice(cursor, edit.start), edit.text);
146
+ cursor = edit.end;
145
147
  }
148
+ pieces.push(source.slice(cursor));
149
+ const body = pieces.join("");
146
150
  const stars = starExports.map((index) => `${MODULE}.exportStar(${namespace(index)});`).join("\n");
147
151
  const code = `(async function (${MODULE}) {
148
152
  "use strict";
@@ -319,12 +323,7 @@ function rewriteImportReferences(program, imports, edits) {
319
323
  case "ExportAllDeclaration":
320
324
  return;
321
325
  default:
322
- for (const [key, value] of Object.entries(node)) {
323
- if (key === "type" || key === "start" || key === "end") continue;
324
- for (const child of Array.isArray(value) ? value : [value]) {
325
- if (child && typeof child === "object") expression(child);
326
- }
327
- }
326
+ forEachChild(node, expression);
328
327
  }
329
328
  }
330
329
 
@@ -353,12 +352,7 @@ function collectVarNames(node, names) {
353
352
  if (node.type === "VariableDeclaration" && node.kind === "var") {
354
353
  for (const declarator of node.declarations) for (const name of boundNames(declarator.id)) names.add(name);
355
354
  }
356
- for (const [key, value] of Object.entries(node)) {
357
- if (key === "type" || key === "start" || key === "end") continue;
358
- for (const child of Array.isArray(value) ? value : [value]) {
359
- if (child && typeof child === "object") collectVarNames(child, names);
360
- }
361
- }
355
+ forEachChild(node, (child) => collectVarNames(child, names));
362
356
  }
363
357
 
364
358
  function importType(node) {
@@ -371,10 +365,23 @@ function importType(node) {
371
365
  function walk(node, visit) {
372
366
  if (!node || typeof node.type !== "string") return;
373
367
  if (visit(node) === false) return;
374
- for (const [key, value] of Object.entries(node)) {
368
+ forEachChild(node, (child) => walk(child, visit));
369
+ }
370
+
371
+ /**
372
+ * Calls `each` with every child object of an AST node. A for-in loop with
373
+ * no temporary arrays: Object.entries and [value] wrappers per node were
374
+ * most of the time spent transforming large modules.
375
+ */
376
+ function forEachChild(node, each) {
377
+ for (const key in node) {
375
378
  if (key === "type" || key === "start" || key === "end") continue;
376
- for (const child of Array.isArray(value) ? value : [value]) {
377
- if (child && typeof child === "object") walk(child, visit);
379
+ const value = node[key];
380
+ if (value === null || typeof value !== "object") continue;
381
+ if (Array.isArray(value)) {
382
+ for (const child of value) if (child !== null && typeof child === "object") each(child);
383
+ } else {
384
+ each(value);
378
385
  }
379
386
  }
380
387
  }