diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts new file mode 100644 index 000000000000..4e5c404ec56c --- /dev/null +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -0,0 +1,78 @@ +import { logger } from '@sentry/core'; +import * as chalk from 'chalk'; +import * as path from 'path'; +import type { RouteManifest } from '../manifest/types'; +import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShortcut } from '../types'; + +/** + * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. + * + * @param userNextConfig - The Next.js config object. + * @param turbopackOptions - The Turbopack options object. + * @returns The Turbopack config object. + */ +export function constructTurbopackConfig({ + userNextConfig, + routeManifest, +}: { + userNextConfig: NextConfigObject; + routeManifest?: RouteManifest; +}): TurbopackOptions { + const newConfig: TurbopackOptions = { + ...userNextConfig.turbopack, + }; + + if (routeManifest) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { + matcher: '**/instrumentation-client.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: { + _sentryRouteManifest: JSON.stringify(routeManifest), + }, + }, + }, + ], + }, + }); + } + + return newConfig; +} + +/** + * Safely add a Turbopack rule to the existing rules. + * + * @param existingRules - The existing rules. + * @param matcher - The matcher for the rule. + * @param rule - The rule to add. + * @returns The updated rules object. + */ +export function safelyAddTurbopackRule( + existingRules: TurbopackOptions['rules'], + { matcher, rule }: { matcher: string; rule: TurbopackRuleConfigItemOrShortcut }, +): TurbopackOptions['rules'] { + if (!existingRules) { + return { + [matcher]: rule, + }; + } + + // If the rule already exists, we don't want to mess with it. + if (existingRules[matcher]) { + logger.info( + `${chalk.cyan( + 'info', + )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, + ); + return existingRules; + } + + return { + ...existingRules, + [matcher]: rule, + }; +} diff --git a/packages/nextjs/src/config/turbopack/index.ts b/packages/nextjs/src/config/turbopack/index.ts new file mode 100644 index 000000000000..06fc8bf09293 --- /dev/null +++ b/packages/nextjs/src/config/turbopack/index.ts @@ -0,0 +1 @@ +export * from './constructTurbopackConfig'; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index c635aa88c21a..81ee686ce205 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -51,6 +51,7 @@ export type NextConfigObject = { // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 + turbopack?: TurbopackOptions; }; export type SentryBuildOptions = { @@ -607,3 +608,38 @@ export type EnhancedGlobal = typeof GLOBAL_OBJ & { SENTRY_RELEASE?: { id: string }; SENTRY_RELEASES?: { [key: string]: { id: string } }; }; + +type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; + +type TurbopackLoaderItem = + | string + | { + loader: string; + // At the moment, Turbopack options must be JSON-serializable, so restrict values. + options: Record; + }; + +type TurbopackRuleCondition = { + path: string | RegExp; +}; + +export type TurbopackRuleConfigItemOrShortcut = TurbopackLoaderItem[] | TurbopackRuleConfigItem; + +type TurbopackRuleConfigItemOptions = { + loaders: TurbopackLoaderItem[]; + as?: string; +}; + +type TurbopackRuleConfigItem = + | TurbopackRuleConfigItemOptions + | { [condition: string]: TurbopackRuleConfigItem } + | false; + +export interface TurbopackOptions { + resolveAlias?: Record>; + resolveExtensions?: string[]; + rules?: Record; + conditions?: Record; + moduleIds?: 'named' | 'deterministic'; + root?: string; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 4e231a3227d6..5ba768f3d0b7 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { createRouteManifest } from './manifest/createRouteManifest'; import type { RouteManifest } from './manifest/types'; +import { constructTurbopackConfig } from './turbopack'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -251,6 +252,8 @@ function getFinalConfigObject( } let nextMajor: number | undefined; + const isTurbopack = process.env.TURBOPACK; + let isTurbopackSupported = false; if (nextJsVersion) { const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); nextMajor = major; @@ -262,6 +265,7 @@ function getFinalConfigObject( (major === 15 && minor > 3) || (major === 15 && minor === 3 && patch === 0 && prerelease === undefined) || (major === 15 && minor === 3 && patch > 0)); + isTurbopackSupported = isSupportedVersion; const isSupportedCanary = major !== undefined && minor !== undefined && @@ -274,7 +278,7 @@ function getFinalConfigObject( parseInt(prerelease.split('.')[1] || '', 10) >= 28; const supportsClientInstrumentation = isSupportedCanary || isSupportedVersion; - if (!supportsClientInstrumentation && process.env.TURBOPACK) { + if (!supportsClientInstrumentation && isTurbopack) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn( @@ -307,12 +311,17 @@ function getFinalConfigObject( ], }, }), - webpack: constructWebpackConfigFunction( - incomingUserNextConfigObject, - userSentryOptions, - releaseName, - routeManifest, - ), + webpack: !isTurbopack + ? constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest) + : undefined, + ...(isTurbopackSupported && isTurbopack + ? { + turbopack: constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + routeManifest, + }), + } + : {}), }; } diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts new file mode 100644 index 000000000000..813d3c0f8894 --- /dev/null +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -0,0 +1,443 @@ +import * as path from 'path'; +import { describe, expect, it, vi } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { + constructTurbopackConfig, + safelyAddTurbopackRule, +} from '../../../src/config/turbopack/constructTurbopackConfig'; +import type { NextConfigObject } from '../../../src/config/types'; + +// Mock path.resolve to return a predictable loader path +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + }; +}); + +describe('constructTurbopackConfig', () => { + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [{ path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }], + staticRoutes: [ + { path: '/users', regex: '/users' }, + { path: '/api/health', regex: '/api/health' }, + ], + }; + + describe('without existing turbopack config', () => { + it('should create a basic turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + }); + + expect(result).toEqual({}); + }); + + it('should create turbopack config with instrumentation rule when manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should call path.resolve with correct arguments', () => { + const userNextConfig: NextConfigObject = {}; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + + constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + }); + + it('should handle Windows-style paths correctly', () => { + // Mock path.resolve to return a Windows-style path + const windowsLoaderPath = 'C:\\my\\project\\dist\\config\\loaders\\valueInjectionLoader.js'; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockReturnValue(windowsLoaderPath); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result.rules).toBeDefined(); + expect(result.rules!['**/instrumentation-client.*']).toBeDefined(); + + const rule = result.rules!['**/instrumentation-client.*']; + expect(rule).toHaveProperty('loaders'); + + const ruleWithLoaders = rule as { loaders: Array<{ loader: string; options: any }> }; + expect(ruleWithLoaders.loaders).toBeDefined(); + expect(ruleWithLoaders.loaders).toHaveLength(1); + + const loader = ruleWithLoaders.loaders[0]!; + expect(loader).toHaveProperty('loader'); + expect(loader).toHaveProperty('options'); + expect(loader.options).toHaveProperty('values'); + expect(loader.options.values).toHaveProperty('_sentryRouteManifest'); + expect(loader.loader).toBe(windowsLoaderPath); + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + + // Restore the original mock behavior + pathResolveSpy.mockReturnValue('/mocked/path/to/valueInjectionLoader.js'); + }); + }); + + describe('with existing turbopack config', () => { + it('should preserve existing turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }); + }); + + it('should merge manifest rule with existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should not override existing instrumentation rule', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { custom: 'value' }, + }, + ], + }; + + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation-client.*': existingRule, + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': existingRule, + }, + }); + }); + }); + + describe('with edge cases', () => { + it('should handle empty route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: emptyManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(emptyManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle complex route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const complexManifest: RouteManifest = { + dynamicRoutes: [ + { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, + { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, + ], + staticRoutes: [], + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: complexManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(complexManifest), + }, + }, + }, + ], + }, + }, + }); + }); + }); +}); + +describe('safelyAddTurbopackRule', () => { + const mockRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + }; + + describe('with undefined/null existingRules', () => { + it('should create new rules object when existingRules is undefined', () => { + const result = safelyAddTurbopackRule(undefined, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + + it('should create new rules object when existingRules is null', () => { + const result = safelyAddTurbopackRule(null as any, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with existing rules', () => { + it('should add new rule to existing rules object', () => { + const existingRules = { + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + '*.test.js': mockRule, + }); + }); + + it('should not override existing rule with same matcher', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { existing: 'option' }, + }, + ], + }; + + const existingRules = { + '*.css': ['css-loader'], + '*.test.js': existingRule, + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': existingRule, + }); + }); + + it('should handle empty rules object', () => { + const existingRules = {}; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with different rule formats', () => { + it('should handle string array rule (shortcut format)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: ['jest-loader', 'babel-loader'], + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': ['jest-loader', 'babel-loader'], + }); + }); + + it('should handle complex rule with conditions', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const complexRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + as: 'javascript/auto', + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: complexRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': complexRule, + }); + }); + + it('should handle disabled rule (false)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: false, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': false, + }); + }); + }); + + describe('immutable', () => { + it('should not mutate original existingRules object', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': mockRule, + }); + }); + }); +});