# Nodejs

> ALWAYS consult this skill before writing, editing, reviewing, or refactoring any Node.js code. It prevents the most common LLM-generated Node.js mistakes: bare 'fs'/'path' imports without the node: prefix, sync APIs (readFileSync) in async contexts, exec() shell injection, JSON.parse(JSON.stringify()) instead of structuredClone(), Math.random() instead of crypto.randomUUID(), missing graceful shutdown, and callback-style APIs when promise versions exist. Trigger on: any server-side JavaScript/TypeScript, CLI tools, scripts, Express/Fastify/Koa apps, anything importing fs/path/crypto/child_process/http/stream/worker_threads, package.json edits, require()-to-ESM refactors, or any mention of Node.js, npm, or server-side JS. Also trigger when you see code with require(), readFileSync, exec(), JSON.parse(JSON.stringify()), or Math.random() in a Node.js context — the skill tells you what to replace them with. Do NOT trigger for browser-only JS, React/Vue/Svelte components, Deno, or Bun-specific code.

- Skill: `crustacean-dev/nodejs` (Agent Skill)
- Install (CLI): `npx skillmds@latest add crustacean-dev/nodejs`
- Raw SKILL.md: https://api.skillmd.com/api/skills/crustacean-dev/nodejs/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: crustacean-dev (https://skillmd.com/u/crustacean-dev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/crustacean-dev/nodejs

---


# Node.js Guardrails

These guardrails apply to all Node.js code you write, review, or modify. They exist because LLM-generated Node.js tends to repeat the same mistakes: missing `node:` prefixes, callback-style APIs when promise versions exist, unsafe `exec()` calls, sync fs operations in async contexts, and `JSON.parse(JSON.stringify())` for deep copies. Following these rules produces code that's modern, secure, and idiomatic for Node.js 20+.

---

## Imports

The `node:` prefix makes it unambiguous that you're importing a built-in module, not a npm package that happens to share the name. Node.js has had this prefix since v16 and it should be used everywhere.

- **Always use the `node:` prefix** for built-in modules — `import fs from 'node:fs/promises'`, not `import fs from 'fs'`. This applies to every built-in: `node:path`, `node:url`, `node:crypto`, `node:os`, `node:stream`, `node:child_process`, `node:util`, `node:events`, etc.
- **Use `node:fs/promises`** — not `node:fs` with callbacks. The promise-based API is cleaner and composes with async/await.
- **Use `node:path`** for all path operations — never concatenate strings with `/`. Paths are OS-dependent and `path.join()` / `path.resolve()` handle this correctly.
- **Use `node:url` and the `URL` class** for URL manipulation — not string concatenation or regex.
- **Use `node:crypto`** for randomness — never `Math.random()` for anything security-related (tokens, IDs, secrets). `crypto.randomUUID()` and `crypto.randomBytes()` exist for this reason.

---

## Async

Node.js is built around non-blocking I/O. Callbacks were the original API style, but modern Node.js has promise-based alternatives for everything. Using async/await makes code readable, debuggable, and composable.

- **async/await everywhere** — never callbacks unless forced by a legacy API that has no promise version.
- **No `.then()` chains** when async/await is available. `.then()` chains are harder to read, harder to debug (stack traces), and harder to compose with try/catch.
- **`Promise.allSettled()` over `Promise.all()`** when partial failure is acceptable — `Promise.all()` rejects on the first failure and you lose the results of the others.
- **`AbortController` + `AbortSignal` for cancellation** — not custom boolean flags like `let cancelled = false`. AbortSignal is the standard cancellation mechanism and integrates with fetch, streams, child processes, and timers.
- **Use `node:timers/promises`** — `import { setTimeout } from 'node:timers/promises'` gives you an awaitable timer directly. Don't wrap `setTimeout` in `new Promise()`.

```js
// wrong
await new Promise(resolve => setTimeout(resolve, 1000));

// correct
import { setTimeout } from 'node:timers/promises';
await setTimeout(1000);
```

---

## Process

- **No `process.exit()` except in CLI entry points** — in library code and most application code, throw an error or return an error value instead. `process.exit()` skips cleanup, kills pending I/O, and makes code untestable.
- **Use `process.exitCode = 1`** over `process.exit(1)` when possible — setting the exit code lets Node.js finish pending operations before exiting naturally.
- **Handle `SIGINT` and `SIGTERM`** in long-running processes (servers, workers, daemons) for graceful shutdown — close connections, flush buffers, release resources.
- **`structuredClone()`** for deep copy — not `JSON.parse(JSON.stringify())`. The JSON trick silently drops `undefined`, functions, `Date` objects, `Map`, `Set`, `RegExp`, and circular references. `structuredClone()` handles all of these correctly.
- **Use `node:worker_threads`** for CPU-heavy work — not `child_process` for running JS code. Worker threads share memory (via `SharedArrayBuffer`) and avoid the overhead of spawning a new process.

---

## File System

Sync fs operations block the event loop. In a server or any async context, this means every other request stalls while you wait for disk I/O. The only acceptable place for sync fs is CLI startup where nothing else is running yet.

- **Always `node:fs/promises`** — never sync fs operations (`readFileSync`, `writeFileSync`, etc.) except during CLI startup/initialization.
- **Specify encoding explicitly** — `readFile(path, { encoding: 'utf-8' })`, not `readFile(path)` which returns a Buffer.
- **`mkdir` with `{ recursive: true }`** — don't check existence with `existsSync` first and then create. The recursive option is idempotent and avoids race conditions.
- **Use `node:os` `tmpdir()`** for temp files — never hardcode `/tmp`. macOS uses `/private/var/folders/...`, Windows uses `%TEMP%`, and containers may mount tmpfs elsewhere.

```js
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

await mkdir(join(tmpdir(), 'my-app'), { recursive: true });
const content = await readFile(configPath, { encoding: 'utf-8' });
```

---

## Streams

Streams are powerful but notoriously error-prone when piped manually. A leaked error handler means an unhandled rejection that crashes your process. `pipeline()` handles backpressure, error propagation, and cleanup for you.

- **Use `pipeline()` from `node:stream/promises`** — never `.pipe()` with manual error handling. `pipeline()` destroys all streams on error and returns a promise.
- **Prefer `node:stream/consumers`** (`.text()`, `.json()`, `.buffer()`) for consuming readable streams — these are built-in and handle encoding correctly.
- **Use `Readable.from()`** for creating streams from iterables — don't push data manually into a PassThrough stream.

```js
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
import { createGzip } from 'node:zlib';

await pipeline(
  createReadStream('input.txt'),
  createGzip(),
  createWriteStream('input.txt.gz')
);
```

---

## Child Processes

Spawning a shell is a common source of injection vulnerabilities. `exec()` passes the command through `/bin/sh`, which means shell metacharacters in user input become code execution. `execFile()` bypasses the shell entirely.

- **`execFile` over `exec`** — avoid shell injection. If you need shell features (pipes, globbing), use `exec` explicitly and sanitize inputs, but prefer `execFile` by default.
- **Always handle `stderr`** — don't ignore it. At minimum, log it. Silently swallowed stderr hides errors that make debugging impossible later.
- **Set `timeout` and `maxBuffer`** on exec/execFile — runaway child processes can hang indefinitely or consume unbounded memory.
- **Use `signal` option with AbortController** for cleanup — this lets you cancel child processes cleanly without resorting to `kill`.

```js
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
const controller = new AbortController();

const { stdout, stderr } = await execFileAsync('git', ['status'], {
  timeout: 10_000,
  maxBuffer: 1024 * 1024,
  signal: controller.signal,
});
```

---

## Versions & Compatibility

- **Target Node.js 20+ (LTS) minimum** unless the project specifies otherwise. Don't use Node.js 18 patterns or polyfills for APIs that landed in v20.
- **Use stable APIs** — not experimental — unless explicitly needed. Check the Node.js docs stability index before recommending lesser-known APIs.

---

## package.json

- **`"type": "module"`** for ESM — all new Node.js projects should use ES modules.
- **`"engines"` field** to declare the Node.js version requirement — this gives clear errors when someone tries to run the project on an unsupported version.
- **`"exports"` field** for package entry points — not just `"main"`. The `"exports"` field supports conditional exports (ESM/CJS), subpath exports, and blocks deep imports into internals.
- **`"packageManager"` field** with corepack for pnpm/yarn version pinning — this ensures everyone on the team uses the same package manager version.

