Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions packages/cli/src/commands/sourcemap/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dry-run marks wasm pairs unchanged

Medium Severity

--dry-run never sets mapWritten or moduleStamped, so wasmChanged stays false. Every wasm pair then counts as skipped and renders with a dash, even when the map would be stamped or the module would get a build_id. JavaScript pairs on the same dry-run still show as modified when they would change.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d27184b. Configure here.


/** Format human-readable output for inject results. */
function formatInjectResult(data: InjectCommandResult): string {
const lines: string[] = [];
Expand All @@ -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"));
}

Expand All @@ -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 " +
Expand Down Expand Up @@ -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);
}

Expand All @@ -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<InjectCommandResult>({
modified,
skipped,
files: results,
wasm: wasmResults,
});

if (modified > 0) {
Expand Down
146 changes: 113 additions & 33 deletions packages/cli/src/commands/sourcemap/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand All @@ -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") {
Expand Down Expand Up @@ -202,13 +240,56 @@ 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",
fullDescription:
"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 " +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/lib/sourcemap/debug-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
*
* @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<SourcemapStampResult> {
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 };
}
mutateSourcemap(map, debugId, { offsetMappings: false });
await writeFile(mapPath, JSON.stringify(map));
return { written: true, replaced: existing };
}

/**
* Generate a deterministic debug ID (UUID v4 format) from content.
*
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/lib/sourcemap/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -664,6 +664,13 @@ export async function assertDirectoryReadable(dir: string): Promise<void> {
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;
};

/**
Expand Down Expand Up @@ -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. ` +
Expand Down
Loading
Loading