__esm helper silently swallows init errors after the first throw (original) (raw)

Version

esbuild 0.25.4. The pattern below has been present in the helper for a long time and reproduces on every recent version I checked.

Summary

The __esm helper used to lazy-initialize ESM-namespace modules surfaces a thrown init error only on the very first call. All subsequent calls return undefined silently, so downstream code throws a misleading TypeError: Cannot read properties of undefined (reading '<some-export>') and the real init error is gone.

Root cause: fn = 0 is sequenced before the call to the underlying init function. If the init throws, fn has already been cleared, so the next time the helper runs, the fn && ... short-circuit skips the init entirely and returns the still-unset res = undefined.

Helper as emitted

var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; };

The order of operations on the call line:

  1. __getOwnPropNames(fn)[0] — read the first property name of fn
  2. fn[...] — fetch the init function
  3. (0, ...) — strip this
  4. (fn = 0) — assignment passed as the argument; fn is now 0 before the call returns
  5. (...) — call the init function with 0 as the arg
  6. If init throws, control jumps out with fn already 0

After step 6, res is still undefined. Every later call returns undefined.

Minimal repro

// boom.js console.log("evaluating boom.js"); throw new Error("init failed (the real cause)"); export const value = 42;

// consumer.js import { value } from "./boom.js"; export function getValue() { return value; }

// entry.js import { getValue } from "./consumer.js"; export default { fetch() { try { return new Response(String(getValue())); } catch (e) { return new Response("err: " + e.message); } }, };

Bundle:

esbuild entry.js --bundle --format=esm --target=es2022 --outfile=out.js

Run twice on a runtime that keeps the module cache (Workers, Deno Deploy, Lambda warm starts, a long-lived Node server). First call: real Error: init failed (the real cause). Second call: Cannot read properties of undefined (reading 'value') — the original error has been swallowed.

Why this hurts in practice

Amplified hugely on long-lived single-isolate runtimes. One isolate handles many requests, so a cold-start init failure produces one log line with the real error, then N log lines with the symptom error. Short log retention or reactive (rather than always-on) tailing means the real cause is unrecoverable without a redeploy.

OpenNext / @opennextjs/cloudflare users are particularly exposed because their middleware bundle wraps Next.js's _ENTRIES registry through __esm; an init error blanks the registry and every later request gets Cannot read properties of undefined (reading 'default').

Proposed fix

Capture the thrown error in a closure and re-throw on every subsequent call:

var __esm = (fn, res, err) => function __init() { if (err) throw err; if (fn) { try { res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0); } catch (e) { err = e; throw e; } } return res; };

One extra closed-over variable per wrapped module. Behavior change: a failed init becomes permanently sticky on the same module instance — which is what users almost always want (re-running an init that threw is rarely safe; side effects may have run).

Workaround

A post-build string-replace patch on the bundled output. Reference: https://github.com/teamvirtus/virtus-web/blob/main/scripts/patch-opennext-bundle.js

Happy to send a PR if you'd like.