diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore index 4187ae735082..634fe9c9c6d9 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -9,3 +9,4 @@ src/routeTree.gen.ts .tmp_chunks .tmp_build_stdout .tmp_build_stderr +.tmp_debug_ids_without_source_map diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts index b0070359e46f..1abc1b7fc93c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -24,12 +24,6 @@ const SERVER_OUTPUT = path.join('.output', 'server'); const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; -const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; - -function readDebugIds(file: string): string[] { - return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); -} - function emittedJsFiles(dir: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -37,22 +31,13 @@ function emittedJsFiles(dir: string): string[] { .map(entry => path.join(entry.parentPath, entry.name)); } -/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ -const deletedFiles = new Set( - [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => - path.relative(fs.realpathSync('.'), match[1] as string), - ), +/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. */ +const debugIdsWithoutSourceMap = new Set( + fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : [], ); -/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ -function debugIdsOfChunksWithoutSourceMap(dir: string): Set { - return new Set( - emittedJsFiles(dir) - .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) - .flatMap(readDebugIds), - ); -} - function sourceMapsIn(dir: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -116,8 +101,9 @@ for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - const exemptDebugIds = debugIdsOfChunksWithoutSourceMap(outputDir); - const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId)); + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts new file mode 100644 index 000000000000..78aff6a1288d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts @@ -0,0 +1,16 @@ +import * as assert from 'assert/strict'; +import * as path from 'path'; +import { findSourceMapFiles, findSourceMappingUrlComments } from '@sentry-internal/test-utils'; + +// Without an auth token nothing is uploaded, but the SDK still asks the bundler plugin to remove the +// source maps it turned on for the client and the Nitro server. +for (const outputDir of [path.join('.output', 'public'), path.join('.output', 'server')]) { + assert.deepEqual(findSourceMapFiles({ outputDir }), [], `Expected no source maps in ${outputDir}`); + assert.deepEqual( + findSourceMappingUrlComments({ outputDir }), + [], + `Expected no sourceMappingURL comments in ${outputDir}`, + ); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json index 1dd5050c0372..6e3c471f0441 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -7,7 +7,9 @@ "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm ts-node --script-mode assert-build.ts" + "test:build:no-upload": "pnpm install && E2E_NO_UPLOAD=1 pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts", + "test:assert:no-upload": "pnpm ts-node --script-mode assert-no-upload.ts" }, "dependencies": { "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", @@ -29,6 +31,13 @@ "vite": "^8.3.0" }, "sentryTest": { - "optional": true + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:no-upload", + "assert-command": "pnpm test:assert:no-upload", + "label": "tanstackstart-react-sourcemaps (no upload)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts index b43b2441b453..48eac51727b6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -2,16 +2,40 @@ import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; +import * as fs from 'node:fs'; import { defineConfig } from 'vite'; +// A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to +// upload. Recorded before the Sentry plugin removes the maps in its own `generateBundle` hook. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + generateBundle: { + order: 'pre' as const, + handler(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }, + }; +} + export default defineConfig({ plugins: [ tanstackStart(), nitro(), viteReact(), + recordChunksWithoutSourceMap(), sentryTanstackStart({ sentryUrl: 'http://localhost:3032', - authToken: 'fake-auth-token', + authToken: process.env.E2E_NO_UPLOAD ? undefined : 'fake-auth-token', org: 'test-org', project: 'test-project', release: { name: 'test-release' }, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 9883b1add4b3..7a9ee28363f6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -7,21 +7,25 @@ import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; // Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug -// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +// IDs can't be uploaded. Recorded here because the Sentry plugin removes the maps from the bundle in its +// own `generateBundle` hook. function recordChunksWithoutSourceMap() { return { name: 'record-chunks-without-source-map', apply: 'build' as const, enforce: 'post' as const, - writeBundle(_options: unknown, bundle: Record) { - for (const output of Object.values(bundle)) { - if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { - continue; + generateBundle: { + order: 'pre' as const, + handler(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } } - for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); - } - } + }, }, }; } diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts index 478c426f9ecb..8d64f130928b 100644 --- a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; +import type { SourceMapState } from './sourceMaps'; /** `nitro/vite` builds the final server in an environment with this name. */ -const NITRO_ENVIRONMENT = 'nitro'; +export const NITRO_ENVIRONMENT = 'nitro'; /** The environment Vite passes to `applyToEnvironment`. */ export type PluginEnvironment = Parameters>[0]; @@ -27,12 +28,6 @@ type Nitro = { }; }; -/** - * 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. @@ -49,11 +44,12 @@ export function isNitroService(environment: PluginEnvironment): boolean { * 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; +export function makeEnableNitroSourceMapsPlugin( + { debug }: { debug?: boolean }, + state: SourceMapState, +): Plugin & { nitro: { name: string; setup: (nitro: Nitro) => void }; } { - let enabledSourceMaps = false; let viteConfigPreventsSourceMaps = false; return { @@ -61,10 +57,8 @@ export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): 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) { + state.sdkEnabledNitroSourceMaps = false; // 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); @@ -84,7 +78,7 @@ export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): } nitro.options.sourcemap = 'hidden'; - enabledSourceMaps = true; + state.sdkEnabledNitroSourceMaps = true; logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`."); }, }, @@ -118,7 +112,7 @@ export function makeComposeNitroServiceSourceMapsPlugin(): Plugin { } /** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */ -export function getNitroServiceOutDirs(config: ResolvedConfig): string[] { +function getNitroServiceOutDirs(config: ResolvedConfig): string[] { if (!(NITRO_ENVIRONMENT in config.environments)) { return []; } @@ -140,9 +134,13 @@ function isInDirectory(file: string, directory: string): boolean { /** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ export function hasOutputSourceMapSetting( - build: Pick | undefined, + build: Pick | undefined, ): boolean { - return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); + // Vite 8 still accepts `rollupOptions` in the user config. + // oxlint-disable-next-line typescript/no-deprecated + return [build?.rolldownOptions?.output ?? [], build?.rollupOptions?.output ?? []] + .flat() + .some(output => output.sourcemap !== undefined); } /** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */ diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index d542af5be045..1434712beaf0 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeServerInstrumentationPlugin } from './serverInstrumentation'; -import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import { createSourceMapState, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; @@ -115,18 +115,23 @@ 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), makeComposeNitroServiceSourceMapsPlugin()); + // The bundler plugin decides on the source map setting in its own `config` hook, so the plugins that enable source + // maps come first. + const sourceMapState = createSourceMapState(); + const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; + if (!sourceMapsDisabled) { + plugins.push( + ...makeEnableSourceMapsVitePlugin(options, sourceMapState), + makeEnableNitroSourceMapsPlugin(options, sourceMapState), + ); + } + + plugins.push(...makeAddSentryVitePlugin(options, sourceMapState), makeComposeNitroServiceSourceMapsPlugin()); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); } - // source maps - const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; - if (!sourceMapsDisabled) { - 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 c263011142ae..e6376c68bc63 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,17 +1,14 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin, UserConfig } from 'vite'; import { - getNitroServiceOutDirs, hasOutputSourceMapSetting, isNitroService, + NITRO_ENVIRONMENT, type PluginEnvironment, - type SourceMapsEnablerApi, } from './nitroSourceMaps'; 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]-[^/]*$/; @@ -37,10 +34,37 @@ export function rewriteTanstackStartSource(source: string, _map: unknown, contex return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); } +/** Shared by the source map plugins of one build. */ +export type SourceMapState = { + /** The SDK set the top-level `build.sourcemap` to `'hidden'`. */ + sdkEnabledSourceMaps: boolean; + /** The SDK set the `sourcemap` option of Nitro to `'hidden'`. */ + sdkEnabledNitroSourceMaps: boolean; +}; + +export function createSourceMapState(): SourceMapState { + return { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false }; +} + +/** + * Whether the SDK turned on the source maps that the bundler plugin decides on. The plugin passes the name of an + * environment with its own `build.sourcemap`, and `undefined` for the top-level setting. With `nitro/vite`, the `ssr` + * service has its own setting too: Nitro reads its maps after the build, so they stay. + */ +function hasSdkEnabledSourceMaps(state: SourceMapState, environment: string | undefined): boolean { + if (environment === undefined) { + return state.sdkEnabledSourceMaps; + } + return environment === NITRO_ENVIRONMENT && state.sdkEnabledNitroSourceMaps; +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ -export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Plugin[] { +export function makeAddSentryVitePlugin( + options: SentryTanstackStartOptions, + state: SourceMapState = createSourceMapState(), +): Plugin[] { const { applicationKey, authToken, @@ -59,42 +83,6 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl reactComponentAnnotation, } = options; - // defer resolving the filesToDeleteAfterUpload until we got access to the Vite config - let resolveFilesToDeleteAfterUpload: ((value: FilesToDeleteAfterUpload) => void) | undefined; - const filesToDeleteAfterUploadPromise = new Promise(resolve => { - resolveFilesToDeleteAfterUpload = resolve; - }); - - const configPlugin: Plugin = { - name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', - apply: 'build', - enforce: 'post', - configResolved(config) { - if (sourcemaps?.filesToDeleteAfterUpload !== undefined) { - resolveFilesToDeleteAfterUpload?.(sourcemaps.filesToDeleteAfterUpload); - return; - } - - if (!isVite8OrLater(this.meta.viteVersion)) { - // eslint-disable-next-line no-console - console.warn( - `[Sentry] TanStack Start requires Vite 8 or later. With an older Vite, Sentry deletes the source maps it generated only in \`build.outDir\` of each environment, so source maps in other output directories can stay in the build output.`, - ); - } - - const filesToDelete = getSentryEnabledSourceMapGlobs(config); - if (debug) { - // eslint-disable-next-line no-console - console.log( - filesToDelete.length > 0 - ? `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.` - : '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', - ); - } - resolveFilesToDeleteAfterUpload?.(filesToDelete); - }, - }; - const sentryPlugins = sentryVitePlugin({ applicationKey, authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, @@ -113,7 +101,11 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl ignore: sourcemaps?.ignore, rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, - filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + filesToDeleteAfterUpload: sourcemaps?.filesToDeleteAfterUpload, + // Called when the bundler plugin decides, after the SDK's enable plugins ran. + deleteSourcemapsAfterUpload: environment => + sourcemaps?.deleteSourcemapsAfterUpload ?? (hasSdkEnabledSourceMaps(state, environment) || undefined), + sdkEnabledSourceMaps: environment => hasSdkEnabledSourceMaps(state, environment), }, reactComponentAnnotation: { enabled: reactComponentAnnotation?.enabled ?? undefined, @@ -130,76 +122,27 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl // 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 => ({ + return sentryPlugins.map(plugin => ({ ...plugin, applyToEnvironment: (environment: PluginEnvironment) => !isNitroService(environment), })); - - return [configPlugin, ...scopedSentryPlugins]; -} - -/** - * 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 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)) - .filter(outDir => !serviceOutDirs.includes(outDir)); - - 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)); -} - -/** 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), - }; - +export function makeEnableSourceMapsVitePlugin( + options: SentryTanstackStartOptions, + state: SourceMapState = createSourceMapState(), +): Plugin[] { return [ { name: 'sentry-tanstackstart-react-source-maps', apply: 'build', enforce: 'post', - api, config(viteConfig) { 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); - + state.sdkEnabledSourceMaps = sourcemap !== viteConfig.build?.sourcemap; return { build: { sourcemap } }; }, }, @@ -217,7 +160,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio * * 3. Users didn't set source maps generation * - we enable 'hidden' source maps generation - * - configure `filesToDeleteAfterUpload` to delete these .map files after the upload (we emit a log about this) + * - pass `deleteSourcemapsAfterUpload: true`, so the bundler plugin removes these .map files from the build output * * @internal Only exported for testing. */ @@ -268,7 +211,7 @@ export function getUpdatedSourceMapSettings( if (debug) { // eslint-disable-next-line no-console console.log( - `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be deleted after they were uploaded to Sentry.`, + `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be removed from the build output.`, ); } diff --git a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts index c3deb2e93e57..edd57132fefc 100644 --- a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts @@ -10,6 +10,7 @@ import { makeEnableNitroSourceMapsPlugin, type PluginEnvironment, } from '../../src/vite/nitroSourceMaps'; +import { createSourceMapState } from '../../src/vite/sourceMaps'; const SSR_OUT_DIR = 'node_modules/.nitro/vite/services/ssr'; @@ -69,31 +70,31 @@ describe('Nitro source map config', () => { layers: NitroConfig[] | undefined, { debug, viteConfig = {} }: { debug?: boolean; viteConfig?: UserConfig } = {}, ) { - const plugin = makeEnableNitroSourceMapsPlugin({ debug }); + const state = createSourceMapState(); + const plugin = makeEnableNitroSourceMapsPlugin({ debug }, state); (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 } }; + return { state, 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([{}, {}]); + const { state, nitroOptions } = setup([{}, {}]); expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); - expect(plugin.api.hasSentryEnabledSourceMaps('client')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(true); }); it.each([false, true, 'hidden'])( 'keeps the setting `sourcemap: %s` of a config source, with the sources', sourcemap => { - const { plugin, nitroOptions } = setup([{}, { sourcemap }]); + const { state, nitroOptions } = setup([{}, { sourcemap }]); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }, ); @@ -101,34 +102,34 @@ describe('Nitro source map config', () => { { 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]); + const { state, nitroOptions } = setup([{}, layer]); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).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 } }); + const { state, nitroOptions } = setup([{}], { viteConfig: { build } }); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).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 } } }); + const { state, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + expect(state.sdkEnabledNitroSourceMaps).toBe(true); }); it('does not enable source maps when Nitro does not expose its config sources', () => { - const { plugin, nitroOptions } = setup(undefined); + const { state, nitroOptions } = setup(undefined); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }); it('logs that it enabled source maps in debug mode', () => { diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 60091f7d93d4..cb9225b326f3 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -5,13 +5,6 @@ import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/ import { makeServerInstrumentationPlugin } from '../../src/vite/serverInstrumentation'; import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; -const mockSourceMapsConfigPlugin: Plugin = { - name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', - apply: 'build', - enforce: 'pre', - config: vi.fn(), -}; - const mockSentryVitePlugin: Plugin = { name: 'sentry-vite-debug-id-upload-plugin', writeBundle: vi.fn(), @@ -84,7 +77,8 @@ vi.mock('../../src/vite/routePatterns', () => ({ })); vi.mock('../../src/vite/sourceMaps', () => ({ - makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), + createSourceMapState: vi.fn(() => ({ sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false })), + makeAddSentryVitePlugin: vi.fn(() => [mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); @@ -127,11 +121,10 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, + mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -171,7 +164,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -189,7 +181,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -207,11 +198,10 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, + mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); }); @@ -226,7 +216,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, @@ -245,7 +234,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, @@ -264,7 +252,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -300,7 +287,6 @@ describe('sentryTanstackStart()', () => { mockTunnelRoutePlugin, mockServerInstrumentationPlugin, 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 index 7effcf3012f7..d43b6129acfe 100644 --- a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts @@ -1,14 +1,14 @@ // @vitest-environment node -import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryVitePluginInternalOptions } from '@sentry/bundler-plugins/vite'; +import type { BuildEnvironmentOptions, Plugin, UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -let sentryVitePluginOptions: SentryVitePluginOptions | undefined; +let sentryVitePluginOptions: SentryVitePluginInternalOptions | undefined; vi.mock('@sentry/bundler-plugins/vite', () => ({ - sentryVitePlugin: (options: SentryVitePluginOptions) => { + sentryVitePlugin: (options: SentryVitePluginInternalOptions) => { sentryVitePluginOptions = options; return []; }, @@ -26,32 +26,44 @@ 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; + return getOwnOutputSourceMapSetting(build) ?? build.sourcemap; +} + +/** Like the bundler plugin, reads `rolldownOptions` and falls back to `rollupOptions`. */ +function getOwnOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { + const output = (build.rolldownOptions ?? build.rollupOptions)?.output; + return Array.isArray(output) ? undefined : output?.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'); +function build({ + vite = {}, + nitro: nitroOptions = {}, + options = {}, +}: { + vite?: UserConfig; + nitro?: NitroOptions; + options?: SentryTanstackStartOptions; +}) { + const plugins = sentryTanstackStart(options); + // Not added when source maps are disabled. + const nitroPlugin = plugins.find(plugin => plugin.name === 'sentry-tanstackstart-nitro-source-maps') as + | NitroModulePlugin + | undefined; const viteConfig = structuredClone(vite); - callConfigHook(nitroPlugin, viteConfig); + if (nitroPlugin) { + callConfigHook(nitroPlugin, viteConfig); + } const nitro = { options: { @@ -60,7 +72,7 @@ async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfi _c12: { layers: [{ config: nitroOptions }] }, }, }; - nitroPlugin.nitro.setup(nitro); + nitroPlugin?.nitro.setup(nitro); const nitroOutput = { ...nitroOptions.rollupConfig?.output, ...nitroOptions.rolldownConfig?.output }; const configWithNitro: UserConfig = mergeConfig(viteConfig, { @@ -75,37 +87,49 @@ async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfi }, }, }); - const userConfig: UserConfig = mergeConfig( - configWithNitro, - callConfigHook(findPlugin(plugins, 'sentry-tanstackstart-react-source-maps'), configWithNitro), - ); + const enablePlugin = plugins.find(plugin => plugin.name === 'sentry-tanstackstart-react-source-maps'); + const userConfig: UserConfig = enablePlugin + ? mergeConfig(configWithNitro, callConfigHook(enablePlugin, configWithNitro)) + : 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 ?? {}), - }, + 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, - ); + ) as Record<(typeof ENVIRONMENT_NAMES)[number], BuildEnvironmentOptions>; return { - client: getOutputSourceMapSetting(environments.client!.build), - ssr: getOutputSourceMapSetting(environments.ssr!.build), - nitro: getOutputSourceMapSetting(environments.nitro!.build), - filesToDelete: await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload, + client: getOutputSourceMapSetting(environments.client), + ssr: getOutputSourceMapSetting(environments.ssr), + nitro: getOutputSourceMapSetting(environments.nitro), + deleted: ENVIRONMENT_NAMES.filter(name => isDeletedByBundlerPlugin(userConfig, name, environments[name])), }; } -const CLIENT_MAPS = '.output/public/**/*.map'; -const SERVER_MAPS = '.output/server/**/*.map'; +/** + * Mirrors the decision of the bundler plugin: it passes the name of an environment with its own `build.sourcemap` to + * the function options. It removes the maps of an output with its own `sourcemap` if they are hidden, or if + * `sdkEnabledSourceMaps` is true and they are not inline. + */ +function isDeletedByBundlerPlugin(userConfig: UserConfig, name: string, build: BuildEnvironmentOptions): boolean { + const environment = userConfig.environments?.[name]?.build?.sourcemap !== undefined ? name : undefined; + const { deleteSourcemapsAfterUpload, sdkEnabledSourceMaps } = sentryVitePluginOptions?.sourcemaps ?? {}; + const deleteOption = + typeof deleteSourcemapsAfterUpload === 'function' + ? deleteSourcemapsAfterUpload(environment) + : deleteSourcemapsAfterUpload; + const sdkEnabled = + typeof sdkEnabledSourceMaps === 'function' ? sdkEnabledSourceMaps(environment) : sdkEnabledSourceMaps; + const outputSourceMap = getOwnOutputSourceMapSetting(build); + if (deleteOption !== true) { + return false; + } + if (outputSourceMap === undefined) { + return build.sourcemap !== false; + } + return outputSourceMap === 'hidden' || (sdkEnabled === true && outputSourceMap === true); +} describe('source map scenarios of a build with Nitro', () => { beforeEach(() => { @@ -118,72 +142,107 @@ describe('source map scenarios of a build with Nitro', () => { vi.restoreAllMocks(); }); - it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: Awaited> }>([ + it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: ReturnType }>([ { label: 'nothing', - expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [CLIENT_MAPS, SERVER_MAPS] }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, }, { label: 'nitro({ sourcemap: true })', nitro: { sourcemap: true }, - expected: { client: 'hidden', ssr: true, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: true, nitro: true, deleted: ['client'] }, }, { label: 'nitro({ sourcemap: false })', nitro: { sourcemap: false }, - expected: { client: 'hidden', ssr: false, nitro: false, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: false, deleted: ['client'] }, }, { label: 'nitro({ rollupConfig: { output: { sourcemap: true } } })', nitro: { rollupConfig: { output: { sourcemap: true } } }, - expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: true, deleted: ['client'] }, }, { label: 'nitro({ rolldownConfig: { output: { sourcemap: true } } })', nitro: { rolldownConfig: { output: { sourcemap: true } } }, - expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: true, deleted: ['client'] }, }, { label: 'build.sourcemap: true', vite: { build: { sourcemap: true } }, - expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: "build.sourcemap: 'hidden'", vite: { build: { sourcemap: 'hidden' } }, - expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'build.sourcemap: false', vite: { build: { sourcemap: false } }, - expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + expected: { client: false, ssr: false, nitro: false, deleted: [] }, }, { label: 'environments.client.build.sourcemap: true', vite: { environments: { client: { build: { sourcemap: true } } } }, - expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'environments.client.build.sourcemap: false', vite: { environments: { client: { build: { sourcemap: false } } } }, - expected: { client: false, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: false, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'build.rolldownOptions.output.sourcemap: true', vite: { build: { rolldownOptions: { output: { sourcemap: true } } } }, - expected: { client: true, ssr: true, nitro: true, filesToDelete: [] }, + expected: { client: true, ssr: true, nitro: true, deleted: [] }, }, { label: 'build.rolldownOptions.output.sourcemap: false', vite: { build: { rolldownOptions: { output: { sourcemap: false } } } }, - expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + expected: { client: false, ssr: false, nitro: false, deleted: [] }, }, { 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] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: "environments.client.build.rolldownOptions.output.sourcemap: 'hidden'", + vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: 'environments.client.build.rollupOptions.output.sourcemap: true', + vite: { environments: { client: { build: { rollupOptions: { output: { sourcemap: true } } } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: "environments.client.build.rollupOptions.output.sourcemap: 'hidden'", + vite: { environments: { client: { build: { rollupOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + ])('deletes the expected source maps when the user sets $label', scenario => { + expect(build(scenario)).toEqual(scenario.expected); + }); + + it.each<{ label: string; options: SentryTanstackStartOptions; expected: ReturnType }>([ + { + label: "sourcemaps.disable: 'disable-upload'", + options: { sourcemaps: { disable: 'disable-upload' } }, + expected: { client: undefined, ssr: false, nitro: false, deleted: [] }, + }, + { + label: 'sourcemaps.deleteSourcemapsAfterUpload: false', + options: { sourcemaps: { deleteSourcemapsAfterUpload: false } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: [] }, + }, + { + label: 'sourcemaps.filesToDeleteAfterUpload', + options: { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, }, - ])('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); + ])('follows the Sentry option $label', ({ options, expected }) => { + expect(build({ options })).toEqual(expected); }); }); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index 12ccae09beb2..92283de4c46c 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,31 +1,31 @@ // @vitest-environment node -import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryVitePluginInternalOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'path'; import { + createSourceMapState, getUpdatedSourceMapSettings, makeAddSentryVitePlugin, 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(), }; -let sentryVitePluginOptions: SentryVitePluginOptions | undefined; +let sentryVitePluginOptions: SentryVitePluginInternalOptions | undefined; -const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginInternalOptions) => { sentryVitePluginOptions = options; return [mockedSentryVitePlugin]; }); vi.mock('@sentry/bundler-plugins/vite', () => ({ - sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), + sentryVitePlugin: (options: SentryVitePluginInternalOptions) => sentryVitePluginSpy(options), })); beforeEach(() => { @@ -47,34 +47,32 @@ describe('makeEnableSourceMapsVitePlugin()', () => { }); it.each([ - { label: 'the user set no source maps', userConfig: {}, expected: { client: true, ssr: true } }, + { label: 'the user set no source maps', userConfig: {}, expected: true }, + { label: 'the user set `build.sourcemap`', userConfig: { build: { sourcemap: true } }, expected: false }, + { label: 'the user set `build.sourcemap: false`', userConfig: { build: { sourcemap: false } }, expected: false }, { - label: 'the user set `build.sourcemap`', - userConfig: { build: { sourcemap: true } }, - expected: { client: false, ssr: false }, + label: 'the user set `build.rolldownOptions.output.sourcemap`', + userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: false, }, { - label: 'the user set the source maps of an environment', - userConfig: { environments: { client: { build: { sourcemap: true } } } }, - expected: { client: false, ssr: true }, + label: "an environment output sets `sourcemap: 'hidden'`", + userConfig: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: true, }, { - label: 'the user set `build.rolldownOptions.output.sourcemap`', - userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, - expected: { client: false, ssr: false }, + label: 'an environment output sets `sourcemap: true`', + userConfig: { environments: { client: { build: { rollupOptions: { output: { sourcemap: true } } } } } }, + expected: true, }, - ] 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); + ] as const)('records whether it enabled the source maps when $label', ({ userConfig, expected }) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const state = { ...createSourceMapState(), sdkEnabledSourceMaps: !expected }; + const plugin = makeEnableSourceMapsVitePlugin({}, state)[0]!; + (plugin.config as (config: UserConfig) => UserConfig)(structuredClone(userConfig) as UserConfig); - expect({ - client: plugin.api.hasSentryEnabledSourceMaps('client'), - ssr: plugin.api.hasSentryEnabledSourceMaps('ssr'), - }).toEqual(expected); - }, - ); + expect(state.sdkEnabledSourceMaps).toBe(expected); + }); it.each([undefined, false, true, 'hidden', 'inline'] as const)( 'preserves user config without duplicating arrays when sourcemap is %s', @@ -106,9 +104,9 @@ describe('makeEnableSourceMapsVitePlugin()', () => { }); describe('makeAddSentryVitePlugin()', () => { - it('passes user-specified vite plugin options to vite plugin', async () => { + it('passes user-specified vite plugin options to vite plugin', () => { const errorHandler = vi.fn(); - const plugins = makeAddSentryVitePlugin({ + makeAddSentryVitePlugin({ org: 'my-org', authToken: 'my-token', sentryUrl: 'https://custom.sentry.io', @@ -133,16 +131,7 @@ describe('makeAddSentryVitePlugin()', () => { }, }); - // The filesToDeleteAfterUpload is now a Promise that resolves when the config hook runs - expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(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?.configResolved as (config: ResolvedConfig) => void)({} as ResolvedConfig); - - // Verify the Promise resolves to the user-specified value - const resolvedFilesToDelete = await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toEqual(['baz/*.js']); + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toEqual(['baz/*.js']); expect(sentryVitePluginSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -195,21 +184,6 @@ describe('makeAddSentryVitePlugin()', () => { ); }); - it('returns Sentry Vite plugins and config plugin', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - }); - - expect(plugins.length).toBeGreaterThanOrEqual(2); - - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); - expect(configPlugin?.apply).toBe('build'); - expect(configPlugin?.enforce).toBe('post'); - expect(typeof configPlugin?.configResolved).toBe('function'); - }); - it.each([ { name: 'client', consumer: 'client', expected: true }, { name: 'ssr', consumer: 'server', expected: false }, @@ -227,160 +201,85 @@ describe('makeAddSentryVitePlugin()', () => { }, ); - describe('default filesToDeleteAfterUpload', () => { - type TestEnvironment = { - consumer: 'client' | 'server'; - outDir: string; - outputDir?: string; - outputSourceMap?: boolean; - }; - - 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' }, - }; - - /** A plugin that enabled the source maps of the given environments, like the Sentry plugins that enable them. */ - function sourceMapsEnabler(environmentNames: string[]): Plugin { + describe('deleteSourcemapsAfterUpload', () => { + /** Calls the function options like the bundler plugin, which passes an environment with its own setting. */ + function decide(environment?: string) { + const { deleteSourcemapsAfterUpload, sdkEnabledSourceMaps } = sentryVitePluginOptions?.sourcemaps ?? {}; return { - name: 'source-maps-enabler', - api: { hasSentryEnabledSourceMaps: (environmentName: string) => environmentNames.includes(environmentName) }, + delete: + typeof deleteSourcemapsAfterUpload === 'function' + ? deleteSourcemapsAfterUpload(environment) + : deleteSourcemapsAfterUpload, + sdkEnabled: + typeof sdkEnabledSourceMaps === 'function' ? sdkEnabledSourceMaps(environment) : sdkEnabledSourceMaps, }; } - 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, - ); + it('reads the state when the bundler plugin decides, not when the plugin is created', () => { + const state = createSourceMapState(); + makeAddSentryVitePlugin({}, state); - return Promise.resolve(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload); - } + expect(decide()).toEqual({ delete: undefined, sdkEnabled: false }); + state.sdkEnabledSourceMaps = true; + expect(decide()).toEqual({ delete: true, sdkEnabled: true }); + }); it.each([ { - label: 'a Nitro build, but not in the Nitro service output', - environments: NITRO_ENVIRONMENTS, - expected: ['.output/public/**/*.map', '.output/server/**/*.map'], + label: 'the SDK enabled the top-level source maps', + state: { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: false }, + expected: { top: true, ssr: undefined, nitro: undefined }, }, { - 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: 'the SDK enabled the Nitro source maps', + state: { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: true }, + expected: { top: undefined, ssr: undefined, nitro: true }, }, { - label: 'environments that share an output directory once', - environments: { - client: { consumer: 'client', outDir: 'dist' }, - ssr: { consumer: 'server', outDir: 'dist' }, - }, - expected: ['dist/**/*.map'], + label: 'the SDK enabled both', + state: { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }, + expected: { top: true, ssr: undefined, nitro: true }, }, - ] as const)('deletes the source maps in each output directory of $label', async ({ environments, expected }) => { - await expect(resolveFilesToDelete(environments)).resolves.toEqual(expected); - }); - - 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, - ); - }, - ); - - 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 }, - }; - - await expect(resolveFilesToDelete(environments)).resolves.toEqual(['.output/server/**/*.map']); + { + label: 'the user enabled all source maps', + state: { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false }, + expected: { top: undefined, ssr: undefined, nitro: undefined }, }, - ); + ])('asks for deletion of the source maps the SDK enabled when $label', ({ state, expected }) => { + makeAddSentryVitePlugin({}, state); - 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(); + expect({ top: decide().delete, ssr: decide('ssr').delete, nitro: decide('nitro').delete }).toEqual(expected); + expect({ + top: decide().sdkEnabled, + ssr: decide('ssr').sdkEnabled, + nitro: decide('nitro').sdkEnabled, + }).toEqual({ top: expected.top === true, ssr: false, nitro: expected.nitro === true }); }); - it('does not warn with Vite 8', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await resolveFilesToDelete(NITRO_ENVIRONMENTS); + // A client environment with its own setting was set by the user, because the SDK sets only the top level. + it('keeps the source maps of an environment that the user configured', () => { + makeAddSentryVitePlugin({}, { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - consoleWarnSpy.mockRestore(); + expect(decide('client')).toEqual({ delete: undefined, sdkEnabled: false }); }); - it('logs the default in debug mode', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await resolveFilesToDelete(NITRO_ENVIRONMENTS, { options: { 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.', + it.each([true, false])('passes a user-set `deleteSourcemapsAfterUpload: %s` through', value => { + makeAddSentryVitePlugin( + { sourcemaps: { deleteSourcemapsAfterUpload: value } }, + { sdkEnabledSourceMaps: !value, sdkEnabledNitroSourceMaps: !value }, ); - consoleSpy.mockRestore(); - }); - - 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([decide().delete, decide('ssr').delete, decide('nitro').delete]).toEqual([value, value, value]); + }); - expect(consoleSpy).toHaveBeenCalledWith( - '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', + it('passes a user-set filesToDeleteAfterUpload through and keeps its own deletion', () => { + makeAddSentryVitePlugin( + { sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }, ); - consoleSpy.mockRestore(); + + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + expect([decide().delete, decide('ssr').delete, decide('nitro').delete]).toEqual([true, undefined, true]); }); });