Not every project can install a binary, and not every invocation of node is yours to change. The standalone runner puts Nub's TypeScript runtime on plain Node as an ordinary npm package — about 5 MB, with no package manager, no registry client and no network code in it.

npm install --save-dev @nubjs/runner
nubr app.ts

Running scripts

The command also runs a script from your package.json, so a project can adopt the runner without changing how anyone invokes it:

{
  "scripts": {
    "dev": "nubr src/index.ts",
    "build": "nubr build.ts --minify"
  }
}
nubr dev              # runs the "dev" script
nubr build -- --watch # extra arguments reach the script

Scripts run through the same shell npm uses, with node_modules/.bin on the path and pre/post hooks honored, and every Node process a script starts inherits the TypeScript support.

Running installed bins

A name that is neither a file nor a script resolves against node_modules/.bin, so an installed tool runs with TypeScript support and no script to wrap it:

nubr vitest run
nubr tsc --noEmit
nubr prettier --write .

The bin gets the same environment a script does. Nothing is fetched from the registry — the tool has to be installed already.

Names resolve most-specific-first: a file, then a script, then a bin. A script wins over a bin of the same name, matching npm, because a script usually wraps the bin it is named after.

As a Node preload

Some invocations of node are fixed: a test runner, a framework CLI, a deploy script that shells out directly. Register the package as a preload and those get TypeScript too:

node --import @nubjs/runner app.ts            # one run
NODE_OPTIONS="--import @nubjs/runner" vitest  # a tool that spawns node itself
node --require @nubjs/runner app.ts           # CommonJS delivery
{
  "scripts": {
    "test": "mocha --import @nubjs/runner 'test/**/*.test.ts'"
  }
}

Features

The package registers the resolve and transpile hooks the nub command uses, without its polyfills, globals, or .env loading:

  • Full TypeScript — enum, namespace, parameter properties, emitDecoratorMetadata decorators — not just the erasable subset Node's built-in stripping accepts
  • JSX / TSX with the automatic runtime
  • Editor-style resolution — extensionless imports, .js → .ts rewriting, tsconfig.json#paths
  • Data-file imports — .yaml, .toml, .json5, .jsonc, .txt, and with { type: "text" }
  • Syntax newer than the running Node, such as using, downleveled by the transpiler
  • Inline source maps on every transpiled file

Both module systems are covered: import and require() of a .ts file resolve and transpile the same way, and worker threads pick the hooks up automatically because Node passes --import down to them.

Dependencies under node_modules are never transpiled, and files Node handles natively load byte-for-byte unchanged.

Entry points

node --import @nubjs/runner app.ts        # ESM hooks + CommonJS require() augmentation
node --require @nubjs/runner app.ts       # same, delivered as a CommonJS preload (Node 20.19+)
node --import @nubjs/runner/esm app.ts    # ESM hooks only

Module formats follow Node's own rules: a .cts file is CommonJS and a .mts file is an ES module. The package transpiles types and syntax; it does not convert one format into the other, so a .cts file uses module.exports, as it would under plain Node.

Node flags

Flags that Node reads at startup go before the file, and the runner passes them through:

nubr --inspect app.ts
nubr --max-old-space-size=4096 app.ts

Those apply to a file run. To pass Node flags to a script, put them in the script's own command line.

Node support

Node 18.19 and newer. On Node 22.15+ the hooks register synchronously in-thread through module.registerHooks(); on older versions they run in Node's loader worker through module.register(). The --require delivery needs require(esm), so below Node 20.19 / 22.12 use --import.

Platform binaries ship as optionalDependencies (@nubjs/runner-*) for macOS, Linux (glibc and musl), and Windows, on x64 and arm64.

Deploying it

The small footprint is the point in a container. Fetch the platform you deploy to, not the one you build on, and pick a Node major that distroless publishes — it carries its own set, which trails the newest Node release:

FROM node:24-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --os=linux --cpu=x64 --libc=glibc

FROM gcr.io/distroless/nodejs24-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY . .
CMD ["--import", "@nubjs/runner", "server.ts"]

The runner and the nub command

Running nub app.ts gives you everything on this page plus the rest of the runtime — polyfilled web APIs, .env loading, Node version provisioning — with no flags. Use the standalone runner when you cannot install the binary or the node invocation is fixed, and nub everywhere else. The two share one transpiler and one resolver, so a file behaves the same under either.