diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 49b9839d98ba..671ec4526a7a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -15,6 +15,7 @@ import { getSourcemapSources, getSourcemaps, loadMockServerResults, + startNodeServer, } from '@sentry-internal/test-utils'; /** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ @@ -170,6 +171,11 @@ const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_O for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); const unuploaded = injectedDebugIds.filter( debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), @@ -232,4 +238,48 @@ if (keepClientSourceMaps) { console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); } +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. +if (!isStaticBuild) { + const clientChunkUrls = fs + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) + .map( + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, + ); + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + + try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => { + const mapUrl = `${chunkUrl}.map`; + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); + return { mapUrl, status, expectedStatus }; + }), + ); + const unexpectedStatuses = responses + .filter(({ status, expectedStatus }) => status !== expectedStatus) + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); + assert.deepEqual( + unexpectedStatuses, + [], + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', + ); + + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); + } finally { + await server.close(); + } +} + console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 9b245bd74ee5..bb70d9382f9e 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -38,6 +38,9 @@ export { getRuntime } from './runtime'; export type { Runtime } from './runtime'; 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 d03c725e6c8c..5d0f634fac59 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -5,7 +5,7 @@ import { warnOnRemovedBuildOptions } from '@sentry/core'; 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'; /** @@ -112,6 +112,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); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 18f67df7ab40..2059462d1490 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,6 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; +import type * as BundlerPluginsCore from '@sentry/bundler-plugins/core'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { ConfigEnv, Plugin, UserConfig } from 'vite'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; import { setupSourceMaps, type SourceMapSetting } from '../../src/vite/sourceMaps'; const { mockSentryVitePlugin, mockSentryRollupPlugin, mockDeleteArtifacts, mockCreateSentryBuildPluginManager } = @@ -15,7 +19,8 @@ const { mockSentryVitePlugin, mockSentryRollupPlugin, mockDeleteArtifacts, mockC }; }); -vi.mock('@sentry/bundler-plugins/core', () => ({ +vi.mock('@sentry/bundler-plugins/core', async importOriginal => ({ + ...(await importOriginal()), createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, })); vi.mock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: mockSentryVitePlugin })); @@ -369,6 +374,104 @@ describe('setupSourceMaps hooks', () => { }); }); + describe('nitro:init hook', () => { + const PUBLIC_MAP = '.output/public/_nuxt/entry.DxB3kLmA.js.map'; + const PUBLIC_CHUNK = '.output/public/_nuxt/entry.DxB3kLmA.js'; + const SSR_BUILD_MAP = '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map'; + + let rootDir: string; + let cwdSpy: MockInstance; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-root-')); + // The fallback deletion patterns are relative to the working directory. + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(rootDir); + for (const file of [PUBLIC_MAP, PUBLIC_CHUNK, SSR_BUILD_MAP]) { + fs.mkdirSync(path.dirname(path.join(rootDir, file)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, file), ''); + } + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function createMockNitro(options: { dev?: boolean } = {}) { + return { + options: { + dev: options.dev ?? false, + output: { publicDir: path.join(rootDir, '.output', 'public') }, + rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, + }, + }; + } + + function findDeletionPlugin(nitro: ReturnType) { + return [nitro.options.rollupConfig.plugins] + .flat(2) + .find( + (plugin): plugin is { name: string; buildStart: () => Promise } => + typeof plugin === 'object' && + plugin !== null && + 'name' in plugin && + plugin.name === 'sentry-nuxt-public-source-map-deletion' && + 'buildStart' in plugin && + typeof plugin.buildStart === 'function', + ); + } + + function remainingFiles(): string[] { + return [PUBLIC_MAP, PUBLIC_CHUNK, SSR_BUILD_MAP].filter(file => fs.existsSync(path.join(rootDir, file))); + } + + it('deletes the source maps in the Nitro public dir when Nitro starts to bundle the server', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: 'hidden', server: true } }); + const nitro = createMockNitro(); + const filesToDeleteAfterUpload = [`${rootDir.split(path.sep).join('/')}/.output/public/**/*.map`]; + + setupSourceMaps({ sourcemaps: { filesToDeleteAfterUpload } }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + const deletionPlugin = findDeletionPlugin(nitro); + await deletionPlugin?.buildStart(); + + expect(deletionPlugin).toBeDefined(); + expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); + }); + + it('deletes only the public source maps with the fallback patterns and keeps the SSR build maps', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); + const nitro = createMockNitro(); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + const deletionPlugin = findDeletionPlugin(nitro); + await deletionPlugin?.buildStart(); + + expect(deletionPlugin).toBeDefined(); + expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); + }); + + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true }, nitroOptions: {} }, + { label: 'dev mode', nuxtOptions: { dev: true }, nitroOptions: { dev: true } }, + ])('does not add the deletion plugin in $label', async ({ nuxtOptions, nitroOptions }) => { + const mockNuxt = createMockNuxt(nuxtOptions); + const nitro = createMockNitro(nitroOptions); + + setupSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + mockNuxt as unknown as Nuxt, + vi.fn(), + ); + await mockNuxt.triggerHook('nitro:init', nitro); + + expect(findDeletionPlugin(nitro)).toBeUndefined(); + }); + }); + describe('debug logging', () => { it('logs a [Sentry] message in production mode', async () => { const mockNuxt = createMockNuxt({ _prepare: false, dev: false });