From 0e2f81e0affba5e0ee85e8fc856a1025fcf07e04 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:13:07 +0200 Subject: [PATCH 1/2] fix(nuxt): Delete public source maps before Nitro lists its public assets (#25267) Nitro lists the files in its public dir when it bundles the server. The SDK deleted the client source maps later, in `close`, so requests for them returned 500 instead of 404. Now a plugin on Nitro's build deletes them in `buildStart`. It deletes only matching files in the public dir. Other matches, like server maps, are still deleted in `close`: the default glob also matches the SSR build's maps, which Nitro needs during its build. Fixes #25242 --- dev-packages/test-utils/src/index.ts | 3 + dev-packages/test-utils/src/node-server.ts | 55 +++++++++++ packages/nuxt/src/vite/sourceMapDeletion.ts | 44 ++++++++- packages/nuxt/src/vite/sourceMaps.ts | 20 +++- .../nuxt/test/vite/sourceMapDeletion.test.ts | 95 +++++++++++++++++++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 dev-packages/test-utils/src/node-server.ts create mode 100644 packages/nuxt/test/vite/sourceMapDeletion.test.ts diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index aaf4cc1d886a..bf194fdfff0a 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -17,6 +17,9 @@ export { export { getPlaywrightConfig } from './playwright-config'; export { createBasicSentryServer, createTestServer } from './server'; +export { startNodeServer } from './node-server'; +export type { NodeServerOptions, NodeServer } from './node-server'; + export { startMockSentryServer } from './mock-sentry-server'; export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server'; export * from './sourcemap-upload-utils'; diff --git a/dev-packages/test-utils/src/node-server.ts b/dev-packages/test-utils/src/node-server.ts new file mode 100644 index 000000000000..c2bf6791af29 --- /dev/null +++ b/dev-packages/test-utils/src/node-server.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process'; + +export interface NodeServerOptions { + /** Path to the server entry, e.g. `.output/server/index.mjs`. */ + entry: string; + port: number; + /** How long to wait for the first response. Defaults to 30 seconds. */ + timeoutMs?: number; +} + +export interface NodeServer { + baseUrl: string; + /** Stops the server and resolves once its process has exited. */ + close: () => Promise; +} + +/** + * Starts a built Node server and resolves once it answers HTTP requests. The server gets the port via `PORT`. + * + * Rejects if the server exits or does not answer before the timeout. Its output is printed only then. + */ +export async function startNodeServer({ entry, port, timeoutMs = 30_000 }: NodeServerOptions): Promise { + const baseUrl = `http://localhost:${port}`; + const child = spawn(process.execPath, [entry], { env: { ...process.env, PORT: String(port) } }); + + let output = ''; + child.stdout.on('data', chunk => (output += chunk)); + child.stderr.on('data', chunk => (output += chunk)); + + const close = async (): Promise => { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + const exited = new Promise(resolve => child.once('exit', resolve)); + child.kill(); + await exited; + }; + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`Server ${entry} exited with code ${child.exitCode} before it answered:\n${output}`); + } + + try { + await fetch(baseUrl); + return { baseUrl, close }; + } catch { + await new Promise(resolve => setTimeout(resolve, 250)); + } + } + + await close(); + throw new Error(`Server ${entry} did not answer on ${baseUrl} within ${timeoutMs}ms:\n${output}`); +} diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index dc23fcf7c5f5..01b4d21daecf 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -1,4 +1,6 @@ -import { createSentryBuildPluginManager, type Options } from '@sentry/bundler-plugins/core'; +import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; export function withoutSourceMapDeletion(options: Options): Options { return { @@ -32,3 +34,43 @@ export async function deleteSourceMapsAfterBuild(options: Options): Promise { + const filesToDeleteAfterUpload = await options.sourcemaps?.filesToDeleteAfterUpload; + + if (filesToDeleteAfterUpload === undefined) { + return; + } + + const files = await globFiles(filesToDeleteAfterUpload); + const publicFiles = files.filter(file => isInDirectory(file, publicDir)); + + await Promise.all( + publicFiles.map(async file => { + logDebug(options, `Deleting ${file} before Nitro bundles the server.`); + // `deleteSourceMapsAfterBuild` tries again in `close`. + await fs.promises.rm(file, { force: true }).catch(error => logDebug(options, `Could not delete ${file}.`, error)); + }), + ); +} + +function logDebug(options: Options, ...messages: unknown[]): void { + if (options.debug) { + // eslint-disable-next-line no-console + console.log('[Sentry]', ...messages); + } +} + +function isInDirectory(file: string, directory: string): boolean { + const relativePath = path.relative(directory, file); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 2918f420ff93..c6971ab0594b 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -4,7 +4,7 @@ import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plu import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -122,6 +122,24 @@ export function setupSourceMaps( } }); + nuxt.hook('nitro:init', nitro => { + if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { + const publicDir = nitro.options.output.publicDir; + const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); + + rollupConfig.plugins = [ + rollupConfig.plugins, + { + name: 'sentry-nuxt-public-source-map-deletion', + // By now the client build is in the public dir, and Nitro has not yet listed its files. + async buildStart() { + await deletePublicSourceMaps(getPluginOptions(moduleOptions, shouldDeleteFilesFallback), publicDir); + }, + }, + ]; + } + }); + nuxt.hook('close', async () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { await deleteSourceMapsAfterBuild(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)); diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts new file mode 100644 index 000000000000..1599aebcdf9f --- /dev/null +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -0,0 +1,95 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { deletePublicSourceMaps } from '../../src/vite/sourceMapDeletion'; + +const BUILD_FILES = [ + '.output/public/_nuxt/entry.DxB3kLmA.js', + '.output/public/_nuxt/entry.DxB3kLmA.js.map', + '.output/public/_nuxt/pages/products.Cq81zR4t.js.map', + '.output/public-assets/_nuxt/entry.DxB3kLmA.js.map', + '.output/server/chunks/nitro/nitro.mjs.map', + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', +]; + +let rootDir: string; +let publicDir: string; + +/** Glob patterns need forward slashes, also on Windows. */ +function toGlob(relativePattern: string): string { + return path.join(rootDir, relativePattern).split(path.sep).join('/'); +} + +function remainingFiles(): string[] { + return BUILD_FILES.filter(file => fs.existsSync(path.join(rootDir, file))); +} + +describe('deletePublicSourceMaps', () => { + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-source-map-deletion-')); + publicDir = path.join(rootDir, '.output', 'public'); + + for (const file of BUILD_FILES) { + fs.mkdirSync(path.dirname(path.join(rootDir, file)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, file), ''); + } + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + it('deletes the matched files in the public dir and keeps the matched files outside of it', async () => { + await deletePublicSourceMaps( + { + sourcemaps: { + filesToDeleteAfterUpload: [toGlob('.*/**/public*/**/*.map'), toGlob('.*/**/server/**/*.map')], + }, + }, + publicDir, + ); + + expect(remainingFiles()).toEqual([ + '.output/public/_nuxt/entry.DxB3kLmA.js', + '.output/public-assets/_nuxt/entry.DxB3kLmA.js.map', + '.output/server/chunks/nitro/nitro.mjs.map', + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + ]); + }); + + it('keeps the files in the public dir that no pattern matches', async () => { + await deletePublicSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: toGlob('.output/public/_nuxt/pages/*.map') } }, + publicDir, + ); + + expect(remainingFiles()).toEqual([ + '.output/public/_nuxt/entry.DxB3kLmA.js', + '.output/public/_nuxt/entry.DxB3kLmA.js.map', + '.output/public-assets/_nuxt/entry.DxB3kLmA.js.map', + '.output/server/chunks/nitro/nitro.mjs.map', + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + ]); + }); + + it('resolves a promised list of patterns', async () => { + await deletePublicSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: Promise.resolve([toGlob('.output/public/**/*.map')]) } }, + publicDir, + ); + + expect(remainingFiles()).toEqual([ + '.output/public/_nuxt/entry.DxB3kLmA.js', + '.output/public-assets/_nuxt/entry.DxB3kLmA.js.map', + '.output/server/chunks/nitro/nitro.mjs.map', + '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map', + ]); + }); + + it('deletes nothing when no deletion patterns are set', async () => { + await deletePublicSourceMaps({ sourcemaps: {} }, publicDir); + + expect(remainingFiles()).toEqual(BUILD_FILES); + }); +}); From d4cf7a998b45e33ecbbe95f332e4803aeb4d01fb Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:02:52 +0200 Subject: [PATCH 2/2] fix linting --- packages/nuxt/src/vite/sourceMapDeletion.ts | 20 ++++++++++++++++++++ packages/nuxt/src/vite/sourceMaps.ts | 20 ++++++-------------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index 01b4d21daecf..7609f94b0a37 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -1,4 +1,5 @@ import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core'; +import type { Nitro } from 'nitropack'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -35,6 +36,25 @@ export async function deleteSourceMapsAfterBuild(options: Options): Promise Options): void { + const publicDir = nitro.options.output.publicDir; + const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); + + rollupConfig.plugins = [ + rollupConfig.plugins, + { + name: 'sentry-nuxt-public-source-map-deletion', + // By now the client build is in the public dir, and Nitro has not yet listed its files. + async buildStart() { + await deletePublicSourceMaps(getOptions(), publicDir); + }, + }, + ]; +} + /** * Deletes the files in `publicDir` that match `filesToDeleteAfterUpload`. Nitro answers a request for a file it * listed at build time but cannot read with a 500. Files outside `publicDir` are deleted in `close`. diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index c6971ab0594b..5a6000e51b2b 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -4,7 +4,11 @@ import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plu import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { + addPublicSourceMapDeletionPlugin, + deleteSourceMapsAfterBuild, + withoutSourceMapDeletion, +} from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -124,19 +128,7 @@ export function setupSourceMaps( nuxt.hook('nitro:init', nitro => { if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { - const publicDir = nitro.options.output.publicDir; - const rollupConfig = (nitro.options.rollupConfig ??= { output: {} }); - - rollupConfig.plugins = [ - rollupConfig.plugins, - { - name: 'sentry-nuxt-public-source-map-deletion', - // By now the client build is in the public dir, and Nitro has not yet listed its files. - async buildStart() { - await deletePublicSourceMaps(getPluginOptions(moduleOptions, shouldDeleteFilesFallback), publicDir); - }, - }, - ]; + addPublicSourceMapDeletionPlugin(nitro, () => getPluginOptions(moduleOptions, shouldDeleteFilesFallback)); } });