Skip to content

Commit b55feb0

Browse files
feat(core): Add hook to customize source map file resolution
fixes #731
1 parent 7bc20a7 commit b55feb0

File tree

10 files changed

+278
-47
lines changed

10 files changed

+278
-47
lines changed

packages/bundler-plugin-core/src/build-plugin-manager.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,8 @@ export function createSentryBuildPluginManager(
518518
tmpUploadFolder,
519519
chunkIndex,
520520
logger,
521-
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook
521+
options.sourcemaps?.rewriteSources ?? defaultRewriteSourcesHook,
522+
options.sourcemaps?.resolveSourceMap
522523
);
523524
}
524525
);

packages/bundler-plugin-core/src/debug-id-upload.ts

+43-43
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import fs from "fs";
22
import path from "path";
3+
import * as url from "url"
34
import * as util from "util";
45
import { promisify } from "util";
56
import { SentryBuildPluginManager } from "./build-plugin-manager";
67
import { Logger } from "./logger";
7-
8-
interface RewriteSourcesHook {
9-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
10-
(source: string, map: any): string;
11-
}
8+
import { ResolveSourceMapHook, RewriteSourcesHook } from "./types";
129

1310
interface DebugIdUploadPluginOptions {
1411
sentryBuildPluginManager: SentryBuildPluginManager;
@@ -27,7 +24,8 @@ export async function prepareBundleForDebugIdUpload(
2724
uploadFolder: string,
2825
chunkIndex: number,
2926
logger: Logger,
30-
rewriteSourcesHook: RewriteSourcesHook
27+
rewriteSourcesHook: RewriteSourcesHook,
28+
resolveSourceMapHook: ResolveSourceMapHook | undefined
3129
) {
3230
let bundleContent;
3331
try {
@@ -60,7 +58,8 @@ export async function prepareBundleForDebugIdUpload(
6058
const writeSourceMapFilePromise = determineSourceMapPathFromBundle(
6159
bundleFilePath,
6260
bundleContent,
63-
logger
61+
logger,
62+
resolveSourceMapHook
6463
).then(async (sourceMapPath) => {
6564
if (sourceMapPath) {
6665
await prepareSourceMapForDebugIdUpload(
@@ -114,61 +113,62 @@ function addDebugIdToBundleSource(bundleSource: string, debugId: string): string
114113
*
115114
* @returns the path to the bundle's source map or `undefined` if none could be found.
116115
*/
117-
async function determineSourceMapPathFromBundle(
116+
export async function determineSourceMapPathFromBundle(
118117
bundlePath: string,
119118
bundleSource: string,
120-
logger: Logger
119+
logger: Logger,
120+
resolveSourceMapHook: ResolveSourceMapHook | undefined
121121
): Promise<string | undefined> {
122-
// 1. try to find source map at `sourceMappingURL` location
123122
const sourceMappingUrlMatch = bundleSource.match(/^\s*\/\/# sourceMappingURL=(.*)$/m);
124-
if (sourceMappingUrlMatch) {
125-
const sourceMappingUrl = path.normalize(sourceMappingUrlMatch[1] as string);
123+
const sourceMappingUrl = sourceMappingUrlMatch ? sourceMappingUrlMatch[1] as string : undefined;
126124

127-
let isUrl;
128-
let isSupportedUrl;
125+
const searchLocations: string[] = [];
126+
127+
if (resolveSourceMapHook) {
128+
const customPath = await resolveSourceMapHook(bundlePath, sourceMappingUrl);
129+
if (customPath) {
130+
searchLocations.push(customPath);
131+
}
132+
}
133+
134+
// 1. try to find source map at `sourceMappingURL` location
135+
if (sourceMappingUrl) {
136+
let parsedUrl: URL | undefined;
129137
try {
130-
const url = new URL(sourceMappingUrl);
131-
isUrl = true;
132-
isSupportedUrl = url.protocol === "file:";
138+
parsedUrl = new URL(sourceMappingUrl);
133139
} catch {
134-
isUrl = false;
135-
isSupportedUrl = false;
140+
// noop
136141
}
137142

138-
let absoluteSourceMapPath;
139-
if (isSupportedUrl) {
140-
absoluteSourceMapPath = sourceMappingUrl;
141-
} else if (isUrl) {
142-
// noop
143+
if (parsedUrl && parsedUrl.protocol === "file:") {
144+
searchLocations.push(url.fileURLToPath(sourceMappingUrl));
145+
} else if (parsedUrl) {
146+
// noop, non-file urls don't translate to a local sourcemap file
143147
} else if (path.isAbsolute(sourceMappingUrl)) {
144-
absoluteSourceMapPath = sourceMappingUrl;
148+
searchLocations.push(path.normalize(sourceMappingUrl))
145149
} else {
146-
absoluteSourceMapPath = path.join(path.dirname(bundlePath), sourceMappingUrl);
147-
}
148-
149-
if (absoluteSourceMapPath) {
150-
try {
151-
// Check if the file actually exists
152-
await util.promisify(fs.access)(absoluteSourceMapPath);
153-
return absoluteSourceMapPath;
154-
} catch (e) {
155-
// noop
156-
}
150+
searchLocations.push(path.normalize(path.join(path.dirname(bundlePath), sourceMappingUrl)));
157151
}
158152
}
159153

160154
// 2. try to find source map at path adjacent to chunk source, but with `.map` appended
161-
try {
162-
const adjacentSourceMapFilePath = bundlePath + ".map";
163-
await util.promisify(fs.access)(adjacentSourceMapFilePath);
164-
return adjacentSourceMapFilePath;
165-
} catch (e) {
166-
// noop
155+
searchLocations.push(bundlePath + ".map")
156+
157+
for (const searchLocation of searchLocations) {
158+
try {
159+
await util.promisify(fs.access)(searchLocation);
160+
logger.debug(`Source map found for bundle \`${bundlePath}\`: \`${searchLocation}\``)
161+
return searchLocation;
162+
} catch (e) {
163+
// noop
164+
}
167165
}
168166

169167
// This is just a debug message because it can be quite spammy for some frameworks
170168
logger.debug(
171-
`Could not determine source map path for bundle: ${bundlePath} - Did you turn on source map generation in your bundler?`
169+
`Could not determine source map path for bundle: \`${bundlePath}\`` +
170+
` - Did you turn on source map generation in your bundler?` +
171+
` (Attempted paths: ${searchLocations.map(e => `\`${e}\``).join(", ")})`
172172
);
173173
return undefined;
174174
}

packages/bundler-plugin-core/src/types.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,17 @@ export interface Options {
124124
*
125125
* Defaults to making all sources relative to `process.cwd()` while building.
126126
*/
127-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
128-
rewriteSources?: (source: string, map: any) => string;
127+
rewriteSources?: RewriteSourcesHook;
128+
129+
/**
130+
* Hook to customize source map file resolution.
131+
*
132+
* The hook is called with the absolute path of the build artifact and the value of the `//# sourceMappingURL=`
133+
* comment, if present. The hook should then return an absolute path indicating where to find the artifact's
134+
* corresponding `.map` file. If no path is returned or the returned path doesn't exist, the standard source map
135+
* resolution process will be used.
136+
*/
137+
resolveSourceMap?: ResolveSourceMapHook;
129138

130139
/**
131140
* 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 +365,11 @@ export interface Options {
356365
};
357366
}
358367

368+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
369+
export type RewriteSourcesHook = (source: string, map: any) => string
370+
371+
export type ResolveSourceMapHook = (artifactPath: string, sourceMappingUrl: string | undefined) => string | undefined | Promise<string | undefined>
372+
359373
export interface ModuleMetadata {
360374
// eslint-disable-next-line @typescript-eslint/no-explicit-any
361375
[key: string]: any;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"use strict";
2+
console.log("wow!");

packages/bundler-plugin-core/test/fixtures/resolve-source-maps/adjacent-sourcemap/index.js.map

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"use strict";
2+
console.log("wow!");

packages/bundler-plugin-core/test/fixtures/resolve-source-maps/separate-directory/sourcemaps/index.js.map

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import * as path from "path";
2+
import * as fs from "fs";
3+
import * as url from "url";
4+
import { determineSourceMapPathFromBundle } from "../../src/debug-id-upload";
5+
import { createLogger } from "../../src/logger";
6+
7+
const logger = createLogger({ prefix: "[resolve-source-maps-test]", silent: false, debug: false });
8+
const fixtureDir = path.resolve(__dirname, "../fixtures/resolve-source-maps");
9+
10+
const adjacentBundlePath = path.join(fixtureDir, "adjacent-sourcemap/index.js");
11+
const adjacentSourceMapPath = path.join(fixtureDir, "adjacent-sourcemap/index.js.map");
12+
const adjacentBundleContent = fs.readFileSync(adjacentBundlePath, "utf-8");
13+
14+
const separateBundlePath = path.join(fixtureDir, "separate-directory/bundles/index.js");
15+
const separateSourceMapPath = path.join(fixtureDir, "separate-directory/sourcemaps/index.js.map");
16+
const separateBundleContent = fs.readFileSync(separateBundlePath, "utf-8");
17+
18+
const sourceMapUrl = "https://sourcemaps.example.com/foo/index.js.map"
19+
20+
function srcMappingUrl(url: string): string {
21+
return `\n//# sourceMappingURL=${url}`
22+
}
23+
24+
describe("Resolve source maps", () => {
25+
it("should resolve source maps next to bundles", async () => {
26+
expect(
27+
await determineSourceMapPathFromBundle(
28+
adjacentBundlePath,
29+
adjacentBundleContent,
30+
logger,
31+
undefined
32+
)
33+
).toEqual(adjacentSourceMapPath);
34+
});
35+
36+
it("shouldn't resolve source maps in separate directories", async () => {
37+
expect(
38+
await determineSourceMapPathFromBundle(
39+
separateBundlePath,
40+
separateBundleContent,
41+
logger,
42+
undefined
43+
)
44+
).toBeUndefined();
45+
});
46+
47+
describe("sourceMappingURL resolution", () => {
48+
it("should resolve source maps when sourceMappingURL is a file URL", async () => {
49+
expect(
50+
await determineSourceMapPathFromBundle(
51+
separateBundlePath,
52+
separateBundleContent + srcMappingUrl(url.pathToFileURL(separateSourceMapPath).href),
53+
logger,
54+
undefined
55+
)
56+
).toEqual(separateSourceMapPath);
57+
});
58+
59+
it("shouldn't resolve source maps when sourceMappingURL is a non-file URL", async () => {
60+
expect(
61+
await determineSourceMapPathFromBundle(
62+
separateBundlePath,
63+
separateBundleContent + srcMappingUrl(sourceMapUrl),
64+
logger,
65+
undefined
66+
)
67+
).toBeUndefined();
68+
});
69+
70+
it("should resolve source maps when sourceMappingURL is an absolute path", async () => {
71+
expect(
72+
await determineSourceMapPathFromBundle(
73+
separateBundlePath,
74+
separateBundleContent + srcMappingUrl(separateSourceMapPath),
75+
logger,
76+
undefined
77+
)
78+
).toEqual(separateSourceMapPath);
79+
});
80+
81+
it("should resolve source maps when sourceMappingURL is a relative path", async () => {
82+
expect(
83+
await determineSourceMapPathFromBundle(
84+
separateBundlePath,
85+
separateBundleContent + srcMappingUrl(path.relative(path.dirname(separateBundlePath), separateSourceMapPath)),
86+
logger,
87+
undefined
88+
)
89+
).toEqual(separateSourceMapPath);
90+
});
91+
});
92+
93+
describe("resolveSourceMap hook", () => {
94+
it("should resolve source maps when a resolveSourceMap hook is provided", async () => {
95+
expect(
96+
await determineSourceMapPathFromBundle(
97+
separateBundlePath,
98+
separateBundleContent + srcMappingUrl(sourceMapUrl),
99+
logger,
100+
() => separateSourceMapPath
101+
)
102+
).toEqual(separateSourceMapPath);
103+
});
104+
105+
it("should pass the correct values to the resolveSourceMap hook", async () => {
106+
const hook = jest.fn(() => separateSourceMapPath)
107+
expect(
108+
await determineSourceMapPathFromBundle(
109+
separateBundlePath,
110+
separateBundleContent + srcMappingUrl(sourceMapUrl),
111+
logger,
112+
hook
113+
)
114+
).toEqual(separateSourceMapPath);
115+
expect(hook.mock.calls[0]).toEqual([separateBundlePath, sourceMapUrl])
116+
});
117+
118+
it("should pass the correct values to the resolveSourceMap hook when no sourceMappingURL is present", async () => {
119+
const hook = jest.fn(() => separateSourceMapPath)
120+
expect(
121+
await determineSourceMapPathFromBundle(
122+
separateBundlePath,
123+
separateBundleContent,
124+
logger,
125+
hook
126+
)
127+
).toEqual(separateSourceMapPath);
128+
expect(hook.mock.calls[0]).toEqual([separateBundlePath, undefined])
129+
});
130+
131+
it("should prefer resolveSourceMap result over heuristic results", async () => {
132+
expect(
133+
await determineSourceMapPathFromBundle(
134+
adjacentBundlePath,
135+
adjacentBundleContent,
136+
logger,
137+
() => separateSourceMapPath
138+
)
139+
).toEqual(separateSourceMapPath);
140+
});
141+
142+
it("should fall back when the resolveSourceMap hook returns undefined", async () => {
143+
expect(
144+
await determineSourceMapPathFromBundle(
145+
adjacentBundlePath,
146+
adjacentBundleContent,
147+
logger,
148+
() => undefined
149+
)
150+
).toEqual(adjacentSourceMapPath);
151+
});
152+
153+
it("should fall back when the resolveSourceMap hook returns a non-existent path", async () => {
154+
expect(
155+
await determineSourceMapPathFromBundle(
156+
adjacentBundlePath,
157+
adjacentBundleContent,
158+
logger,
159+
() => path.join(fixtureDir, "non-existent.js.map")
160+
)
161+
).toEqual(adjacentSourceMapPath);
162+
});
163+
});
164+
});

0 commit comments

Comments
 (0)