From 8392c43a995dee88aadcd2d67d6adec1acfb4c7c Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:18:04 +0200 Subject: [PATCH 1/3] test(nextjs): Add orchestion bundling tests --- .../tests/worker-bundle.test.ts | 41 +++++++++++++++++++ .../test/serverEntryBundlerGraph.test.ts | 32 +++++++++++++++ .../orchestrion/bundlerBuildOutput.test.ts | 38 +++++++++++++++++ 3 files changed, 111 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts create mode 100644 packages/nextjs/test/serverEntryBundlerGraph.test.ts create mode 100644 packages/server-utils/test/orchestrion/bundlerBuildOutput.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts new file mode 100644 index 000000000000..31814184db88 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { isDevMode } from './isDevMode'; + +/** + * The orchestrion bundler plugins are build-time-only, and their module-scope side effects break + * on Workers (an unawaited `WebAssembly.compile()` crashed every cold start, issue #22794). The + * worker bundle OpenNext produces must therefore never contain them: importing `@sentry/nextjs` + * on the server has to keep the plugin graph out of the deployed artifact. + */ +test('worker bundle does not contain the orchestrion bundler plugins', () => { + test.skip(isDevMode, 'requires the production worker build'); + + const openNextDir = path.resolve(__dirname, '..', '.open-next'); + expect(fs.existsSync(path.join(openNextDir, 'worker.js'))).toBe(true); + + // `assets` holds the static client files; everything else is code the worker can run. + const serverFiles = collectJsFiles(openNextDir).filter( + filePath => !filePath.startsWith(path.join(openNextDir, 'assets')), + ); + expect(serverFiles.length).toBeGreaterThan(0); + + const markers = ['code-transformer-bundler-plugins', '__codeTransformerWebpackDiagnostics']; + const leaks = serverFiles.filter(filePath => { + const content = fs.readFileSync(filePath, 'utf8'); + return markers.some(marker => content.includes(marker)); + }); + + expect(leaks.map(filePath => path.relative(openNextDir, filePath))).toEqual([]); +}); + +function collectJsFiles(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return collectJsFiles(fullPath); + } + return /\.(js|mjs|cjs)$/.test(entry.name) ? [fullPath] : []; + }); +} 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/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(); + }, + ); +}); From be38b6c99afd888ecdc64f11267abe2582d39cad Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:42:45 +0200 Subject: [PATCH 2/3] Add `resolveImportMeta` shim --- packages/server-utils/rollup.npm.config.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 675a81424abe..c313a9f1cc4c 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -48,6 +48,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 @@ -94,7 +107,7 @@ export default [ 'src/orchestrion/bundler/bun.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', From 6174940decedffa27c18d0156f4c74576206c1df Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:57:29 +0200 Subject: [PATCH 3/3] test if orchestrion markers are still in there --- .../tests/worker-bundle.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts index 31814184db88..dbd927e2053b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cf-workers/tests/worker-bundle.test.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import * as fs from 'fs'; +import { createRequire } from 'module'; import * as path from 'path'; import { isDevMode } from './isDevMode'; @@ -22,6 +23,17 @@ test('worker bundle does not contain the orchestrion bundler plugins', () => { expect(serverFiles.length).toBeGreaterThan(0); const markers = ['code-transformer-bundler-plugins', '__codeTransformerWebpackDiagnostics']; + + // The markers must still exist in the installed plugin build. + // If upstream renames them, this fails instead of letting the leak check below pass. + const pluginGraphSources = readOrchestrionPluginGraphSources(); + for (const marker of markers) { + expect( + pluginGraphSources.some(source => source.includes(marker)), + `marker "${marker}" is gone from the @sentry/server-utils plugin build — update the markers`, + ).toBe(true); + } + const leaks = serverFiles.filter(filePath => { const content = fs.readFileSync(filePath, 'utf8'); return markers.some(marker => content.includes(marker)); @@ -30,6 +42,23 @@ test('worker bundle does not contain the orchestrion bundler plugins', () => { expect(leaks.map(filePath => path.relative(openNextDir, filePath))).toEqual([]); }); +/** + * Reads the source of the installed `@sentry/server-utils` webpack plugin entry plus the files it + * requires relatively — the graph a leak would drag into the worker bundle. `createRequire` takes + * the `require` export condition, so this resolves the CJS build, whose `require('./…')` calls the + * regex below picks up. + */ +function readOrchestrionPluginGraphSources(): string[] { + const pluginEntry = createRequire(__filename).resolve('@sentry/server-utils/orchestrion/webpack'); + const entrySource = fs.readFileSync(pluginEntry, 'utf8'); + return [ + entrySource, + ...[...entrySource.matchAll(/require\('(\.\.?\/[^']+)'\)/g)].map(([, specifier]) => + fs.readFileSync(path.resolve(path.dirname(pluginEntry), specifier), 'utf8'), + ), + ]; +} + function collectJsFiles(dir: string): string[] { return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { const fullPath = path.join(dir, entry.name);