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
-
pnpm create next-app (App Router, Next 16).
-
pnpm add ssh2 @opennextjs/cloudflare wrangler.
-
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);
}
-
pnpm exec opennextjs-cloudflare build.
-
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:
- 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
- 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
Describe the bug
When a Next.js API route imports
ssh2,opennextjs-cloudflare buildfails during the esbuild bundle step becausecpu-features(an optional native addon ofssh2) statically resolves a.nodebinary, even though therequiresits inside atry/catch.Dependency chain:
Error:
ssh2intentionally wraps therequireintry/catchso 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 resolvesrequirestatically, the bundler breaks before we ever reach runtime.The same issue has been reported upstream several times and the
ssh2maintainer's position is thatcpu-featuresis already declared inoptionalDependenciesand this is a bundler concern:So the fix really needs to live in
@opennextjs/cloudflare(or its esbuild configuration) rather than inssh2.Steps to reproduce
pnpm create next-app(App Router, Next 16).pnpm add ssh2 @opennextjs/cloudflare wrangler.Add an API route that imports
ssh2:pnpm exec opennextjs-cloudflare build.Observe the esbuild error about
cpufeatures.nodeandcpu-features/lib/index.js.Minimal workaround we currently ship (run before every build):
After stubbing
cpu-features/lib/index.js, the bundle succeeds andssh2happily falls back to its JS crypto path on the Worker.Expected behavior
opennextjs-cloudflare buildshould either:.nodeaddon entry files (or at least well-known optional ones such ascpu-features) with a throwing stub so that any surroundingtry/catchstill triggers the pure-JS fallback, oropen-next.config.ts) to mark a package /.nodefile as "stub on Workers", without having to post-processnode_modulesourselves.Ideally documented for the common
ssh2case, 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/AAdditional context
ssh2itself may still not run on Workers because of Workers / workerd: ssh2 fails to load because poly1305.js WASM is instantiated at module init mscdex/ssh2#1494 (poly1305 WASM instantiated at module init). That one is genuinely anssh2bug. The issue reported here is strictly the build-time failure caused bycpu-features, which is solvable at the bundler layer..nodefiles in the Workers target.