Skip to content

node builtins: defer cross-module requires and load-time work - #35541

Open
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/faster-builtin-loading
Open

node builtins: defer cross-module requires and load-time work#35541
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/faster-builtin-loading

Conversation

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

What does this PR do?

Loading Node builtins did far more work than necessary at startup: eager require() chains (node:path → validators → shared → primordials; node:tty and internal/util/colors materializing process.stderr, which builds the whole stream/fs stack; node:fsfs/promises → the 80KB glob impl; node:http_http_servernode:net + the node:stream umbrella + internal/util/inspect), plus per-load work like primordials' makeSafe prototype copies and colors.refresh().

This defers those requires into the function bodies that use them, materializes heavy export members (fs.promises, tty/stream classes, http.Server/globalAgent, os.constants, …) on first access, computes primordials' Safe* entries lazily, and keeps util.inspect's implementation unloaded until first call while preserving the identity of the public inspect function seen by [util.inspect.custom] hooks. Also fixes a diagnostics_channel bug this uncovered: WeakReference.incRef() never held a strong ref, so a subscribed channel with no other references could be GC'd with its subscribers.

User-mode instruction counts, same native code (only src/js differs) — CommonJS require("node:http") 109M→31M, assert 94M→18M, tty 67M→8M, fs 41M→16M, util 34M→20M, stream 38M→16M, vm 37M→10M, http2 116M→79M; ESM-importing every node builtin 245M→222M.

How did you verify your code works?

bun test test/js/node and the vendored Node parallel suite (test/js/node/test/parallel, ~3200 files) on a release build show the same failure set as main; drove the debug and release binaries directly against Node for the touched surfaces; added a regression test for the diagnostics_channel GC bug (fails on main, passes here).

Loading builtin modules did far more work than needed at startup: eager
require() chains (node:path -> validators -> shared -> primordials,
node:tty and internal/util/colors materializing process.stderr, node:fs
loading fs/promises + the glob implementation, node:http loading the
server, node:net, node:stream umbrella and internal/util/inspect), plus
per-load work like primordials' makeSafe copies and colors' refresh().

Move those requires into the function bodies that use them, make heavy
export members (fs.promises, tty stream classes, stream classes,
http Server/globalAgent, os.constants, ...) materialize on first access,
compute primordials' Safe* entries lazily, and keep util.inspect's
implementation unloaded until first use while preserving the identity of
the public inspect function seen by custom-inspect hooks.

Also fixes a diagnostics_channel bug this exposed: WeakReference.incRef()
never held a strong reference, so a subscribed channel with no other
references could be garbage-collected together with its subscribers.

Measured in user-mode instructions (identical native code, only src/js
differs): CommonJS require("node:http") 109M -> 31M, assert 94M -> 18M,
tty 67M -> 8M, fs 41M -> 16M, util 34M -> 20M, stream 38M -> 16M,
vm 37M -> 10M, http2 116M -> 79M; importing every node builtin via ESM
drops from 245M to 222M.
Comment on lines +172 to 183

defineLazy("SafeArrayIterator", getSafeArrayIterator);
defineLazy("MapPrototypeGetSize", () => getGetter(Map, "size"));
defineLazy("SetPrototypeGetSize", () => getGetter(Set, "size"));
defineLazy("TypedArrayPrototypeGetLength", () => getGetter(Uint8Array, "length"));
defineLazy("TypedArrayPrototypeGetSymbolToStringTag", () => getGetter(Uint8Array, Symbol.toStringTag));
defineLazy("SafeStringIterator", () => createSafeIterator(StringPrototypeSymbolIterator, StringIteratorPrototypeNext));
defineLazy("SafeMap", () =>
makeSafe(
Map,
class SafeMap extends Map {
constructor(i) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Wrapping SafeMap/SafeSet/SafeWeakMap/SafeWeakSet and the *PrototypeGetSize getters in defineLazy() moves makeSafe()/getGetter()'s prototype snapshot from primordials-load to first-property-access, so a builtin loading primordials no longer immunizes the Safe* entries it doesn't touch (e.g. readable.ts's SafeSet now snapshots on the first multi-dest .pipe() rather than at module load). Bun's primordials was already load-order-dependent and none of the affected sites are security boundaries, so this is a conscious perf-vs-tamper-resistance tradeoff worth acknowledging rather than a blocker — a cheap mitigation is to capture the raw Map/Set/WeakMap/WeakSet prototype refs into module-level consts (as already done for the iterator inputs) and have the lazy makeSafe/getGetter read from those.

Extended reasoning...

What changed

makeSafe(unsafe, safe) copies own-property descriptors from unsafe.prototype at call time (Reflect.getOwnPropertyDescriptor(unsafePrototype, key)), and getGetter(cls, name) reads cls.prototype.__lookupGetter__(name) at call time. Before this PR, both ran synchronously during internal/primordials module evaluation for every Safe* class and every *PrototypeGetSize/TypedArrayPrototypeGet* getter. After this PR, each is wrapped in defineLazy(), so the snapshot for a given entry is taken on that entry's first property access instead.

Why this widens the tamper window (narrowly)

Previously, loading internal/primordials for any reason snapshotted all Safe* classes atomically. Now each entry materializes independently. Two concrete deltas:

  1. Lost cross-module protection. require('node:readline') loads primordials but only touches SafeStringIterator. Before: that also built SafeWeakSet from the clean prototype. After: if user code then tampers WeakSet.prototype.has before require('node:assert') first destructures SafeWeakSet, makeSafe copies the tampered method.
  2. readable.ts's SafeSet moved from top-level destructure to inside pipe(). Before this PR the awaitDrainWriters SafeSet was snapshotted when internal/streams/readable loaded; now it snapshots the first time a Readable is piped to a second destination. User code that tampers Set.prototype.add between those two points now poisons the "safe" set.

SafeArrayIterator/SafeStringIterator are not affected: their factory/next inputs (ArrayPrototypeSymbolIterator, ArrayIteratorPrototypeNext, StringPrototypeSymbolIterator, StringIteratorPrototypeNext) are still captured eagerly at lines 39-46, so their lazy createSafeIterator() cannot observe tampering.

Addressing the refutation

One verifier argued this is not a real regression because (a) Bun's primordials was never bootstrap-loaded, so tamper-resistance was always load-order-dependent, and (b) every consumer destructures at its own module top-level, firing the lazy getter synchronously during that same require() — so per-consumer snapshot timing is unchanged.

Point (a) is correct and important: this file was never a hard tamper boundary. Pre-PR, user code that tampered Set.prototype before requiring any primordials-consuming builtin already got a tampered SafeSet. This PR does not create a new bug class — it widens an existing conditional window. That's why this is a nit, not a blocker.

Point (b) is mostly right but not universally: readable.ts's SafeSet destructure moved out of module top-level and into pipe() in this same PR, so for that consumer the snapshot genuinely moved from module-load to first-multi-dest-pipe. And the cross-module "loading primordials for A also protects B" property — while never a documented contract — was real defense-in-depth that is now gone.

Step-by-step example

  1. User's entrypoint: const { Readable } = require('stream') → loads internal/streams/readable, which loads internal/primordials. Pre-PR: SafeSet is built here from clean Set.prototype. Post-PR: SafeSet is not touched (readable.ts no longer top-level destructures it), so the lazy getter stays armed.
  2. User: Set.prototype.add = function () {}.
  3. User: readable.pipe(dest1); readable.pipe(dest2) → second pipe hits the kMultiAwaitDrain branch, does const { SafeSet } = require('internal/primordials') → lazy getter fires → makeSafe(Set, ...) runs → Reflect.getOwnPropertyDescriptor(Set.prototype, 'add') returns the tampered add.
  4. state.awaitDrainWriters.add(dest) is now a no-op; back-pressure bookkeeping silently breaks.

Pre-PR, step 1 would have snapshotted SafeSet before step 2 could tamper it.

Why nit, not normal

  • Bun's primary tamper-resistance mechanism is $-intrinsics, not this file — the header explicitly says "TODO: Use native code and JSC intrinsics… Do not use this file for new code".
  • The pre-PR guarantee was already load-order-dependent; this widens an imperfect window rather than opening a new one.
  • The most-used entries (SafeMap, SafeSet, the Map/Set/TypedArray getters) are still eagerly materialized whenever node:util loads, via inspect_globals' top-level destructure — the PR author preserved that on purpose.
  • None of the affected paths (awaitDrainWriters, assert's SafeWeakSet, calltracker's SafeWeakMap) are security boundaries.
  • The PR author explicitly acknowledged the changed guarantee (worker/messaging.ts comment updated from "at bootstrap" to "when this module loads").

Suggested cheap mitigation (optional)

Capture the raw prototypes into module-level consts alongside the iterator inputs already at lines 39-46 — e.g. const MapPrototype = Map.prototype, SetPrototype = Set.prototype, ... — and have the lazy makeSafe/getGetter closures read from those instead of the live Map/Set/WeakMap/WeakSet. That keeps all the deferred work (the ~4× Reflect.ownKeys walk + descriptor copy) lazy while restoring the pre-PR snapshot instant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the timing: at HEAD nothing loads internal/primordials before user code either (only lazy require sites in on-demand modules; no bootstrap requireId), and a Map.prototype.size tamper on line 1 of a script is already visible to MapPrototypeGetSize on release 1.4.0 — so the snapshot was always post-user-code; this only moves it from first-module-require to first-property-access. Where a module actually depends on capturing at its own load (assert's SafeMap/SafeSet/SafeWeakSet, readline's SafeStringIterator, worker_threads/messaging's SafeMap) the destructure stays at module top level. Left as-is.

Comment thread src/js/internal/stream.ts
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 522f7a0b-621e-4894-a851-aa3eaed98b00

📥 Commits

Reviewing files that changed from the base of the PR and between 15a5913 and 946b625.

📒 Files selected for processing (5)
  • src/js/internal/assert/assertion_error.ts
  • src/js/internal/util/inspect.js
  • src/js/internal/util/inspect_globals.ts
  • src/js/node/diagnostics_channel.ts
  • src/js/node/util.ts

Walkthrough

Changes

The PR defers internal dependency loading and selected export initialization across assertion, stream, HTTP, networking, filesystem, TLS, utility, worker, and Node compatibility modules. It also updates inspection symbols, diagnostics-channel retention, TTY stream behavior, and lazy accessor wiring.

Lazy initialization and Node compatibility

Layer / File(s) Summary
Primordials and inspection foundations
src/js/internal/primordials.js, src/js/internal/util/*, src/js/internal/assert/*, src/js/internal/validators.ts
Primordials, inspection helpers, assertion diff generation, validators, colors, and error utilities use cached lazy loading or updated Node inspection symbols.
Stream dependency and operator loading
src/js/internal/stream*.ts, src/js/internal/streams/*, src/js/internal/fs/streams.ts
Stream constructors, operators, pipelines, validators, destruction paths, file handles, adapters, and stream exports load dependencies at use sites.
HTTP, networking, TLS, and filesystem wiring
src/js/node/_http*, src/js/node/http*.ts, src/js/node/net.ts, src/js/node/tls.ts, src/js/node/fs.ts, src/js/node/https.ts
HTTP, HTTP/2, HTTPS, cluster, filesystem, networking, TLS, and related exported values use deferred module loading and cached accessors.
Node export accessors and runtime loaders
src/js/node/{crypto,os,path,stream,timers,util,v8,vm,wasi,worker_threads}.ts, src/js/internal/worker/*
Node-compatible exports and runtime helpers switch selected values and dependencies to lazy getters, inline requires, or cached loaders.
TTY behavior and diagnostics retention
src/js/node/tty.ts, src/js/node/diagnostics_channel.ts, test/js/node/diagnostics_channel/*
TTY stream constructors and methods are lazily created, while diagnostics channels retain subscribed targets across garbage collection and test that behavior.

Possibly related PRs

  • oven-sh/bun#31216 — Overlaps in worker_threads.ts, including MessagePort error and validation paths.
  • oven-sh/bun#34660 — Overlaps in assertion error diff and inspection behavior.
  • oven-sh/bun#33527 — Overlaps in TTY setRawMode per-instance state handling.

Suggested reviewers: robobun, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: deferring builtin cross-module requires and load-time work.
Description check ✅ Passed The PR description includes both required sections and covers the change, impact, and verification.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/internal/fs/streams.ts`:
- Around line 6-20: Update the `loadFileHandle` destructuring assignment to bind
`fileHandlePrototypeFsync` from `FileHandle.prototype.sync` instead of `.fsync`,
preserving the `fileHandleStreamFs` fast path that compares `fh.sync`.

In `@src/js/internal/streams/compose.ts`:
- Around line 6-19: Restore the ArrayPrototypeSlice binding in the module
initialization alongside the other required internal utilities, ensuring the
existing compose logic can call it when composing multiple streams without a
ReferenceError.

In `@src/js/internal/streams/pipeline.ts`:
- Around line 277-278: Cache the lazily loaded Duplex dependency consistently in
the pipeline implementation: introduce or reuse a module-scoped lazy dependency
matching the existing addAbortListener and PassThrough patterns, and update both
Duplex require call sites—including the one around stream conversion and the
later call site—to use that cached value.

In `@src/js/internal/streams/readable.ts`:
- Line 1726: Remove the redundant comment immediately preceding the
Readable.prototype streaming operator attachment code; leave the operator
implementation unchanged.

In `@src/js/node/net.ts`:
- Around line 4199-4224: Make the lazy export and inspection paths
tamper-resistant: in src/js/node/net.ts lines 4199-4224, update both BlockList
and SocketAddress getters/setters to use captured primordial property-definition
helpers instead of mutable Object.defineProperties or Reflect.defineProperty; in
src/js/node/fs.ts lines 1420-1422, use the captured property-definition helper
in the setter; and in src/js/node/vm.ts line 331, replace the mutable Symbol.for
lookup with a captured or intrinsic symbol-registry lookup for the inspect key.

In `@src/js/node/tty.ts`:
- Around line 223-242: Update the ReadStream and WriteStream setters in the
exports object to assign the provided value to their corresponding module-level
loader cache before redefining the exported property through defineTTYValue.
Keep the getter and existing export behavior unchanged so internal loader calls
reuse the setter-assigned implementations.
- Around line 37-41: Update the lazy prototype getter in ReadStreamImpl to
preserve ReadStream’s existing constructor when replacing the prototype with one
based on fs.ReadStream.prototype. Copy the constructor from the prototype
created by $toClass before returning the lazily created prototype, so instances
continue to report ReadStream as their constructor.

In `@src/js/node/util.ts`:
- Line 20: Replace the local RegExp.prototype.exec capture with the
primordial-safe RegExpPrototypeExec imported from internal/primordials,
alongside SafeMap. Ensure styleText() hex validation continues using that
imported safe executor and remove the mutable prototype capture.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a3ba6715-f47d-4274-9fb8-c54487006b91

📥 Commits

Reviewing files that changed from the base of the PR and between 65f3fe3 and 405d205.

📒 Files selected for processing (64)
  • src/js/internal/assert/assertion_error.ts
  • src/js/internal/cluster/child.ts
  • src/js/internal/cluster/primary.ts
  • src/js/internal/errors.ts
  • src/js/internal/fs/streams.ts
  • src/js/internal/http/FakeSocket.ts
  • src/js/internal/primordials.js
  • src/js/internal/promisify.ts
  • src/js/internal/shared.ts
  • src/js/internal/stream.promises.ts
  • src/js/internal/stream.ts
  • src/js/internal/streams/add-abort-signal.ts
  • src/js/internal/streams/compose.ts
  • src/js/internal/streams/destroy.ts
  • src/js/internal/streams/duplex.ts
  • src/js/internal/streams/end-of-stream.ts
  • src/js/internal/streams/from.ts
  • src/js/internal/streams/operators.ts
  • src/js/internal/streams/pipeline.ts
  • src/js/internal/streams/readable.ts
  • src/js/internal/streams/state.ts
  • src/js/internal/streams/transform.ts
  • src/js/internal/streams/writable.ts
  • src/js/internal/timers.ts
  • src/js/internal/tls.ts
  • src/js/internal/util/colors.ts
  • src/js/internal/util/deprecate.ts
  • src/js/internal/util/inspect.js
  • src/js/internal/util/inspect_globals.ts
  • src/js/internal/validators.ts
  • src/js/internal/webstreams_adapters.ts
  • src/js/internal/worker/messaging.ts
  • src/js/node/_http2_upgrade.ts
  • src/js/node/_http_agent.ts
  • src/js/node/_http_client.ts
  • src/js/node/_http_common.ts
  • src/js/node/_http_incoming.ts
  • src/js/node/_http_outgoing.ts
  • src/js/node/_http_server.ts
  • src/js/node/assert.ts
  • src/js/node/child_process.ts
  • src/js/node/crypto.ts
  • src/js/node/diagnostics_channel.ts
  • src/js/node/fs.promises.ts
  • src/js/node/fs.ts
  • src/js/node/http.ts
  • src/js/node/http2.ts
  • src/js/node/https.ts
  • src/js/node/net.ts
  • src/js/node/os.ts
  • src/js/node/path.ts
  • src/js/node/readline.ts
  • src/js/node/stream.ts
  • src/js/node/timers.promises.ts
  • src/js/node/timers.ts
  • src/js/node/tls.ts
  • src/js/node/tty.ts
  • src/js/node/util.ts
  • src/js/node/v8.ts
  • src/js/node/vm.ts
  • src/js/node/wasi.ts
  • src/js/node/worker_threads.ts
  • src/js/node/zlib.ts
  • test/js/node/diagnostics_channel/diagnostics_channel.test.ts

Comment thread src/js/internal/fs/streams.ts
Comment thread src/js/internal/streams/compose.ts
Comment thread src/js/internal/streams/pipeline.ts Outdated
Comment thread src/js/internal/streams/readable.ts Outdated
Comment thread src/js/node/net.ts
Comment thread src/js/node/tty.ts
Comment thread src/js/node/tty.ts
Comment thread src/js/node/util.ts
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator
Updated 10:09 AM PT - Jul 25th, 2026

@Jarred-Sumner, your commit ec4868a has some failures in Build #80539 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35541

That installs a local version of the PR into your bun-35541 executable, so you can run:

bun-35541 --bun

…-loading

# Conflicts:
#	src/js/node/readline.ts
…er cache

- pipeline: memoize the internal/streams/duplex load like the other lazy deps
- internal/stream: remove the now-unused ObjectKeys binding
- readable: drop a redundant comment
- tty: assigning ReadStream/WriteStream also seeds the module's loader cache

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/js/node/tty.ts (1)

37-41: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Lazy prototype drops ReadStream's own constructor. Object.create(fs.ReadStream.prototype) discards the prototype $toClass installed, so instances report fs.ReadStream as their constructor. Copy the constructor onto the new prototype.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/tty.ts` around lines 37 - 41, Update the lazy prototype getter in
ReadStreamImpl to preserve the constructor installed by $toClass: after creating
the prototype from fs.ReadStream.prototype, copy ReadStreamImpl’s constructor
onto the new prototype before returning it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/tls.ts`:
- Around line 1419-1426: Move the Object.defineProperty call for
kNativeSecureContextCtor from the Server constructor to module-scope
initialization, preserving getNativeSecureContext, enumerable: false, and
configurable: true. Ensure it is registered once on Server.prototype while
leaving constructor behavior unchanged.

In `@src/js/node/tty.ts`:
- Around line 116-117: Update the property descriptors in both class prototype
getter definitions to set enumerable to false, matching Node’s standard
non-enumerable prototype behavior. Preserve configurable: true and the existing
getter/replacement logic.

---

Duplicate comments:
In `@src/js/node/tty.ts`:
- Around line 37-41: Update the lazy prototype getter in ReadStreamImpl to
preserve the constructor installed by $toClass: after creating the prototype
from fs.ReadStream.prototype, copy ReadStreamImpl’s constructor onto the new
prototype before returning it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0441d8e8-39a4-4483-8604-d628ebe529e0

📥 Commits

Reviewing files that changed from the base of the PR and between 405d205 and 15a5913.

📒 Files selected for processing (8)
  • src/js/internal/shared.ts
  • src/js/internal/stream.ts
  • src/js/internal/streams/pipeline.ts
  • src/js/internal/streams/readable.ts
  • src/js/internal/util/inspect.js
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • src/js/node/tty.ts
💤 Files with no reviewable changes (2)
  • src/js/internal/stream.ts
  • src/js/internal/streams/readable.ts

Comment thread src/js/node/tls.ts
Comment on lines 1419 to +1426
// Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to
// an SNICallback (the `context.context || context` unwrap accepts both the
// wrapper and the unwrapped native context).
Server.prototype[kNativeSecureContextCtor] = NativeSecureContext;
Object.defineProperty(Server.prototype, kNativeSecureContextCtor, {
get: getNativeSecureContext,
enumerable: false,
configurable: true,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial

Hoist the kNativeSecureContextCtor accessor out of the Server constructor.

Object.defineProperty(Server.prototype, kNativeSecureContextCtor, {...}) targets the prototype and doesn't depend on this/options, yet it re-runs on every new tls.Server(...) call. Since this PR's goal is eliminating repeated per-call work, this accessor should be defined once at module scope instead of inside the constructor.

♻️ Proposed fix
 function Server(options, secureConnectionListener): void {
   ...
-  // Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to
-  // an SNICallback (the `context.context || context` unwrap accepts both the
-  // wrapper and the unwrapped native context).
-  Object.defineProperty(Server.prototype, kNativeSecureContextCtor, {
-    get: getNativeSecureContext,
-    enumerable: false,
-    configurable: true,
-  });
-
   Server.prototype.getTicketKeys = function () {
     throw Error("Not implented in Bun yet");
   };
   ...
 }
+
+// Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to
+// an SNICallback (the `context.context || context` unwrap accepts both the
+// wrapper and the unwrapped native context). Defined once at module load
+// rather than on every Server construction.
+Object.defineProperty(Server.prototype, kNativeSecureContextCtor, {
+  get: getNativeSecureContext,
+  enumerable: false,
+  configurable: true,
+});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Lets net.ts's SNI dispatch recognize a raw native SecureContext handed to
// an SNICallback (the `context.context || context` unwrap accepts both the
// wrapper and the unwrapped native context).
Server.prototype[kNativeSecureContextCtor] = NativeSecureContext;
Object.defineProperty(Server.prototype, kNativeSecureContextCtor, {
get: getNativeSecureContext,
enumerable: false,
configurable: true,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/tls.ts` around lines 1419 - 1426, Move the Object.defineProperty
call for kNativeSecureContextCtor from the Server constructor to module-scope
initialization, preserving getNativeSecureContext, enumerable: false, and
configurable: true. Ensure it is registered once on Server.prototype while
leaving constructor behavior unchanged.

Comment thread src/js/node/tty.ts
Comment on lines +116 to +117
enumerable: true,
configurable: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

enumerable: true makes prototype enumerable on both classes. Function/class prototype is non-enumerable in Node; here Object.keys(tty.ReadStream) includes "prototype" until the getter fires (after which the replacement descriptor is non-enumerable), so enumeration results differ before/after first access.

♻️ Match standard descriptor
-    enumerable: true,
+    enumerable: false,
     configurable: true,

Also applies to: 215-216

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/js/node/tty.ts` around lines 116 - 117, Update the property descriptors
in both class prototype getter definitions to set enumerable to false, matching
Node’s standard non-enumerable prototype behavior. Preserve configurable: true
and the existing getter/replacement logic.

Comment thread src/js/node/tls.ts
Comment on lines +506 to +507
let NativeSecureContext;
const getNativeSecureContext = () => (NativeSecureContext ??= $rust("SecureContext.rs", "js.getConstructor"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Making NativeSecureContext lazy leaves TLSSocket.prototype[kNativeSecureContextCtor] = NativeSecureContext; (line 1003) assigning undefined at module load — the sibling Server.prototype site (line 1422) was converted to a getter but this one was missed. As a result, a socket-level SNICallback (new tls.TLSSocket(sock, { isServer: true, SNICallback })) that returns a raw native SecureContext now fails net.ts's state.server?.[kNativeSecureContextCtor] && context instanceof ... check and rejects with "Invalid SNI context". Convert line 1003 to the same Object.defineProperty(TLSSocket.prototype, kNativeSecureContextCtor, { get: getNativeSecureContext, ... }) pattern.

Extended reasoning...

What the bug is

This PR converted the eager const NativeSecureContext = $rust(...) binding in src/js/node/tls.ts into a lazy one:

let NativeSecureContext;
const getNativeSecureContext = () => (NativeSecureContext ??= $rust("SecureContext.rs", "js.getConstructor"));

It correctly updated Server.prototype[kNativeSecureContextCtor] (line 1422) to an accessor that calls getNativeSecureContext, but it left the parallel assignment at line 1003 untouched:

// Lets net.ts's SNI dispatch recognize a raw native SecureContext returned by
// a socket-level SNICallback (`new tls.TLSSocket(sock, { isServer: true,
// SNICallback })`), where the handler's `this` is the socket, not a Server.
TLSSocket.prototype[kNativeSecureContextCtor] = NativeSecureContext;

This line runs at module top level. Nothing between the let NativeSecureContext; declaration (line 506) and line 1003 calls getNativeSecureContext() at module scope — newNativeSecureContext (line 613) and the InternalSecureContext constructor only reference it inside function bodies. So TLSSocket.prototype[kNativeSecureContextCtor] is now permanently undefined.

The code path that triggers it

The consumer is in src/js/node/net.ts:698:

} else if (state.server?.[kNativeSecureContextCtor] && context instanceof state.server[kNativeSecureContextCtor]) {
  state.selected = context;
} else {
  state.failed = new Error("Invalid SNI context");
}

Per the comment at tls.ts:1000-1002, when SNICallback is supplied at the socket level (new tls.TLSSocket(rawSock, { isServer: true, SNICallback })), state.server in this dispatch is the TLSSocket instance, not a tls.Server. The prototype lookup state.server[kNativeSecureContextCtor] therefore reads from TLSSocket.prototype.

Why existing code doesn't prevent it

The Server.prototype getter added at line 1422 only helps when state.server is a tls.Server. For the socket-level SNI path there is no Server, so the TLSSocket prototype is the only source of the constructor. On main (df6c7ee) NativeSecureContext was an eagerly-evaluated const, so line 1003 assigned the real constructor and this branch worked.

Step-by-step proof

  1. require('node:tls') evaluates. Line 506: let NativeSecureContext;undefined.
  2. Line 1003 runs at top level: TLSSocket.prototype[kNativeSecureContextCtor] = undefined.
  3. User creates const s = new tls.TLSSocket(raw, { isServer: true, SNICallback: (name, cb) => cb(null, nativeCtx) }) where nativeCtx is a raw native SecureContext (no .context wrapper property).
  4. During the handshake, net.ts's SNI dispatch runs with state.server = s (the TLSSocket) and context = nativeCtx.
  5. context.context is undefined (raw native handle has no JS wrapper property), so innerContext is falsy → falls through to the else if.
  6. state.server?.[kNativeSecureContextCtor] reads s[kNativeSecureContextCtor]TLSSocket.prototype[kNativeSecureContextCtor]undefined. The && short-circuits.
  7. Falls through to state.failed = new Error("Invalid SNI context"), and the handshake is rejected — a regression from main where the instanceof check would have accepted nativeCtx.

Impact and fix

This is a functional regression on a documented path (the comment at tls.ts:1000-1002 spells out exactly this use case), and it's the classic missed-sibling-site refactor called out in REVIEW.md ("Fix the whole class in the same PR — grep for every sibling site sharing the pattern"). The fix is a one-liner: replace the direct assignment at line 1003 with the same accessor pattern used at line 1422:

Object.defineProperty(TLSSocket.prototype, kNativeSecureContextCtor, {
  get: getNativeSecureContext,
  enumerable: false,
  configurable: true,
});

Comment on lines +118 to +127
for (const key of ["defaultOptions", "replDefaults"] as const) {
Object.defineProperty(publicInspect, key, {
__proto__: null,
get() {
return loadImpl().inspect[key];
},
set(options) {
loadImpl().inspect[key] = options;
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The replDefaults accessor on publicInspect omits configurable: true, so Object.getOwnPropertyDescriptor(require('util').inspect, 'replDefaults').configurable flips from true (pre-PR, when util.inspect was the internal inspect) to false. The internal inspect deliberately keeps replDefaults configurable (inspect.js:747) because node:repl re-defines it; Bun's own repl.js redefines it on the internal inspect so nothing internal breaks, but userland Object.defineProperty(util.inspect, 'replDefaults', ...) will now throw. Adding configurable: true to this loop's descriptor (or splitting replDefaults out) restores the previous surface.

Extended reasoning...

What the bug is

inspect_globals.ts builds publicInspect — the new function that util.ts exports as util.inspect — and mirrors defaultOptions/replDefaults onto it via a shared loop:

for (const key of ["defaultOptions", "replDefaults"] as const) {
  Object.defineProperty(publicInspect, key, {
    __proto__: null,
    get() { return loadImpl().inspect[key]; },
    set(options) { loadImpl().inspect[key] = options; },
  });
}

Because the descriptor omits configurable, both properties default to non-configurable. For defaultOptions this happens to match the internal inspect (inspect.js:726–735 also omits configurable). But for replDefaults it diverges: the internal inspect explicitly sets configurable: true (inspect.js:747) with a comment — "node:repl re-defines this property with its own writer-backed accessor (see REPLServer constructor); keep it configurable so that works."

The code path

Before this PR, node/util.ts did const inspect = utl.inspect where utl = require("internal/util/inspect"), so util.inspect was the internal inspect and inherited its replDefaults descriptor (configurable: true). After this PR, node/util.ts does const inspect = inspectGlobals.publicInspect, so util.inspect === publicInspect and its replDefaults descriptor is the non-configurable one from the loop above.

Why existing code doesn't prevent it

The second loop in inspect_globals.ts (for colors/styles) does set configurable: true, but the first loop does not — and replDefaults is in the first loop. Bun's own src/js/node/repl.js isn't affected because it redefines replDefaults on the internal inspect (obtained via internal/repl/node-inspect), not on util.inspect. So there's no internal breakage, only a public-surface descriptor regression.

Impact

Observable but narrow:

  • Object.getOwnPropertyDescriptor(require('util').inspect, 'replDefaults').configurable changes from truefalse.
  • Object.defineProperty(require('util').inspect, 'replDefaults', {...}) now throws TypeError: Attempting to change ... unconfigurable property where it previously succeeded.
  • Plain assignment (util.inspect.replDefaults = {...}) still works — it goes through the setter, which forwards to the internal inspect's setter and merges into inspectReplDefaults. So the common mutation path is unaffected.

Userland calling defineProperty on util.inspect.replDefaults is exotic; nothing in Bun itself hits it. Hence nit, not blocking.

Step-by-step proof

  1. require('node:util') runs → inspectGlobals is loaded → the for (const key of ["defaultOptions", "replDefaults"]) loop runs Object.defineProperty(publicInspect, "replDefaults", { __proto__: null, get, set }). Per ECMA-262 §6.2.6.4/OrdinaryDefineOwnProperty, a missing [[Configurable]] in the descriptor defaults to false when the property does not already exist.
  2. util.ts sets const inspect = inspectGlobals.publicInspect and exports it.
  3. Object.getOwnPropertyDescriptor(require('util').inspect, 'replDefaults'){ get: [Function], set: [Function], enumerable: false, configurable: false }.
  4. Compare pre-PR: util.inspect was internal/util/inspect's inspect, whose replDefaults was defined at inspect.js:736–748 with explicit configurable: true → the same call returned configurable: true.
  5. Therefore Object.defineProperty(require('util').inspect, 'replDefaults', { get() {...} }) succeeded pre-PR and throws post-PR.

Fix

Add configurable: true to the first loop's descriptor (harmless for defaultOptions, required for replDefaults to match the wrapped function), or split replDefaults into its own Object.defineProperty with configurable: true to mirror inspect.js exactly.

Jarred-Sumner pushed a commit that referenced this pull request Aug 12, 2026
…s ESM (#37525)

Builtins implemented in `src/js` are handed to ES module importers by
`generateInternalModuleSourceCode`
(`src/jsc/bindings/ModuleLoader.cpp`), which snapshots the builtin's
CommonJS exports object into a synthetic module record. It did that with
`object->get()` on every own enumerable property, so every accessor on
the exports object ran at import time. Those accessors are the builtins'
lazy-loading mechanism, which means they only ever helped `require()`
callers:

- `node:fs`: `ReadStream`, `WriteStream`, `FileReadStream`,
`FileWriteStream`, `Utf8Stream` each `require("internal/fs/streams")`,
which loads the whole `node:stream` stack.
- `node:tls`: `rootCertificates` parses the bundled CA store,
`DEFAULT_CIPHERS` queries BoringSSL.
- `node:http`: `globalAgent` instantiates the agent. `node:timers` /
`node:stream`: `promises` load `timers/promises` and `stream/promises`.
`node:assert`: `AssertionError` loads `internal/assert/assertion_error`.
`node:repl`, `node:events`, `node:buffer`, `node:os` have a few more.

#35541 is about to turn more of these into accessors (`fs.promises`
among them), which this path would immediately defeat for the most
common import style there is.

## Repro

```js
// probe.mjs, run as `bun --expose-internals probe.mjs esm` vs `... require`
const mode = process.argv[2];
const { createRequire } = await import("node:module");
let t = performance.now();
if (mode === "esm") await import("node:fs"); else createRequire(import.meta.url)("node:fs");
const fsMs = performance.now() - t;
t = performance.now();
createRequire(import.meta.url)("internal/fs/streams");
console.log(mode, "node:fs", fsMs.toFixed(1), "ms; internal/fs/streams afterwards", (performance.now() - t).toFixed(1), "ms");
```

bun 1.4.0 (release): `esm node:fs 13.3 ms; internal/fs/streams
afterwards 0.2 ms` (already loaded) vs `require node:fs 7.8 ms;
internal/fs/streams afterwards 5.0 ms`. On a debug build the difference
is ~370ms per process, and `test/harness.ts` does `import fs from
"node:fs"`, so every test file paid it.

The directly observable form, which the tests use: the `node:fs` getters
replace themselves with data properties when they run, so after `import
fs from "node:fs"`, `Object.getOwnPropertyDescriptor(fs, "ReadStream")`
had a `value` instead of a `get`.

## Fix

An ES module binding cannot be made lazy from the embedder side: once
the record exists, named imports read the exporting environment's slot
directly (`ModuleVar`) and namespace reads go through `getValue()` in
`JSModuleNamespaceObject`, so the slot has to hold the value before
anything binds to it. The engine half is oven-sh/WebKit#408, merged as
7b763944f0ec. `WEBKIT_VERSION` moves from 09e477744721 (what main pins)
to that sha; it is the only commit between the two:

- `SyntheticModuleRecord::tryCreateWithExportNamesAndValues()` gets an
overload taking a source object; an export whose value is the empty
`JSValue` is declared but left in TDZ.
- `materializeLazyExport()` fills such a slot from `source[name]` the
first time something binds to it:
`CyclicModuleRecord::initializeEnvironment` when an importer links a
named import that resolves to it (directly or through `export *` /
`export { x } from` chains),
`JSModuleNamespaceObject::getOwnPropertySlotCommon` when a namespace
read finds the slot empty (it then re-reads and installs the value the
same way as before, so the namespace IC still applies), and
`WebAssemblyModuleRecord::initializeImports`, which snapshots imported
bindings directly (unreachable in bun today, reachable once #35587
lands). It is a no-op for a slot that already has a value, so
`overrideExportValue` (what `mock.module` and `spyOn` use) keeps
winning, `*namespace*` is skipped, a getter that re-enters keeps the
first value that landed, and an exception from the getter propagates and
leaves the slot for the next attempt. The write uses the same
`symbolTablePutTouchWatchpointSet` the eager path used for its one
write, so IC / DFG behaviour after materialization is identical to an
eagerly built record; the baseline namespace IC already bails to the
slow path on an empty slot and the DFG only folds loads that went
through an installed IC.
- `SyntheticSourceProvider::createWithLazyExports()` takes a generator
that returns the source object, and `makeModule` passes it through.
Records built any other way (JSON modules, `vm.SyntheticModule`, every
existing `create()` caller) have no source object and take a pointer
test on the new paths.

The bun half is `generateInternalModuleSourceCode`: it now looks each
property up with `getOwnPropertySlot`, snapshots data properties exactly
as before, declares everything else (accessors) as a lazy export, and
returns the exports object as the source. A builtin without accessors
(most of them) produces a record identical to the old one. `require()`
of a builtin never went through this function (`fetchCommonJSModule`
returns the registry object), so it is unaffected. The engine change is
inert without this opt-in: building the new WebKit with the old
`ModuleLoader.cpp` still fails the new tests the same way the release
does.

### Why this is the right behaviour

Node builds the ESM facade of a builtin by reading the getters too, so
the old behaviour was not wrong, just expensive, and there is no compat
reason to keep it. The only observable difference is *when* an accessor
export is sampled: at import before, at the point something first binds
to it now. Data properties still snapshot at import. A namespace export
or named import of an accessor is a snapshot either way (that is what
`module.syncBuiltinESMExports()` exists for), and the later sample is
never staler than the old one. Everything an importer can do with the
export resolves to the same object `require()` would hand out, which is
what the tests check for each binding path.

`Object.keys(ns)`, `hasOwnProperty`, spread, and `console.log(ns)` still
materialize what they touch, because `[[GetOwnProperty]]` has to produce
the value; `in` ([[HasProperty]]) does not. Plugin `loader: "object"`
modules and user CommonJS imported from ESM are deliberately not
touched: those expose user objects whose getter timing is user-visible
(#36677 is about that), whereas builtins' accessors are our own
lazy-loading idiom.

## Tests

`test/js/bun/resolve/builtin-esm-lazy-exports.test.ts`, next to the
other module-system behaviour tests in that directory. Each case runs in
its own process (a binding can only be materialized once) and checks
both that importing left the `node:fs` accessors untouched and that the
binding is the real class once used, for: default + named data import,
named import of an accessor (exactly that one materializes), `import *
as`, `import defer * as`, dynamic `import()` including `Object.keys` and
that the export list is unchanged, `export *` and `export { x as y }
from` re-exports (named import through the star and a read off the
re-exporter's namespace, which exercises materializing on a record other
than the namespace's own), accessors on `node:assert` (a function
exports object), `node:timers` and `node:stream`, and `spyOn` /
`mock.module` on an already imported builtin. 7 of the 8 cases fail on
the current eager code (everything reports `value`); the
`assert`/`timers`/`stream` identity case is a regression guard.

With the fix, the probe above reports `internal/fs/streams` as not
loaded after `import("node:fs")` (debug build: 338ms to load it
afterwards in both modes, versus 10ms before because the import had
already loaded it). Also run on the new build:
`test/js/node/fs/fs.test.ts` (covers the existing `export { ReadStream,
WriteStream } from "node:fs"` and `export * from "node:fs"` fixtures),
the `test/js/bun/resolve` module tests,
`test/js/bun/test/mock/mock-module*.test.ts`,
`node-module-module.test.js`, `buffer-inspectmaxbytes.test.ts` (which
pins that a named import of an accessor is a snapshot), and the events /
os / timers / assert / tls-internals / node-http suites, all green apart
from two failures that are identical on the released binary in this
environment (`os.userInfo`, the http proxy test).

<!-- robobun:evidence:begin -->

---

**[decide:webkit]** gate passed · iteration 0 · 3 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 7 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
132 |       afterKeys: kinds(),
133 |       keysMatch: namespaceKeys.sort().join() === [...exportsKeys, "default"].sort().join(),
134 |       allAreTheClasses: STREAMS.every(name => typeof ns[name] === "function" && ns[name] === fs[name]),
135 |     });
136 |   `);
137 |   expect(result).toEqual({
                       ^
error: expect(received).toEqual(expected)

  {
    "afterImport": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
-     "ReadStream": "accessor",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
+     "ReadStream": "value",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
    "afterKeys": {
      "FileReadStream": "value",
      "FileWriteStream": "value",
      "ReadStream": "value",
      "Utf8Stream": "value",
      "WriteStream": "value",
    },
    "after
... (truncated)

release without fix: 7 FAILED
bun test v1.4.0-canary.1 (9008ae7)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
68 |   const result = await runEntry(`
69 |     import { ReadStream } from "node:fs";
70 |     import { fs, kinds, print } from "./helper.mjs";
71 |     print({ kinds: kinds(), isTheClass: typeof ReadStream === "function" && ReadStream === fs.ReadStream });
72 |   `);
73 |   expect(result).toEqual({ kinds: kinds("ReadStream"), isTheClass: true });
                      ^
error: expect(received).toEqual(expected)

  {
    "isTheClass": true,
    "kinds": {
-     "FileReadStream": "accessor",
-     "FileWriteStream": "accessor",
+     "FileReadStream": "value",
+     "FileWriteStream": "value",
      "ReadStream": "value",
-     "Utf8Stream": "accessor",
-     "WriteStream": "accessor",
+     "Utf8Stream": "value",
+     "WriteStream": "value",
    },
  }

- Expected  - 4
+ Received  + 4

      at <anonymous> (/workspace/bun/test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:73:18)
89 |       afterRead: kinds(),
90 |       isTheClass: typeof WriteStream === "function" && WriteStream === fs.WriteStream,
91 |       secondReadIsStable: ns.WriteStream === WriteStream,
92 |     })
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/resolve/builtin-esm-lazy-exports.test.ts
bun test v1.4.0 (435641a)

test/js/bun/resolve/builtin-esm-lazy-exports.test.ts:
(pass) importing the module does not run its accessors [1746.65ms]
(pass) a named import materializes exactly the binding it links [2145.14ms]
(pass) a namespace export materializes when it is read, not on import or `in` [2175.43ms]
(pass) a deferred namespace (import defer) materializes on read as well [2720.80ms]
(pass) import() namespace: same export list as before, enumerating it materializes everything [2873.44ms]
(pass) re-exports bind through to the builtin's own binding [2232.58ms]
(pass) spyOn and mock.module on an imported builtin [2125.50ms]
(pass) accessors on other builtins bind to what require() returns [2735.47ms]

 8 pass
 0 fail
 24 expect() calls
Ran 8 tests across 1 file. [8.97s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     435641a
  features     baseline

22 deps, 107 codegen, 1176 objects in 1540ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[4/1238] fetch zlib
[zlib] up to date
[5/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] fetch picohttpparser
[picohttpparser] up to date
[8/1237] gen .bind.ts → GeneratedBindings.cpp
[9/1237] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingBuffer.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingBuffer.cpp
[10/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[11/1237] gen ProcessBindingHTTPParser.lut.h
Generating /workspac
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
scripts/build/deps/webkit.ts                       |   2 +-
 src/jsc/bindings/ModuleLoader.cpp                  |  28 ++-
 .../bun/resolve/builtin-esm-lazy-exports.test.ts   | 232 +++++++++++++++++++++
 3 files changed, 254 insertions(+), 8 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                                  reads  edits  tests
scripts/build/deps/webkit.ts                              3      3      0
src/jsc/bindings/ModuleLoader.cpp                         1      3      0
test/js/bun/resolve/builtin-esm-lazy-exports.test.ts      0      0      0
```

</details>

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants