From b038e63f2b1fb986720d08852580f4534d5579ae Mon Sep 17 00:00:00 2001 From: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:03:56 -0500 Subject: [PATCH 1/3] fix(nextjs): Split `node` export condition into `import`/`require` The `node` condition in the root `exports` map of `@sentry/nextjs` was a bare string pointing at the CJS server build. Since `node` matches before the top-level `import` condition for any Node.js process, ESM consumers under a plain Node loader always received the CJS build, and `cjs-module-lexer` could not statically detect the re-exports coming from `@sentry/core` / `@sentry/node`, leaving named imports broken and namespace imports with silently-undefined members. Split the `node` condition into an `import`/`require` pair pointing at the ESM and CJS server builds respectively, matching the shape already used by the sibling `browser`/`edge`/`worker` conditions in this package and by the `node` condition in @sentry/sveltekit and @sentry/nuxt. Also add the explicit `.js` extension to the `next/constants` import so the now-reachable ESM server build is actually loadable by Node's ESM resolver (`next` ships no `exports` map, so extensionless deep imports fail there). Fixes #22791 --- packages/nextjs/package.json | 5 ++++- packages/nextjs/src/common/utils/isBuild.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 2f62b534f3cd..780870d66b33 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -43,7 +43,10 @@ "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" }, - "node": "./build/cjs/index.server.js", + "node": { + "import": "./build/esm/index.server.js", + "require": "./build/cjs/index.server.js" + }, "import": "./build/esm/index.server.js" }, "./async-storage-shim": { diff --git a/packages/nextjs/src/common/utils/isBuild.ts b/packages/nextjs/src/common/utils/isBuild.ts index 92b9808f75b9..c97003f56b47 100644 --- a/packages/nextjs/src/common/utils/isBuild.ts +++ b/packages/nextjs/src/common/utils/isBuild.ts @@ -1,4 +1,6 @@ -import { PHASE_PRODUCTION_BUILD } from 'next/constants'; +// The explicit `.js` extension is required for the ESM build to be loadable by plain Node.js: `next` does not define +// an `exports` map, so extensionless deep imports like `next/constants` are not resolvable by Node's ESM resolver. +import { PHASE_PRODUCTION_BUILD } from 'next/constants.js'; /** * Decide if the currently running process is part of the build phase or happening at runtime. From 2c34bd649daf0c3211d26bc9db687645c03cd986 Mon Sep 17 00:00:00 2001 From: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:05:41 -0500 Subject: [PATCH 2/3] test(nextjs): Add regression tests for `node` export condition split Assert that the `node` condition of the root export has an `import`/`require` split whose entries point at files that actually exist in the ESM and CJS build output, and that the ESM server module graph contains no extensionless `next/*` deep imports (which are unresolvable by Node's ESM resolver because `next` ships no `exports` map). Ref #22791 --- packages/nextjs/test/exportsMap.test.ts | 79 +++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/nextjs/test/exportsMap.test.ts diff --git a/packages/nextjs/test/exportsMap.test.ts b/packages/nextjs/test/exportsMap.test.ts new file mode 100644 index 000000000000..de34dcd79a60 --- /dev/null +++ b/packages/nextjs/test/exportsMap.test.ts @@ -0,0 +1,79 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * The `node` export condition must offer an `import`/`require` split like its sibling conditions. When it was a bare + * string pointing at the CJS server build, every ESM consumer under a plain Node.js loader received the CJS build + * (`node` matches before the top-level `import` condition), so `cjs-module-lexer` could not see the bindings + * re-exported from `@sentry/core` / `@sentry/node`: named imports failed to link and namespace imports contained + * silently-undefined members. + * + * Regression test for https://github.com/getsentry/sentry-javascript/issues/22791 + */ +describe('package.json exports map', () => { + const packageRoot = resolve(__dirname, '..'); + const packageJson = JSON.parse(readFileSync(resolve(packageRoot, 'package.json'), 'utf8')) as { + exports: Record>; + }; + + const nodeCondition = packageJson.exports['.']?.['node'] as Record | undefined; + + it('has an `import`/`require` split under the `node` condition of the root export', () => { + expect(nodeCondition).toBeInstanceOf(Object); + expect(typeof nodeCondition?.import).toBe('string'); + expect(typeof nodeCondition?.require).toBe('string'); + }); + + it('points the `node.import` condition at an existing file in the ESM build output', () => { + expect(nodeCondition?.import).toMatch(/\/esm\//); + expect(existsSync(resolve(packageRoot, nodeCondition?.import as string))).toBe(true); + }); + + it('points the `node.require` condition at an existing file in the CJS build output', () => { + expect(nodeCondition?.require).toMatch(/\/cjs\//); + expect(existsSync(resolve(packageRoot, nodeCondition?.require as string))).toBe(true); + }); +}); + +/** + * `next` does not declare an `exports` map, so Node's ESM resolver requires explicit file extensions for deep imports + * like `next/constants.js`. Extensionless specifiers work in webpack/turbopack but throw `ERR_MODULE_NOT_FOUND` under + * a plain Node.js loader, which would make the ESM server build (reachable via `node.import`) unloadable. + * + * Regression test for https://github.com/getsentry/sentry-javascript/issues/22791 + */ +describe('ESM server build is loadable by plain Node.js', () => { + it('uses explicit file extensions for all `next/*` deep imports in the ESM server module graph', () => { + const packageRoot = resolve(__dirname, '..'); + const entry = resolve(packageRoot, 'build/esm/index.server.js'); + expect(existsSync(entry)).toBe(true); + + const importSpecifierRegex = /(?:from|import)\s*['"]([^'"]+)['"]/g; + const visited = new Set(); + const queue = [entry]; + const extensionlessNextImports: string[] = []; + + while (queue.length > 0) { + const file = queue.pop() as string; + if (visited.has(file) || !existsSync(file)) { + continue; + } + visited.add(file); + + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(importSpecifierRegex)) { + const specifier = match[1] as string; + if (specifier.startsWith('.')) { + const resolved = resolve(dirname(file), specifier); + queue.push(resolved.endsWith('.js') ? resolved : `${resolved}.js`); + } else if (/^next\/.+/.test(specifier) && !/\.[cm]?js$/.test(specifier)) { + extensionlessNextImports.push(`${specifier} (in ${file.replace(packageRoot, '')})`); + } + } + } + + expect(visited.size).toBeGreaterThan(1); + expect(extensionlessNextImports).toEqual([]); + }); +}); From 33840f5dad1d4eb716307079ff95c4ccc8e67099 Mon Sep 17 00:00:00 2001 From: Mohith Gajjela <109003762+Mohith26@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:05:18 -0500 Subject: [PATCH 3/3] fix(nextjs): Shim __dirname for the ESM config build The config code (webpack.ts and the turbopack rule builders) resolves loader paths with __dirname, which does not exist in ESM. Derive a module-local dirname from import.meta.url when __dirname is undefined, following the @sentry/bundler-plugins pattern (Rollup transpiles import.meta for the CJS build; the typeof guard keeps CJS on the fast path). Adds a graph test asserting the ESM config build contains no unguarded __dirname references. --- .../turbopack/constructTurbopackConfig.ts | 12 +++++- .../turbopack/generateValueInjectionRules.ts | 12 +++++- packages/nextjs/src/config/webpack.ts | 22 +++++++---- packages/nextjs/test/exportsMap.test.ts | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9e9aad687f45..4bfb22d0a62c 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,5 +1,6 @@ import { debug } from '@sentry/core'; import * as path from 'path'; +import { fileURLToPath } from 'url'; import { getOrchestrionLoaderPath, getSentryInstrumentations, @@ -17,6 +18,13 @@ import type { import { supportsNativeDebugIds, supportsTurbopackRuleCondition } from '../util'; import { generateValueInjectionRules } from './generateValueInjectionRules'; +// The ESM build has no `__dirname`; derive it from `import.meta.url` in that case, +// following the `@sentry/bundler-plugins` pattern (Rollup transpiles `import.meta` +// for the CJS build, and the `typeof __dirname` branch keeps CJS working regardless). +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore Rollup transpiles import.meta for the CJS build +const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. * @@ -86,7 +94,7 @@ export function constructTurbopackConfig({ condition: { not: { path: /next\/dist\/build\/polyfills/ } }, loaders: [ { - loader: path.resolve(__dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'), + loader: path.resolve(_dirname, '..', 'loaders', 'moduleMetadataInjectionLoader.js'), options: { applicationKey, }, @@ -107,7 +115,7 @@ export function constructTurbopackConfig({ condition: { not: 'foreign' }, loaders: [ { - loader: path.resolve(__dirname, '..', 'loaders', 'componentAnnotationLoader.js'), + loader: path.resolve(_dirname, '..', 'loaders', 'componentAnnotationLoader.js'), options: { ignoredComponents: turbopackReactComponentAnnotation.ignoredComponents ?? [], }, diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts index 8bca01cb2ce4..0cc4841abf98 100644 --- a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -1,9 +1,17 @@ import * as path from 'path'; +import { fileURLToPath } from 'url'; import type { VercelCronsConfig } from '../../common/types'; import type { RouteManifest } from '../manifest/types'; import type { JSONValue, TurbopackMatcherWithRule } from '../types'; import { getPackageModules, supportsTurbopackRuleCondition } from '../util'; +// The ESM build has no `__dirname`; derive it from `import.meta.url` in that case, +// following the `@sentry/bundler-plugins` pattern (Rollup transpiles `import.meta` +// for the CJS build, and the `typeof __dirname` branch keeps CJS working regardless). +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore Rollup transpiles import.meta for the CJS build +const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + /** * Generate the value injection rules for client and server in turbopack config. */ @@ -62,7 +70,7 @@ export function generateValueInjectionRules({ ...(hasConditionSupport ? { condition: { not: 'foreign' } } : {}), loaders: [ { - loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + loader: path.resolve(_dirname, '..', 'loaders', 'valueInjectionLoader.js'), options: { values: clientValues, }, @@ -82,7 +90,7 @@ export function generateValueInjectionRules({ ...(hasConditionSupport ? { condition: { not: 'foreign' } } : {}), loaders: [ { - loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + loader: path.resolve(_dirname, '..', 'loaders', 'valueInjectionLoader.js'), options: { values: serverValues, }, diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 950596b91c98..3df3eb6311e8 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -5,6 +5,7 @@ import { debug, escapeStringForRegex, loadModule, parseSemver } from '@sentry/co import * as fs from 'fs'; import { createRequire } from 'module'; import * as path from 'path'; +import { fileURLToPath } from 'url'; import type { VercelCronsConfig } from '../common/types'; import { externalizeOrchestrionRuntimePackages } from './diagnosticsChannelInjection'; import { getBuildPluginOptions, normalizePathForGlob } from './getBuildPluginOptions'; @@ -27,6 +28,13 @@ import { sentryOrchestrionWebpackPlugin } from '@sentry/server-utils/orchestrion import { getNextjsVersion, getPackageModules } from './util'; import type { VercelCronsConfigResult } from './withSentryConfig/getFinalConfigObjectUtils'; +// The ESM build has no `__dirname`; derive it from `import.meta.url` in that case, +// following the `@sentry/bundler-plugins` pattern (Rollup transpiles `import.meta` +// for the CJS build, and the `typeof __dirname` branch keeps CJS working regardless). +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore Rollup transpiles import.meta for the CJS build +const _dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + // Next.js runs webpack 3 times, once for the client, the server, and for edge. Because we don't want to print certain // warnings 3 times, we keep track of them here. let showedMissingGlobalErrorWarningMsg = false; @@ -238,7 +246,7 @@ export function constructWebpackConfigFunction({ test: isPageResource, use: [ { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, wrappingTargetKind: 'page', @@ -252,7 +260,7 @@ export function constructWebpackConfigFunction({ test: isApiRouteResource, use: [ { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, vercelCronsConfig: vercelCronsConfigForWrapper, @@ -269,7 +277,7 @@ export function constructWebpackConfigFunction({ test: isMiddlewareResource, use: [ { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, wrappingTargetKind: 'middleware', @@ -286,7 +294,7 @@ export function constructWebpackConfigFunction({ test: isServerComponentResource, use: [ { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, wrappingTargetKind: 'server-component', @@ -300,7 +308,7 @@ export function constructWebpackConfigFunction({ test: isRouteHandlerResource, use: [ { - loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'), + loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'), options: { ...staticWrappingLoaderOptions, wrappingTargetKind: 'route-handler', @@ -764,7 +772,7 @@ function addValueInjectionLoader({ test: /(src[\\/])?instrumentation.(js|ts)/, use: [ { - loader: path.resolve(__dirname, 'loaders/valueInjectionLoader.js'), + loader: path.resolve(_dirname, 'loaders/valueInjectionLoader.js'), options: { values: serverValues, }, @@ -776,7 +784,7 @@ function addValueInjectionLoader({ test: /(?:sentry\.client\.config\.(jsx?|tsx?)|(?:src[\\/])?instrumentation-client\.(js|ts))$/, use: [ { - loader: path.resolve(__dirname, 'loaders/valueInjectionLoader.js'), + loader: path.resolve(_dirname, 'loaders/valueInjectionLoader.js'), options: { values: clientValues, }, diff --git a/packages/nextjs/test/exportsMap.test.ts b/packages/nextjs/test/exportsMap.test.ts index de34dcd79a60..8fba4a74b13b 100644 --- a/packages/nextjs/test/exportsMap.test.ts +++ b/packages/nextjs/test/exportsMap.test.ts @@ -76,4 +76,42 @@ describe('ESM server build is loadable by plain Node.js', () => { expect(visited.size).toBeGreaterThan(1); expect(extensionlessNextImports).toEqual([]); }); + it('has no unguarded __dirname in the ESM config build graph', () => { + // `__dirname` does not exist in ESM. The config code derives a module dirname + // from `import.meta.url` and may only reference `__dirname` behind a + // `typeof __dirname` guard (the CJS fast path). + const packageRoot = resolve(__dirname, '..'); + const entry = resolve(packageRoot, 'build', 'esm', 'config', 'index.js'); + expect(existsSync(entry)).toBe(true); + + const importSpecifierRegex = /(?:from|import)\s*['"]([^'"]+)['"]/g; + const visited = new Set(); + const queue = [entry]; + const unguardedDirnameUses: string[] = []; + + while (queue.length > 0) { + const file = queue.pop() as string; + if (visited.has(file) || !existsSync(file)) { + continue; + } + visited.add(file); + + const source = readFileSync(file, 'utf8'); + for (const line of source.split('\n')) { + if (line.includes('__dirname') && !line.includes('typeof __dirname')) { + unguardedDirnameUses.push(`${line.trim().slice(0, 80)} (in ${file.replace(packageRoot, '')})`); + } + } + for (const match of source.matchAll(importSpecifierRegex)) { + const specifier = match[1] as string; + if (specifier.startsWith('.')) { + const resolved = resolve(dirname(file), specifier); + queue.push(resolved.endsWith('.js') ? resolved : `${resolved}.js`); + } + } + } + + expect(visited.size).toBeGreaterThan(1); + expect(unguardedDirnameUses).toEqual([]); + }); });