Skip to content

Subpath exports resolved correctly but result discarded for CJS files. #215

@RizonSunny

Description

@RizonSunny

What version of pkg are you using?

6.13.1

What version of Node.js are you using?

24.11.1

What operating system are you using?

Windows

What CPU architecture are you using?

x86_64

What Node versions, OSs and CPU architectures are you building for?

node24

Describe the Bug

Description

After the ESM support introduced in v6.13.0 (#192), packages that are dual-format (shipping both .js ESM and .cjs CommonJS files) with subpath exports fail to resolve when require()'d via a subpath.

The ESM-aware resolver correctly resolves the path using the exports map, but the result is discarded because the resolved .cjs file is not considered ESM. The code then falls back to the legacy resolve package which doesn't understand the exports field at all.

Reproduction

Package: @langchain/langgraph@1.0.15

Code being packaged:

const { ToolNode, toolsCondition } = require('@langchain/langgraph/prebuilt');

Relevant package.json of @langchain/langgraph:

{
  "type": "module",
  "main": "./dist/index.js",
  "exports": {
    "./prebuilt": {
      "import": {
        "types": "./dist/prebuilt/index.d.ts",
        "default": "./dist/prebuilt/index.js"
      },
      "require": {
        "types": "./dist/prebuilt/index.d.cts",
        "default": "./dist/prebuilt/index.cjs"
      }
    }
  }
}

Actual file structure (both files exist on disk):

node_modules/@langchain/langgraph/
  dist/
    prebuilt/
      index.js    ← ESM (import/export syntax)
      index.cjs   ← CJS (require/exports syntax)

Root Cause Analysis

I traced through the resolution logic and found three compounding bugs:

Bug 1: Correct ESM resolution result is discarded in follow.ts

resolver.ts correctly resolves the subpath:

require('@langchain/langgraph/prebuilt')
  → tryResolveESM()
  → resolveWithExports(pkg, './prebuilt', { require: true })
  → resolves to: dist/prebuilt/index.cjs ✅

Then at resolver.ts:158-161, isESMFile() is called on the resolved .cjs file:

return {
  resolved: esmResolved,            // dist/prebuilt/index.cjs
  isESM: isESMFile(esmResolved),    // .cjs → always returns false
};

Back in follow.ts:87:

if (result.isESM) {
  // SKIPPED — because .cjs → isESM = false
  // The correctly resolved path is never used!
  resolve(result.resolved);
  return;
}
// Falls through...

The resolver found the right file, but follow.ts throws the answer away because it gates on result.isESM rather than "did the exports-aware resolver succeed?"

Bug 2: CJS fallback doesn't understand exports field

After discarding the correct result, the code falls to follow.ts:148 which uses the resolve npm package (sync()). This legacy resolver only understands the main field — it has no concept of exports subpath mappings. It tries to find prebuilt/index.js at the package root and fails.

Bug 3: Synthetic main patching misses nested condition objects

The walker's package.json patch at walker.ts:1039-1043 checks:

if (typeof exports['.'].require === 'string' && exports['.'].require) {
  mainEntry = exports['.'].require;
}

But in this package, exports['.'].require is an object ({ types: "...", default: "..." }), not a string. So the synthetic main field is never generated, even for the root export.

Additionally, the synthetic main logic only handles exports['.'] — it does nothing for subpath exports like exports['./prebuilt'].

Resolution Flow Diagram

require('@langchain/langgraph/prebuilt')
       │
       ▼
  ESM Resolver ──→ resolves to dist/prebuilt/index.cjs ✅
       │
       ▼
  isESMFile('.cjs') ──→ false (correct — .cjs IS commonjs)
       │
       ▼
  result.isESM = false
       │
       ▼
  follow.ts:87 → if(result.isESM) ──→ SKIPPED ❌ (resolved path discarded!)
       │
       ▼
  CJS fallback sync() ──→ doesn't understand "exports" map ❌
       │
       ▼
  MODULE_NOT_FOUND 💥

Expected Behavior

When a package defines subpath exports with a require condition in its package.json:

"./prebuilt": {
  "require": {
    "default": "./dist/prebuilt/index.cjs"
  }
}

Then require('@langchain/langgraph/prebuilt') should resolve to dist/prebuilt/index.cjs inside the packaged executable, just as it does in normal Node.js runtime.

The exports-aware resolver already finds this file correctly — the result should be used regardless of whether the resolved file is .cjs or .js, since the exports field is a package-level feature, not an ESM-only feature.

To Reproduce

Mention on the #Reproduction paragraph in description.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions