Skip to content

Commit 53777ce

Browse files
authored
fix(next): Ensure inline sourcemaps are generated for wrapped modules in Dev (#18640)
This fixes breakpoints for editors like VSCode/Cursor in server-side code. I have verified that breakpoints work in: - Server-side app router components - Server-side pages router functions and static params - API endpoints and server-side functions - middleware This only affects webpack, and doesn't change anything for prod builds. closes #17088
1 parent ba7f90a commit 53777ce

File tree

3 files changed

+144
-8
lines changed

3 files changed

+144
-8
lines changed

packages/nextjs/src/config/loaders/wrappingLoader.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type WrappingLoaderOptions = {
4343
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler';
4444
vercelCronsConfig?: VercelCronsConfig;
4545
nextjsRequestAsyncStorageModulePath?: string;
46+
isDev?: boolean;
4647
};
4748

4849
/**
@@ -66,6 +67,7 @@ export default function wrappingLoader(
6667
wrappingTargetKind,
6768
vercelCronsConfig,
6869
nextjsRequestAsyncStorageModulePath,
70+
isDev,
6971
} = 'getOptions' in this ? this.getOptions() : this.query;
7072

7173
this.async();
@@ -220,7 +222,7 @@ export default function wrappingLoader(
220222

221223
// Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` out into
222224
// individual exports (which nextjs seems to require).
223-
wrapUserCode(templateCode, userCode, userModuleSourceMap)
225+
wrapUserCode(templateCode, userCode, userModuleSourceMap, isDev, this.resourcePath)
224226
.then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => {
225227
this.callback(null, wrappedCode, wrappedCodeSourceMap);
226228
})
@@ -245,13 +247,18 @@ export default function wrappingLoader(
245247
*
246248
* @param wrapperCode The wrapper module code
247249
* @param userModuleCode The user module code
250+
* @param userModuleSourceMap The source map for the user module
251+
* @param isDev Whether we're in development mode (affects sourcemap generation)
252+
* @param userModulePath The absolute path to the user's original module (for sourcemap accuracy)
248253
* @returns The wrapped user code and a source map that describes the transformations done by this function
249254
*/
250255
async function wrapUserCode(
251256
wrapperCode: string,
252257
userModuleCode: string,
253258
// eslint-disable-next-line @typescript-eslint/no-explicit-any
254259
userModuleSourceMap: any,
260+
isDev?: boolean,
261+
userModulePath?: string,
255262
// eslint-disable-next-line @typescript-eslint/no-explicit-any
256263
): Promise<{ code: string; map?: any }> {
257264
const wrap = (withDefaultExport: boolean): Promise<RollupBuild> =>
@@ -267,21 +274,48 @@ async function wrapUserCode(
267274
resolveId: id => {
268275
if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) {
269276
return id;
270-
} else {
271-
return null;
272277
}
278+
279+
return null;
273280
},
274281
load(id) {
275282
if (id === SENTRY_WRAPPER_MODULE_NAME) {
276283
return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from');
277-
} else if (id === WRAPPING_TARGET_MODULE_NAME) {
284+
}
285+
286+
if (id !== WRAPPING_TARGET_MODULE_NAME) {
287+
return null;
288+
}
289+
290+
// In prod/build, we should not interfere with sourcemaps
291+
if (!isDev || !userModulePath) {
292+
return { code: userModuleCode, map: userModuleSourceMap };
293+
}
294+
295+
// In dev mode, we need to adjust the sourcemap to use absolute paths for the user's file.
296+
// This ensures debugger breakpoints correctly map back to the original file.
297+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
298+
const userSources: string[] = userModuleSourceMap?.sources;
299+
if (Array.isArray(userSources)) {
278300
return {
279301
code: userModuleCode,
280-
map: userModuleSourceMap, // give rollup access to original user module source map
302+
map: {
303+
...userModuleSourceMap,
304+
sources: userSources.map((source: string, index: number) => (index === 0 ? userModulePath : source)),
305+
},
281306
};
282-
} else {
283-
return null;
284307
}
308+
309+
// If no sourcemap exists, create a simple identity mapping with the absolute path
310+
return {
311+
code: userModuleCode,
312+
map: {
313+
version: 3,
314+
sources: [userModulePath],
315+
sourcesContent: [userModuleCode],
316+
mappings: '',
317+
},
318+
};
285319
},
286320
},
287321

@@ -352,7 +386,22 @@ async function wrapUserCode(
352386

353387
const finalBundle = await rollupBuild.generate({
354388
format: 'esm',
355-
sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map comment in the output
389+
// In dev mode, use inline sourcemaps so debuggers can map breakpoints back to original source.
390+
// In production, use hidden sourcemaps (no sourceMappingURL comment) to avoid exposing internals.
391+
sourcemap: isDev ? 'inline' : 'hidden',
392+
// In dev mode, preserve absolute paths in sourcemaps so debuggers can correctly resolve breakpoints.
393+
// By default, Rollup converts absolute paths to relative paths, which breaks debugging.
394+
// We only do this in dev mode to avoid interfering with Sentry's sourcemap upload in production.
395+
sourcemapPathTransform: isDev
396+
? relativeSourcePath => {
397+
// If we have userModulePath and this relative path matches the end of it, use the absolute path
398+
if (userModulePath?.endsWith(relativeSourcePath)) {
399+
return userModulePath;
400+
}
401+
// Keep other paths (like sentry-wrapper-module) as-is
402+
return relativeSourcePath;
403+
}
404+
: undefined,
356405
});
357406

358407
// The module at index 0 is always the entrypoint, which in this case is the proxy module.

packages/nextjs/src/config/webpack.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export function constructWebpackConfigFunction({
150150
projectDir,
151151
rawNewConfig.resolve?.modules,
152152
),
153+
isDev,
153154
};
154155

155156
const normalizeLoaderResourcePath = (resourcePath: string): string => {

packages/nextjs/test/config/wrappingLoader.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,90 @@ describe('wrappingLoader', () => {
249249
expect(wrappedCode).toMatch(/const proxy = userProvidedProxy \? wrappedHandler : undefined/);
250250
});
251251
});
252+
253+
describe('sourcemap handling', () => {
254+
it('should include inline sourcemap in dev mode', async () => {
255+
const callback = vi.fn();
256+
257+
const userCode = `
258+
export function middleware(request) {
259+
return new Response('ok');
260+
}
261+
`;
262+
const userCodeSourceMap = undefined;
263+
264+
const loaderPromise = new Promise<void>(resolve => {
265+
const loaderThis = {
266+
...defaultLoaderThis,
267+
resourcePath: '/my/src/middleware.ts',
268+
callback: callback.mockImplementation(() => {
269+
resolve();
270+
}),
271+
getOptions() {
272+
return {
273+
pagesDir: '/my/pages',
274+
appDir: '/my/app',
275+
pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX,
276+
excludeServerRoutes: [],
277+
wrappingTargetKind: 'middleware',
278+
vercelCronsConfig: undefined,
279+
nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js',
280+
isDev: true,
281+
};
282+
},
283+
} satisfies LoaderThis<WrappingLoaderOptions>;
284+
285+
wrappingLoader.call(loaderThis, userCode, userCodeSourceMap);
286+
});
287+
288+
await loaderPromise;
289+
290+
const wrappedCode = callback.mock.calls[0][1] as string;
291+
292+
// In dev mode, should have inline sourcemap for debugger support
293+
expect(wrappedCode).toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,');
294+
});
295+
296+
it('should not include inline sourcemap in production mode', async () => {
297+
const callback = vi.fn();
298+
299+
const userCode = `
300+
export function middleware(request) {
301+
return new Response('ok');
302+
}
303+
`;
304+
const userCodeSourceMap = undefined;
305+
306+
const loaderPromise = new Promise<void>(resolve => {
307+
const loaderThis = {
308+
...defaultLoaderThis,
309+
resourcePath: '/my/src/middleware.ts',
310+
callback: callback.mockImplementation(() => {
311+
resolve();
312+
}),
313+
getOptions() {
314+
return {
315+
pagesDir: '/my/pages',
316+
appDir: '/my/app',
317+
pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX,
318+
excludeServerRoutes: [],
319+
wrappingTargetKind: 'middleware',
320+
vercelCronsConfig: undefined,
321+
nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js',
322+
isDev: false,
323+
};
324+
},
325+
} satisfies LoaderThis<WrappingLoaderOptions>;
326+
327+
wrappingLoader.call(loaderThis, userCode, userCodeSourceMap);
328+
});
329+
330+
await loaderPromise;
331+
332+
const wrappedCode = callback.mock.calls[0][1] as string;
333+
334+
// In production mode, should NOT have inline sourcemap (hidden sourcemap instead)
335+
expect(wrappedCode).not.toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,');
336+
});
337+
});
252338
});

0 commit comments

Comments
 (0)