Skip to content

Commit 399cc85

Browse files
authored
fix(nextjs): Strip sourceMappingURL comments after deleting source maps in turbopack builds (#19814)
When `withSentryConfig` is used with Next.js 16 + turbopack, the SDK auto-enables `productionBrowserSourceMaps: true` and `deleteSourcemapsAfterUpload: true`. After the build, `.map` files are deleted, but the corresponding `//# sourceMappingURL=...` comments remain in the JS chunks. When browsers (e.g. Chrome DevTools) request these nonexistent `.map` files, Next.js 16 (turbopack) does not return a 404. Instead, the request falls through to the app router, where catch-all routes handle it. Since middleware matchers typically exclude `_next` paths, middleware does not run for these requests, causing SSR failures for middleware-dependent features (e.g. Clerk's `auth()` throws because `clerkMiddleware()` headers are absent). This fix strips dangling `sourceMappingURL` comments from client JS/CSS files after source map deletion, but only for turbopack builds where the issue manifests. - Scoped to turbopack only to minimize blast radius - Runs after `deleteArtifacts()` in the post-build hook - Handles JS, MJS, CJS, and CSS files recursively in `static/` closes #19600
1 parent bc868e6 commit 399cc85

File tree

2 files changed

+259
-2
lines changed

2 files changed

+259
-2
lines changed

packages/nextjs/src/config/handleRunAfterProductionCompile.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { createSentryBuildPluginManager as createSentryBuildPluginManagerType } from '@sentry/bundler-plugin-core';
22
import { loadModule } from '@sentry/core';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
35
import { getBuildPluginOptions } from './getBuildPluginOptions';
46
import type { SentryBuildOptions } from './types';
57

@@ -60,4 +62,65 @@ export async function handleRunAfterProductionCompile(
6062
prepareArtifacts: false,
6163
});
6264
await sentryBuildPluginManager.deleteArtifacts();
65+
66+
// After deleting source map files in turbopack builds, strip any remaining
67+
// sourceMappingURL comments from client JS files. Without this, browsers request
68+
// the deleted .map files, and in Next.js 16 (turbopack) those requests fall through
69+
// to the app router instead of returning 404, which can break middleware-dependent
70+
// features like Clerk auth.
71+
const deleteSourcemapsAfterUpload = sentryBuildOptions.sourcemaps?.deleteSourcemapsAfterUpload ?? false;
72+
if (deleteSourcemapsAfterUpload && buildTool === 'turbopack') {
73+
await stripSourceMappingURLComments(path.join(distDir, 'static'), sentryBuildOptions.debug);
74+
}
75+
}
76+
77+
const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/;
78+
const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\n]+\*\/$/;
79+
80+
/**
81+
* Strips sourceMappingURL comments from all JS/MJS/CJS/CSS files in the given directory.
82+
* This prevents browsers from requesting deleted .map files.
83+
*/
84+
export async function stripSourceMappingURLComments(staticDir: string, debug?: boolean): Promise<void> {
85+
let entries: string[];
86+
try {
87+
entries = await fs.promises.readdir(staticDir, { recursive: true }).then(e => e.map(f => String(f)));
88+
} catch {
89+
// Directory may not exist (e.g., no static output)
90+
return;
91+
}
92+
93+
const filesToProcess = entries.filter(
94+
f => f.endsWith('.js') || f.endsWith('.mjs') || f.endsWith('.cjs') || f.endsWith('.css'),
95+
);
96+
97+
const results = await Promise.all(
98+
filesToProcess.map(async file => {
99+
const filePath = path.join(staticDir, file);
100+
try {
101+
const content = await fs.promises.readFile(filePath, 'utf-8');
102+
103+
const isCSS = file.endsWith('.css');
104+
const regex = isCSS ? CSS_SOURCEMAPPING_URL_COMMENT_REGEX : SOURCEMAPPING_URL_COMMENT_REGEX;
105+
106+
const strippedContent = content.replace(regex, '');
107+
if (strippedContent !== content) {
108+
await fs.promises.writeFile(filePath, strippedContent, 'utf-8');
109+
return file;
110+
}
111+
} catch {
112+
// Skip files that can't be read/written
113+
}
114+
return undefined;
115+
}),
116+
);
117+
118+
const strippedCount = results.filter(Boolean).length;
119+
120+
if (debug && strippedCount > 0) {
121+
// eslint-disable-next-line no-console
122+
console.debug(
123+
`[@sentry/nextjs] Stripped sourceMappingURL comments from ${String(strippedCount)} file(s) to prevent requests for deleted source maps.`,
124+
);
125+
}
63126
}

packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts

Lines changed: 196 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { loadModule } from '@sentry/core';
2-
import { beforeEach, describe, expect, it, vi } from 'vitest';
3-
import { handleRunAfterProductionCompile } from '../../src/config/handleRunAfterProductionCompile';
2+
import * as fs from 'fs';
3+
import * as os from 'os';
4+
import * as path from 'path';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import {
7+
handleRunAfterProductionCompile,
8+
stripSourceMappingURLComments,
9+
} from '../../src/config/handleRunAfterProductionCompile';
410
import type { SentryBuildOptions } from '../../src/config/types';
511

612
vi.mock('@sentry/core', () => ({
@@ -305,6 +311,85 @@ describe('handleRunAfterProductionCompile', () => {
305311
});
306312
});
307313

314+
describe('sourceMappingURL stripping', () => {
315+
let readdirSpy: ReturnType<typeof vi.spyOn>;
316+
317+
beforeEach(() => {
318+
// Spy on fs.promises.readdir to detect whether stripping was attempted.
319+
// The actual readdir will fail (dir doesn't exist), which is fine — we just
320+
// need to know if it was called.
321+
readdirSpy = vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT'));
322+
});
323+
324+
afterEach(() => {
325+
readdirSpy.mockRestore();
326+
});
327+
328+
it('strips sourceMappingURL comments for turbopack builds with deleteSourcemapsAfterUpload', async () => {
329+
await handleRunAfterProductionCompile(
330+
{
331+
releaseName: 'test-release',
332+
distDir: '/path/to/.next',
333+
buildTool: 'turbopack',
334+
},
335+
{
336+
...mockSentryBuildOptions,
337+
sourcemaps: { deleteSourcemapsAfterUpload: true },
338+
},
339+
);
340+
341+
expect(readdirSpy).toHaveBeenCalledWith(
342+
path.join('/path/to/.next', 'static'),
343+
expect.objectContaining({ recursive: true }),
344+
);
345+
});
346+
347+
it('does NOT strip sourceMappingURL comments for webpack builds even with deleteSourcemapsAfterUpload', async () => {
348+
await handleRunAfterProductionCompile(
349+
{
350+
releaseName: 'test-release',
351+
distDir: '/path/to/.next',
352+
buildTool: 'webpack',
353+
},
354+
{
355+
...mockSentryBuildOptions,
356+
sourcemaps: { deleteSourcemapsAfterUpload: true },
357+
},
358+
);
359+
360+
expect(readdirSpy).not.toHaveBeenCalled();
361+
});
362+
363+
it('does NOT strip sourceMappingURL comments when deleteSourcemapsAfterUpload is false', async () => {
364+
await handleRunAfterProductionCompile(
365+
{
366+
releaseName: 'test-release',
367+
distDir: '/path/to/.next',
368+
buildTool: 'turbopack',
369+
},
370+
{
371+
...mockSentryBuildOptions,
372+
sourcemaps: { deleteSourcemapsAfterUpload: false },
373+
},
374+
);
375+
376+
expect(readdirSpy).not.toHaveBeenCalled();
377+
});
378+
379+
it('does NOT strip sourceMappingURL comments when deleteSourcemapsAfterUpload is undefined', async () => {
380+
await handleRunAfterProductionCompile(
381+
{
382+
releaseName: 'test-release',
383+
distDir: '/path/to/.next',
384+
buildTool: 'turbopack',
385+
},
386+
mockSentryBuildOptions,
387+
);
388+
389+
expect(readdirSpy).not.toHaveBeenCalled();
390+
});
391+
});
392+
308393
describe('path handling', () => {
309394
it('correctly passes distDir to debug ID injection', async () => {
310395
const customDistDir = '/custom/dist/path';
@@ -343,3 +428,112 @@ describe('handleRunAfterProductionCompile', () => {
343428
});
344429
});
345430
});
431+
432+
describe('stripSourceMappingURLComments', () => {
433+
let tmpDir: string;
434+
435+
beforeEach(async () => {
436+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sentry-test-'));
437+
await fs.promises.mkdir(path.join(tmpDir, 'chunks'), { recursive: true });
438+
});
439+
440+
afterEach(async () => {
441+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
442+
});
443+
444+
it('strips sourceMappingURL comment from JS files', async () => {
445+
const filePath = path.join(tmpDir, 'chunks', 'abc123.js');
446+
await fs.promises.writeFile(filePath, 'console.log("hello");\n//# sourceMappingURL=abc123.js.map');
447+
448+
await stripSourceMappingURLComments(tmpDir);
449+
450+
const content = await fs.promises.readFile(filePath, 'utf-8');
451+
expect(content).toBe('console.log("hello");');
452+
expect(content).not.toContain('sourceMappingURL');
453+
});
454+
455+
it('strips sourceMappingURL comment from MJS files', async () => {
456+
const filePath = path.join(tmpDir, 'chunks', 'module.mjs');
457+
await fs.promises.writeFile(filePath, 'export default 42;\n//# sourceMappingURL=module.mjs.map');
458+
459+
await stripSourceMappingURLComments(tmpDir);
460+
461+
const content = await fs.promises.readFile(filePath, 'utf-8');
462+
expect(content).toBe('export default 42;');
463+
});
464+
465+
it('strips sourceMappingURL comment from CSS files', async () => {
466+
const filePath = path.join(tmpDir, 'chunks', 'styles.css');
467+
await fs.promises.writeFile(filePath, '.foo { color: red; }\n/*# sourceMappingURL=styles.css.map */');
468+
469+
await stripSourceMappingURLComments(tmpDir);
470+
471+
const content = await fs.promises.readFile(filePath, 'utf-8');
472+
expect(content).toBe('.foo { color: red; }');
473+
});
474+
475+
it('does not modify files without sourceMappingURL comments', async () => {
476+
const filePath = path.join(tmpDir, 'chunks', 'clean.js');
477+
const originalContent = 'console.log("no source map ref");';
478+
await fs.promises.writeFile(filePath, originalContent);
479+
480+
await stripSourceMappingURLComments(tmpDir);
481+
482+
const content = await fs.promises.readFile(filePath, 'utf-8');
483+
expect(content).toBe(originalContent);
484+
});
485+
486+
it('handles files in nested subdirectories', async () => {
487+
const nestedDir = path.join(tmpDir, 'chunks', 'app', 'page');
488+
await fs.promises.mkdir(nestedDir, { recursive: true });
489+
const filePath = path.join(nestedDir, 'layout.js');
490+
await fs.promises.writeFile(filePath, 'var x = 1;\n//# sourceMappingURL=layout.js.map');
491+
492+
await stripSourceMappingURLComments(tmpDir);
493+
494+
const content = await fs.promises.readFile(filePath, 'utf-8');
495+
expect(content).toBe('var x = 1;');
496+
});
497+
498+
it('handles non-existent directory gracefully', async () => {
499+
await expect(stripSourceMappingURLComments('/nonexistent/path')).resolves.toBeUndefined();
500+
});
501+
502+
it('handles sourceMappingURL with @-style comment', async () => {
503+
const filePath = path.join(tmpDir, 'chunks', 'legacy.js');
504+
await fs.promises.writeFile(filePath, 'var y = 2;\n//@ sourceMappingURL=legacy.js.map');
505+
506+
await stripSourceMappingURLComments(tmpDir);
507+
508+
const content = await fs.promises.readFile(filePath, 'utf-8');
509+
expect(content).toBe('var y = 2;');
510+
});
511+
512+
it('ignores non-JS/CSS files', async () => {
513+
const filePath = path.join(tmpDir, 'chunks', 'data.json');
514+
const originalContent = '{"key": "value"}\n//# sourceMappingURL=data.json.map';
515+
await fs.promises.writeFile(filePath, originalContent);
516+
517+
await stripSourceMappingURLComments(tmpDir);
518+
519+
const content = await fs.promises.readFile(filePath, 'utf-8');
520+
expect(content).toBe(originalContent);
521+
});
522+
523+
it('processes multiple files concurrently', async () => {
524+
const files = ['a.js', 'b.mjs', 'c.cjs', 'd.css'];
525+
for (const file of files) {
526+
const ext = path.extname(file);
527+
const comment = ext === '.css' ? `/*# sourceMappingURL=${file}.map */` : `//# sourceMappingURL=${file}.map`;
528+
await fs.promises.writeFile(path.join(tmpDir, file), `content_${file}\n${comment}`);
529+
}
530+
531+
await stripSourceMappingURLComments(tmpDir);
532+
533+
for (const file of files) {
534+
const content = await fs.promises.readFile(path.join(tmpDir, file), 'utf-8');
535+
expect(content).toBe(`content_${file}`);
536+
expect(content).not.toContain('sourceMappingURL');
537+
}
538+
});
539+
});

0 commit comments

Comments
 (0)