3030 * bin.js.map (sourcemap, uploaded to Sentry then deleted)
3131 */
3232
33- import { mkdirSync } from "node:fs" ;
33+ import { mkdirSync , renameSync } from "node:fs" ;
3434import { promisify } from "node:util" ;
3535import { gzip } from "node:zlib" ;
3636import { processBinary } from "binpunch" ;
3737import { $ } from "bun" ;
3838import { build as esbuild } from "esbuild" ;
3939import pkg from "../package.json" ;
4040import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js" ;
41- import { injectDebugId } from "./debug-id.js" ;
41+ import { injectDebugId , PLACEHOLDER_DEBUG_ID } from "./debug-id.js" ;
4242
4343const gzipAsync = promisify ( gzip ) ;
4444
@@ -104,13 +104,15 @@ async function bundleJs(): Promise<boolean> {
104104 target : "esnext" ,
105105 format : "esm" ,
106106 external : [ "bun:*" ] ,
107- sourcemap : "external" ,
107+ sourcemap : "linked" ,
108+ // Minify syntax and whitespace but NOT identifiers. Bun.build
108109 minify : true ,
109110 metafile : true ,
110111 define : {
111112 SENTRY_CLI_VERSION : JSON . stringify ( VERSION ) ,
112113 SENTRY_CLIENT_ID_BUILD : JSON . stringify ( SENTRY_CLIENT_ID ) ,
113114 "process.env.NODE_ENV" : JSON . stringify ( "production" ) ,
115+ __SENTRY_DEBUG_ID__ : JSON . stringify ( PLACEHOLDER_DEBUG_ID ) ,
114116 } ,
115117 } ) ;
116118
@@ -147,20 +149,57 @@ async function bundleJs(): Promise<boolean> {
147149 * injection always runs (even without auth token) so local builds get
148150 * debug IDs for development/testing.
149151 */
150- async function injectAndUploadSourcemap ( ) : Promise < void > {
151- // Always inject debug IDs (even without auth token) so local builds
152- // get debug IDs for development/testing purposes.
152+ /** Module-level debug ID set by {@link injectDebugIds} for use in {@link uploadSourcemapToSentry}. */
153+ let currentDebugId : string | undefined ;
154+
155+ /**
156+ * Inject debug IDs into the JS and sourcemap. Runs before compilation.
157+ * The upload happens separately after compilation (see {@link uploadSourcemapToSentry}).
158+ */
159+ async function injectDebugIds ( ) : Promise < void > {
160+ // skipSnippet: true — the IIFE snippet breaks ESM (placed before import
161+ // declarations). The debug ID is instead registered in constants.ts via
162+ // a build-time __SENTRY_DEBUG_ID__ constant.
153163 console . log ( " Injecting debug IDs..." ) ;
154- let debugId : string ;
155164 try {
156- ( { debugId } = await injectDebugId ( BUNDLE_JS , SOURCEMAP_FILE ) ) ;
165+ const { debugId } = await injectDebugId ( BUNDLE_JS , SOURCEMAP_FILE , {
166+ skipSnippet : true ,
167+ } ) ;
168+ currentDebugId = debugId ;
157169 console . log ( ` -> Debug ID: ${ debugId } ` ) ;
158170 } catch ( error ) {
159171 const msg = error instanceof Error ? error . message : String ( error ) ;
160172 console . warn ( ` Warning: Debug ID injection failed: ${ msg } ` ) ;
161173 return ;
162174 }
163175
176+ // Replace the placeholder UUID with the real debug ID in the JS bundle.
177+ // Both are 36-char UUIDs so sourcemap character positions stay valid.
178+ try {
179+ const jsContent = await Bun . file ( BUNDLE_JS ) . text ( ) ;
180+ await Bun . write (
181+ BUNDLE_JS ,
182+ jsContent . split ( PLACEHOLDER_DEBUG_ID ) . join ( currentDebugId )
183+ ) ;
184+ } catch ( error ) {
185+ const msg = error instanceof Error ? error . message : String ( error ) ;
186+ console . warn (
187+ ` Warning: Debug ID placeholder replacement failed: ${ msg } `
188+ ) ;
189+ }
190+ }
191+
192+ /**
193+ * Upload the (composed) sourcemap to Sentry. Runs after compilation
194+ * because {@link compileTarget} composes the Bun sourcemap with the
195+ * esbuild sourcemap first.
196+ */
197+ async function uploadSourcemapToSentry ( ) : Promise < void > {
198+ const debugId = currentDebugId ;
199+ if ( ! debugId ) {
200+ return ;
201+ }
202+
164203 if ( ! process . env . SENTRY_AUTH_TOKEN ) {
165204 console . log ( " No SENTRY_AUTH_TOKEN, skipping sourcemap upload" ) ;
166205 return ;
@@ -169,7 +208,13 @@ async function injectAndUploadSourcemap(): Promise<void> {
169208 console . log ( ` Uploading sourcemap to Sentry (release: ${ VERSION } )...` ) ;
170209
171210 try {
172- const urlPrefix = "~/$bunfs/root/" ;
211+ // With sourcemap: "linked", Bun's runtime auto-resolves Error.stack
212+ // paths via the embedded map, producing relative paths like
213+ // "dist-bin/bin.js". The beforeSend hook normalizes these to absolute
214+ // ("/dist-bin/bin.js") so the symbolicator's candidate URL generator
215+ // produces "~/dist-bin/bin.js" — matching our upload URL.
216+ const dir = BUNDLE_JS . slice ( 0 , BUNDLE_JS . lastIndexOf ( "/" ) + 1 ) ;
217+ const urlPrefix = `~/${ dir } ` ;
173218 const jsBasename = BUNDLE_JS . split ( "/" ) . pop ( ) ?? "bin.js" ;
174219 const mapBasename = SOURCEMAP_FILE . split ( "/" ) . pop ( ) ?? "bin.js.map" ;
175220
@@ -204,8 +249,11 @@ async function injectAndUploadSourcemap(): Promise<void> {
204249/**
205250 * Step 2: Compile the pre-bundled JS into a native binary for a target.
206251 *
207- * Uses the JS file produced by {@link bundleJs} — no sourcemap is embedded
208- * in the binary (it's uploaded to Sentry separately).
252+ * Uses the JS file produced by {@link bundleJs}. The esbuild sourcemap
253+ * (JS → original TS) is uploaded to Sentry as-is — no composition needed
254+ * because `sourcemap: "linked"` causes Bun to embed a sourcemap in the
255+ * binary that its runtime uses to auto-resolve `Error.stack` positions
256+ * back to the esbuild output's coordinate space.
209257 */
210258async function compileTarget ( target : BuildTarget ) : Promise < boolean > {
211259 const packageName = getPackageName ( target ) ;
@@ -215,31 +263,48 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
215263
216264 console . log ( ` Step 2: Compiling ${ packageName } ...` ) ;
217265
218- const result = await Bun . build ( {
219- entrypoints : [ BUNDLE_JS ] ,
220- compile : {
221- target : getBunTarget ( target ) as
222- | "bun-darwin-arm64"
223- | "bun-darwin-x64"
224- | "bun-linux-x64"
225- | "bun-linux-arm64"
226- | "bun-windows-x64" ,
227- outfile,
228- } ,
229- // Already minified in Step 1 — skip re-minification to avoid
230- // double-minifying identifiers and producing different output.
231- minify : false ,
232- } ) ;
233-
234- if ( ! result . success ) {
235- console . error ( ` Failed to compile ${ packageName } :` ) ;
236- for ( const log of result . logs ) {
237- console . error ( ` ${ log } ` ) ;
266+ // Rename the esbuild map out of the way before Bun.build overwrites it
267+ // (sourcemap: "linked" writes Bun's own map to bin.js.map).
268+ // Restored in the finally block so subsequent targets and the upload
269+ // always find the esbuild map, even if compilation fails.
270+ const esbuildMapBackup = `${ SOURCEMAP_FILE } .esbuild` ;
271+ renameSync ( SOURCEMAP_FILE , esbuildMapBackup ) ;
272+
273+ try {
274+ const result = await Bun . build ( {
275+ entrypoints : [ BUNDLE_JS ] ,
276+ compile : {
277+ target : getBunTarget ( target ) as
278+ | "bun-darwin-arm64"
279+ | "bun-darwin-x64"
280+ | "bun-linux-x64"
281+ | "bun-linux-arm64"
282+ | "bun-windows-x64" ,
283+ outfile,
284+ } ,
285+ // "linked" embeds a sourcemap in the binary. At runtime, Bun's engine
286+ // auto-resolves Error.stack positions through this embedded map back to
287+ // the esbuild output positions. The esbuild sourcemap (uploaded to
288+ // Sentry) then maps those to original TypeScript sources.
289+ sourcemap : "linked" ,
290+ // Minify whitespace and syntax but NOT identifiers to avoid Bun's
291+ // identifier renaming collision bug (oven-sh/bun#14585).
292+ minify : { whitespace : true , syntax : true , identifiers : false } ,
293+ } ) ;
294+
295+ if ( ! result . success ) {
296+ console . error ( ` Failed to compile ${ packageName } :` ) ;
297+ for ( const log of result . logs ) {
298+ console . error ( ` ${ log } ` ) ;
299+ }
300+ return false ;
238301 }
239- return false ;
240- }
241302
242- console . log ( ` -> ${ outfile } ` ) ;
303+ console . log ( ` -> ${ outfile } ` ) ;
304+ } finally {
305+ // Restore the esbuild sourcemap (Bun.build wrote its own map).
306+ renameSync ( esbuildMapBackup , SOURCEMAP_FILE ) ;
307+ }
243308
244309 // Hole-punch: zero unused ICU data entries so they compress to nearly nothing.
245310 // Always runs so the smoke test exercises the same binary as the release.
@@ -344,8 +409,10 @@ async function build(): Promise<void> {
344409 process . exit ( 1 ) ;
345410 }
346411
347- // Inject debug IDs and upload sourcemap to Sentry before compiling (non-fatal on failure)
348- await injectAndUploadSourcemap ( ) ;
412+ // Inject debug IDs into the JS and sourcemap (non-fatal on failure).
413+ // Upload happens AFTER compilation because Bun.build (with sourcemap: "linked")
414+ // overwrites bin.js.map. We restore it from the saved copy before uploading.
415+ await injectDebugIds ( ) ;
349416
350417 console . log ( "" ) ;
351418
@@ -362,6 +429,9 @@ async function build(): Promise<void> {
362429 }
363430 }
364431
432+ // Step 3: Upload the composed sourcemap to Sentry (after compilation)
433+ await uploadSourcemapToSentry ( ) ;
434+
365435 // Clean up intermediate bundle (only the binaries are artifacts)
366436 await $ `rm -f ${ BUNDLE_JS } ${ SOURCEMAP_FILE } ` ;
367437
0 commit comments