@@ -38,7 +38,7 @@ import { $ } 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,6 +263,10 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
215263
216264 console . log ( ` Step 2: Compiling ${ packageName } ...` ) ;
217265
266+ // Save the esbuild map before Bun.build overwrites it (sourcemap: "linked"
267+ // writes Bun's own map to bin.js.map). We restore it after for upload.
268+ const savedEsbuildMap = await Bun . file ( SOURCEMAP_FILE ) . arrayBuffer ( ) ;
269+
218270 const result = await Bun . build ( {
219271 entrypoints : [ BUNDLE_JS ] ,
220272 compile : {
@@ -226,9 +278,14 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
226278 | "bun-windows-x64" ,
227279 outfile,
228280 } ,
229- // Already minified in Step 1 — skip re-minification to avoid
230- // double-minifying identifiers and producing different output.
231- minify : false ,
281+ // "linked" embeds a sourcemap in the binary. At runtime, Bun's engine
282+ // auto-resolves Error.stack positions through this embedded map back to
283+ // the esbuild output positions. The esbuild sourcemap (uploaded to
284+ // Sentry) then maps those to original TypeScript sources.
285+ sourcemap : "linked" ,
286+ // Minify whitespace and syntax but NOT identifiers to avoid Bun's
287+ // identifier renaming collision bug (oven-sh/bun#14585).
288+ minify : { whitespace : true , syntax : true , identifiers : false } ,
232289 } ) ;
233290
234291 if ( ! result . success ) {
@@ -241,6 +298,9 @@ async function compileTarget(target: BuildTarget): Promise<boolean> {
241298
242299 console . log ( ` -> ${ outfile } ` ) ;
243300
301+ // Restore the esbuild sourcemap (Bun.build overwrote it with its own map).
302+ await Bun . write ( SOURCEMAP_FILE , savedEsbuildMap ) ;
303+
244304 // Hole-punch: zero unused ICU data entries so they compress to nearly nothing.
245305 // Always runs so the smoke test exercises the same binary as the release.
246306 const hpStats = processBinary ( outfile ) ;
@@ -344,8 +404,10 @@ async function build(): Promise<void> {
344404 process . exit ( 1 ) ;
345405 }
346406
347- // Inject debug IDs and upload sourcemap to Sentry before compiling (non-fatal on failure)
348- await injectAndUploadSourcemap ( ) ;
407+ // Inject debug IDs into the JS and sourcemap (non-fatal on failure).
408+ // Upload happens AFTER compilation because Bun.build (with sourcemap: "linked")
409+ // overwrites bin.js.map. We restore it from the saved copy before uploading.
410+ await injectDebugIds ( ) ;
349411
350412 console . log ( "" ) ;
351413
@@ -362,6 +424,9 @@ async function build(): Promise<void> {
362424 }
363425 }
364426
427+ // Step 3: Upload the composed sourcemap to Sentry (after compilation)
428+ await uploadSourcemapToSentry ( ) ;
429+
365430 // Clean up intermediate bundle (only the binaries are artifacts)
366431 await $ `rm -f ${ BUNDLE_JS } ${ SOURCEMAP_FILE } ` ;
367432
0 commit comments