diff --git a/packages/nextjs/src/config/loaders/wrappingLoader.ts b/packages/nextjs/src/config/loaders/wrappingLoader.ts index 3125102e9656..4bdf841cef2c 100644 --- a/packages/nextjs/src/config/loaders/wrappingLoader.ts +++ b/packages/nextjs/src/config/loaders/wrappingLoader.ts @@ -43,6 +43,7 @@ export type WrappingLoaderOptions = { wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler'; vercelCronsConfig?: VercelCronsConfig; nextjsRequestAsyncStorageModulePath?: string; + isDev?: boolean; }; /** @@ -66,6 +67,7 @@ export default function wrappingLoader( wrappingTargetKind, vercelCronsConfig, nextjsRequestAsyncStorageModulePath, + isDev, } = 'getOptions' in this ? this.getOptions() : this.query; this.async(); @@ -220,7 +222,7 @@ export default function wrappingLoader( // Run the proxy module code through Rollup, in order to split the `export * from ''` out into // individual exports (which nextjs seems to require). - wrapUserCode(templateCode, userCode, userModuleSourceMap) + wrapUserCode(templateCode, userCode, userModuleSourceMap, isDev, this.resourcePath) .then(({ code: wrappedCode, map: wrappedCodeSourceMap }) => { this.callback(null, wrappedCode, wrappedCodeSourceMap); }) @@ -245,6 +247,9 @@ export default function wrappingLoader( * * @param wrapperCode The wrapper module code * @param userModuleCode The user module code + * @param userModuleSourceMap The source map for the user module + * @param isDev Whether we're in development mode (affects sourcemap generation) + * @param userModulePath The absolute path to the user's original module (for sourcemap accuracy) * @returns The wrapped user code and a source map that describes the transformations done by this function */ async function wrapUserCode( @@ -252,6 +257,8 @@ async function wrapUserCode( userModuleCode: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any userModuleSourceMap: any, + isDev?: boolean, + userModulePath?: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any ): Promise<{ code: string; map?: any }> { const wrap = (withDefaultExport: boolean): Promise => @@ -267,21 +274,48 @@ async function wrapUserCode( resolveId: id => { if (id === SENTRY_WRAPPER_MODULE_NAME || id === WRAPPING_TARGET_MODULE_NAME) { return id; - } else { - return null; } + + return null; }, load(id) { if (id === SENTRY_WRAPPER_MODULE_NAME) { return withDefaultExport ? wrapperCode : wrapperCode.replace('export { default } from', 'export {} from'); - } else if (id === WRAPPING_TARGET_MODULE_NAME) { + } + + if (id !== WRAPPING_TARGET_MODULE_NAME) { + return null; + } + + // In prod/build, we should not interfere with sourcemaps + if (!isDev || !userModulePath) { + return { code: userModuleCode, map: userModuleSourceMap }; + } + + // In dev mode, we need to adjust the sourcemap to use absolute paths for the user's file. + // This ensures debugger breakpoints correctly map back to the original file. + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const userSources: string[] = userModuleSourceMap?.sources; + if (Array.isArray(userSources)) { return { code: userModuleCode, - map: userModuleSourceMap, // give rollup access to original user module source map + map: { + ...userModuleSourceMap, + sources: userSources.map((source: string, index: number) => (index === 0 ? userModulePath : source)), + }, }; - } else { - return null; } + + // If no sourcemap exists, create a simple identity mapping with the absolute path + return { + code: userModuleCode, + map: { + version: 3, + sources: [userModulePath], + sourcesContent: [userModuleCode], + mappings: '', + }, + }; }, }, @@ -352,7 +386,22 @@ async function wrapUserCode( const finalBundle = await rollupBuild.generate({ format: 'esm', - sourcemap: 'hidden', // put source map data in the bundle but don't generate a source map comment in the output + // In dev mode, use inline sourcemaps so debuggers can map breakpoints back to original source. + // In production, use hidden sourcemaps (no sourceMappingURL comment) to avoid exposing internals. + sourcemap: isDev ? 'inline' : 'hidden', + // In dev mode, preserve absolute paths in sourcemaps so debuggers can correctly resolve breakpoints. + // By default, Rollup converts absolute paths to relative paths, which breaks debugging. + // We only do this in dev mode to avoid interfering with Sentry's sourcemap upload in production. + sourcemapPathTransform: isDev + ? relativeSourcePath => { + // If we have userModulePath and this relative path matches the end of it, use the absolute path + if (userModulePath?.endsWith(relativeSourcePath)) { + return userModulePath; + } + // Keep other paths (like sentry-wrapper-module) as-is + return relativeSourcePath; + } + : undefined, }); // The module at index 0 is always the entrypoint, which in this case is the proxy module. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 7ae5b6859330..497f170725c8 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -150,6 +150,7 @@ export function constructWebpackConfigFunction({ projectDir, rawNewConfig.resolve?.modules, ), + isDev, }; const normalizeLoaderResourcePath = (resourcePath: string): string => { diff --git a/packages/nextjs/test/config/wrappingLoader.test.ts b/packages/nextjs/test/config/wrappingLoader.test.ts index ab33450790bb..97e2b016301e 100644 --- a/packages/nextjs/test/config/wrappingLoader.test.ts +++ b/packages/nextjs/test/config/wrappingLoader.test.ts @@ -249,4 +249,90 @@ describe('wrappingLoader', () => { expect(wrappedCode).toMatch(/const proxy = userProvidedProxy \? wrappedHandler : undefined/); }); }); + + describe('sourcemap handling', () => { + it('should include inline sourcemap in dev mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: true, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In dev mode, should have inline sourcemap for debugger support + expect(wrappedCode).toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + + it('should not include inline sourcemap in production mode', async () => { + const callback = vi.fn(); + + const userCode = ` + export function middleware(request) { + return new Response('ok'); + } + `; + const userCodeSourceMap = undefined; + + const loaderPromise = new Promise(resolve => { + const loaderThis = { + ...defaultLoaderThis, + resourcePath: '/my/src/middleware.ts', + callback: callback.mockImplementation(() => { + resolve(); + }), + getOptions() { + return { + pagesDir: '/my/pages', + appDir: '/my/app', + pageExtensionRegex: DEFAULT_PAGE_EXTENSION_REGEX, + excludeServerRoutes: [], + wrappingTargetKind: 'middleware', + vercelCronsConfig: undefined, + nextjsRequestAsyncStorageModulePath: '/my/request-async-storage.js', + isDev: false, + }; + }, + } satisfies LoaderThis; + + wrappingLoader.call(loaderThis, userCode, userCodeSourceMap); + }); + + await loaderPromise; + + const wrappedCode = callback.mock.calls[0][1] as string; + + // In production mode, should NOT have inline sourcemap (hidden sourcemap instead) + expect(wrappedCode).not.toContain('//# sourceMappingURL=data:application/json;charset=utf-8;base64,'); + }); + }); });