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
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test';
import * as fs from 'fs';
import { createRequire } from 'module';
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'];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: did we verify this still is the same here on v11? Just do make sure, because we changed a bunch of stuff around these I believe.

@s1gr1d s1gr1d Sep 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on v11: both are still in the build output.

  • code-transformer-bundler-plugins is the vendored directory name and appears in the require paths inside build/cjs/orchestrion/bundler/webpack.js
  • and __codeTransformerWebpackDiagnostics is still in the vendored webpack plugin

But I'm going to add a check to make sure this fails when this is renamed.


// 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));
});

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'),
),
Comment thread
s1gr1d marked this conversation as resolved.
];
}

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] : [];
});
}
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 @@ -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
Expand Down Expand Up @@ -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',
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');
Comment thread
s1gr1d marked this conversation as resolved.

// 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