Some packages genuinely cannot publish their source: a licensed component, an algorithm covered by contract, an internal library whose comments reference systems that must not be named. The usual reaction is to disable maps entirely, which produces stack traces like this for every consumer:

TypeError: Cannot read properties of undefined (reading 'transform')
    at t (/app/node_modules/@scope/engine/dist/index.mjs:1:24193)
    at n (/app/node_modules/@scope/engine/dist/index.mjs:1:24886)

There is a middle configuration that costs nothing in disclosure and restores most of the diagnostic value: ship maps that carry positions but not text.


Root Cause

A source map has two independent parts. The mappings field encodes position translations — generated line and column to original file, line and column. The optional sourcesContent array embeds the text of each original file. Disabling maps throws away both; stripping sourcesContent throws away only the second.

The distinction matters because the two carry very different information. Positions reveal file names and shape — that a function came from src/transform/pipeline.ts at line 88. Text reveals the implementation. For a closed-source package, the first is nearly always acceptable and the second is the thing under discussion.


Minimal Reproduction

{
  "compilerOptions": {
    "sourceMap": true,
    "inlineSources": true,
    "outDir": "./dist"
  }
}
node -e '
  const m = JSON.parse(require("fs").readFileSync("dist/index.mjs.map", "utf8"));
  console.log("sources:", m.sources.length);
  console.log("embedded text:", m.sourcesContent ? m.sourcesContent.length + " file(s)" : "none");
  console.log("first 60 chars:", (m.sourcesContent?.[0] ?? "").slice(0, 60));
'
sources: 12
embedded text: 12 file(s)
first 60 chars: // INTERNAL: do not expose the pricing weights to customers

Every line of source, comments included, is inside a file published to the registry.


Three Configurations and What Each Reveals

What each configuration gives a consumer Embedded sources give full text and complete debugging. Maps without sourcesContent give file names, lines and columns but no text. No maps at all give only positions inside the generated bundle. Disclosure and diagnostic value are not the same axis embedded sources consumer sees: everything traces: file, line, text breakpoints: full right for open source wrong under a licence that forbids it positions only consumer sees: file names traces: file, line, column breakpoints: stop, no text the closed-source default most value per unit of disclosure no maps consumer sees: nothing useful traces: bundle offsets breakpoints: generated code protects nothing extra the implementation is already in the tarball

Step-by-Step Fix

1. Emit maps without embedded sources

   "compilerOptions": {
     "sourceMap": true,
-    "inlineSources": true,
+    "inlineSources": false,
     "outDir": "./dist"
   }
   {
-    "files": ["dist", "src"]
+    "files": ["dist"]
   }

The maps now reference source paths that are not present in the tarball. That is deliberate here, and it is the one situation where a dangling reference is the intended outcome.

2. Strip any residual content from generated maps

Bundlers and minifiers can re-add sourcesContent even when the compiler was told not to. A post-build pass makes the guarantee unconditional:

// scripts/strip-sources.ts
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";

let stripped = 0;
for (const file of readdirSync("dist").filter((f) => f.endsWith(".map"))) {
  const path = join("dist", file);
  const map = JSON.parse(readFileSync(path, "utf8")) as { sourcesContent?: string[] };
  if (map.sourcesContent) {
    delete map.sourcesContent;
    writeFileSync(path, JSON.stringify(map));
    stripped += 1;
  }
}
console.log(`stripped embedded sources from ${stripped} map(s)`);
{
  "scripts": {
    "build": "tsup && tsx scripts/strip-sources.ts",
    "prepack": "npm run build"
  }
}

3. Decide whether file paths themselves are sensitive

Positions include file names, and a directory structure can occasionally disclose more than intended — internal project names, customer names in a path, an unreleased feature’s module. Where that matters, sourceRoot and a build-time path rewrite normalise them:

const map = JSON.parse(readFileSync(path, "utf8")) as { sources: string[] };
map.sources = map.sources.map((s) => s.replace(/^.*\/src\//, "src/"));

4. Consider hosting maps separately when debugging matters more

If your support process needs full fidelity for authenticated customers, the annotation can point at a URL rather than a sibling file:

//# sourceMappingURL=https://maps.example.com/@scope/engine/1.4.0/index.mjs.map

That configuration gives complete debugging to whoever can authenticate and nothing to anyone else. Weigh it carefully: you are now operating a service whose availability affects other people’s incident response, and an unauthenticated misconfiguration discloses more than shipping the maps would have.

HAZARD PREVENTION

Symptom: A sourcesContent-free build still exposes source text in a consumer’s debugger.

Root cause: A second artefact — the CommonJS output, or a separate browser bundle — was produced by a different tool whose configuration was not updated, and its map still embeds sources.

Fix: Run the stripping script over the whole output directory rather than one file, and assert in CI that no map in the tarball contains a sourcesContent key.


Verification

# no map in the packed tarball embeds source text
d=$(mktemp -d); npm pack --silent | xargs -I{} mv {} "$d/"
tar -xzf "$d"/*.tgz -C "$d"
if grep -rl '"sourcesContent"' "$d/package" 2>/dev/null | head -1; then
  echo "FAIL: embedded sources present"; else echo "OK: no embedded sources"; fi

# positions still resolve to original file names
node -e '
  const m = JSON.parse(require("fs").readFileSync("dist/index.mjs.map","utf8"));
  console.log("mappings present:", m.mappings.length > 0, "| sources:", m.sources.slice(0,3).join(", "));
'

Expected: OK: no embedded sources, followed by mappings present: true and a list of original file names.


Edge Cases / Gotchas

  • Minified output plus positions-only maps is still hard to read. The map resolves the position; the surrounding generated code remains minified. Shipping unminified output for a Node-only package removes that friction at no disclosure cost.
  • A dangling sourceMappingURL is noisy in some tools. Browsers log a 404 for the missing map when devtools are open; publishing the map file itself (just without content) avoids that while still withholding source text.
  • Licence obligations may cut the other way. Copyleft dependencies bundled into your output can carry source-availability requirements, which no map configuration satisfies or avoids.
  • Comments in source are the actual disclosure risk. Where a review finds nothing confidential in the comments, the case for withholding sources at all is worth re-examining — it is often inherited policy rather than a current requirement.
  • Stack traces still reveal function names. Minification usually renames them, but not always, and an error message written for developers can disclose more than a file path ever would.

Frequently Asked Questions

Is publishing source maps actually a disclosure risk?

For most packages, no. The published JavaScript already contains the implementation, and unminified output is readable without any map. Embedded sources add formatting, comments and original names — a difference of convenience rather than kind, except where the comments themselves are confidential.

What is still useful about a map with no sources?

Position mapping. A trace resolves to the original file, line and column even without text, turning an unreadable minified offset into a coordinate you can match against your own checkout.

Can I host maps somewhere other than the package?

Yes — the annotation accepts an absolute URL, so maps can be served only to authenticated requests. The cost is that offline consumers get nothing and you now operate a service that affects other people’s debugging.

Does stripping sourcesContent break debugger breakpoints?

It removes the ability to display source text. A debugger still stops at the mapped position but shows generated code. Stepping works; reading does not.

Should a closed-source package ship no maps at all?

That is the weakest option: it costs consumers every diagnostic advantage and protects nothing that the published JavaScript does not already reveal.



↑ Back to Source Maps and Debugging Published Packages