diff --git a/packages/nextjs/src/config/getBuildPluginOptions.ts b/packages/nextjs/src/config/getBuildPluginOptions.ts index e616c49263af..3d7cc7711762 100644 --- a/packages/nextjs/src/config/getBuildPluginOptions.ts +++ b/packages/nextjs/src/config/getBuildPluginOptions.ts @@ -1,4 +1,5 @@ import type { Options as SentryBuildPluginOptions } from '@sentry/bundler-plugin-core'; +import * as fs from 'fs'; import * as path from 'path'; import type { SentryBuildOptions } from './types'; @@ -24,6 +25,10 @@ const FILE_PATTERNS = { GLOB: 'static/chunks/**', PATH: 'static/chunks', }, + STATIC_IMMUTABLE_CHUNKS: { + GLOB: 'static/immutable/chunks/**', + PATH: 'static/immutable/chunks', + }, STATIC_CHUNKS_PAGES: { GLOB: 'static/chunks/pages/**', PATH: 'static/chunks/pages', @@ -95,6 +100,13 @@ function createSourcemapUploadAssetPatterns( // In turbopack we always want to upload the full static chunks directory // as the build output is not split into pages|app chunks assets.push(path.posix.join(normalizedDistPath, getStaticChunksPattern({ useDirectoryPath: true }))); + + // With `experimental.supportsImmutableAssets` (auto-enabled on Vercel preview), Turbopack emits + // client chunks to `static/immutable/chunks` instead of `static/chunks` + const immutableChunksPath = path.posix.join(normalizedDistPath, FILE_PATTERNS.STATIC_IMMUTABLE_CHUNKS.PATH); + if (fs.existsSync(immutableChunksPath)) { + assets.push(immutableChunksPath); + } } else { // Webpack client builds in after-production-compile mode if (widenClientFileUpload) { diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index 25e4d1a6a485..d5d0b98efbad 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -68,6 +68,21 @@ export async function handleRunAfterProductionCompile( // We don't want to prepare the artifacts because we injected debug ids manually before prepareArtifacts: false, }); + + const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; + + // Deletion covers all of `static/`, so if Next.js emits chunks outside the upload + // patterns, their maps would be deleted without ever having been uploaded — warn instead + // of failing silently. + if ( + deleteSourcemapsAfterUpload && + buildTool === 'turbopack' && + !sentryBuildOptions.sourcemaps?.assets && + options.sourcemaps?.disable !== true + ) { + await warnAboutUncoveredSourcemaps(path.join(distDir, 'static'), options.sourcemaps?.assets); + } + await sentryBuildPluginManager.deleteArtifacts(); // After deleting source map files in turbopack builds, strip any remaining @@ -77,7 +92,6 @@ export async function handleRunAfterProductionCompile( // features like Clerk auth. // When SRI is enabled, we must skip this step because Next.js computes integrity // hashes during the build — modifying files afterward invalidates those hashes. - const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; if (deleteSourcemapsAfterUpload && buildTool === 'turbopack' && !sriEnabled) { await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug); } @@ -90,6 +104,37 @@ export async function handleRunAfterProductionCompile( } } +async function warnAboutUncoveredSourcemaps( + staticDir: string, + uploadAssets: string | string[] | undefined, +): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(staticDir, { recursive: true }).then(e => e.map(f => String(f))); + } catch { + return; + } + + const assetPaths = (Array.isArray(uploadAssets) ? uploadAssets : uploadAssets ? [uploadAssets] : []).map(p => + p.replace(/\\/g, '/'), + ); + const staticDirPosix = staticDir.replace(/\\/g, '/'); + + const uncovered = entries + .filter(entry => /\.(js|mjs|cjs)\.map$/.test(entry)) + .map(entry => path.posix.join(staticDirPosix, entry.replace(/\\/g, '/'))) + .filter(mapPath => !assetPaths.some(assetPath => mapPath === assetPath || mapPath.startsWith(`${assetPath}/`))); + + if (uncovered.length > 0) { + // eslint-disable-next-line no-console + console.warn( + `[@sentry/nextjs] Found ${uncovered.length} source map file(s) under "${staticDir}" (e.g. "${ + uncovered[0] + }") that are not covered by the source map upload patterns and will be deleted without having been uploaded to Sentry. Stack traces for the affected files will not be symbolicated. Set the \`sourcemaps.assets\` option in \`withSentryConfig\` to cover these files.`, + ); + } +} + const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\n]+\*\/$/; diff --git a/packages/nextjs/test/config/getBuildPluginOptions.test.ts b/packages/nextjs/test/config/getBuildPluginOptions.test.ts index 0b8a729e5942..b0934f5ff4c9 100644 --- a/packages/nextjs/test/config/getBuildPluginOptions.test.ts +++ b/packages/nextjs/test/config/getBuildPluginOptions.test.ts @@ -1,7 +1,13 @@ +import * as fs from 'fs'; import { describe, expect, it, vi } from 'vitest'; import { getBuildPluginOptions } from '../../src/config/getBuildPluginOptions'; import type { SentryBuildOptions } from '../../src/config/types'; +vi.mock('fs', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, existsSync: vi.fn(actual.existsSync) }; +}); + describe('getBuildPluginOptions', () => { const mockReleaseName = 'test-release-1.0.0'; const mockDistDirAbsPath = '/path/to/.next'; @@ -295,6 +301,36 @@ describe('getBuildPluginOptions', () => { ]); expect(result.reactComponentAnnotation).toBeUndefined(); }); + + it('includes the immutable chunks directory in after-production-compile-turbopack assets when it exists', () => { + vi.mocked(fs.existsSync).mockImplementationOnce(p => p === '/path/to/.next/static/immutable/chunks'); + + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-turbopack', + }); + + expect(result.sourcemaps?.assets).toEqual([ + '/path/to/.next/server', + '/path/to/.next/static/chunks', + '/path/to/.next/static/immutable/chunks', + ]); + }); + + it('does not include the immutable chunks directory in after-production-compile-turbopack assets when it does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValueOnce(false); + + const result = getBuildPluginOptions({ + sentryBuildOptions: baseSentryOptions, + releaseName: mockReleaseName, + distDirAbsPath: mockDistDirAbsPath, + buildTool: 'after-production-compile-turbopack', + }); + + expect(result.sourcemaps?.assets).toEqual(['/path/to/.next/server', '/path/to/.next/static/chunks']); + }); }); describe('useRunAfterProductionCompileHook functionality', () => { diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 7c21c26d2ef6..dd3f23343878 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -2,7 +2,9 @@ import { loadModule } from '@sentry/core'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import type { MockInstance } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getBuildPluginOptions } from '../../src/config/getBuildPluginOptions'; import { handleRunAfterProductionCompile, stripSourceMappingURLComments, @@ -312,7 +314,7 @@ describe('handleRunAfterProductionCompile', () => { }); describe('sourceMappingURL stripping', () => { - let readdirSpy: ReturnType; + let readdirSpy: MockInstance; beforeEach(() => { // Spy on fs.promises.readdir to detect whether stripping was attempted. @@ -399,7 +401,8 @@ describe('handleRunAfterProductionCompile', () => { }, { ...mockSentryBuildOptions, - sourcemaps: { deleteSourcemapsAfterUpload: true }, + // user-provided assets skip the uncovered-source-map check, which also reads the static dir + sourcemaps: { deleteSourcemapsAfterUpload: true, assets: ['.next/static'] }, }, ); @@ -427,6 +430,170 @@ describe('handleRunAfterProductionCompile', () => { }); }); + describe('uncovered source map warning', () => { + let readdirSpy: MockInstance; + let warnSpy: MockInstance; + + const uncoveredWarningMatcher = expect.stringContaining('not covered by the source map upload patterns'); + + beforeEach(() => { + readdirSpy = vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT')); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.mocked(getBuildPluginOptions).mockReturnValueOnce({ + org: 'test-org', + project: 'test-project', + sourcemaps: { + assets: ['/path/to/.next/server', '/path/to/.next/static/chunks'], + }, + }); + }); + + afterEach(() => { + readdirSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('warns when source maps exist outside the upload asset paths and deletion is enabled for turbopack', async () => { + readdirSpy.mockResolvedValue(['immutable/chunks/page-abc.js.map', 'chunks/covered.js.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(warnSpy).toHaveBeenCalledWith(uncoveredWarningMatcher); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('immutable/chunks/page-abc.js.map')); + }); + + it('does not warn when all source maps are covered by the upload asset paths', async () => { + readdirSpy.mockResolvedValue(['chunks/page-abc.js.map', 'chunks/app/layout.mjs.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + + it('ignores css source maps', async () => { + readdirSpy.mockResolvedValue(['css/styles.css.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + + it('does not warn when deleteSourcemapsAfterUpload is disabled', async () => { + readdirSpy.mockResolvedValue(['immutable/chunks/page-abc.js.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: false }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + + it('does not warn for webpack builds', async () => { + readdirSpy.mockResolvedValue(['immutable/chunks/page-abc.js.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + + it('does not warn when sourcemaps are disabled', async () => { + readdirSpy.mockResolvedValue(['immutable/chunks/page-abc.js.map']); + vi.mocked(getBuildPluginOptions) + .mockReset() + .mockReturnValueOnce({ + org: 'test-org', + project: 'test-project', + sourcemaps: { + disable: true, + assets: ['/path/to/.next/server', '/path/to/.next/static/chunks'], + }, + }); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + + it('does not warn when the user provided their own sourcemaps.assets', async () => { + readdirSpy.mockResolvedValue(['immutable/chunks/page-abc.js.map']); + + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { + deleteSourcemapsAfterUpload: true, + assets: ['.next/server', '.next/static'], + }, + }, + ); + + expect(warnSpy).not.toHaveBeenCalledWith(uncoveredWarningMatcher); + }); + }); + describe('path handling', () => { it('correctly passes distDir to debug ID injection', async () => { const customDistDir = '/custom/dist/path';