diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index 901ba9dffa6e..f6e5a3b21617 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -1,5 +1,7 @@ import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core'; import { loadModule } from '@sentry/core'; +import * as fs from 'fs'; +import * as path from 'path'; import { getBuildPluginOptions } from './getBuildPluginOptions'; import type { SentryBuildOptions } from './types'; @@ -60,4 +62,65 @@ export async function handleRunAfterProductionCompile( prepareArtifacts: false, }); await sentryBuildPluginManager.deleteArtifacts(); + + // After deleting source map files in turbopack builds, strip any remaining + // sourceMappingURL comments from client JS files. Without this, browsers request + // the deleted .map files, and in Next.js 16 (turbopack) those requests fall through + // to the app router instead of returning 404, which can break middleware-dependent + // features like Clerk auth. + const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false; + if (deleteSourcemapsAfterUpload && buildTool === 'turbopack') { + await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug); + } +} + +const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; +const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\n]+\*\/$/; + +/** + * Strips sourceMappingURL comments from all JS/MJS/CJS/CSS files in the given directory. + * This prevents browsers from requesting deleted .map files. + */ +export async function stripSourceMappingURLComments(staticDir: string, debug?: boolean): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(staticDir, { recursive: true }).then(e => e.map(f => String(f))); + } catch { + // Directory may not exist (e.g., no static output) + return; + } + + const filesToProcess = entries.filter( + f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs') || f.endsWith('.css'), + ); + + const results = await Promise.all( + filesToProcess.map(async file => { + const filePath = path.join(staticDir, file); + try { + const content = await fs.promises.readFile(filePath, 'utf-8'); + + const isCSS = file.endsWith('.css'); + const regex = isCSS ? CSS_SOURCEMAPPING_URL_COMMENT_REGEX : SOURCEMAPPING_URL_COMMENT_REGEX; + + const strippedContent = content.replace(regex, ''); + if (strippedContent !== content) { + await fs.promises.writeFile(filePath, strippedContent, 'utf-8'); + return file; + } + } catch { + // Skip files that can't be read/written + } + return undefined; + }), + ); + + const strippedCount = results.filter(Boolean).length; + + if (debug && strippedCount > 0) { + // eslint-disable-next-line no-console + console.debug( + `[@sentry/nextjs] Stripped sourceMappingURL comments from ${String(strippedCount)} file(s) to prevent requests for deleted source maps.`, + ); + } } diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index 2d1769986158..3d551dfb6c40 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -1,6 +1,12 @@ import { loadModule } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { handleRunAfterProductionCompile } from '../../src/config/handleRunAfterProductionCompile'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + handleRunAfterProductionCompile, + stripSourceMappingURLComments, +} from '../../src/config/handleRunAfterProductionCompile'; import type { SentryBuildOptions } from '../../src/config/types'; vi.mock('@sentry/core', () => ({ @@ -305,6 +311,85 @@ describe('handleRunAfterProductionCompile', () => { }); }); + describe('sourceMappingURL stripping', () => { + let readdirSpy: ReturnType; + + beforeEach(() => { + // Spy on fs.promises.readdir to detect whether stripping was attempted. + // The actual readdir will fail (dir doesn't exist), which is fine — we just + // need to know if it was called. + readdirSpy = vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT')); + }); + + afterEach(() => { + readdirSpy.mockRestore(); + }); + + it('strips sourceMappingURL comments for turbopack builds with deleteSourcemapsAfterUpload', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).toHaveBeenCalledWith( + path.join('/path/to/.next', 'static'), + expect.objectContaining({ recursive: true }), + ); + }); + + it('does NOT strip sourceMappingURL comments for webpack builds even with deleteSourcemapsAfterUpload', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'webpack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: true }, + }, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('does NOT strip sourceMappingURL comments when deleteSourcemapsAfterUpload is false', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + { + ...mockSentryBuildOptions, + sourcemaps: { deleteSourcemapsAfterUpload: false }, + }, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + + it('does NOT strip sourceMappingURL comments when deleteSourcemapsAfterUpload is undefined', async () => { + await handleRunAfterProductionCompile( + { + releaseName: 'test-release', + distDir: '/path/to/.next', + buildTool: 'turbopack', + }, + mockSentryBuildOptions, + ); + + expect(readdirSpy).not.toHaveBeenCalled(); + }); + }); + describe('path handling', () => { it('correctly passes distDir to debug ID injection', async () => { const customDistDir = '/custom/dist/path'; @@ -343,3 +428,112 @@ describe('handleRunAfterProductionCompile', () => { }); }); }); + +describe('stripSourceMappingURLComments', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-test-')); + await fs.promises.mkdir(path.join(tmpDir, 'chunks'), { recursive: true }); + }); + + afterEach(async () => { + await fs.promises.rm(tmpDir, { recursive: true, force: true }); + }); + + it('strips sourceMappingURL comment from JS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'abc123.js'); + await fs.promises.writeFile(filePath, 'console.log("hello");\n//# sourceMappingURL=abc123.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('console.log("hello");'); + expect(content).not.toContain('sourceMappingURL'); + }); + + it('strips sourceMappingURL comment from MJS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'module.mjs'); + await fs.promises.writeFile(filePath, 'export default 42;\n//# sourceMappingURL=module.mjs.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('export default 42;'); + }); + + it('strips sourceMappingURL comment from CSS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'styles.css'); + await fs.promises.writeFile(filePath, '.foo { color: red; }\n/*# sourceMappingURL=styles.css.map */'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('.foo { color: red; }'); + }); + + it('does not modify files without sourceMappingURL comments', async () => { + const filePath = path.join(tmpDir, 'chunks', 'clean.js'); + const originalContent = 'console.log("no source map ref");'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('handles files in nested subdirectories', async () => { + const nestedDir = path.join(tmpDir, 'chunks', 'app', 'page'); + await fs.promises.mkdir(nestedDir, { recursive: true }); + const filePath = path.join(nestedDir, 'layout.js'); + await fs.promises.writeFile(filePath, 'var x = 1;\n//# sourceMappingURL=layout.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var x = 1;'); + }); + + it('handles non-existent directory gracefully', async () => { + await expect(stripSourceMappingURLComments('/nonexistent/path')).resolves.toBeUndefined(); + }); + + it('handles sourceMappingURL with @-style comment', async () => { + const filePath = path.join(tmpDir, 'chunks', 'legacy.js'); + await fs.promises.writeFile(filePath, 'var y = 2;\n//@ sourceMappingURL=legacy.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var y = 2;'); + }); + + it('ignores non-JS/CSS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'data.json'); + const originalContent = '{"key": "value"}\n//# sourceMappingURL=data.json.map'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('processes multiple files concurrently', async () => { + const files = ['a.js', 'b.mjs', 'c.cjs', 'd.css']; + for (const file of files) { + const ext = path.extname(file); + const comment = ext === '.css' ? `/*# sourceMappingURL=${file}.map */` : `//# sourceMappingURL=${file}.map`; + await fs.promises.writeFile(path.join(tmpDir, file), `content_${file}\n${comment}`); + } + + await stripSourceMappingURLComments(tmpDir); + + for (const file of files) { + const content = await fs.promises.readFile(path.join(tmpDir, file), 'utf-8'); + expect(content).toBe(`content_${file}`); + expect(content).not.toContain('sourceMappingURL'); + } + }); +});