From d632fe8ab902ff77168ad00191831be2d715fd03 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:15:04 +0100 Subject: [PATCH 01/15] feat(tanstackstart-react): Auto-copy build file to correct folder --- .../tanstackstart-react/package.json | 2 +- .../src/vite/copyInstrumentationFile.ts | 78 ++++++ .../src/vite/sentryTanstackStart.ts | 4 + .../test/vite/copyInstrumentationFile.test.ts | 249 ++++++++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 44 +++- 5 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts create mode 100644 packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index d75ebb148639..f5ba627d5c2c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -4,7 +4,7 @@ "version": "0.0.1", "type": "module", "scripts": { - "build": "vite build && cp instrument.server.mjs .output/server", + "build": "vite build", "start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts new file mode 100644 index 000000000000..154e294223f2 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -0,0 +1,78 @@ +import { consoleSandbox } from '@sentry/core'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { Plugin, ResolvedConfig } from 'vite'; + +/** + * Creates a Vite plugin that copies the user's `instrument.server.mjs` file + * to the server build output directory after the build completes. + * + * Supports: + * - Nitro deployments (reads output dir from the Nitro Vite environment config) + * - Cloudflare/Netlify deployments (outputs to `dist/server`) + */ +export function makeCopyInstrumentationFilePlugin(): Plugin { + let serverOutputDir: string | undefined; + + return { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', + + configResolved(resolvedConfig: ResolvedConfig) { + // Nitro case: read server dir from the nitro environment config + // Vite 6 environment configs are not part of the public type definitions yet, + // so we need to access them via an index signature. + const environments = (resolvedConfig as Record)['environments'] as + | Record } } }> + | undefined; + const nitroEnv = environments?.nitro; + if (nitroEnv) { + const rollupOutput = nitroEnv.build?.rollupOptions?.output; + const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir; + if (dir) { + serverOutputDir = dir; + return; + } + } + + // Cloudflare/Netlify case: detect by plugin name + const plugins = resolvedConfig.plugins || []; + const hasCloudflareOrNetlify = plugins.some(p => /cloudflare|netlify/i.test(p.name)); + if (hasCloudflareOrNetlify) { + serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); + } + }, + + async closeBundle() { + if (!serverOutputDir) { + return; + } + + const instrumentationSource = path.resolve(process.cwd(), 'instrument.server.mjs'); + + try { + await fs.promises.access(instrumentationSource, fs.constants.F_OK); + } catch { + // No instrumentation file found — nothing to copy + return; + } + + const destination = path.resolve(serverOutputDir, 'instrument.server.mjs'); + + try { + await fs.promises.mkdir(serverOutputDir, { recursive: true }); + await fs.promises.copyFile(instrumentationSource, destination); + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log(`[Sentry TanStack Start] Copied instrument.server.mjs to ${destination}`); + }); + } catch (error) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', error); + }); + } + }, + }; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index d14033ff052d..a8b8d4e8f8bd 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,6 +1,7 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeCopyInstrumentationFilePlugin } from './copyInstrumentationFile'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; /** @@ -53,6 +54,9 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + // copy instrumentation file to build output + plugins.push(makeCopyInstrumentationFilePlugin()); + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts new file mode 100644 index 000000000000..ead32928621f --- /dev/null +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -0,0 +1,249 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type { Plugin, ResolvedConfig } from 'vite'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeCopyInstrumentationFilePlugin } from '../../src/vite/copyInstrumentationFile'; + +vi.mock('fs', () => ({ + promises: { + access: vi.fn(), + mkdir: vi.fn(), + copyFile: vi.fn(), + }, + constants: { + F_OK: 0, + }, +})); + +type AnyFunction = (...args: unknown[]) => unknown; + +describe('makeCopyInstrumentationFilePlugin()', () => { + let plugin: Plugin; + + beforeEach(() => { + vi.clearAllMocks(); + plugin = makeCopyInstrumentationFilePlugin(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('has the correct plugin name', () => { + expect(plugin.name).toBe('sentry-tanstackstart-copy-instrumentation-file'); + }); + + it('applies only to build', () => { + expect(plugin.apply).toBe('build'); + }); + + it('enforces post', () => { + expect(plugin.enforce).toBe('post'); + }); + + describe('configResolved', () => { + it('detects Nitro environment and reads output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + // Verify by calling closeBundle - it should attempt to access the file + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Nitro environment with array rollup output', () => { + const resolvedConfig = { + root: '/project', + plugins: [], + environments: { + nitro: { + build: { + rollupOptions: { + output: [{ dir: '/project/.output/server' }], + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Cloudflare plugin and sets dist/server as output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'vite-plugin-cloudflare' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('detects Netlify plugin and sets dist/server as output dir', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'netlify-plugin' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + }); + + it('does not set output dir when neither Nitro nor Cloudflare/Netlify is detected', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).not.toHaveBeenCalled(); + }); + }); + + describe('closeBundle', () => { + it('copies instrumentation file when it exists and output dir is set', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + fs.constants.F_OK, + ); + expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project/.output/server', 'instrument.server.mjs'), + ); + }); + + it('does nothing when no server output dir is detected', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).not.toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + }); + + it('does nothing when instrumentation file does not exist', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + }); + + it('logs a warning when copy fails', async () => { + const resolvedConfig = { + root: '/project', + plugins: [], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockRejectedValueOnce(new Error('Permission denied')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (plugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', + expect.any(Error), + ); + + warnSpy.mockRestore(); + }); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index ef18da74d03a..400464e204aa 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -28,6 +28,12 @@ const mockMiddlewarePlugin: Plugin = { transform: vi.fn(), }; +const mockCopyInstrumentationPlugin: Plugin = { + name: 'sentry-tanstackstart-copy-instrumentation-file', + apply: 'build', + enforce: 'post', +}; + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -37,6 +43,10 @@ vi.mock('../../src/vite/autoInstrumentMiddleware', () => ({ makeAutoInstrumentMiddlewarePlugin: vi.fn(() => mockMiddlewarePlugin), })); +vi.mock('../../src/vite/copyInstrumentationFile', () => ({ + makeCopyInstrumentationFilePlugin: vi.fn(() => mockCopyInstrumentationPlugin), +})); + describe('sentryTanstackStart()', () => { beforeEach(() => { vi.clearAllMocks(); @@ -51,7 +61,12 @@ describe('sentryTanstackStart()', () => { it('returns source maps plugins in production mode', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockEnableSourceMapsPlugin, + ]); }); it('returns no plugins in development mode', () => { @@ -68,7 +83,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { @@ -77,7 +92,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: 'disable-upload' }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { @@ -86,7 +101,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: false }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockEnableSourceMapsPlugin, + ]); }); }); @@ -94,7 +114,12 @@ describe('sentryTanstackStart()', () => { it('includes middleware plugin by default', () => { const plugins = sentryTanstackStart({ sourcemaps: { disable: true } }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockMiddlewarePlugin, + ]); }); it('includes middleware plugin when autoInstrumentMiddleware is true', () => { @@ -103,7 +128,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockCopyInstrumentationPlugin, + mockMiddlewarePlugin, + ]); }); it('does not include middleware plugin when autoInstrumentMiddleware is false', () => { @@ -112,7 +142,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockCopyInstrumentationPlugin]); }); it('passes correct options to makeAutoInstrumentMiddlewarePlugin', () => { From d6e225dd961bb0e80785120eeedf3a27dae3b1ee Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:32:57 +0100 Subject: [PATCH 02/15] detect by plugin name --- .../src/vite/copyInstrumentationFile.ts | 46 +++++++++++-------- .../test/vite/copyInstrumentationFile.test.ts | 41 ++++++++++++++--- 2 files changed, 61 insertions(+), 26 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 154e294223f2..9a4c460051b8 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -20,27 +20,35 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { enforce: 'post', configResolved(resolvedConfig: ResolvedConfig) { - // Nitro case: read server dir from the nitro environment config - // Vite 6 environment configs are not part of the public type definitions yet, - // so we need to access them via an index signature. - const environments = (resolvedConfig as Record)['environments'] as - | Record } } }> - | undefined; - const nitroEnv = environments?.nitro; - if (nitroEnv) { - const rollupOutput = nitroEnv.build?.rollupOptions?.output; - const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir; - if (dir) { - serverOutputDir = dir; - return; - } - } - - // Cloudflare/Netlify case: detect by plugin name const plugins = resolvedConfig.plugins || []; - const hasCloudflareOrNetlify = plugins.some(p => /cloudflare|netlify/i.test(p.name)); - if (hasCloudflareOrNetlify) { + const hasPlugin = (name: string): boolean => plugins.some(p => p.name === name); + + if (hasPlugin('nitro')) { + // Nitro case: read server dir from the nitro environment config + // Vite 6 environment configs are not part of the public type definitions yet, + // so we need to access them via an index signature. + const environments = (resolvedConfig as Record)['environments'] as + | Record } } }> + | undefined; + const nitroEnv = environments?.nitro; + if (nitroEnv) { + const rollupOutput = nitroEnv.build?.rollupOptions?.output; + const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir; + if (dir) { + serverOutputDir = dir; + } + } + } else if (hasPlugin('cloudflare') || hasPlugin('netlify')) { serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); + } else { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry TanStack Start] Could not determine server output directory. ' + + 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + }); } }, diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index ead32928621f..8778a4af7827 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -45,7 +45,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Nitro environment and reads output dir', () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -71,7 +71,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Nitro environment with array rollup output', () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -94,7 +94,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Cloudflare plugin and sets dist/server as output dir', () => { const resolvedConfig = { root: '/project', - plugins: [{ name: 'vite-plugin-cloudflare' }], + plugins: [{ name: 'cloudflare' }], } as unknown as ResolvedConfig; (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -108,7 +108,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('detects Netlify plugin and sets dist/server as output dir', () => { const resolvedConfig = { root: '/project', - plugins: [{ name: 'netlify-plugin' }], + plugins: [{ name: 'netlify' }], } as unknown as ResolvedConfig; (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -125,11 +125,34 @@ describe('makeCopyInstrumentationFilePlugin()', () => { plugins: [{ name: 'some-other-plugin' }], } as unknown as ResolvedConfig; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (plugin.configResolved as AnyFunction)(resolvedConfig); (plugin.closeBundle as AnyFunction)(); expect(fs.promises.access).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('logs a warning when no recognized deployment plugin is detected', () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (plugin.configResolved as AnyFunction)(resolvedConfig); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] Could not determine server output directory. ' + + 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + + warnSpy.mockRestore(); }); }); @@ -137,7 +160,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('copies instrumentation file when it exists and output dir is set', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -176,18 +199,22 @@ describe('makeCopyInstrumentationFilePlugin()', () => { plugins: [{ name: 'some-other-plugin' }], } as unknown as ResolvedConfig; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + (plugin.configResolved as AnyFunction)(resolvedConfig); await (plugin.closeBundle as AnyFunction)(); expect(fs.promises.access).not.toHaveBeenCalled(); expect(fs.promises.copyFile).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); }); it('does nothing when instrumentation file does not exist', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { @@ -214,7 +241,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('logs a warning when copy fails', async () => { const resolvedConfig = { root: '/project', - plugins: [], + plugins: [{ name: 'nitro' }], environments: { nitro: { build: { From 3a48e1b0692ade720b52a3947491c61379d35ee3 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:43:00 +0100 Subject: [PATCH 03/15] =?UTF-8?q?deslo=C3=BCg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/vite/copyInstrumentationFile.ts | 3 +-- .../test/vite/copyInstrumentationFile.test.ts | 21 +++---------------- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 9a4c460051b8..88c6d9c4bb48 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -44,8 +44,7 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry TanStack Start] Could not determine server output directory. ' + - 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + '[Sentry TanStack Start] Could not detect nitro, cloudflare, or netlify vite plugin. ' + 'The instrument.server.mjs file will not be copied to the build output automatically.', ); }); diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index 8778a4af7827..f127fa8d4a8e 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -119,7 +119,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { expect(fs.promises.access).toHaveBeenCalled(); }); - it('does not set output dir when neither Nitro nor Cloudflare/Netlify is detected', () => { + it('logs a warning and does not set output dir when no recognized plugin is detected', () => { const resolvedConfig = { root: '/project', plugins: [{ name: 'some-other-plugin' }], @@ -131,26 +131,11 @@ describe('makeCopyInstrumentationFilePlugin()', () => { (plugin.closeBundle as AnyFunction)(); - expect(fs.promises.access).not.toHaveBeenCalled(); - - warnSpy.mockRestore(); - }); - - it('logs a warning when no recognized deployment plugin is detected', () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'some-other-plugin' }], - } as unknown as ResolvedConfig; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - (plugin.configResolved as AnyFunction)(resolvedConfig); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] Could not determine server output directory. ' + - 'Could not detect nitro, cloudflare, or netlify vite plugin. ' + + '[Sentry TanStack Start] Could not detect nitro, cloudflare, or netlify vite plugin. ' + 'The instrument.server.mjs file will not be copied to the build output automatically.', ); + expect(fs.promises.access).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); From 379adc7d9db095960976a23963dc3f8b187758e4 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 11:54:31 +0100 Subject: [PATCH 04/15] Add warn message if no instrument file found --- .../src/vite/copyInstrumentationFile.ts | 9 ++++++++- .../test/vite/copyInstrumentationFile.test.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 88c6d9c4bb48..647265893813 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -49,6 +49,7 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { ); }); } + }, async closeBundle() { @@ -61,7 +62,13 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { try { await fs.promises.access(instrumentationSource, fs.constants.F_OK); } catch { - // No instrumentation file found — nothing to copy + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + }); return; } diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index f127fa8d4a8e..ac84ae38cd3e 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -196,7 +196,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { warnSpy.mockRestore(); }); - it('does nothing when instrumentation file does not exist', async () => { + it('warns and does not copy when instrumentation file does not exist', async () => { const resolvedConfig = { root: '/project', plugins: [{ name: 'nitro' }], @@ -217,10 +217,18 @@ describe('makeCopyInstrumentationFilePlugin()', () => { vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await (plugin.closeBundle as AnyFunction)(); expect(fs.promises.access).toHaveBeenCalled(); expect(fs.promises.copyFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + + warnSpy.mockRestore(); }); it('logs a warning when copy fails', async () => { From 7eb02b344fd93dbbf037862c3a9629b86bf863b8 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 12:05:22 +0100 Subject: [PATCH 05/15] instrument file path configurable --- .../src/vite/copyInstrumentationFile.ts | 19 +++-- .../src/vite/sentryTanstackStart.ts | 11 ++- .../test/vite/copyInstrumentationFile.test.ts | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 647265893813..9a2b22c3bd55 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -4,14 +4,17 @@ import * as path from 'path'; import type { Plugin, ResolvedConfig } from 'vite'; /** - * Creates a Vite plugin that copies the user's `instrument.server.mjs` file + * Creates a Vite plugin that copies the user's instrumentation file * to the server build output directory after the build completes. * + * By default, copies `instrument.server.mjs` from the project root. + * A custom file path can be provided via `instrumentationFilePath`. + * * Supports: * - Nitro deployments (reads output dir from the Nitro Vite environment config) * - Cloudflare/Netlify deployments (outputs to `dist/server`) */ -export function makeCopyInstrumentationFilePlugin(): Plugin { +export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: string): Plugin { let serverOutputDir: string | undefined; return { @@ -57,7 +60,8 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { return; } - const instrumentationSource = path.resolve(process.cwd(), 'instrument.server.mjs'); + const instrumentationFileName = instrumentationFilePath || 'instrument.server.mjs'; + const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); try { await fs.promises.access(instrumentationSource, fs.constants.F_OK); @@ -65,26 +69,27 @@ export function makeCopyInstrumentationFilePlugin(): Plugin { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( - '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + `[Sentry TanStack Start] No ${instrumentationFileName} file found in project root. ` + 'The Sentry instrumentation file will not be copied to the build output.', ); }); return; } - const destination = path.resolve(serverOutputDir, 'instrument.server.mjs'); + const destinationFileName = path.basename(instrumentationFileName); + const destination = path.resolve(serverOutputDir, destinationFileName); try { await fs.promises.mkdir(serverOutputDir, { recursive: true }); await fs.promises.copyFile(instrumentationSource, destination); consoleSandbox(() => { // eslint-disable-next-line no-console - console.log(`[Sentry TanStack Start] Copied instrument.server.mjs to ${destination}`); + console.log(`[Sentry TanStack Start] Copied ${destinationFileName} to ${destination}`); }); } catch (error) { consoleSandbox(() => { // eslint-disable-next-line no-console - console.warn('[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', error); + console.warn(`[Sentry TanStack Start] Failed to copy ${destinationFileName} to build output.`, error); }); } }, diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index a8b8d4e8f8bd..651dff1d7ebb 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -20,6 +20,15 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default true */ autoInstrumentMiddleware?: boolean; + + /** + * Path to the instrumentation file to be copied to the server build output directory. + * + * Relative paths are resolved from the current working directory. + * + * @default 'instrument.server.mjs' + */ + instrumentationFilePath?: string; } /** @@ -55,7 +64,7 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; // copy instrumentation file to build output - plugins.push(makeCopyInstrumentationFilePlugin()); + plugins.push(makeCopyInstrumentationFilePlugin(options.instrumentationFilePath)); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index ac84ae38cd3e..0ec1ce8b1d2a 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -262,6 +262,77 @@ describe('makeCopyInstrumentationFilePlugin()', () => { '[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', expect.any(Error), ); + }); + + it('uses custom instrumentation file path when provided', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin('custom/path/my-instrument.mjs'); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (customPlugin.closeBundle as AnyFunction)(); + + expect(fs.promises.access).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), + fs.constants.F_OK, + ); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), + path.resolve('/project/.output/server', 'my-instrument.mjs'), + ); + }); + + it('warns with custom file name when custom instrumentation file is not found', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin('custom/my-instrument.mjs'); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await (customPlugin.closeBundle as AnyFunction)(); + + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry TanStack Start] No custom/my-instrument.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); warnSpy.mockRestore(); }); From dcf888c9d86b1c9c62f37a7c8164ba54cf1d32ba Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 30 Jan 2026 13:18:00 +0100 Subject: [PATCH 06/15] yf --- packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 9a2b22c3bd55..018e02d8663a 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -52,7 +52,6 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri ); }); } - }, async closeBundle() { From 41f210dbcefaeb35da3e37b04b4e6e9bf98f0947 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 12:53:01 +0100 Subject: [PATCH 07/15] Fix nitro --- .../tanstackstart-react/src/vite/copyInstrumentationFile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 018e02d8663a..26c3c526c588 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -24,7 +24,7 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri configResolved(resolvedConfig: ResolvedConfig) { const plugins = resolvedConfig.plugins || []; - const hasPlugin = (name: string): boolean => plugins.some(p => p.name === name); + const hasPlugin = (name: string): boolean => plugins.some(p => p.name?.includes(name)); if (hasPlugin('nitro')) { // Nitro case: read server dir from the nitro environment config From 882daa1e25e1503a8e682a93831ac7a65bae926c Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 13:06:25 +0100 Subject: [PATCH 08/15] be more explicit with the type --- .../src/vite/copyInstrumentationFile.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 26c3c526c588..1cbac554fc22 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -16,6 +16,8 @@ import type { Plugin, ResolvedConfig } from 'vite'; */ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: string): Plugin { let serverOutputDir: string | undefined; + type RollupOutputDir = { dir?: string } | Array<{ dir?: string }>; + type ViteEnvironments = Record; return { name: 'sentry-tanstackstart-copy-instrumentation-file', @@ -25,14 +27,12 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri configResolved(resolvedConfig: ResolvedConfig) { const plugins = resolvedConfig.plugins || []; const hasPlugin = (name: string): boolean => plugins.some(p => p.name?.includes(name)); + console.log('plugins', plugins); if (hasPlugin('nitro')) { // Nitro case: read server dir from the nitro environment config - // Vite 6 environment configs are not part of the public type definitions yet, - // so we need to access them via an index signature. - const environments = (resolvedConfig as Record)['environments'] as - | Record } } }> - | undefined; + console.log('resolvedConfig', resolvedConfig); + const environments = (resolvedConfig as { environments?: ViteEnvironments }).environments; const nitroEnv = environments?.nitro; if (nitroEnv) { const rollupOutput = nitroEnv.build?.rollupOptions?.output; From c9295d47fbcc5a2050302205ec73f894cce1349e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 13:45:34 +0100 Subject: [PATCH 09/15] comment --- .../tanstackstart-react/src/vite/copyInstrumentationFile.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 1cbac554fc22..24afcff1dad3 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -30,8 +30,9 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri console.log('plugins', plugins); if (hasPlugin('nitro')) { - // Nitro case: read server dir from the nitro environment config - console.log('resolvedConfig', resolvedConfig); + // I don't think we have a way to access the nitro instance directly to get the server dir, so we need to access it via the vite environment config. + // This works because Nitro's Vite bundler sets the rollup output dir to the resolved serverDir: + // https://github.com/nitrojs/nitro/blob/1954b824597f6ac52fb8b064415cb85d0feda078/src/build/vite/bundler.ts#L35 const environments = (resolvedConfig as { environments?: ViteEnvironments }).environments; const nitroEnv = environments?.nitro; if (nitroEnv) { From 3cf681e8c98935c0ecf3145c066ced1533955818 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 13:59:05 +0100 Subject: [PATCH 10/15] add escape hatch --- .../src/vite/copyInstrumentationFile.ts | 17 ++++- .../src/vite/sentryTanstackStart.ts | 21 ++++++- .../test/vite/copyInstrumentationFile.test.ts | 63 ++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 24afcff1dad3..6474d24cb92f 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -3,6 +3,11 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Plugin, ResolvedConfig } from 'vite'; +interface CopyInstrumentationFilePluginOptions { + instrumentationFilePath?: string; + serverOutputDir?: string; +} + /** * Creates a Vite plugin that copies the user's instrumentation file * to the server build output directory after the build completes. @@ -14,7 +19,7 @@ import type { Plugin, ResolvedConfig } from 'vite'; * - Nitro deployments (reads output dir from the Nitro Vite environment config) * - Cloudflare/Netlify deployments (outputs to `dist/server`) */ -export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: string): Plugin { +export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationFilePluginOptions): Plugin { let serverOutputDir: string | undefined; type RollupOutputDir = { dir?: string } | Array<{ dir?: string }>; type ViteEnvironments = Record; @@ -25,9 +30,14 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri enforce: 'post', configResolved(resolvedConfig: ResolvedConfig) { + // If user provided serverOutputDir, use it directly and skip auto-detection + if (options?.serverOutputDir) { + serverOutputDir = path.resolve(resolvedConfig.root, options.serverOutputDir); + return; + } + const plugins = resolvedConfig.plugins || []; const hasPlugin = (name: string): boolean => plugins.some(p => p.name?.includes(name)); - console.log('plugins', plugins); if (hasPlugin('nitro')) { // I don't think we have a way to access the nitro instance directly to get the server dir, so we need to access it via the vite environment config. @@ -43,6 +53,7 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri } } } else if (hasPlugin('cloudflare') || hasPlugin('netlify')) { + // There seems to be no way for users to configure the server output dir for these plugins, so we just assume it's `dist/server`, which is the default output dir. serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); } else { consoleSandbox(() => { @@ -60,7 +71,7 @@ export function makeCopyInstrumentationFilePlugin(instrumentationFilePath?: stri return; } - const instrumentationFileName = instrumentationFilePath || 'instrument.server.mjs'; + const instrumentationFileName = options?.instrumentationFilePath || 'instrument.server.mjs'; const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); try { diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 651dff1d7ebb..d2d8051f0fed 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -29,6 +29,20 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @default 'instrument.server.mjs' */ instrumentationFilePath?: string; + + /** + * Custom server output directory path for the instrumentation file. + * + * By default, the plugin auto-detects the output directory: + * - For Nitro: reads from Vite environment config + * - For Cloudflare/Netlify: uses `dist/server` + * + * Use this option to override the default when your deployment target + * uses a non-standard output directory. + * + * @example 'build/server' + */ + serverOutputDir?: string; } /** @@ -64,7 +78,12 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; // copy instrumentation file to build output - plugins.push(makeCopyInstrumentationFilePlugin(options.instrumentationFilePath)); + plugins.push( + makeCopyInstrumentationFilePlugin({ + instrumentationFilePath: options.instrumentationFilePath, + serverOutputDir: options.serverOutputDir, + }), + ); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index 0ec1ce8b1d2a..bfbbb70f5b96 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -139,6 +139,65 @@ describe('makeCopyInstrumentationFilePlugin()', () => { warnSpy.mockRestore(); }); + + it('uses serverOutputDir option when provided, bypassing auto-detection', () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'build/custom-server' }); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + // No warning should be logged since serverOutputDir is provided + expect(warnSpy).not.toHaveBeenCalled(); + + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + (customPlugin.closeBundle as AnyFunction)(); + + // Should attempt to access the file (indicating serverOutputDir was set) + expect(fs.promises.access).toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('serverOutputDir option overrides auto-detected Nitro output dir', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'custom/output' }); + + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { + dir: '/project/.output/server', + }, + }, + }, + }, + }, + } as unknown as ResolvedConfig; + + (customPlugin.configResolved as AnyFunction)(resolvedConfig); + + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + + await (customPlugin.closeBundle as AnyFunction)(); + + // Should use the custom serverOutputDir, not Nitro's auto-detected dir + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'custom/output'), { recursive: true }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project', 'custom/output', 'instrument.server.mjs'), + ); + }); }); describe('closeBundle', () => { @@ -265,7 +324,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); it('uses custom instrumentation file path when provided', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin('custom/path/my-instrument.mjs'); + const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/path/my-instrument.mjs' }); const resolvedConfig = { root: '/project', @@ -302,7 +361,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); it('warns with custom file name when custom instrumentation file is not found', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin('custom/my-instrument.mjs'); + const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' }); const resolvedConfig = { root: '/project', From 71014bba7e0185ee1d1893942cb8c83ef4d7e429 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 14:08:36 +0100 Subject: [PATCH 11/15] simple logs --- .../src/vite/copyInstrumentationFile.ts | 37 +++++++------------ .../test/vite/copyInstrumentationFile.test.ts | 8 ++-- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index 6474d24cb92f..bfd711be380f 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -1,4 +1,3 @@ -import { consoleSandbox } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; import type { Plugin, ResolvedConfig } from 'vite'; @@ -56,13 +55,11 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF // There seems to be no way for users to configure the server output dir for these plugins, so we just assume it's `dist/server`, which is the default output dir. serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server'); } else { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - '[Sentry TanStack Start] Could not detect nitro, cloudflare, or netlify vite plugin. ' + - 'The instrument.server.mjs file will not be copied to the build output automatically.', - ); - }); + // eslint-disable-next-line no-console + console.warn( + '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); } }, @@ -77,13 +74,11 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF try { await fs.promises.access(instrumentationSource, fs.constants.F_OK); } catch { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn( - `[Sentry TanStack Start] No ${instrumentationFileName} file found in project root. ` + - 'The Sentry instrumentation file will not be copied to the build output.', - ); - }); + // eslint-disable-next-line no-console + console.warn( + `[Sentry] No ${instrumentationFileName} file found in project root. ` + + 'The Sentry instrumentation file will not be copied to the build output.', + ); return; } @@ -93,15 +88,11 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF try { await fs.promises.mkdir(serverOutputDir, { recursive: true }); await fs.promises.copyFile(instrumentationSource, destination); - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.log(`[Sentry TanStack Start] Copied ${destinationFileName} to ${destination}`); - }); + // eslint-disable-next-line no-console + console.log(`[Sentry] Copied ${destinationFileName} to ${destination}`); } catch (error) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.warn(`[Sentry TanStack Start] Failed to copy ${destinationFileName} to build output.`, error); - }); + // eslint-disable-next-line no-console + console.warn(`[Sentry] Failed to copy ${destinationFileName} to build output.`, error); } }, }; diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index bfbbb70f5b96..2d5248c78bde 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -132,7 +132,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { (plugin.closeBundle as AnyFunction)(); expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] Could not detect nitro, cloudflare, or netlify vite plugin. ' + + '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + 'The instrument.server.mjs file will not be copied to the build output automatically.', ); expect(fs.promises.access).not.toHaveBeenCalled(); @@ -283,7 +283,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { expect(fs.promises.access).toHaveBeenCalled(); expect(fs.promises.copyFile).not.toHaveBeenCalled(); expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] No instrument.server.mjs file found in project root. ' + + '[Sentry] No instrument.server.mjs file found in project root. ' + 'The Sentry instrumentation file will not be copied to the build output.', ); @@ -318,7 +318,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { await (plugin.closeBundle as AnyFunction)(); expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', + '[Sentry] Failed to copy instrument.server.mjs to build output.', expect.any(Error), ); }); @@ -388,7 +388,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { await (customPlugin.closeBundle as AnyFunction)(); expect(warnSpy).toHaveBeenCalledWith( - '[Sentry TanStack Start] No custom/my-instrument.mjs file found in project root. ' + + '[Sentry] No custom/my-instrument.mjs file found in project root. ' + 'The Sentry instrumentation file will not be copied to the build output.', ); expect(fs.promises.copyFile).not.toHaveBeenCalled(); From a839ec9a24d651ba5318af53f05bedb05c960dd9 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 14:20:06 +0100 Subject: [PATCH 12/15] clean up tests --- .../test/vite/copyInstrumentationFile.test.ts | 163 ++++-------------- 1 file changed, 29 insertions(+), 134 deletions(-) diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index 2d5248c78bde..f944dff420c3 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -17,6 +17,23 @@ vi.mock('fs', () => ({ type AnyFunction = (...args: unknown[]) => unknown; +function createNitroConfig(overrides?: Partial): ResolvedConfig { + return { + root: '/project', + plugins: [{ name: 'nitro' }], + environments: { + nitro: { + build: { + rollupOptions: { + output: { dir: '/project/.output/server' }, + }, + }, + }, + }, + ...overrides, + } as unknown as ResolvedConfig; +} + describe('makeCopyInstrumentationFilePlugin()', () => { let plugin: Plugin; @@ -29,35 +46,9 @@ describe('makeCopyInstrumentationFilePlugin()', () => { vi.restoreAllMocks(); }); - it('has the correct plugin name', () => { - expect(plugin.name).toBe('sentry-tanstackstart-copy-instrumentation-file'); - }); - - it('applies only to build', () => { - expect(plugin.apply).toBe('build'); - }); - - it('enforces post', () => { - expect(plugin.enforce).toBe('post'); - }); - describe('configResolved', () => { it('detects Nitro environment and reads output dir', () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -91,24 +82,10 @@ describe('makeCopyInstrumentationFilePlugin()', () => { expect(fs.promises.access).toHaveBeenCalled(); }); - it('detects Cloudflare plugin and sets dist/server as output dir', () => { + it.each(['cloudflare', 'netlify'])('detects %s plugin and sets dist/server as output dir', pluginName => { const resolvedConfig = { root: '/project', - plugins: [{ name: 'cloudflare' }], - } as unknown as ResolvedConfig; - - (plugin.configResolved as AnyFunction)(resolvedConfig); - - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (plugin.closeBundle as AnyFunction)(); - - expect(fs.promises.access).toHaveBeenCalled(); - }); - - it('detects Netlify plugin and sets dist/server as output dir', () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'netlify' }], + plugins: [{ name: pluginName }], } as unknown as ResolvedConfig; (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -167,21 +144,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('serverOutputDir option overrides auto-detected Nitro output dir', async () => { const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'custom/output' }); - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (customPlugin.configResolved as AnyFunction)(resolvedConfig); @@ -202,21 +165,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { describe('closeBundle', () => { it('copies instrumentation file when it exists and output dir is set', async () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -256,21 +205,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); it('warns and does not copy when instrumentation file does not exist', async () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -291,21 +226,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); it('logs a warning when copy fails', async () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (plugin.configResolved as AnyFunction)(resolvedConfig); @@ -324,23 +245,11 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); it('uses custom instrumentation file path when provided', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/path/my-instrument.mjs' }); + const customPlugin = makeCopyInstrumentationFilePlugin({ + instrumentationFilePath: 'custom/path/my-instrument.mjs', + }); - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (customPlugin.configResolved as AnyFunction)(resolvedConfig); @@ -363,21 +272,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { it('warns with custom file name when custom instrumentation file is not found', async () => { const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' }); - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: { - dir: '/project/.output/server', - }, - }, - }, - }, - }, - } as unknown as ResolvedConfig; + const resolvedConfig = createNitroConfig(); (customPlugin.configResolved as AnyFunction)(resolvedConfig); From b0456e72ac938c7f461e3db46e4bc4ea11dd4dcc Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 15:03:40 +0100 Subject: [PATCH 13/15] cleanup --- .../src/vite/copyInstrumentationFile.ts | 19 ++++++---- .../test/vite/copyInstrumentationFile.test.ts | 36 ++----------------- 2 files changed, 14 insertions(+), 41 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts index bfd711be380f..215238042e48 100644 --- a/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts +++ b/packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts @@ -14,13 +14,15 @@ interface CopyInstrumentationFilePluginOptions { * By default, copies `instrument.server.mjs` from the project root. * A custom file path can be provided via `instrumentationFilePath`. * - * Supports: - * - Nitro deployments (reads output dir from the Nitro Vite environment config) - * - Cloudflare/Netlify deployments (outputs to `dist/server`) + * The server output directory can be configured via `serverOutputDir`. + * By default, it will be auto-detected based on the vite plugin being used. + * + * For nitro deployments, we use the Nitro Vite environment config to get the server output directory. + * For cloudflare and netlify deployments, we assume the server output directory is `dist/server`, which is the default output directory for these plugins. */ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationFilePluginOptions): Plugin { let serverOutputDir: string | undefined; - type RollupOutputDir = { dir?: string } | Array<{ dir?: string }>; + type RollupOutputDir = { dir?: string }; type ViteEnvironments = Record; return { @@ -39,14 +41,14 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF const hasPlugin = (name: string): boolean => plugins.some(p => p.name?.includes(name)); if (hasPlugin('nitro')) { - // I don't think we have a way to access the nitro instance directly to get the server dir, so we need to access it via the vite environment config. + // There seems to be no way to access the nitro instance directly to get the server dir, so we need to access it via the vite environment config. // This works because Nitro's Vite bundler sets the rollup output dir to the resolved serverDir: // https://github.com/nitrojs/nitro/blob/1954b824597f6ac52fb8b064415cb85d0feda078/src/build/vite/bundler.ts#L35 const environments = (resolvedConfig as { environments?: ViteEnvironments }).environments; const nitroEnv = environments?.nitro; if (nitroEnv) { const rollupOutput = nitroEnv.build?.rollupOptions?.output; - const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir; + const dir = rollupOutput?.dir; if (dir) { serverOutputDir = dir; } @@ -64,6 +66,7 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF }, async closeBundle() { + // Auto-detection failed, so we don't copy the instrumentation file. if (!serverOutputDir) { return; } @@ -71,8 +74,9 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF const instrumentationFileName = options?.instrumentationFilePath || 'instrument.server.mjs'; const instrumentationSource = path.resolve(process.cwd(), instrumentationFileName); + // Check if the instrumentation file exists. try { - await fs.promises.access(instrumentationSource, fs.constants.F_OK); + await fs.promises.access(instrumentationSource); } catch { // eslint-disable-next-line no-console console.warn( @@ -82,6 +86,7 @@ export function makeCopyInstrumentationFilePlugin(options?: CopyInstrumentationF return; } + // Copy the instrumentation file to the server output directory. const destinationFileName = path.basename(instrumentationFileName); const destination = path.resolve(serverOutputDir, destinationFileName); diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index f944dff420c3..299f2976956e 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -10,9 +10,6 @@ vi.mock('fs', () => ({ mkdir: vi.fn(), copyFile: vi.fn(), }, - constants: { - F_OK: 0, - }, })); type AnyFunction = (...args: unknown[]) => unknown; @@ -59,29 +56,6 @@ describe('makeCopyInstrumentationFilePlugin()', () => { expect(fs.promises.access).toHaveBeenCalled(); }); - it('detects Nitro environment with array rollup output', () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'nitro' }], - environments: { - nitro: { - build: { - rollupOptions: { - output: [{ dir: '/project/.output/server' }], - }, - }, - }, - }, - } as unknown as ResolvedConfig; - - (plugin.configResolved as AnyFunction)(resolvedConfig); - - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (plugin.closeBundle as AnyFunction)(); - - expect(fs.promises.access).toHaveBeenCalled(); - }); - it.each(['cloudflare', 'netlify'])('detects %s plugin and sets dist/server as output dir', pluginName => { const resolvedConfig = { root: '/project', @@ -175,10 +149,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { await (plugin.closeBundle as AnyFunction)(); - expect(fs.promises.access).toHaveBeenCalledWith( - path.resolve(process.cwd(), 'instrument.server.mjs'), - fs.constants.F_OK, - ); + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'instrument.server.mjs')); expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); expect(fs.promises.copyFile).toHaveBeenCalledWith( path.resolve(process.cwd(), 'instrument.server.mjs'), @@ -259,10 +230,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { await (customPlugin.closeBundle as AnyFunction)(); - expect(fs.promises.access).toHaveBeenCalledWith( - path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), - fs.constants.F_OK, - ); + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'custom/path/my-instrument.mjs')); expect(fs.promises.copyFile).toHaveBeenCalledWith( path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), path.resolve('/project/.output/server', 'my-instrument.mjs'), From fed360426fcf7436a966d6750afe461dd02d4fca Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 15:20:14 +0100 Subject: [PATCH 14/15] cleanup tests --- .../test/vite/copyInstrumentationFile.test.ts | 51 ++++++++----------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index 299f2976956e..a9dcfeb01259 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -44,19 +44,21 @@ describe('makeCopyInstrumentationFilePlugin()', () => { }); describe('configResolved', () => { - it('detects Nitro environment and reads output dir', () => { + it('detects Nitro environment and reads output dir', async () => { const resolvedConfig = createNitroConfig(); (plugin.configResolved as AnyFunction)(resolvedConfig); - // Verify by calling closeBundle - it should attempt to access the file - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (plugin.closeBundle as AnyFunction)(); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - expect(fs.promises.access).toHaveBeenCalled(); + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); }); - it.each(['cloudflare', 'netlify'])('detects %s plugin and sets dist/server as output dir', pluginName => { + it.each(['cloudflare', 'netlify'])('detects %s plugin and sets dist/server as output dir', async pluginName => { const resolvedConfig = { root: '/project', plugins: [{ name: pluginName }], @@ -64,10 +66,13 @@ describe('makeCopyInstrumentationFilePlugin()', () => { (plugin.configResolved as AnyFunction)(resolvedConfig); - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (plugin.closeBundle as AnyFunction)(); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - expect(fs.promises.access).toHaveBeenCalled(); + await (plugin.closeBundle as AnyFunction)(); + + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'dist', 'server'), { recursive: true }); }); it('logs a warning and does not set output dir when no recognized plugin is detected', () => { @@ -91,7 +96,7 @@ describe('makeCopyInstrumentationFilePlugin()', () => { warnSpy.mockRestore(); }); - it('uses serverOutputDir option when provided, bypassing auto-detection', () => { + it('uses serverOutputDir option when provided, bypassing auto-detection', async () => { const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'build/custom-server' }); const resolvedConfig = { @@ -106,34 +111,22 @@ describe('makeCopyInstrumentationFilePlugin()', () => { // No warning should be logged since serverOutputDir is provided expect(warnSpy).not.toHaveBeenCalled(); - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (customPlugin.closeBundle as AnyFunction)(); - - // Should attempt to access the file (indicating serverOutputDir was set) - expect(fs.promises.access).toHaveBeenCalled(); - - warnSpy.mockRestore(); - }); - - it('serverOutputDir option overrides auto-detected Nitro output dir', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'custom/output' }); - - const resolvedConfig = createNitroConfig(); - - (customPlugin.configResolved as AnyFunction)(resolvedConfig); - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); await (customPlugin.closeBundle as AnyFunction)(); - // Should use the custom serverOutputDir, not Nitro's auto-detected dir - expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'custom/output'), { recursive: true }); + // Verify the custom serverOutputDir is used + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'build/custom-server'), { + recursive: true, + }); expect(fs.promises.copyFile).toHaveBeenCalledWith( path.resolve(process.cwd(), 'instrument.server.mjs'), - path.resolve('/project', 'custom/output', 'instrument.server.mjs'), + path.resolve('/project', 'build/custom-server', 'instrument.server.mjs'), ); + + warnSpy.mockRestore(); }); }); From a7a621481adddb8428c0387f218c11c81222d4b4 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Feb 2026 15:36:06 +0100 Subject: [PATCH 15/15] improve tests --- .../test/vite/copyInstrumentationFile.test.ts | 275 ++++++++---------- 1 file changed, 120 insertions(+), 155 deletions(-) diff --git a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts index a9dcfeb01259..4bd30dd433a0 100644 --- a/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts +++ b/packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts @@ -43,213 +43,178 @@ describe('makeCopyInstrumentationFilePlugin()', () => { vi.restoreAllMocks(); }); - describe('configResolved', () => { - it('detects Nitro environment and reads output dir', async () => { - const resolvedConfig = createNitroConfig(); + it('copies instrumentation file with Nitro config', async () => { + const resolvedConfig = createNitroConfig(); - (plugin.configResolved as AnyFunction)(resolvedConfig); + (plugin.configResolved as AnyFunction)(resolvedConfig); - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - await (plugin.closeBundle as AnyFunction)(); + await (plugin.closeBundle as AnyFunction)(); - expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); - }); - - it.each(['cloudflare', 'netlify'])('detects %s plugin and sets dist/server as output dir', async pluginName => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: pluginName }], - } as unknown as ResolvedConfig; - - (plugin.configResolved as AnyFunction)(resolvedConfig); - - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - - await (plugin.closeBundle as AnyFunction)(); - - expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'dist', 'server'), { recursive: true }); - }); - - it('logs a warning and does not set output dir when no recognized plugin is detected', () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'some-other-plugin' }], - } as unknown as ResolvedConfig; - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - (plugin.configResolved as AnyFunction)(resolvedConfig); - - (plugin.closeBundle as AnyFunction)(); + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'instrument.server.mjs')); + expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project/.output/server', 'instrument.server.mjs'), + ); + }); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + - 'The instrument.server.mjs file will not be copied to the build output automatically.', - ); - expect(fs.promises.access).not.toHaveBeenCalled(); + it.each(['cloudflare', 'netlify'])('copies instrumentation file with %s config', async pluginName => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: pluginName }], + } as unknown as ResolvedConfig; - warnSpy.mockRestore(); - }); + (plugin.configResolved as AnyFunction)(resolvedConfig); - it('uses serverOutputDir option when provided, bypassing auto-detection', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'build/custom-server' }); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'some-other-plugin' }], - } as unknown as ResolvedConfig; + await (plugin.closeBundle as AnyFunction)(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'dist', 'server'), { recursive: true }); + }); - (customPlugin.configResolved as AnyFunction)(resolvedConfig); + it('warns and does nothing when no recognized plugin is detected', async () => { + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; - // No warning should be logged since serverOutputDir is provided - expect(warnSpy).not.toHaveBeenCalled(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + (plugin.configResolved as AnyFunction)(resolvedConfig); - await (customPlugin.closeBundle as AnyFunction)(); + await (plugin.closeBundle as AnyFunction)(); - // Verify the custom serverOutputDir is used - expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'build/custom-server'), { - recursive: true, - }); - expect(fs.promises.copyFile).toHaveBeenCalledWith( - path.resolve(process.cwd(), 'instrument.server.mjs'), - path.resolve('/project', 'build/custom-server', 'instrument.server.mjs'), - ); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] Could not detect nitro, cloudflare, or netlify vite plugin. ' + + 'The instrument.server.mjs file will not be copied to the build output automatically.', + ); + expect(fs.promises.access).not.toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); + warnSpy.mockRestore(); }); - describe('closeBundle', () => { - it('copies instrumentation file when it exists and output dir is set', async () => { - const resolvedConfig = createNitroConfig(); + it('uses serverOutputDir option bypassing auto-detection', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ serverOutputDir: 'build/custom-server' }); - (plugin.configResolved as AnyFunction)(resolvedConfig); + const resolvedConfig = { + root: '/project', + plugins: [{ name: 'some-other-plugin' }], + } as unknown as ResolvedConfig; - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await (plugin.closeBundle as AnyFunction)(); + (customPlugin.configResolved as AnyFunction)(resolvedConfig); - expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'instrument.server.mjs')); - expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true }); - expect(fs.promises.copyFile).toHaveBeenCalledWith( - path.resolve(process.cwd(), 'instrument.server.mjs'), - path.resolve('/project/.output/server', 'instrument.server.mjs'), - ); - }); + // No warning should be logged since serverOutputDir is provided + expect(warnSpy).not.toHaveBeenCalled(); - it('does nothing when no server output dir is detected', async () => { - const resolvedConfig = { - root: '/project', - plugins: [{ name: 'some-other-plugin' }], - } as unknown as ResolvedConfig; + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await (customPlugin.closeBundle as AnyFunction)(); - (plugin.configResolved as AnyFunction)(resolvedConfig); + // Verify the custom serverOutputDir is used + expect(fs.promises.mkdir).toHaveBeenCalledWith(path.resolve('/project', 'build/custom-server'), { + recursive: true, + }); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'instrument.server.mjs'), + path.resolve('/project', 'build/custom-server', 'instrument.server.mjs'), + ); - await (plugin.closeBundle as AnyFunction)(); + warnSpy.mockRestore(); + }); - expect(fs.promises.access).not.toHaveBeenCalled(); - expect(fs.promises.copyFile).not.toHaveBeenCalled(); + it('warns when instrumentation file does not exist', async () => { + const resolvedConfig = createNitroConfig(); - warnSpy.mockRestore(); - }); + (plugin.configResolved as AnyFunction)(resolvedConfig); - it('warns and does not copy when instrumentation file does not exist', async () => { - const resolvedConfig = createNitroConfig(); + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - (plugin.configResolved as AnyFunction)(resolvedConfig); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + await (plugin.closeBundle as AnyFunction)(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(fs.promises.access).toHaveBeenCalled(); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] No instrument.server.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); - await (plugin.closeBundle as AnyFunction)(); + warnSpy.mockRestore(); + }); - expect(fs.promises.access).toHaveBeenCalled(); - expect(fs.promises.copyFile).not.toHaveBeenCalled(); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry] No instrument.server.mjs file found in project root. ' + - 'The Sentry instrumentation file will not be copied to the build output.', - ); + it('warns when copy operation fails', async () => { + const resolvedConfig = createNitroConfig(); - warnSpy.mockRestore(); - }); + (plugin.configResolved as AnyFunction)(resolvedConfig); - it('logs a warning when copy fails', async () => { - const resolvedConfig = createNitroConfig(); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockRejectedValueOnce(new Error('Permission denied')); - (plugin.configResolved as AnyFunction)(resolvedConfig); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockRejectedValueOnce(new Error('Permission denied')); + await (plugin.closeBundle as AnyFunction)(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await (plugin.closeBundle as AnyFunction)(); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] Failed to copy instrument.server.mjs to build output.', + expect.any(Error), + ); + }); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry] Failed to copy instrument.server.mjs to build output.', - expect.any(Error), - ); + it('uses custom instrumentation file path', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ + instrumentationFilePath: 'custom/path/my-instrument.mjs', }); - it('uses custom instrumentation file path when provided', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin({ - instrumentationFilePath: 'custom/path/my-instrument.mjs', - }); - - const resolvedConfig = createNitroConfig(); + const resolvedConfig = createNitroConfig(); - (customPlugin.configResolved as AnyFunction)(resolvedConfig); + (customPlugin.configResolved as AnyFunction)(resolvedConfig); - vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); - vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined); + vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined); - await (customPlugin.closeBundle as AnyFunction)(); + await (customPlugin.closeBundle as AnyFunction)(); - expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'custom/path/my-instrument.mjs')); - expect(fs.promises.copyFile).toHaveBeenCalledWith( - path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), - path.resolve('/project/.output/server', 'my-instrument.mjs'), - ); - }); + expect(fs.promises.access).toHaveBeenCalledWith(path.resolve(process.cwd(), 'custom/path/my-instrument.mjs')); + expect(fs.promises.copyFile).toHaveBeenCalledWith( + path.resolve(process.cwd(), 'custom/path/my-instrument.mjs'), + path.resolve('/project/.output/server', 'my-instrument.mjs'), + ); + }); - it('warns with custom file name when custom instrumentation file is not found', async () => { - const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' }); + it('warns with custom file name when file not found', async () => { + const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' }); - const resolvedConfig = createNitroConfig(); + const resolvedConfig = createNitroConfig(); - (customPlugin.configResolved as AnyFunction)(resolvedConfig); + (customPlugin.configResolved as AnyFunction)(resolvedConfig); - vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); + vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT')); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await (customPlugin.closeBundle as AnyFunction)(); + await (customPlugin.closeBundle as AnyFunction)(); - expect(warnSpy).toHaveBeenCalledWith( - '[Sentry] No custom/my-instrument.mjs file found in project root. ' + - 'The Sentry instrumentation file will not be copied to the build output.', - ); - expect(fs.promises.copyFile).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith( + '[Sentry] No custom/my-instrument.mjs file found in project root. ' + + 'The Sentry instrumentation file will not be copied to the build output.', + ); + expect(fs.promises.copyFile).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); + warnSpy.mockRestore(); }); });