A consumer’s error report contains this, and nothing else useful:

TypeError: Cannot read properties of undefined (reading 'headers')
    at Zt (/app/node_modules/@scope/my-library/dist/index.mjs:1:18452)
    at async /app/src/server.ts:22:20

One frame in their code, one meaningless offset in yours. The map may exist, may even be in the tarball, and the trace still does not resolve — because the chain from generated position to original source has four links and any one of them can be broken independently.


Root Cause

Resolving a position requires all of: a //# sourceMappingURL= annotation at the end of the generated file, a map file at the path that annotation names, a mappings field inside it that reaches back past every transformation applied, and a runtime willing to apply it.

A build pipeline that compiles, bundles, then minifies applies three transformations, and the map that ships must compose all three. Each tool needs to be told to emit maps and to consume the incoming one; a tool that does neither produces output whose map — if any — describes only its own input, which is a file nobody outside the build has.


Minimal Reproduction

// tsup.config.ts — maps not requested
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  minify: true,
});
npx tsup && tail -c 120 dist/index.mjs; echo; ls dist/
...}return t};export{Zt as createClient};
index.mjs

No annotation, no .map file, and every consumer trace reports offsets into a single minified line.


Four links, any one of which breaks the trace A resolvable trace requires the sourceMappingURL annotation to survive minification, the map file to be included in the tarball, the mappings to be composed through the compiler and bundler, and the runtime to apply source maps. All four must hold; each fails silently on its own 1. annotation last line of the file stripped by minifiers that drop comments check: tail -1 2. map shipped inside the tarball governed by files and .npmignore check: npm pack --dry-run 3. composed reaches back to .ts not to an intermediate build artefact check: map.sources 4. runtime Node 20+: default on Node 18: needs a flag not yours to control check: document it

Step-by-Step Fix

1. Ask the build for maps, in every stage

  export default defineConfig({
    entry: ["src/index.ts"],
    format: ["esm", "cjs"],
+   sourcemap: true,
-   minify: true,
+   minify: false,          // Node consumers gain nothing from minification
  });
npx tsup && ls dist/ && tail -1 dist/index.mjs
index.cjs  index.cjs.map  index.mjs  index.mjs.map
//# sourceMappingURL=index.mjs.map

2. Confirm the mappings reach your TypeScript

An emitted map whose sources name an intermediate directory means composition failed somewhere in the pipeline.

node -e '
  const m = JSON.parse(require("fs").readFileSync("dist/index.mjs.map","utf8"));
  console.log("sources:", m.sources.slice(0, 4).join(", "));
  console.log("all .ts?", m.sources.every(s => s.endsWith(".ts")));
'
sources: ../src/index.ts, ../src/client.ts, ../src/internal/fmt.ts
all .ts? true

A false here — sources naming .js files under a temporary build directory — means one stage consumed its input without its map. Enable maps in the earlier stage and re-check.

3. Ship the maps

   {
-    "files": ["dist/**/*.mjs", "dist/**/*.cjs", "dist/**/*.d.ts"]
+    "files": ["dist", "src"]
   }

Explicit glob lists are the usual culprit: they were written before maps existed and silently exclude them.

4. Verify from a consumer’s position, not yours

d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
cd "$d" && npm init -y >/dev/null && npm install ./*.tgz --silent

node --input-type=module -e "
  const { createClient } = await import('@scope/my-library');
  await createClient({ baseUrl: 'https://example.com' }).send();  // throws
" 2>&1 | head -4
file:///tmp/.../node_modules/@scope/my-library/src/client.ts:88
    return response.headers.get("etag");
                    ^
TypeError: Cannot read properties of undefined (reading 'headers')

HAZARD PREVENTION

Symptom: The map exists, the annotation is present, and traces still report bundle offsets — but only in production.

Root cause: The consumer’s own bundler processed your package and produced its own output without composing your map, so the position now refers to their bundle. Your map is intact and no longer in the chain.

Fix: This is outside your control, and the useful response is documentation: note in your README that consumers bundling your package should enable source maps in their own build. What you can control is not compounding the problem by minifying your own output first.

5. Document the runtime requirement for older Node

### Debugging

Stack traces from this package resolve to TypeScript source automatically on
Node 20+. On Node 18, run with source map support enabled:

    NODE_OPTIONS=--enable-source-maps node app.js

Verification

# all four links, in one script
node -e '
  const fs = require("fs"), path = require("path");
  const js = fs.readFileSync("dist/index.mjs", "utf8");
  const ann = js.match(/\/\/# sourceMappingURL=(\S+)\s*$/m);
  if (!ann) throw new Error("1. annotation missing");
  const mapPath = path.resolve("dist", ann[1]);
  if (!fs.existsSync(mapPath)) throw new Error("2. map file missing");
  const map = JSON.parse(fs.readFileSync(mapPath, "utf8"));
  if (!map.sources.every(s => s.endsWith(".ts"))) throw new Error("3. chain not composed");
  console.log("links 1-3 OK; link 4 is the consumer runtime");
'

# and the packed tarball actually contains the map
npm pack --dry-run | grep -c "\.map"

Expected: links 1-3 OK; link 4 is the consumer runtime, and a non-zero count of .map entries in the pack listing.


Edge Cases / Gotchas

  • inlineSourceMap and separate .map files are mutually exclusive. Turning on the inline form embeds a base64 map in the JavaScript, which makes the file much larger and confuses tools expecting a sibling file.
  • Windows path separators can appear in sources. A map generated on Windows may record backslashes that some resolvers fail to match; normalising paths in a post-build step avoids a platform-specific failure.
  • A CommonJS output can behave differently from the ESM one. Both need maps and both need checking; the CommonJS file is the one that silently regresses because far fewer people read it.
  • Error frames created before your code runs are not yours to fix. A trace whose first frame is inside Node internals or another dependency will not improve regardless of your configuration.
  • Bundling a dependency drags its map problems into yours. An inlined dependency without maps produces unresolvable frames inside your file, which reads as your package being unmapped.

Frequently Asked Questions

Why do traces resolve locally but not for consumers?

Locally the runtime finds your source tree through relative paths that exist. In a consumer’s install those paths must resolve inside node_modules, so a map whose sources point outside the package directory resolves to nothing. Testing from a packed tarball surfaces this.

Does Node.js need a flag to use source maps?

Node 20 and later apply maps to uncaught exceptions and Error.stack by default; Node 18 needs --enable-source-maps. A library cannot set that for consumers, so document it for the versions you support.

What breaks the map chain when a bundler runs after the compiler?

The bundler must consume the incoming map and compose it with its own transformation. Without that, the resulting map describes only the bundler’s input files and stops there.

Can minification be kept without losing traces?

Yes, if the minifier consumes the incoming map and preserves the annotation. Most strip comments by default, which severs the chain even when the map file is written correctly.

Should a Node-targeted library be minified at all?

Usually not. It saves bytes a server-side consumer does not pay for, costs readability, and introduces exactly this fragility.



↑ Back to Source Maps and Debugging Published Packages