diff --git a/packages/nextjs/src/config/diagnosticsChannelInjection.ts b/packages/nextjs/src/config/diagnosticsChannelInjection.ts index 74233c87c923..b1acfc52e297 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 { loadOrchestrionBundlerPlugin } from './loadOrchestrionBundlerPlugin'; /** * 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 = loadOrchestrionBundlerPlugin()?.resolveOrchestrionRuntimeRequest(request); return resolved ? `commonjs ${resolved}` : undefined; } diff --git a/packages/nextjs/src/config/loadOrchestrionBundlerPlugin.ts b/packages/nextjs/src/config/loadOrchestrionBundlerPlugin.ts new file mode 100644 index 000000000000..b4b4c0a18505 --- /dev/null +++ b/packages/nextjs/src/config/loadOrchestrionBundlerPlugin.ts @@ -0,0 +1,35 @@ +import { loadModule } from '@sentry/core'; +// Type-only imports: erased at compile time so the module graph of the runtime server entry +// (`index.server.ts` → `withSentryConfig` → …) never statically reaches +// `@sentry/server-utils/orchestrion/webpack`. That subpath bundles the code-transformer bundler +// plugin, whose vendored core compiles a WASM lexer at module-evaluation time. On Cloudflare +// Workers runtime WASM compilation is forbidden, so a static import made every cold start throw a +// `CompileError`; elsewhere it was silent bundle weight. See #22794. +import type { + getOrchestrionLoaderPath, + getSentryInstrumentations, + resolveOrchestrionRuntimeRequest, + sentryOrchestrionWebpackPlugin, + serializeInstrumentations, +} from '@sentry/server-utils/orchestrion/webpack'; + +type OrchestrionWebpackModule = { + getOrchestrionLoaderPath: typeof getOrchestrionLoaderPath; + getSentryInstrumentations: typeof getSentryInstrumentations; + resolveOrchestrionRuntimeRequest: typeof resolveOrchestrionRuntimeRequest; + sentryOrchestrionWebpackPlugin: typeof sentryOrchestrionWebpackPlugin; + serializeInstrumentations: typeof serializeInstrumentations; +}; + +/** + * Loads `@sentry/server-utils/orchestrion/webpack` at build time via a runtime require rather than + * a static import. This mirrors how the Sentry sourcemap plugin (`@sentry/bundler-plugins/webpack`) + * is loaded in `webpack.ts` — both are build-time-only and must stay out of the runtime server + * bundle's static module graph. + * + * Callers run exclusively inside `withSentryConfig`'s bundler-config functions (never at module + * scope), so the deferred load resolves the same way the static import did during a real build. + */ +export function loadOrchestrionBundlerPlugin(): OrchestrionWebpackModule | undefined { + return loadModule('@sentry/server-utils/orchestrion/webpack', module); +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9e9aad687f45..926e9298527b 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 { loadOrchestrionBundlerPlugin } from '../loadOrchestrionBundlerPlugin'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { @@ -138,16 +134,23 @@ function maybeAddOrchestrionRule( return rules; } + const orchestrion = loadOrchestrionBundlerPlugin(); + if (!orchestrion) { + return rules; + } + return safelyAddTurbopackRule(rules, { matcher: '*.{js,mjs,cjs}', rule: { condition: 'node', loaders: [ { - loader: getOrchestrionLoaderPath(), + loader: orchestrion.getOrchestrionLoaderPath(), // Turbopack JSON-serializes loader options, so a RegExp `filePath` must be encoded first. options: { - instrumentations: serializeInstrumentations(getSentryInstrumentations()) as unknown as JSONValue[], + instrumentations: orchestrion.serializeInstrumentations( + orchestrion.getSentryInstrumentations(), + ) as unknown as JSONValue[], }, }, ], diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 950596b91c98..ca7b8c691281 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 { loadOrchestrionBundlerPlugin } from './loadOrchestrionBundlerPlugin'; import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; @@ -430,8 +430,11 @@ 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); - prependOrchestrionRuntimeExternals(newConfig); + const orchestrion = loadOrchestrionBundlerPlugin(); + if (orchestrion) { + newConfig.plugins.push(orchestrion.sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); + prependOrchestrionRuntimeExternals(newConfig); + } } return newConfig; diff --git a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts index 50e4e371e85d..0291cd749154 100644 --- a/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts +++ b/packages/nextjs/test/config/diagnosticsChannelInjection.test.ts @@ -1,11 +1,19 @@ import { existsSync } from 'node:fs'; import { isAbsolute } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { BUNDLE_SAFE_INSTRUMENTED_PACKAGES, externalizeOrchestrionRuntimePackages, filterInstrumentedExternals, } from '../../src/config/diagnosticsChannelInjection'; + +// `externalizeOrchestrionRuntimePackages` loads the orchestrion bundler plugin lazily via a runtime +// `require` that Vitest's module runner can't resolve, so the seam is pointed at the real module +// through `importActual` (which Vitest does resolve). +vi.mock('../../src/config/loadOrchestrionBundlerPlugin', async () => { + const actual = await vi.importActual>('@sentry/server-utils/orchestrion/webpack'); + return { loadOrchestrionBundlerPlugin: () => actual }; +}); import { setUpBuildTimeVariables } from '../../src/config/withSentryConfig/buildTime'; import { getServerExternalPackagesPatch } from '../../src/config/withSentryConfig/getFinalConfigObjectBundlerUtils'; import type { NextConfigObject } from '../../src/config/types'; diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index fadb4cdd4820..e30714f6ffd2 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -7,6 +7,14 @@ import { } from '../../../src/config/turbopack/constructTurbopackConfig'; import type { NextConfigObject } from '../../../src/config/types'; +// The orchestrion bundler plugin is loaded lazily via `loadOrchestrionBundlerPlugin` (a runtime +// `require` Vitest's module runner can't resolve), so the seam is pointed at the real module +// through `importActual` (which Vitest does resolve). +vi.mock('../../../src/config/loadOrchestrionBundlerPlugin', async () => { + const actual = await vi.importActual>('@sentry/server-utils/orchestrion/webpack'); + return { loadOrchestrionBundlerPlugin: () => actual }; +}); + // Mock path.resolve to return a predictable loader path vi.mock('path', async () => { const actual = await vi.importActual('path'); diff --git a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts index a31a8ca4490a..6f6f48b3319f 100644 --- a/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts +++ b/packages/nextjs/test/config/webpack/constructWebpackConfig.test.ts @@ -15,12 +15,19 @@ 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' }), -})); +// The orchestrion bundler plugin is loaded lazily via `loadOrchestrionBundlerPlugin` (a runtime +// `require` Vitest's module runner can't resolve), so the seam is mocked here rather than the +// subpath. Only the plugin factory is stubbed — `resolveOrchestrionRuntimeRequest` must stay real +// because the externals handler under test uses it. +vi.mock('../../../src/config/loadOrchestrionBundlerPlugin', async () => { + const actual = await vi.importActual>('@sentry/server-utils/orchestrion/webpack'); + return { + loadOrchestrionBundlerPlugin: () => ({ + ...actual, + sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }), + }), + }; +}); describe('constructWebpackConfigFunction()', () => { it('includes expected properties', async () => { diff --git a/packages/nextjs/test/orchestrionStaticImport.test.ts b/packages/nextjs/test/orchestrionStaticImport.test.ts new file mode 100644 index 000000000000..24ea7df82c68 --- /dev/null +++ b/packages/nextjs/test/orchestrionStaticImport.test.ts @@ -0,0 +1,70 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * `@sentry/server-utils/orchestrion/webpack` bundles the code-transformer bundler plugin, whose + * vendored core compiles a WASM lexer at module-evaluation time. A static import of this subpath + * anywhere in the package pulls that module into the runtime server entry's static graph, so on + * Cloudflare Workers — where runtime WASM compilation is forbidden — every cold start threw a + * `CompileError`, even with the feature disabled. It must only ever be reached through the deferred + * `loadModule(...)` in `loadOrchestrionBundlerPlugin`. + * + * Regression test for https://github.com/getsentry/sentry-javascript/issues/22794 + */ +describe('`@sentry/server-utils/orchestrion/webpack` is never a static import in the built package', () => { + const builds = { + cjs: resolve(__dirname, '../build/cjs'), + esm: resolve(__dirname, '../build/esm'), + }; + + const SUBPATH = '@sentry/server-utils/orchestrion/webpack'; + + // Static ESM `import`/`export … from ''` (named, namespace, or bare side-effect import). + const STATIC_IMPORT = new RegExp(String.raw`\b(?:import|export)\b[^;]*?from\s*['"]${escapeRegExp(SUBPATH)}['"]`); + const BARE_IMPORT = new RegExp(String.raw`\bimport\s*['"]${escapeRegExp(SUBPATH)}['"]`); + // Static CJS `require('')` — distinct from the deferred `loadModule('', module)`. + const STATIC_REQUIRE = new RegExp(String.raw`\brequire\(\s*['"]${escapeRegExp(SUBPATH)}['"]`); + + it.each(Object.keys(builds))('has no static import of the orchestrion webpack subpath in the %s build', build => { + const root = builds[build as keyof typeof builds]; + + const offenders = jsFiles(root) + .filter(file => { + const source = readFileSync(file, 'utf8'); + return STATIC_IMPORT.test(source) || BARE_IMPORT.test(source) || STATIC_REQUIRE.test(source); + }) + .map(file => relative(root, file)); + + expect(offenders).toEqual([]); + }); + + it('reaches the orchestrion webpack subpath only through the deferred loader', () => { + for (const [, root] of Object.entries(builds)) { + const referencing = jsFiles(root).filter(file => readFileSync(file, 'utf8').includes(SUBPATH)); + expect(referencing.map(file => relative(root, file))).toEqual([ + join('config', 'loadOrchestrionBundlerPlugin.js'), + ]); + expect(readFileSync(referencing[0]!, 'utf8')).toMatch( + new RegExp(String.raw`loadModule\(\s*['"]${escapeRegExp(SUBPATH)}['"]\s*,\s*module\s*\)`), + ); + } + }); +}); + +function jsFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...jsFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + files.push(full); + } + } + return files; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +}