buninu-browser 0.0.2 → 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 (61) 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 +30 -6
  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 +110 -9
  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/patches/wasmpeg/0001-windows-wasm-path.patch +14 -0
  38. package/patches/wasmpeg/README.md +28 -0
  39. package/test/computed-style.test.js +52 -0
  40. package/test/document-types.test.js +111 -0
  41. package/test/download.test.js +192 -0
  42. package/test/fixtures/tone.mp3 +0 -0
  43. package/test/flex.test.js +11 -0
  44. package/test/happy-dom-matches.test.js +25 -0
  45. package/test/headless-shell.test.js +27 -0
  46. package/test/hit-test.test.js +37 -0
  47. package/test/page-scripts.test.js +388 -29
  48. package/test/piano.test.js +65 -0
  49. package/test/positioning.test.js +11 -0
  50. package/test/pulse-client.test.js +130 -0
  51. package/test/render-tree.test.js +14 -0
  52. package/test/resolver.test.js +12 -1
  53. package/test/screenshot-cli.test.js +19 -0
  54. package/test/text-layout.test.js +22 -0
  55. package/test/user-agent.test.js +42 -0
  56. package/vendor/happy-dom/packages/happy-dom/src/nodes/element/Element.ts +3 -6
  57. package/vendor/happy-dom/packages/happy-dom/src/nodes/html-script-element/HTMLScriptElement.ts +2 -20
  58. package/vendor/happy-dom/packages/happy-dom/src/query-selector/QuerySelector.ts +15 -3
  59. package/vendor/ses/dist/ses.mjs +2 -0
  60. package/vendor/wasmpeg/VENDORING.md +7 -3
  61. package/vendor/wasmpeg/src/js/gpu.js +2 -1
@@ -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;
@@ -33,7 +48,8 @@ const COMPOSITOR_TIMEOUT = 10_000;
33
48
  const MAX_CHANNEL_LINE = 96 * 1024 * 1024;
34
49
  const LIFECYCLE_NAMES = new Set(["DOMContentLoaded", "load"]);
35
50
 
36
- const DEFAULT_ENTRY = new URL("./main.js", import.meta.url).pathname;
51
+ // A file path, not a URL's pathname ("/C:/..." on Windows).
52
+ const DEFAULT_ENTRY = Bun.fileURLToPath(new URL("./main.js", import.meta.url));
37
53
 
38
54
  export class RendererHost {
39
55
  #entry;
@@ -41,6 +57,7 @@ export class RendererHost {
41
57
  #timeout;
42
58
  #onRestart;
43
59
  #onNavigate;
60
+ #onDownload;
44
61
  #network;
45
62
  // The document this host asked its renderer to load: the trusted origin
46
63
  // for everything the renderer requests.
@@ -50,6 +67,8 @@ export class RendererHost {
50
67
  #pending = new Map();
51
68
  // Page WebSockets: id -> { child, socket } (Bun's WebSocket, opened here)
52
69
  #sockets = new Map();
70
+ // Audio streams: id -> { child, stream } (PulseAudio playback streams)
71
+ #audio = new Map();
53
72
  #nextId = 1;
54
73
  #queue = Promise.resolve();
55
74
  #restarting = false;
@@ -67,7 +86,8 @@ export class RendererHost {
67
86
  * args?: string[],
68
87
  * timeout?: number,
69
88
  * onRestart?: (call: (method: string, ...params: unknown[]) => Promise<unknown>) => Promise<void>,
70
- * 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,
71
91
  * onFrame?: () => void,
72
92
  * onLifecycle?: (name: "DOMContentLoaded" | "load", generation: number) => void,
73
93
  * compositor?: boolean,
@@ -76,14 +96,19 @@ export class RendererHost {
76
96
  * }} options
77
97
  */
78
98
  constructor({
79
- 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,
80
101
  network = new NetworkService({ fetch: fetchImpl }), onFrame = null, onLifecycle = null,
81
- compositor = entry === DEFAULT_ENTRY,
102
+ // The compositor's channel is an extra stdio pipe used as fd 3, which
103
+ // Windows does not have (Bun hands out libuv pipes there, not file
104
+ // descriptors): there the main thread answers screenshots and scrolling.
105
+ compositor = entry === DEFAULT_ENTRY && process.platform !== "win32",
82
106
  } = {}) {
83
107
  this.#onFrame = onFrame;
84
108
  this.#onLifecycle = onLifecycle;
85
109
  this.#compositor = compositor;
86
110
  this.#onNavigate = onNavigate;
111
+ this.#onDownload = onDownload;
87
112
  this.#entry = entry;
88
113
  this.#args = args;
89
114
  this.#timeout = timeout;
@@ -104,9 +129,11 @@ export class RendererHost {
104
129
  const compositor = this.#compositor;
105
130
  const child = Bun.spawn({
106
131
  cmd: [process.execPath, this.#entry, ...this.#args,
107
- ...(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] : [])],
108
134
  // No inherited environment: nothing from the controller leaks in.
109
- env: {},
135
+ // Windows programs need SystemRoot to load system libraries.
136
+ env: process.platform === "win32" && process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {},
110
137
  // With BUNINU_LOG, output goes through the controller into the log file.
111
138
  // fd 3 is a socket to the renderer's compositor thread.
112
139
  stdio: ["ignore", logging ? "pipe" : "inherit", logging ? "pipe" : "inherit", ...(compositor ? ["pipe"] : [])],
@@ -121,6 +148,7 @@ export class RendererHost {
121
148
  onExit: (_process, exitCode, signalCode) => {
122
149
  this.#closeChannel(channel);
123
150
  this.#closeSockets(child);
151
+ this.#closeAudio(child);
124
152
  if (this.#child !== child) return;
125
153
  console.error(`buninu-browser: renderer ${child.pid} exited unexpectedly (${signalCode ?? `code ${exitCode}`})`);
126
154
  this.#child = null;
@@ -272,6 +300,7 @@ export class RendererHost {
272
300
  this.#closeChannel(this.#channel);
273
301
  const child = this.#child;
274
302
  this.#closeSockets(child);
303
+ this.#closeAudio(child);
275
304
  this.#child = null;
276
305
  this.#ready = null;
277
306
  child?.kill();
@@ -345,6 +374,28 @@ export class RendererHost {
345
374
  if (entry?.child !== child) return;
346
375
  const code = Number.isInteger(message.code) ? message.code : undefined;
347
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(() => {});
348
399
  } else if (message.type === "lifecycle" && LIFECYCLE_NAMES.has(message.name) && Number.isInteger(message.generation)) {
349
400
  this.#onLifecycle?.(message.name, message.generation);
350
401
  } else if (message.type === "cookie" && typeof message.value === "string" && message.value.length <= 4096) {
@@ -357,7 +408,19 @@ export class RendererHost {
357
408
  } catch {
358
409
  return;
359
410
  }
360
- 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 });
361
424
  }
362
425
  }
363
426
 
@@ -393,6 +456,43 @@ export class RendererHost {
393
456
  });
394
457
  }
395
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
+
396
496
  #closeSockets(child) {
397
497
  for (const [id, entry] of this.#sockets) {
398
498
  if (entry.child !== child) continue;
@@ -428,6 +528,7 @@ export class RendererHost {
428
528
  url: response.url || target.href,
429
529
  contentType: response.contentType,
430
530
  body: response.body,
531
+ documentCookie: this.#network.documentCookie(this.#documentUrl),
431
532
  },
432
533
  });
433
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
  }