diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 74233c87c923..773871383c69 100644 --- a/packages/nextjs/src/config/diagnosticsChannelInjection.ts +++ b/packages/nextjs/src/config/diagnosticsChannelInjection.ts @@ -1,4 +1,4 @@ -import { resolveOrchestrionRuntimeRequest } from '@sentry/server-utils/orchestrion/webpack'; +import { loadOrchestrionBundler } from './loadOrchestrionBundler'; /** * Instrumented packages verified (via e2e) to bundle correctly, removed from Sentry's own @@ -58,6 +58,6 @@ export async function externalizeOrchestrionRuntimePackages({ return undefined; } - const resolved = resolveOrchestrionRuntimeRequest(request); + const resolved = loadOrchestrionBundler().resolveOrchestrionRuntimeRequest(request); return resolved ? `commonjs ${resolved}` : undefined; } diff --git a/packages/nextjs/src/config/loadOrchestrionBundler.ts b/packages/nextjs/src/config/loadOrchestrionBundler.ts new file mode 100644 index 000000000000..331edf13c3ba --- /dev/null +++ b/packages/nextjs/src/config/loadOrchestrionBundler.ts @@ -0,0 +1,29 @@ +import { createRequire } from 'module'; +import type * as orchestrionBundler from '@sentry/server-utils/orchestrion/webpack'; + +type OrchestrionBundlerModule = typeof orchestrionBundler; + +// Use `createRequire` (never the CJS `require` alias) so bundlers don't emit a "Critical +// dependency" warning. Resolving from this file's own location keeps it working under pnpm +// isolated installations. +function getNodeRequire(): ReturnType { + let nodeRequire: ReturnType; + /*! rollup-include-cjs-only */ + nodeRequire = createRequire(__filename); + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + nodeRequire = createRequire(import.meta.url); + /*! rollup-include-esm-only-end */ + return nodeRequire; +} + +/** + * Loads `@sentry/server-utils/orchestrion/webpack` at call time instead of module scope. The + * runtime server entry re-exports `withSentryConfig`, so a static import would run the bundler + * plugins' module-scope side effects on every server-side SDK import (issues #23789, #22794). + * Synchronous because Next.js `webpack` config functions cannot be async. Node's require cache + * already returns the same module on repeated calls, so no memoization is needed. + */ +export function loadOrchestrionBundler(): OrchestrionBundlerModule { + return getNodeRequire()('@sentry/server-utils/orchestrion/webpack') as OrchestrionBundlerModule; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9e9aad687f45..1e18aa57bb31 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,10 +1,6 @@ import { debug } from '@sentry/core'; import * as path from 'path'; -import { - getOrchestrionLoaderPath, - getSentryInstrumentations, - serializeInstrumentations, -} from '@sentry/server-utils/orchestrion/webpack'; +import { loadOrchestrionBundler } from '../loadOrchestrionBundler'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { @@ -138,6 +134,8 @@ function maybeAddOrchestrionRule( return rules; } + const { getOrchestrionLoaderPath, getSentryInstrumentations, serializeInstrumentations } = loadOrchestrionBundler(); + return safelyAddTurbopackRule(rules, { matcher: '*.{js,mjs,cjs}', rule: { diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 89e411b1bf40..b13dfac5c11d 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -23,7 +23,7 @@ import type { WebpackEntryProperty, WebpackPluginInstance, } from './types'; -import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion/webpack'; +import { loadOrchestrionBundler } from './loadOrchestrionBundler'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; @@ -434,7 +434,9 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { - newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); + newConfig.plugins.push( + loadOrchestrionBundler().sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance, + ); prependOrchestrionRuntimeExternals(newConfig); } diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index d046d1a23bfe..00149b640bb3 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -3,6 +3,7 @@ import '../mocks'; import * as core from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; import * as getBuildPluginOptionsModule from '../../../src/config/getBuildPluginOptions'; +import type * as loadOrchestrionBundlerModule from '../../../src/config/loadOrchestrionBundler'; import * as util from '../../../src/config/util'; import { CLIENT_SDK_CONFIG_FILE, @@ -16,12 +17,18 @@ import { } from '../fixtures'; import { materializeFinalNextConfig, materializeFinalWebpackConfig } from '../testUtils'; -// Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real because -// the externals handler under test uses it. -vi.mock('@sentry/server-utils/orchestrion/webpack', async importOriginal => ({ - ...(await importOriginal>()), - sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), -})); +// Stub only the plugin factory. The externals handler under test needs the real +// `resolveOrchestrionRuntimeRequest`. The bundler module loads via native `require`, which +// `vi.mock` cannot intercept, so the stub goes on the loader. +vi.mock('../../../src/config/loadOrchestrionBundler', async importOriginal => { + const original = await importOriginal(); + return { + loadOrchestrionBundler: () => ({ + ...original.loadOrchestrionBundler(), + sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), + }), + }; +}); describe('constructWebpackConfigFunction()', () => { it('includes expected properties', async () => { diff --git a/packages/nextjs/test/serverEntryBundlerGraph.test.ts b/packages/nextjs/test/serverEntryBundlerGraph.test.ts new file mode 100644 index 000000000000..17b5447a66fc --- /dev/null +++ b/packages/nextjs/test/serverEntryBundlerGraph.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Importing the SDK server entry must not load the orchestrion bundler plugins. They are + * build-time-only, and their module-scope side effects break runtimes the build never sees, + * like jsdom/happy-dom test runs (issue #23789) and Cloudflare Workers cold starts (issue #22794). + * Runs in a child process for a clean module cache and real Node resolution. + */ +describe('built CJS server entry', () => { + const serverEntry = resolve(__dirname, '../build/cjs/index.server.js'); + + it('loads under a DOM test environment without pulling in the orchestrion bundler graph', () => { + const script = ` + globalThis.document = { baseURI: 'http://localhost:3000/' }; + require(${JSON.stringify(serverEntry)}); + const toPosix = modulePath => modulePath.split(require('path').sep).join('/'); + const bundlerModules = Object.keys(require.cache).map(toPosix).filter( + modulePath => modulePath.includes('code-transformer-bundler-plugins') || modulePath.includes('orchestrion/bundler'), + ); + if (bundlerModules.length > 0) { + console.error('Bundler-plugin modules loaded at import time:\\n' + bundlerModules.join('\\n')); + process.exit(1); + } + `; + + // On failure, stderr carries either the leaked module list or the import crash itself. + const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8' }); + expect(result.status, result.stderr).toBe(0); + }); +}); diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 1a7bf99b5d12..eb86382dd476 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -53,6 +53,19 @@ const debugNodeAlias = { }, }; +// This package only runs in Node, but rollup's default CJS replacement for `import.meta.url` +// picks browser behavior whenever a `document` global exists, and jsdom/happy-dom define +// `document` while tests run in Node. Always emit the unconditional Node form instead. +const importMetaUrlNodeShim = { + name: 'import-meta-url-node-shim', + resolveImportMeta(property, { format }) { + if (property === 'url' && format === 'cjs') { + return "require('node:url').pathToFileURL(__filename).href"; + } + return null; + }, +}; + // Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the // repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that // prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs @@ -124,7 +137,7 @@ export default [ 'src/orchestrion/bundler/esbuild.ts', ], packageSpecificConfig: { - plugins: [debugNodeAlias, commonJSPlugin, thirdPartyLicensePlugin], + plugins: [debugNodeAlias, commonJSPlugin, importMetaUrlNodeShim, thirdPartyLicensePlugin], output: { // set exports to 'named' or 'auto' so that rollup doesn't warn exports: 'named', diff --git a/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts b/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts new file mode 100644 index 000000000000..04258099b948 --- /dev/null +++ b/packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts @@ -0,0 +1,38 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const nodeRequire = createRequire(import.meta.url); +const BUILD_CJS_DIR = resolve(__dirname, '../../build/cjs'); + +// The five entries share vendored chunks, and the require cache would keep a chunk's module scope +// from running again after the first test. Drop everything under `build/cjs` first, so each test +// really executes the code it claims to. +function requireFresh(entry: string): unknown { + for (const key of Object.keys(nodeRequire.cache)) { + if (key.startsWith(BUILD_CJS_DIR)) { + Reflect.deleteProperty(nodeRequire.cache, key); + } + } + return nodeRequire(resolve(BUILD_CJS_DIR, 'orchestrion/bundler', `${entry}.js`)); +} + +/** + * The bundler entries must load in Node even when a `document` global exists, which is the case + * under jsdom/happy-dom: the vendored code must never treat `document` as proof of a browser. + * Runs against `build/cjs` because that guard lives in the emitted code, not the sources. + * Reference Issue: https://github.com/getsentry/sentry-javascript/issues/23789 + */ +describe('built CJS bundler entries load under DOM test environments', () => { + afterEach(() => { + delete (globalThis as { document?: unknown }).document; + }); + + it.each(['webpack', 'webpack-loader', 'esbuild', 'vite', 'rollup'])( + 'build/cjs/orchestrion/bundler/%s.js loads while a `document` global is defined', + entry => { + (globalThis as { document?: unknown }).document = { baseURI: 'http://localhost:3000/' }; + expect(() => requireFresh(entry)).not.toThrow(); + }, + ); +});