Skip to content

Commit e945b1f

Browse files
committed
fix(nuxt): Delete Sentry-enabled source maps from Nitro output directories
1 parent 9a31690 commit e945b1f

9 files changed

Lines changed: 306 additions & 44 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
.nitro
66
.cache
77
dist
8+
.netlify
89

910
# Node dependencies
1011
node_modules

‎dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,35 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';
2424
/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */
2525
const isStaticBuild = process.env.NUXT_COMMAND === 'generate';
2626

27-
const CLIENT_OUTPUT = path.join('.output', 'public');
27+
const nitroPreset = process.env.NITRO_PRESET ?? 'node-server';
28+
29+
/** If Nitro moves one of these directories, the debug ID checks below fail. */
30+
const OUTPUT_DIRS: Record<string, { client: string; server: string }> = {
31+
'node-server': { client: path.join('.output', 'public'), server: path.join('.output', 'server') },
32+
netlify: { client: 'dist', server: path.join('.netlify', 'functions-internal', 'server') },
33+
'cloudflare-pages': { client: 'dist', server: path.join('dist', '_worker.js') },
34+
};
35+
36+
const outputDirs = OUTPUT_DIRS[nitroPreset];
37+
assert.ok(outputDirs, `Expected output directories for the Nitro preset ${nitroPreset}`);
38+
39+
const CLIENT_OUTPUT = outputDirs.client;
2840
/** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */
2941
const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client');
30-
const SERVER_OUTPUT = path.join('.output', 'server');
42+
const SERVER_OUTPUT = outputDirs.server;
3143

3244
/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */
3345
const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT';
3446
const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER';
3547

3648
const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
3749

50+
/** Some presets write the server into the client output, e.g. Cloudflare Pages. */
51+
function isClientFile(file: string): boolean {
52+
const relativePath = path.relative(SERVER_OUTPUT, file);
53+
return relativePath.startsWith('..') || path.isAbsolute(relativePath);
54+
}
55+
3856
function filesContaining(dir: string, needle: string): string[] {
3957
return fs
4058
.readdirSync(dir, { recursive: true, withFileTypes: true })
@@ -64,7 +82,7 @@ function debugIdsOfChunksWithoutSourceMap(dir: string): Set<string> {
6482
}
6583

6684
console.log(
67-
`Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` +
85+
`Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, Nitro preset ${nitroPreset}, ` +
6886
`client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`,
6987
);
7088

@@ -218,7 +236,7 @@ console.log('');
218236

219237
// --- What the build leaves behind in the client output ---
220238

221-
const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT });
239+
const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }).filter(isClientFile);
222240

223241
if (keepClientSourceMaps) {
224242
assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`);
@@ -228,20 +246,30 @@ if (keepClientSourceMaps) {
228246
assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`);
229247

230248
// The maps are gone, so a surviving reference only 404s in devtools and leaks where they were.
231-
const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT });
249+
const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }).filter(isClientFile);
232250
assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`);
233251

234252
// Catches source maps inlined as `data:` URIs, which the reference check above skips by design.
235-
const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER);
253+
const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER).filter(isClientFile);
236254
assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`);
237255

238256
console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`);
239257
}
240258

241-
// --- What the built server answers for the client source maps ---
259+
// --- What the build leaves behind in the server output ---
242260

243-
// `nuxt generate` emits no server, and a static host serves only the files that exist.
261+
// Nuxt enables the server maps by default, so they are the user's and Sentry must keep them.
244262
if (!isStaticBuild) {
263+
const serverSourceMaps = findSourceMapFiles({ outputDir: SERVER_OUTPUT });
264+
assert.ok(serverSourceMaps.length > 0, `Expected Sentry to keep the server source maps in ${SERVER_OUTPUT}`);
265+
console.log(` ${serverSourceMaps.length} source map(s) kept in ${SERVER_OUTPUT}, as Nuxt enables them\n`);
266+
}
267+
268+
// --- What the built server answers for the client source maps ---
269+
270+
// `nuxt generate` emits no server, and a static host serves only the files that exist. The other presets build a
271+
// server for their platform, which Node cannot start.
272+
if (!isStaticBuild && nitroPreset === 'node-server') {
245273
const clientChunkUrls = fs
246274
.readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true })
247275
.filter(entry => entry.isFile() && entry.name.endsWith('.js'))

‎dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/nuxt.config.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
// own warning tells users to set, which makes it the setup worth regression-testing.
44
const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true';
55

6+
// With `sourcemap.client` unset, Sentry enables the client maps and finds their output directory itself, which
7+
// differs per Nitro preset.
8+
const nitroPreset = process.env.NITRO_PRESET;
9+
610
export default defineNuxtConfig({
711
compatibilityDate: '2025-06-06',
812
imports: { autoImport: false },
913

10-
sourcemap: { client: 'hidden' },
14+
sourcemap: { client: nitroPreset ? undefined : 'hidden' },
15+
16+
nitro: { preset: nitroPreset },
1117

1218
modules: ['@sentry/nuxt/module'],
1319

@@ -27,7 +33,7 @@ export default defineNuxtConfig({
2733
release: { name: 'test-release' },
2834
// Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should
2935
// upload the maps and leave the emitted files alone.
30-
sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] },
36+
sourcemaps: keepClientSourceMaps || nitroPreset ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] },
3137
debug: true,
3238
},
3339
});

‎dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@
1111
"test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build",
1212
"test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert",
1313
"test:build:static": "NUXT_COMMAND=generate pnpm test:build",
14-
"test:assert:static": "NUXT_COMMAND=generate pnpm test:assert"
14+
"test:assert:static": "NUXT_COMMAND=generate pnpm test:assert",
15+
"test:build:netlify": "NITRO_PRESET=netlify pnpm test:build",
16+
"test:assert:netlify": "NITRO_PRESET=netlify pnpm test:assert",
17+
"test:build:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:build",
18+
"test:assert:cloudflare-pages": "NITRO_PRESET=cloudflare-pages pnpm test:assert"
1519
},
1620
"dependencies": {
1721
"@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz",
@@ -38,6 +42,16 @@
3842
"build-command": "pnpm test:build:static",
3943
"assert-command": "pnpm test:assert:static",
4044
"label": "nuxt-4-sourcemaps (static / nuxt generate)"
45+
},
46+
{
47+
"build-command": "pnpm test:build:netlify",
48+
"assert-command": "pnpm test:assert:netlify",
49+
"label": "nuxt-4-sourcemaps (netlify preset)"
50+
},
51+
{
52+
"build-command": "pnpm test:build:cloudflare-pages",
53+
"assert-command": "pnpm test:assert:cloudflare-pages",
54+
"label": "nuxt-4-sourcemaps (cloudflare-pages preset)"
4155
}
4256
]
4357
}

‎packages/nuxt/src/vite/sourceMapDeletion.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,46 @@ import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry
22
import * as fs from 'node:fs';
33
import * as path from 'node:path';
44

5+
/** Nitro's output directories, which depend on the Nitro preset, e.g. `dist` for the public files on Netlify. */
6+
type NitroOutputDirs = { publicDir: string; serverDir: string };
7+
8+
/** The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set up. */
9+
export type DeletionFallback = { client: boolean; server: boolean; outputDirs?: NitroOutputDirs };
10+
11+
/**
12+
* Globs for the source maps in Nitro's output directories of the sides whose source maps only exist because Sentry
13+
* enabled them. Empty until Nitro is set up.
14+
*/
15+
export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] {
16+
if (!fallback?.outputDirs) {
17+
return [];
18+
}
19+
20+
const { client, server, outputDirs } = fallback;
21+
const { publicDir, serverDir } = outputDirs;
22+
return [
23+
...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []),
24+
...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []),
25+
];
26+
}
27+
28+
/** Some presets write the server into the public dir, e.g. Cloudflare Pages into `dist/_worker.js`. */
29+
function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | undefined): string[] {
30+
const publicGlob = toGlobPath(publicDir);
31+
32+
if (keptServerDir === undefined || !isInDirectory(keptServerDir, publicDir)) {
33+
return [`${publicGlob}/**/*.map`];
34+
}
35+
36+
const [serverDirName] = path.relative(publicDir, keptServerDir).split(path.sep);
37+
return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`];
38+
}
39+
40+
/** `filesToDeleteAfterUpload` is matched from the working directory and needs forward slashes, also on Windows. */
41+
function toGlobPath(directory: string): string {
42+
return path.relative(process.cwd(), directory).split(path.sep).join('/') || '.';
43+
}
44+
545
export function withoutSourceMapDeletion(options: Options): Options {
646
return {
747
...options,

‎packages/nuxt/src/vite/sourceMaps.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import { warnOnRemovedBuildOptions } from '@sentry/core';
55
import type { NitroConfig } from 'nitropack';
66
import type { Plugin } from 'vite';
77
import type { SentryNuxtModuleOptions } from '../common/types';
8-
import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion';
8+
import {
9+
deletePublicSourceMaps,
10+
deleteSourceMapsAfterBuild,
11+
type DeletionFallback,
12+
getFallbackFilesToDelete,
13+
withoutSourceMapDeletion,
14+
} from './sourceMapDeletion';
915
import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin';
1016

1117
/**
@@ -33,7 +39,7 @@ export function setupSourceMaps(
3339
const sourceMapsEnabled = moduleOptions.sourcemaps?.disable !== true;
3440

3541
// In case we overwrite the source map settings, we default to deleting the files
36-
const shouldDeleteFilesFallback = { client: true, server: true };
42+
const shouldDeleteFilesFallback: DeletionFallback = { client: true, server: true };
3743

3844
nuxt.hook('modules:done', () => {
3945
if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) {
@@ -99,6 +105,9 @@ export function setupSourceMaps(
99105

100106
validateNitroSourceMapSettings(nuxt, nitroConfig, moduleOptions);
101107

108+
// `rollupConfig.output.sourcemap` overrides `sourcemap.server`, so the server source maps are the user's.
109+
shouldDeleteFilesFallback.server &&= nitroConfig.rollupConfig.output?.sourcemap === undefined;
110+
102111
if (isDebug) {
103112
// eslint-disable-next-line no-console
104113
console.log('[Sentry] Adding Sentry Rollup plugin to the server runtime.');
@@ -115,6 +124,7 @@ export function setupSourceMaps(
115124
nuxt.hook('nitro:init', nitro => {
116125
if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) {
117126
const publicDir = nitro.options.output.publicDir;
127+
shouldDeleteFilesFallback.outputDirs = { publicDir, serverDir: nitro.options.output.serverDir };
118128
const rollupConfig = (nitro.options.rollupConfig ??= { output: {} });
119129

120130
rollupConfig.plugins = [
@@ -151,7 +161,7 @@ function normalizePath(path: string): string {
151161
*/
152162
export function getPluginOptions(
153163
moduleOptions: SentryNuxtModuleOptions,
154-
shouldDeleteFilesFallback?: { client: boolean; server: boolean },
164+
shouldDeleteFilesFallback?: DeletionFallback,
155165
): SentryVitePluginOptions | SentryRollupPluginOptions {
156166
const sourcemapsOptions = moduleOptions.sourcemaps || {};
157167
const filesToDeleteAfterUpload = resolveFilesToDeleteAfterUpload(moduleOptions, shouldDeleteFilesFallback);
@@ -200,19 +210,15 @@ export function getPluginOptions(
200210
* (client/server) whose `sourcemap` setting resolved to `undefined`, where Sentry stepped in and
201211
* enabled `'hidden'`. Given Nuxt's default of `{ server: true, client: false }`, this isn't the
202212
* default case: it only kicks in when a side is left `undefined` (whole config or a single sub-key).
213+
* Sentry deletes them in Nitro's output directories, which are only known once Nitro is set up.
203214
* @see https://nuxt.com/docs/4.x/api/nuxt-config#sourcemap
204215
*/
205216
function resolveFilesToDeleteAfterUpload(
206217
moduleOptions: SentryNuxtModuleOptions,
207-
shouldDeleteFilesFallback?: { client: boolean; server: boolean },
218+
shouldDeleteFilesFallback?: DeletionFallback,
208219
): string | Array<string> | undefined {
209-
const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server;
210-
const fallbackFilesToDelete = [
211-
...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []),
212-
...(shouldDeleteFilesFallback?.server
213-
? ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map']
214-
: []),
215-
];
220+
const fallbackFilesToDelete = getFallbackFilesToDelete(shouldDeleteFilesFallback);
221+
const shouldDeleteFilesAfterUpload = fallbackFilesToDelete.length > 0;
216222

217223
const filesToDeleteAfterUpload = moduleOptions.sourcemaps?.filesToDeleteAfterUpload;
218224

0 commit comments

Comments
 (0)