Skip to content
bucker

@bucker/bundler-plugin

The build-time half of symbolication. It stamps a deterministic Debug ID into every emitted JavaScript chunk and its source map, uploads the maps, and fails the build if any shipped Debug ID does not resolve on the server.

  • Install: npm install --save-dev @bucker/bundler-plugin
  • Bundlers: Vite and Rollup (the default export), webpack 5 and rspack (/webpack), esbuild (/esbuild), Next.js with Turbopack or webpack (/next), and any other build through the post-build directory pass (/directory). See Other bundlers.
  • Node: 20.19 or later.

Distribution note. The package is built and tested but not yet on npm. Its licence is decided (Apache-2.0); publishing is the remaining human step (sdks/PUBLISHING.md). Until then npm install @bucker/bundler-plugin will not resolve.

Why Debug IDs, and only Debug IDs

Bucker is Debug-ID-only (ECMA-426). There is no release-plus-filename matching, in the plugin or on the server. That fallback is the support-ticket factory Debug IDs were invented to escape, and a wrong resolution is worse than none: it makes the remediation agent patch code that was never running. If a chunk was built without this plugin it sends no debug_meta, and the server has nothing to look the map up by.

What it does

  1. Derives a Debug ID for each emitted chunk from the SHA-256 of its content (a UUIDv8). The same build always produces the same IDs, so re-running CI does not orphan maps already uploaded, and an unchanged chunk keeps its ID across releases.
  2. Injects //# debugId=<uuid> into the chunk (before //# sourceMappingURL=, which must stay last) and the same debugId field into its .map. It also appends a one-line registration to the chunk so the ID reaches the events @bucker/browser and @bucker/node send.
  3. Uploads each map.
  4. Verifies that every shipped ID resolves server-side, and throws if any does not.

Step 4 is the one that matters: a map discovered broken at crash time has already cost an incident.

Usage

// vite.config.ts
import { buckerBundlerPlugin } from '@bucker/bundler-plugin'

export default {
  build: { sourcemap: true }, // required: no map, nothing to upload
  plugins: [
    buckerBundlerPlugin({
      url: 'https://api.bucker.io',
      org: 'acme',
      project: 'checkout',
      token: process.env.BUCKER_TOKEN,
      releaseVersion: process.env.GIT_SHA,
    }),
  ],
}

With no url, org, project or token the injection still runs and the network steps are skipped, so a developer's local build produces exactly the IDs CI will upload.

url is the control-plane API base (https://api.bucker.io), not the ingest host in your DSN. token is a personal access token with project:write, created under Settings → Tokens and held as a CI secret. Never give it a VITE_ or NEXT_PUBLIC_ prefix: the plugin runs at build time and the token must not enter the bundle it is stamping.

webpack and rspack

// webpack.config.js (rspack.config.js is the same; BuckerRspackPlugin is an alias)
const { BuckerWebpackPlugin } = require('@bucker/bundler-plugin/webpack')

module.exports = {
  devtool: 'source-map', // or 'hidden-source-map'
  plugins: [
    new BuckerWebpackPlugin({
      url: 'https://api.bucker.io',
      org: 'acme',
      project: 'checkout',
      token: process.env.BUCKER_TOKEN,
    }),
  ],
}

Chunks are stamped in processAssets just after the stage the source-map plugin emits maps in, so the hash is over the minified code that ships and the content hash webpack computes afterwards sees the stamped bytes. Upload and verify run in afterEmit; a failure fails the compilation. Only the hooks above are used and nothing imports webpack, so the same class serves rspack. It is tested against a structural fake of those hooks; neither bundler is installed in this repository, so smoke-test the first build.

esbuild

import { build } from 'esbuild'
import { buckerEsbuildPlugin } from '@bucker/bundler-plugin/esbuild'

await build({
  entryPoints: ['src/server.ts'],
  bundle: true,
  outdir: 'dist',
  sourcemap: true,
  plugins: [buckerEsbuildPlugin({ url, org, project, token: process.env.BUCKER_TOKEN })],
})

Runs in onEnd, over the files on disk (the plugin turns the metafile on to find them) or the in-memory outputFiles with write: false. An upload or verify failure is returned as an esbuild error, so the build fails the way a syntax error does.

Next.js

// next.config.mjs
import { withBucker } from '@bucker/bundler-plugin/next'

export default withBucker({ reactStrictMode: true })

withBucker does not hook into the bundler, so it works with Turbopack (the default) and next build --webpack alike and never races the build. It chains compiler.runAfterProductionCompile, which Next.js calls once compilation has finished, and there stamps every chunk under .next/ that has a map, uploads the maps, verifies them and fails next build if one does not resolve. It turns on productionBrowserSourceMaps unless your config sets it, and then deletes the browser maps from .next/static after the upload so they are not served; set keepBrowserSourceMaps: true to keep them. Credentials come from BUCKER_API_URL, BUCKER_ORG, BUCKER_PROJECT, BUCKER_TOKEN and BUCKER_RELEASE, so next.config holds no secret. npx create-bucker --source-maps writes this for you.

After the build: any other bundler, tsc, a Node server

import { processBuildDirectory } from '@bucker/bundler-plugin/directory'

await processBuildDirectory('dist', { url, org, project, token: process.env.BUCKER_TOKEN })

Walks the directory, stamps every JavaScript file that has a source map (its own sourceMappingURL, or <file>.map) in place, leaves every other file exactly as it is, then uploads and verifies. Running it twice is a no-op: a file that already carries a Debug ID keeps it.

Without a bundler

import { computeDebugId, uploadArtifacts, verifyArtifacts } from '@bucker/bundler-plugin'

const debugId = computeDebugId(chunkSource)
await uploadArtifacts([{ debugId, name: 'app.js.map', content: mapJson }], client)
const report = await verifyArtifacts([debugId], client)
if (!report.ok) process.exit(1)

Every adapter above ends in the same verify gate, through one shared core (@bucker/bundler-plugin/core).

Options

Option Default Meaning
url, org, project, token none Where and as whom to upload. All four are needed for the network steps
releaseVersion none Recorded with the uploaded artifact
enabled true false disables the plugin entirely
upload true when credentials are present Upload after write
verify follows upload Confirm every ID resolves server-side
onVerifyFailure 'throw' 'warn' downgrades the release gate to a log line
uploadBundles false Also upload the minified chunk. Maps are what resolve
stripSourcesContent false Omit original source text from an uploaded map
include /\.[cm]?js$/ Which emitted files get an ID
uploadTimeoutMs 60000 One artifact upload
verifyTimeoutMs 15000 The verify request
maxRateLimitWaitMs 300000 Total time one upload pass may spend waiting out 429s
onRateLimited none Called with each wait, in ms, before it is slept
fetchImpl, fs, logger, sleep globals Injection points; the test suite uses all four

What is uploaded

Each .map is uploaded whole, sourcesContent included: the original text of every source file it lists. The server reads it back to show the lines around a symbolicated frame; without it a frame resolves to a file and line with no code beside it. The text is retained with the release artifact for as long as the artifact is. Set stripSourcesContent: true if the bundled sources must not leave CI at all (for example because they embed third-party content your licence restricts); frames still resolve to file, line and name.

Rate limits

The upload route accepts 60 requests a minute from one client address, and verify 30. Every chunk's map is one upload, so a code-split app past 60 chunks crosses the limit on every build. The plugin paces itself: a 429 is waited out for as long as Retry-After says (one second if it says nothing) and the same artifact is sent again. It logs once that it is pacing, so a CI step that goes quiet for a minute is not mistaken for a hang. Waiting is bounded by maxRateLimitWaitMs and by six attempts per request; past either, that artifact fails with a message naming the 429, and the build fails like any other upload failure. Verify accepts at most 500 Debug IDs a request, so larger builds verify in batches. Two builds uploading from one CI address share the limit: raise maxRateLimitWaitMs or build them in turn.

Wire format

Uploads are base64 in a JSON body (no multipart):

POST /orgs/:org/projects/:project/artifacts
{ "debugId": "...", "type": "SOURCEMAP", "name": "app-a1b2.js.map", "contentBase64": "..." }

POST /orgs/:org/projects/:project/artifacts/verify
{ "debugIds": ["..."] }

Uploading after the deploy

A map uploaded AFTER events that needed it is not wasted. A new (or replaced) SOURCEMAP upload answers "reprocessRequested": true, and within about a minute the workers re-symbolicate the project's stored events that named that debug id as missing — inside the project's hot retention window, newest first, at most 1,000 events per upload. Only the frames the new map resolves are rewritten (their report entries flip from "No artifact" to "Resolved"); every other frame stays exactly as ingest stored it, and no event moves to another issue. A byte-identical re-upload answers false: it resolves nothing new.

Idle expiry

A source map that no event has used in 90 days is deleted, row and bytes, by the workers' artifact-expiry pass. "Used" means an incoming (or re-symbolicated) event's frames were resolved against it; a verify call from CI does not count, so a pipeline that verifies every build does not keep every build's maps forever. A map never used counts from its upload. Native artifacts (dSYM, ProGuard, Dart symbols) are not swept: nothing resolves them, so nothing could keep them. The window is deployment-wide: ARTIFACT_EXPIRY_IDLE_DAYS sets it (0 keeps every map) and ARTIFACT_EXPIRY_ENABLED=false turns the pass off. An expired map is gone: an event arriving later for that build reads "No artifact", and re-uploading the map repairs it like any late upload.

Troubleshooting

Symptom Cause Fix
Build passes, frames stay minified The maps were never produced build.sourcemap: true. No map means nothing to upload, and the plugin cannot invent one
Build fails: "Debug IDs did not resolve" Upload failed or the project is wrong Check org/project slugs and that the token has project:write; the failure message lists the unresolved IDs
Events carry no debug_meta The chunk was built without the plugin, or a later step rewrote it Make sure nothing minifies or rewrites the chunk after the plugin runs; the ID is derived from the content it saw
429 messages in CI More than 60 maps a minute Expected on large builds; the plugin paces itself. Raise maxRateLimitWaitMs if it gives up
Token appears in the bundle It was given a public env prefix Rename it; rotate the token under Settings → Tokens
Nothing is uploaded locally No credentials, by design Set url, org, project and token in CI only

See also if nothing arrives for the event path (as opposed to the symbolication path this page covers).

Other bundlers

Every bundler above has an adapter in this package. A build that already uses Sentry's tooling can also keep it: Bucker reads the _sentryDebugIds registry that Sentry's bundler plugins write, and serves the chunk-upload and artifact-bundle endpoints @sentry/webpack-plugin, @sentry/vite-plugin and sentry-cli call, so pointing SENTRY_URL (or the plugin's url) at the Bucker api with a Bucker org CI token is the whole change. Switch from Sentry §3 has the variables and the rules.