Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/nextjs/src/config/diagnosticsChannelInjection.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -58,6 +58,6 @@ export async function externalizeOrchestrionRuntimePackages({
return undefined;
}

const resolved = resolveOrchestrionRuntimeRequest(request);
const resolved = loadOrchestrionBundler().resolveOrchestrionRuntimeRequest(request);
return resolved ? `commonjs ${resolved}` : undefined;
}
29 changes: 29 additions & 0 deletions packages/nextjs/src/config/loadOrchestrionBundler.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createRequire> {
let nodeRequire: ReturnType<typeof createRequire>;
/*! 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;
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -138,6 +134,8 @@ function maybeAddOrchestrionRule(
return rules;
}

const { getOrchestrionLoaderPath, getSentryInstrumentations, serializeInstrumentations } = loadOrchestrionBundler();

return safelyAddTurbopackRule(rules, {
matcher: '*.{js,mjs,cjs}',
rule: {
Expand Down
6 changes: 4 additions & 2 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Record<string, unknown>>()),
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<typeof loadOrchestrionBundlerModule>();
return {
loadOrchestrionBundler: () => ({
...original.loadOrchestrionBundler(),
sentryOrchestrionWebpackPlugin: () => ({ _name: 'sentry-orchestrion-webpack-plugin' }),
}),
};
});

describe('constructWebpackConfigFunction()', () => {
it('includes expected properties', async () => {
Expand Down
32 changes: 32 additions & 0 deletions packages/nextjs/test/serverEntryBundlerGraph.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 14 additions & 1 deletion packages/server-utils/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
},
);
});
Loading