Runtime
What Nub adds when it runs your code on stock Node — TypeScript and JSX with no build step, editor-style resolution, automatic .env loading, data-file imports, and modern globals — plus the plain-Node escape hatch that turns it all off.
Run a TypeScript file directly — no tsconfig, no build step, no ts-node:
nub index.ts
nub server.tsx
nub script.jsOn top of stock Node, nub <file> adds a modern runtime:
- 🦆 Full TypeScript — non-erasable syntax (
enum,namespace, parameter properties),emitDecoratorMetadatadecorators - ⚛️ JSX / TSX — the automatic runtime by default
- 🧭 Editor-style resolution — extensionless imports,
.js → .tsrewriting,tsconfig.json#paths - 🆕 Modern syntax like
using— transpiler-downleveled on older Node - 🔐 Automatic
.env*loading — Next.js / Vite parity, with${VAR}expansion - 🗂️ Data-file imports —
.yaml,.toml,.jsonc,.json5,.txtas parsed default exports - 🌐 Modern globals backfilled per Node version —
Temporal,URLPattern,WebSocket,EventSource,node:sqlite, and more - 🧵 Source maps in stack traces, automatically
- ⚡ ~2.9× faster startup than
tsx
And it's a true drop-in for node: same argv shape, same flag set, same behavior — every flag reaches Node verbatim. No vendor-specific API surface, no lock-in. Pass --node (or set NODE_COMPAT=1) to turn every augmentation off and run on plain Node.
Supported Node versions
Nub runs your code on stock Node — it resolves which Node your project pins and fetches one if it's missing, but never ships a runtime of its own. The supported floor is Node 18.19; below that Nub refuses to run. At or above the floor, Nub picks one of two delivery tiers — fast or compatibility — by whether your Node has sync module.registerHooks(). The two are functionally equivalent; they differ only in startup overhead. See the per-line table below.
Supported versions and tiers
The hard floor is Node 18.19 — below it no loader-hook API can carry Nub's augmentations, so Nub refuses to run. Above the floor, augmentation is delivered through one of two tiers:
- Fast tier — sync
module.registerHooks(), run in-thread via a--requireCJS preload. No loader-worker, lower startup overhead. - Compatibility tier — async
module.register(), run in a loader-worker thread via an--importESM preload. Same augmentations, a little more startup cost.
Nub selects the tier from your Node version. The fast tier needs sync module.registerHooks(), which Node added in v23.5.0 and backported to v22.15.0 — and never backported to the 20.x line. So the tier boundary lands differently per Node major:
| Node line | Minimum Nub supports | Fast-tier floor | Tier | Recommended |
|---|---|---|---|---|
| 18.x | 18.19 | — | Compatibility (no registerHooks) | |
| 20.x | all 20.x | — | Compatibility (registerHooks never backported to 20) | |
| 22.x | all 22.x | 22.15 | Fast at 22.15+, compatibility on 22.0–22.14 | ✓ at 22.15+ |
| 23.x | all 23.x | 23.5 | Fast at 23.5+, compatibility on 23.0–23.4 | ✓ at 23.5+ |
| 24.x | all 24.x | 24.0 | Fast (whole line) | ✓ |
| 25.x | all 25.x | 25.0 | Fast (whole line) | ✓ |
| 26.x | all 26.x | 26.0 | Fast (whole line) | ✓ default |
A dash in the fast-tier column means that line has no fast-tier build — every patch runs in the compatibility tier. Both tiers are fully supported; nothing about your code changes between them. A ✓ marks the fast-tier versions, recommended for the lowest startup overhead — the latest (Node 26) is the default.
Why a fast-tier Node is recommended
The compatibility tier routes module loading through an async module.register() loader-worker, which costs about 1.4× slower cold start than the fast tier — a fixed ~80 ms startup overhead plus ~90 µs per module. There's zero runtime cost: execution speed is identical, and the penalty is module loading only. The fast tier (22.15+, sync module.registerHooks()) has none of it.
Augmentations
In brief:
- TypeScript — the whole TypeScript surface runs directly, including non-erasable syntax (
enum,namespace, parameter properties). Types are stripped, not checked. - JSX —
.jsxand.tsxfiles run with the modern automatic runtime by default, configured throughtsconfig.jsonor a per-file@jsxImportSourcepragma. - Decorators — legacy decorators run with no build step under
experimentalDecorators, includingemitDecoratorMetadatafor the DI / ORM ecosystem. - Resolution — extensionless imports,
.js → .tsrewriting, andtsconfig.jsonpath aliases resolve at runtime, the waytscand your editor resolve them. - Environment variables —
.env*files load into the environment before Node starts, with${VAR}expansion. Nodotenv, no--env-file. - Loaders — import
.yaml,.toml,.jsonc,.json5, and.txtfiles directly as parsed values, with no npm parser. - Modern APIs — modern globals and built-ins (Temporal, URLPattern, WebSocket, EventSource, node:sqlite, and more) work out of the box, polyfilled or unflagged per Node-version band.
- Workers — the browser-shape
Workerglobal runs your.tsentry points, wrappingnode:worker_threads. - Web Storage —
localStorage/sessionStoragebehavior and the opt-in that backs persistent storage.
Modern APIs
Modern globals — TC39, web-platform, and newer Node built-ins — work out of the box under Nub. Whatever Node version you run, the API is there — native where Node ships it, and where Node doesn't, Nub fills the gap automatically. You write against the API; the version split is Nub's problem, not yours.
const now = Temporal.Now.plainDateTimeISO();
const route = new URLPattern({ pathname: "/u/:id" });
const ws = new WebSocket("wss://api.example.com");
const stream = new EventSource("https://api/stream");
const { DatabaseSync } = require("node:sqlite");Two mechanisms do the work, picked per API: Nub either preloads a JS polyfill (feature-detected, so it steps aside the moment the native global appears) or injects the --experimental-* flag that an older Node hides the API behind. Once Node stabilizes an API, it's native and Nub does nothing.
The minimum version column is the lowest Node where the API works under Nub.
| API | Minimum version | How |
|---|---|---|
Temporal | — | polyfilled below Node 26, native above |
URLPattern | — | polyfilled below Node 24, native above |
RegExp.escape | — | polyfilled below Node 24, native above |
Error.isError | — | polyfilled below Node 24, native above |
Promise.try | — | polyfilled below Node 24, native above |
Float16Array | — | polyfilled below Node 24, native above |
navigator | — | backfilled below Node 21, native above |
navigator.locks | — | polyfilled below Node 24.5, native above |
reportError | — | polyfilled |
vm.Module | — | unflagged |
ShadowRealm | — | unflagged |
Wasm module imports | — | unflagged below Node 24.5 (22.19 on the 22.x line), native above |
WebSocket | Node 20.10 | unflagged below Node 22, native above |
EventSource | Node 20.18 | unflagged below the native line, native above |
node:sqlite | Node 22.5 | unflagged below Node 22.13, native above |
addon imports | Node 22.20 | unflagged, never native |
A dash means the API works across the full supported range, 18.19 and up. The floored rows have a real cutoff, because the underlying Node mechanism doesn't exist below it:
- The
WebSocketglobal needs Node 20.10 — the flag Nub injects below the native line (Node 22) doesn't exist on older 20.x patches. - The
EventSourceglobal needs Node 20.18, the patch where Node added the flag on the 20 LTS line. - The
node:sqlitemodule needs Node 22.5, where Node first shipped it. - Importing a native
.nodeaddon from an ES module needs Node 22.20 (or 23.6 on the 23 line), where Node added the flag Nub injects. It is never native — Node keeps the import behind the flag — so Nub injects it on every version that has it.
Node version resolution
Like Corepack's shims, nub itself auto-installs Node versions as needed. When you run a file with nub, it infers the version of Node your project expects and provisions it — downloading and caching the matching stock build if it isn't already on your machine. The inference walks up from the working directory to the nearest pin, taking the first source that yields a version. Highest precedence first:
NODE_EXECUTABLE— an explicit path to a Node binary (a hard override)package.json→devEngines.runtime.node-version.nvmrcpackage.json→engines.node(a range)- the
nodeon yourPATH, when nothing is pinned
This resolved Node version is auto-installed and cached for future runs.
$ echo 26 > .node-version
$ nub hello.ts
Using Node.js 26.4.0 (resolved from .node-version)
Installed in 9.8s
Hello world!A few precision details the common case never makes you think about:
- Discovery walks up the directory tree to the nearest pin, and skips pin files that live inside an installed dependency (under
node_modules) — a dependency's own CI pin never drives your project. - The
package.jsonfields (devEngines.runtime,engines.node) are read from the workspace root manifest when one exists above you — a monorepo pins its Node once at the root. - Once a version is pinned, Nub finds a binary for it in order: the
nodeonPATH(sofnm/Volta/miseauto-switching just works) → Nub's own download store (~/.cache/nub/node/<version>/) → annvm-installed version → download the matching stock build from nodejs.org, SHA-256 verified and cached.
For pinning, pre-installing, and the nub node subcommands, see Managing Node versions.
A drop-in for node
Anything node <args> accepts, nub <args> accepts too. Pass-through is the default for the entire flag space — every flag reaches Node verbatim:
# diagnostics
--prof --cpu-prof --report-*
# module resolution
--conditions --preserve-symlinks
# inspector
--inspect --inspect-brk
# warnings
--no-warnings --trace-deprecationReading a program from stdin works the same way node - does.
nub --max-old-space-size=4096 build.ts
nub --import ./instrument.js server.ts
nub script.ts --port 3000 --verbose # everything after the file is your script's argv
echo 'console.log(1 + 1)' | nub -Compatibility mode
Nub augments by default. Compatibility mode turns every augmentation off — no load hook, no preload, no unflagging, no .env loading — and runs your code on plain Node, exactly as node <file> would. It still runs the project's pinned Node (version provisioning stays on), so it's the additivity escape hatch and the differential-debugging tool: it makes the "would a plain-Node user get the same result?" test real. (This is distinct from the compatibility tier — the startup path for the Node lines without sync registerHooks, in the table above — which is about how augmentation is delivered, not whether it runs.)
There are two spellings, a per-invocation flag and a tree-wide env var, and they compose — either one forces compatibility mode.
--node
Pass --node to run a single invocation with zero augmentation. Your code runs on the project's pinned Node (from .node-version / .nvmrc, fetched if missing), vanilla — which makes it the tool for differential debugging: does this reproduce on plain Node, on the exact version this project pins? A bare node script.js can't answer that, because it runs your shell's Node, not the project's.
nub --node script.js # the project's pinned Node, vanilla
node script.js # your shell's Node, unaugmented, unprovisionedNODE_COMPAT
Setting a truthy NODE_COMPAT (1, true, or yes, case-insensitive) is the project/tree-wide form of --node. It has the identical effect — zero augmentation, Node-version provisioning still on — but it's persistent and inherited by every descendant node / nub in the tree, so you don't repeat --node per invocation:
export NODE_COMPAT=1 # every nub/node in this shell now runs vanilla
nub script.ts # plain Node, still on the project's pinned versionHow it works
Every augmentation rides on Node's own extension surfaces — Nub patches nothing and ships no runtime of its own. It registers a module.registerHooks() load hook (an async module.register() loader-worker on the compatibility tier) for transpilation and resolution, and injects a small --import preload for the globals and .env loading. On the fast tier, transpilation runs through an embedded oxc N-API addon. The test for any augmentation is whether a user on plain Node, plus the corresponding module.register() / --import / npm addon, would get the same result — which is exactly what --node and NODE_COMPAT turn back off.
Introduction
Nub is an all-in-one Rust toolkit for Node.js. Run TypeScript files and scripts, install dependencies, and manage Node itself — on the Node you already have, with no lock-in.
TypeScript
How Nub runs the full TypeScript surface on stock Node — non-erasable syntax, resource-management downleveling, and source maps — none of which plain Node does.