diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index a0cf3f4e84c7..f84c52692899 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,6 +1,6 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { Plugin, UserConfig } from 'vite'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; @@ -30,6 +30,12 @@ export function rewriteTanstackStartSource(source: string, _map: unknown, contex return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); } +/** + * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only + * there because Sentry enabled them. + */ +type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -62,21 +68,29 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', apply: 'build', enforce: 'post', - config(config) { - const userFilesToDelete = sourcemaps?.filesToDeleteAfterUpload; - - // Only auto-delete source maps if the user didn't configure sourcemaps at all - if (typeof userFilesToDelete === 'undefined' && typeof config.build?.sourcemap === 'undefined') { - if (debug) { - // eslint-disable-next-line no-console - console.log( - '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: ["./**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', - ); - } - resolveFilesToDeleteAfterUpload?.(['./**/*.map']); - } else { - resolveFilesToDeleteAfterUpload?.(userFilesToDelete); + configResolved(config) { + if (sourcemaps?.filesToDeleteAfterUpload !== undefined) { + resolveFilesToDeleteAfterUpload?.(sourcemaps.filesToDeleteAfterUpload); + return; + } + + if (!isVite8OrLater(this.meta.viteVersion)) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] TanStack Start requires Vite 8 or later. With an older Vite, Sentry deletes the source maps it generated only in \`build.outDir\` of each environment, so source maps in other output directories can stay in the build output.`, + ); + } + + const filesToDelete = getSentryEnabledSourceMapGlobs(config); + if (debug) { + // eslint-disable-next-line no-console + console.log( + filesToDelete.length > 0 + ? `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.` + : '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', + ); } + resolveFilesToDeleteAfterUpload?.(filesToDelete); }, }; @@ -116,21 +130,72 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl return [configPlugin, ...sentryPlugins]; } +/** + * Globs for the source maps in the output directories that only have source maps because Sentry enabled them. A glob + * such as `./**\/*.map` does not enter dot directories like `.output`, where Nitro puts the client build. + */ +function getSentryEnabledSourceMapGlobs(config: ResolvedConfig): string[] { + const outDirs = Object.entries(config.environments) + .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) + .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) + .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)); + + return [...new Set(outDirs)].map(outDir => { + const relativeOutDir = path.relative(process.cwd(), outDir).split(path.sep).join('/'); + return `${relativeOutDir || '.'}/**/*.map`; + }); +} + +/** `output.dir` takes precedence over `build.outDir`. Nitro sets only `output.dir` for its server build. */ +function getOutputDirs(root: string, build: ResolvedConfig['environments'][string]['build']): string[] { + // Vite 7 has no `rolldownOptions`. + const outputs = [build.rolldownOptions?.output ?? {}].flat(); + return outputs.map(output => path.resolve(root, output.dir ?? build.outDir)); +} + +/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ +function hasOutputSourceMapSetting(build: Pick | undefined): boolean { + return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); +} + +/** Vite tells plugins its version since Vite 7. */ +function isVite8OrLater(viteVersion: string | undefined): boolean { + return Number(viteVersion?.split('.')[0]) >= 8; +} + +function hasSentryEnabledSourceMaps(config: ResolvedConfig, environmentName: string): boolean { + return config.plugins.some(plugin => + (plugin.api as Partial | undefined)?.hasSentryEnabledSourceMaps?.(environmentName), + ); +} + /** * A Sentry plugin for TanStack Start React to enable "hidden" source maps if they are unset. */ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptions): Plugin[] { + let enabledSourceMaps = false; + let environmentsWithSourceMapSetting: string[] = []; + + const api: SourceMapsEnablerApi = { + hasSentryEnabledSourceMaps: environmentName => + enabledSourceMaps && !environmentsWithSourceMapSetting.includes(environmentName), + }; + return [ { name: 'sentry-tanstackstart-react-source-maps', apply: 'build', enforce: 'post', + api, config(viteConfig) { - return { - build: { - sourcemap: getUpdatedSourceMapSettings(viteConfig, options), - }, - }; + const sourcemap = getUpdatedSourceMapSettings(viteConfig, options); + enabledSourceMaps = sourcemap !== viteConfig.build?.sourcemap; + // The setting of an environment overrides the top-level setting, e.g. `nitro/vite` sets it for its environments. + environmentsWithSourceMapSetting = Object.entries(viteConfig.environments ?? {}) + .filter(([, environment]) => environment?.build?.sourcemap !== undefined) + .map(([name]) => name); + + return { build: { sourcemap } }; }, }, ]; @@ -142,19 +207,19 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio * - keep this setting (emit a warning that errors won't be unminified in Sentry) * - We won't upload anything * - * 2. Users enabled source map generation (true, 'hidden', 'inline'). + * 2. Users enabled source map generation (true, 'hidden', 'inline') or set `build.rolldownOptions.output.sourcemap`. * - keep this setting (don't do anything - like deletion - besides uploading) * * 3. Users didn't set source maps generation * - we enable 'hidden' source maps generation - * - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + * - configure `filesToDeleteAfterUpload` to delete these .map files after the upload (we emit a log about this) * - * --> only exported for testing + * @internal Only exported for testing. */ export function getUpdatedSourceMapSettings( viteConfig: UserConfig, sentryPluginOptions?: SentryTanstackStartOptions, -): boolean | 'inline' | 'hidden' { +): boolean | 'inline' | 'hidden' | undefined { viteConfig.build = viteConfig.build || {}; const viteUserSourceMapSetting = viteConfig.build?.sourcemap; @@ -183,6 +248,15 @@ export function getUpdatedSourceMapSettings( } return viteUserSourceMapSetting; + } else if (hasOutputSourceMapSetting(viteConfig.build)) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + '[Sentry] We discovered `vite.build.rolldownOptions.output.sourcemap`, which overrides `vite.build.sourcemap`. Sentry will keep this source map setting.', + ); + } + + return undefined; } // If the user did not specify a source map setting, we enable 'hidden' by default diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index eb4b3740b658..0f41d8d261ef 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { UserConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'path'; @@ -10,13 +10,19 @@ import { makeEnableSourceMapsVitePlugin, rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; +import type { SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; const mockedSentryVitePlugin = { name: 'sentry-vite-debug-id-upload-plugin', writeBundle: vi.fn(), }; -const sentryVitePluginSpy = vi.fn((_options: SentryVitePluginOptions) => [mockedSentryVitePlugin]); +let sentryVitePluginOptions: SentryVitePluginOptions | undefined; + +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { + sentryVitePluginOptions = options; + return [mockedSentryVitePlugin]; +}); vi.mock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), @@ -24,6 +30,7 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ beforeEach(() => { vi.clearAllMocks(); + sentryVitePluginOptions = undefined; }); describe('makeEnableSourceMapsVitePlugin()', () => { @@ -39,6 +46,36 @@ describe('makeEnableSourceMapsVitePlugin()', () => { expect(sourceMapsConfigPlugins).toHaveLength(1); }); + it.each([ + { label: 'the user set no source maps', userConfig: {}, expected: { client: true, ssr: true } }, + { + label: 'the user set `build.sourcemap`', + userConfig: { build: { sourcemap: true } }, + expected: { client: false, ssr: false }, + }, + { + label: 'the user set the source maps of an environment', + userConfig: { environments: { client: { build: { sourcemap: true } } } }, + expected: { client: false, ssr: true }, + }, + { + label: 'the user set `build.rolldownOptions.output.sourcemap`', + userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: { client: false, ssr: false }, + }, + ] as const)( + 'tells which environments only have source maps because of Sentry when $label', + ({ userConfig, expected }) => { + const plugin = makeEnableSourceMapsVitePlugin({})[0]!; + (plugin.config as (config: UserConfig) => UserConfig)(structuredClone(userConfig) as UserConfig); + + expect({ + client: plugin.api.hasSentryEnabledSourceMaps('client'), + ssr: plugin.api.hasSentryEnabledSourceMaps('ssr'), + }).toEqual(expected); + }, + ); + it.each([undefined, false, true, 'hidden', 'inline'] as const)( 'preserves user config without duplicating arrays when sourcemap is %s', sourcemap => { @@ -97,15 +134,14 @@ describe('makeAddSentryVitePlugin()', () => { }); // The filesToDeleteAfterUpload is now a Promise that resolves when the config hook runs - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); - // Trigger the config hook to resolve the Promise + // Trigger the configResolved hook to resolve the Promise const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({}); + (configPlugin?.configResolved as (config: ResolvedConfig) => void)({} as ResolvedConfig); // Verify the Promise resolves to the user-specified value - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; + const resolvedFilesToDelete = await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload; expect(resolvedFilesToDelete).toEqual(['baz/*.js']); expect(sentryVitePluginSpy).toHaveBeenCalledWith( @@ -171,89 +207,163 @@ describe('makeAddSentryVitePlugin()', () => { expect(configPlugin).toBeDefined(); expect(configPlugin?.apply).toBe('build'); expect(configPlugin?.enforce).toBe('post'); - expect(typeof configPlugin?.config).toBe('function'); + expect(typeof configPlugin?.configResolved).toBe('function'); }); - it('uses default filesToDeleteAfterUpload when not specified', async () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - }); - - // The filesToDeleteAfterUpload is now a Promise - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); - - // Trigger the config hook to resolve the Promise (with no sourcemap configured) - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({}); + describe('default filesToDeleteAfterUpload', () => { + type TestEnvironment = { + consumer: 'client' | 'server'; + outDir: string; + outputDir?: string; + outputSourceMap?: boolean; + }; + + const NITRO_ENVIRONMENTS: Record = { + client: { consumer: 'client', outDir: '.output/public' }, + // Nitro sets the output directory of its server build with `output.dir` only. + nitro: { consumer: 'server', outDir: 'dist', outputDir: '.output/server' }, + }; + + /** A plugin that enabled the source maps of the given environments, like the Sentry plugins that enable them. */ + function sourceMapsEnabler(environmentNames: string[]): Plugin { + return { + name: 'source-maps-enabler', + api: { hasSentryEnabledSourceMaps: (environmentName: string) => environmentNames.includes(environmentName) }, + }; + } - // Verify the Promise resolves to the default value - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toEqual(['./**/*.map']); - }); + function resolveFilesToDelete( + environments: Record, + { + sentryEnabledEnvironments = Object.keys(environments), + options = {}, + viteVersion = '8.3.1', + }: { sentryEnabledEnvironments?: string[]; options?: SentryTanstackStartOptions; viteVersion?: string } = {}, + ): Promise { + const isVite7 = viteVersion.startsWith('7.'); + const plugins = makeAddSentryVitePlugin(options); + const config = { + root: process.cwd(), + plugins: [...plugins, sourceMapsEnabler(sentryEnabledEnvironments)], + environments: Object.fromEntries( + Object.entries(environments).map(([name, { consumer, outDir, outputDir, outputSourceMap }]) => [ + name, + { + consumer, + build: { + outDir, + ...(!isVite7 && { rolldownOptions: { output: { dir: outputDir, sourcemap: outputSourceMap } } }), + }, + }, + ]), + ), + } as unknown as ResolvedConfig; + + const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); + (configPlugin?.configResolved as (this: unknown, config: ResolvedConfig) => void).call( + { meta: { viteVersion } }, + config, + ); + + return Promise.resolve(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload); + } - it('does not auto-delete when user configured build.sourcemap', async () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', + it.each([ + { + label: 'a Nitro build', + environments: NITRO_ENVIRONMENTS, + expected: ['.output/public/**/*.map', '.output/server/**/*.map'], + }, + { + label: 'a build without Nitro', + environments: { + client: { consumer: 'client', outDir: 'dist/client' }, + ssr: { consumer: 'server', outDir: 'dist/server' }, + }, + expected: ['dist/client/**/*.map', 'dist/server/**/*.map'], + }, + { + label: 'environments that share an output directory once', + environments: { + client: { consumer: 'client', outDir: 'dist' }, + ssr: { consumer: 'server', outDir: 'dist' }, + }, + expected: ['dist/**/*.map'], + }, + ] as const)('deletes the source maps in each output directory of $label', async ({ environments, expected }) => { + await expect(resolveFilesToDelete(environments)).resolves.toEqual(expected); }); - // The filesToDeleteAfterUpload is a Promise - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); + it.each([ + { label: 'the client', sentryEnabledEnvironments: ['nitro'], expected: ['.output/server/**/*.map'] }, + { label: 'the Nitro server', sentryEnabledEnvironments: ['client'], expected: ['.output/public/**/*.map'] }, + { label: 'any environment', sentryEnabledEnvironments: [], expected: [] }, + ])( + 'keeps the source maps of $label when Sentry did not enable them', + async ({ sentryEnabledEnvironments, expected }) => { + await expect(resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments })).resolves.toEqual( + expected, + ); + }, + ); - // Trigger the config hook with sourcemap configured by user - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({ build: { sourcemap: true } }); + it.each([true, false])( + 'keeps the source maps of an environment that sets `output.sourcemap: %s`', + async outputSourceMap => { + const environments = { + ...NITRO_ENVIRONMENTS, + client: { ...NITRO_ENVIRONMENTS.client!, outputSourceMap }, + }; - // Verify the Promise resolves to undefined (no auto-delete) - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toBeUndefined(); - }); + await expect(resolveFilesToDelete(environments)).resolves.toEqual(['.output/server/**/*.map']); + }, + ); - it('logs auto-delete message when user did not configure sourcemap', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - debug: true, + it('warns and deletes the source maps in `build.outDir` with Vite 7', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const environments = { + client: { consumer: 'client', outDir: 'dist/client' }, + ssr: { consumer: 'server', outDir: 'dist/server' }, + } as const; + + await expect(resolveFilesToDelete(environments, { viteVersion: '7.3.2' })).resolves.toEqual([ + 'dist/client/**/*.map', + 'dist/server/**/*.map', + ]); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('TanStack Start requires Vite 8 or later.')); + consoleWarnSpy.mockRestore(); }); - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); + it('does not warn with Vite 8', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await resolveFilesToDelete(NITRO_ENVIRONMENTS); - // Simulate config hook with no sourcemap configured - if (configPlugin?.config) { - (configPlugin.config as (config: UserConfig) => void)({}); - } + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + it('logs the default in debug mode', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleSpy.mockRestore(); - }); + await resolveFilesToDelete(NITRO_ENVIRONMENTS, { options: { debug: true } }); - it('does not log auto-delete message when user configured sourcemap', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - debug: true, + expect(consoleSpy).toHaveBeenCalledWith( + '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: [".output/public/**/*.map",".output/server/**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', + ); + consoleSpy.mockRestore(); }); - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); + it('logs that it deletes no source maps when Sentry enabled none', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments: [], options: { debug: true } }); - // Simulate config hook with sourcemap configured - if (configPlugin?.config) { - (configPlugin.config as (config: UserConfig) => void)({ build: { sourcemap: true } }); - } - - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); - - consoleSpy.mockRestore(); + expect(consoleSpy).toHaveBeenCalledWith( + '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', + ); + consoleSpy.mockRestore(); + }); }); it('passes rewriteSources to the vite plugin', () => { @@ -359,6 +469,22 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe(expected); }); + it.each([true, false, 'hidden'] as const)( + 'should keep `build.sourcemap` unset when `build.rolldownOptions.output.sourcemap` is %s', + sourcemap => { + const result = getUpdatedSourceMapSettings( + { build: { rolldownOptions: { output: { sourcemap } } } }, + { debug: true }, + ); + + expect(result).toBeUndefined(); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith( + '[Sentry] We discovered `vite.build.rolldownOptions.output.sourcemap`, which overrides `vite.build.sourcemap`. Sentry will keep this source map setting.', + ); + }, + ); + it('should set sourcemap to hidden when not configured', () => { const result = getUpdatedSourceMapSettings({});