Skip to content

ESM Import Resolution Issues #1454

@r-near

Description

@r-near

Bug Description

@near-js/client fails in ESM environments due to missing .js extensions in import paths. Current build output:

// lib/esm/index.js
export * from './constants';     // Missing .js extension
export * from './crypto';        // Missing .js extension

Error in Vite/other ESM environments:

Error: Cannot find module '@near-js/client/lib/esm/constants' imported from '@near-js/client/lib/esm/index.js'

Technical Context

To understand this bug, it helps to know that JavaScript has two different ways of importing/exporting code between files:

  1. The older CommonJS (CJS) way:
// Importing
const { thing } = require('./file')
// Exporting
module.exports = { thing }
  1. The newer ESM way:
// Importing
import { thing } from './file.js'  // Note the .js!
// Exporting
export const thing = {}

The key difference here is that ESM requires explicit file extensions (.js) in import paths, while CommonJS doesn't. This is part of the ESM specification and helps with performance and reliability.

Root Cause

Our package has several issues that combine to create this bug:

  1. Package declares "type": "module" making it ESM-first
  2. ESM spec requires explicit file extensions in import paths
  3. TypeScript's moduleResolution: "node" outputs extension-less imports
  4. No post-processing step to add extensions

In other words: we're telling JavaScript "use the new ESM system" (type: "module"), but our build process is creating import statements that only work with the old system.

Impact

  • Breaks in all ESM environments (Vite, webpack w/ESM, Node.js w/ESM)
  • Affects downstream packages using native ESM
  • Type definitions also lack extensions, causing TS errors

Current Setup

Here's our current build configuration:

// package.json
{
  "type": "module",
  "main": "lib/esm/index.js",
  "exports": {
    "require": "./lib/commonjs/index.cjs",
    "import": "./lib/esm/index.js"
  },
  "scripts": {
    "build": "pnpm compile:esm && pnpm compile:cjs",
    "compile:esm": "tsc -p tsconfig.json",
    "compile:cjs": "tsc -p tsconfig.cjs.json && cjsify ./lib/commonjs"
  }
}

This setup attempts to support both CJS and ESM (called "dual package support"), but the ESM output isn't spec-compliant because of the missing extensions.

Proposed Solution: Migrate to tsup

Instead of managing all this complexity ourselves, we should switch to tsup. Think of tsup as a smart bundler that knows how to handle all these module format issues automatically - it's become the standard tool for building modern TypeScript libraries.

Implementation

  1. Install tsup:
pnpm add -D tsup
  1. Update package.json:
{
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "require": "./dist/index.js",
      "import": "./dist/index.mjs",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts"
  }
}
  1. Update tsconfig.json:

Even though tsup handles the build output, we still need a proper tsconfig.json for:

  • Editor support (VSCode, etc.)
  • Type checking during development
  • Ensuring ESM compatibility for source files

Our current tsconfig:

{
  "compilerOptions": {
    "module": "es2022",
    "moduleResolution": "node",
    "target": "es2022"
    // ... other options
  }
}

With tsup, we have two options:

A. Keep using moduleResolution: "node" since tsup will handle the ESM output:

{
  "compilerOptions": {
    "module": "es2022",
    "moduleResolution": "node",
    "target": "es2022"
  }
}

B. Switch to modern module resolution to catch ESM issues during development:

{
  "compilerOptions": {
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2022"
  }
}

Option B is recommended as it helps catch ESM compatibility issues earlier in the development process rather than at build time.

Benefits of tsup

  1. Handles ESM/CJS dual package output automatically
  2. Adds proper file extensions for ESM
  3. Bundles dependencies correctly
  4. Much faster than our current two-step build process
  5. Follows modern TypeScript library best practices

Alternative Solutions

While tsup is recommended, there are other approaches we could take:

  1. Add .js extensions manually in our TypeScript code

    • ✅ Simple to understand
    • ❌ Requires changing all import statements
    • ❌ More maintenance burden
    • ❌ Easy to forget for new code
  2. Switch back to "type": "commonjs"

    • ✅ Quick fix
    • ❌ Goes against modern JavaScript trends
    • ❌ May cause issues for ESM-only environments
    • ❌ Technical debt we'll need to fix later
  3. Add a post-processing step

    • ✅ No code changes needed
    • ❌ More complex build process
    • ❌ Another tool to maintain
    • ❌ Can be brittle

Next Steps

  1. Try the tsup migration in a branch
  2. Verify it works with different consumers:
    • Vite
    • webpack
    • Node.js (both ESM and CJS)
  3. Check bundle sizes
  4. Review the generated types
  5. Run integration tests

Let me know if you'd like me to create a PR with these changes or if you have any questions about the solution!

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Status

    Shipped 🚀

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions