From 27e85c741681a70a7a5d1a24b98e72df0e6e93c4 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:53:07 +0200 Subject: [PATCH 1/6] fix(tanstackstart-react): Rewrite source paths of split route modules TanStack Start splits route files into virtual modules such as `index.tsx?tss-serverfn-split`. The uploaded source maps kept these queries and paths relative to the map's directory, so frames did not show the path of the source file. Remove the split queries and make source paths relative to the project root, so frames show paths such as `src/routes/index.tsx`. A user's `sourcemaps.rewriteSources` still takes precedence. --- .../src/vite/sourceMaps.ts | 28 +++++++- .../test/vite/sourceMaps.test.ts | 67 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 817cad432a22..113444db628a 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,9 +1,35 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import * as path from 'node:path'; import type { Plugin, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; +/** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ +const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; + +/** Sources such as `virtual:#nitro/runtime` are no file paths. Two letters at least, so `C:` stays a drive letter. */ +const SCHEME_REGEX = /^[a-z][a-z\d+.-]+:/i; + +/** + * Makes source paths relative to the project root and removes TanStack's split queries, so that client and server + * frames show the path of the source file, such as `src/routes/index.tsx`. + * + * Paths in a source map are relative to the map's directory, which the default hook ignores. + * + * @internal Only exported for testing. + */ +export function rewriteTanstackStartSource(source: string, _map: unknown, context?: { mapDir: string }): string { + const sourcePath = source.replace(TANSTACK_SPLIT_QUERY_REGEX, ''); + + if (SCHEME_REGEX.test(sourcePath)) { + return sourcePath; + } + + const absolutePath = path.resolve(context?.mapDir ?? process.cwd(), sourcePath); + return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -70,7 +96,7 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index e5ea14c95b3e..eb4b3740b658 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -2,11 +2,13 @@ import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import type { UserConfig } from 'vite'; import { mergeConfig } from 'vite'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; import { getUpdatedSourceMapSettings, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin, + rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; const mockedSentryVitePlugin = { @@ -273,6 +275,16 @@ describe('makeAddSentryVitePlugin()', () => { ); }); + it('rewrites sources with the TanStack Start hook by default', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ rewriteSources: rewriteTanstackStartSource }), + }), + ); + }); + it('sets the correct metaFramework in telemetry options', () => { makeAddSentryVitePlugin({ org: 'my-org', @@ -353,3 +365,56 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe('hidden'); }); }); + +describe('rewriteTanstackStartSource', () => { + const projectRoot = path.resolve('/projects/shop'); + + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + label: 'a server function split from a route', + source: '../../../src/routes/index.tsx?tss-serverfn-split', + mapDir: '.output/server/_ssr', + expected: 'src/routes/index.tsx', + }, + { + label: 'a route component split for the client', + source: '../../../src/routes/users.$userId.tsx?tsr-split=component', + mapDir: '.output/public/assets', + expected: 'src/routes/users.$userId.tsx', + }, + { + label: 'a dependency', + source: '../../node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + mapDir: '.output/server', + expected: 'node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + }, + { + label: 'a source that climbs fewer directories than the map is deep', + source: '../shared/format-price.ts', + mapDir: '.output/server/chunks/build', + expected: '.output/server/chunks/shared/format-price.ts', + }, + ])('makes the path of $label relative to the project root', ({ source, mapDir, expected }) => { + expect(rewriteTanstackStartSource(source, {}, { mapDir: path.join(projectRoot, mapDir) })).toBe(expected); + }); + + it('keeps sources that are no file paths', () => { + const mapDir = path.join(projectRoot, '.output/server'); + + expect(rewriteTanstackStartSource('virtual:#nitro/virtual/plugins', {}, { mapDir })).toBe( + 'virtual:#nitro/virtual/plugins', + ); + }); + + it('resolves sources from the project root without a map directory', () => { + expect(rewriteTanstackStartSource('src/routes/index.tsx?tss-serverfn-split', {})).toBe('src/routes/index.tsx'); + }); +}); From 2f5889c11e6873fb2646f2a0faae1566ff69e6a3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:53:51 +0200 Subject: [PATCH 2/6] fix(tanstackstart-react): Delete only the source maps that Sentry enabled The default `filesToDeleteAfterUpload: ['./**/*.map']` does not enter dot directories like `.output`, so source maps that Sentry enabled stayed in the client build of Nitro apps. Delete the source maps in the output directory of each environment that only has source maps because Sentry enabled them. Keep source maps that the user enabled with `build.sourcemap`, an environment setting or `build.rolldownOptions.output.sourcemap`. Warn on Vite 7, where Sentry only knows `build.outDir`. --- .../src/vite/sourceMaps.ts | 120 ++++++-- .../test/vite/sourceMaps.test.ts | 264 +++++++++++++----- 2 files changed, 290 insertions(+), 94 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 113444db628a..3f39e8acb6e8 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,10 +1,16 @@ 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; +/** + * 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 }; + /** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; @@ -62,21 +68,27 @@ 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 && filesToDelete.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.`, + ); } + resolveFilesToDeleteAfterUpload?.(filesToDelete); }, }; @@ -116,21 +128,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 +205,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 +246,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..b545a33d72a9 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,161 @@ 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('does not log the default when Sentry enabled no source maps', 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).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + consoleSpy.mockRestore(); + }); }); it('passes rewriteSources to the vite plugin', () => { @@ -359,6 +467,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({}); From a58eeb92758f1a270b5784b43c576dd1b9c70be9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:58 +0200 Subject: [PATCH 3/6] fix(tanstackstart-react): Unminify server errors in Nitro builds Server errors in TanStack Start stayed minified. Sentry did not turn on Nitro's source maps, and Nitro's maps stopped at its intermediate SSR bundle in `node_modules/.nitro`. Load the SSR source maps into Nitro's build, so the final maps point to `src/`. Turn on 'hidden' Nitro source maps only if no Vite or Nitro config sets them, and always set `sourcemapMinify: false` to keep `sourcesContent`. Delete the Nitro source maps that Sentry turned on after the upload. Fixes #21012 Co-Authored-By: kyrylolvov --- .../src/vite/nitroSourceMaps.ts | 159 +++++++++++++ .../src/vite/sentryTanstackStart.ts | 5 +- .../src/vite/sourceMaps.ts | 33 +-- .../test/vite/nitroSourceMaps.test.ts | 209 ++++++++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 26 +++ .../test/vite/sourceMapScenarios.test.ts | 189 ++++++++++++++++ .../test/vite/sourceMaps.test.ts | 20 +- 7 files changed, 624 insertions(+), 17 deletions(-) create mode 100644 packages/tanstackstart-react/src/vite/nitroSourceMaps.ts create mode 100644 packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts create mode 100644 packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts new file mode 100644 index 000000000000..c4247c7877d2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -0,0 +1,159 @@ +import { readFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; + +/** `nitro/vite` builds the final server in an environment with this name. */ +const NITRO_ENVIRONMENT = 'nitro'; + +/** The environment Vite passes to `applyToEnvironment`. */ +export type PluginEnvironment = Parameters>[0]; + +type BundlerSourceMapConfig = { output?: { sourcemap?: unknown } }; + +type NitroSourceMapConfig = { + sourcemap?: unknown; + rollupConfig?: BundlerSourceMapConfig; + rolldownConfig?: BundlerSourceMapConfig; +}; + +/** The parts of the Nitro instance that this plugin uses. */ +type Nitro = { + options: { + sourcemap: unknown; + experimental: { sourcemapMinify?: false }; + // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. + _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; + }; +}; + +/** + * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only + * there because Sentry enabled them. + */ +export type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; + +/** + * Whether Nitro bundles the output of this environment again. With `nitro/vite`, every server environment besides + * `nitro` is a Nitro service, such as TanStack Start's `ssr` environment. + */ +export function isNitroService(environment: PluginEnvironment): boolean { + return ( + environment.name !== NITRO_ENVIRONMENT && + environment.config.consumer === 'server' && + NITRO_ENVIRONMENT in environment.config.environments + ); +} + +/** + * Keeps the sources in the source maps of Nitro's server build. Enables these source maps, unless a Nitro config + * source or `build.rolldownOptions.output.sourcemap` of the Vite config sets them, or the Vite config disables them. + */ +export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): Plugin & { + api: SourceMapsEnablerApi; + nitro: { name: string; setup: (nitro: Nitro) => void }; +} { + let enabledSourceMaps = false; + let viteConfigPreventsSourceMaps = false; + + return { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', + // The `config` hook must run before the one of `nitro/vite`, which runs the Nitro modules. + enforce: 'pre', + api: { + hasSentryEnabledSourceMaps: environmentName => enabledSourceMaps && environmentName === NITRO_ENVIRONMENT, + }, + config(viteConfig) { + // Nitro overrides `build.sourcemap` for its environments, but `false` shows that the user wants no source maps. + viteConfigPreventsSourceMaps = + viteConfig.build?.sourcemap === false || hasOutputSourceMapSetting(viteConfig.build); + }, + // `nitro/vite` installs the Nitro module of each Vite plugin after it merged all config sources. + nitro: { + name: 'sentry-tanstackstart-nitro-source-maps', + setup(nitro) { + // Otherwise Nitro removes `sourcesContent` from its source maps, and `mappings` from maps with only + // `node_modules` sources. Nitro only allows `false`. + nitro.options.experimental.sourcemapMinify = false; + + const layers = nitro.options._c12?.layers; + // Without the config sources, Sentry cannot tell if the user disabled source maps. + if (!layers || viteConfigPreventsSourceMaps || layers.some(layer => hasNitroSourceMapSetting(layer.config))) { + return; + } + + nitro.options.sourcemap = 'hidden'; + enabledSourceMaps = true; + logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`."); + }, + }, + }; +} + +/** + * Nitro reads the output of its services as plain code, so its source maps would end at that output. Loading the + * service's source map with the code lets the bundler compose both maps to the original source files. + */ +export function makeComposeNitroServiceSourceMapsPlugin(): Plugin { + return { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + applyToEnvironment: environment => environment.name === NITRO_ENVIRONMENT, + async load(id) { + const serviceOutDirs = getNitroServiceOutDirs(this.environment.config); + if (!serviceOutDirs.some(outDir => isInDirectory(id, outDir))) { + return null; + } + + try { + const [code, map] = await Promise.all([readFile(id, 'utf8'), readFile(`${id}.map`, 'utf8')]); + return { code, map }; + } catch { + // A chunk without a source map: let Nitro load the code as usual. + return null; + } + }, + }; +} + +/** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */ +export function getNitroServiceOutDirs(config: ResolvedConfig): string[] { + if (!(NITRO_ENVIRONMENT in config.environments)) { + return []; + } + + return Object.entries(config.environments) + .filter(([name, environment]) => name !== NITRO_ENVIRONMENT && environment.consumer === 'server') + .map(([, environment]) => path.resolve(config.root, environment.build.outDir)); +} + +function isInDirectory(file: string, directory: string): boolean { + const relativePath = path.relative(directory, file); + return ( + relativePath !== '' && + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ +export function hasOutputSourceMapSetting( + build: Pick | undefined, +): boolean { + return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); +} + +/** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */ +function hasNitroSourceMapSetting(config: NitroSourceMapConfig | null | undefined): boolean { + return [config?.sourcemap, config?.rollupConfig?.output?.sourcemap, config?.rolldownConfig?.output?.sourcemap].some( + setting => setting !== undefined, + ); +} + +function logDebug(debug: boolean | undefined, message: string): void { + if (debug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] ${message}`); + } +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index c3359c077d1b..3c1c28db9bd6 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -2,6 +2,7 @@ import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sen import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; @@ -92,7 +93,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push(...makeAddSentryVitePlugin(options)); + plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { @@ -102,7 +103,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // source maps const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; if (!sourceMapsDisabled) { - plugins.push(...makeEnableSourceMapsVitePlugin(options)); + plugins.push(...makeEnableSourceMapsVitePlugin(options), makeEnableNitroSourceMapsPlugin(options)); } return plugins; diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 3f39e8acb6e8..ee6ce6f7f8be 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,16 +1,17 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { + getNitroServiceOutDirs, + hasOutputSourceMapSetting, + isNitroService, + type PluginEnvironment, + type SourceMapsEnablerApi, +} from './nitroSourceMaps'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; -/** - * 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 }; - /** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; @@ -125,7 +126,14 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl }, }); - return [configPlugin, ...sentryPlugins]; + // Nitro bundles the output of its services again. Debug IDs from a service would end up in Nitro's output and point + // to the service's source maps instead of Nitro's. + const scopedSentryPlugins = sentryPlugins.map(plugin => ({ + ...plugin, + applyToEnvironment: (environment: PluginEnvironment) => !isNitroService(environment), + })); + + return [configPlugin, ...scopedSentryPlugins]; } /** @@ -133,10 +141,12 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl * such as `./**\/*.map` does not enter dot directories like `.output`, where Nitro puts the client build. */ function getSentryEnabledSourceMapGlobs(config: ResolvedConfig): string[] { + const serviceOutDirs = getNitroServiceOutDirs(config); const outDirs = Object.entries(config.environments) .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) - .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)); + .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)) + .filter(outDir => !serviceOutDirs.includes(outDir)); return [...new Set(outDirs)].map(outDir => { const relativeOutDir = path.relative(process.cwd(), outDir).split(path.sep).join('/'); @@ -151,11 +161,6 @@ function getOutputDirs(root: string, build: ResolvedConfig['environments'][strin 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; diff --git a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts new file mode 100644 index 000000000000..c3deb2e93e57 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts @@ -0,0 +1,209 @@ +// @vitest-environment node +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { UserConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + isNitroService, + makeComposeNitroServiceSourceMapsPlugin, + makeEnableNitroSourceMapsPlugin, + type PluginEnvironment, +} from '../../src/vite/nitroSourceMaps'; + +const SSR_OUT_DIR = 'node_modules/.nitro/vite/services/ssr'; + +function createEnvironment(name: string, consumer: 'client' | 'server', environmentNames: string[]): PluginEnvironment { + const environments = Object.fromEntries(environmentNames.map(environmentName => [environmentName, {}])); + return { name, config: { consumer, environments } } as unknown as PluginEnvironment; +} + +describe('isNitroService', () => { + it.each([ + { + label: 'the ssr environment of a Nitro build', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: true, + }, + { + label: 'the nitro environment', + name: 'nitro', + consumer: 'server', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the client environment', + name: 'client', + consumer: 'client', + environments: ['client', 'ssr', 'nitro'], + expected: false, + }, + { + label: 'the ssr environment without Nitro', + name: 'ssr', + consumer: 'server', + environments: ['client', 'ssr'], + expected: false, + }, + ] as const)('returns $expected for $label', ({ name, consumer, environments, expected }) => { + expect(isNitroService(createEnvironment(name, consumer, [...environments]))).toBe(expected); + }); +}); + +describe('Nitro source map config', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + type NitroConfig = { + sourcemap?: unknown; + rollupConfig?: { output?: { sourcemap?: unknown } }; + rolldownConfig?: { output?: { sourcemap?: unknown } }; + }; + + /** Runs the Nitro module with the config sources that Nitro merged, e.g. the `nitro()` options and `nitro.config.ts`. */ + function setup( + layers: NitroConfig[] | undefined, + { debug, viteConfig = {} }: { debug?: boolean; viteConfig?: UserConfig } = {}, + ) { + const plugin = makeEnableNitroSourceMapsPlugin({ debug }); + (plugin.config as (config: UserConfig) => void)(viteConfig); + const nitro = { + options: { sourcemap: false, experimental: {}, _c12: layers && { layers: layers.map(config => ({ config })) } }, + }; + plugin.nitro.setup(nitro); + + return { plugin, nitroOptions: { sourcemap: nitro.options.sourcemap, experimental: nitro.options.experimental } }; + } + + it('enables hidden source maps with their sources when no config source sets them', () => { + const { plugin, nitroOptions } = setup([{}, {}]); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + expect(plugin.api.hasSentryEnabledSourceMaps('client')).toBe(false); + }); + + it.each([false, true, 'hidden'])( + 'keeps the setting `sourcemap: %s` of a config source, with the sources', + sourcemap => { + const { plugin, nitroOptions } = setup([{}, { sourcemap }]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }, + ); + + it.each([ + { label: '`rollupConfig.output.sourcemap`', layer: { rollupConfig: { output: { sourcemap: true } } } }, + { label: '`rolldownConfig.output.sourcemap`', layer: { rolldownConfig: { output: { sourcemap: false } } } }, + ])('keeps the source maps when a config source sets $label', ({ layer }) => { + const { plugin, nitroOptions } = setup([{}, layer]); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it.each([ + { label: '`build.sourcemap: false`', build: { sourcemap: false } }, + { label: '`build.rolldownOptions.output.sourcemap`', build: { rolldownOptions: { output: { sourcemap: true } } } }, + ])('keeps the source maps when the Vite config sets $label', ({ build }) => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build } }); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('enables source maps when the Vite config sets `build.sourcemap: true`, which Nitro overrides', () => { + const { plugin, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); + + expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + }); + + it('does not enable source maps when Nitro does not expose its config sources', () => { + const { plugin, nitroOptions } = setup(undefined); + + expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); + expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + }); + + it('logs that it enabled source maps in debug mode', () => { + const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + setup([], { debug: true }); + + expect(consoleLogSpy).toHaveBeenCalledWith( + "[Sentry] Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`.", + ); + }); +}); + +describe('Nitro service source maps', () => { + let rootDir: string; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-tanstackstart-nitro-')); + fs.mkdirSync(path.join(rootDir, SSR_OUT_DIR, 'assets'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function writeFile(relativePath: string, contents: string): string { + const file = path.join(rootDir, relativePath); + fs.writeFileSync(file, contents); + return file; + } + + function load(id: string): Promise { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const context = { + environment: { + config: { + root: rootDir, + environments: { + client: { consumer: 'client', build: { outDir: '.output/public' } }, + ssr: { consumer: 'server', build: { outDir: SSR_OUT_DIR } }, + nitro: { consumer: 'server', build: { outDir: '.output/server' } }, + }, + }, + }, + }; + return (plugin.load as unknown as (this: typeof context, id: string) => Promise).call(context, id); + } + + it('applies only to the nitro environment', () => { + const plugin = makeComposeNitroServiceSourceMapsPlugin(); + const applyToEnvironment = plugin.applyToEnvironment as (environment: PluginEnvironment) => boolean; + + expect(applyToEnvironment(createEnvironment('nitro', 'server', ['ssr', 'nitro']))).toBe(true); + expect(applyToEnvironment(createEnvironment('ssr', 'server', ['ssr', 'nitro']))).toBe(false); + }); + + it('loads a service chunk together with its source map', async () => { + const map = '{"version":3,"sources":["../../../../../src/routes/index.tsx"],"mappings":"AAAA"}'; + const chunk = writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js`, 'export const handler = () => {};'); + writeFile(`${SSR_OUT_DIR}/assets/index-BKeMB_FB.js.map`, map); + + await expect(load(chunk)).resolves.toEqual({ code: 'export const handler = () => {};', map }); + }); + + it('leaves a service chunk without a source map to Nitro', async () => { + const chunk = writeFile(`${SSR_OUT_DIR}/assets/empty-plugin-adapters-BJX69BzA.js`, 'export {};'); + + await expect(load(chunk)).resolves.toBeNull(); + }); + + it('leaves modules outside the service output to Nitro', async () => { + fs.mkdirSync(path.join(rootDir, 'src'), { recursive: true }); + const module = writeFile('src/server.ts', 'export default {};'); + writeFile('src/server.ts.map', '{"version":3,"sources":[],"mappings":""}'); + + await expect(load(module)).resolves.toBeNull(); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 0715873cdfa5..106c8fb633ae 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -23,6 +23,17 @@ const mockEnableSourceMapsPlugin: Plugin = { config: vi.fn(), }; +const mockComposeNitroServiceSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-service-source-maps', + apply: 'build', + load: vi.fn(), +}; + +const mockEnableNitroSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-nitro-source-maps', + apply: 'build', +}; + const mockMiddlewarePlugin: Plugin = { name: 'sentry-tanstack-middleware-auto-instrument', apply: 'build', @@ -61,6 +72,11 @@ vi.mock('../../src/vite/sourceMaps', () => ({ makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); +vi.mock('../../src/vite/nitroSourceMaps', () => ({ + makeComposeNitroServiceSourceMapsPlugin: vi.fn(() => mockComposeNitroServiceSourceMapsPlugin), + makeEnableNitroSourceMapsPlugin: vi.fn(() => mockEnableNitroSourceMapsPlugin), +})); + vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); @@ -88,7 +104,9 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); @@ -122,6 +140,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -136,6 +155,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -150,7 +170,9 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, + mockEnableNitroSourceMapsPlugin, ]); }); }); @@ -164,6 +186,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -179,6 +202,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); @@ -194,6 +218,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -226,6 +251,7 @@ describe('sentryTanstackStart()', () => { mockOrchestrionPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, ]); }); diff --git a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts new file mode 100644 index 000000000000..7effcf3012f7 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts @@ -0,0 +1,189 @@ +// @vitest-environment node +import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; + +let sentryVitePluginOptions: SentryVitePluginOptions | undefined; + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => { + sentryVitePluginOptions = options; + return []; + }, +})); + +type SourceMapSetting = boolean | 'hidden' | 'inline' | undefined; + +type NitroOptions = { + sourcemap?: SourceMapSetting; + rollupConfig?: { output?: { sourcemap?: SourceMapSetting } }; + rolldownConfig?: { output?: { sourcemap?: SourceMapSetting } }; +}; + +type NitroModulePlugin = Plugin & { nitro: { setup(nitro: unknown): void } }; + +const ENVIRONMENT_NAMES = ['client', 'ssr', 'nitro'] as const; + +function findPlugin(plugins: Plugin[], name: string): T { + const plugin = plugins.find(plugin => plugin.name === name); + expect(plugin).toBeDefined(); + return plugin as T; +} + +function callConfigHook(plugin: Plugin, config: UserConfig): UserConfig { + return (plugin.config as (config: UserConfig) => UserConfig | undefined)(config) ?? {}; +} + +/** The source map setting that applies to the output of an environment. */ +function getOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { + const output = build.rolldownOptions?.output; + return (Array.isArray(output) ? undefined : output?.sourcemap) ?? build.sourcemap; +} + +/** + * Runs Sentry's source map plugins in the order of a build with `nitro/vite`, which configures Nitro between the + * `config` hooks of the Sentry plugins with `enforce: 'pre'` and `enforce: 'post'`. + */ +async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfig; nitro?: NitroOptions }) { + const plugins = sentryTanstackStart({}); + const nitroPlugin = findPlugin(plugins, 'sentry-tanstackstart-nitro-source-maps'); + + const viteConfig = structuredClone(vite); + callConfigHook(nitroPlugin, viteConfig); + + const nitro = { + options: { + sourcemap: nitroOptions.sourcemap ?? false, + experimental: {}, + _c12: { layers: [{ config: nitroOptions }] }, + }, + }; + nitroPlugin.nitro.setup(nitro); + + const nitroOutput = { ...nitroOptions.rollupConfig?.output, ...nitroOptions.rolldownConfig?.output }; + const configWithNitro: UserConfig = mergeConfig(viteConfig, { + environments: { + client: { build: { outDir: '.output/public' } }, + ssr: { build: { sourcemap: nitro.options.sourcemap, outDir: 'node_modules/.nitro/vite/services/ssr' } }, + nitro: { + build: { + sourcemap: nitro.options.sourcemap, + rolldownOptions: { output: { ...nitroOutput, dir: '.output/server' } }, + }, + }, + }, + }); + const userConfig: UserConfig = mergeConfig( + configWithNitro, + callConfigHook(findPlugin(plugins, 'sentry-tanstackstart-react-source-maps'), configWithNitro), + ); + + const environments = Object.fromEntries( + ENVIRONMENT_NAMES.map(name => [ + name, + { + consumer: name === 'client' ? 'client' : 'server', + build: mergeConfig({ outDir: 'dist', ...userConfig.build }, userConfig.environments?.[name]?.build ?? {}), + }, + ]), + ); + const resolvedConfig = { root: process.cwd(), plugins, environments } as unknown as ResolvedConfig; + const deletionPlugin = findPlugin(plugins, 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); + (deletionPlugin.configResolved as (this: unknown, config: ResolvedConfig) => void).call( + { meta: { viteVersion: '8.3.4' } }, + resolvedConfig, + ); + + return { + client: getOutputSourceMapSetting(environments.client!.build), + ssr: getOutputSourceMapSetting(environments.ssr!.build), + nitro: getOutputSourceMapSetting(environments.nitro!.build), + filesToDelete: await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload, + }; +} + +const CLIENT_MAPS = '.output/public/**/*.map'; +const SERVER_MAPS = '.output/server/**/*.map'; + +describe('source map scenarios of a build with Nitro', () => { + beforeEach(() => { + sentryVitePluginOptions = undefined; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: Awaited> }>([ + { + label: 'nothing', + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [CLIENT_MAPS, SERVER_MAPS] }, + }, + { + label: 'nitro({ sourcemap: true })', + nitro: { sourcemap: true }, + expected: { client: 'hidden', ssr: true, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ sourcemap: false })', + nitro: { sourcemap: false }, + expected: { client: 'hidden', ssr: false, nitro: false, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rollupConfig: { output: { sourcemap: true } } })', + nitro: { rollupConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'nitro({ rolldownConfig: { output: { sourcemap: true } } })', + nitro: { rolldownConfig: { output: { sourcemap: true } } }, + expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + }, + { + label: 'build.sourcemap: true', + vite: { build: { sourcemap: true } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: "build.sourcemap: 'hidden'", + vite: { build: { sourcemap: 'hidden' } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.sourcemap: false', + vite: { build: { sourcemap: false } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.sourcemap: true', + vite: { environments: { client: { build: { sourcemap: true } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'environments.client.build.sourcemap: false', + vite: { environments: { client: { build: { sourcemap: false } } } }, + expected: { client: false, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: true', + vite: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: { client: true, ssr: true, nitro: true, filesToDelete: [] }, + }, + { + label: 'build.rolldownOptions.output.sourcemap: false', + vite: { build: { rolldownOptions: { output: { sourcemap: false } } } }, + expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + }, + { + label: 'environments.client.build.rolldownOptions.output.sourcemap: true', + vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: true } } } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + }, + ])('keeps the source maps of the user and deletes the others when the user sets $label', async scenario => { + await expect(build(scenario)).resolves.toEqual(scenario.expected); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index b545a33d72a9..6467caced32e 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -210,6 +210,23 @@ describe('makeAddSentryVitePlugin()', () => { expect(typeof configPlugin?.configResolved).toBe('function'); }); + it.each([ + { name: 'client', consumer: 'client', expected: true }, + { name: 'ssr', consumer: 'server', expected: false }, + { name: 'nitro', consumer: 'server', expected: true }, + ] as const)( + 'applies the Sentry Vite plugin to the $name environment of a Nitro build: $expected', + ({ name, consumer, expected }) => { + const sentryPlugin = makeAddSentryVitePlugin({}).find(plugin => plugin.name === mockedSentryVitePlugin.name); + const environment = { name, config: { consumer, environments: { client: {}, ssr: {}, nitro: {} } } }; + + expect(sentryPlugin).toBeDefined(); + expect((sentryPlugin?.applyToEnvironment as unknown as (env: typeof environment) => boolean)(environment)).toBe( + expected, + ); + }, + ); + describe('default filesToDeleteAfterUpload', () => { type TestEnvironment = { consumer: 'client' | 'server'; @@ -220,6 +237,7 @@ describe('makeAddSentryVitePlugin()', () => { const NITRO_ENVIRONMENTS: Record = { client: { consumer: 'client', outDir: '.output/public' }, + ssr: { consumer: 'server', outDir: 'node_modules/.nitro/vite/services/ssr' }, // Nitro sets the output directory of its server build with `output.dir` only. nitro: { consumer: 'server', outDir: 'dist', outputDir: '.output/server' }, }; @@ -270,7 +288,7 @@ describe('makeAddSentryVitePlugin()', () => { it.each([ { - label: 'a Nitro build', + label: 'a Nitro build, but not in the Nitro service output', environments: NITRO_ENVIRONMENTS, expected: ['.output/public/**/*.map', '.output/server/**/*.map'], }, From 38a674a8a385b4da777112591243696cedd22ab2 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:07:52 +0200 Subject: [PATCH 4/6] chore: Add external contributor to CHANGELOG.md Credit @kyrylolvov for #24356, which this fix is based on. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5571f47fb4eb..95d81c47cd62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @kyrylolvov. Thank you for your contribution! + ### Important Changes - **feat(bundler-plugins): Release `@sentry/webpack-plugin`, `@sentry/vite-plugin`, `@sentry/rollup-plugin` and `@sentry/esbuild-plugin` from the SDK repository ([#25095](https://github.com/getsentry/sentry-javascript/pull/25095))** From 9b4ac7e6b36513c816399da7bdfb68a68b45009b Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:34:40 +0200 Subject: [PATCH 5/6] ref(tanstackstart-react): Link the Nitro issue about internal config layers Sentry reads Nitro's config sources from the internal `_c12` option. Link the issue that asks Nitro to expose them, so the code can move to the public option later. --- packages/tanstackstart-react/src/vite/nitroSourceMaps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts index c4247c7877d2..f759cc125fc2 100644 --- a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -22,6 +22,7 @@ type Nitro = { sourcemap: unknown; experimental: { sourcemapMinify?: false }; // c12 keeps each config source that Nitro merged: the `nitro()` options, `nitro.config.ts`, presets and more. + // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 _c12?: { layers?: { config?: NitroSourceMapConfig | null }[] }; }; }; From a8059fa8ad83484e15a875ba3ad03aa588f1bf8a Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:35:08 +0200 Subject: [PATCH 6/6] fix(tanstackstart-react): Log when Sentry deletes no source maps In debug mode, log that no source maps are deleted after the upload when Sentry did not enable source map generation for the build. Before, debug mode logged nothing in this case. --- packages/tanstackstart-react/src/vite/sourceMaps.ts | 6 ++++-- packages/tanstackstart-react/test/vite/sourceMaps.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 0da8126574cc..f84c52692899 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -82,10 +82,12 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl } const filesToDelete = getSentryEnabledSourceMapGlobs(config); - if (debug && filesToDelete.length > 0) { + if (debug) { // eslint-disable-next-line no-console console.log( - `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.`, + 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); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index b545a33d72a9..0f41d8d261ef 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -354,12 +354,14 @@ describe('makeAddSentryVitePlugin()', () => { consoleSpy.mockRestore(); }); - it('does not log the default when Sentry enabled no source maps', async () => { + it('logs that it deletes no source maps when Sentry enabled none', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments: [], options: { debug: true } }); - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + 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(); }); });