Skip to content
Open
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
5 changes: 4 additions & 1 deletion packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion packages/nextjs/src/common/utils/isBuild.ts
Original file line number Diff line number Diff line change
@@ -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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The import path for next/constants.js in isBuild.ts does not match the Rollup external configuration, which still lists 'next/constants', causing a build failure.
Severity: HIGH

Suggested Fix

Update the external array in packages/nextjs/rollup.npm.config.mjs. Change the entry from 'next/constants' to 'next/constants.js' to match the new import path used in the source code. This will ensure Rollup correctly identifies the module as an external dependency during the build process.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/nextjs/src/common/utils/isBuild.ts#L3

Potential issue: The code was updated to import from `'next/constants.js'` to fix a Node
ESM resolver issue. However, the Rollup configuration in
`packages/nextjs/rollup.npm.config.mjs` was not updated accordingly and still lists
`'next/constants'` as an external dependency. The external matcher function will not
recognize `'next/constants.js'` as external because it neither exactly matches
`'next/constants'` nor starts with `'next/constants/'`. As a result, Rollup will attempt
to bundle this module instead of treating it as external, likely causing a build failure
or incorrect bundling of Next.js internals.

Did we get this right? 👍 / 👎 to inform future reviews.


/**
* Decide if the currently running process is part of the build phase or happening at runtime.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { debug } from '@sentry/core';
import * as path from 'path';
import { fileURLToPath } from 'url';
import {
getOrchestrionLoaderPath,
getSentryInstrumentations,
Expand All @@ -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.
*
Expand Down Expand Up @@ -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,
},
Expand All @@ -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 ?? [],
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down
22 changes: 15 additions & 7 deletions packages/nextjs/src/config/webpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -238,7 +246,7 @@ export function constructWebpackConfigFunction({
test: isPageResource,
use: [
{
loader: path.resolve(__dirname, 'loaders', 'wrappingLoader.js'),
loader: path.resolve(_dirname, 'loaders', 'wrappingLoader.js'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ESM loaders crash on __dirname

High Severity

The node.import split makes ESM next.config resolve @sentry/nextjs to the ESM build, so _dirname now points webpack/turbopack at ESM loaders under build/esm/ (which has "type": "module"). Those loaders still read templates via bare __dirname at module scope, which throws ReferenceError in ESM and breaks builds for ESM apps that previously worked via the old CJS-only node condition.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 33840f5. Configure here.

options: {
...staticWrappingLoaderOptions,
wrappingTargetKind: 'page',
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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,
},
Expand All @@ -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,
},
Expand Down
117 changes: 117 additions & 0 deletions packages/nextjs/test/exportsMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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<string, Record<string, unknown>>;
};

const nodeCondition = packageJson.exports['.']?.['node'] as Record<string, string> | 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<string>();
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([]);
});
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<string>();
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([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dirname test skips loaders

Medium Severity

The new unguarded-__dirname regression test only walks relative imports from the ESM config entry. Loader paths are passed as path.resolve strings, so wrappingLoader and friends are never visited and the test cannot catch the ESM loader crash this PR introduces. Flagged because the review rules require fix-PR tests to actually cover the newly added behaviour.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit 33840f5. Configure here.

});