Skip to content

Commit 5fbc75c

Browse files
authored
fix(server-utils): Bundle the external requires of instrumented CommonJS on Rolldown
Resolve each require() call from a package that orchestrion bundled instead of walking package.json files. Only calls that Vite would make external are bundled, and the user's ssr.external entries and optional dependencies stay external.
1 parent 617d0ef commit 5fbc75c

7 files changed

Lines changed: 340 additions & 249 deletions

File tree

‎dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/server-build.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import path from 'node:path';
33
import { expect, test } from '@playwright/test';
44
import { bundleReferencesModule } from '@sentry-internal/test-utils';
55

6-
// Vite 8 emits a `require()` of an external package in bundled CommonJS as `__require()`, and Nitro's
7-
// second bundling pass cannot follow that call. In a flat install (the hoisted variant), Vite keeps
8-
// the dependencies of the instrumented drivers external unless the SDK bundles them, and the server
9-
// then fails with `Cannot find module` outside the project.
6+
// Nitro can't follow the `__require()` calls that Vite 8 emits for external requires in bundled
7+
// CommonJS, so the deployed server fails with `Cannot find module`. Only flat installs (the hoisted
8+
// variant) keep the drivers' dependencies external.
9+
// https://vite.dev/guide/migration#require-calls-for-externalized-modules
1010
test('bundles the CommonJS dependencies of the instrumented drivers', () => {
1111
const root = path.join(process.cwd(), '.output/server');
1212
const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' })

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

Lines changed: 0 additions & 89 deletions
This file was deleted.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { isPlainObject } from '@sentry/core';
2+
import { readFileSync } from 'node:fs';
3+
import { createRequire, isBuiltin } from 'node:module';
4+
import { isAbsolute } from 'node:path';
5+
import type { Plugin } from 'vite';
6+
7+
type ResolveIdHandler = Extract<NonNullable<Plugin['resolveId']>, (...args: never[]) => unknown>;
8+
type ResolveIdParameters = Parameters<ResolveIdHandler>;
9+
10+
const NODE_MODULES = '/node_modules/';
11+
12+
/**
13+
* Creates a `resolveId` handler that bundles the external packages that bundled CommonJS `require()`s.
14+
*
15+
* Rolldown keeps these calls as `__require()`, which a second bundler over the server output can't follow:
16+
* - https://vite.dev/guide/migration#require-calls-for-externalized-modules
17+
* - https://rolldown.rs/in-depth/bundling-cjs#require-external-modules
18+
*
19+
* Only handles requires from packages bundled for orchestrion, and from packages bundled because of them.
20+
*/
21+
export function createExternalRequireResolver(
22+
moduleNames: readonly string[],
23+
): (this: ThisParameterType<ResolveIdHandler>, ...args: ResolveIdParameters) => Promise<string | null> {
24+
const bundledPackagesByEnvironment = new Map<string, Set<string>>();
25+
26+
return async function resolveExternalRequire(source, importer, options) {
27+
if (
28+
!this.meta.rolldownVersion ||
29+
this.environment.mode !== 'build' ||
30+
options.kind !== 'require-call' ||
31+
!importer ||
32+
!isPackageSpecifier(source)
33+
) {
34+
return null;
35+
}
36+
37+
const bundledPackages = bundledPackagesByEnvironment.get(this.environment.name) ?? new Set(moduleNames);
38+
bundledPackagesByEnvironment.set(this.environment.name, bundledPackages);
39+
40+
const owner = ownerPackage(importer);
41+
const name = packageName(source);
42+
if (
43+
!owner ||
44+
!bundledPackages.has(owner.name) ||
45+
isUserExternal(this.environment.config.resolve.external, source, name) ||
46+
isOptionalDependency(owner.dir, name)
47+
) {
48+
return null;
49+
}
50+
51+
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
52+
if (!resolved) {
53+
return null;
54+
}
55+
// Vite bundles it anyway (e.g. under pnpm), so its own requires count too.
56+
if (!resolved.external) {
57+
bundledPackages.add(name);
58+
return null;
59+
}
60+
61+
const file = resolveWithNode(source, importer);
62+
if (file) {
63+
bundledPackages.add(name);
64+
}
65+
return file ?? null;
66+
};
67+
}
68+
69+
function isPackageSpecifier(source: string): boolean {
70+
return !isBuiltin(source) && !isAbsolute(source) && !/^[.\0]/.test(source);
71+
}
72+
73+
// `pg-types/lib/x` → `pg-types`, `@scope/pkg/x` → `@scope/pkg`.
74+
function packageName(specifier: string): string {
75+
const [first = '', second] = specifier.split('/');
76+
return first.startsWith('@') && second ? `${first}/${second}` : first;
77+
}
78+
79+
// Uses the last `node_modules` segment, so nested and pnpm store paths work.
80+
function ownerPackage(file: string): { name: string; dir: string } | undefined {
81+
const path = file.replace(/\\/g, '/');
82+
const start = path.lastIndexOf(NODE_MODULES);
83+
if (start === -1) {
84+
return undefined;
85+
}
86+
const name = packageName(path.slice(start + NODE_MODULES.length));
87+
return { name, dir: `${path.slice(0, start)}${NODE_MODULES}${name}` };
88+
}
89+
90+
// Matches like Vite. `external: true` is Vite's SSR default, not a choice for this package.
91+
function isUserExternal(external: readonly string[] | true, source: string, name: string): boolean {
92+
return Array.isArray(external) && external.some(entry => entry === source || entry === name);
93+
}
94+
95+
// These can be missing or native, like `pg-native`.
96+
function isOptionalDependency(packageDir: string, name: string): boolean {
97+
const manifest = readManifest(`${packageDir}/package.json`);
98+
if (!manifest) {
99+
return false;
100+
}
101+
const { peerDependenciesMeta, optionalDependencies } = manifest;
102+
const peerMeta = isPlainObject(peerDependenciesMeta) ? peerDependenciesMeta[name] : undefined;
103+
return (
104+
(isPlainObject(peerMeta) && peerMeta.optional === true) ||
105+
(isPlainObject(optionalDependencies) && name in optionalDependencies)
106+
);
107+
}
108+
109+
// The file that `require()` loads at runtime.
110+
function resolveWithNode(source: string, importer: string): string | undefined {
111+
try {
112+
return createRequire(importer).resolve(source);
113+
} catch {
114+
return undefined;
115+
}
116+
}
117+
118+
function readManifest(file: string): Record<string, unknown> | undefined {
119+
try {
120+
const manifest: unknown = JSON.parse(readFileSync(file, 'utf8'));
121+
return isPlainObject(manifest) ? manifest : undefined;
122+
} catch {
123+
return undefined;
124+
}
125+
}

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

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite';
2-
import { resolve } from 'node:path';
3-
import type { ConfigEnv, ConfigPluginContext, Plugin, ResolvedConfig, UserConfig } from 'vite';
2+
import type { ConfigEnv, Plugin, ResolvedConfig, UserConfig } from 'vite';
43

54
export type { Plugin as VitePlugin } from 'vite';
65
import { instrumentedModuleNames } from '../config';
7-
import { commonJSDependencyNames } from './commonJSDependencies';
86
import type { PluginOptions } from './options';
97
import { externalEntryMatchesModule, externalizedModulesWarning, orchestrionTransformOptions } from './options';
8+
import { createExternalRequireResolver } from './externalRequire';
109
import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './resolve';
1110

1211
type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown;
@@ -71,12 +70,11 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
7170
}
7271

7372
const upstream = codeTransformer(orchestrionTransformOptions(options));
74-
let commonJSDependencies: string[] = [];
7573
const noExternalModules = (): string[] => [
7674
...instrumentedModuleNames(options.instrumentations),
7775
'@sentry/server-utils',
78-
...commonJSDependencies,
7976
];
77+
const resolveExternalRequire = createExternalRequireResolver(noExternalModules());
8078

8179
const instance = {};
8280
// `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone.
@@ -98,7 +96,10 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
9896
// packages into default imports, and `@sentry/core` has no default export, so the server
9997
// crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`.
10098
async resolveId(source, importer, resolveOptions) {
101-
if (source !== SNIPPET_IMPORT_SPECIFIER || !resolveOptions?.ssr) {
99+
if (source !== SNIPPET_IMPORT_SPECIFIER) {
100+
return resolveExternalRequire.call(this, source, importer, resolveOptions);
101+
}
102+
if (!resolveOptions?.ssr) {
102103
return null;
103104
}
104105
const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true });
@@ -120,33 +121,14 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin {
120121
// ESM entry, which crashes at startup.
121122
// Not in `serve`: Vite's dev SSR runner has no CJS interop, so inlined `mysql`/`ioredis` throw
122123
// `exports is not defined`, and the runtime hook injects the same publishers instead.
123-
// Rolldown (Vite 8) emits a `require()` of an external package in bundled CommonJS as
124-
// `__require()`, which a bundler that runs over the server output cannot follow. Rollup turns it
125-
// into an `import`. So on Rolldown, the dependencies that this CommonJS requires are bundled too.
126124
config: {
127125
// Runs after the framework plugins, so `build.ssr` set by their `config` hooks is visible.
128126
order: 'post',
129-
handler(
130-
this: ConfigPluginContext | undefined,
131-
config: UserConfig,
132-
env?: ConfigEnv,
133-
): { ssr: { noExternal: string[] } } | null {
134-
if (env?.command === 'serve') {
135-
return null;
136-
}
137-
138-
// Vite 5 calls `config` hooks without a `this` context.
139-
if (this?.meta.rolldownVersion) {
140-
commonJSDependencies = commonJSDependencyNames(
141-
resolve(config.root ?? process.cwd()),
142-
instrumentedModuleNames(options.instrumentations),
143-
);
144-
}
145-
127+
handler(config: UserConfig, env?: ConfigEnv): { ssr: { noExternal: string[] } } | null {
146128
// A top-level `ssr` key makes Vite 6+ add an `ssr` environment with no entry, which
147129
// `vite build --app` cannot build. Vite 5 has no `configEnvironment` and needs the key, and
148130
// its SSR builds always set `build.ssr`.
149-
if (!(config.ssr || config.build?.ssr)) {
131+
if (env?.command === 'serve' || !(config.ssr || config.build?.ssr)) {
150132
return null;
151133
}
152134

0 commit comments

Comments
 (0)