← Blog
Colin McDonnell

Introducing Nub — an all-in-one toolkit for Node.js

A Rust CLI that augments the Node you already have — TypeScript that just runs, a faster script runner, and a fast bin runner. It augments Node.js instead of trying to replace it.

Nub is an all-in-one toolkit powered by Node.js that modernizes the developer experience of the Node.js ecosystem. Use it instead of node, npm run, and npx (or the equivalents in your preferred package manager).

$ npm i -g @nubjs/nub
$ nub index.ts             # run a TypeScript file
$ nub run dev              # run a package.json script
$ nub watch src/server.ts  # restart on file changes
$ nubx prisma generate     # run a CLI from node_modules/.bin
$ nub node install 26      # manage Node.js versions

Why?

Node is the gold standard for server-side JavaScript. It defines the stdlib the whole ecosystem is built on and sets the bar for stability and trust. That reputation comes from strict compatibility guarantees and careful governance — the same caution that means Node can't chase modern DX. So the day-to-day toolchain grew up around it, one tool at a time:

  • node — runs files. Recently added limited TypeScript support.
  • npm run (or pnpm run, yarn run) — the "script runner." Node leaves package management to userland, so third-party package managers became the frontend for almost everything you do in a Node project.
  • npx (or pnpm exec) — the "bin runner," for CLIs your dependencies ship into node_modules/.bin.
  • tsx / ts-node — TypeScript support, bolted on.
  • dotenv.env file loading.
  • nodemon — restart on file changes.

Bun and Deno proved there's enormous appetite for an all-in-one toolkit that replaces this pile. But neither has dented Node's production dominance — six years after Deno 1.0 and two and a half after Bun 1.0, neither is a first-class target on AWS, GCP, or Azure, and their Node.js conformance is poor:

RuntimePass rateTests passing
Node 25.8100%4,375 / 4,375
Nub98.7%4,318 / 4,375
Deno 2.876.6%3,351 / 4,375
Bun 1.3.1440.1%1,756 / 4,375

Deno's Node-compat suite, node-relative. Nub's gap is 57 documented deltas — module-hook and native-addon visibility, plus three intended divergences from default-on Web Storage and compile caching. Reproduce →

The "next-generation" runtimes share a fundamental flaw: they're trying to replace Node.js. Nub embraces it instead. It's a single Rust binary that transpiles your code and executes it on the stock node you already have installed — Nub passes Node's test suite because it is Node. It combines:

  • A file runnernub index.ts. Full TypeScript support (parity with Bun) with perfect Node compatibility.
  • A script runnernub run build. An order of magnitude faster than pnpm run.
  • A bin runnernubx prisma generate. An order of magnitude faster than npx.
  • A watchernub watch. The dependency graph drives the watch set; no globs to maintain.
  • A Node version managernub node install 26. Reads your .node-version, installs on demand.
  • A package managernub install. pnpm's CLI surface, lockfile-compatible with any project — npm, pnpm, or bun.

nub <file> — a TypeScript-first Node.js

The top-level command runs files. It's a true drop-in replacement for node — same flags, same argv, same runtime behavior:

$ nub \
    --max-old-space-size=4096 \
    --inspect \
    --import ./instrument.js \
    app.ts --port 3000

Nub transpiles your code in memory with oxc (compiled into a native Node addon) and runs the output on the stock node binary. There is no Nub runtime — just real Node, with enhancements. The transpile costs nothing measurable: running TypeScript is a statistical tie with plain node on Node 24+, and ~3× faster than tsx — while supporting the non-erasable syntax Node's stripper rejects:

hello.ts · hyperfine, 20 runs

nub hello.ts44 ms
node hello.ts45 ms
tsx hello.ts128 ms · 2.9× slower

TypeScript-first

Node's built-in type stripping erases annotations and rejects everything else — most real TypeScript codebases won't run on it. Nub transpiles each file through its native addon instead, so the enums, namespaces, parameter properties, and extensionless imports that Node doesn't allow all just work — with inline source maps, so stack traces and breakpoints point at your TypeScript source.

import { Model } from "./base"   // extensionless → ./base.ts

enum Status { Draft, Sent, Paid }

class Invoice extends Model {
  constructor(public status = Status.Draft) {} // parameter property
}

Respects tsconfig.json#paths

The most consistent frustration in Node + TypeScript: your editor, your bundler, and Node's ESM resolver disagree about what's a valid import. Nub reads tsconfig.json#paths — with baseUrl and full extends chains — and applies the aliases at runtime, so import { db } from "@/db" just works.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@db": ["src/db/index.ts"]
    }
  }
}

JSX

.jsx and .tsx files execute directly — no Vite plugin, no separate compile step. The JSX runtime is sourced the way the rest of your toolchain sources it: compilerOptions.jsx and jsxImportSource from your nearest tsconfig.json, with a /** @jsxImportSource preact */ pragma winning per-file. Defaults match oxc, Vite, and Bun: automatic runtime, react import source. And JSX is recognized in .jsx / .tsx only — never silently enabled in plain .js.

const view = <Hello name="world" />  // .tsx — no build step

Decorators

Legacy experimentalDecorators — the form NestJS, TypeORM, and Angular depend on, which V8 can't run natively — transpile out of the box. Switch the flag on in your tsconfig.json and decorated classes just work, emitDecoratorMetadata included. TC39 decorators (TypeScript 5.0's default) are on the way, gated on oxc shipping the transform — Nub follows your tsconfig.json either way.

// with "experimentalDecorators": true in your tsconfig.json
@logged
class API {
  @memoize
  async fetchUser(id: string) {
    // ...
  }
}

using declarations

Explicit resource management — using and await using — works on every supported Node: native where V8 has shipped it, transpiled where it hasn't.

await using db = await connect()  // disposed at scope end

YAML and TOML imports

Import a config file directly. No npm install yaml, no fs.readFile + parse plumbing — the load hook parses the file in Rust and hands you the value. Supported out of the box: .json (Node-native), .jsonc, .json5, .toml, .yaml / .yml, and .txt. These are extension-based loaders, not built-in modules — import { parse } from "yaml" still resolves to the npm package, and under --node the loaders are off entirely.

import config from "./config.yaml"   // parsed object
import flags  from "./feature.jsonc" // comments stripped
import pkg    from "./Cargo.toml"    // parsed object
import prompt from "./prompt.txt"    // string

import { host, port } from "./config.yaml" // named exports

.env loading

Nub loads .env, .env.local, .env.<NODE_ENV>, and .env.<NODE_ENV>.local automatically — Vite/Bun precedence rules, ${VAR} expansion included — and injects them before Node starts. One deliberate exception: .env.local is skipped under NODE_ENV=test, following the Next.js convention, so local secrets don't leak into your test suite.

# .env
APP=acme
DATABASE_URL=postgres://localhost/${APP}_dev

# No dotenv. No cross-env. No import "dotenv/config".
$ nub server.ts

nub node — a version manager you should never need

Nub resolves the right Node for every project implicitly — from .node-version, .nvmrc, or package.json#engines — every time it runs a file. If that version isn't installed, it's downloaded from nodejs.org (checksum-verified, cached under ~/.cache/nub) and your code runs on it. No pin? The node on your PATH is used, however you manage it. It's the uv experience, for Node.

$ echo 26 > .node-version
$ nub hello.ts
Using Node.js 26.3.0 (resolved from .node-version)
Installing from nodejs.org... (29 MB)
Installed in 9.8s
Hello world!

The explicit commands exist too — no shell hooks, no PATH munging:

nub node install 26     # install a version
nub node ls             # what's installed
nub node pin 26         # write .node-version
nub node uninstall 22   # remove a version

Modern APIs

The polyfilled rows work on every Node version Nub supports — feature-detected, native wins. The unflagged rows are exact per-version bands; each maps to a named entry in Nub's flag table. Node has been closing this gap itself. The table is a moving target by design: as your Node floor rises, the polyfills feature-detect and step aside. Same code, no migration.

Native — Node ships it; Nub steps aside entirely. Unflagged — Node implements it behind an --experimental-* flag; Nub injects the flag. Polyfilled — Nub preloads an established community polyfill until your Node floor reaches the native line.

APINative sinceOn older supported Nodes
Workerpolyfilled over node:worker_threads
TemporalNode 26polyfilled
URLPatternNode 24polyfilled
WebSocketNode 22.0unflagged on 20.10–21.x; absent below 20.10
EventSourceunflagged on 20.18+ and 22.3+; absent below
localStorageNode 25unflagged on 22.4–24, plus a workspace-scoped --localstorage-file on every version ≥22.4
sessionStorageNode 25unflagged on 22.4–24 — in-memory and per-process by design
vm.Moduleunflagged on the entire 18.19+ floor
node:sqliteNode 22.13unflagged on 22.5–22.12; absent below 22.5
RegExp.escapeNode 24polyfilled
Error.isErrorNode 24polyfilled
Promise.tryNode 24polyfilled
Float16ArrayNode 24polyfilled

Worker

Browser-shape worker constructor over node:worker_threads. Node has no native plan (nodejs/node#43583, open since 2022); Nub bridges EventTarget, MessageEvent, and ErrorEvent semantics in ~150 lines. The same Worker you'd use in a browser, in Bun, or in Deno.

const worker = new Worker(
  new URL("./worker.ts", import.meta.url),
  { type: "module" },
);
worker.addEventListener("message", (e) => console.log(e.data));
worker.postMessage({ task: "compute" });

Temporal

The TC39 date/time API. Native in Node 26+; older Nodes get the reference @js-temporal/polyfill by the proposal authors.

const now = Temporal.Now.plainDateTimeISO();
const tomorrow = now.add({ days: 1 });
const meeting = Temporal.ZonedDateTime.from("2026-06-01T09:00[America/New_York]");

URLPattern

WHATWG URL pattern matching. Native in Node 24+; polyfilled on older lines.

const route = new URLPattern({ pathname: "/users/:id" });
const match = route.exec("https://example.com/users/42");
match?.pathname.groups.id; // "42"

WebSocket

Browser-standard WebSocket client. On by default since Node 22.0; on 20.10–21.x Nub injects --experimental-websocket. Below 20.10 Node has no WebSocket client, and Nub doesn't fake one.

const ws = new WebSocket("wss://api.example.com/stream");

ws.addEventListener("message", (e) => console.log(e.data));
ws.addEventListener("open", () => ws.send("hello"));

EventSource

SSE client — handy for LLM streaming. Behind --experimental-eventsource on every Node that ships it (20.18+, 22.3+); Nub injects the flag.

const stream = new EventSource("https://api.example.com/llm/stream");
stream.addEventListener("message", (e) => process.stdout.write(e.data));

localStorage / sessionStorage

Native in Node 25+, flag-gated on 22.4–24 — and on every version, persistence silently fails unless you set --localstorage-file yourself. Nub injects the flag and defaults the storage file to ~/.cache/nub/webstorage/<workspace-hash>/localstorage.sqlite — scoped to your workspace, so monorepo members share one store and it survives across runs. Your own --localstorage-file, set anywhere, always wins. sessionStorage is in-memory and per-process, by design.

localStorage.setItem("auth.token", "abc123");
const token = localStorage.getItem("auth.token");

vm.Module

Evaluate ESM with a custom resolver. Behind --experimental-vm-modules since 2017 and still flagged; Jest and Vitest depend on it. Nub auto-injects.

import { SourceTextModule } from "node:vm";

const mod = new SourceTextModule(`export const answer = 42;`);
await mod.link(() => {});
await mod.evaluate();
mod.namespace.answer; // 42

node:sqlite

Embedded SQLite. On by default since Node 22.13 / 23.4; Nub injects --experimental-sqlite on 22.5–22.12 and 23.0–23.3. It doesn't exist below 22.5.

import { DatabaseSync } from "node:sqlite";

const db = new DatabaseSync("app.db");
db.exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)");
db.prepare("INSERT INTO users (name) VALUES (?)").run("Ada");

Stage 4 standard-library bridges

Finished TC39 methods — native on Node 24+, polyfilled on older lines, each one feature-detected and deferred the moment your floor rises:

  • RegExp.escape — escape text for literal use in a RegExp.
  • Error.isError — cross-realm Error detection, where instanceof lies.
  • Promise.try — start a chain from a sync-or-async function.
  • Float16Array — plus DataView.getFloat16 / setFloat16 and Math.f16round. One real caveat: the polyfill is ~3× slower than V8's hardware-FP16 path, so pin Node 24+ for ML-inference hot loops.

nub watch — built-in watch mode

Restart on change — driven by the actual dependency graph, not a glob list:

$ nub watch src/server.ts
Listening on http://localhost:3000

 src/db.ts changed restarting
Listening on http://localhost:3000

The watch set is the resolved dependency graph of your entry file, plus the files that invalidate the process without appearing in it: the tsconfig.json chain (with extends), your .env* files, and package.json. Build outputs and coverage artifacts never enter the watch set — there is nothing to configure.

The engine underneath is Node's own --watch. Nub's preload reports every file the load hook touches — including .ts files transpiled in memory, which Node never sees on disk — to Node's watcher over its dependency-report IPC channel. That report is what makes --watch work for TypeScript at all.

nub run — an 18× faster pnpm run

A drop-in replacement for npm run and pnpm run, minus the ~150 ms Node bootstrap they pay on every invocation:

$ nub run dev           # any package.json script
$ nub run test --watch  # trailing flags pass through — no -- separator
$ nub -r run build      # every workspace package, topo-ordered

Performance

pnpm and npm are themselves Node.js programs: every pnpm run <script> boots a Node process, loads the package manager, parses its config, walks the workspace, and finally shells out to your script. That's ~150–200 ms of overhead before your script's first byte runs — and in a 30-package monorepo, pnpm -r run build pays it 30 times.

Script-runner overhead — a no-op script, so the figure is pure wrapper cost:

noop script · hyperfine, 20 runs

nub run noop9 ms
npm run noop104 ms · 11× slower
pnpm run noop161 ms · 18× slower

Bin runnernubx resolves node_modules/.bin in Rust and execs the binary directly; pnpm exec and npx boot a full Node first:

esbuild --version · hyperfine, 20 runs

nubx esbuild --version11 ms
pnpm exec esbuild --version191 ms · 17× slower
npx esbuild --version226 ms · 20× slower

The benchmarked command is identical on every row — the delta is pure wrapper overhead. Methodology: hyperfine --warmup 3 --runs 20, Apple M1 Max, Node 24.14.0 — the numbers are machine-specific, the ratios are the portable claim. Reproduce with benchmarks/.

Workspace-aware

Nub reads workspace topology from package.json#workspaces (npm, yarn) and pnpm-workspace.yaml (pnpm), and lifts pnpm's --filter selector grammar verbatim — the most expressive of any package manager. The rest of the workspace flag surface comes along with it, npm spellings accepted as aliases, and packages always run in dependency order:

nub -r run build                  # all packages, topo-ordered
nub --filter @org/api dev         # one package by name
nub --filter "./packages/*" test  # glob match
nub --filter ...@org/web build    # @org/web and its dependencies
nub --filter "[main]" test        # changed since main
nub -r --no-bail test             # keep going past failures
nub -r --workspace-concurrency 4 build   # cap parallel jobs

Drop-in compatible

Everything your scripts depend on still works: the npm_* env vars, pre / post lifecycle hooks, and pnpm-style node_modules/.bin resolution in monorepos. .npmrc#script-shell is honored.

Node 22 shipped its own script runner, node --run — roughly 6× faster than npm run per Node's own benchmarks, but deliberately stripped down (no lifecycle hooks, no npm_* env vars, no workspaces) in ways that break most real projects.

Subprocesses inherit augmentation

When nub run executes a script, nub is aliased as node for the duration — so spawned subprocesses (say, a CLI with a node shebang) get the augmentation too. --node turns this off:

nub run --node test

nubx — a 20× faster npx

Run any CLI your dependencies ship into node_modules/.bin, no matter which package manager put it there:

nubx eslint .
nubx prisma generate
nubx tsc --noEmit

The .bin walk-up happens in Rust and the binary is exec'd directly — no Node bootstrap in the wrapper. The binary being executed is identical on both sides; the entire delta is the wrapper overhead in the benchmarks above.

nub install — a built-in package manager

Nub ships a full package manager, powered by the embedded aube engine — built by jdx, the creator of mise. The CLI surface is pnpm's; the lockfile is whatever your project already uses:

$ nub install   # pnpm-lock.yaml → read, written back
$ nub install   # package-lock.json → read, written back
$ nub install   # bun.lock → read, written back
$ nub install   # yarn.lock → honored read-only

There's no migration. nub install detects the lockfile your project already has — npm, pnpm, or bun — and writes the same format back, never a foreign file (yarn.lock is read-only: an install that would rewrite it is refused, with the exact yarn command to run instead). Fresh projects get a standard pnpm-lock.yaml. And your package manager keeps working, side by side: run either tool, commit the same file, switch back any time.

What aube brings:

  • Speed — warm installs run ~6× faster than pnpm and ~7× faster than Bun on aube's benchmarks.
  • The tightest security defaults of any Node package manager — lifecycle scripts wait for approval (with an opt-in build jail — the only one in the ecosystem), exotic transitive deps are blocked, and brand-new releases sit in a 24-hour cooling window.
  • Less disk — a global content-addressable store shares package files across projects instead of duplicating them per checkout.

The install layout follows the lockfile too. pnpm projects and fresh ones get strict, symlinked, pnpm-style installs, with the virtual store tucked under node_modules/.nub; projects with an npm, yarn, or bun lockfile default to the flat hoisted layout those tools produce, so tooling that walks node_modules sees the layout it expects. One .npmrc line (node-linker) overrides either default.

$ nub install
node_modules/express .nub/express@5.1.0/…
$ nub install --node-linker=hoisted   # or one .npmrc line

Configuration comes from the files you already maintain — .npmrc (registry, auth, flags), pnpm-workspace.yaml, package.json#workspaces — and Nub's own defaults rank below every user source. No new config file, no "nub" field in package.json.

nub pm

Sometimes you want the original tool itself. nub pm use declares and provisions the project's package manager — corepack's job, without the PATH shims. It resolves the version, fetches and verifies it from the npm registry, writes packageManager (with a +sha512 hash computed from the verified tarball), and aligns the lockfile — converting formats when you switch managers.

$ nub pm use pnpm@^9
using pnpm@9.15.9
  package.json: packageManager = pnpm@9.15.9 (+sha512)
  pnpm-lock.yaml: kept (already pnpm's format)

Zero Nub-specific APIs, zero lock-in

There are no Nub-specific APIs.

  • No Nub global
  • No nub:* module namespace
  • No @nub/* npm scope
  • No NUB_* environment variables
  • No "nub" field in package.json
  • No nub-named config files

Everything Nub ships is a web platform standard (WHATWG / W3C / WinterTC), a TC39 proposal, an unflagged Node.js feature, or a pragmatic affordance for ecosystem compatibility (usually TypeScript). Remove Nub tomorrow and your code keeps working, unchanged.

FAQ

Why hasn't anyone else done this?

Because the extension surface that makes it possible is new. Everything Nub does rides on five public Node mechanisms:

  • module.registerHooks() (Node 22.15, April 2025) — synchronous loader hooks. The entire TS / JSX / YAML / TOML path is one registered hook.
  • module.register() (Node 20.6, 2023) — programmatic loader registration, used on the legacy lines.
  • --import / --require preloads — code that runs before your main, used to install the polyfills.
  • NODE_OPTIONS / V8 flag injection — unflags the --experimental-* features Node already implements.
  • N-API addons (ABI-stable since 2017) — the oxc transpiler, called from inside the load hook.

That's the whole kit. No patched Node binary, no vendored libnode, no separate runtime to discover. Deno (2018) and Bun (2022) predate most of this surface — back then, shipping integrated TypeScript and modern defaults meant writing a runtime around them. Today the same layer composes on top of Node, with the entire compatibility surface preserved for free. The build-vs.-extend tradeoff has tipped.

Is it faster than Node.js?

No. Your code runs in Node itself — once it's executing, V8 is V8. What's dramatically faster is the CLI surface: nub run and nubx beat pnpm run and npx by an order of magnitude on cold start, because those tools pay a full Node bootstrap to do what amounts to a script lookup, and Nub — a native Rust binary — doesn't.

Transpile output lands in a content-addressed on-disk cache keyed on (source content, Nub version, resolved tsconfig). The first run transpiles; every run after pays roughly nothing, and entries dedupe across projects with identical files.

What can I drop from my project?

Most of the toolchain around your code — including the pieces that were never npm packages to begin with:

  • tsx / ts-node → TypeScript runs natively
  • dotenv / cross-env.env* loading is built in
  • nodemonnub watch
  • tsconfig-pathspaths resolution is built in
  • npxnubx
  • nvm / fnm → the pin file alone; the right Node installs itself
  • corepacknub pm
  • npm / pnpm / bun as CLIs → nub install and nub run, against the lockfile you already have

And running on a modern Node floor lets you drop what Node itself obsoleted: node-fetch, abort-controller, uuid (the v4 case), rimraf, mkdirp, glob (basic usage), form-data, and the ws client. That credit is Node's — Nub just makes the modern floor practical.

Why not just use Node's built-in TypeScript support?

Node 22.18+ runs .ts files by default — but it strips types, it doesn't transpile. Enums error. namespace errors. Parameter properties error. JSX doesn't work. Decorator metadata doesn't work. Node 26 even removed --experimental-transform-types, the flag that used to handle enums — the Node TSC's posture is that full transpilation is userland's job.

Node also deliberately ignores tsconfig.json: no paths, no baseUrl, no jsx, no experimentalDecorators, no extends chains — the Node 26 docs are explicit that tsconfig-dependent features are "intentionally unsupported." Extensionless .ts imports don't resolve, and no source maps are generated.

If your codebase is "erasableSyntaxOnly": true clean, Node's built-in support is fine. If it isn't, you need a real transpile pipeline — which is what tsx, Bun, Deno, and Nub provide, and Node deliberately doesn't.

Plenty more answered on the FAQ page:


A note on Node

None of this is a criticism of Node.js. Node is an exceptionally well-managed project, and its caution about breaking changes is the right caution — the whole ecosystem depends on Node staying predictable. That same caution means Node can't ship opinionated modern defaults. The extension surface it has shipped over the last three years means it no longer has to. Nub is the layer on top.


Nub is open source. Install: npm install -g @nubjs/nub. Source: github.com/nubjs/nub.