Symbolicating Stack Traces in a Node CLI Script

A self-contained Node.js script that accepts a raw minified stack trace and a .map file path, then prints each frame resolved to its original source location, covering Local Symbolication with Mozilla source-map Library offline tooling and fitting into the broader Source Map Generation & Stack Trace Debugging pipeline without requiring any SaaS account or network access.

Node CLI symbolication pipeline Diagram showing a raw minified stack entering the CLI via argument or stdin, passing through frame parsing, SourceMapConsumer lookup, and finally printing annotated resolved frames to stdout. Input --stack <file> --map <file> Parse Frames V8 regex extraction line + column ints Consumer Lookup originalPositionFor { line, col } → orig stdout annotated frames exit 0 / exit 1 Node CLI Symbolication Pipeline no network · reads .map from disk · exits with structured output consumer.destroy() frees WASM heap

Symptom / Trigger

You paste a minified production stack trace into a terminal and need the original source locations immediately — without uploading anything to a hosted service and without opening a browser. The raw trace looks like this:

TypeError: Cannot read properties of undefined (reading 'id')
    at e (https://cdn.example.com/assets/app.3c9fa2.js:1:48312)
    at t.render (https://cdn.example.com/assets/app.3c9fa2.js:1:9047)
    at HTMLButtonElement.<anonymous> (https://cdn.example.com/assets/app.3c9fa2.js:1:102841)
    at HTMLButtonElement.dispatch (https://cdn.example.com/assets/app.3c9fa2.js:1:5291)

Every frame sits on line 1 with a large column offset. The function names e, t.render are minifier-generated identifiers. You have the matching .map file locally at dist/app.3c9fa2.js.map.

Running the finished CLI produces:

✓  src/components/ProductCard.tsx:42:12  (getUserId)
✓  src/components/ProductCard.tsx:81:4   (ProductCard.render)
✗  [no mapping] col 102841
✓  src/utils/events.ts:17:8             (dispatchClick)

Root Cause Explanation

The broken pattern is opening the .map file manually and calling JSON.parse, then trying to access consumer.mappings directly — the raw VLQ string is not human-readable and the library does not expose a direct accessor:

// WRONG — parsedMap.mappings is an encoded VLQ string, not a usable structure
const parsedMap = JSON.parse(fs.readFileSync('app.js.map', 'utf-8'));
const original = parsedMap.mappings[48312]; // undefined — this API does not exist

The correct path is through SourceMapConsumer, which decodes VLQ lazily and exposes originalPositionFor as the stable query interface.

Step-by-Step Fix

1. Create the project and install the dependency

mkdir symbolicate-cli && cd symbolicate-cli
npm init -y
npm install [email protected]

2. Write the CLI entry point

Create symbolicate.js with argument parsing, WASM initialisation, and a main() function:

#!/usr/bin/env node
'use strict';

const sourceMap = require('source-map');
const fs = require('fs/promises');
const path = require('path');

// Register the WASM binary path — required in Node.js without a bundler
sourceMap.SourceMapConsumer.initialize({
  'lib/mappings.wasm': path.join(
    __dirname,
    'node_modules/source-map/lib/mappings.wasm'
  )
});

async function main() {
  const args = parseArgs(process.argv.slice(2));

  if (!args.map || !args.stack) {
    console.error('Usage: node symbolicate.js --map <path.map> --stack <path.txt>');
    console.error('       node symbolicate.js --map <path.map> --stack -   (read stack from stdin)');
    process.exit(1);
  }

  const rawStack = args.stack === '-'
    ? await readStdin()                            // allow piping: cat err.txt | node symbolicate.js
    : await fs.readFile(args.stack, 'utf-8');

  const rawMap = await fs.readFile(args.map, 'utf-8');
  // Must await the constructor — v0.7+ is async
  const consumer = await new sourceMap.SourceMapConsumer(rawMap);

  const frames = parseStack(rawStack);

  for (const frame of frames) {
    const pos = consumer.originalPositionFor({
      line: frame.line,     // 1-based, direct from Error.stack
      column: frame.column  // 0-based, direct from Error.stack
    });

    if (pos.source === null) {
      console.log(`✗  [no mapping] col ${frame.column}`);
    } else {
      const name = pos.name ? `(${pos.name})` : '';
      console.log(`${pos.source}:${pos.line}:${pos.column}  ${name}`);
    }
  }

  consumer.destroy(); // release WASM heap — always call after processing
}

main().catch(err => {
  console.error(err.message);
  process.exit(1);
});

3. Implement the frame parser and argument helpers

Add these helper functions to the same file:

// Parses V8/Node.js Error.stack format: "  at fn (url:LINE:COL)"
// Also handles anonymous frames: "  at url:LINE:COL"
function parseStack(stackStr) {
  const frameRe = /at (?:.+ \()?([^\s(]+):(\d+):(\d+)\)?/g;
  const frames = [];
  let match;
  while ((match = frameRe.exec(stackStr)) !== null) {
    frames.push({
      url: match[1],
      line: parseInt(match[2], 10),    // 1-based
      column: parseInt(match[3], 10)   // 0-based
    });
  }
  return frames;
}

function parseArgs(argv) {
  const result = {};
  for (let i = 0; i < argv.length; i++) {
    if (argv[i] === '--map') result.map = argv[i + 1];
    if (argv[i] === '--stack') result.stack = argv[i + 1];
  }
  return result;
}

async function readStdin() {
  const chunks = [];
  for await (const chunk of process.stdin) chunks.push(chunk);
  return Buffer.concat(chunks).toString('utf-8');
}

4. Make it executable and test it

chmod +x symbolicate.js

# Test with explicit file paths
node symbolicate.js --map dist/app.3c9fa2.js.map --stack captured-error.txt

# Test with stdin pipe
cat captured-error.txt | node symbolicate.js --map dist/app.3c9fa2.js.map --stack -

5. Add JSON output mode for machine consumption

When integrating this CLI into a CI pipeline, structured output is more reliable than parsing terminal strings. Add a --json flag:

// In main(), replace the console.log loop with this conditional block
const results = frames.map(frame => {
  const pos = consumer.originalPositionFor({ line: frame.line, column: frame.column });
  if (pos.source === null) {
    return { resolved: false, minLine: frame.line, minColumn: frame.column };
  }
  return {
    resolved: true,
    source: pos.source,
    line: pos.line,
    column: pos.column,
    name: pos.name ?? null
  };
});

if (args.json) {
  console.log(JSON.stringify(results, null, 2)); // machine-readable output
} else {
  results.forEach(r => {
    if (!r.resolved) {
      console.log(`✗  [no mapping] col ${r.minColumn}`);
    } else {
      console.log(`${r.source}:${r.line}:${r.column}  ${r.name ? `(${r.name})` : ''}`);
    }
  });
}

Run with node symbolicate.js --map dist/app.js.map --stack err.txt --json to get an array of resolved frame objects that can be piped to jq or stored in a CI artifact.

Verification

Trigger a known error in a test build and verify round-trip accuracy:

// verify.js — run with: node verify.js
const { execSync } = require('child_process');
const assert = require('assert');

const output = execSync(
  'node symbolicate.js --map test/fixtures/app.js.map --stack test/fixtures/err.txt --json'
).toString();

const frames = JSON.parse(output);

// Assert the first frame resolved correctly
assert.strictEqual(frames[0].resolved, true);
assert.match(frames[0].source, /ProductCard/); // known source file
assert.ok(frames[0].line > 0, 'line must be positive');

console.log('Verification passed — symbolication is working correctly');

Commit test/fixtures/app.js.map and test/fixtures/err.txt as deterministic test assets alongside the script. Run node verify.js in CI to catch regressions when the source-map package version changes.

Handling Firefox (SpiderMonkey) Stack Format

V8 and SpiderMonkey encode stack frames differently. The parseV8Frame function above handles Chrome, Node.js, Edge, and Deno. Firefox stacks look like this:

TypeError: Cannot read properties of undefined (reading 'id')
render@https://cdn.example.com/assets/app.3c9fa2.js:1:9047
dispatch@https://cdn.example.com/assets/app.3c9fa2.js:1:5291
@https://cdn.example.com/assets/app.3c9fa2.js:1:102841

SpiderMonkey uses functionName@url:line:col with no parentheses. An anonymous frame has @url:line:col with nothing before the @. Add a SpiderMonkey parser alongside the V8 one and combine results:

// Parses SpiderMonkey (Firefox) stack frames: "fnName@url:LINE:COL"
function parseSpiderMonkeyFrame(frameStr) {
  // Match: optionalName@url:line:col
  const match = frameStr.match(/^([^@]*)@(.+):(\d+):(\d+)$/);
  if (!match) return null;
  return {
    name: match[1] || null,     // empty string for anonymous frames
    url: match[2],
    line: parseInt(match[3], 10),
    column: parseInt(match[4], 10)
  };
}

// Auto-detect format from the first frame line
function parseStack(stackStr) {
  const lines = stackStr.split('\n').slice(1).map(l => l.trim()).filter(Boolean);
  const isSpiderMonkey = lines.length > 0 && lines[0].includes('@');

  return lines
    .map(line => isSpiderMonkey ? parseSpiderMonkeyFrame(line) : parseV8Frame(line))
    .filter(Boolean);
}

The detection heuristic checks for @ in the first non-empty frame line, which is reliable in practice because V8 frames always start with at and never contain a bare @. If you need to handle stacks from multiple engines in the same batch, parse each line with both regexes and take whichever matches.

Annotating Output with Source Snippets

When sourcesContent is present in the .map file, you can print the surrounding source lines alongside the resolved frame — a significant usability improvement when reading long terminal output:

// After resolving pos = consumer.originalPositionFor(...)
if (pos.source !== null) {
  const content = consumer.sourceContentFor(pos.source, /* returnNullOnMissing */ true);
  if (content !== null) {
    const srcLines = content.split('\n');
    const start = Math.max(0, pos.line - 2);   // 2 lines of context before
    const end = Math.min(srcLines.length, pos.line + 1); // 1 line after
    console.log(`  ${pos.source}:${pos.line}:${pos.column}`);
    srcLines.slice(start, end).forEach((l, i) => {
      const lineNum = start + i + 1;
      const marker = lineNum === pos.line ? '→' : ' '; // mark the exact line
      console.log(`  ${marker} ${String(lineNum).padStart(4)}  ${l}`);
    });
    console.log();
  } else {
    console.log(`${pos.source}:${pos.line}:${pos.column}`);
  }
}

This prints:

  src/components/ProductCard.tsx:42:12
      40    const item = props.items[index];
      41    if (!item) return null;
  →   42    return <span>{item.id}</span>;
      43  };

The snippet pinpoints the throw site within the component without requiring the engineer to open the file in an editor. The sourceContentFor call is O(1) — it simply returns the pre-loaded string from the sourcesContent array.

Edge Cases & Gotchas

  • Frames from bundled node_modules land on node_modules/... source paths. This is correct behaviour — the .map file records the true source. If you want to filter those frames out, check pos.source.includes('node_modules') and skip them.
  • Webpack runtime chunks produce null source for several frames. The bootstrap code at the start of a Webpack bundle is generated, not mapped. Expect { source: null } for frames whose column sits inside that runtime region.
  • Column zero returns the first mapped token, not necessarily the correct one. Some error reporters normalise columns to 0 when they cannot determine the exact offset. A column of 0 resolves to the first mapping on that line, which may be a module boundary rather than the actual throw site.
  • Source paths in the map are relative to the build root. The pos.source value will be something like webpack://./src/Button.tsx, not an absolute filesystem path. Trim the scheme prefix with a regex if you need a path fs.readFile can open.

FAQ

What happens if the .map file was generated from a different build than the bundle?

originalPositionFor returns positions, but they will be wrong — pointing into incorrect lines of unrelated source files. There is no runtime error; the mismatch is silent. Always verify that the bundle and .map file share the same content hash. Webpack’s [contenthash] in the filename makes this explicit.

Can this script handle multi-bundle stacks where frames come from different .js files?

The basic version above uses a single consumer for a single .map. To handle multi-bundle stacks, parse the url field from each frame, derive the corresponding map path (e.g., replace .js with .js.map), construct a consumer per unique URL, and cache them. The caching strategy is covered in detail in Caching Parsed Source Maps for Faster Symbolication.

Does the CLI work with Vite or esbuild source maps?

Yes. Both tools produce standard Source Map v3 JSON. The only prerequisite is that the .map file includes "version": 3. Vite’s default sourcemap: true produces external maps that this script reads directly. esbuild’s --sourcemap=external flag does the same.