Skip to content

Node.js support#939

Open
justjake wants to merge 52 commits intoanomalyco:mainfrom
justjake:justjake--node-compat-static
Open

Node.js support#939
justjake wants to merge 52 commits intoanomalyco:mainfrom
justjake:justjake--node-compat-static

Conversation

@justjake
Copy link
Copy Markdown

@justjake justjake commented Apr 10, 2026

Partially addresses #2

This PR brings Node.js compatibility. Bun-only imports and APIs have been replaced by compatibility interface modules, which pick a runtime-appropriate implementation. Most codepaths when running in bun are the same as before the PR.

Node.js differenes

  • bun:ffi -> @opentui/core/compat/ffi: I re-implemented relevant bun:ffi APIs on top of koffi. There's more copying than in bun, and I'm unsure about correctness, but I think it's a worthwhile start. The upcoming version of koffi offer capabilities much closer to bun, but I'm not sure when the author will release it.
  • bun:test -> @opentui/core/compat/test: I know the AGENTS.md says no vitest, but vitest is the most popular Node.js test runner, and its api is very similar to bun:test. We use vite alias to replace bun:test with the compat module based on vitest. Source code still says "bun:test" because "bun:test" only works when your test file directly imports it.
    • to avoid snapshots from different test runners thrashing, we create independent snapshots for nodejs. Perhaps this is avoidable somehow? I didn't investigate.
  • globalThis.Worker -> @opentui/core/compat/Worker. Node.js doesn't have a global worker, and its worker_threads worker isn't web-compatible without this shim. Warning: there's a bit of weird node module loader shenanigans here to work around node not understanding import "./foo.js" means import "./foo.ts", but it's pretty benign for opentui's use.
  • bun-ffi-structs: oh boy. I ended up having codex vendor this because module-loader tricks won't work once we're bundled, and it wasn't immediately obvious how to do as a build step. Bun runtime still uses upstream, nodejs gets this hack.
  • Bun.spawnSync -> @opentui/core/compat/testHelpers. This is only used in test, so I put in own file to avoid bundling it.
  • Bun.* -> opentui/core/compat/runtime: Re-implemented Bun.sleep, Bun.writeFile, and installed the typical npm packages for Bun.stringWidth, Bun.stripANSI
  • import ... with { ... } -> new URL(..., import.meta.url). These were pretty mechanically easy to switch to the more universal option.

Testing

Test scripts:

  • test:bun: the old bun run test | bun run test:js. Unit tests w/ bun:test.
  • test:nodejs: npx vitest run. Maybe I could use bunx but I want to run under node!
  • test:dist: New type of integration test that tests that the npm pack'd artifact actually works in a few very simple example projects. Node is not as forgiving as Bun, so this caught a few goofs that don't show up in vitest or bun test. For example, for some reason Bun@1.2 would emit invalid bundles with duplicate exports, detected by this test. Another case was needing to import react-reconciler/something.js w/ the extension. ¯_(ツ)_/¯ node.

Thanks

This is a cool project, and thank you in advance for your attention to this matter.

Let me know if you'd prefer I split up the changes. I personally prefer to review 1 big pr rather than 10 small prs without context, but I know its not the common opinion. Lots of those lines of code are duplicate snapshots!

@simonklee
Copy link
Copy Markdown
Member

Thanks for taking a stab at this. A few questions, just to start;

  • What is koffi and how does it compare to lib,src,test,doc: add node:ffi module nodejs/node#62072 once that lands?
  • What is mise and why is it needed
  • Why Node 22 and not target latest version
  • We plan on moving threejs/3d to its own package so any compat/complexity added because of that should probably be avoided.

@justjake
Copy link
Copy Markdown
Author

justjake commented Apr 10, 2026

Thanks for taking a stab at this. A few questions, just to start;

koffi is a NAPI native module that implements ffi, similar to bun:ffi or this node:ffi pr. Like node:ffi, it passes ArrayBuffer backed values as pointers to native code.

They're all quite similar, but bun:ffi is notable for exposing ptr(typedArray) -> number, which neither the current version of koffi or node:ffi expose. However, the next version of koffi has an equivalent koffi.address(typedArray) -> bigint, which is missing from node:ffi and would make compat/nodejs/ffi.ts more efficient and truly zero-copy.

We could make both current version of koffi & any ffi layer based on node:ffi zero-copy by extending the Pointer type to be type Pointer = number & { __pointer__: null } | TypedArray | ArrayBufferLike and making ptr an identity function under nodejs (export const ptr = (value) => value), that may not be too much work, I didn't investigate to what degree opentui relies on ptr returning number values.

Fun fact, koffi supports and provides premade builds for many architectures / platforms. I only care about the usual linux_x64 / darwin_arm64 / win32_x64 but I think it's cute:

        case 'android_arm64': { native = require('@koromix/koffi-android-arm64'); } break;
        case 'android_x64': { native = require('@koromix/koffi-android-x64'); } break;
        case 'darwin_arm64': { native = require('@koromix/koffi-darwin-arm64'); } break;
        case 'darwin_x64': { native = require('@koromix/koffi-darwin-x64'); } break;
        case 'freebsd_arm64': { native = require('@koromix/koffi-freebsd-arm64'); } break;
        case 'freebsd_ia32': { native = require('@koromix/koffi-freebsd-ia32'); } break;
        case 'freebsd_x64': { native = require('@koromix/koffi-freebsd-x64'); } break;
        case 'linux_armhf': { native = require('@koromix/koffi-linux-arm'); } break;
        case 'linux_arm64': { native = require('@koromix/koffi-linux-arm64'); } break;
        case 'linux_ia32': { native = require('@koromix/koffi-linux-ia32'); } break;
        case 'linux_loong64': { native = require('@koromix/koffi-linux-loong64'); } break;
        case 'linux_riscv64d': { native = require('@koromix/koffi-linux-riscv64'); } break;
        case 'linux_x64': { native = require('@koromix/koffi-linux-x64'); } break;
        case 'openbsd_ia32': { native = require('@koromix/koffi-openbsd-ia32'); } break;
        case 'openbsd_x64': { native = require('@koromix/koffi-openbsd-x64'); } break;
        case 'win32_arm64': { native = require('@koromix/koffi-win32-arm64'); } break;
        case 'win32_ia32': { native = require('@koromix/koffi-win32-ia32'); } break;
        case 'win32_x64': { native = require('@koromix/koffi-win32-x64'); } break;
  • What is mise and why is it needed

mise is a version manager like asdf or nvm. it's not needed in ci, just a convenience for other engineers who may want to set up zig + node + bun with a single command (mise install) in the repo. I can remove mise.toml fine with no affect on the rest of the pr.

  • Why Node 22 and not target latest version

In general think it's good to target the oldest LTS version when building new things unless it's a significant disadvantage to implementation quality. What's the benefit from only targeting the latest version?

Probably more importantly, I want to use opentui in a 25 million line codebase that's currently running on Node.js 22 (https://www.notion.com).

  • We plan on moving threejs/3d to its own package so any compat/complexity added because of that should probably be avoided.

No incidental complexity from 3d stuff. Maybe I changed some imports in there to compat/ffi, same as other files. The 3D examples don't run under Node due to deeper dependency on Bun stuff it seems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants