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
Nub is a drop-in for node: same argv shape, same flag set, and every flag reaches Node verbatim. 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 the Node your project pins and fetches it when missing.
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, 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. The tier boundary differs 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 |
Why a fast-tier Node is recommended
The compatibility tier routes module loading through an async module.register() loader-worker: about 1.4× slower cold start than the fast tier — a fixed ~80 ms plus ~90 µs per module — with no effect on execution speed. The fast tier (22.15+, sync module.registerHooks()) has none of it.
Augmentations
- TypeScript — every TypeScript feature 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, including emitted design-type metadata for 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. - Varlock — a project with an
@env-specschema hands its environment to Varlock instead, with no wrapper command and no import in your source. - 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) are present, 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. - Threadpool — libuv's threadpool is sized to the cores available to the process instead of Node's fixed four, with the extra threads at a lower priority on Linux.
Memory-constrained launches
On Linux x64, direct Node launches use a 16 MiB V8 semi-space within the following cgroup memory budgets. This includes file runs and Node-backed nub exec/nubx binaries.
| Node release | Memory budget, inclusive |
|---|---|
| 22.23.2 | 512 MiB–1 GiB |
| 24.20.0 | 512 MiB |
The ranges are benchmarked, not a universal larger-heap policy. Node 24.20.0 already chooses 16 MiB immediately above 512 MiB. Larger Node 22 budgets and Node 26 keep stock defaults because the larger nursery regressed production-mode SSR, despite improving some allocation-heavy fixtures. Budgets below 512 MiB are excluded because smaller-container pressure tests found additional OOM kills. Other releases and platforms also keep Node's defaults.
Nub checks both the leaf cgroup limit that Node uses for sizing and tighter ancestor limits. A tighter ancestor cannot enable tuning for an oversized or unlimited leaf, and any effective budget below 512 MiB disables it. This changes the main heap only; Workers retain their own defaults and explicit resourceLimits.
Nub skips this tuning when Node options or preloads are supplied through argv, NODE_OPTIONS, or project configuration, or when PnP, a command prefix, or an external environment loader owns startup. Unknown memory limits, watch mode, compiled executables, and inherited augmented processes keep their existing behavior. Application arguments after the entry path do not disable tuning.
The larger nursery can reduce garbage collection for allocation-heavy workloads, but it is not a process memory cap. Workers, native allocations, and other processes can share the same container budget. Use explicit V8 flags for an application-specific budget, or compatibility mode for unchanged Node defaults.
Modern APIs
Modern globals — TC39, web-platform, and newer Node built-ins — are available under Nub. Native implementations win; Nub fills a missing API or enables a version's experimental Node feature only where needed.
const now = Temporal.Now.plainDateTimeISO();
const route = new URLPattern({ pathname: "/u/:id" });
const { me, feed } = await Promise.allKeyed({
me: getMe(),
feed: getFeed(),
});See Modern APIs for the complete version-aware reference and examples.
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)nodeExecutableinnub.jsonc— a path, or a command that prints onepackage.json#/devEngines/runtime.node-version.nvmrc.tool-versions— the asdf/mise file; itsnodejsornodelinepackage.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.8.2 (resolved from .node-version)
Installed in 9.8s
Hello world!A few details of the walk:
- Discovery walks up the directory tree to the nearest pin, and skips pin files 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 is honored) → 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 — 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
# everything after the file is your script's argv
nub script.ts --port 3000 --verbose
echo 'console.log(1 + 1)' | nub -Debugging in VS Code
Point VS Code's debugger at the nub binary to run a program through Nub with breakpoints and stepping. See Debugging.
Project configuration
Settings you want on every run go in a nub.jsonc at the project root rather than on the command line.
{
"preload": ["./instrumentation.ts", "tsx/esm"], // before the entry
"nodeOptions": ["--enable-source-maps"], // on every run
"v8Flags": ["--expose-gc"] // on Node's argv
}A preload entry is either a path, resolved from the directory holding the file that supplied it, or a bare package specifier resolved the way an import in your project would be. V8 flags reach Node on its command line rather than through NODE_OPTIONS, so the full V8 set works — including the flags NODE_OPTIONS rejects, such as --stack-size.
A command-line flag always wins over the file. See nub.jsonc for the full precedence order.
Compatibility mode
Compatibility mode turns every augmentation off — the load hook, preloads, unflagging, and .env loading — and runs your code on plain Node. It still runs the project's pinned Node; version provisioning stays on. (This is distinct from the compatibility tier in the table above, the startup path for Node lines without sync registerHooks, which only changes how augmentation is delivered.)
--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).
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) has the same effect as --node and is inherited by every descendant node / nub in the tree:
export NODE_COMPAT=1 # every nub/node in this shell now runs vanilla
nub script.ts # plain Node, still on the pinned versionnodeCompat
Setting nodeCompat in a nub.jsonc applies compatibility mode to every run in the project.
{
// ...
"nodeCompat": true // compatibility mode for every run
}None of the other nub.jsonc runtime fields apply once it is set.
How it works
Nub 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.
Read the full docs for module customization hooks on nodejs.org.
Introduction
Nub is an all-in-one Rust toolkit for Node.js. Run TypeScript files and scripts, install dependencies, and manage Node itself — all on stock Node, with no lock-in.
TypeScript
How Nub runs every TypeScript feature on stock Node — non-erasable syntax, resource-management downleveling, and source maps — none of which plain Node does.