Skip to content

Commit 9d7eca2

Browse files
JPeer264claude
andcommitted
fix(server-utils): Transform modules once when two Sentry Vite plugins add orchestrion
`sentryCloudflareVitePlugin` and a framework plugin such as `sentryReactRouter` both add the orchestrion Vite plugin to one build. Both instances then transform each instrumented module, the second one declares the injected snippet again, and the build fails with a redeclaration error. The plugin is now named `sentry-orchestrion`, and an instance that finds an earlier one in the same build leaves the transform to it. `react-router-8-framework` no longer needs `buildTimeInstrumentation: false` in its Cloudflare config. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 9d6d6f9 commit 9d7eca2

3 files changed

Lines changed: 50 additions & 7 deletions

File tree

‎dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ export default defineConfig(async config => ({
1010
reactRouter(),
1111
sentryCloudflareVitePlugin(),
1212
...((await sentryReactRouter(
13-
// Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build.
14-
{ sourcemaps: { disable: true }, buildTimeInstrumentation: false },
13+
{ sourcemaps: { disable: true } },
1514
config,
1615
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1716
)) as any[]),

‎packages/server-utils/src/orchestrion/bundler/vite.ts‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,19 @@ import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './re
99

1010
type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown;
1111

12+
const PLUGIN_NAME = 'sentry-orchestrion';
13+
1214
// On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of
1315
// client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without
1416
// this gate the transform would also run in the CLIENT build — where modules
1517
// like `@remix-run/server-runtime` sit in the client graph, and the injected
1618
// snippet's import of the subscriber factories (which import
1719
// `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate
1820
// on the `ssr` flag, which Vite passes on both major versions.
19-
function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] {
21+
function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] {
2022
const gate = (handler: TransformHandler): TransformHandler =>
2123
function (code, id, opts) {
22-
if (!opts?.ssr) {
24+
if (!opts?.ssr || isDuplicate()) {
2325
return null;
2426
}
2527
return handler.call(this, code, id, opts);
@@ -62,9 +64,13 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
6264
'@sentry/server-utils',
6365
];
6466

65-
return {
67+
// `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone.
68+
let isDuplicate = false;
69+
70+
const plugin: Plugin = {
6671
...upstream,
67-
transform: ssrOnlyTransform(upstream.transform),
72+
name: PLUGIN_NAME,
73+
transform: ssrOnlyTransform(upstream.transform, () => isDuplicate),
6874
// The module-injected snippet imports `@sentry/server-utils` from INSIDE
6975
// transformed `node_modules` files. Under isolated installs (pnpm) that bare
7076
// specifier doesn't resolve from an instrumented package's location, so when
@@ -119,8 +125,12 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
119125
return { resolve: { noExternal: noExternalModules() } };
120126
},
121127
configResolved(config: ResolvedConfig): void {
128+
// Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and
129+
// `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build.
130+
isDuplicate = config.plugins.find(p => p.name === PLUGIN_NAME) !== plugin;
131+
122132
// Nothing is force-bundled in `serve`, so an externalized module is expected there.
123-
if (config.command === 'serve') {
133+
if (isDuplicate || config.command === 'serve') {
124134
return;
125135
}
126136

@@ -140,4 +150,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
140150
}
141151
},
142152
};
153+
154+
return plugin;
143155
}

‎packages/server-utils/test/orchestrion/bundler.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ describe('sentryOrchestrionPlugin (vite)', () => {
227227
const plugin = vitePlugin();
228228
(plugin.configResolved as (config: unknown) => void)({
229229
command,
230+
plugins: [plugin],
230231
ssr: { external: ssrExternal },
231232
logger: { warn },
232233
} as unknown as ResolvedConfig);
@@ -324,6 +325,37 @@ describe('sentryOrchestrionPlugin (vite)', () => {
324325
expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed');
325326
});
326327

328+
it('transforms and warns only in the first instance when two Sentry plugins add it to one build', () => {
329+
const first = vitePlugin();
330+
const second = vitePlugin();
331+
const warn = vi.fn();
332+
const config = {
333+
command: 'build',
334+
plugins: [{ name: 'other' }, first, second],
335+
ssr: { external: ['mysql'] },
336+
logger: { warn },
337+
} as unknown as ResolvedConfig;
338+
339+
(first.configResolved as (config: unknown) => void)(config);
340+
(second.configResolved as (config: unknown) => void)(config);
341+
342+
const firstTransform = first.transform as (
343+
this: unknown,
344+
code: string,
345+
id: string,
346+
opts?: { ssr?: boolean },
347+
) => unknown;
348+
const secondTransform = second.transform as (
349+
this: unknown,
350+
code: string,
351+
id: string,
352+
opts?: { ssr?: boolean },
353+
) => unknown;
354+
expect(firstTransform.call({}, 'code', 'id', { ssr: true })).toBe('transformed');
355+
expect(secondTransform.call({}, 'code', 'id', { ssr: true })).toBeNull();
356+
expect(warn).toHaveBeenCalledTimes(1);
357+
});
358+
327359
it('gates resolveId on the ssr flag and falls back to self-resolution', async () => {
328360
const plugin = vitePlugin();
329361
const resolveId = plugin.resolveId as (

0 commit comments

Comments
 (0)