satyam
home

ErrSource

Self-hosted error tracker for Vite apps — a build-time plugin, a 3 KB browser SDK, and a server that turns minified production stack traces back into the exact file, line, and code snippet. Slack alerts on new issues.

Open Source
Dev Tool
TypeScript · Vite · Hono · SQLite · AWS S3
ErrSource cover

The problem

When an error happens in a production frontend, the browser gives you this:

TypeError: Cannot read properties of undefined (reading 'map')
    at d (https://app.example.com/assets/index-DkA9f3.js:1:45231)

Minified bundle, function named d, line 1 (the whole bundle is one line), column 45231. Completely useless. The source maps that could decode it exist — but you can't deploy them publicly, because they contain your entire original source code.

ErrSource is my answer, built from scratch to understand how tools like Sentry actually work: a Vite plugin that uploads source maps privately at build time, a 3 KB browser SDK that captures errors, and a symbolication server that turns that garbage above back into:

at boom (src/main.ts:7:23)

     5 | function boom(input: { items?: string[] }) {
     6 |   // throws at runtime when items is undefined
 →   7 |   return input.items!.map((s) => s.toUpperCase());
     8 | }

…delivered to Slack, seconds after a user hits it.

How it works

Build time. The Vite plugin forces sourcemap: "hidden" (maps generated, but no sourceMappingURL comment for browsers to follow), collects every .map file in the writeBundle hook, and uploads them to the server keyed by a release id — the git commit hash. It then deletes the maps from dist/ so a static deploy can never leak them, and injects window.__ERRSOURCE_RELEASE__ into the HTML.

Runtime. The SDK registers window.addEventListener("error") and "unhandledrejection" — the two hooks that catch what React error boundaries never will (event handlers, async code, timers). Reports carry the same release id, so the server always symbolicates against the exact maps of the build that crashed. Transport uses fetch with keepalive: true so reports survive the page being closed mid-error, and deliberately omits the content-type header so the request qualifies as a CORS "simple request" — no preflight, works against any server.

Symbolication. The server parses the stack (Chrome and Firefox/Safari formats differ), loads the release's map, and runs a position lookup through the source map's VLQ-encoded mappings table: generated (line 1, col 45231) → original (src/main.ts, line 7, col 23). The original code snippet comes from sourcesContent, which ships inside the map itself — the server never needs repo access. Errors are grouped into issues by a fingerprint (normalized message + top original frame locations), stored in SQLite, and new issues trigger a Slack webhook.

Decisions I'd defend

  • Two security models for two endpoints. Map uploads hold source code, so they sit behind a Bearer key. The error-ingest endpoint is called from visitors' browsers, so it can't hold a secret — its defenses are shape validation, size limits, and rate limiting. Worst-case abuse is noise; the maps can never leak because only symbolicated frames ever leave the server.
  • Client-side dedup and rate limiting in the SDK. An error inside a requestAnimationFrame loop fires 60 times a second. Without the SDK dropping duplicates, every user with a render loop bug becomes a DDoS on my own server.
  • The SDK must never throw. Every public function is wrapped in try/catch. An error reporter that crashes the app it monitors is a self-own.

What broke

The first end-to-end smoke test found a data-loss bug that lint, types, and build all passed over: when an upload failed, the plugin still deleted the map from dist/ — destroying the only copy in existence and making that release permanently un-debuggable. The fix: cleanup now only removes maps the server confirmed. Lesson burned in: green checkmarks are not correctness; run the real thing before calling it done.

Stack

TypeScript everywhere. The plugin and SDK build with tsup (dual ESM/CJS, ESM-only respectively). The server is Hono on Node 24 — native TypeScript execution and built-in node:sqlite, zero build step. Maps store to S3 (or local disk in dev), source-map-js does the lookups.