Fixing Rollup Source Map Paths for Monorepo Builds
In a monorepo, Rollup runs from inside one package directory while your error tracker thinks in terms of the repository root, and that single mismatch is enough to make an otherwise perfect source map resolve to nothing. This how-to fixes the sources array and sourceRoot so a map built in packages/web still symbolicates against the paths your tracker stores. It sits under Rollup & esbuild Source Map Configuration and the broader Source Map Generation & Stack Trace Debugging reference; if you are on esbuild instead of Rollup, the companion how-tos Generating Source Maps with esbuild in Production and Combining esbuild with Terser Without Breaking Maps cover the equivalent path handling.
Symptom / Trigger
The build succeeds, the upload command reports that it accepted the map, yet every production frame from the package stays minified in your dashboard. When you open the emitted map and print its sources, the paths climb out of the package with a stack of ../ segments, or they carry the absolute checkout directory from CI:
TypeError: cannot read property 'total' of undefined
at r (index.4f9a1c.js:1:8842)
at index.4f9a1c.js:1:8610
$ node -e "console.log(require('./packages/web/dist/index.4f9a1c.js.map').sources)"
[
'../../packages/web/src/cart.ts', // climbs above the package root
'../../../shared/src/money.ts', // reaches a sibling workspace package
'/home/runner/work/shop/shop/packages/web/src/app.ts' // absolute CI leak
]
Your tracker was configured to match paths like app:///packages/web/src/cart.ts, so none of these three shapes line up. The map is present and internally valid; it simply describes the file tree from the wrong anchor point. That distinction matters when you triage the problem, because most of the usual source map advice assumes the map is missing or the sourceMappingURL comment leaked, and neither is true here. The mappings decode, the columns are honest, and a local SourceMapConsumer run resolves every position perfectly — it only fails once a remote tracker tries to reconcile the encoded path against the path it stored for the release. Nothing in the build output warns you, which is why a monorepo can ship broken symbolication for weeks before anyone connects the empty stack traces to the day the app moved into packages/web.
Root Cause Explanation
Rollup computes each sources entry as a path relative to the map file’s own location, and it derives the original file locations from wherever the process was launched. In a single-package repo those two agree, so the paths come out clean. In a monorepo you almost always invoke the build from a nested directory — pnpm --filter web build, an Nx or Turborepo task, or a plain cd packages/web && rollup -c — so process.cwd() is the package, the output dir is packages/web/dist, and a shared dependency imported from ../shared lives above that package. Rollup faithfully encodes the shortest relative walk between the map and each source, which is exactly the ../../ and ../../../ mess above.
// rollup.config.js — the broken default in a monorepo
export default {
input: 'src/index.ts',
output: {
dir: 'dist', // resolved against packages/web, not the repo root
format: 'es',
sourcemap: 'hidden', // map is emitted, but sources climb out of the package
},
};
Because the tracker matches by exact string, and because a local build, a CI container, and a teammate’s machine each launch from a slightly different absolute base, three byte-identical source files can produce three different sources arrays. The fix is to stop relying on Rollup’s implicit anchor and pin every path to one stable reference: the workspace root.
Two extra complications are specific to monorepos and worth naming before the fix. First, package managers that use symlinked workspaces — pnpm most aggressively, but also npm and yarn workspaces — resolve a shared dependency through a link, so the physical file Rollup reads may live under node_modules/.pnpm/… rather than at shared/src/…. Rollup encodes whichever path it actually opened, and that path is neither stable across installs nor meaningful to a human reading a stack trace. Second, task runners such as Turborepo, Nx, and pnpm’s --filter deliberately change the working directory per package to parallelize builds, so the very same rollup.config.js yields different relative paths depending on which package triggered it. Any fix that hard-codes a number of ../ segments will break the moment the directory depth changes, which is why the transform below computes the relationship dynamically instead.
Step-by-Step Fix
The strategy is the same regardless of your workspace tool: find the repository root once, rewrite every sources entry into a stable root-relative string, and set a sourceRoot that mirrors what the tracker expects.
- Resolve the workspace root once so every path can be made relative to it.
// rollup.config.js — locate the repo root deterministically
import { execSync } from 'node:child_process';
// git's top level is stable no matter which package you build from.
const REPO_ROOT = execSync('git rev-parse --show-toplevel').toString().trim();
- Rewrite each source into a repo-relative path with
sourcemapPathTransform.
// rollup.config.js — normalize sources against the repo root
import path from 'node:path';
const sourcemapPathTransform = (relativePath, mapPath) => {
// relativePath is relative to the map; resolve it to an absolute file first.
const abs = path.resolve(path.dirname(mapPath), relativePath);
// Then re-express it relative to the repo root, with forward slashes.
return path.relative(REPO_ROOT, abs).split(path.sep).join('/');
};
- Set a
sourceRootprefix that matches your tracker’s path rewrite rule.
// rollup.config.js — one prefix, written verbatim into the map
export default {
input: 'src/index.ts',
output: {
dir: 'dist',
format: 'es',
sourcemap: 'hidden',
sourceRoot: 'app:///', // must equal the prefix configured on the tracker
sourcemapPathTransform, // from step 2
},
};
- Collapse symlinked workspace dependencies so shared packages map to real paths.
// rollup.config.js — de-symlink pnpm/npm workspace deps before rewriting
import fs from 'node:fs';
const deSymlink = (p) => {
// pnpm links shared packages through node_modules/.pnpm; follow to the real file.
try { return fs.realpathSync(p); } catch { return p; }
};
- Wire the de-symlink step into the transform so shared code resolves too.
// rollup.config.js — final transform used in output.sourcemapPathTransform
const finalTransform = (relativePath, mapPath) => {
const real = deSymlink(path.resolve(path.dirname(mapPath), relativePath));
return path.relative(REPO_ROOT, real).split(path.sep).join('/'); // e.g. packages/web/src/cart.ts
};
With these in place, a source that Rollup would have written as ../../../shared/src/money.ts becomes shared/src/money.ts, and the sourceRoot turns it into app:///shared/src/money.ts — the exact string the tracker stored for that release.
Verification
Prove the paths are now root-relative before you upload, by reading the emitted map and asserting every entry starts with a known workspace segment. This is a fast, dependency-free check you can drop straight into CI.
// scripts/check-monorepo-paths.mjs — fail the build on any stray path
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
const dist = path.resolve('dist');
const OK = /^(packages|apps|shared)\//; // your workspace top-level folders
let bad = 0;
for (const f of readdirSync(dist).filter((n) => n.endsWith('.map'))) {
const { sources } = JSON.parse(readFileSync(path.join(dist, f), 'utf8'));
for (const s of sources) {
if (s.startsWith('..') || path.isAbsolute(s) || !OK.test(s)) {
console.error(`FAIL ${f}: ${s}`); // still climbing or absolute
bad++;
}
}
}
process.exit(bad ? 1 : 0); // green means every source is workspace-relative
A clean run prints nothing and exits 0; every sources entry now reads like packages/web/src/cart.ts, and prefixed with sourceRoot it becomes the app:///packages/web/src/cart.ts your tracker matches. Run this check on the map artifacts, not on the freshly transformed values in memory, because a plugin ordering mistake or a later post-processing step can still rewrite the file after Rollup hands it off; validating the bytes on disk is the only guarantee that what you upload is what you inspected. The three-step contract below — anchor, rewrite, prefix — is what turns a climbing path into a resolvable one, and keeping the check in CI means a future change to the workspace layout fails loudly at build time instead of silently blanking your stack traces in production.
Edge Cases & Gotchas
- Windows separators.
path.relativereturnspackages\web\src\cart.tson Windows; always.split(path.sep).join('/')because source maps are URLs and consumers never interpret backslashes as separators. git rev-parseunavailable in CI. Shallow clones or non-git build images make the root lookup throw. Fall back to an explicitprocess.env.WORKSPACE_ROOTor walk up fromprocess.cwd()until you find thepnpm-workspace.yamlor rootpackage.json.- Hoisted vs isolated
node_modules. With pnpm’s isolated store a shared package resolves through.pnpm/…; withoutrealpathSyncthose paths land innode_modulesand never match your source folders. npm/yarn hoisting can hide the same package under the app’s ownnode_modules. - Prefix drift. If you change
sourceRootyou must change the tracker’s rewrite rule in the same release, or every frame will be off by exactly that prefix — a silent, total symbolication failure that looks like “map not found”.
FAQ
Why do my sources have ../../ when a single-package build is clean?
Because Rollup writes each sources entry as the shortest relative walk from the map file to the original source, and in a monorepo you launch the build from a nested package while shared code lives above it. The relative walk therefore climbs out of the package with ../ segments. Anchoring every path to the repository root with sourcemapPathTransform removes the climb and yields a stable string that is identical on every machine.
Do I set sourceRoot to the filesystem path of my monorepo?
No. sourceRoot should be the logical prefix your error tracker prepends when it stores and matches sources — commonly app:/// or a URL origin — not a real directory on the build machine. Its only job is to agree, character for character, with the tracker’s path convention. The actual normalization of the file paths is done by the transform in step 2; sourceRoot just namespaces the already-clean result.
How do I handle a shared package that several apps bundle?
Let the transform resolve the real file with realpathSync and express it relative to the repo root, so shared/src/money.ts comes out the same no matter which app pulled it in. As long as every app uses the identical sourceRoot and repo-relative scheme, the tracker stores one canonical path for the shared module and resolves frames from any app that bundled it.
Related
- Rollup & esbuild Source Map Configuration — parent guide to sourcemap modes, hidden maps, and path handling for both bundlers.
- Generating Source Maps with esbuild in Production — the esbuild
outbaseandsourceRootequivalent of this Rollup path fix. - Combining esbuild with Terser Without Breaking Maps — keeping the map chain intact when a second minifier runs after the bundler.
- Source Map Generation & Stack Trace Debugging — the top-level reference for source map formats and symbolication workflows.