# `@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](#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`](../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](https://tc39.es/ecma426/)). 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

```ts
// 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

```js
// 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

```ts
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

```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

```ts
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

```ts
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 `429`s |
| `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](troubleshooting.md) 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](switch-from-sentry.md#3-source-maps) has the variables and the rules.
