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
12 changes: 12 additions & 0 deletions packages/nextjs/src/config/getBuildPluginOptions.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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',
Expand Down Expand Up @@ -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) {
Expand Down
47 changes: 46 additions & 1 deletion packages/nextjs/src/config/handleRunAfterProductionCompile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}
Expand All @@ -90,6 +104,37 @@ export async function handleRunAfterProductionCompile(
}
}

async function warnAboutUncoveredSourcemaps(
staticDir: string,
uploadAssets: string | string[] | undefined,
): Promise<void> {
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]+\*\/$/;

Expand Down
36 changes: 36 additions & 0 deletions packages/nextjs/test/config/getBuildPluginOptions.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fs>();
return { ...actual, existsSync: vi.fn(actual.existsSync) };
});

describe('getBuildPluginOptions', () => {
const mockReleaseName = 'test-release-1.0.0';
const mockDistDirAbsPath = '/path/to/.next';
Expand Down Expand Up @@ -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', () => {
Expand Down
171 changes: 169 additions & 2 deletions packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -312,7 +314,7 @@ describe('handleRunAfterProductionCompile', () => {
});

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

beforeEach(() => {
// Spy on fs.promises.readdir to detect whether stripping was attempted.
Expand Down Expand Up @@ -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'] },
},
);

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