diff --git a/packages/bundler-plugin-core/src/build-plugin-manager.ts b/packages/bundler-plugin-core/src/build-plugin-manager.ts index 397f04bf..1e7f6f81 100644 --- a/packages/bundler-plugin-core/src/build-plugin-manager.ts +++ b/packages/bundler-plugin-core/src/build-plugin-manager.ts @@ -518,7 +518,8 @@ export function createSentryBuildPluginManager( tmpUploadFolder, chunkIndex, logger, - options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook + options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook, + options.sourcemaps?.resolveSourceMap ); } ); diff --git a/packages/bundler-plugin-core/src/debug-id-upload.ts b/packages/bundler-plugin-core/src/debug-id-upload.ts index c9f9302e..3f5e47e5 100644 --- a/packages/bundler-plugin-core/src/debug-id-upload.ts +++ b/packages/bundler-plugin-core/src/debug-id-upload.ts @@ -1,14 +1,11 @@ import fs from "fs"; import path from "path"; +import * as url from "url"; import * as util from "util"; import { promisify } from "util"; import { SentryBuildPluginManager } from "./build-plugin-manager"; import { Logger } from "./logger"; - -interface RewriteSourcesHook { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (source: string, map: any): string; -} +import { ResolveSourceMapHook, RewriteSourcesHook } from "./types"; interface DebugIdUploadPluginOptions { sentryBuildPluginManager: SentryBuildPluginManager; @@ -27,7 +24,8 @@ export async function prepareBundleForDebugIdUpload( uploadFolder: string, chunkIndex: number, logger: Logger, - rewriteSourcesHook: RewriteSourcesHook + rewriteSourcesHook: RewriteSourcesHook, + resolveSourceMapHook: ResolveSourceMapHook | undefined ) { let bundleContent; try { @@ -60,7 +58,8 @@ export async function prepareBundleForDebugIdUpload( const writeSourceMapFilePromise = determineSourceMapPathFromBundle( bundleFilePath, bundleContent, - logger + logger, + resolveSourceMapHook ).then(async (sourceMapPath) => { if (sourceMapPath) { await prepareSourceMapForDebugIdUpload( @@ -114,61 +113,72 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string * * @returns the path to the bundle's source map or `undefined` if none could be found. */ -async function determineSourceMapPathFromBundle( +export async function determineSourceMapPathFromBundle( bundlePath: string, bundleSource: string, - logger: Logger + logger: Logger, + resolveSourceMapHook: ResolveSourceMapHook | undefined ): Promise { - // 1. try to find source map at `sourceMappingURL` location const sourceMappingUrlMatch = bundleSource.match(/^\s*\/\/# sourceMappingURL=(.*)$/m); - if (sourceMappingUrlMatch) { - const sourceMappingUrl = path.normalize(sourceMappingUrlMatch[1] as string); + const sourceMappingUrl = sourceMappingUrlMatch ? (sourceMappingUrlMatch[1] as string) : undefined; + + const searchLocations: string[] = []; + + if (resolveSourceMapHook) { + logger.debug( + `Calling sourcemaps.resolveSourceMap(${JSON.stringify(bundlePath)}, ${JSON.stringify( + sourceMappingUrl + )})` + ); + const customPath = await resolveSourceMapHook(bundlePath, sourceMappingUrl); + logger.debug(`resolveSourceMap hook returned: ${JSON.stringify(customPath)}`); + + if (customPath) { + searchLocations.push(customPath); + } + } - let isUrl; - let isSupportedUrl; + // 1. try to find source map at `sourceMappingURL` location + if (sourceMappingUrl) { + let parsedUrl: URL | undefined; try { - const url = new URL(sourceMappingUrl); - isUrl = true; - isSupportedUrl = url.protocol === "file:"; + parsedUrl = new URL(sourceMappingUrl); } catch { - isUrl = false; - isSupportedUrl = false; + // noop } - let absoluteSourceMapPath; - if (isSupportedUrl) { - absoluteSourceMapPath = sourceMappingUrl; - } else if (isUrl) { - // noop + if (parsedUrl && parsedUrl.protocol === "file:") { + searchLocations.push(url.fileURLToPath(sourceMappingUrl)); + } else if (parsedUrl) { + // noop, non-file urls don't translate to a local sourcemap file } else if (path.isAbsolute(sourceMappingUrl)) { - absoluteSourceMapPath = sourceMappingUrl; + searchLocations.push(path.normalize(sourceMappingUrl)); } else { - absoluteSourceMapPath = path.join(path.dirname(bundlePath), sourceMappingUrl); - } - - if (absoluteSourceMapPath) { - try { - // Check if the file actually exists - await util.promisify(fs.access)(absoluteSourceMapPath); - return absoluteSourceMapPath; - } catch (e) { - // noop - } + searchLocations.push(path.normalize(path.join(path.dirname(bundlePath), sourceMappingUrl))); } } // 2. try to find source map at path adjacent to chunk source, but with `.map` appended - try { - const adjacentSourceMapFilePath = bundlePath + ".map"; - await util.promisify(fs.access)(adjacentSourceMapFilePath); - return adjacentSourceMapFilePath; - } catch (e) { - // noop + searchLocations.push(bundlePath + ".map"); + + for (const searchLocation of searchLocations) { + try { + await util.promisify(fs.access)(searchLocation); + logger.debug(`Source map found for bundle \`${bundlePath}\`: \`${searchLocation}\``); + return searchLocation; + } catch (e) { + // noop + } } // This is just a debug message because it can be quite spammy for some frameworks logger.debug( - `Could not determine source map path for bundle: ${bundlePath} - Did you turn on source map generation in your bundler?` + `Could not determine source map path for bundle \`${bundlePath}\`` + + ` with sourceMappingURL=${ + sourceMappingUrl === undefined ? "undefined" : `\`${sourceMappingUrl}\`` + }` + + ` - Did you turn on source map generation in your bundler?` + + ` (Attempted paths: ${searchLocations.map((e) => `\`${e}\``).join(", ")})` ); return undefined; } diff --git a/packages/bundler-plugin-core/src/types.ts b/packages/bundler-plugin-core/src/types.ts index 9ea12397..1c088a4d 100644 --- a/packages/bundler-plugin-core/src/types.ts +++ b/packages/bundler-plugin-core/src/types.ts @@ -124,8 +124,28 @@ export interface Options { * * Defaults to making all sources relative to `process.cwd()` while building. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rewriteSources?: (source: string, map: any) => string; + rewriteSources?: RewriteSourcesHook; + + /** + * Hook to customize source map file resolution. + * + * The hook is called with the absolute path of the build artifact and the value of the `//# sourceMappingURL=` + * comment, if present. The hook should then return an absolute path (or a promise that resolves to one) indicating + * where to find the artifact's corresponding source map file. If no path is returned or the returned path doesn't + * exist, the standard source map resolution process will be used. + * + * The standard process first tries to resolve based on the `//# sourceMappingURL=` value (it supports `file://` + * urls and absolute/relative paths). If that path doesn't exist, it then looks for a file named + * `${artifactName}.map` in the same directory as the artifact. + * + * Note: This is mostly helpful for complex builds with custom source map generation. For example, if you put source + * maps into a separate directory and rewrite the `//# sourceMappingURL=` comment to something other than a relative + * directory, sentry will be unable to locate the source maps for a given build artifact. This hook allows you to + * implement the resolution process yourself. + * + * Use the `debug` option to print information about source map resolution. + */ + resolveSourceMap?: ResolveSourceMapHook; /** * A glob or an array of globs that specifies the build artifacts that should be deleted after the artifact upload to Sentry has been completed. @@ -356,6 +376,14 @@ export interface Options { }; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type RewriteSourcesHook = (source: string, map: any) => string; + +export type ResolveSourceMapHook = ( + artifactPath: string, + sourceMappingUrl: string | undefined +) => string | undefined | Promise; + export interface ModuleMetadata { // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; diff --git a/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js new file mode 100644 index 00000000..ff1a49c7 --- /dev/null +++ b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js @@ -0,0 +1,2 @@ +"use strict"; +console.log("wow!"); \ No newline at end of file diff --git a/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js.map b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js.map new file mode 100644 index 00000000..11ab57d0 --- /dev/null +++ b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"input.js","sourceRoot":"","sources":["input.tsx"],"names":[],"mappings":";AAAA,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA"} \ No newline at end of file diff --git a/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/bundles/index.js b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/bundles/index.js new file mode 100644 index 00000000..ff1a49c7 --- /dev/null +++ b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/bundles/index.js @@ -0,0 +1,2 @@ +"use strict"; +console.log("wow!"); \ No newline at end of file diff --git a/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/sourcemaps/index.js.map b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/sourcemaps/index.js.map new file mode 100644 index 00000000..88e40095 --- /dev/null +++ b/packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/sourcemaps/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"input.js","sourceRoot":"","sources":["input.tsx"],"names":[],"mappings":";AAAA,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA"} diff --git a/packages/bundler-plugin-core/test/sentry/resolve-source-maps.test.ts b/packages/bundler-plugin-core/test/sentry/resolve-source-maps.test.ts new file mode 100644 index 00000000..b8e5c309 --- /dev/null +++ b/packages/bundler-plugin-core/test/sentry/resolve-source-maps.test.ts @@ -0,0 +1,165 @@ +import * as path from "path"; +import * as fs from "fs"; +import * as url from "url"; +import { determineSourceMapPathFromBundle } from "../../src/debug-id-upload"; +import { createLogger } from "../../src/logger"; + +const logger = createLogger({ prefix: "[resolve-source-maps-test]", silent: false, debug: false }); +const fixtureDir = path.resolve(__dirname, "../fixtures/resolve-source-maps"); + +const adjacentBundlePath = path.join(fixtureDir, "adjacent-sourcemap/index.js"); +const adjacentSourceMapPath = path.join(fixtureDir, "adjacent-sourcemap/index.js.map"); +const adjacentBundleContent = fs.readFileSync(adjacentBundlePath, "utf-8"); + +const separateBundlePath = path.join(fixtureDir, "separate-directory/bundles/index.js"); +const separateSourceMapPath = path.join(fixtureDir, "separate-directory/sourcemaps/index.js.map"); +const separateBundleContent = fs.readFileSync(separateBundlePath, "utf-8"); + +const sourceMapUrl = "https://sourcemaps.example.com/foo/index.js.map"; + +function srcMappingUrl(url: string): string { + return `\n//# sourceMappingURL=${url}`; +} + +describe("Resolve source maps", () => { + it("should resolve source maps next to bundles", async () => { + expect( + await determineSourceMapPathFromBundle( + adjacentBundlePath, + adjacentBundleContent, + logger, + undefined + ) + ).toEqual(adjacentSourceMapPath); + }); + + it("shouldn't resolve source maps in separate directories", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent, + logger, + undefined + ) + ).toBeUndefined(); + }); + + describe("sourceMappingURL resolution", () => { + it("should resolve source maps when sourceMappingURL is a file URL", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + srcMappingUrl(url.pathToFileURL(separateSourceMapPath).href), + logger, + undefined + ) + ).toEqual(separateSourceMapPath); + }); + + it("shouldn't resolve source maps when sourceMappingURL is a non-file URL", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + srcMappingUrl(sourceMapUrl), + logger, + undefined + ) + ).toBeUndefined(); + }); + + it("should resolve source maps when sourceMappingURL is an absolute path", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + srcMappingUrl(separateSourceMapPath), + logger, + undefined + ) + ).toEqual(separateSourceMapPath); + }); + + it("should resolve source maps when sourceMappingURL is a relative path", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + + srcMappingUrl(path.relative(path.dirname(separateBundlePath), separateSourceMapPath)), + logger, + undefined + ) + ).toEqual(separateSourceMapPath); + }); + }); + + describe("resolveSourceMap hook", () => { + it("should resolve source maps when a resolveSourceMap hook is provided", async () => { + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + srcMappingUrl(sourceMapUrl), + logger, + () => separateSourceMapPath + ) + ).toEqual(separateSourceMapPath); + }); + + it("should pass the correct values to the resolveSourceMap hook", async () => { + const hook = jest.fn(() => separateSourceMapPath); + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent + srcMappingUrl(sourceMapUrl), + logger, + hook + ) + ).toEqual(separateSourceMapPath); + expect(hook.mock.calls[0]).toEqual([separateBundlePath, sourceMapUrl]); + }); + + it("should pass the correct values to the resolveSourceMap hook when no sourceMappingURL is present", async () => { + const hook = jest.fn(() => separateSourceMapPath); + expect( + await determineSourceMapPathFromBundle( + separateBundlePath, + separateBundleContent, + logger, + hook + ) + ).toEqual(separateSourceMapPath); + expect(hook.mock.calls[0]).toEqual([separateBundlePath, undefined]); + }); + + it("should prefer resolveSourceMap result over heuristic results", async () => { + expect( + await determineSourceMapPathFromBundle( + adjacentBundlePath, + adjacentBundleContent, + logger, + () => separateSourceMapPath + ) + ).toEqual(separateSourceMapPath); + }); + + it("should fall back when the resolveSourceMap hook returns undefined", async () => { + expect( + await determineSourceMapPathFromBundle( + adjacentBundlePath, + adjacentBundleContent, + logger, + () => undefined + ) + ).toEqual(adjacentSourceMapPath); + }); + + it("should fall back when the resolveSourceMap hook returns a non-existent path", async () => { + expect( + await determineSourceMapPathFromBundle( + adjacentBundlePath, + adjacentBundleContent, + logger, + () => path.join(fixtureDir, "non-existent.js.map") + ) + ).toEqual(adjacentSourceMapPath); + }); + }); +}); diff --git a/packages/dev-utils/src/generate-documentation-table.ts b/packages/dev-utils/src/generate-documentation-table.ts index 197673a2..3719d760 100644 --- a/packages/dev-utils/src/generate-documentation-table.ts +++ b/packages/dev-utils/src/generate-documentation-table.ts @@ -96,6 +96,28 @@ errorHandler: (err) => { fullDescription: "Hook to rewrite the `sources` field inside the source map before being uploaded to Sentry. Does not modify the actual source map. Effectively, this modifies how files inside the stacktrace will show up in Sentry.\n\nDefaults to making all sources relative to `process.cwd()` while building.", }, + { + name: "resolveSourceMap", + type: "(artifactPath: string, sourceMappingUrl: string | undefined) => string | undefined | Promise", + fullDescription: `Hook to customize source map file resolution. + +The hook is called with the absolute path of the build artifact and the value of the \`//# sourceMappingURL=\` +comment, if present. The hook should then return an absolute path (or a promise that resolves to one) indicating +where to find the artifact's corresponding source map file. If no path is returned or the returned path doesn't +exist, the standard source map resolution process will be used. + +The standard process first tries to resolve based on the \`//# sourceMappingURL=\` value (it supports \`file://\` +urls and absolute/relative paths). If that path doesn't exist, it then looks for a file named +\`\${artifactName}.map\` in the same directory as the artifact. + +Note: This is mostly helpful for complex builds with custom source map generation. For example, if you put source +maps into a separate directory and rewrite the \`//# sourceMappingURL=\` comment to something other than a relative +directory, sentry will be unable to locate the source maps for a given build artifact. This hook allows you to +implement the resolution process yourself. + +Use the \`debug\` option to print information about source map resolution. +`, + }, { name: "filesToDeleteAfterUpload", type: "string | string[] | Promise",