Skip to content
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
65 changes: 57 additions & 8 deletions packages/nextjs/src/config/loaders/wrappingLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type WrappingLoaderOptions = {
wrappingTargetKind: 'page' | 'api-route' | 'middleware' | 'server-component' | 'route-handler';
vercelCronsConfig?: VercelCronsConfig;
nextjsRequestAsyncStorageModulePath?: string;
isDev?: boolean;
};

/**
Expand All @@ -66,6 +67,7 @@ export default function wrappingLoader(
wrappingTargetKind,
vercelCronsConfig,
nextjsRequestAsyncStorageModulePath,
isDev,
} = 'getOptions' in this ? this.getOptions() : this.query;

this.async();
Expand Down Expand Up @@ -220,7 +222,7 @@ export default function wrappingLoader(

// Run the proxy module code through Rollup, in order to split the `export * from '<wrapped file>'` 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);
})
Expand All @@ -245,13 +247,18 @@ 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(
wrapperCode: string,
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<RollupBuild> =>
Expand All @@ -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: '',
},
};
},
},

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function constructWebpackConfigFunction({
projectDir,
rawNewConfig.resolve?.modules,
),
isDev,
};

const normalizeLoaderResourcePath = (resourcePath: string): string => {
Expand Down
86 changes: 86 additions & 0 deletions packages/nextjs/test/config/wrappingLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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<WrappingLoaderOptions>;

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<void>(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<WrappingLoaderOptions>;

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,');
});
});
});
Loading