Skip to content

Commit d632fe8

Browse files
committed
feat(tanstackstart-react): Auto-copy build file to correct folder
1 parent b0add63 commit d632fe8

File tree

5 files changed

+369
-8
lines changed

5 files changed

+369
-8
lines changed

dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"version": "0.0.1",
55
"type": "module",
66
"scripts": {
7-
"build": "vite build && cp instrument.server.mjs .output/server",
7+
"build": "vite build",
88
"start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs",
99
"test": "playwright test",
1010
"clean": "npx rimraf node_modules pnpm-lock.yaml",
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { consoleSandbox } from '@sentry/core';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import type { Plugin, ResolvedConfig } from 'vite';
5+
6+
/**
7+
* Creates a Vite plugin that copies the user's `instrument.server.mjs` file
8+
* to the server build output directory after the build completes.
9+
*
10+
* Supports:
11+
* - Nitro deployments (reads output dir from the Nitro Vite environment config)
12+
* - Cloudflare/Netlify deployments (outputs to `dist/server`)
13+
*/
14+
export function makeCopyInstrumentationFilePlugin(): Plugin {
15+
let serverOutputDir: string | undefined;
16+
17+
return {
18+
name: 'sentry-tanstackstart-copy-instrumentation-file',
19+
apply: 'build',
20+
enforce: 'post',
21+
22+
configResolved(resolvedConfig: ResolvedConfig) {
23+
// Nitro case: read server dir from the nitro environment config
24+
// Vite 6 environment configs are not part of the public type definitions yet,
25+
// so we need to access them via an index signature.
26+
const environments = (resolvedConfig as Record<string, unknown>)['environments'] as
27+
| Record<string, { build?: { rollupOptions?: { output?: { dir?: string } | Array<{ dir?: string }> } } }>
28+
| undefined;
29+
const nitroEnv = environments?.nitro;
30+
if (nitroEnv) {
31+
const rollupOutput = nitroEnv.build?.rollupOptions?.output;
32+
const dir = Array.isArray(rollupOutput) ? rollupOutput[0]?.dir : rollupOutput?.dir;
33+
if (dir) {
34+
serverOutputDir = dir;
35+
return;
36+
}
37+
}
38+
39+
// Cloudflare/Netlify case: detect by plugin name
40+
const plugins = resolvedConfig.plugins || [];
41+
const hasCloudflareOrNetlify = plugins.some(p => /cloudflare|netlify/i.test(p.name));
42+
if (hasCloudflareOrNetlify) {
43+
serverOutputDir = path.resolve(resolvedConfig.root, 'dist', 'server');
44+
}
45+
},
46+
47+
async closeBundle() {
48+
if (!serverOutputDir) {
49+
return;
50+
}
51+
52+
const instrumentationSource = path.resolve(process.cwd(), 'instrument.server.mjs');
53+
54+
try {
55+
await fs.promises.access(instrumentationSource, fs.constants.F_OK);
56+
} catch {
57+
// No instrumentation file found — nothing to copy
58+
return;
59+
}
60+
61+
const destination = path.resolve(serverOutputDir, 'instrument.server.mjs');
62+
63+
try {
64+
await fs.promises.mkdir(serverOutputDir, { recursive: true });
65+
await fs.promises.copyFile(instrumentationSource, destination);
66+
consoleSandbox(() => {
67+
// eslint-disable-next-line no-console
68+
console.log(`[Sentry TanStack Start] Copied instrument.server.mjs to ${destination}`);
69+
});
70+
} catch (error) {
71+
consoleSandbox(() => {
72+
// eslint-disable-next-line no-console
73+
console.warn('[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.', error);
74+
});
75+
}
76+
},
77+
};
78+
}

packages/tanstackstart-react/src/vite/sentryTanstackStart.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { BuildTimeOptionsBase } from '@sentry/core';
22
import type { Plugin } from 'vite';
33
import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware';
4+
import { makeCopyInstrumentationFilePlugin } from './copyInstrumentationFile';
45
import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps';
56

67
/**
@@ -53,6 +54,9 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P
5354

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

57+
// copy instrumentation file to build output
58+
plugins.push(makeCopyInstrumentationFilePlugin());
59+
5660
// middleware auto-instrumentation
5761
if (options.autoInstrumentMiddleware !== false) {
5862
plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug }));
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import type { Plugin, ResolvedConfig } from 'vite';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { makeCopyInstrumentationFilePlugin } from '../../src/vite/copyInstrumentationFile';
6+
7+
vi.mock('fs', () => ({
8+
promises: {
9+
access: vi.fn(),
10+
mkdir: vi.fn(),
11+
copyFile: vi.fn(),
12+
},
13+
constants: {
14+
F_OK: 0,
15+
},
16+
}));
17+
18+
type AnyFunction = (...args: unknown[]) => unknown;
19+
20+
describe('makeCopyInstrumentationFilePlugin()', () => {
21+
let plugin: Plugin;
22+
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
plugin = makeCopyInstrumentationFilePlugin();
26+
});
27+
28+
afterEach(() => {
29+
vi.restoreAllMocks();
30+
});
31+
32+
it('has the correct plugin name', () => {
33+
expect(plugin.name).toBe('sentry-tanstackstart-copy-instrumentation-file');
34+
});
35+
36+
it('applies only to build', () => {
37+
expect(plugin.apply).toBe('build');
38+
});
39+
40+
it('enforces post', () => {
41+
expect(plugin.enforce).toBe('post');
42+
});
43+
44+
describe('configResolved', () => {
45+
it('detects Nitro environment and reads output dir', () => {
46+
const resolvedConfig = {
47+
root: '/project',
48+
plugins: [],
49+
environments: {
50+
nitro: {
51+
build: {
52+
rollupOptions: {
53+
output: {
54+
dir: '/project/.output/server',
55+
},
56+
},
57+
},
58+
},
59+
},
60+
} as unknown as ResolvedConfig;
61+
62+
(plugin.configResolved as AnyFunction)(resolvedConfig);
63+
64+
// Verify by calling closeBundle - it should attempt to access the file
65+
vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT'));
66+
(plugin.closeBundle as AnyFunction)();
67+
68+
expect(fs.promises.access).toHaveBeenCalled();
69+
});
70+
71+
it('detects Nitro environment with array rollup output', () => {
72+
const resolvedConfig = {
73+
root: '/project',
74+
plugins: [],
75+
environments: {
76+
nitro: {
77+
build: {
78+
rollupOptions: {
79+
output: [{ dir: '/project/.output/server' }],
80+
},
81+
},
82+
},
83+
},
84+
} as unknown as ResolvedConfig;
85+
86+
(plugin.configResolved as AnyFunction)(resolvedConfig);
87+
88+
vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT'));
89+
(plugin.closeBundle as AnyFunction)();
90+
91+
expect(fs.promises.access).toHaveBeenCalled();
92+
});
93+
94+
it('detects Cloudflare plugin and sets dist/server as output dir', () => {
95+
const resolvedConfig = {
96+
root: '/project',
97+
plugins: [{ name: 'vite-plugin-cloudflare' }],
98+
} as unknown as ResolvedConfig;
99+
100+
(plugin.configResolved as AnyFunction)(resolvedConfig);
101+
102+
vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT'));
103+
(plugin.closeBundle as AnyFunction)();
104+
105+
expect(fs.promises.access).toHaveBeenCalled();
106+
});
107+
108+
it('detects Netlify plugin and sets dist/server as output dir', () => {
109+
const resolvedConfig = {
110+
root: '/project',
111+
plugins: [{ name: 'netlify-plugin' }],
112+
} as unknown as ResolvedConfig;
113+
114+
(plugin.configResolved as AnyFunction)(resolvedConfig);
115+
116+
vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT'));
117+
(plugin.closeBundle as AnyFunction)();
118+
119+
expect(fs.promises.access).toHaveBeenCalled();
120+
});
121+
122+
it('does not set output dir when neither Nitro nor Cloudflare/Netlify is detected', () => {
123+
const resolvedConfig = {
124+
root: '/project',
125+
plugins: [{ name: 'some-other-plugin' }],
126+
} as unknown as ResolvedConfig;
127+
128+
(plugin.configResolved as AnyFunction)(resolvedConfig);
129+
130+
(plugin.closeBundle as AnyFunction)();
131+
132+
expect(fs.promises.access).not.toHaveBeenCalled();
133+
});
134+
});
135+
136+
describe('closeBundle', () => {
137+
it('copies instrumentation file when it exists and output dir is set', async () => {
138+
const resolvedConfig = {
139+
root: '/project',
140+
plugins: [],
141+
environments: {
142+
nitro: {
143+
build: {
144+
rollupOptions: {
145+
output: {
146+
dir: '/project/.output/server',
147+
},
148+
},
149+
},
150+
},
151+
},
152+
} as unknown as ResolvedConfig;
153+
154+
(plugin.configResolved as AnyFunction)(resolvedConfig);
155+
156+
vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined);
157+
vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined);
158+
vi.mocked(fs.promises.copyFile).mockResolvedValueOnce(undefined);
159+
160+
await (plugin.closeBundle as AnyFunction)();
161+
162+
expect(fs.promises.access).toHaveBeenCalledWith(
163+
path.resolve(process.cwd(), 'instrument.server.mjs'),
164+
fs.constants.F_OK,
165+
);
166+
expect(fs.promises.mkdir).toHaveBeenCalledWith('/project/.output/server', { recursive: true });
167+
expect(fs.promises.copyFile).toHaveBeenCalledWith(
168+
path.resolve(process.cwd(), 'instrument.server.mjs'),
169+
path.resolve('/project/.output/server', 'instrument.server.mjs'),
170+
);
171+
});
172+
173+
it('does nothing when no server output dir is detected', async () => {
174+
const resolvedConfig = {
175+
root: '/project',
176+
plugins: [{ name: 'some-other-plugin' }],
177+
} as unknown as ResolvedConfig;
178+
179+
(plugin.configResolved as AnyFunction)(resolvedConfig);
180+
181+
await (plugin.closeBundle as AnyFunction)();
182+
183+
expect(fs.promises.access).not.toHaveBeenCalled();
184+
expect(fs.promises.copyFile).not.toHaveBeenCalled();
185+
});
186+
187+
it('does nothing when instrumentation file does not exist', async () => {
188+
const resolvedConfig = {
189+
root: '/project',
190+
plugins: [],
191+
environments: {
192+
nitro: {
193+
build: {
194+
rollupOptions: {
195+
output: {
196+
dir: '/project/.output/server',
197+
},
198+
},
199+
},
200+
},
201+
},
202+
} as unknown as ResolvedConfig;
203+
204+
(plugin.configResolved as AnyFunction)(resolvedConfig);
205+
206+
vi.mocked(fs.promises.access).mockRejectedValueOnce(new Error('ENOENT'));
207+
208+
await (plugin.closeBundle as AnyFunction)();
209+
210+
expect(fs.promises.access).toHaveBeenCalled();
211+
expect(fs.promises.copyFile).not.toHaveBeenCalled();
212+
});
213+
214+
it('logs a warning when copy fails', async () => {
215+
const resolvedConfig = {
216+
root: '/project',
217+
plugins: [],
218+
environments: {
219+
nitro: {
220+
build: {
221+
rollupOptions: {
222+
output: {
223+
dir: '/project/.output/server',
224+
},
225+
},
226+
},
227+
},
228+
},
229+
} as unknown as ResolvedConfig;
230+
231+
(plugin.configResolved as AnyFunction)(resolvedConfig);
232+
233+
vi.mocked(fs.promises.access).mockResolvedValueOnce(undefined);
234+
vi.mocked(fs.promises.mkdir).mockResolvedValueOnce(undefined);
235+
vi.mocked(fs.promises.copyFile).mockRejectedValueOnce(new Error('Permission denied'));
236+
237+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
238+
239+
await (plugin.closeBundle as AnyFunction)();
240+
241+
expect(warnSpy).toHaveBeenCalledWith(
242+
'[Sentry TanStack Start] Failed to copy instrument.server.mjs to build output.',
243+
expect.any(Error),
244+
);
245+
246+
warnSpy.mockRestore();
247+
});
248+
});
249+
});

0 commit comments

Comments
 (0)