Skip to content

Commit b33725e

Browse files
feat(core): Add hook to customize source map file resolution (#732)
* feat(core): Add hook to customize source map file resolution fixes #731 * feat(core): Address `resolveSourceMap` PR comments 1. Added better debug logging around the `resolveSourceMap` hook invocation 2. Expanded the `resolveSourceMap` doc comment to include more detail and an example use case 3. Added `resolveSourceMap` documentation to `generate-documentation-table.ts` 4. Removed the feature-specific playground test previously added * Fix formatting --------- Co-authored-by: Andrei Borza <[email protected]>
1 parent 7b097fc commit b33725e

File tree

9 files changed

+278
-46
lines changed

9 files changed

+278
-46
lines changed

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

Lines changed: 2 additions & 1 deletion
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

Lines changed: 53 additions & 43 deletions
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,72 @@ 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;
124+
125+
const searchLocations: string[] = [];
126+
127+
if (resolveSourceMapHook) {
128+
logger.debug(
129+
`Calling sourcemaps.resolveSourceMap(${JSON.stringify(bundlePath)}, ${JSON.stringify(
130+
sourceMappingUrl
131+
)})`
132+
);
133+
const customPath = await resolveSourceMapHook(bundlePath, sourceMappingUrl);
134+
logger.debug(`resolveSourceMap hook returned: ${JSON.stringify(customPath)}`);
135+
136+
if (customPath) {
137+
searchLocations.push(customPath);
138+
}
139+
}
126140

127-
let isUrl;
128-
let isSupportedUrl;
141+
// 1. try to find source map at `sourceMappingURL` location
142+
if (sourceMappingUrl) {
143+
let parsedUrl: URL | undefined;
129144
try {
130-
const url = new URL(sourceMappingUrl);
131-
isUrl = true;
132-
isSupportedUrl = url.protocol === "file:";
145+
parsedUrl = new URL(sourceMappingUrl);
133146
} catch {
134-
isUrl = false;
135-
isSupportedUrl = false;
147+
// noop
136148
}
137149

138-
let absoluteSourceMapPath;
139-
if (isSupportedUrl) {
140-
absoluteSourceMapPath = sourceMappingUrl;
141-
} else if (isUrl) {
142-
// noop
150+
if (parsedUrl && parsedUrl.protocol === "file:") {
151+
searchLocations.push(url.fileURLToPath(sourceMappingUrl));
152+
} else if (parsedUrl) {
153+
// noop, non-file urls don't translate to a local sourcemap file
143154
} else if (path.isAbsolute(sourceMappingUrl)) {
144-
absoluteSourceMapPath = sourceMappingUrl;
155+
searchLocations.push(path.normalize(sourceMappingUrl));
145156
} 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-
}
157+
searchLocations.push(path.normalize(path.join(path.dirname(bundlePath), sourceMappingUrl)));
157158
}
158159
}
159160

160161
// 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
162+
searchLocations.push(bundlePath + ".map");
163+
164+
for (const searchLocation of searchLocations) {
165+
try {
166+
await util.promisify(fs.access)(searchLocation);
167+
logger.debug(`Source map found for bundle \`${bundlePath}\`: \`${searchLocation}\``);
168+
return searchLocation;
169+
} catch (e) {
170+
// noop
171+
}
167172
}
168173

169174
// This is just a debug message because it can be quite spammy for some frameworks
170175
logger.debug(
171-
`Could not determine source map path for bundle: ${bundlePath} - Did you turn on source map generation in your bundler?`
176+
`Could not determine source map path for bundle \`${bundlePath}\`` +
177+
` with sourceMappingURL=${
178+
sourceMappingUrl === undefined ? "undefined" : `\`${sourceMappingUrl}\``
179+
}` +
180+
` - Did you turn on source map generation in your bundler?` +
181+
` (Attempted paths: ${searchLocations.map((e) => `\`${e}\``).join(", ")})`
172182
);
173183
return undefined;
174184
}

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,28 @@ 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 (or a promise that resolves to one) indicating
134+
* where to find the artifact's corresponding source map file. If no path is returned or the returned path doesn't
135+
* exist, the standard source map resolution process will be used.
136+
*
137+
* The standard process first tries to resolve based on the `//# sourceMappingURL=` value (it supports `file://`
138+
* urls and absolute/relative paths). If that path doesn't exist, it then looks for a file named
139+
* `${artifactName}.map` in the same directory as the artifact.
140+
*
141+
* Note: This is mostly helpful for complex builds with custom source map generation. For example, if you put source
142+
* maps into a separate directory and rewrite the `//# sourceMappingURL=` comment to something other than a relative
143+
* directory, sentry will be unable to locate the source maps for a given build artifact. This hook allows you to
144+
* implement the resolution process yourself.
145+
*
146+
* Use the `debug` option to print information about source map resolution.
147+
*/
148+
resolveSourceMap?: ResolveSourceMapHook;
129149

130150
/**
131151
* 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 {
356376
};
357377
}
358378

379+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
380+
export type RewriteSourcesHook = (source: string, map: any) => string;
381+
382+
export type ResolveSourceMapHook = (
383+
artifactPath: string,
384+
sourceMappingUrl: string | undefined
385+
) => string | undefined | Promise<string | undefined>;
386+
359387
export interface ModuleMetadata {
360388
// eslint-disable-next-line @typescript-eslint/no-explicit-any
361389
[key: string]: any;
Lines changed: 2 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 0 deletions
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

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 +
86+
srcMappingUrl(path.relative(path.dirname(separateBundlePath), separateSourceMapPath)),
87+
logger,
88+
undefined
89+
)
90+
).toEqual(separateSourceMapPath);
91+
});
92+
});
93+
94+
describe("resolveSourceMap hook", () => {
95+
it("should resolve source maps when a resolveSourceMap hook is provided", async () => {
96+
expect(
97+
await determineSourceMapPathFromBundle(
98+
separateBundlePath,
99+
separateBundleContent + srcMappingUrl(sourceMapUrl),
100+
logger,
101+
() => separateSourceMapPath
102+
)
103+
).toEqual(separateSourceMapPath);
104+
});
105+
106+
it("should pass the correct values to the resolveSourceMap hook", async () => {
107+
const hook = jest.fn(() => separateSourceMapPath);
108+
expect(
109+
await determineSourceMapPathFromBundle(
110+
separateBundlePath,
111+
separateBundleContent + srcMappingUrl(sourceMapUrl),
112+
logger,
113+
hook
114+
)
115+
).toEqual(separateSourceMapPath);
116+
expect(hook.mock.calls[0]).toEqual([separateBundlePath, sourceMapUrl]);
117+
});
118+
119+
it("should pass the correct values to the resolveSourceMap hook when no sourceMappingURL is present", async () => {
120+
const hook = jest.fn(() => separateSourceMapPath);
121+
expect(
122+
await determineSourceMapPathFromBundle(
123+
separateBundlePath,
124+
separateBundleContent,
125+
logger,
126+
hook
127+
)
128+
).toEqual(separateSourceMapPath);
129+
expect(hook.mock.calls[0]).toEqual([separateBundlePath, undefined]);
130+
});
131+
132+
it("should prefer resolveSourceMap result over heuristic results", async () => {
133+
expect(
134+
await determineSourceMapPathFromBundle(
135+
adjacentBundlePath,
136+
adjacentBundleContent,
137+
logger,
138+
() => separateSourceMapPath
139+
)
140+
).toEqual(separateSourceMapPath);
141+
});
142+
143+
it("should fall back when the resolveSourceMap hook returns undefined", async () => {
144+
expect(
145+
await determineSourceMapPathFromBundle(
146+
adjacentBundlePath,
147+
adjacentBundleContent,
148+
logger,
149+
() => undefined
150+
)
151+
).toEqual(adjacentSourceMapPath);
152+
});
153+
154+
it("should fall back when the resolveSourceMap hook returns a non-existent path", async () => {
155+
expect(
156+
await determineSourceMapPathFromBundle(
157+
adjacentBundlePath,
158+
adjacentBundleContent,
159+
logger,
160+
() => path.join(fixtureDir, "non-existent.js.map")
161+
)
162+
).toEqual(adjacentSourceMapPath);
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)