Skip to content

@sentry/nextjs: "node" export condition is a bare CJS path with no import/require split, so ESM consumers get a partial namespace #22791

Description

@jmatthewpryor

Package: @sentry/nextjs@10.29.0
Node: v22.18.0

Summary

exports["."]["node"] is a bare string pointing at the CJS build. Every other platform condition in the same map splits import/require; node does not. Because node is active for any Node process and is declared before import, any ESM consumer under a plain-Node loader resolves build/cjs/index.server.js rather than build/esm/index.server.js — and cjs-module-lexer cannot see the bindings that arrive via export * from @sentry/core / @sentry/node, so the resulting namespace is partial.

The manifest

Read directly from the installed package:

"exports": {
  ".": {
    "types":      "./build/types/index.types.d.ts",
    "edge":       { "import": "./build/esm/edge/index.js",   "require": "./build/cjs/edge/index.js" },
    "edge-light": { "import": "./build/esm/edge/index.js",   "require": "./build/cjs/edge/index.js" },
    "worker":     { "import": "./build/esm/edge/index.js",   "require": "./build/cjs/edge/index.js" },
    "workerd":    { "import": "./build/esm/edge/index.js",   "require": "./build/cjs/edge/index.js" },
    "browser":    { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" },
    "node":       "./build/cjs/index.server.js",   // <-- bare string, CJS only
    "import":     "./build/esm/index.server.js"    // <-- unreachable for Node consumers
  }
}

browser and all four edge/worker conditions split import/require. node is the only platform condition that doesn't — which makes the "import" entry below it effectively dead code for Node, since node always matches first.

Reproduction

From any project with the package installed — no other setup:

$ node --input-type=module -e "console.log(import.meta.resolve('@sentry/nextjs'))"
file:///.../@sentry/nextjs/build/cjs/index.server.js          # CJS, not ESM

$ node --input-type=module -e "import { addBreadcrumb } from '@sentry/nextjs'"
SyntaxError: Named export 'addBreadcrumb' not found. The requested module
'@sentry/nextjs' is a CommonJS module, which may not support all
module.exports as named exports.

$ node --input-type=module -e "import * as S from '@sentry/nextjs'; console.log(typeof S.init, typeof S.captureException, typeof S.isEnabled)"
function undefined undefined

Impact

Named imports fail loudly at link time, which is the safe case.

Namespace imports are the dangerous one. They link cleanly and the missing bindings are undefined, so the failure surfaces as TypeError: X is not a function at call time, arbitrarily far from the import. The detected/undetected split follows nothing a consumer can predict:

present undefined
init, startSpan, captureRequestError, withSentryConfig captureException, captureMessage, setTag, setContext, addBreadcrumb, isEnabled, isInitialized, consoleLoggingIntegration

captureException is a particularly unhappy landing spot, since it is usually called from inside a catch block — so the resulting TypeError is frequently swallowed by the very error handler that invoked it, and the error is silently never reported.

This does not affect apps built by Next itself (webpack/turbopack prefer the import condition), nor Vite-based loaders. It affects plain-Node ESM tooling: scripts, CLIs, health-check probes and test harnesses that import the SDK outside the bundler.

A default import (import S from '@sentry/nextjs') recovers the full server-build surface, because it reads the runtime module.exports object rather than the lexer's static guess. That is a viable workaround, but consumers have to know to apply it, and it is easily "tidied" back into a namespace import by a later reader.

Suggested fix

Give node the same import/require split its sibling conditions already have:

"node": {
  "import":  "./build/esm/index.server.js",
  "require": "./build/cjs/index.server.js"
}

Backwards-compatible for CJS consumers (require still resolves the CJS build), and it makes the existing "import" entry reachable for ESM ones.

Possibly related

#20038workerd/worker conditions resolving to @sentry/node, breaking Cloudflare Workers. Same class (an export condition resolving to the wrong build), different condition.

If this is intentional

If node deliberately pins CJS — for instance because OpenTelemetry instrumentation patching relies on require semantics — that would be very useful to have stated, since it makes the default-import workaround permanent rather than transitional. Given browser does split, the asymmetry currently reads as an oversight rather than a decision.

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions