Skip to main content

nodejs

Write and review Node.js: the module system, package manifest, streams, event loop, process and worker lifecycle, the permission model, native TypeScript execution, `node:test`, and the runtime version `engines.node` permits.

Source facts

Repository
xobotyi/cc-foundry
Last source activity
October 13, 2026 at 09:31
Detected SKILL.md language
English
Stars
22
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
17 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
nodejs
description
Write and review Node.js: the module system, package manifest, streams, event loop, process and worker lifecycle, the permission model, native TypeScript execution, `node:test`, and the runtime version `engines.node` permits.
when_to_use
Invoke whenever Node.js runtime code is touched at all — writing, reviewing, refactoring, or debugging a server, a CLI, a script, or a `package.json`, and whenever a project's Node floor is raised. Also invoke on the symptoms: `ERR_REQUIRE_ESM`, `ERR_REQUIRE_ASYNC_MODULE`, or `ERR_MODULE_NOT_FOUND`; an import that resolves in the bundler and not in Node; a `.ts` file Node refuses to run; a process that hangs at exit or dies with no stack; a stream that buffers without bound; a leaked HTTP connection; `ERR_ACCESS_DENIED`; a `node:test` mock or fake timer that does not take effect; or a flag that works on one Node major and not the next. Covers the Node runtime and `node:test`; JavaScript semantics belong to the javascript skill, types to typescript, the Bun runtime to bun, and Vitest to vitest.
compatibility
Uses Claude Code frontmatter beyond the Agent Skills spec (when_to_use)
One thread runs every callback in a Node process, so anything that holds it holds the whole program. Three biases decide most calls: - **The `engines.node` floor decides what may be written**, not the `node` binary on the machine. - **Read an API's stability index before depending on it.** Node ships an API for years before committing to it, and an experimental one can be deleted without a deprecation cycle. - **Check the standard library before adding a dependency.** It absorbed `fetch`, a test runner, a file watcher, `glob`, SQLite, `.env` parsing, a script runner, and TypeScript execution. ## Node Version `engines.node` in `package.json` is the floor a project claims. Read it before writing code and never reach for an API above it. Nothing enforces it at run time — Node never reads the field, and npm checks it only under `engine-strict=true` — so the floor holds by discipline and by CI running the oldest major it names. **The installed binary is not the floor.** `node --version` reports what one machine has, not what the project supports. Support windows, which decide whether a floor is defensible: - **20** — end of life 2026-04-30. A floor of `>=20` claims a runtime that receives no fixes at all. - **22** — maintenance from 2025-10-21, end of life 2027-04-30. Security and critical fixes only; no new capability. - **24** — Active LTS from 2025-10-28, maintenance from 2026-10-20, end of life 2028-04-30. - **26** — released 2026-05-05, Active LTS from 2026-10-28, end of life 2029-04-30. Floor version per feature — the APIs and behavior changes that decide what may be written. - **20.19.0** — `require(esm)` unflagged on the 20 line; syntax detection on by default - **22.0.0** — `node --run`; `fs.glob`; `WebSocket` global; watch mode Stable; `assert { type: 'json' }` removed - **22.12.0** — `require(esm)` unflagged on the 22 line; JSON modules Stable - **22.13.0** — Permission Model Stable and `--permission` accepted; `node:sqlite` unflagged - **22.15.0** — `module.registerHooks()` - **22.16.0** — `import.meta.dirname` and `import.meta.filename` Stable; `--experimental-config-file` - **22.18.0** — type stripping on by default; `import.meta.main` - **22.21.0** — `--env-file` Stable; `NODE_USE_ENV_PROXY` - **24.0.0** — `--experimental-permission` renamed `--permission`; `AsyncLocalStorage` on `AsyncContextFrame`; `URLPattern` global; test runner awaits subtests; `url.parse()` runtime-deprecated (DEP0169); `args` with `{ shell: true }` runtime-deprecated (DEP0190) - **24.12.0** — type stripping Stable - **24.20.0** — `--permission-audit` - **26.0.0** — `Temporal` global; `module.register()` runtime-deprecated (DEP0205); `--experimental-transform-types` removed; `node:_stream_*` removed. It also inherits what 25 added and neither 22 nor 24 received: `--allow-net`, and with it network confinement under `--permission` - **26.3.0** — `permission.drop()` **No odd major is a defensible floor.** Node 21, 23, and 25 are end of life — 25 since 2026-06-01 — so a feature that arrived on one is reachable only from the LTS major above it. Read [`${CLAUDE_SKILL_DIR}/references/versions/node-NN.md`] — one file per LTS major: `node-20.md`, `node-22.md`, `node-24.md`, `node-26.md` — when writing against a feature near the floor, and whenever raising the floor. Each carries what its major added, the stability each addition holds, the behavior it changed, and the traps it introduced. ## Stability Index Every documented API carries one, and it decides how the API may be used, not merely how finished it is. - **2 - Stable** — protected by semver. Safe in a published library's surface. - **1 - Experimental** — outside semver, and the level most unfinished APIs print. Three subdivisions refine it: **1.2 - Release candidate**, where no further breaking change is anticipated but none is ruled out; **1.1 - Active development**, nearing minimum viability; **1.0 - Early development**, unfinished and expecting substantial change. - **3 - Legacy** — semver-protected and unmaintained. Bugs in it are not fixed. Migrate rather than report. - **0 - Deprecated** — carries a DEP number and a migration. What follows from the index: - **An experimental API can be removed with no deprecation cycle.** `--experimental-transform-types` shipped in 22.7.0 and was deleted in 26.0.0, taking the runtime path for TypeScript enums with it. - **Never put an experimental API in a published library's public surface.** A consumer cannot see that the risk was taken on their behalf. - **The index moves per major.** `TracingChannel` is Stable from 26.8.0 and experimental on 24; `module.register()` is supported on 24 and runtime-deprecated on 26. State the index for the floor, not for the machine. - **Check the index rather than the flag.** Type stripping needs no flag from 22.18.0 and was not Stable until 24.12.0; an unflagged feature is not a committed one. ## Module System - **Declare `"type"` in every `package.json`.** Without it, a `.js` or extensionless file is parsed as CommonJS and re-parsed as an ES module when that fails. The retry costs startup time on every ES module and turns a real syntax error into a confusing module-system error. - **`.mjs` and `.cjs` override `"type"`; nothing overrides them.** Use them to place one file of the other kind inside a package, never as the default. - **Node's resolver performs no extension search and no directory-index lookup.** It loads the specifier as written, so `./startup` resolves in a bundler and throws `ERR_MODULE_NOT_FOUND` here. - **Import a builtin through `node:`** — `import fs from 'node:fs/promises'`. The prefix cannot be shadowed by a package of the same name, and `node:test` and `node:sqlite` resolve under no other specifier. - **`import.meta.dirname` and `import.meta.filename` replace `__dirname` and `__filename`**, and exist only on `file:` modules. - **The `assert` import-attribute form was removed in 22.0.0 and throws.** Only `with` parses. - **`require()` of an ES module works** from 20.19.0 and 22.12.0, and throws `ERR_REQUIRE_ASYNC_MODULE` when anything in the graph uses top-level `await`. That is a property of the whole graph, so a transitive dependency can break a caller that did not change. - **Reach for `module.registerHooks()`, not `module.register()`.** The latter is runtime-deprecated in 26.0.0 (DEP0205) and needs `--allow-worker` under the Permission Model, because its hooks run on a worker thread. Read [`${CLAUDE_SKILL_DIR}/references/conventions/modules.md`] when a specifier resolves in a bundler and not in Node, when writing a loader hook, or when repeated start-up cost is worth caching — it carries what decides a file's module system, the specifier rules, the two hook APIs, and the compile cache. ## Package Manifest - **`"exports"` seals the package.** Once present, nothing outside the map is importable, deep relative paths included. Add it deliberately: it is the only way to keep internals private, and adding it to a published package is a breaking change. - **Condition order in the map is the match order.** Earlier keys win, so write the most specific first. - **`"default"` goes last and `"types"` first.** `"default"` matches every caller, and `"types"` is a convention of the type systems rather than a condition Node reads. - **`"import"` and `"require"` describe the caller's syntax, not the target's format.** A `.mjs` file behind `"require"` is legal and loads through `require(esm)`. - **Use `"module-sync"` instead of a dual build** where one ES module must serve both callers. Two builds of one package give a process two class identities and two copies of every module-level singleton. - **`"imports"` keys start with `#`** and are the only path-alias mechanism that survives `node file.ts`, because nothing in the runtime reads `tsconfig.json`. - **Every `"exports"` target starts with `./`.** Absolute paths, `file:` URLs, `../`, and any `.`, `..`, or `node_modules` segment are rejected with `ERR_INVALID_PACKAGE_TARGET`. Read [`${CLAUDE_SKILL_DIR}/references/conventions/modules.md`] when designing an `"exports"` map or publishing a package that must serve both `require` and `import` — it carries the condition-matching rules, the target-path restrictions, and the dual-package hazard. ## TypeScript Execution Node erases type syntax and runs the result. It does not compile, does not type-check, and does not read `tsconfig.json`. - **Keep `tsc --noEmit` in the verification path.** Running a `.ts` file proves it parses, nothing more. - **`import type` is mandatory for type-only imports.** Erasure is syntactic: without the keyword the import survives as a value import and fails at run time. `verbatimModuleSyntax: true` makes `tsc` enforce it. - **Write the real extension in the specifier** — `import './x.ts'`, and `require('./x.ts')` in CommonJS. Node loads the file that exists; it does not rewrite `.js` to `.ts`. - **Enums, `namespace` with runtime code, parameter properties, and import aliases raise `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`.** `--experimental-transform-types` handled some of them and was removed in 26.0.0. Set `erasableSyntaxOnly: true` so the compiler rejects them first. - **A decorator fails as a bare `SyntaxError` instead**, with no `code` to match on — measured on 26.2.0. The parser rejects the `@` before type stripping runs. - **A dependency shipping `.ts` files cannot be loaded.** Node refuses TypeScript under any `node_modules` path, deliberately and at every version. Read [`${CLAUDE_SKILL_DIR}/references/conventions/typescript-execution.md`] when a `.ts` file fails to run, when choosing between type stripping and a compiler, or when writing the `tsconfig.json` that matches the runtime — it carries the flag history, the full refused-syntax list, and the matching compiler options. ## Blocking - **No synchronous file, process, or crypto call on a request path.** `readFileSync`, `execSync`, `pbkdf2Sync`, `inflateSync` belong in CLIs, build scripts, and startup code that runs before the server listens. - **Bound every input before parsing it.** `JSON.parse` is synchronous and scales with input size, so an unbounded body is a denial of service that needs no bug. - **Never match a backtracking pattern against user input.** Nested quantifiers, overlapping alternations, and backreferences with repetition give one request the whole thread. Use `indexOf` or a linear engine. - **Recursive `process.nextTick` starves I/O.** The tick queue drains to exhaustion before the loop advances, so a self-scheduling `nextTick` stops the process serving traffic while looking idle. `queueMicrotask` has the same property one level down. - **`setImmediate` beats `setTimeout(fn, 0)` inside an I/O callback and races it at the top level.** Never encode either ordering as a dependency. - **Partition long synchronous loops** with `setImmediate` between chunks, or move them to a worker. Read [`${CLAUDE_SKILL_DIR}/references/conventions/concurrency.md`] when a process stops serving traffic while looking idle, or when one slow operation surfaces as an unrelated failure — it carries the loop phase order and the list of work that shares the libuv thread pool. ## Streams - **Compose with `pipeline` from `node:stream/promises`.** A `.pipe()` chain propagates neither errors nor destruction, so one failure leaves the rest of the chain open with its descriptors held. - **Respect the return value of `write()`.** `false` means the buffer is over `highWaterMark`; keep writing and memory grows without any error being raised. `pipeline` and `for await` handle this. - **`highWaterMark` is a threshold, not a cap.** A single write larger than it still buffers in full, and in object mode the count is objects rather than bytes. - **Destroy a stream on the error path.** `stream.destroy(err)` releases the descriptor or socket; an undestroyed stream leaks it. - **An unhandled `'error'` event crashes the process.** Every stream outside a `pipeline` needs a listener. - **`readable.map`, `filter`, `take`, `reduce` and the rest are Experimental**, on every major through 26. They read like committed API and are not. Keep them out of a library's surface. - **Do not stream data already in memory.** Streams earn their cost on unbounded or arriving-over-time input. Read [`${CLAUDE_SKILL_DIR}/references/conventions/streams.md`] when crossing between Node streams and web streams, when writing a custom `Readable`, `Writable`, or `Transform`, or when tuning `highWaterMark` — it carries the interop matrix, the platform-dependent defaults, and the whole-stream consumers. ## Errors and Exit - **Set `process.exitCode` and let the loop drain.** `process.exit()` terminates immediately, and a write to a pipe or a socket may still be in flight — measured on 26.2.0/macOS, 512 KiB written to a pipe arrives as 64 KiB. A write to a file is synchronous and survives, which is why the loss reads as intermittent. - **Match on `error.code`, never on the message.** Every built-in error carries one; messages change between minors. - **`util.inspect` renders a `cause` chain**, so an error chained with `{ cause: err }` reaches a Node logger intact without the caller unwrapping it. - **Never resume after `'uncaughtException'`.** Installing the listener disables the crash and leaves the process running with unknown state. Log, flush, exit. - **An unhandled rejection is fatal by default.** Do not add `--unhandled-rejections=warn` to make a test suite pass. - **`'exit'` listeners run synchronously only.** Anything asynchronous scheduled there never runs. - **Cancel with `AbortSignal`.** `fetch`, `fs/promises`, `timers/promises`, `pipeline`, `stream.addAbortSignal`, and `events.on` all take `{ signal }`. - **A cancelled call does not report itself the same way twice.** Node's own APIs reject with an `AbortError` carrying `code: 'ABORT_ERR'`; `fetch` rejects with a `DOMException` whose `code` is the number `20`; and a `fetch` deadline built from `AbortSignal.timeout(ms)` rejects with `name === 'TimeoutError'`, not `'AbortError'` — measured on 26.2.0. Test `signal.aborted` where the signal is in scope, and `err.name === 'AbortError' || err.name === 'TimeoutError'` where it is not. - **Set the HTTP server timeouts.** `requestTimeout` defaults to 300000 ms and `keepAliveTimeout` to 5000 ms; the latter must be shorter than the idle timeout of any proxy in front, or the proxy reuses a socket Node has closed. Read [`${CLAUDE_SKILL_DIR}/references/conventions/lifecycle.md`] when writing a graceful shutdown, when a process refuses to exit or exits without flushing, or when auditing server timeouts — it carries the shutdown sequence, the deprecation flags, and the timeout defaults. ## Async Context and Diagnostics - **`run(store, fn)` is the only Stable way to enter a store.** `enterWith` and `withScope` are Experimental, and `enterWith` applies for the rest of the current synchronous execution rather than for a scope — called inside one event handler it leaks into every later handler on the same emit. - **Instrument with `diagnostics_channel`, not with a logger call.** Publishing to a channel with no subscriber costs nothing measurable, so the instrumentation can ship enabled. - **Create a channel once at module top level** and guard expensive payload construction with `channel.hasSubscribers`. - **Subscribers receive the message object by reference.** A subscriber that mutates it changes what the publisher and later subscribers see. - **Measure loop lag with `perf_hooks.monitorEventLoopDelay()`**, not with a `setTimeout` drift estimate. - **Get a stack out of a native crash with `--report-on-fatalerror`.** No JavaScript handler runs at that point. Read [`${CLAUDE_SKILL_DIR}/references/conventions/observability.md`] when context goes missing across an async boundary, when adding tracing to a library, or when picking a profiling flag — it carries the context-loss diagnosis, the `TracingChannel` contract, and the list of channels Node publishes itself. ## HTTP Client - **Consume or cancel every response body**, on the error path too. undici does not release the connection until the body is read or cancelled, and the garbage collector is not prompt enough to save it — a pool exhausted this way stalls rather than errors. - **`fetch` does not reject on a non-2xx status.** Check `res.ok`. - **`fetch` has no total timeout.** Pass `signal: AbortSignal.timeout(ms)`, combined with the caller's signal through `AbortSignal.any`. - **`http.Agent` options do not reach `fetch`.** It uses undici's global dispatcher; change it with a `dispatcher` option per request or `setGlobalDispatcher` process-wide. - **`fetch` ignores `HTTP_PROXY` unless told otherwise** — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, from 22.21.0 and 24.5.0.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub