Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/nextjs/src/config/handleRunAfterProductionCompile.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<void> {
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.`,
);
}
}
198 changes: 196 additions & 2 deletions packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -305,6 +311,85 @@ describe('handleRunAfterProductionCompile', () => {
});
});

describe('sourceMappingURL stripping', () => {
let readdirSpy: ReturnType<typeof vi.spyOn>;

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';
Expand Down Expand Up @@ -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');
}
});
});
Loading