diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 4cf3a981b6be..ad8fa7914f8d 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -11,6 +11,7 @@ "@react-router/serve": "^8.0.0", "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "ioredis": "^5.11.1", diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts new file mode 100644 index 000000000000..27dc4f31a025 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/tests/server-build.test.ts @@ -0,0 +1,24 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; +import { bundleReferencesModule } from '@sentry-internal/test-utils'; +import { RUNTIME } from './constants'; + +// `@sentry/core` is a direct dependency of this app so that Vite resolves it from the app root and +// keeps it external, as in an npm install. Bundled into the server build, it hides the case where +// bundled CommonJS `require()`s `@sentry/core`: Vite 5-7 turn that into a default import, and +// `@sentry/core` has no default export, so the server crashes at startup. +test.describe('server build', () => { + test.skip(RUNTIME === 'cloudflare', 'The Cloudflare build bundles all dependencies.'); + + test('imports the external @sentry/core by name only', () => { + const root = path.join(process.cwd(), 'build/server'); + const serverBundle = readdirSync(root, { recursive: true, encoding: 'utf8' }) + .filter(file => file.endsWith('.js')) + .map(file => readFileSync(path.join(root, file), 'utf8')) + .join('\n'); + + expect(bundleReferencesModule(serverBundle, '@sentry/core'), 'Vite must keep @sentry/core external').toBe(true); + expect(serverBundle.match(/\bimport\s+[\w$]+\s*(?:,\s*\{[^}]*\})?\s*from\s*["']@sentry\/[^"']+["']/g)).toBeNull(); + }); +}); diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b8f66d961f4..c25552ac21cb 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -34,6 +34,11 @@ function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { return transform; } +// Maps the CJS entry of `@sentry/server-utils` to its ESM entry; any other id is returned unchanged. +function toEsmEntry(id: string): string { + return id.replace(/([/\\])build[/\\]cjs[/\\]index\.js$/, '$1build$1esm$1index.js'); +} + /** * Vite plugin that runs the orchestrion code transform on the bundled output. * @@ -71,15 +76,21 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { // normal resolution fails, fall back to this package's own resolution so it // gets bundled from its real on-disk path. SSR-gated like the transform: the // specifier only appears in SSR modules. + // + // Always bundle the ESM entry. In a CommonJS module the snippet `require()`s the specifier, + // which resolves to the CJS build. Vite 5-7 turn that build's `require()`s of external + // packages into default imports, and `@sentry/core` has no default export, so the server + // crashes at startup. Vite 8 keeps the `require()`s, which loads a second copy of `@sentry/core`. async resolveId(source, importer, resolveOptions) { if (source !== SNIPPET_IMPORT_SPECIFIER || !resolveOptions?.ssr) { return null; } const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true }); if (resolved) { - return resolved; + return resolved.external ? resolved : { ...resolved, id: toEsmEntry(resolved.id) }; } - return resolveOrchestrionRuntimeRequest(source) ?? null; + const fallback = resolveOrchestrionRuntimeRequest(source); + return fallback ? toEsmEntry(fallback) : null; }, applyToEnvironment(environment) { // Orchestrion splices `node:diagnostics_channel` calls into instrumented modules, which only diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b04fa7d1f160..07dc87d1c09b 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -348,6 +348,50 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(typeof fallback).toBe('string'); expect(fallback).toContain('server-utils'); }); + + it('resolves the snippet import to the ESM entry, also for require() calls', async () => { + const plugin = vitePlugin(); + const resolveId = plugin.resolveId as ( + this: unknown, + source: string, + importer: string | undefined, + opts?: { ssr?: boolean }, + ) => Promise; + + const resolve = vi.fn().mockResolvedValue(null); + // How Vite 5-7 resolve a `require()` in a CommonJS module. + const requireOptions = { ssr: true, custom: { 'node-resolve': { isRequire: true } } }; + + resolve.mockResolvedValueOnce({ + id: '/app/node_modules/@sentry/server-utils/build/cjs/index.js', + moduleSideEffects: false, + }); + await expect( + resolveId.call({ resolve }, '@sentry/server-utils', '/app/node_modules/mysql/index.js', requireOptions), + ).resolves.toEqual({ + id: '/app/node_modules/@sentry/server-utils/build/esm/index.js', + moduleSideEffects: false, + }); + expect(resolve).toHaveBeenCalledWith('@sentry/server-utils', '/app/node_modules/mysql/index.js', { + ...requireOptions, + skipSelf: true, + }); + + resolve.mockResolvedValueOnce({ id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\cjs\\index.js' }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: 'C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\index.js', + }); + + resolve.mockResolvedValueOnce({ id: '@sentry/server-utils', external: true }); + await expect(resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions)).resolves.toEqual({ + id: '@sentry/server-utils', + external: true, + }); + + const fallback = await resolveId.call({ resolve }, '@sentry/server-utils', '/x.js', requireOptions); + expect(fallback).toMatch(/[/\\]build[/\\]esm[/\\]index\.js$/); + expect(existsSync(fallback as string)).toBe(true); + }); }); describe('sentryOrchestrionPlugin (bun)', () => {