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 4df9fbb14208..af8ef89927ed 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 @@ -3,16 +3,27 @@ import tsConfigPaths from 'vite-tsconfig-paths'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; +import { wrapConfigWithSentry } from '@sentry/tanstackstart-react'; -export default defineConfig({ - server: { - port: 3000, - }, - plugins: [ - tsConfigPaths(), - tanstackStart(), - nitro(), - // react's vite plugin must come after start's vite plugin - viteReact(), - ], -}); +export default defineConfig( + wrapConfigWithSentry( + { + server: { + port: 3000, + }, + plugins: [ + tsConfigPaths(), + tanstackStart(), + nitro(), + // react's vite plugin must come after start's vite plugin + viteReact(), + ], + }, + { + org: process.env.E2E_TEST_SENTRY_ORG_SLUG, + project: process.env.E2E_TEST_SENTRY_PROJECT, + authToken: process.env.E2E_TEST_AUTH_TOKEN, + debug: true, + }, + ), +); diff --git a/packages/solidstart/src/vite/sourceMaps.ts b/packages/solidstart/src/vite/sourceMaps.ts index 0cd44e6a61c7..8d10e6a16112 100644 --- a/packages/solidstart/src/vite/sourceMaps.ts +++ b/packages/solidstart/src/vite/sourceMaps.ts @@ -76,7 +76,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentrySolidStartPluginOp ]; } -/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-j avascript/issues/13993) +/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) * * 1. User explicitly disabled source maps * - keep this setting (emit a warning that errors won't be unminified in Sentry) diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 0bbcdfcc2ed0..96a86b98f710 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -55,7 +55,11 @@ "@sentry-internal/browser-utils": "10.33.0", "@sentry/core": "10.33.0", "@sentry/node": "10.33.0", - "@sentry/react": "10.33.0" + "@sentry/react": "10.33.0", + "@sentry/vite-plugin": "^4.6.1" + }, + "devDependencies": { + "vite": "^5.4.11" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/tanstackstart-react/src/config/index.ts b/packages/tanstackstart-react/src/config/index.ts index cb0ff5c3b541..41c03e21fda2 100644 --- a/packages/tanstackstart-react/src/config/index.ts +++ b/packages/tanstackstart-react/src/config/index.ts @@ -1 +1 @@ -export {}; +export { wrapConfigWithSentry } from './wrapConfigWithSentry'; diff --git a/packages/tanstackstart-react/src/config/wrapConfigWithSentry.ts b/packages/tanstackstart-react/src/config/wrapConfigWithSentry.ts new file mode 100644 index 000000000000..1184e5d0f06f --- /dev/null +++ b/packages/tanstackstart-react/src/config/wrapConfigWithSentry.ts @@ -0,0 +1,45 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { UserConfig } from 'vite'; +import { addSentryPlugins } from '../vite'; + +/** + * Wraps a Vite configuration object with Sentry build-time enhancements such as + * automatic source maps upload. + * + * @example + * ```typescript + * // vite.config.ts + * import { defineConfig } from 'vite'; + * import { wrapConfigWithSentry } from '@sentry/tanstackstart-react'; + * + * export default defineConfig( + * wrapConfigWithSentry( + * { + * // Your Vite/TanStack Start config + * plugins: [...] + * }, + * { + * // Sentry build-time options + * org: 'your-org', + * project: 'your-project', + * }, + * ), + * ); + * ``` + * + * @param config - A Vite configuration object + * @param sentryPluginOptions - Options to configure the Sentry Vite plugin + * @returns The modified Vite config to be passed to `defineConfig` + */ +export function wrapConfigWithSentry( + config: UserConfig = {}, + sentryPluginOptions: BuildTimeOptionsBase = {}, +): UserConfig { + const userPlugins = Array.isArray(config.plugins) ? [...config.plugins] : []; + const plugins = addSentryPlugins(userPlugins, sentryPluginOptions, config); + + return { + ...config, + plugins, + }; +} diff --git a/packages/tanstackstart-react/src/vite/addSentryPlugins.ts b/packages/tanstackstart-react/src/vite/addSentryPlugins.ts new file mode 100644 index 000000000000..0c425145c76a --- /dev/null +++ b/packages/tanstackstart-react/src/vite/addSentryPlugins.ts @@ -0,0 +1,30 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { PluginOption, UserConfig } from 'vite'; +import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; + +/** + * Adds Sentry plugins to the given array of Vite plugins. + */ +export function addSentryPlugins( + plugins: PluginOption[], + options: BuildTimeOptionsBase, + viteConfig: UserConfig, +): PluginOption[] { + const sentryPlugins: PluginOption[] = []; + + // Only add source map plugins in production builds + if (process.env.NODE_ENV !== 'development') { + // Check if source maps upload is enabled, default is enabled + const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; + + if (!sourceMapsDisabled) { + const sourceMapsPlugins = makeAddSentryVitePlugin(options, viteConfig); + const enableSourceMapsPlugin = makeEnableSourceMapsVitePlugin(options); + + sentryPlugins.push(...sourceMapsPlugins, ...enableSourceMapsPlugin); + } + } + + // Prepend Sentry plugins so they run first + return [...sentryPlugins, ...plugins]; +} diff --git a/packages/tanstackstart-react/src/vite/index.ts b/packages/tanstackstart-react/src/vite/index.ts new file mode 100644 index 000000000000..e66ccf733bd2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/index.ts @@ -0,0 +1 @@ +export { addSentryPlugins } from './addSentryPlugins'; diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts new file mode 100644 index 000000000000..6d268549a3cd --- /dev/null +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -0,0 +1,133 @@ +import type { BuildTimeOptionsBase } from '@sentry/core'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { Plugin, UserConfig } from 'vite'; + +/** + * A Sentry plugin for adding the @sentry/vite-plugin to automatically upload source maps to Sentry. + */ +export function makeAddSentryVitePlugin(options: BuildTimeOptionsBase, viteConfig: UserConfig): Plugin[] { + const { authToken, bundleSizeOptimizations, debug, org, project, sourcemaps, telemetry } = options; + + let updatedFilesToDeleteAfterUpload: string[] | undefined = undefined; + + if ( + typeof sourcemaps?.filesToDeleteAfterUpload === 'undefined' && + // Only if source maps were previously not set, we update the "filesToDeleteAfterUpload" (as we override the setting with "hidden") + typeof viteConfig.build?.sourcemap === 'undefined' + ) { + // For .output, .vercel, .netlify etc. + updatedFilesToDeleteAfterUpload = ['.*/**/*.map']; + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify( + updatedFilesToDeleteAfterUpload, + )}\` to delete generated source maps after they were uploaded to Sentry.`, + ); + } + } + + return [ + ...sentryVitePlugin({ + authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, + bundleSizeOptimizations: bundleSizeOptimizations ?? undefined, + debug: debug ?? false, + org: org ?? process.env.SENTRY_ORG, + project: project ?? process.env.SENTRY_PROJECT, + sourcemaps: { + filesToDeleteAfterUpload: sourcemaps?.filesToDeleteAfterUpload ?? updatedFilesToDeleteAfterUpload, + }, + telemetry: telemetry ?? true, + _metaOptions: { + telemetry: { + metaFramework: 'tanstackstart-react', + }, + }, + }), + ]; +} + +/** + * A Sentry plugin for TanStack Start React to enable "hidden" source maps if they are unset. + */ +export function makeEnableSourceMapsVitePlugin(options: BuildTimeOptionsBase): Plugin[] { + return [ + { + name: 'sentry-tanstackstart-react-source-maps', + apply: 'build', + enforce: 'post', + config(viteConfig) { + return { + ...viteConfig, + build: { + ...viteConfig.build, + sourcemap: getUpdatedSourceMapSettings(viteConfig, options), + }, + }; + }, + }, + ]; +} + +/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) + * + * 1. User explicitly disabled source maps + * - keep this setting (emit a warning that errors won't be unminified in Sentry) + * - We won't upload anything + * + * 2. Users enabled source map generation (true, 'hidden', 'inline'). + * - keep this setting (don't do anything - like deletion - besides uploading) + * + * 3. Users didn't set source maps generation + * - we enable 'hidden' source maps generation + * - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + * + * --> only exported for testing + */ +export function getUpdatedSourceMapSettings( + viteConfig: UserConfig, + sentryPluginOptions?: BuildTimeOptionsBase, +): boolean | 'inline' | 'hidden' { + viteConfig.build = viteConfig.build || {}; + + const viteSourceMap = viteConfig.build?.sourcemap; + let updatedSourceMapSetting = viteSourceMap; + + const settingKey = 'vite.build.sourcemap'; + const debug = sentryPluginOptions?.debug; + + if (viteSourceMap === false) { + updatedSourceMapSetting = viteSourceMap; + + if (debug) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Source map generation is currently disabled in your TanStack Start configuration (\`${settingKey}: false\`). Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified.`, + ); + } else { + // eslint-disable-next-line no-console + console.warn('[Sentry] Source map generation is disabled in your TanStack Start configuration.'); + } + } else if (viteSourceMap && ['hidden', 'inline', true].includes(viteSourceMap)) { + updatedSourceMapSetting = viteSourceMap; + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We discovered \`${settingKey}\` is set to \`${viteSourceMap.toString()}\`. Sentry will keep this source map setting.`, + ); + } + } else { + updatedSourceMapSetting = 'hidden'; + + 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.`, + ); + } + } + + return updatedSourceMapSetting; +} diff --git a/packages/tanstackstart-react/test/vite/addSentryPlugins.test.ts b/packages/tanstackstart-react/test/vite/addSentryPlugins.test.ts new file mode 100644 index 000000000000..8e369d7ff1e0 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/addSentryPlugins.test.ts @@ -0,0 +1,95 @@ +import type { Plugin } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { addSentryPlugins } from '../../src/vite/addSentryPlugins'; + +const mockSourceMapsPlugin: Plugin = { + name: 'sentry-vite-debug-id-upload-plugin', + writeBundle: vi.fn(), +}; + +const mockEnableSourceMapsPlugin: Plugin = { + name: 'sentry-tanstackstart-react-source-maps', + apply: 'build', + enforce: 'post', + config: vi.fn(), +}; + +vi.mock('../../src/vite/sourceMaps', () => ({ + makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsPlugin]), + makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), +})); + +describe('addSentryPlugins()', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.NODE_ENV = 'production'; + }); + + afterEach(() => { + process.env.NODE_ENV = 'production'; + }); + + it('prepends Sentry plugins to the original plugins array', () => { + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], {}, {}); + + expect(result).toHaveLength(3); + expect(result[0]).toBe(mockSourceMapsPlugin); + expect(result[1]).toBe(mockEnableSourceMapsPlugin); + expect(result[2]).toBe(userPlugin); + }); + + it('does not add plugins in development mode', () => { + process.env.NODE_ENV = 'development'; + + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], {}, {}); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(userPlugin); + }); + + it('does not add plugins when sourcemaps.disable is true', () => { + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], { sourcemaps: { disable: true } }, {}); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(userPlugin); + }); + + it('does not add plugins when sourcemaps.disable is "disable-upload"', () => { + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], { sourcemaps: { disable: 'disable-upload' } }, {}); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(userPlugin); + }); + + it('adds plugins when sourcemaps.disable is false', () => { + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], { sourcemaps: { disable: false } }, {}); + + expect(result).toHaveLength(3); + expect(result[0]).toBe(mockSourceMapsPlugin); + expect(result[1]).toBe(mockEnableSourceMapsPlugin); + expect(result[2]).toBe(userPlugin); + }); + + it('adds plugins by default when sourcemaps is not specified', () => { + const userPlugin: Plugin = { name: 'user-plugin' }; + const result = addSentryPlugins([userPlugin], {}, {}); + + expect(result).toHaveLength(3); + expect(result[0]).toBe(mockSourceMapsPlugin); + expect(result[1]).toBe(mockEnableSourceMapsPlugin); + expect(result[2]).toBe(userPlugin); + }); + + it('returns only Sentry plugins when no user plugins are provided', () => { + const result = addSentryPlugins([], {}, {}); + + expect(result).toHaveLength(2); + expect(result[0]).toBe(mockSourceMapsPlugin); + expect(result[1]).toBe(mockEnableSourceMapsPlugin); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts new file mode 100644 index 000000000000..4dbdaeaedde0 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -0,0 +1,182 @@ +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getUpdatedSourceMapSettings, + makeAddSentryVitePlugin, + makeEnableSourceMapsVitePlugin, +} from '../../src/vite/sourceMaps'; + +const mockedSentryVitePlugin = { + name: 'sentry-vite-debug-id-upload-plugin', + writeBundle: vi.fn(), +}; + +const sentryVitePluginSpy = vi.fn((_options: SentryVitePluginOptions) => [mockedSentryVitePlugin]); + +vi.mock('@sentry/vite-plugin', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('makeEnableSourceMapsVitePlugin()', () => { + it('returns a plugin to enable source maps', () => { + const sourceMapsConfigPlugins = makeEnableSourceMapsVitePlugin({}); + const enableSourceMapPlugin = sourceMapsConfigPlugins[0]; + + expect(enableSourceMapPlugin?.name).toEqual('sentry-tanstackstart-react-source-maps'); + expect(enableSourceMapPlugin?.apply).toEqual('build'); + expect(enableSourceMapPlugin?.enforce).toEqual('post'); + expect(enableSourceMapPlugin?.config).toEqual(expect.any(Function)); + + expect(sourceMapsConfigPlugins).toHaveLength(1); + }); +}); + +describe('makeAddSentryVitePlugin()', () => { + it('passes user-specified vite plugin options to vite plugin', () => { + makeAddSentryVitePlugin( + { + org: 'my-org', + authToken: 'my-token', + sourcemaps: { + filesToDeleteAfterUpload: ['baz/*.js'], + }, + bundleSizeOptimizations: { + excludeTracing: true, + }, + }, + {}, + ); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'my-org', + authToken: 'my-token', + sourcemaps: { + filesToDeleteAfterUpload: ['baz/*.js'], + }, + bundleSizeOptimizations: { + excludeTracing: true, + }, + }), + ); + }); + + it('should update `filesToDeleteAfterUpload` if source map generation was previously not defined', () => { + makeAddSentryVitePlugin( + { + org: 'my-org', + authToken: 'my-token', + bundleSizeOptimizations: { + excludeTracing: true, + }, + }, + {}, + ); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: ['.*/**/*.map'], + }), + }), + ); + }); + + it('should not update `filesToDeleteAfterUpload` if source map generation was previously enabled', () => { + makeAddSentryVitePlugin( + { + org: 'my-org', + authToken: 'my-token', + bundleSizeOptimizations: { + excludeTracing: true, + }, + }, + { build: { sourcemap: true } }, + ); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: undefined, + }), + }), + ); + }); + + it('should not update `filesToDeleteAfterUpload` if source map generation was previously disabled', () => { + makeAddSentryVitePlugin( + { + org: 'my-org', + authToken: 'my-token', + bundleSizeOptimizations: { + excludeTracing: true, + }, + }, + { build: { sourcemap: false } }, + ); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: undefined, + }), + }), + ); + }); + + it('sets the correct metaFramework in telemetry options', () => { + makeAddSentryVitePlugin( + { + org: 'my-org', + authToken: 'my-token', + }, + {}, + ); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + _metaOptions: { + telemetry: { + metaFramework: 'tanstackstart-react', + }, + }, + }), + ); + }); +}); + +describe('getUpdatedSourceMapSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should keep sourcemap as false and emit warning when explicitly disabled', () => { + const result = getUpdatedSourceMapSettings({ build: { sourcemap: false } }); + + expect(result).toBe(false); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalled(); + }); + + it.each([ + ['hidden', 'hidden'], + ['inline', 'inline'], + [true, true], + ] as const)('should keep sourcemap as %s when explicitly set', (input, expected) => { + const result = getUpdatedSourceMapSettings({ build: { sourcemap: input } }); + + expect(result).toBe(expected); + }); + + it('should set sourcemap to hidden when not configured', () => { + const result = getUpdatedSourceMapSettings({}); + + expect(result).toBe('hidden'); + }); +}); diff --git a/packages/tanstackstart-react/tsconfig.json b/packages/tanstackstart-react/tsconfig.json index 220ba3fa2b86..9399ef75ead6 100644 --- a/packages/tanstackstart-react/tsconfig.json +++ b/packages/tanstackstart-react/tsconfig.json @@ -1,9 +1,5 @@ { "extends": "../../tsconfig.json", "include": ["src/**/*"], - "compilerOptions": { - "lib": ["es2020"], - "module": "Node16", - "moduleResolution": "Node16" - } + "compilerOptions": {} }