Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions packages/tanstackstart-react/src/vite/copyInstrumentationFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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.
*
* By default, copies `instrument.server.mjs` from the project root.
* A custom file path can be provided via `instrumentationFilePath`.
*
* 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 };
type ViteEnvironments = Record<string, { build?: { rollupOptions?: { output?: RollupOutputDir } } }>;

return {
name: 'sentry-tanstackstart-copy-instrumentation-file',
apply: 'build',
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));

if (hasPlugin('nitro')) {
// 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 = rollupOutput?.dir;
if (dir) {
serverOutputDir = dir;
}
}
} 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 {
// 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.',
);
}
},

async closeBundle() {
// Auto-detection failed, so we don't copy the instrumentation file.
if (!serverOutputDir) {
return;
}

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);
} catch {
// 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;
}

// Copy the instrumentation file to the server output directory.
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);
// eslint-disable-next-line no-console
console.log(`[Sentry] Copied ${destinationFileName} to ${destination}`);
} catch (error) {
// eslint-disable-next-line no-console
console.warn(`[Sentry] Failed to copy ${destinationFileName} to build output.`, error);
}
},
};
}
32 changes: 32 additions & 0 deletions packages/tanstackstart-react/src/vite/sentryTanstackStart.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -19,6 +20,29 @@ 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;

/**
* 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;
}

/**
Expand Down Expand Up @@ -53,6 +77,14 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P

const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)];

// copy instrumentation file to build output
plugins.push(
makeCopyInstrumentationFilePlugin({
instrumentationFilePath: options.instrumentationFilePath,
serverOutputDir: options.serverOutputDir,
}),
);

// middleware auto-instrumentation
if (options.autoInstrumentMiddleware !== false) {
plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug }));
Expand Down
220 changes: 220 additions & 0 deletions packages/tanstackstart-react/test/vite/copyInstrumentationFile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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(),
},
}));

type AnyFunction = (...args: unknown[]) => unknown;

function createNitroConfig(overrides?: Partial<ResolvedConfig>): 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;

beforeEach(() => {
vi.clearAllMocks();
plugin = makeCopyInstrumentationFilePlugin();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('copies instrumentation file with Nitro config', async () => {
const resolvedConfig = createNitroConfig();

(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'));
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.each(['cloudflare', 'netlify'])('copies instrumentation file with %s config', 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('warns and does nothing when no recognized plugin is detected', async () => {
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);

await (plugin.closeBundle as AnyFunction)();

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();
});

it('uses serverOutputDir option bypassing auto-detection', async () => {
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).mockResolvedValueOnce(undefined);
vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined);
vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined);

await (customPlugin.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'),
);

warnSpy.mockRestore();
});

it('warns when instrumentation file does not exist', async () => {
const resolvedConfig = createNitroConfig();

(plugin.configResolved as AnyFunction)(resolvedConfig);

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] No instrument.server.mjs file found in project root. ' +
'The Sentry instrumentation file will not be copied to the build output.',
);

warnSpy.mockRestore();
});

it('warns when copy operation fails', async () => {
const resolvedConfig = createNitroConfig();

(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] 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',
});

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)();

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 file not found', async () => {
const customPlugin = makeCopyInstrumentationFilePlugin({ instrumentationFilePath: 'custom/my-instrument.mjs' });

const resolvedConfig = createNitroConfig();

(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] 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();
});
});
Loading
Loading