Skip to content

[BUG] Bundling ssh2 fails because optional cpu-features native addon (.node) is statically resolved by esbuild #1226

@mushan0x0

Description

@mushan0x0

Describe the bug

When a Next.js API route imports ssh2, opennextjs-cloudflare build fails during the esbuild bundle step because cpu-features (an optional native addon of ssh2) statically resolves a .node binary, even though the require sits inside a try/catch.

Dependency chain:

src/app/api/.../route.ts
  └─ ssh2
      └─ lib/protocol/constants.js  (try { require('cpu-features') } catch { ... })
          └─ cpu-features/lib/index.js
              └─ ./build/Release/cpufeatures.node   ← esbuild tries to parse this

Error:

✘ [ERROR] No loader is configured for ".node" files: node_modules/.pnpm/cpu-features@.../node_modules/cpu-features/build/Release/cpufeatures.node

    node_modules/.pnpm/cpu-features@.../node_modules/cpu-features/lib/index.js:3:19:
      3 │ module.exports = require('./build/Release/cpufeatures.node');

ssh2 intentionally wraps the require in try/catch so the native addon is fully optional at runtime (it falls back to a pure-JS implementation, which is what a Workers deployment needs). But because esbuild resolves require statically, the bundler breaks before we ever reach runtime.

The same issue has been reported upstream several times and the ssh2 maintainer's position is that cpu-features is already declared in optionalDependencies and this is a bundler concern:

So the fix really needs to live in @opennextjs/cloudflare (or its esbuild configuration) rather than in ssh2.

Steps to reproduce

  1. pnpm create next-app (App Router, Next 16).

  2. pnpm add ssh2 @opennextjs/cloudflare wrangler.

  3. Add an API route that imports ssh2:

    // src/app/api/ping/route.ts
    import { Client } from "ssh2";
    export async function GET() {
      const c = new Client();
      return new Response(typeof c);
    }
  4. pnpm exec opennextjs-cloudflare build.

  5. Observe the esbuild error about cpufeatures.node and cpu-features/lib/index.js.

Minimal workaround we currently ship (run before every build):

// scripts/patch-cpu-features.mjs
import { readFileSync, writeFileSync, globSync } from "node:fs";

const stub =
  "'use strict';\nmodule.exports = () => { throw new Error('cpu-features disabled in Workers'); };\n";

for (const p of globSync(
  "node_modules/.pnpm/cpu-features@*/node_modules/cpu-features/lib/index.js",
)) {
  if (readFileSync(p, "utf8") !== stub) writeFileSync(p, stub);
}

After stubbing cpu-features/lib/index.js, the bundle succeeds and ssh2 happily falls back to its JS crypto path on the Worker.

Expected behavior

opennextjs-cloudflare build should either:

  1. Automatically replace .node addon entry files (or at least well-known optional ones such as cpu-features) with a throwing stub so that any surrounding try/catch still triggers the pure-JS fallback, or
  2. Expose a supported configuration surface (e.g. in open-next.config.ts) to mark a package / .node file as "stub on Workers", without having to post-process node_modules ourselves.

Ideally documented for the common ssh2 case, since it appears repeatedly in Workers issues.

@opennextjs/cloudflare version

1.19.2

Wrangler version

4.83.0

next info output

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.3.0: Wed Jan 28 20:56:34 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T8112
  Available memory (MB): 24576
  Available CPU cores: 8
Binaries:
  Node: 22.16.0
  npm: 10.9.2
  Yarn: 1.22.1
  pnpm: 10.32.1
Relevant Packages:
  next: 16.2.4 // Latest available version is detected (16.2.4).
  eslint-config-next: N/A
  react: 19.2.5
  react-dom: 19.2.5
  typescript: 5.9.3
Next.js Config:
  output: N/A

Additional context

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