Skip to content

Commit 1099abf

Browse files
JPeer264claude
andcommitted
fix(server-utils): Stop the orchestrion Vite plugin from adding an empty ssr environment
In a build, Vite 6+ adds an `ssr` environment when the config has a top-level `ssr` key. The orchestrion plugin always returned `ssr.noExternal` from its `config` hook, so Vite created an `ssr` environment without an entry. A `buildApp` that builds every environment (for example `vite build --app`) then failed with "input should not be an html file when building for SSR". On Vite 6+ the plugin now sets `resolve.noExternal` per server environment in `configEnvironment`. The top-level `ssr` key is only added when the config already has `ssr` or `build.ssr`, which keeps Vite 5 SSR builds (Remix v2, SvelteKit) force-bundling the instrumented modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 77e36ac commit 1099abf

10 files changed

Lines changed: 169 additions & 28 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>orchestrion build all environments</title>
5+
</head>
6+
<body></body>
7+
</html>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { streamText } from 'ai';
2+
3+
export default {
4+
async fetch(request: Request): Promise<Response> {
5+
if (new URL(request.url).pathname === '/worker') {
6+
return new Response(`streamText: ${typeof streamText}`);
7+
}
8+
return new Response('not found', { status: 404 });
9+
},
10+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { SerializedStreamedSpanContainer } from '@sentry/core';
2+
import { SENTRY_OP, SENTRY_ORIGIN, URL_PATH } from '@sentry/conventions/attributes';
3+
import { readdirSync, readFileSync } from 'fs';
4+
import { join } from 'path';
5+
import { expect, it } from 'vitest';
6+
import { createRunner } from '../../../../runner';
7+
8+
// Regression test: a top-level `ssr` option from the orchestrion plugin made Vite create an extra
9+
// `ssr` environment without an entry. A `buildApp` that builds every environment then failed with
10+
// "input should not be an html file when building for SSR".
11+
it('builds when buildApp builds every Vite environment', async ({ signal }) => {
12+
const runner = createRunner(__dirname)
13+
.unordered()
14+
.expect(envelope => {
15+
const spanItem = envelope[1].find(item => item[0].type === 'span');
16+
const container = spanItem?.[1] as SerializedStreamedSpanContainer;
17+
const serverSpan = container.items.find(item => item.is_segment);
18+
19+
expect(serverSpan?.attributes[SENTRY_OP]?.value).toBe('http.server');
20+
expect(serverSpan?.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare');
21+
expect(serverSpan?.attributes[URL_PATH]?.value).toBe('/worker');
22+
})
23+
.start(signal);
24+
25+
const response = await runner.makeRequest<string>('get', '/worker');
26+
expect(response).toBe('streamText: function');
27+
await runner.completed();
28+
29+
expect(readdirSync(join(__dirname, 'dist')).sort()).toEqual(['client', 'cloudflare_vite_dc_build_all_environments']);
30+
31+
const workerDir = join(__dirname, 'dist', 'cloudflare_vite_dc_build_all_environments');
32+
const workerBundle = readdirSync(workerDir)
33+
.filter(name => /\.m?js$/.test(name))
34+
.map(name => readFileSync(join(workerDir, name), 'utf8'))
35+
.join('\n');
36+
expect(workerBundle).toContain('orchestrion:ai:streamText');
37+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
plugins: [cloudflare(), sentryCloudflareVitePlugin()],
7+
// Builds every Vite environment, as `vite build --app` does.
8+
builder: {
9+
async buildApp(builder) {
10+
for (const environment of Object.values(builder.environments)) {
11+
await builder.build(environment);
12+
}
13+
},
14+
},
15+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-dc-build-all-environments",
4+
"main": "index.ts",
5+
"compatibility_date": "2026-04-26",
6+
"compatibility_flags": ["nodejs_compat"],
7+
"assets": {
8+
"directory": "./dist/client",
9+
},
10+
}

‎packages/remix/src/vite/orchestrionPlugin.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export function makeOrchestrionPlugin(options: Pick<SentryRemixVitePluginOptions
5757
const config = hookHandler(orchestrion.config as ObjectHook<ConfigHook> | undefined);
5858
const configResolved = hookHandler(orchestrion.configResolved);
5959

60+
let isWorkerConfig = false;
6061
let isWorkerBuild = false;
6162

6263
return {
@@ -66,9 +67,13 @@ export function makeOrchestrionPlugin(options: Pick<SentryRemixVitePluginOptions
6667
config: {
6768
order: 'post',
6869
handler(userConfig: UserConfig, env: ConfigEnv) {
69-
return isWorkerTarget(userConfig) ? null : (config?.(userConfig, env) ?? null);
70+
isWorkerConfig = isWorkerTarget(userConfig);
71+
return isWorkerConfig ? null : (config?.(userConfig, env) ?? null);
7072
},
7173
},
74+
// Gated on the worker check from the `config` hook, because Vite calls `configEnvironment`
75+
// before `configResolved`.
76+
configEnvironment: gateHook(orchestrion.configEnvironment as ObjectHook<AnyHook> | undefined, () => isWorkerConfig),
7277
// The authoritative check: the resolved config reflects every plugin regardless of ordering,
7378
// and this always runs before the first `transform`.
7479
configResolved(resolvedConfig: ResolvedConfig) {

‎packages/remix/test/vite/index.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { sentryRemixVitePlugin } from '../../src/vite';
77
const orchestrionConfig = vi.fn((_config: UserConfig, env: ConfigEnv) =>
88
env.command === 'serve' ? null : { ssr: { noExternal: ['mysql'] } },
99
);
10+
const orchestrionConfigEnvironment = vi.fn(() => ({ resolve: { noExternal: ['mysql'] } }));
1011
const orchestrionConfigResolved = vi.fn();
1112
const orchestrionTransform = vi.fn(() => ({ code: 'transformed' }));
1213

@@ -17,6 +18,7 @@ const orchestrionVite = vi.fn((options?: { buildTimeInstrumentation?: boolean })
1718
name: 'code-transformer',
1819
enforce: 'pre',
1920
config: orchestrionConfig,
21+
configEnvironment: orchestrionConfigEnvironment,
2022
configResolved: orchestrionConfigResolved,
2123
transform: orchestrionTransform,
2224
},
@@ -86,6 +88,9 @@ describe('sentryRemixVitePlugin', () => {
8688
const orchestrion = sentryRemixVitePlugin()[1]!;
8789

8890
expect(callHook(orchestrion.config, NODE_CONFIG, BUILD_ENV)).toEqual({ ssr: { noExternal: ['mysql'] } });
91+
expect(callHook(orchestrion.configEnvironment, 'ssr', {}, BUILD_ENV)).toEqual({
92+
resolve: { noExternal: ['mysql'] },
93+
});
8994

9095
callHook(orchestrion.configResolved, NODE_CONFIG);
9196
expect(orchestrionConfigResolved).toHaveBeenCalledTimes(1);
@@ -99,6 +104,9 @@ describe('sentryRemixVitePlugin', () => {
99104
expect(callHook(orchestrion.config, config, BUILD_ENV)).toBeNull();
100105
expect(orchestrionConfig).not.toHaveBeenCalled();
101106

107+
expect(callHook(orchestrion.configEnvironment, 'ssr', {}, BUILD_ENV)).toBeNull();
108+
expect(orchestrionConfigEnvironment).not.toHaveBeenCalled();
109+
102110
callHook(orchestrion.configResolved, config);
103111
expect(orchestrionConfigResolved).not.toHaveBeenCalled();
104112

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

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite';
2-
import type { ConfigEnv, Plugin, ResolvedConfig } from 'vite';
2+
import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
33

44
export type { Plugin as VitePlugin } from 'vite';
55
import { instrumentedModuleNames } from '../config';
@@ -57,6 +57,10 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
5757
}
5858

5959
const upstream = codeTransformer(orchestrionTransformOptions(options));
60+
const noExternalModules = (): string[] => [
61+
...instrumentedModuleNames(options.instrumentations),
62+
'@sentry/server-utils',
63+
];
6064

6165
return {
6266
...upstream,
@@ -83,32 +87,36 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
8387
// calls never land in a browser (`client`) bundle (where they'd throw `X is not a function`).
8488
return environment.config.consumer === 'server';
8589
},
86-
config(_config: unknown, env?: ConfigEnv): { ssr: { noExternal: string[] } } | null {
87-
// Vite's dev SSR runner has no CommonJS interop, so an inlined `ioredis`/`mysql` throws
88-
// `exports is not defined` on first import. Left external they stay on Node's loader, where
89-
// the runtime hook `Sentry.init()` registers injects the same publishers.
90-
if (env?.command === 'serve') {
90+
// Vite externalizes dependencies in SSR builds, so the transform only sees an instrumented
91+
// package when it is bundled. `@sentry/server-utils` is bundled too, because the injected
92+
// snippet `require()`s it, and Vite 5's CJS interop turns that into a default import of our
93+
// ESM entry, which crashes at startup.
94+
// Not in `serve`: Vite's dev SSR runner has no CJS interop, so inlined `mysql`/`ioredis` throw
95+
// `exports is not defined`, and the runtime hook injects the same publishers instead.
96+
config: {
97+
// Runs after the framework plugins, so `build.ssr` set by their `config` hooks is visible.
98+
order: 'post',
99+
handler(config: UserConfig, env?: ConfigEnv): { ssr: { noExternal: string[] } } | null {
100+
// A top-level `ssr` key makes Vite 6+ add an `ssr` environment with no entry, which
101+
// `vite build --app` cannot build. Vite 5 has no `configEnvironment` and needs the key, and
102+
// its SSR builds always set `build.ssr`.
103+
if (env?.command === 'serve' || !(config.ssr || config.build?.ssr)) {
104+
return null;
105+
}
106+
107+
return { ssr: { noExternal: noExternalModules() } };
108+
},
109+
},
110+
configEnvironment(
111+
name: string,
112+
config: { consumer?: 'client' | 'server' },
113+
env?: ConfigEnv,
114+
): { resolve: { noExternal: string[] } } | null {
115+
if (env?.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') {
91116
return null;
92117
}
93118

94-
// Force-bundle every instrumented package so the code transform actually
95-
// sees its source. Vite externalizes dependencies in SSR builds by
96-
// default, leaving them as bare `require()`/`import` calls resolved from
97-
// `node_modules` at runtime — those copies are untouched and the
98-
// diagnostics_channel calls never get injected. Vite merges array
99-
// `noExternal` entries with the user's config, so we don't overwrite
100-
// their additions.
101-
//
102-
// `@sentry/server-utils` must be bundled too: the module-injected snippet
103-
// `require()`s it from inside transformed CJS deps, and when the package
104-
// stays external, Vite 5's CommonJS interop (`esmExternals: false`)
105-
// rewrites that require into a DEFAULT import of our named-exports-only
106-
// ESM entry — a link-time crash at server startup. Bundling sidesteps
107-
// external ESM/CJS interop on both Vite majors, and the ESM barrel
108-
// tree-shakes to just the helper and the factories actually referenced.
109-
return {
110-
ssr: { noExternal: [...instrumentedModuleNames(options.instrumentations), '@sentry/server-utils'] },
111-
};
119+
return { resolve: { noExternal: noExternalModules() } };
112120
},
113121
configResolved(config: ResolvedConfig): void {
114122
// Nothing is force-bundled in `serve`, so an externalized module is expected there.

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -233,10 +233,29 @@ describe('sentryOrchestrionPlugin (vite)', () => {
233233
return warn;
234234
}
235235

236-
function runConfig(command: 'build' | 'serve'): { ssr: { noExternal: string[] } } | null {
236+
function runConfig(
237+
command: 'build' | 'serve',
238+
userConfig: Record<string, unknown> = { build: { ssr: true } },
239+
): { ssr: { noExternal: string[] } } | null {
237240
const plugin = vitePlugin();
238-
const config = plugin.config as (config: unknown, env: unknown) => { ssr: { noExternal: string[] } } | null;
239-
return config.call(plugin, {}, { command, mode: 'production' });
241+
const config = plugin.config as {
242+
handler: (config: unknown, env: unknown) => { ssr: { noExternal: string[] } } | null;
243+
};
244+
return config.handler.call(plugin, userConfig, { command, mode: 'production' });
245+
}
246+
247+
function runConfigEnvironment(
248+
name: string,
249+
environmentConfig: Record<string, unknown>,
250+
command: 'build' | 'serve' = 'build',
251+
): { resolve: { noExternal: string[] } } | null {
252+
const plugin = vitePlugin();
253+
const configEnvironment = plugin.configEnvironment as (
254+
name: string,
255+
config: unknown,
256+
env: unknown,
257+
) => { resolve: { noExternal: string[] } } | null;
258+
return configEnvironment.call(plugin, name, environmentConfig, { command, mode: 'production' });
240259
}
241260

242261
it('warns when instrumented modules are listed in ssr.external', () => {
@@ -266,6 +285,22 @@ describe('sentryOrchestrionPlugin (vite)', () => {
266285
it('does not force-bundle instrumented modules on the dev server', () => {
267286
// Inlined in dev, the CommonJS drivers throw `exports is not defined` on import.
268287
expect(runConfig('serve')).toBeNull();
288+
expect(runConfigEnvironment('ssr', {}, 'serve')).toBeNull();
289+
});
290+
291+
it('adds the top-level ssr option only when the config already has ssr or build.ssr', () => {
292+
expect(runConfig('build', {})).toBeNull();
293+
expect(runConfig('build', { ssr: { target: 'node' } })?.ssr.noExternal).toContain('mysql');
294+
expect(runConfig('build', { build: { ssr: 'src/server.ts' } })?.ssr.noExternal).toContain('mysql');
295+
});
296+
297+
it('force-bundles instrumented modules in server environments', () => {
298+
expect(runConfigEnvironment('ssr', {})?.resolve.noExternal).toEqual(
299+
expect.arrayContaining(['mysql', '@sentry/server-utils']),
300+
);
301+
expect(runConfigEnvironment('worker', { consumer: 'server' })?.resolve.noExternal).toContain('mysql');
302+
expect(runConfigEnvironment('client', {})).toBeNull();
303+
expect(runConfigEnvironment('browser', { consumer: 'client' })).toBeNull();
269304
});
270305

271306
it('does not warn about externalized instrumented modules on the dev server', () => {

0 commit comments

Comments
 (0)