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))** diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts new file mode 100644 index 000000000000..f759cc125fc2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -0,0 +1,160 @@ +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. + // It is internal until Nitro exposes the layers: https://github.com/nitrojs/nitro/issues/4771 + _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 0da8126574cc..b211f7ff7d9d 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,6 +1,13 @@ 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; @@ -30,12 +37,6 @@ 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. */ @@ -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'], },