diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/middleware.ts new file mode 100644 index 000000000000..b2117419c10f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/middleware.ts @@ -0,0 +1,6 @@ +import { NextResponse } from 'next/server'; + +export function middleware() { + // Basic middleware to ensure that the build works with edge runtime + return NextResponse.next(); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts index 42b2e3727bd6..28cc91e9b879 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/cjs-api-endpoints.test.ts @@ -11,7 +11,8 @@ test('should create a transaction for a CJS pages router API endpoint', async ({ const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { return ( transactionEvent.transaction === 'GET /api/cjs-api-endpoint' && - transactionEvent.contexts?.trace?.op === 'http.server' + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction_info?.source === 'route' ); }); @@ -73,7 +74,8 @@ test('should not mess up require statements in CJS API endpoints', async ({ requ const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { return ( transactionEvent.transaction === 'GET /api/cjs-api-endpoint-with-require' && - transactionEvent.contexts?.trace?.op === 'http.server' + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction_info?.source === 'route' ); }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts index 3bcc1bbbea92..798ea3409089 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/server/wrapApiHandlerWithSentry.test.ts @@ -22,7 +22,11 @@ const cases = [ cases.forEach(({ name, url, transactionName }) => { test(`Should capture transactions for routes with various shapes (${name})`, async ({ request }) => { const transactionEventPromise = waitForTransaction('nextjs-13', transactionEvent => { - return transactionEvent.transaction === transactionName && transactionEvent.contexts?.trace?.op === 'http.server'; + return ( + transactionEvent.transaction === transactionName && + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction_info?.source === 'route' + ); }); request.get(url).catch(() => { diff --git a/packages/nextjs/.eslintrc.js b/packages/nextjs/.eslintrc.js index d255f74ce829..1f0ae547d4e0 100644 --- a/packages/nextjs/.eslintrc.js +++ b/packages/nextjs/.eslintrc.js @@ -21,5 +21,11 @@ module.exports = { 'import/no-extraneous-dependencies': 'off', }, }, + { + files: ['src/config/polyfills/perf_hooks.js'], + globals: { + globalThis: 'readonly', + }, + }, ], }; diff --git a/packages/nextjs/rollup.npm.config.mjs b/packages/nextjs/rollup.npm.config.mjs index 3582cf4574ef..89271a21e9d3 100644 --- a/packages/nextjs/rollup.npm.config.mjs +++ b/packages/nextjs/rollup.npm.config.mjs @@ -88,5 +88,20 @@ export default [ }, }), ), + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/config/polyfills/perf_hooks.js'], + + packageSpecificConfig: { + output: { + // Preserve the original file structure (i.e., so that everything is still relative to `src`) + entryFileNames: 'config/polyfills/[name].js', + + // make it so Rollup calms down about the fact that we're combining default and named exports + exports: 'named', + }, + }, + }), + ), ...makeOtelLoaders('./build', 'sentry-node'), ]; diff --git a/packages/nextjs/src/config/polyfills/perf_hooks.js b/packages/nextjs/src/config/polyfills/perf_hooks.js new file mode 100644 index 000000000000..1a0ce4a2af76 --- /dev/null +++ b/packages/nextjs/src/config/polyfills/perf_hooks.js @@ -0,0 +1,26 @@ +// Polyfill for Node.js perf_hooks module in edge runtime +// This mirrors the polyfill from packages/vercel-edge/rollup.npm.config.mjs +const __sentry__timeOrigin = Date.now(); + +// Ensure performance global is available +if (typeof globalThis !== 'undefined' && globalThis.performance === undefined) { + globalThis.performance = { + timeOrigin: __sentry__timeOrigin, + now: function () { + return Date.now() - __sentry__timeOrigin; + }, + }; +} + +// Export the performance object for perf_hooks compatibility +export const performance = globalThis.performance || { + timeOrigin: __sentry__timeOrigin, + now: function () { + return Date.now() - __sentry__timeOrigin; + }, +}; + +// Default export for CommonJS compatibility +export default { + performance, +}; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 1ca5eaa6bab0..55f080fb433e 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -581,6 +581,7 @@ export type BuildContext = { webpack: { version: string; DefinePlugin: new (values: Record) => WebpackPluginInstance; + ProvidePlugin: new (values: Record) => WebpackPluginInstance; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any defaultLoaders: any; // needed for type tests (test:types) diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index b336e1e2ee9b..8741efe81194 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -60,6 +60,8 @@ export function constructWebpackConfigFunction( const pageExtensions = userNextConfig.pageExtensions || ['tsx', 'ts', 'jsx', 'js']; const dotPrefixedPageExtensions = pageExtensions.map(ext => `.${ext}`); const pageExtensionRegex = pageExtensions.map(escapeStringForRegex).join('|'); + const nextVersion = nextJsVersion || getNextjsVersion(); + const { major } = parseSemver(nextVersion || ''); // We add `.ts` and `.js` back in because `pageExtensions` might not be relevant to the instrumentation file // e.g. user's setting `.mdx`. In that case we still want to default look up @@ -70,8 +72,6 @@ export function constructWebpackConfigFunction( warnAboutDeprecatedConfigFiles(projectDir, instrumentationFile, runtime); } if (runtime === 'server') { - const nextJsVersion = getNextjsVersion(); - const { major } = parseSemver(nextJsVersion || ''); // was added in v15 (https://github.com/vercel/next.js/pull/67539) if (major && major >= 15) { warnAboutMissingOnRequestErrorHandler(instrumentationFile); @@ -103,6 +103,11 @@ export function constructWebpackConfigFunction( addOtelWarningIgnoreRule(newConfig); + // Add edge runtime polyfills when building for edge in dev mode + if (major && major === 13 && runtime === 'edge' && isDev) { + addEdgeRuntimePolyfills(newConfig, buildContext); + } + let pagesDirPath: string | undefined; const maybePagesDirPath = path.join(projectDir, 'pages'); const maybeSrcPagesDirPath = path.join(projectDir, 'src', 'pages'); @@ -865,6 +870,24 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules) } } +function addEdgeRuntimePolyfills(newConfig: WebpackConfigObjectWithModuleRules, buildContext: BuildContext): void { + // Use ProvidePlugin to inject performance global only when accessed + newConfig.plugins = newConfig.plugins || []; + newConfig.plugins.push( + new buildContext.webpack.ProvidePlugin({ + performance: [path.resolve(__dirname, 'polyfills', 'perf_hooks.js'), 'performance'], + }), + ); + + // Add module resolution aliases for problematic Node.js modules in edge runtime + newConfig.resolve = newConfig.resolve || {}; + newConfig.resolve.alias = { + ...newConfig.resolve.alias, + // Redirect perf_hooks imports to a polyfilled version + perf_hooks: path.resolve(__dirname, 'polyfills', 'perf_hooks.js'), + }; +} + function _getModules(projectDir: string): Record { try { const packageJson = path.join(projectDir, 'package.json'); diff --git a/packages/nextjs/test/config/fixtures.ts b/packages/nextjs/test/config/fixtures.ts index a3c4feb0123b..5f5d4a2d3504 100644 --- a/packages/nextjs/test/config/fixtures.ts +++ b/packages/nextjs/test/config/fixtures.ts @@ -99,7 +99,13 @@ export function getBuildContext( distDir: '.next', ...materializedNextConfig, } as NextConfigObject, - webpack: { version: webpackVersion, DefinePlugin: class {} as any }, + webpack: { + version: webpackVersion, + DefinePlugin: class {} as any, + ProvidePlugin: class { + constructor(public definitions: Record) {} + } as any, + }, defaultLoaders: true, totalPages: 2, isServer: buildTarget === 'server' || buildTarget === 'edge', diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index 3a8e86b94e29..7371d35c859a 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -2,11 +2,13 @@ import '../mocks'; import * as core from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import * as util from '../../../src/config/util'; import * as getWebpackPluginOptionsModule from '../../../src/config/webpackPluginOptions'; import { CLIENT_SDK_CONFIG_FILE, clientBuildContext, clientWebpackConfig, + edgeBuildContext, exportedNextConfig, serverBuildContext, serverWebpackConfig, @@ -185,4 +187,123 @@ describe('constructWebpackConfigFunction()', () => { }); }); }); + + describe('edge runtime polyfills', () => { + it('adds polyfills only for edge runtime in dev mode on Next.js 13', async () => { + // Mock Next.js version 13 - polyfills should be added + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('13.0.0'); + + // Test edge runtime in dev mode with Next.js 13 - should add polyfills + const edgeDevBuildContext = { ...edgeBuildContext, dev: true }; + const edgeDevConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeDevBuildContext, + }); + + const edgeProvidePlugin = edgeDevConfig.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin'); + expect(edgeProvidePlugin).toBeDefined(); + expect(edgeDevConfig.resolve?.alias?.perf_hooks).toMatch(/perf_hooks\.js$/); + + vi.restoreAllMocks(); + }); + + it('does NOT add polyfills for edge runtime in prod mode even on Next.js 13', async () => { + // Mock Next.js version 13 - but prod mode should still not add polyfills + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('13.0.0'); + + // Test edge runtime in prod mode - should NOT add polyfills + const edgeProdBuildContext = { ...edgeBuildContext, dev: false }; + const edgeProdConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeProdBuildContext, + }); + + const edgeProdProvidePlugin = edgeProdConfig.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin'); + expect(edgeProdProvidePlugin).toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it('does NOT add polyfills for server runtime even on Next.js 13', async () => { + // Mock Next.js version 13 + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('13.0.0'); + + // Test server runtime in dev mode - should NOT add polyfills + const serverDevBuildContext = { ...serverBuildContext, dev: true }; + const serverDevConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: serverDevBuildContext, + }); + + const serverProvidePlugin = serverDevConfig.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin'); + expect(serverProvidePlugin).toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it('does NOT add polyfills for client runtime even on Next.js 13', async () => { + // Mock Next.js version 13 + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('13.0.0'); + + // Test client runtime in dev mode - should NOT add polyfills + const clientDevBuildContext = { ...clientBuildContext, dev: true }; + const clientDevConfig = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: clientWebpackConfig, + incomingWebpackBuildContext: clientDevBuildContext, + }); + + const clientProvidePlugin = clientDevConfig.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin'); + expect(clientProvidePlugin).toBeUndefined(); + + vi.restoreAllMocks(); + }); + + it('does NOT add polyfills for edge runtime in dev mode on Next.js versions other than 13', async () => { + const edgeDevBuildContext = { ...edgeBuildContext, dev: true }; + + // Test with Next.js 12 - should NOT add polyfills + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('12.3.0'); + const edgeConfigV12 = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeDevBuildContext, + }); + expect(edgeConfigV12.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin')).toBeUndefined(); + vi.restoreAllMocks(); + + // Test with Next.js 14 - should NOT add polyfills + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('14.0.0'); + const edgeConfigV14 = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeDevBuildContext, + }); + expect(edgeConfigV14.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin')).toBeUndefined(); + vi.restoreAllMocks(); + + // Test with Next.js 15 - should NOT add polyfills + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + const edgeConfigV15 = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeDevBuildContext, + }); + expect(edgeConfigV15.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin')).toBeUndefined(); + vi.restoreAllMocks(); + + // Test with undefined Next.js version - should NOT add polyfills + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + const edgeConfigUndefined = await materializeFinalWebpackConfig({ + exportedNextConfig, + incomingWebpackConfig: serverWebpackConfig, + incomingWebpackBuildContext: edgeDevBuildContext, + }); + expect(edgeConfigUndefined.plugins?.find(plugin => plugin.constructor.name === 'ProvidePlugin')).toBeUndefined(); + vi.restoreAllMocks(); + }); + }); });