From db01619ca4d24e4b92fe6427166116505d97e522 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Tue, 15 Sep 2026 13:23:45 +0200 Subject: [PATCH 1/3] feat(sourcemap): add wasm sourcemap support for inject and upload - Process wasm + map pairs alongside JS in inject and upload by default - Derive map debug IDs from the module build_id instead of content hash - Upload wasm and map as a matched artifact pair through existing API - Improve empty-directory errors when wasm modules lack companion maps --- packages/cli/src/commands/sourcemap/inject.ts | 44 ++- packages/cli/src/commands/sourcemap/upload.ts | 146 +++++++--- packages/cli/src/lib/sourcemap/debug-id.ts | 43 +++ packages/cli/src/lib/sourcemap/inject.ts | 22 +- packages/cli/src/lib/sourcemap/wasm.ts | 263 ++++++++++++++++++ .../test/commands/sourcemap/upload.test.ts | 183 ++++++++++++ packages/cli/test/lib/sourcemap/wasm.test.ts | 239 ++++++++++++++++ 7 files changed, 902 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/lib/sourcemap/wasm.ts create mode 100644 packages/cli/test/lib/sourcemap/wasm.test.ts diff --git a/packages/cli/src/commands/sourcemap/inject.ts b/packages/cli/src/commands/sourcemap/inject.ts index aca157ed5e..bfd8137966 100644 --- a/packages/cli/src/commands/sourcemap/inject.ts +++ b/packages/cli/src/commands/sourcemap/inject.ts @@ -22,14 +22,27 @@ import { type InjectResult, injectDirectory, } from "../../lib/sourcemap/inject.js"; +import { + addWasmDiscoveryCounts, + discoverWasmPairs, + syncWasmPairs, + type WasmSyncResult, +} from "../../lib/sourcemap/wasm.js"; /** Result type for the inject command output. */ type InjectCommandResult = { modified: number; skipped: number; files: InjectResult[]; + /** Wasm module + sourcemap pairs reconciled onto the module's build id. */ + wasm: WasmSyncResult[]; }; +/** Whether a wasm pair was changed on disk. */ +function wasmChanged(result: WasmSyncResult): boolean { + return result.mapWritten || result.moduleStamped; +} + /** Format human-readable output for inject results. */ function formatInjectResult(data: InjectCommandResult): string { const lines: string[] = []; @@ -50,6 +63,16 @@ function formatInjectResult(data: InjectCommandResult): string { } } + if (data.wasm.length > 0) { + lines.push(""); + for (const pair of data.wasm) { + const status = wasmChanged(pair) ? "✓" : "–"; + // A dry run over an unstamped module cannot know the id it would mint. + const debugId = pair.debugId ?? "(pending)"; + lines.push(`${status} ${pair.wasmPath} → ${colorTag("muted", debugId)}`); + } + } + return renderMarkdown(lines.join("\n")); } @@ -60,6 +83,9 @@ export const injectCommand = buildCommand({ "Scans a directory for .js/.mjs/.cjs files and their companion .map files, " + "then injects Sentry debug IDs for reliable sourcemap resolution.\n\n" + "The injection is idempotent — files that already have debug IDs are skipped.\n\n" + + "WebAssembly pairs (app.wasm + app.wasm.map) are handled too: the map " + + "takes the module's build_id as its debug ID, and a module without one " + + "is stamped.\n\n" + "Exits with an error if zero JS + sourcemap pairs are discovered " + "(typical cause: bundler not emitting .map files). Pass " + "--allow-empty to suppress this check for directories that may " + @@ -153,8 +179,12 @@ export const injectCommand = buildCommand({ ); const pairs = await discoverFilePairs(dir, extSet, ignoreMatcher); - if (pairs.length === 0 && !flags["allow-empty"]) { - const diag = await diagnoseEmptyDiscovery(dir, { extensions }); + const wasmPairs = await discoverWasmPairs(dir, ignoreMatcher); + if (pairs.length === 0 && wasmPairs.length === 0 && !flags["allow-empty"]) { + const diag = await addWasmDiscoveryCounts( + dir, + await diagnoseEmptyDiscovery(dir, { extensions }) + ); throw buildEmptyDiscoveryError(dir, diag); } @@ -163,14 +193,20 @@ export const injectCommand = buildCommand({ ignoreMatcher, dryRun: flags["dry-run"], }); + const wasmResults = await syncWasmPairs(wasmPairs, { + dryRun: flags["dry-run"], + }); - const modified = results.filter((r) => r.injected).length; - const skipped = results.length - modified; + const modified = + results.filter((r) => r.injected).length + + wasmResults.filter(wasmChanged).length; + const skipped = results.length + wasmResults.length - modified; yield new CommandOutput({ modified, skipped, files: results, + wasm: wasmResults, }); if (modified > 0) { diff --git a/packages/cli/src/commands/sourcemap/upload.ts b/packages/cli/src/commands/sourcemap/upload.ts index e36d0f557b..42f4425f0e 100644 --- a/packages/cli/src/commands/sourcemap/upload.ts +++ b/packages/cli/src/commands/sourcemap/upload.ts @@ -30,6 +30,12 @@ import { type InjectResult, injectDirectory, } from "../../lib/sourcemap/inject.js"; +import { + addWasmDiscoveryCounts, + discoverWasmPairs, + syncWasmPairs, + type WasmSyncResult, +} from "../../lib/sourcemap/wasm.js"; /** Result type for the upload command. */ type UploadCommandResult = { @@ -118,12 +124,44 @@ type ArtifactContext = { noRewrite: boolean; }; -/** Compute a JS file's URL-space relative path (post-strip, forward slashes). */ -function jsRelativePath(jsPath: string, ctx: ArtifactContext): string { - const rel = relative(ctx.resolvedDir, jsPath).replaceAll("\\", "/"); +/** Compute a file's URL-space relative path (post-strip, forward slashes). */ +function urlRelativePath(path: string, ctx: ArtifactContext): string { + const rel = relative(ctx.resolvedDir, path).replaceAll("\\", "/"); return ctx.pathPrefixToStrip ? stripPrefix(rel, ctx.pathPrefixToStrip) : rel; } +/** + * Build the artifact pair for a wasm module and its sourcemap. + * + * Structurally the same as the external-map JS case — the module takes the + * `minified_source` slot — but the debug ID comes off the module's `build_id` + * rather than from hashing content, and the module is uploaded as the bytes on + * disk with no injected snippet. + */ +function buildWasmArtifactPair( + result: WasmSyncResult, + ctx: ArtifactContext +): ArtifactFile[] { + const wasmRelative = urlRelativePath(result.wasmPath, ctx); + const mapRelative = urlRelativePath(result.mapPath, ctx); + const debugIdField = result.debugId ? { debugId: result.debugId } : {}; + return [ + { + path: result.wasmPath, + ...debugIdField, + type: "minified_source", + url: `${ctx.urlPrefix}${wasmRelative}`, + sourcemapFilename: posixRelative(posixDirname(wasmRelative), mapRelative), + }, + { + path: result.mapPath, + ...debugIdField, + type: "source_map", + url: `${ctx.urlPrefix}${mapRelative}`, + }, + ]; +} + /** * Build the `minified_source` + `source_map` artifact pair for a discovered * file, dispatching on whether the sourcemap is inline or an external file. @@ -133,7 +171,7 @@ function buildArtifactPair( ctx: ArtifactContext ): ArtifactFile[] { const { jsPath, map, debugId } = result; - const jsRelative = jsRelativePath(jsPath, ctx); + const jsRelative = urlRelativePath(jsPath, ctx); const debugIdField = debugId ? { debugId } : {}; if (map.kind === "inline") { @@ -202,6 +240,47 @@ function buildArtifactPair( ]; } +/** + * Resolve the directory prefix to strip from every uploaded URL. + * + * `--strip-prefix` is taken verbatim, normalized to end at a directory + * boundary; `--strip-common-prefix` derives one from the discovered paths. + * Wasm pairs contribute both of their paths — a wasm-only upload should strip + * its own common directory just as a JS one does. + */ +function resolvePathPrefixToStrip( + discovered: { + resolvedDir: string; + results: InjectResult[]; + wasmResults: WasmSyncResult[]; + }, + flags: { "strip-prefix"?: string; "strip-common-prefix"?: boolean } +): string { + if (!flags["strip-common-prefix"]) { + // Normalize --strip-prefix to end with "/" so it strips at directory + // boundaries. Without this, "build" would strip from "build/app.js" + // leaving "/app.js" instead of "app.js". + const explicit = flags["strip-prefix"] ?? ""; + if (explicit && !explicit.endsWith("/")) { + return `${explicit}/`; + } + return explicit; + } + const { resolvedDir, results, wasmResults } = discovered; + const toRelative = (path: string) => + relative(resolvedDir, path).replaceAll("\\", "/"); + // Only the JS path participates for inline maps (no standalone .map file). + const allRelative = results.flatMap((r) => + r.mapPath + ? [toRelative(r.jsPath), toRelative(r.mapPath)] + : [toRelative(r.jsPath)] + ); + for (const wasm of wasmResults) { + allRelative.push(toRelative(wasm.wasmPath), toRelative(wasm.mapPath)); + } + return computeCommonPrefix(allRelative); +} + export const uploadCommand = buildCommand({ docs: { brief: "Upload sourcemaps to Sentry", @@ -209,6 +288,8 @@ export const uploadCommand = buildCommand({ "Upload JavaScript sourcemaps and source files to Sentry using " + "debug-ID-based matching.\n\n" + "Automatically injects debug IDs into any files that don't already have them.\n" + + "WebAssembly pairs (app.wasm + app.wasm.map) upload alongside, keyed by " + + "the module's build_id.\n" + "Org/project are auto-detected from DSN, env vars, or config defaults.\n\n" + "Exits with an error if zero JS + sourcemap pairs are discovered " + "(typical cause: bundler not emitting .map files). Pass " + @@ -341,10 +422,14 @@ export const uploadCommand = buildCommand({ ); const pairs = await discoverFilePairs(dir, extSet, ignoreMatcher); + const wasmPairs = await discoverWasmPairs(dir, ignoreMatcher); - if (pairs.length === 0) { + if (pairs.length === 0 && wasmPairs.length === 0) { if (!flags["allow-empty"]) { - const diag = await diagnoseEmptyDiscovery(dir, { extensions }); + const diag = await addWasmDiscoveryCounts( + dir, + await diagnoseEmptyDiscovery(dir, { extensions }) + ); throw buildEmptyDiscoveryError(dir, diag); } // --allow-empty: nothing to upload, so don't require Sentry @@ -391,37 +476,32 @@ export const uploadCommand = buildCommand({ ignoreMatcher, }); + // `--no-rewrite` runs the wasm sync read-only: nothing is stamped and no + // map is rewritten, but a `build_id` already on disk still identifies the + // pair, so it rides along on the manifest entries. + const wasmResults = await syncWasmPairs(wasmPairs, { + dryRun: flags["no-rewrite"], + }); + const urlPrefix = flags["url-prefix"] ?? "~/"; // Build artifact file list with paths relative to the upload directory const resolvedDir = resolve(dir); - // Normalize --strip-prefix to end with "/" so it strips at directory - // boundaries. Without this, "build" would strip from "build/app.js" - // leaving "/app.js" instead of "app.js". - let pathPrefixToStrip = flags["strip-prefix"] ?? ""; - if (pathPrefixToStrip && !pathPrefixToStrip.endsWith("/")) { - pathPrefixToStrip = `${pathPrefixToStrip}/`; - } - if (flags["strip-common-prefix"]) { - // Only the JS path participates for inline maps (no standalone .map file). - const allRelative = results.flatMap((r) => { - const rels = [relative(resolvedDir, r.jsPath).replaceAll("\\", "/")]; - if (r.mapPath) { - rels.push(relative(resolvedDir, r.mapPath).replaceAll("\\", "/")); - } - return rels; - }); - pathPrefixToStrip = computeCommonPrefix(allRelative); - } - - const artifactFiles: ArtifactFile[] = results.flatMap((result) => - buildArtifactPair(result, { - resolvedDir, - urlPrefix, - pathPrefixToStrip, - noRewrite: flags["no-rewrite"] ?? false, - }) - ); + const artifactCtx: ArtifactContext = { + resolvedDir, + urlPrefix, + pathPrefixToStrip: resolvePathPrefixToStrip( + { resolvedDir, results, wasmResults }, + flags + ), + noRewrite: flags["no-rewrite"] ?? false, + }; + const artifactFiles: ArtifactFile[] = [ + ...results.flatMap((result) => buildArtifactPair(result, artifactCtx)), + ...wasmResults.flatMap((result) => + buildWasmArtifactPair(result, artifactCtx) + ), + ]; await uploadSourcemaps({ org, diff --git a/packages/cli/src/lib/sourcemap/debug-id.ts b/packages/cli/src/lib/sourcemap/debug-id.ts index 8358830bb1..09ac8dc8b6 100644 --- a/packages/cli/src/lib/sourcemap/debug-id.ts +++ b/packages/cli/src/lib/sourcemap/debug-id.ts @@ -59,6 +59,49 @@ export function readSourcemapDebugId(map: unknown): string | undefined { return; } +/** Outcome of stamping a debug ID onto a standalone sourcemap. */ +export type SourcemapStampResult = { + /** Whether the map was rewritten on disk. */ + written: boolean; + /** The debug ID the map carried before, when it carried a different one. */ + replaced?: string; +}; + +/** + * Set a debug ID on a sourcemap file, leaving everything else alone. + * + * Unlike {@link injectDebugId} this touches no host file, so it suits maps + * whose companion artifact is not JavaScript — a wasm module carries its own + * id in a custom section and must never be rewritten as text. Nothing is + * offset either: `mappings` only shifts when injection prepends a runtime + * snippet line to a JS bundle. + * + * Idempotent: a map already carrying `debugId` is left byte-identical. + * + * @param mapPath - Path to the `.map` file + * @param debugId - The debug ID to write + * @param options.dryRun - Report what would change without writing + * @returns Whether the map was written, and the id it replaced + */ +export async function setSourcemapDebugId( + mapPath: string, + debugId: string, + options: { dryRun?: boolean } = {} +): Promise { + const map = JSON.parse(await readFile(mapPath, "utf-8")) as SourcemapJson; + const existing = readSourcemapDebugId(map); + if (existing === debugId) { + return { written: false }; + } + if (options.dryRun) { + return { written: false, replaced: existing }; + } + map.debug_id = debugId; + map.debugId = debugId; + await writeFile(mapPath, JSON.stringify(map)); + return { written: true, replaced: existing }; +} + /** * Generate a deterministic debug ID (UUID v4 format) from content. * diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 0c51b38593..9409612899 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -519,7 +519,7 @@ async function findCompanionMap( * dropping large JS files would skip debug-ID injection on the * exact bundles users care about most. */ -const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; +export const SOURCEMAP_SKIP_DIRS: readonly string[] = [NODE_MODULES_DIRNAME]; /** * Build an `ignore` matcher from user-provided patterns and/or an @@ -664,6 +664,13 @@ export async function assertDirectoryReadable(dir: string): Promise { export type DiscoveryDiagnostic = { jsFiles: number; mapFiles: number; + /** + * `.wasm` modules found, excluding DWARF companions. Filled in by + * `addWasmDiscoveryCounts`; absent when only JS was scanned. + */ + wasmFiles?: number; + /** `.wasm.map` files found. */ + wasmMaps?: number; }; /** @@ -841,6 +848,19 @@ export function buildEmptyDiscoveryError( diag: DiscoveryDiagnostic ): ValidationError { const { jsFiles, mapFiles } = diag; + const wasmFiles = diag.wasmFiles ?? 0; + const wasmMaps = diag.wasmMaps ?? 0; + // A wasm-only build directory: the JS advice would send the user to a + // bundler setting that has nothing to do with their toolchain. + if (jsFiles === 0 && mapFiles === 0 && wasmFiles > 0 && wasmMaps === 0) { + return new ValidationError( + `Found ${wasmFiles} .wasm file(s) in '${dir}' but no companion ` + + ".wasm.map files. Your build is not emitting wasm sourcemaps: " + + "compile with `-gsource-map` (Emscripten). Pass --allow-empty " + + "to suppress.", + "directory" + ); + } if (jsFiles === 0 && mapFiles === 0) { return new ValidationError( `Directory '${dir}' contains no JS or sourcemap files. ` + diff --git a/packages/cli/src/lib/sourcemap/wasm.ts b/packages/cli/src/lib/sourcemap/wasm.ts new file mode 100644 index 0000000000..3bc4f432f2 --- /dev/null +++ b/packages/cli/src/lib/sourcemap/wasm.ts @@ -0,0 +1,263 @@ +/** + * Debug IDs for WebAssembly modules and their sourcemaps. + * + * Emscripten's `-gsource-map` emits `app.wasm` next to `app.wasm.map`, a plain + * Source Map v3 file. Sentry identifies a wasm module by the `build_id` custom + * section, which events report as `debug_meta.images[].debug_id`, and matches + * uploaded maps by the `debug_id` field inside the map JSON. This module puts + * the same id on both sides so the two artifacts join up. + * + * The module's `build_id` is the source of truth: it is what the running + * module reports, and unlike the JS path there is nothing to derive from + * content. A map that disagrees is corrected, not adopted. + * + * The wasm path deliberately shares nothing with JS injection beyond + * discovery settings. A module is binary — it gets no `//# debugId=` comment, + * no runtime snippet, and its map needs no `mappings` offset. + */ + +import { readFile, stat } from "node:fs/promises"; +import { relative, resolve as resolvePath } from "node:path"; +import type ignore from "ignore"; +import { logger } from "../logger.js"; +import { walkFiles } from "../scan/index.js"; +import { parseSections } from "../wasm/binary.js"; +import { + buildIdFromSections, + debugIdFromBuildId, + ensureWasmBuildId, + formatBuildId, +} from "../wasm/build-id.js"; +import { isDebugCompanionPath } from "../wasm/prepare.js"; +import { setSourcemapDebugId } from "./debug-id.js"; +import { type DiscoveryDiagnostic, SOURCEMAP_SKIP_DIRS } from "./inject.js"; + +const log = logger.withTag("sourcemap.wasm"); + +/** + * Extensions scanned when looking for wasm modules. Never user-configurable: + * `--ext` selects JavaScript flavours, and a wasm module is always `.wasm`. + */ +const WASM_EXTENSIONS: ReadonlySet = new Set([".wasm"]); + +/** A WebAssembly module and the sourcemap sitting next to it. */ +export type WasmPair = { + /** Absolute path to the `.wasm` module. */ + wasmPath: string; + /** Absolute path to the companion `.wasm.map`. */ + mapPath: string; +}; + +/** Outcome of reconciling one module with its sourcemap. */ +export type WasmSyncResult = { + /** Absolute path to the `.wasm` module. */ + wasmPath: string; + /** Absolute path to the companion `.wasm.map`. */ + mapPath: string; + /** + * The debug ID both files now carry. Absent only when a dry run declined to + * stamp a module that had no `build_id`, so the id is not yet decided. + */ + debugId?: string; + /** Whether the map was rewritten. */ + mapWritten: boolean; + /** Whether the module was given a `build_id` it did not have. */ + moduleStamped: boolean; +}; + +/** Controls the id a pair is reconciled onto, and whether anything is written. */ +export type WasmSyncOptions = { + /** Report what would change without touching either file. */ + dryRun?: boolean; + /** + * Build id to stamp when the module has none. Defaults to a random UUID. + * A library-level seam for deterministic tests, not a CLI flag. + */ + buildId?: Uint8Array; +}; + +/** + * Give a wasm module and its sourcemap a shared debug ID. + * + * Reads the module's `build_id`, stamping a fresh one when it has none, then + * writes that id onto the map. Idempotent: a map already carrying the id is + * left byte-identical, and a module that already has a `build_id` is never + * rewritten. + * + * @param pair - The module and its companion map + * @param options - Stamping options + * @returns What the pair now carries, and what was written + */ +export async function syncWasmSourcemap( + pair: WasmPair, + options: WasmSyncOptions = {} +): Promise { + const { wasmPath, mapPath } = pair; + + // One parse serves both the read and the stamp — `ensureWasmBuildId` takes + // the sections it should re-encode, so nothing re-reads the module. + const sections = parseSections(await readFile(wasmPath)); + const existing = buildIdFromSections(sections); + const buildId = await ensureWasmBuildId(wasmPath, sections, existing, { + buildId: options.buildId, + dryRun: options.dryRun, + }); + const moduleStamped = !existing && buildId !== null; + + const debugId = buildId + ? debugIdFromBuildId(formatBuildId(buildId)) + : undefined; + if (!debugId) { + // Either a dry run left the module unstamped, or its `build_id` is too + // short to form a UUID. Neither is worth failing the run over — the map + // simply keeps whatever it had. + return { wasmPath, mapPath, mapWritten: false, moduleStamped }; + } + + const stamp = await setSourcemapDebugId(mapPath, debugId, { + dryRun: options.dryRun, + }); + if (stamp.replaced) { + log.debug( + `${mapPath} carried debug ID ${stamp.replaced}; replaced with the module's build_id ${debugId}` + ); + } + + return { + wasmPath, + mapPath, + debugId, + mapWritten: stamp.written, + moduleStamped, + }; +} + +/** + * Reconcile every discovered pair, in order. + * + * Sequential on purpose: each pair is two small file operations, and a + * bounded-concurrency pool would buy nothing on the handful of modules a wasm + * build emits. + */ +export async function syncWasmPairs( + pairs: WasmPair[], + options: WasmSyncOptions = {} +): Promise { + const results: WasmSyncResult[] = []; + for (const pair of pairs) { + results.push(await syncWasmSourcemap(pair, options)); + } + return results; +} + +/** + * Find `.wasm` + `.wasm.map` pairs in a build directory. + * + * Pairing is by filename convention only — the wasm `sourceMappingURL` custom + * section is not consulted. Modules with no map beside them are not pairs and + * are dropped silently; `buildEmptyDiscoveryError` explains the whole-directory + * case. + * + * `*.debug.wasm` files are skipped: those are DWARF companions written by + * `debug-files prepare`, uploaded through the debug-file pipeline instead. + * + * @param dir - Directory to scan + * @param ignoreMatcher - Optional gitignore-style matcher, as for JS discovery + * @returns One entry per module that has a companion map + */ +export async function discoverWasmPairs( + dir: string, + ignoreMatcher?: ReturnType +): Promise { + const absDir = resolvePath(dir); + const pairs: WasmPair[] = []; + for await (const wasmPath of walkWasmModules(absDir, ignoreMatcher)) { + const mapPath = `${wasmPath}.map`; + if (await hasCompanionMap(mapPath)) { + pairs.push({ wasmPath, mapPath }); + } + } + return pairs; +} + +/** + * Extend a JS discovery diagnostic with wasm counts. + * + * Only called on the zero-pairs error path, so a second walk costs nothing + * that matters. `.wasm.map` files are moved out of the JS `mapFiles` tally — + * the JS walk counts every `.map` — so the JS branches of + * `buildEmptyDiscoveryError` keep describing JS alone. + * + * @param dir - Directory to scan + * @param js - The diagnostic from `diagnoseEmptyDiscovery` + * @returns The diagnostic with wasm counts filled in + */ +export async function addWasmDiscoveryCounts( + dir: string, + js: DiscoveryDiagnostic +): Promise { + const absDir = resolvePath(dir); + let wasmFiles = 0; + let wasmMaps = 0; + for await (const wasmPath of walkWasmModules(absDir)) { + wasmFiles += 1; + if (await hasCompanionMap(`${wasmPath}.map`)) { + wasmMaps += 1; + } + } + return { + jsFiles: js.jsFiles, + mapFiles: Math.max(js.mapFiles - wasmMaps, 0), + wasmFiles, + wasmMaps, + }; +} + +/** + * Yield the deployable `.wasm` modules under `absDir`. + * + * Uses the same traversal settings as JS discovery — build outputs are usually + * gitignored, `dist`/`build` must not be pruned, and a wasm module easily + * exceeds any size cap. + */ +async function* walkWasmModules( + absDir: string, + ignoreMatcher?: ReturnType +): AsyncGenerator { + for await (const entry of walkFiles({ + cwd: absDir, + extensions: WASM_EXTENSIONS, + alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, + hidden: false, + respectGitignore: false, + maxFileSize: Number.POSITIVE_INFINITY, + })) { + const wasmPath = entry.absolutePath; + if (isDebugCompanionPath(wasmPath)) { + continue; + } + if (ignoreMatcher) { + const rel = relative(absDir, wasmPath).replaceAll("\\", "/"); + if (ignoreMatcher.ignores(rel)) { + continue; + } + } + yield wasmPath; + } +} + +/** + * Whether a module's companion sourcemap exists. + * + * An absent map is the common case, not a failure: a module without one is + * simply not a pair, and the whole-directory case is reported by + * `buildEmptyDiscoveryError`. + */ +async function hasCompanionMap(mapPath: string): Promise { + try { + return (await stat(mapPath)).isFile(); + } catch (error) { + log.debug(`no companion sourcemap at ${mapPath}`, error); + return false; + } +} diff --git a/packages/cli/test/commands/sourcemap/upload.test.ts b/packages/cli/test/commands/sourcemap/upload.test.ts index 6d7c8a3aa7..23f8ef6639 100644 --- a/packages/cli/test/commands/sourcemap/upload.test.ts +++ b/packages/cli/test/commands/sourcemap/upload.test.ts @@ -13,6 +13,33 @@ import { uploadCommand } from "../../../src/commands/sourcemap/upload.js"; // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as sourcemapsApi from "../../../src/lib/api/sourcemaps.js"; import { ValidationError } from "../../../src/lib/errors.js"; +import { + encodeModule, + makeBuildIdSection, +} from "../../../src/lib/wasm/binary.js"; +import { uuidToBytes } from "../../../src/lib/wasm/build-id.js"; + +/** Deterministic wasm build id, and the debug ID it maps to. */ +const WASM_DEBUG_ID = "00000000-0000-4000-8000-000000000000"; + +/** Write `.wasm` + `.wasm.map` into `dir`. */ +async function writeWasmPair( + dir: string, + stem: string, + buildId?: string +): Promise<{ wasmPath: string; mapPath: string }> { + const sections = buildId + ? [makeBuildIdSection(uuidToBytes(buildId) as Uint8Array)] + : []; + const wasmPath = join(dir, `${stem}.wasm`); + const mapPath = join(dir, `${stem}.wasm.map`); + await writeFile(wasmPath, encodeModule(sections)); + await writeFile( + mapPath, + JSON.stringify({ version: 3, sources: ["a.c"], names: [], mappings: "" }) + ); + return { wasmPath, mapPath }; +} type InjectFuncArgs = { ext?: string; @@ -219,6 +246,71 @@ describe("sourcemap inject command — --allow-empty behavior", () => { await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); }); + test("wasm pair: stamps the map with the module's build_id", async () => { + const { mapPath } = await writeWasmPair(dir, "app", WASM_DEBUG_ID); + const ctx = makeContext(); + + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + + const map = JSON.parse(await readFile(mapPath, "utf-8")); + expect(map.debug_id).toBe(WASM_DEBUG_ID); + expect(map.debugId).toBe(WASM_DEBUG_ID); + }); + + test("wasm pair alongside a JS pair: both are injected", async () => { + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile(join(dir, "app.js.map"), '{"version":3}\n'); + const { mapPath } = await writeWasmPair(dir, "mod", WASM_DEBUG_ID); + const ctx = makeContext(); + + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + + expect(await readFile(join(dir, "app.js"), "utf-8")).toContain( + "//# debugId=" + ); + expect(JSON.parse(await readFile(mapPath, "utf-8")).debug_id).toBe( + WASM_DEBUG_ID + ); + }); + + test("wasm-only directory does not trip the missing-.map guard", async () => { + await writeWasmPair(dir, "app", WASM_DEBUG_ID); + const ctx = makeContext(); + + await expect(func.call(ctx, {}, dir)).resolves.toBeUndefined(); + }); + + test(".wasm files without .wasm.map: error recommends -gsource-map", async () => { + await writeFile( + join(dir, "app.wasm"), + encodeModule([ + makeBuildIdSection(uuidToBytes(WASM_DEBUG_ID) as Uint8Array), + ]) + ); + const ctx = makeContext(); + try { + await func.call(ctx, {}, dir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(ValidationError); + const msg = (err as Error).message; + expect(msg).toContain("1 .wasm file(s)"); + expect(msg).toContain("-gsource-map"); + } + }); + + test("--dry-run over a wasm pair: writes nothing", async () => { + const { wasmPath, mapPath } = await writeWasmPair(dir, "app"); + const wasmBefore = await readFile(wasmPath); + const mapBefore = await readFile(mapPath); + const ctx = makeContext(); + + await func.call(ctx, { "dry-run": true }, dir); + + expect(await readFile(wasmPath)).toEqual(wasmBefore); + expect(await readFile(mapPath)).toEqual(mapBefore); + }); + test("sourceMappingURL: invalid inline base64 is skipped (zero pairs)", async () => { await writeFile( join(dir, "bad-inline.js"), @@ -769,6 +861,97 @@ describe("sourcemap upload command — --allow-empty behavior", () => { } }); + test("wasm pair: uploads module and map under the build_id", async () => { + await writeWasmPair(dir, "app", WASM_DEBUG_ID); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files).toHaveLength(2); + const wasm = files.find((f) => f.type === "minified_source"); + const map = files.find((f) => f.type === "source_map"); + expect(wasm?.url).toBe("~/app.wasm"); + expect(wasm?.sourcemapFilename).toBe("app.wasm.map"); + expect(map?.url).toBe("~/app.wasm.map"); + // Both sides carry the module's build_id as the debug ID. + expect(wasm?.debugId).toBe(WASM_DEBUG_ID); + expect(map?.debugId).toBe(WASM_DEBUG_ID); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("wasm pair alongside a JS pair: four artifact entries", async () => { + await writeFile(join(dir, "app.js"), "console.log(1)\n"); + await writeFile( + join(dir, "app.js.map"), + JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }) + ); + await writeWasmPair(dir, "mod", WASM_DEBUG_ID); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files.map((f) => f.url).sort()).toEqual([ + "~/app.js", + "~/app.js.map", + "~/mod.wasm", + "~/mod.wasm.map", + ]); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("DWARF companions are not uploaded as sourcemap artifacts", async () => { + await writeWasmPair(dir, "app", WASM_DEBUG_ID); + await writeWasmPair(dir, "app.debug", WASM_DEBUG_ID); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, {}, dir); + const urls = uploadSpy.mock.calls[0]?.[0]?.files.map((f) => f.url) ?? []; + expect(urls.some((u) => u.includes("debug.wasm"))).toBe(false); + } finally { + uploadSpy.mockRestore(); + } + }); + + test("--no-rewrite + wasm pair: nothing written, id read off the module", async () => { + const { wasmPath, mapPath } = await writeWasmPair( + dir, + "app", + WASM_DEBUG_ID + ); + const mapBefore = await readFile(mapPath); + const wasmBefore = await readFile(wasmPath); + + const uploadSpy = vi + .spyOn(sourcemapsApi, "uploadSourcemaps") + .mockResolvedValue(undefined); + try { + const ctx = makeContext(); + await func.call(ctx, { "no-rewrite": true }, dir); + expect(await readFile(wasmPath)).toEqual(wasmBefore); + expect(await readFile(mapPath)).toEqual(mapBefore); + const files = uploadSpy.mock.calls[0]?.[0]?.files ?? []; + expect(files.every((f) => f.debugId === WASM_DEBUG_ID)).toBe(true); + } finally { + uploadSpy.mockRestore(); + } + }); + test("pre-existing inline map debug ID: uploaded on both entries", async () => { const pluginId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; const jsPath = join(dir, "inline-plugin.js"); diff --git a/packages/cli/test/lib/sourcemap/wasm.test.ts b/packages/cli/test/lib/sourcemap/wasm.test.ts new file mode 100644 index 0000000000..71b59f3d9e --- /dev/null +++ b/packages/cli/test/lib/sourcemap/wasm.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for pairing WebAssembly modules with their sourcemaps and giving both + * the module's `build_id` as a debug ID. + * + * The single-pair sync is exercised directly — no walker, no upload API — so + * each rule (stamp, adopt, correct, leave alone) is checked in isolation. + */ + +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import ignore from "ignore"; +import { beforeEach, describe, expect, test } from "vitest"; +import { + addWasmDiscoveryCounts, + discoverWasmPairs, + syncWasmSourcemap, +} from "../../../src/lib/sourcemap/wasm.js"; +import { + encodeModule, + makeBuildIdSection, + makeCustomSection, +} from "../../../src/lib/wasm/binary.js"; +import { uuidToBytes } from "../../../src/lib/wasm/build-id.js"; + +/** Deterministic build id used wherever the exact debug ID matters. */ +const FIXED_UUID = "00000000-0000-4000-8000-000000000000"; + +/** A second id, for the map-disagrees case. */ +const OTHER_UUID = "11111111-2222-4333-8444-555555555555"; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "sentry-wasm-sourcemap-")); +}); + +/** Write a wasm module, optionally carrying a `build_id`. */ +async function writeWasm(name: string, buildId?: string): Promise { + const sections = [makeCustomSection("name", Uint8Array.from([0x00]))]; + if (buildId) { + sections.push(makeBuildIdSection(uuidToBytes(buildId) as Uint8Array)); + } + const path = join(dir, name); + await writeFile(path, encodeModule(sections)); + return path; +} + +/** Write a Source Map v3 file, optionally carrying a debug ID. */ +async function writeMap(name: string, debugId?: string): Promise { + const map: Record = { + version: 3, + sources: ["app.c"], + names: [], + mappings: "AAAA", + }; + if (debugId) { + map.debug_id = debugId; + } + const path = join(dir, name); + await writeFile(path, JSON.stringify(map)); + return path; +} + +/** Read a map back as a plain object. */ +async function readMap(path: string): Promise> { + return JSON.parse(await readFile(path, "utf-8")); +} + +describe("syncWasmSourcemap", () => { + test("stamps the map with the id the module already carries", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const before = await readFile(wasmPath); + const mapPath = await writeMap("app.wasm.map"); + + const result = await syncWasmSourcemap({ wasmPath, mapPath }); + + expect(result.debugId).toBe(FIXED_UUID); + expect(result.mapWritten).toBe(true); + expect(result.moduleStamped).toBe(false); + const map = await readMap(mapPath); + expect(map.debug_id).toBe(FIXED_UUID); + expect(map.debugId).toBe(FIXED_UUID); + // The module is the source of truth, so it is never rewritten. + expect(await readFile(wasmPath)).toEqual(before); + }); + + test("stamps a module that has no build_id, then the map", async () => { + const wasmPath = await writeWasm("bare.wasm"); + const mapPath = await writeMap("bare.wasm.map"); + + const result = await syncWasmSourcemap( + { wasmPath, mapPath }, + { buildId: uuidToBytes(FIXED_UUID) as Uint8Array } + ); + + expect(result.moduleStamped).toBe(true); + expect(result.debugId).toBe(FIXED_UUID); + expect((await readMap(mapPath)).debug_id).toBe(FIXED_UUID); + }); + + test("writes nothing when the map already carries the module's id", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const mapPath = await writeMap("app.wasm.map", FIXED_UUID); + const before = await readFile(mapPath); + + const result = await syncWasmSourcemap({ wasmPath, mapPath }); + + expect(result.debugId).toBe(FIXED_UUID); + expect(result.mapWritten).toBe(false); + expect(await readFile(mapPath)).toEqual(before); + }); + + test("overwrites a map id that disagrees with the module", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const mapPath = await writeMap("app.wasm.map", OTHER_UUID); + + const result = await syncWasmSourcemap({ wasmPath, mapPath }); + + expect(result.mapWritten).toBe(true); + expect((await readMap(mapPath)).debug_id).toBe(FIXED_UUID); + }); + + test("preserves the rest of the map", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const mapPath = await writeMap("app.wasm.map"); + + await syncWasmSourcemap({ wasmPath, mapPath }); + + const map = await readMap(mapPath); + expect(map.version).toBe(3); + expect(map.sources).toEqual(["app.c"]); + expect(map.mappings).toBe("AAAA"); + }); + + test("dry run leaves both files untouched", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const mapPath = await writeMap("app.wasm.map"); + const wasmBefore = await readFile(wasmPath); + const mapBefore = await readFile(mapPath); + + const result = await syncWasmSourcemap( + { wasmPath, mapPath }, + { dryRun: true } + ); + + // The id is still reportable: it was read off the module. + expect(result.debugId).toBe(FIXED_UUID); + expect(result.mapWritten).toBe(false); + expect(await readFile(wasmPath)).toEqual(wasmBefore); + expect(await readFile(mapPath)).toEqual(mapBefore); + }); + + test("dry run over an unstamped module reports no id", async () => { + const wasmPath = await writeWasm("bare.wasm"); + const mapPath = await writeMap("bare.wasm.map"); + const wasmBefore = await readFile(wasmPath); + + const result = await syncWasmSourcemap( + { wasmPath, mapPath }, + { dryRun: true } + ); + + expect(result.debugId).toBeUndefined(); + expect(result.moduleStamped).toBe(false); + expect(await readFile(wasmPath)).toEqual(wasmBefore); + }); +}); + +describe("discoverWasmPairs", () => { + test("pairs a module with the map beside it", async () => { + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + const mapPath = await writeMap("app.wasm.map"); + + expect(await discoverWasmPairs(dir)).toEqual([{ wasmPath, mapPath }]); + }); + + test("skips DWARF companions written by debug-files prepare", async () => { + await writeWasm("app.debug.wasm", FIXED_UUID); + await writeMap("app.debug.wasm.map"); + + expect(await discoverWasmPairs(dir)).toEqual([]); + }); + + test("skips a module with no map beside it", async () => { + await writeWasm("lonely.wasm", FIXED_UUID); + + expect(await discoverWasmPairs(dir)).toEqual([]); + }); + + test("honours a gitignore-style ignore matcher", async () => { + await mkdir(join(dir, "vendor")); + await writeFile( + join(dir, "vendor", "lib.wasm"), + encodeModule([makeBuildIdSection(uuidToBytes(FIXED_UUID) as Uint8Array)]) + ); + await writeFile(join(dir, "vendor", "lib.wasm.map"), '{"version":3}'); + const wasmPath = await writeWasm("app.wasm", FIXED_UUID); + await writeMap("app.wasm.map"); + + const matcher = ignore(); + matcher.add(["vendor/**"]); + + const pairs = await discoverWasmPairs(dir, matcher); + + expect(pairs.map((p) => p.wasmPath)).toEqual([wasmPath]); + }); +}); + +describe("addWasmDiscoveryCounts", () => { + test("counts modules and moves .wasm.map out of the JS map tally", async () => { + await writeWasm("app.wasm", FIXED_UUID); + await writeMap("app.wasm.map"); + + const diag = await addWasmDiscoveryCounts(dir, { + jsFiles: 0, + mapFiles: 1, + }); + + expect(diag).toEqual({ + jsFiles: 0, + mapFiles: 0, + wasmFiles: 1, + wasmMaps: 1, + }); + }); + + test("reports modules built without -gsource-map", async () => { + await writeWasm("app.wasm", FIXED_UUID); + + const diag = await addWasmDiscoveryCounts(dir, { + jsFiles: 0, + mapFiles: 0, + }); + + expect(diag.wasmFiles).toBe(1); + expect(diag.wasmMaps).toBe(0); + }); +}); From 0c95a88d32ae9d105226038e7bcaa1f207851b68 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Wed, 16 Sep 2026 17:12:14 +0200 Subject: [PATCH 2/3] ref(wasm): share build id stamping across prepare and sourcemaps - Give on-disk build id stamping a single home in lib/wasm/stamp.ts, so every command stamps under the same rule - Keep split.ts pure: it stays a bytes-in/bytes-out port of the Rust wasm-split, with stamp.ts as the filesystem layer above it - Write sourcemap debug IDs through the existing map mutation path - Drop a stamping option no command used --- packages/cli/src/lib/sourcemap/debug-id.ts | 10 +-- packages/cli/src/lib/sourcemap/wasm.ts | 22 ++---- packages/cli/src/lib/wasm/prepare.ts | 44 +----------- packages/cli/src/lib/wasm/stamp.ts | 71 ++++++++++++++++++++ packages/cli/test/lib/sourcemap/wasm.test.ts | 49 +++++++++----- 5 files changed, 118 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/lib/wasm/stamp.ts diff --git a/packages/cli/src/lib/sourcemap/debug-id.ts b/packages/cli/src/lib/sourcemap/debug-id.ts index 09ac8dc8b6..9edd5d0688 100644 --- a/packages/cli/src/lib/sourcemap/debug-id.ts +++ b/packages/cli/src/lib/sourcemap/debug-id.ts @@ -72,9 +72,10 @@ export type SourcemapStampResult = { * * Unlike {@link injectDebugId} this touches no host file, so it suits maps * whose companion artifact is not JavaScript — a wasm module carries its own - * id in a custom section and must never be rewritten as text. Nothing is - * offset either: `mappings` only shifts when injection prepends a runtime - * snippet line to a JS bundle. + * id in a custom section and must never be rewritten as text. The map itself is + * edited by {@link mutateSourcemap}, so it gains the id under both field + * spellings exactly as an injected map does, with no `mappings` offset: + * `mappings` only shifts when injection prepends a runtime snippet line. * * Idempotent: a map already carrying `debugId` is left byte-identical. * @@ -96,8 +97,7 @@ export async function setSourcemapDebugId( if (options.dryRun) { return { written: false, replaced: existing }; } - map.debug_id = debugId; - map.debugId = debugId; + mutateSourcemap(map, debugId, { offsetMappings: false }); await writeFile(mapPath, JSON.stringify(map)); return { written: true, replaced: existing }; } diff --git a/packages/cli/src/lib/sourcemap/wasm.ts b/packages/cli/src/lib/sourcemap/wasm.ts index 3bc4f432f2..f4118688f1 100644 --- a/packages/cli/src/lib/sourcemap/wasm.ts +++ b/packages/cli/src/lib/sourcemap/wasm.ts @@ -25,10 +25,10 @@ import { parseSections } from "../wasm/binary.js"; import { buildIdFromSections, debugIdFromBuildId, - ensureWasmBuildId, formatBuildId, } from "../wasm/build-id.js"; import { isDebugCompanionPath } from "../wasm/prepare.js"; +import { ensureBuildIdOnDisk } from "../wasm/stamp.js"; import { setSourcemapDebugId } from "./debug-id.js"; import { type DiscoveryDiagnostic, SOURCEMAP_SKIP_DIRS } from "./inject.js"; @@ -65,15 +65,10 @@ export type WasmSyncResult = { moduleStamped: boolean; }; -/** Controls the id a pair is reconciled onto, and whether anything is written. */ +/** Whether reconciling a pair is allowed to write. */ export type WasmSyncOptions = { /** Report what would change without touching either file. */ dryRun?: boolean; - /** - * Build id to stamp when the module has none. Defaults to a random UUID. - * A library-level seam for deterministic tests, not a CLI flag. - */ - buildId?: Uint8Array; }; /** @@ -94,14 +89,11 @@ export async function syncWasmSourcemap( ): Promise { const { wasmPath, mapPath } = pair; - // One parse serves both the read and the stamp — `ensureWasmBuildId` takes - // the sections it should re-encode, so nothing re-reads the module. - const sections = parseSections(await readFile(wasmPath)); - const existing = buildIdFromSections(sections); - const buildId = await ensureWasmBuildId(wasmPath, sections, existing, { - buildId: options.buildId, - dryRun: options.dryRun, - }); + // One read serves both the lookup and the stamp. Reading the id first also + // spares an already-stamped module the full re-encode `stampBuildId` does. + const bytes = await readFile(wasmPath); + const existing = buildIdFromSections(parseSections(bytes)); + const buildId = await ensureBuildIdOnDisk(wasmPath, bytes, existing, options); const moduleStamped = !existing && buildId !== null; const debugId = buildId diff --git a/packages/cli/src/lib/wasm/prepare.ts b/packages/cli/src/lib/wasm/prepare.ts index 063a820b95..22c8d84a7d 100644 --- a/packages/cli/src/lib/wasm/prepare.ts +++ b/packages/cli/src/lib/wasm/prepare.ts @@ -32,6 +32,7 @@ import { randomBuildId, } from "./build-id.js"; import { splitWasm } from "./split.js"; +import { ensureBuildIdOnDisk, stampBuildId } from "./stamp.js"; const log = logger.withTag("wasm.prepare"); @@ -239,49 +240,6 @@ async function readCompanionBuildId(path: string): Promise { } } -/** - * Give a module a build id, writing it back in place when it has none. - * - * `wasm-split` stamps every module it processes regardless of debug quality. - * Sentry matches a stack frame to its debug file by build id, so an unstamped - * module can never be symbolicated — not even from a debug file uploaded - * later. Stamping now keeps that option open. - * - * @param path - Module to stamp. - * @param bytes - The module as read from disk. - * @param existing - Id the module already carries, if any. - * @returns The effective build id, or `null` when a dry run left the module - * untouched. - */ -async function ensureBuildIdOnDisk( - path: string, - bytes: Uint8Array, - existing: Uint8Array | null, - options: PrepareOptions -): Promise { - // Checked before splitting so an already-stamped module is never re-encoded. - if (existing) { - return existing; - } - if (options.dryRun) { - return null; - } - const stamped = stampBuildId(bytes, options.buildId); - await writeFile(path, stamped.module); - return stamped.buildId; -} - -/** - * Stamp a module with a build id, changing nothing else. - * - * A split with neither `strip` nor `companion` is exactly that, so stamping - * shares one implementation with the real split rather than reassembling the - * section list by hand. - */ -function stampBuildId(bytes: Uint8Array, buildId?: Uint8Array) { - return splitWasm(bytes, { ...(buildId ? { buildId } : {}) }); -} - /** Whether two build ids are byte-identical. */ function buildIdsMatch(a: Uint8Array | null, b: Uint8Array | null): boolean { if (!(a && b) || a.length !== b.length) { diff --git a/packages/cli/src/lib/wasm/stamp.ts b/packages/cli/src/lib/wasm/stamp.ts new file mode 100644 index 0000000000..621b964c97 --- /dev/null +++ b/packages/cli/src/lib/wasm/stamp.ts @@ -0,0 +1,71 @@ +/** + * Writing a build id onto a module already on disk. + * + * Kept apart from `split.ts`, which stays a pure bytes-in/bytes-out port of the + * Rust `wasm-split`. Everything here is the file-level layer above it, shared + * by `debug-files prepare` and the wasm sourcemap path so both stamp under the + * same rule: an id already on the module always wins, and a dry run writes + * nothing. + */ + +import { writeFile } from "node:fs/promises"; +import { type SplitWasmResult, splitWasm } from "./split.js"; + +/** How to stamp a module that carries no build id yet. */ +export type StampOptions = { + /** Id to use when one must be minted. Defaults to a random v4 UUID. */ + buildId?: Uint8Array; + /** Report what would change without writing. */ + dryRun?: boolean; +}; + +/** + * Stamp a module with a build id, changing nothing else. + * + * A split with neither `strip` nor `companion` is exactly that, so stamping + * shares one implementation with the real split rather than reassembling the + * section list by hand. + * + * @param bytes - The module as read from disk + * @param buildId - Id to stamp when the module carries none + * @returns The effective id and the re-encoded module + */ +export function stampBuildId( + bytes: Uint8Array, + buildId?: Uint8Array +): SplitWasmResult { + return splitWasm(bytes, { buildId }); +} + +/** + * Give a module a build id, writing it back in place when it has none. + * + * `wasm-split` stamps every module it processes regardless of debug quality. + * Sentry matches a stack frame to its debug file by build id, so an unstamped + * module can never be symbolicated — not even from a debug file uploaded + * later. Stamping now keeps that option open. + * + * @param path - Module to stamp + * @param bytes - The module as read from disk + * @param existing - Id the module already carries, if any + * @param options - Which id to mint, and whether to write at all + * @returns The effective build id, or `null` when a dry run left the module + * untouched + */ +export async function ensureBuildIdOnDisk( + path: string, + bytes: Uint8Array, + existing: Uint8Array | null, + options: StampOptions = {} +): Promise { + // Checked before splitting so an already-stamped module is never re-encoded. + if (existing) { + return existing; + } + if (options.dryRun) { + return null; + } + const stamped = stampBuildId(bytes, options.buildId); + await writeFile(path, stamped.module); + return stamped.buildId; +} diff --git a/packages/cli/test/lib/sourcemap/wasm.test.ts b/packages/cli/test/lib/sourcemap/wasm.test.ts index 71b59f3d9e..9afc295e0a 100644 --- a/packages/cli/test/lib/sourcemap/wasm.test.ts +++ b/packages/cli/test/lib/sourcemap/wasm.test.ts @@ -16,12 +16,19 @@ import { discoverWasmPairs, syncWasmSourcemap, } from "../../../src/lib/sourcemap/wasm.js"; +import { parseSections } from "../../../src/lib/wasm/binary.js"; import { - encodeModule, - makeBuildIdSection, - makeCustomSection, -} from "../../../src/lib/wasm/binary.js"; -import { uuidToBytes } from "../../../src/lib/wasm/build-id.js"; + buildIdFromSections, + debugIdFromBuildId, + formatBuildId, + uuidToBytes, +} from "../../../src/lib/wasm/build-id.js"; +import { + byteVector, + customSection, + fromHex, + wasmModule, +} from "../wasm/helpers.js"; /** Deterministic build id used wherever the exact debug ID matters. */ const FIXED_UUID = "00000000-0000-4000-8000-000000000000"; @@ -37,12 +44,14 @@ beforeEach(async () => { /** Write a wasm module, optionally carrying a `build_id`. */ async function writeWasm(name: string, buildId?: string): Promise { - const sections = [makeCustomSection("name", Uint8Array.from([0x00]))]; + const sections = [customSection("name", fromHex("00"))]; if (buildId) { - sections.push(makeBuildIdSection(uuidToBytes(buildId) as Uint8Array)); + sections.push( + customSection("build_id", byteVector(uuidToBytes(buildId) as Uint8Array)) + ); } const path = join(dir, name); - await writeFile(path, encodeModule(sections)); + await writeFile(path, wasmModule(sections)); return path; } @@ -67,6 +76,12 @@ async function readMap(path: string): Promise> { return JSON.parse(await readFile(path, "utf-8")); } +/** Read the debug ID a module's `build_id` section stands for. */ +async function readModuleDebugId(path: string): Promise { + const buildId = buildIdFromSections(parseSections(await readFile(path))); + return buildId ? debugIdFromBuildId(formatBuildId(buildId)) : undefined; +} + describe("syncWasmSourcemap", () => { test("stamps the map with the id the module already carries", async () => { const wasmPath = await writeWasm("app.wasm", FIXED_UUID); @@ -89,14 +104,13 @@ describe("syncWasmSourcemap", () => { const wasmPath = await writeWasm("bare.wasm"); const mapPath = await writeMap("bare.wasm.map"); - const result = await syncWasmSourcemap( - { wasmPath, mapPath }, - { buildId: uuidToBytes(FIXED_UUID) as Uint8Array } - ); + const result = await syncWasmSourcemap({ wasmPath, mapPath }); expect(result.moduleStamped).toBe(true); - expect(result.debugId).toBe(FIXED_UUID); - expect((await readMap(mapPath)).debug_id).toBe(FIXED_UUID); + // The minted id is random, so the invariant to check is that both sides + // ended up with the one now on the module. + expect(result.debugId).toBe(await readModuleDebugId(wasmPath)); + expect((await readMap(mapPath)).debug_id).toBe(result.debugId); }); test("writes nothing when the map already carries the module's id", async () => { @@ -192,7 +206,12 @@ describe("discoverWasmPairs", () => { await mkdir(join(dir, "vendor")); await writeFile( join(dir, "vendor", "lib.wasm"), - encodeModule([makeBuildIdSection(uuidToBytes(FIXED_UUID) as Uint8Array)]) + wasmModule([ + customSection( + "build_id", + byteVector(uuidToBytes(FIXED_UUID) as Uint8Array) + ), + ]) ); await writeFile(join(dir, "vendor", "lib.wasm.map"), '{"version":3}'); const wasmPath = await writeWasm("app.wasm", FIXED_UUID); From 6f3691e8a688866ac9eaedfd117248da2f2ddaa2 Mon Sep 17 00:00:00 2001 From: Ana-Maria Dumitrache Date: Thu, 17 Sep 2026 14:01:27 +0200 Subject: [PATCH 3/3] fix(response) orphaned .wasm.map diagnostic misclassification - Count `.wasm.map` files directly in `addWasmDiscoveryCounts` instead of only when a companion `.wasm` exists - Widen the diagnostic walk to include `.map` so orphan wasm maps leave the JS `mapFiles` tally - Add a `buildEmptyDiscoveryError` branch for wasm maps without modules, explaining the missing `.wasm` requirement - Cover orphan and JS-map tally behavior in `wasm.test.ts` --- packages/cli/src/lib/sourcemap/inject.ts | 11 ++++++ packages/cli/src/lib/sourcemap/wasm.ts | 35 ++++++++++++++++---- packages/cli/test/lib/sourcemap/wasm.test.ts | 32 ++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/sourcemap/inject.ts b/packages/cli/src/lib/sourcemap/inject.ts index 9409612899..efc1a9cd5e 100644 --- a/packages/cli/src/lib/sourcemap/inject.ts +++ b/packages/cli/src/lib/sourcemap/inject.ts @@ -861,6 +861,17 @@ export function buildEmptyDiscoveryError( "directory" ); } + // Wasm maps with no module beside them: the debug ID comes from the + // module's `build_id`, so the map alone is not uploadable. + if (jsFiles === 0 && mapFiles === 0 && wasmMaps > 0 && wasmFiles === 0) { + return new ValidationError( + `Found ${wasmMaps} .wasm.map file(s) in '${dir}' but no .wasm ` + + "modules. A wasm sourcemap is matched to its module's build_id, so " + + "the .wasm file must sit beside it — point the command at your " + + "build output. Pass --allow-empty to suppress.", + "directory" + ); + } if (jsFiles === 0 && mapFiles === 0) { return new ValidationError( `Directory '${dir}' contains no JS or sourcemap files. ` + diff --git a/packages/cli/src/lib/sourcemap/wasm.ts b/packages/cli/src/lib/sourcemap/wasm.ts index f4118688f1..b1c8484dc9 100644 --- a/packages/cli/src/lib/sourcemap/wasm.ts +++ b/packages/cli/src/lib/sourcemap/wasm.ts @@ -40,6 +40,16 @@ const log = logger.withTag("sourcemap.wasm"); */ const WASM_EXTENSIONS: ReadonlySet = new Set([".wasm"]); +/** + * Extensions scanned when counting artifacts for the zero-pairs diagnostic. + * Covers `.map` so an orphaned `app.wasm.map` — a map whose module is not in + * the directory — is attributed to wasm instead of being left in the JS tally. + */ +const WASM_DIAGNOSTIC_EXTENSIONS: ReadonlySet = new Set([ + ".wasm", + ".map", +]); + /** A WebAssembly module and the sourcemap sitting next to it. */ export type WasmPair = { /** Absolute path to the `.wasm` module. */ @@ -178,7 +188,9 @@ export async function discoverWasmPairs( * Only called on the zero-pairs error path, so a second walk costs nothing * that matters. `.wasm.map` files are moved out of the JS `mapFiles` tally — * the JS walk counts every `.map` — so the JS branches of - * `buildEmptyDiscoveryError` keep describing JS alone. + * `buildEmptyDiscoveryError` keep describing JS alone. Maps are counted + * independently of modules, so a map left behind by a missing module is still + * recognised as wasm. * * @param dir - Directory to scan * @param js - The diagnostic from `diagnoseEmptyDiscovery` @@ -191,10 +203,17 @@ export async function addWasmDiscoveryCounts( const absDir = resolvePath(dir); let wasmFiles = 0; let wasmMaps = 0; - for await (const wasmPath of walkWasmModules(absDir)) { - wasmFiles += 1; - if (await hasCompanionMap(`${wasmPath}.map`)) { + for await (const path of walkWasmModules( + absDir, + undefined, + WASM_DIAGNOSTIC_EXTENSIONS + )) { + // The widened extension set also yields JS `.map` files; those stay in the + // JS tally. + if (path.endsWith(".wasm.map")) { wasmMaps += 1; + } else if (path.endsWith(".wasm")) { + wasmFiles += 1; } } return { @@ -211,14 +230,18 @@ export async function addWasmDiscoveryCounts( * Uses the same traversal settings as JS discovery — build outputs are usually * gitignored, `dist`/`build` must not be pruned, and a wasm module easily * exceeds any size cap. + * + * @param extensions - Widened only by the diagnostic walk, which also needs + * `.map` files; callers must then filter the extra extensions themselves. */ async function* walkWasmModules( absDir: string, - ignoreMatcher?: ReturnType + ignoreMatcher?: ReturnType, + extensions: ReadonlySet = WASM_EXTENSIONS ): AsyncGenerator { for await (const entry of walkFiles({ cwd: absDir, - extensions: WASM_EXTENSIONS, + extensions, alwaysSkipDirs: SOURCEMAP_SKIP_DIRS, hidden: false, respectGitignore: false, diff --git a/packages/cli/test/lib/sourcemap/wasm.test.ts b/packages/cli/test/lib/sourcemap/wasm.test.ts index 9afc295e0a..bb2818fabc 100644 --- a/packages/cli/test/lib/sourcemap/wasm.test.ts +++ b/packages/cli/test/lib/sourcemap/wasm.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import ignore from "ignore"; import { beforeEach, describe, expect, test } from "vitest"; +import { buildEmptyDiscoveryError } from "../../../src/lib/sourcemap/inject.js"; import { addWasmDiscoveryCounts, discoverWasmPairs, @@ -255,4 +256,35 @@ describe("addWasmDiscoveryCounts", () => { expect(diag.wasmFiles).toBe(1); expect(diag.wasmMaps).toBe(0); }); + + test("attributes a map with no module to wasm, not JS", async () => { + await writeMap("app.wasm.map"); + + const diag = await addWasmDiscoveryCounts(dir, { + jsFiles: 0, + mapFiles: 1, + }); + + expect(diag).toEqual({ + jsFiles: 0, + mapFiles: 0, + wasmFiles: 0, + wasmMaps: 1, + }); + expect(buildEmptyDiscoveryError(dir, diag).message).toContain( + "1 .wasm.map file(s)" + ); + }); + + test("leaves JS maps in the JS tally", async () => { + await writeMap("app.js.map"); + + const diag = await addWasmDiscoveryCounts(dir, { + jsFiles: 0, + mapFiles: 1, + }); + + expect(diag.mapFiles).toBe(1); + expect(diag.wasmMaps).toBe(0); + }); });