Skip to content

feat(core): Add hook to customize source map file resolution #732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion packages/bundler-plugin-core/src/build-plugin-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,8 @@ export function createSentryBuildPluginManager(
tmpUploadFolder,
chunkIndex,
logger,
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook,
options.sourcemaps?.resolveSourceMap
);
}
);
Expand Down
86 changes: 43 additions & 43 deletions packages/bundler-plugin-core/src/debug-id-upload.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -27,7 +24,8 @@ export async function prepareBundleForDebugIdUpload(
uploadFolder: string,
chunkIndex: number,
logger: Logger,
rewriteSourcesHook: RewriteSourcesHook
rewriteSourcesHook: RewriteSourcesHook,
resolveSourceMapHook: ResolveSourceMapHook | undefined
) {
let bundleContent;
try {
Expand Down Expand Up @@ -60,7 +58,8 @@ export async function prepareBundleForDebugIdUpload(
const writeSourceMapFilePromise = determineSourceMapPathFromBundle(
bundleFilePath,
bundleContent,
logger
logger,
resolveSourceMapHook
).then(async (sourceMapPath) => {
if (sourceMapPath) {
await prepareSourceMapForDebugIdUpload(
Expand Down Expand Up @@ -114,61 +113,62 @@ 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<string | undefined> {
// 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;

let isUrl;
let isSupportedUrl;
const searchLocations: string[] = [];

if (resolveSourceMapHook) {
const customPath = await resolveSourceMapHook(bundlePath, sourceMappingUrl);
if (customPath) {
searchLocations.push(customPath);
}
}

// 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}\`` +
` - Did you turn on source map generation in your bundler?` +
` (Attempted paths: ${searchLocations.map(e => `\`${e}\``).join(", ")})`
);
return undefined;
}
Expand Down
18 changes: 16 additions & 2 deletions packages/bundler-plugin-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,17 @@ 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 indicating where to find the artifact's
* corresponding `.map` file. If no path is returned or the returned path doesn't exist, the standard source map
* resolution process will be used.
*/
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.
Expand Down Expand Up @@ -356,6 +365,11 @@ 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<string | undefined>

export interface ModuleMetadata {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use strict";
console.log("wow!");

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"use strict";
console.log("wow!");

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

164 changes: 164 additions & 0 deletions packages/bundler-plugin-core/test/sentry/resolve-source-maps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
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);
});
});
});
Loading
Loading