@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 thennpm install @bucker/bundler-pluginwill 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
- 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.
- Injects
//# debugId=<uuid>into the chunk (before//# sourceMappingURL=, which must stay last) and the samedebugIdfield into its.map. It also appends a one-line registration to the chunk so the ID reaches the events@bucker/browserand@bucker/nodesend. - Uploads each map.
- 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.