sota-javascript-typescript
State-of-the-art JavaScript and TypeScript engineering (2026) for both writing and auditing code. Covers strict TypeScript configuration and type design, language idioms and pitfalls, async patterns, Node.js backends, JS/TS-specific security (XSS, prototype pollution, supply chai
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-javascript-typescript
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA JavaScript / TypeScript Engineering
Purpose
This skill encodes 2026 state-of-the-art for JS/TS so generated code is strict, secure, and fast by default — and so audits of existing code find the bug classes that actually bite: untyped boundaries, floating promises, XSS sinks, prototype pollution, supply-chain gaps, event-loop blocking, and leak-prone listeners. It has two operating modes; pick one explicitly at the start of a task.
Baseline assumptions (mid-2026): TypeScript ≥5.9 strict (6.0 = last JS-based compiler; 7.0 Go-native stable since July 2026, shipped as the regular typescript package — frameworks embedding the compiler API via Volar, e.g. Vue/Angular/Astro/Svelte, stay on 6.0 until a stable plugin API lands), ESM-first, Node LTS ≥22 (24 = active LTS; Node 26 ships Temporal by default), ES2024+ available, React 19.2-era with Server Components and React Compiler 1.0 where relevant, vitest 4 + flat-config ESLint (v9/v10).
BUILD mode (writing or modifying code)
- Read the relevant rules files first (index below) for the area you're touching. Don't generate from memory what a rules file specifies.
- Defaults unless the codebase dictates otherwise: strict tsconfig (rules/01), ESM,
unknownoverany, discriminated unions for state, zod/valibot parse at every untrusted boundary,??/?.discipline, AbortController on cancellable ops, pino logging in services, Web APIs over deps. - Match the host codebase for style, framework, and structure — but do not replicate its security bugs or
any-sprawl into new code. New code meets the bar even in old repos. - Boundary rule: every input from outside the type system (HTTP, env, JSON.parse, storage, postMessage, DB without typed client) is parsed with a schema before use. No
as Ton external data. - Finish the job: new code compiles under
tsc --noEmit, passes lint, and ships with behavior-level tests (rules/07). Handle the error path of every async call — no floating promises. - When a requirement conflicts with a rule (e.g., legacy CJS, jest), follow the codebase and note the deviation; don't silently half-apply both.
AUDIT mode (reviewing existing code)
Scope first (frontend? Node service? library?), then read the matching rules files and run their audit checklists — each ends with grep/eslint hunt patterns. Validate findings: confirm attacker-controlled data actually reaches the sink, confirm the perf issue is on a hot path. No speculative findings.
Severity conventions:
- CRITICAL — remotely exploitable now: untrusted input reaching eval/innerHTML/exec/SQL, auth bypass, secrets exfiltratable via XSS.
- HIGH — exploitable with conditions, or guaranteed-corruption bug class: missing boundary validation, prototype-pollution-prone merge of request data, floating promises dropping errors, tokens in localStorage, unbounded request bodies, float money math, sync crypto blocking the loop.
- MEDIUM — weakened posture or latent defect: missing strict tsconfig flags, no graceful shutdown, missing CSP/timeouts,
||vs??on falsy-valid values, index keys on mutable lists, unbounded caches, missing supply-chain controls. - LOW — hygiene/debt: dead deps,
hasOwnProperty, console.log in services, snapshot sprawl, missing memoization on profiled-hot paths.
Finding format:
[SEVERITY] Title (CWE-xxx if security)
File: path/to/file.ts:42
Issue: what is wrong, in one or two sentences
Evidence: the offending snippet
Impact: what an attacker/user/operator experiences
Fix: concrete change (code if short)
Order the report CRITICAL→LOW, deduplicate repeated patterns into one finding with a file list, and end with the top 3 systemic recommendations (e.g., "enable noUncheckedIndexedAccess", "adopt MSW", "add zod to route boundaries").
Rules index
| File | Read this when... |
|---|---|
| rules/01-typescript-config-and-types.md | touching tsconfig; designing types/interfaces; seeing any/casts; modeling state; validating input shape; setting up a library or monorepo; deciding zod-vs-types questions |
| rules/02-language-idioms.md | writing any JS/TS logic: equality, ??/?., array methods, immutability, Map/Set, in-band sentinels (absence encoded as -1/0/"") — -1 lies where NaN poisons, error classes and Result types, generators, dates (Temporal), money/number precision |
| rules/03-async-patterns.md | anything with promises/async: combinator choice, floating promises, AbortController/timeouts, event-loop ordering, top-level await, workers, streams, async race conditions |
| rules/04-node-backend.md | building/auditing Node services: runtime choice, dropping deps for built-ins, env config, HTTP hardening (timeouts/body limits), graceful shutdown, process error policy, pino |
| rules/05-security.md | any security-relevant code or audit: XSS sinks, CSP, prototype pollution, npm supply chain, ReDoS, token storage/JWT, postMessage, child_process injection, SSRF |
| rules/06-performance.md | bundle size, React re-renders/keys/RSC, virtualization, debounce/throttle, memory leaks, Node event-loop blocking, profiling before optimizing |
| rules/07-testing-and-tooling.md | writing tests or setting up tooling: vitest, testing-library behavior testing, MSW, Playwright, ESLint flat + typescript-eslint strict, knip, publint/attw, CI gates. Test strategy — suite shape, TDD, doubles, test data, flake policy — lives in sota-testing; load it for any build that writes logic. This file owns JS/TS runner mechanics only. |
For a full audit, read 01→07 in order; security-focused audits prioritize 05, 01, 03, 04.
Top 10 non-negotiables
strict: true+noUncheckedIndexedAccessin every tsconfig; noany, no@ts-ignore—unknown+ narrowing,@ts-expect-errorwith reason.- Parse, don't cast, at boundaries: zod/valibot on every HTTP body/query, env var, JSON.parse, webhook, postMessage payload. Types inside, schemas at the edge.
- No floating promises: every promise awaited or explicitly
.catch-handled;@typescript-eslint/no-floating-promisesas error.allSettledfor independent work; bounded concurrency. - Discriminated unions for state, exhaustive
switchwithneverdefault — no boolean-soup interfaces with optional data/error pairs. ===always;??/?.over||/&&for null-handling; immutable updates (toSorted,structuredClone, spread) on shared data.- Errors are
Errorsubclasses withcause; never throw strings; never swallow with empty catch; Node policy = log fatally then exit on uncaught. - XSS sinks are forbidden by default: no
innerHTML/dangerouslySetInnerHTMLwith non-constant input unless DOMPurify-sanitized at render; no eval family ever; CSP on HTML responses. - No secrets in localStorage; no shell interpolation: httpOnly cookies for tokens;
execFile/spawnarray-args, neverexecwith template strings; parameterized SQL. - Supply chain controlled: committed lockfile +
npm ci, install scripts disabled/allowlisted, update cooldown, minimal deps — prefer platform built-ins (fetch, node:test, crypto.randomUUID). - AbortController + timeouts on all I/O; never block the event loop (>50ms CPU → worker; no
*Syncin request paths); listeners and intervals always cleaned up.
Files (sota-skills)
-
rules
-
01-typescript-config-and-types.md 15.1 KB
# TypeScript Configuration & Type Craft ## tsconfig: strict everything, no exceptions New projects start from this baseline. Existing projects migrate flag-by-flag; never ship with `strict: false`. ```jsonc { "compilerOptions": { // Strictness — all non-negotiable "strict": true, "noUncheckedIndexedAccess": true, // arr[i] is T | undefined — catches the #1 runtime crash class "exactOptionalPropertyTypes": true, // { x?: T } ≠ { x: T | undefined } "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, "useUnknownInCatchVariables": true, // implied by strict, listed for visibility // Module hygiene — ESM-first "module": "NodeNext", // or "ESNext" + "moduleResolution": "Bundler" for bundled apps "moduleResolution": "NodeNext", "verbatimModuleSyntax": true, // forces `import type`; makes transpile-only tools (esbuild, swc) safe "isolatedModules": true, // Output / interop "target": "ES2024", "lib": ["ES2024"], // add "DOM", "DOM.Iterable" only for browser code "esModuleInterop": true, "skipLibCheck": true, // pragmatic: don't pay for broken third-party d.ts "forceConsistentCasingInFileNames": true, "declaration": true, // libraries only "sourceMap": true } } ``` Rationale for the two flags most teams skip: - `noUncheckedIndexedAccess`: without it, `users[0].name` compiles and crashes on empty arrays. With it, you're forced to narrow: `const u = users[0]; if (!u) return;`. Use `.at(0)` which is honest (`T | undefined`) even without the flag. - `exactOptionalPropertyTypes`: distinguishes "absent" from "explicitly undefined". Critical for `JSON.stringify`, spread-merging config, and exactness of API payloads. Fix violations by deleting keys, not assigning `undefined`. `verbatimModuleSyntax` replaces deprecated `importsNotUsedAsValues`/`preserveValueImports`. It makes every file independently transpilable — required for esbuild/swc/Bun, and it documents intent: types via `import type`, values via `import`. TypeScript 6.0 (March 2026) is the last release on the JavaScript codebase; TypeScript 7.0 (the Go-native compiler, 8–12× faster builds, stable since July 2026) now ships as the regular `typescript` package. 6.0 flips defaults to `strict: true`, `module: "esnext"`, `target: "es2025"` and deprecates `baseUrl`, `moduleResolution: "node"`/`"classic"`, `outFile`, `target: "es5"`, and the non-strict interop flags — 7.0 makes them hard errors. The explicit config above stays valid under 6.0/7.0; migrate via 6.0 (treat its deprecation warnings as must-fix), then mind 7.0's changed defaults: `types` defaults to `[]` (list needed `@types` explicitly) and `rootDir` defaults to `./` (set it when tsconfig sits above `src`). Frameworks embedding the compiler API via Volar (Vue, Angular, Astro, Svelte, MDX) stay on 6.0 until 7.x exposes a stable programmatic API. ## No `any`. Use `unknown` + narrowing `any` disables the compiler transitively — it infects everything it touches. `unknown` is the type-safe top type: you must narrow before use. ```ts // BAD — any leaks; typo compiles, crashes at runtime function handle(e: any) { console.log(e.mesage); } // GOOD — unknown forces narrowing function handle(e: unknown) { if (e instanceof Error) console.log(e.message); else console.log(String(e)); } ``` - Catch clauses are `unknown` under strict mode. Never `catch (e: any)`. - `as any` in tests is still a bug factory; prefer typed builders/factories or `satisfies`. - Escape hatch hierarchy (best→worst): proper type > generic > `unknown` + guard > `as T` with a comment why > `// @ts-expect-error` with reason > `any` (forbidden; lint it: `@typescript-eslint/no-explicit-any: error`). - `@ts-expect-error` over `@ts-ignore` always — it errors when the suppression becomes stale. Narrowing toolkit: `typeof`, `instanceof`, `in`, `Array.isArray`, discriminant property checks, user-defined guards (`x is T`), assertion functions (`asserts x is T`). Prefer discriminant checks over custom guards — guards are unchecked promises; a wrong guard body is a silent `as`. ## Discriminated unions are the workhorse Model states as a closed union with a literal discriminant. This makes illegal states unrepresentable and gives exhaustive switches for free. ```ts // BAD — boolean soup; 4 fields allow 16 shapes, ~3 are valid interface State { loading: boolean; data?: User[]; error?: Error; } // GOOD — exactly the valid states exist type State = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: User[] } | { status: 'error'; error: Error }; function render(s: State) { switch (s.status) { case 'idle': return null; case 'loading': return spinner(); case 'success': return list(s.data); // data narrowed — no `!` case 'error': return alert(s.error); default: { const _exhaustive: never = s; throw new Error('unreachable'); } } } ``` The `never` default makes adding a variant a compile error at every consumer — this is the whole point. Also enable `@typescript-eslint/switch-exhaustiveness-check`. Use unions for: API responses (success/error), form states, events (`{ type: 'click', ... } | { type: 'keydown', ... }`), Result types. If you find yourself writing `field?: T` pairs that are "both or neither", that's a union begging to exist. ## Branded types for IDs and units Structural typing means `UserId` and `OrderId` as plain `string` are interchangeable — a swapped argument compiles. Brand them: ```ts declare const brand: unique symbol; type Brand<T, B extends string> = T & { readonly [brand]: B }; type UserId = Brand<string, 'UserId'>; type OrderId = Brand<string, 'OrderId'>; type Cents = Brand<number, 'Cents'>; const UserId = (s: string): UserId => s as UserId; // single blessed constructor function getOrders(userId: UserId): Order[] { /* ... */ } getOrders(orderId); // compile error — the bug class is dead ``` Brand at the validation boundary (zod: `z.string().uuid().brand<'UserId'>()`). Brand money, durations (ms vs s), and anything where unit confusion has bitten anyone ever. Zero runtime cost. ## `satisfies` and `as const` `satisfies` validates against a type while preserving the narrower inferred type. `: Type` annotation widens; `as Type` lies. ```ts // BAD — annotation widens; config.port is string | number const config: Record<string, string | number> = { port: 3000, host: 'localhost' }; // GOOD — checked AND port stays number const config = { port: 3000, host: 'localhost' } satisfies Record<string, string | number>; // as const for literal preservation + readonly const ROUTES = ['/home', '/about', '/admin'] as const; type Route = (typeof ROUTES)[number]; // '/home' | '/about' | '/admin' ``` Pattern: derive types from values (`as const` + `typeof` + indexed access), not values from types. One source of truth. `as` casts: legitimate only at (1) trusted boundaries already validated at runtime, (2) `as const`, (3) widening `as unknown as T` quarantined in one adapter file with a comment. `as` in business logic is a finding. ## Validate at the boundary, trust the types inside TypeScript types are erased — they verify nothing at runtime. Every untrusted input (HTTP body, query params, env vars, JSON.parse, localStorage, webhooks, LLM output, DB rows from untyped clients) must be parsed, not cast. ```ts import { z } from 'zod'; const CreateUser = z.object({ email: z.string().email(), age: z.number().int().min(0).max(150), role: z.enum(['user', 'admin']).default('user'), }); type CreateUser = z.infer<typeof CreateUser>; // type derived from schema — one source of truth // BAD — a lie with extra steps const user = (await req.json()) as CreateUser; // GOOD — parse, don't validate-and-cast const result = CreateUser.safeParse(await req.json()); if (!result.success) return badRequest(result.error.flatten()); const user = result.data; // genuinely CreateUser from here on ``` - zod v4 / valibot (smaller bundles, tree-shakeable — prefer for frontend) / ArkType are all fine; pick one per repo. - Parse env once at startup into a typed, frozen config object; crash fast on missing vars (see rules/04). - Inside the boundary, do NOT re-validate everywhere — that's noise. Types carry the proof. - `JSON.parse` returns `any`. Wrap it: `const parseJson = (s: string): unknown => JSON.parse(s);` ## Utility types: use the built-ins, derive don't duplicate `Partial`, `Required`, `Readonly`, `Pick`, `Omit`, `Record`, `Exclude`, `Extract`, `NonNullable`, `Parameters`, `ReturnType`, `Awaited`. Derive variants from one canonical type: ```ts interface User { id: UserId; email: string; createdAt: Date; passwordHash: string; } type PublicUser = Omit<User, 'passwordHash'>; type UserPatch = Partial<Pick<User, 'email'>>; ``` Caveats: - `Omit` doesn't distribute over unions. Use a distributive helper when omitting from a union: `type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;` - Prefer `Readonly<T>`/`readonly T[]` on function parameters you don't mutate — it documents and enforces. - `ReturnType<typeof fn>` couples consumers to implementation; fine internally, avoid in public API. ## Enums: don't. Use unions or const objects `enum` is non-erasable syntax (generates runtime code, breaks single-file transpilers — TS 5.8's `erasableSyntaxOnly` flag bans it outright), numeric enums are unsound (any number assigns), and const enums break under isolatedModules. ```ts // BAD enum Role { User, Admin } // Role.User === 0; role = 42 compiles // GOOD — string literal union (zero runtime, narrows perfectly) type Role = 'user' | 'admin'; // GOOD — const object when you need runtime iteration/values const Role = { User: 'user', Admin: 'admin' } as const; type Role = (typeof Role)[keyof typeof Role]; Object.values(Role); // runtime list for zod enums, dropdowns ``` Same reasoning bans `namespace` and parameter properties in new code: prefer plain modules and explicit field assignment. Enable `erasableSyntaxOnly` where the toolchain is TS ≥5.8 — it guarantees the whole codebase is type-strippable (Node's native TS execution, esbuild, swc all benefit). ## Functions: generics, overloads, and shape - Generic only when a type relationship must be preserved (`function first<T>(xs: readonly T[]): T | undefined`). A type parameter used once in the signature is usually noise — take `unknown` or the concrete type. - Constrain at the parameter (`<T extends HasId>`), return the narrow type; avoid `extends object` (allows arrays/functions — usually you mean `Record<string, unknown>`). - Prefer union parameters over overloads; overloads only when return type depends on argument type in ways unions can't express. Overload implementations are unchecked against each signature — keep them trivial. - Options-object for ≥3 params or any boolean (`createUser(email, { sendWelcome: true })` — call sites self-document; booleans positionally are unreadable). - Return types: annotate exported/public functions explicitly (inference drift across refactors changes your API silently; `explicit-module-boundary-types` lint); let locals infer. - Template literal types shine for constrained string APIs: `type EventName = \`on${Capitalize<string>}\`;`, route params extraction, CSS unit types — stop before you've written a parser in the type system (see next section). ## When type gymnastics hurt Types serve the code. Stop when: - A conditional/mapped type takes longer to understand than the duplication it removes. Two similar interfaces are often cheaper than one clever generic. - Inference errors surface 5 layers from the cause. Recursive conditional types produce unreadable diagnostics for teammates. - Compile time degrades (run `tsc --extendedDiagnostics`; deep template-literal and recursive types are the usual culprits). - You're encoding business rules better checked at runtime (e.g., "max 10 items" belongs in zod, not in a tuple-length type). Heuristics: max ~2 levels of nested conditional types in app code; name intermediate types; if a type needs a comment explaining how it works (not what it means), simplify it. Libraries can spend more complexity than apps — their users see only the inferred results. ## ESM-first and monorepo project references - New code is ESM: `"type": "module"` in package.json, `import`/`export` only. No `require`, no `module.exports`. CJS interop via default-import of CJS packages works under `esModuleInterop`. - `module: "NodeNext"` requires explicit `.js` extensions on relative imports in Node libraries (`import { x } from './util.js'`). Bundled apps using `moduleResolution: "Bundler"` may omit them. - Library `package.json`: use `exports` map with `types` condition first; ship `.d.ts` next to `.js`. Verify with `publint` and `arethetypeswrong` (see rules/07). Monorepos: TypeScript project references give incremental, dependency-ordered builds: ```jsonc // packages/api/tsconfig.json { "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, "outDir": "dist", "rootDir": "src" }, "references": [{ "path": "../shared" }] } ``` - Root: `tsc -b` (build mode). Each package: `composite: true` + `declaration: true`. - Import via workspace package names (`@app/shared`), never `../../shared/src/...` cross-package relative paths. - Pair with pnpm workspaces + turborepo/nx for task caching. Set `declarationMap: true` so go-to-definition lands in source, not d.ts. ## Audit checklist - [ ] `tsconfig.json`: `strict: true` present; `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `verbatimModuleSyntax`, `isolatedModules` enabled. Any of these missing in a 2026 codebase is a finding (HIGH for missing `strict`). - [ ] `grep -rn ": any\|as any\|<any>\|any\[\]" --include="*.ts" --include="*.tsx" src/` — every hit needs justification; `as any` in production code is HIGH. - [ ] `grep -rn "@ts-ignore\|@ts-nocheck" src/` — should be `@ts-expect-error` with a reason comment; `@ts-nocheck` is HIGH. - [ ] `grep -rn "as [A-Z]" --include="*.ts" src/ | grep -v "as const\|as unknown"` — casts in business logic; check each masks no missing validation. - [ ] `grep -rn "JSON.parse\|req.body\|req.query\|process.env" src/` — confirm each flows through a schema parse before typed use; raw `as T` on external data is HIGH. - [ ] `grep -rn "loading: boolean" src/` plus adjacent optional `data`/`error` fields — boolean-soup state, recommend discriminated union (MEDIUM). - [ ] IDs typed as bare `string` passed across ≥2 entity types — recommend branding (LOW/MEDIUM by blast radius). - [ ] ESLint has `typescript-eslint` strict-type-checked config; `no-explicit-any`, `switch-exhaustiveness-check`, `no-unsafe-*` rules enabled. - [ ] Monorepo: cross-package relative imports (`grep -rn "from '\.\./\.\./.*/src/"`) — bypass project references (MEDIUM). - [ ] `grep -rn "^export enum\|^enum \|const enum" src/` — migrate to unions/const objects (LOW; const enum under isolatedModules is MEDIUM). - [ ] Exported functions without explicit return types in library/public API (`explicit-module-boundary-types` lint) — LOW. - [ ] Libraries: `exports` map present, `publint` + `attw` pass. -
02-language-idioms.md 17.2 KB
# Language Idioms & Pitfalls ## Equality and coercion - Always `===`/`!==`. `==` coercion rules are unmemorizable (`[] == false`, `'' == 0`, `null == undefined` all true). Single allowed exception: `x == null` to test null-or-undefined at once — but `x === null || x === undefined` or `x ?? fallback` is clearer; just ban `==` entirely (`eslint eqeqeq: ["error", "always"]`). - `Object.is` only for `NaN`/`-0` distinction. `NaN === NaN` is false; use `Number.isNaN(x)` — never the global `isNaN`, which coerces (`isNaN('foo')` is true). - `Number.isInteger`, `Number.isFinite` over global counterparts for the same reason. ## Nullish discipline: `??` and `?.` `||` treats `0`, `''`, `false` as missing. `??` only treats `null`/`undefined` as missing. ```ts // BAD — port 0 and empty prefix silently replaced const port = config.port || 3000; const prefix = config.prefix || '/api'; // GOOD const port = config.port ?? 3000; const retries = opts.retries ?? 3; // retries: 0 respected el.count ??= 0; // nullish assignment ``` Optional chaining rules: - `?.` is for genuinely-optional data, not for silencing the compiler. A long chain `a?.b?.c?.d` usually means the type is wrong or validation was skipped upstream — fix the source. - `x?.()` for optional callbacks; `arr?.[i]` for optional indexing. - Don't combine `?.` with non-null assertion `!` — pick one truth. `!` is banned in app code except immediately after an explicit check the compiler can't see (document why); prefer restructuring so narrowing works. - Remember `a?.b.c` short-circuits the whole chain when `a` is nullish — `.c` is safe; but `(a?.b).c` is not. ## Array method selection Pick the method that states intent; reviewers read methods faster than loop bodies. | Need | Use | Not | |---|---|---| | transform each | `map` | `forEach` + push | | keep some | `filter` | manual loop | | first match | `find` / `findIndex` / `findLast` | `filter(...)[0]` | | any/all match | `some` / `every` | `filter(...).length > 0` | | reduce to one value | `reduce` (sparingly) | — | | flatten + map | `flatMap` | `map(...).flat()` | | membership | `includes` | `indexOf !== -1` | | group | `Object.groupBy` / `Map.groupBy` (ES2024) | reduce boilerplate | | index from end | `at(-1)` | `arr[arr.length - 1]` | - `forEach` only for pure side effects; it ignores return values and cannot `await` correctly (`forEach(async ...)` fires-and-forgets every iteration — classic bug; use `for...of` with `await`, or `Promise.all(arr.map(...))` for parallel). - `reduce` building objects/arrays with spread per iteration is O(n²) — use a mutable accumulator inside the reduce or a plain loop. - Early-exit needs: `some`/`every`/`find` short-circuit; `map`/`filter` don't — use `for...of` when you must break out of a transform. - Don't chain `filter().map()` over hot million-element arrays; one `for...of` or `flatMap` pass is fine. Below that scale, readability wins. ## In-band sentinels: `-1`, and why `NaN` is the better-behaved one `indexOf`, `lastIndexOf` and `findIndex` all return `-1` when not found (verified, Node 24). The idiom is fine where you test it immediately — `.includes()` / `.some()` say what you mean — and becomes the class in `sota-architecture` rules/02 §8a the moment the `-1` is stored, passed, or compared later. Two sentinels with **opposite** failure behaviour, both verified: | | `> 20` | `< 20` | consequence | |---|---|---|---| | `-1` | `false` | `true` | **lies**: wins one ordering, loses the other | | `NaN` | `false` | `false` | **poisons**: every comparison is false, incl. `NaN === NaN` | `parseInt("x")` → `NaN` is therefore the *safer* of the two: it cannot silently win a comparison, and `Number.isNaN` is an unambiguous test. `-1` cannot be tested without knowing the field's domain. Neither is as good as `null`/`undefined` with `strictNullChecks` and `??` (see *Nullish discipline* above) — note `-1 ?? fallback` is `-1`, so `??` does **not** rescue a sentinel; only `null`/`undefined` trigger it. - TS: type the absent case (`number | null`), never `number` with a documented magic value. A `-1` in a return type is invisible to every checker. - Audit: `grep -rnE 'return -1|=== -1|!== -1' --include='*.ts' --include='*.js' src/` — the `=== -1` hits are usually correct (immediate tests); the `return -1` hits are the producers, and a stored `-1` is where it goes wrong. ## Immutability patterns Mutating shared data causes spooky action at a distance and breaks React/state-library change detection. ```ts // BAD — sort/reverse/splice mutate in place const sorted = users.sort((a, b) => a.age - b.age); // also reordered `users`! // GOOD — ES2023 change-by-copy methods const sorted = users.toSorted((a, b) => a.age - b.age); const reversed = items.toReversed(); const without = items.toSpliced(i, 1); const updated = items.with(i, newItem); ``` - Mutators to flag on shared/parameter arrays: `sort`, `reverse`, `splice`, `push/pop/shift/unshift`, `fill`, `copyWithin`. Local arrays you just created may be mutated freely — purity at the boundary, pragmatism inside. - Deep copy: `structuredClone(obj)` — handles Dates, Maps, Sets, cycles, typed arrays. Never `JSON.parse(JSON.stringify(x))` (drops `undefined`, functions, Dates become strings, throws on cycles). Note structuredClone drops functions and prototypes — data only. - Shallow update idiom: `{ ...obj, field: v }` / `[...arr, item]` — shallow is fine when nested values are themselves replaced, not mutated. - Declare `readonly` arrays/properties in signatures; `as const` for fixed tables. `Object.freeze` is shallow and dev-only value — types are the real enforcement. - `let` is a smell outside loops/accumulators; `const` everywhere (`prefer-const` lint). ## Map/Set over object-as-map Objects as dictionaries inherit `Object.prototype` (`'toString' in obj` is true!), stringify all keys, and are the prototype-pollution sink. ```ts // BAD const cache: Record<string, User> = {}; if (cache[name]) ... // breaks for name = "constructor" // GOOD const cache = new Map<string, User>(); cache.set(name, user); cache.get(name); ``` - `Map`: arbitrary key types, `.size`, guaranteed insertion order, faster frequent add/delete, no prototype hazards. - `Set` for membership: `seen.has(x)` is O(1) vs `arr.includes(x)` O(n). Dedupe: `[...new Set(arr)]`. - If an object truly must be a dictionary (JSON shape), create it via `Object.create(null)` or always guard with `Object.hasOwn(obj, key)` (ES2022 — replaces `obj.hasOwnProperty`). - `WeakMap`/`WeakSet` to associate data with objects without preventing GC (e.g., DOM node metadata, memoization keyed by object). - `Map.prototype.getOrInsert(key, default)` / `getOrInsertComputed(key, fn)` (V8 14.6: Node 26, Chrome 146+) replace the check-then-set dance for cache/grouping maps — use where your runtime floor allows. - `Record<string, T>` indexing under `noUncheckedIndexedAccess` correctly yields `T | undefined` — Map's `.get` was always honest about this. ## Error handling Never throw strings or plain objects — they lose stack traces and break `instanceof` routing. ```ts // BAD throw 'user not found'; throw { code: 404 }; catch (e) { console.log(e); throw new Error('failed: ' + e); } // stack lost // GOOD — subclass + cause chain class NotFoundError extends Error { constructor(public readonly resource: string, public readonly id: string, opts?: ErrorOptions) { super(`${resource} ${id} not found`, opts); this.name = 'NotFoundError'; } } try { await db.query(sql); } catch (e) { throw new NotFoundError('user', id, { cause: e }); // ES2022 cause preserves the chain } ``` Rules: - `catch (e)` is `unknown` — narrow with `instanceof` before reading `.message`. Helper for the rest: `const toError = (e: unknown): Error => e instanceof Error ? e : new Error(String(e), { cause: e });` - Set `this.name` in subclasses; route on `instanceof` or a `code` field, never on message text. - Always pass `{ cause: e }` when wrapping — loggers (pino) serialize the chain. - Never swallow: empty `catch {}` is a finding unless commented with why. Catch only where you can handle or add context; otherwise let it propagate. - `finally` for cleanup; or ES2026 explicit resource management: `using conn = await pool.acquire()` with `[Symbol.asyncDispose]` (use `await using` for async disposal) — adopt where the runtime/tsconfig supports it. Result-style for expected failures: exceptions for bugs/infra, values for domain outcomes the caller must handle. ```ts type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }; async function parsePrice(input: string): Promise<Result<Cents, 'invalid' | 'negative'>> { /* ... */ } const r = await parsePrice(raw); if (!r.ok) return showError(r.error); // compiler forces the check use(r.value); ``` Use Result (hand-rolled discriminated union, or neverthrow if the team wants combinators) for validation, parsing, business-rule failures. Don't Result-ify everything — infra errors (DB down) should still throw. ES2025 `try`-expression proposals aside, today the union is the idiom. `safeParse` from zod is exactly this pattern. ## Iterators and generators - Generators for lazy/infinite/paginated sequences — they avoid materializing intermediate arrays: ```ts async function* paginate(url: string): AsyncGenerator<Item> { let next: string | null = url; while (next) { const page = await fetchPage(next); yield* page.items; next = page.nextUrl; } } for await (const item of paginate(api)) { if (matches(item)) break; } // stops fetching early ``` - ES2025 iterator helpers: `Iterator.from(it).filter(f).map(g).take(10).toArray()` — lazy chaining without arrays. - Make domain collections iterable via `[Symbol.iterator]` rather than exposing internal arrays. - Caveat: generators are single-pass; spreading one consumes it. Don't iterate twice. ## Proxy caution `Proxy` is for frameworks (Vue reactivity, immer), not application code. Costs: every property access pays a trap-call penalty; identity breaks (`proxy !== target`); `this`-binding bugs with private fields and built-ins (Map/Date methods throw through naive proxies); devtools/debugging opacity. If you reach for Proxy in app code, the answer is almost always an explicit function, a class, or a Map. `Reflect.*` belongs inside proxy handlers, rarely elsewhere. ## Dates: Temporal, and surviving without it `Date` is mutable, months are 0-indexed, parsing is implementation-defined, and it has no timezone besides local/UTC. Temporal (Stage 4 March 2026, part of ES2026; shipped in Chrome/Edge 144+, Firefox 139+, and enabled by default in Node 26) fixes all of it — immutable, explicit types: ```ts // GOOD — Temporal (Safari still hasn't shipped it — use `temporal-polyfill` for web targets) const meeting = Temporal.ZonedDateTime.from('2026-03-08T09:00[America/New_York]'); const later = meeting.add({ hours: 2 }); // DST-safe const dur = end.since(start); // Temporal.Duration const today = Temporal.Now.plainDateISO('Europe/Prague'); ``` Type selection: `Instant` for timestamps, `PlainDate` for calendar dates (birthdays — no timezone!), `PlainTime`, `ZonedDateTime` for wall-clock + zone, `Duration` for spans. Choosing the right type eliminates the bug class. Until Temporal is available everywhere you ship (Node ≥26 backends: it is; browser code: polyfill until Safari ships): store/transmit UTC ISO-8601 strings or epoch ms; convert at display; use date-fns (tree-shakeable) over dayjs/moment (moment is dead — flag it). Never do arithmetic by adding `86400000` — DST days are 23/25h. ## Number precision and money - All JS numbers are float64: `0.1 + 0.2 !== 0.3`; integers exact only to `Number.MAX_SAFE_INTEGER` (2^53−1). 64-bit DB IDs and Twitter snowflakes silently corrupt as numbers — keep them strings. - Money: integer minor units (cents) in a branded type, never floats. ```ts type Cents = Brand<number, 'Cents'>; const total = (items: readonly Cents[]) => items.reduce((a, b) => (a + b) as Cents, 0 as Cents); const display = (c: Cents) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c / 100); ``` - Division/percentages: decide the rounding rule explicitly (`Math.round` half-away-from-zero vs banker's), document it, test it. Allocation (splitting $10 three ways) must distribute the remainder, not round each share. - `BigInt` for >2^53 integers (crypto, snowflakes, wei). Don't mix with `number` (`1n + 1` throws); `JSON.stringify` throws on BigInt — serialize as string. - High-precision decimals (rates, FX): a decimal library (`decimal.js`/`big.js`) or do the math in the database. The TC39 Decimal proposal isn't shipped. - `parseFloat`/`parseInt` accept garbage prefixes (`parseInt('12px')` → 12). Prefer `Number(str)` + `Number.isFinite` check, or zod `z.coerce.number()`. Always pass radix if you do use `parseInt(s, 10)`. ## Functions and modules over classes; classes where they earn it Default unit of design: pure functions + plain data (typed objects), composed in modules. Classes earn their place for: stateful long-lived things with invariants (connection pools, caches), Error subclasses, when a framework expects them. Avoid: - Classes as namespaces (all-static members) — use a module. - Single-implementation interfaces + DI-container ceremony in app code — pass dependencies as function/constructor parameters directly; introduce the interface when the second implementation (or the test fake) actually exists. - Inheritance for code reuse — compose; `extends` only for genuine is-a with stable base (Error, framework bases). Deep hierarchies in JS are refactor glue traps. - Getters with side effects or surprise allocation; getters that throw. ```ts // BAD — class-as-namespace + hidden temporal coupling class UserService { static db: Db; static async get(id: string) { return this.db.find(id); } } // GOOD — explicit deps, trivially testable export const makeUserService = (db: Db) => ({ get: (id: UserId) => db.find(id), // ... }); export type UserService = ReturnType<typeof makeUserService>; ``` Module hygiene: no side effects at import time (registrations, connections, reading env) outside the composition root — importing a module should be safe and free. Side-effectful imports break tree-shaking, tests, and tooling. ## Strings and Unicode - `str.length` counts UTF-16 code units, not characters: `'👨👩👧'.length === 8`. Iterate by code point (`[...str]`, `for...of`) for character-ish ops; grapheme-correct counting/truncation needs `Intl.Segmenter`: ```ts const seg = new Intl.Segmenter('en', { granularity: 'grapheme' }); const truncate = (s: string, n: number) => [...seg.segment(s)].slice(0, n).map(x => x.segment).join(''); ``` - Normalize before comparing user-entered text: `a.normalize('NFC') === b.normalize('NFC')` ('é' has two encodings). - Locale-aware comparison/sorting: `Intl.Collator`/`localeCompare`, never `<` on strings for human-facing sort. All formatting (numbers, dates, lists, plurals) via `Intl.*` — never hand-rolled `${day}/${month}` strings. - `replaceAll` over `replace(/g/)` for literal replacement (no regex-escaping bugs). When building a RegExp from user input is unavoidable, escape it (`RegExp.escape` (ES2025) or the well-known escape helper) — see rules/05 ReDoS. - Multi-line template literals respect indentation — use `dedent` or keep them flush-left; don't ship accidental leading whitespace in SQL/emails. ## Audit checklist - [ ] In-band sentinels: `grep -rnE 'return -1' --include='*.ts' --include='*.js' src/` — a `-1` that is **stored or passed** rather than tested on the next line. Remember `??` does not rescue it (`-1 ?? x` is `-1`); only `null`/`undefined` trigger it. - [ ] `grep -rn "[^=!]==[^=]\|!=[^=]" --include="*.ts" src/` — loose equality (MEDIUM; eqeqeq lint). - [ ] `grep -rn "|| 0\||| ''\||| \[\]\||| {}" src/` and `\b(port|count|index|limit|offset|retries)\s*=.*||` — `||` where `??` is meant (MEDIUM, HIGH if money/ports). - [ ] `grep -rn "forEach(async" src/` — fire-and-forget async iteration (HIGH). - [ ] `grep -rn "\.sort(\|\.reverse(\|\.splice(" src/` — verify each operates on a locally-owned array, else `toSorted`/`toReversed`/`toSpliced` (MEDIUM). - [ ] `grep -rn "JSON.parse(JSON.stringify" src/` — replace with `structuredClone` (LOW/MEDIUM). - [ ] `grep -rn "throw ['\"\`]\|throw {" src/` — thrown non-Errors (HIGH). - [ ] `grep -rn "catch ([a-z]*)\s*{\s*}" src/` and `catch.*{\s*$` followed by `}` — swallowed errors (HIGH). - [ ] `grep -rn "new Error(.*+\|new Error(\`" src/` — wrapping without `{ cause }` (LOW). - [ ] `grep -rn "hasOwnProperty" src/` — use `Object.hasOwn` (LOW). - [ ] `grep -rn "price\|amount\|total\|balance" --include="*.ts" src/ | grep -i "float\|\* 0\.\|/ 100\|toFixed"` — float money math (HIGH). - [ ] `grep -rn "from 'moment'\|require('moment')" src/` — dead library; migrate (MEDIUM). `new Date(` arithmetic with `86400000`/`3600000` constants — DST bugs (MEDIUM). - [ ] `grep -rn "!" --include="*.tsx" -l src/` then targeted `grep -rn "\w!\.\|\w!;" src/` — non-null assertions; each needs justification (MEDIUM in app code). - [ ] ESLint: `eqeqeq`, `prefer-const`, `no-param-reassign`, `@typescript-eslint/no-floating-promises`, `no-non-null-assertion` configured. -
03-async-patterns.md 14.7 KB
# Async JavaScript ## Promise combinators: pick deliberately | Combinator | Resolves when | Rejects when | Use for | |---|---|---|---| | `Promise.all` | all fulfill | **first rejection** (others keep running, results dropped) | interdependent work — if one fails, the batch is useless | | `Promise.allSettled` | always (array of `{status, value/reason}`) | never | independent work — you want every outcome (batch sends, multi-source fetch) | | `Promise.race` | first settle (fulfil OR reject) | first rejection | timeouts (prefer `AbortSignal.timeout`), first-response-wins | | `Promise.any` | first fulfillment | all reject (`AggregateError`) | redundant sources/mirrors — first success wins | ```ts // BAD — one failed notification aborts reporting on all the rest const results = await Promise.all(users.map(notify)); // GOOD — independent ops: collect all outcomes, then handle failures const results = await Promise.allSettled(users.map(notify)); const failed = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); if (failed.length) logger.warn({ count: failed.length }, 'notifications failed'); ``` - `Promise.all` losers are NOT cancelled — pass a shared `AbortSignal` and abort it in a catch if the others' work matters. - Sequential-vs-parallel: `await` in a loop is sequential (sometimes correct — rate limits, ordering); `Promise.all(arr.map(f))` is parallel. Choose explicitly; accidental sequential awaits are a top latency bug. For bounded parallelism over large sets use a pool (`p-limit`, or `Array.fromAsync` over a semaphore-wrapped generator) — unbounded `Promise.all` over 10k fetches is a self-DoS. ```ts // BAD — sequential, 10× slower than needed for (const id of ids) results.push(await fetchUser(id)); // GOOD — parallel with a concurrency cap const limit = pLimit(8); const results = await Promise.all(ids.map(id => limit(() => fetchUser(id)))); ``` ## Floating promises and unhandled rejections A promise nobody awaits or `.catch`es crashes Node (default since v15) and silently drops errors in browsers. ```ts // BAD — floating; rejection is an unhandled crash, completion unordered saveAudit(event); return response; // GOOD — await it, or explicitly detach with a handler await saveAudit(event); // or, genuinely fire-and-forget: void saveAudit(event).catch(e => logger.error({ err: e }, 'audit failed')); ``` - Enable `@typescript-eslint/no-floating-promises` and `no-misused-promises` (catches `async` callbacks passed where `void` is expected — `setTimeout`, event handlers, `array.forEach`). - `async` executor in `new Promise(async ...)` is a smell — throws inside it are lost; you almost never need `new Promise` at all when the API already returns promises. Promisify callbacks with `util.promisify` or `new Promise` at the lowest layer only. - Don't mix `.then` chains and `await` in one function; pick `await` + try/catch. - `return await fn()` inside try/catch is required for the catch to see the rejection; bare `return fn()` skips it (`@typescript-eslint/return-await` rule, `in-try-catch` option). ## AbortController everywhere Every cancellable operation takes an `AbortSignal`. fetch, event listeners, streams, and your own long-running functions. ```ts // fetch with timeout + caller cancellation async function getUser(id: UserId, signal?: AbortSignal): Promise<User> { const res = await fetch(`/api/users/${id}`, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(5000)]) : AbortSignal.timeout(5000), }); if (!res.ok) throw new HttpError(res.status); return UserSchema.parse(await res.json()); } // event listeners: one signal removes them all — the leak-proof pattern const ac = new AbortController(); window.addEventListener('resize', onResize, { signal: ac.signal }); el.addEventListener('click', onClick, { signal: ac.signal }); // teardown (React useEffect cleanup, component unmount, route change): ac.abort(); // custom operations: check + propagate async function processAll(items: Item[], signal: AbortSignal) { for (const item of items) { signal.throwIfAborted(); await process(item, signal); // propagate downward } } ``` - `AbortSignal.timeout(ms)` replaces hand-rolled `Promise.race` timeout patterns; `AbortSignal.any([...])` composes caller-cancel + timeout. - Abort rejections are `DOMException` named `'AbortError'` (or `'TimeoutError'` from `.timeout`) — treat as flow control, not failure: `if (e instanceof DOMException && e.name === 'AbortError') return;` - React: abort in-flight fetches in effect cleanup to kill setState-after-unmount and stale-response races. Stale-response guarding via abort beats `let cancelled = true` flags. - Public async APIs in your codebase should accept `signal` as part of an options object. Functions doing network/FS/timers without a signal path are unkillable — that's a finding for long-running ops. ## Event loop: microtasks vs macrotasks Ordering model: run-to-completion of current task → drain ALL microtasks (promise reactions, `queueMicrotask`, MutationObserver) → render (browser) → next macrotask (`setTimeout`, I/O, UI events). - `await` yields to the microtask queue, not the event loop. Since ALL queued microtasks drain before the next macrotask, a tight loop of `await Promise.resolve()` still starves rendering, timers, and I/O. To genuinely yield to the loop use `scheduler.yield()` (browsers), `setTimeout(0)`, or `setImmediate` (Node). - Node ordering: `process.nextTick` runs before promise microtasks (avoid nextTick in app code — it can starve everything); `setImmediate` runs after I/O, before timers of the next loop iteration. - Long synchronous work blocks everything (see workers below). Chunk big loops: process N items, then `await scheduler.yield()` / `setImmediate`. - Zalgo: never make an API sometimes-sync, sometimes-async. If a function may return cached-sync or fetched-async, always go async (`return cached !== undefined ? Promise.resolve(cached)…` — just declare it `async`). ## Top-level await Allowed in ESM only. Implications: - It blocks every importer until resolution — a slow TLA in a shared module delays the whole graph. Fine for app entrypoints (config load, DB connect); avoid in library code and widely-imported modules. - Circular imports + TLA can deadlock or yield partially-initialized modules. - A rejected TLA fails module evaluation permanently — every subsequent import of that module rethrows. Wrap in try/catch with a fallback if the module must stay importable. - Prefer lazy init (exported `async function init()` or a memoized `getClient()`) over TLA for connections, so tests and tooling can import the module without side effects. ## Workers for CPU-bound work The event loop handles I/O concurrency; it cannot parallelize CPU. Anything >~50ms of synchronous compute (parsing huge JSON, image processing, crypto, compression, large sorts) belongs off-thread. ```ts // Node worker_threads pool — use piscina rather than hand-rolling import Piscina from 'piscina'; const pool = new Piscina({ filename: new URL('./worker.js', import.meta.url).href }); const hash = await pool.run({ file }, { signal }); // Browser const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }); worker.postMessage(data); // structured clone; use Transferable for big buffers worker.postMessage(buf, [buf]); // transfer ArrayBuffer — zero copy, source neutered ``` - postMessage structured-clones by default — copying a 100MB buffer per call erases the win; transfer `ArrayBuffer`s or use `SharedArrayBuffer` (requires COOP/COEP headers in browsers). - Workers have startup cost (~ms) — pool them, don't spawn per task. - Comlink (browser) wraps workers in async proxies and removes postMessage boilerplate. - Don't use workers for I/O-bound work — that's what async already does, workers just add overhead. ## Async iteration and web streams `for await...of` consumes async iterables — paginated APIs, streams, message queues — with backpressure for free (you don't pull the next chunk until you're done with this one). ```ts // Web Streams (standard in Node ≥18, browsers, Deno, Bun, edge runtimes) const res = await fetch(url, { signal }); for await (const chunk of res.body!.pipeThrough(new TextDecoderStream())) { feed(chunk); // process as it arrives — no full buffering } // Transform pipeline await readable .pipeThrough(new TextDecoderStream()) .pipeThrough(toLines()) // TransformStream .pipeTo(destination, { signal }); ``` - Prefer Web Streams (`ReadableStream`/`WritableStream`/`TransformStream`) over Node streams in new cross-platform code; bridge legacy with `Readable.toWeb()`/`fromWeb()`. - Node-side pipelines: `stream/promises` `pipeline(src, transform, dst, { signal })` — handles error propagation and cleanup; never `.pipe()` chains without error handling (each `.pipe` swallows downstream errors). - Don't buffer whole files/bodies when output is also a stream — `await res.json()` on a 2GB body is an OOM; stream it. - `Array.fromAsync(asyncIterable)` (ES2024) materializes when you genuinely need the full array. - `ReadableStream` is async-iterable; respect cancellation: a `break` out of `for await` cancels the stream (releases the lock) — that's correct behavior, rely on it. ## Retries with backoff, abort-aware Retry only transient failures (network errors, 429/503), only idempotent operations, bounded attempts, exponential backoff with full jitter, and propagate the caller's signal so cancellation stops the retry loop too. ```ts async function withRetry<T>(fn: (signal: AbortSignal) => Promise<T>, opts: { attempts?: number; signal?: AbortSignal } = {}): Promise<T> { const { attempts = 3, signal } = opts; for (let i = 0; ; i++) { signal?.throwIfAborted(); try { return await fn(signal ?? new AbortController().signal); } catch (e) { if (i >= attempts - 1 || !isTransient(e)) throw e; const delay = Math.random() * Math.min(1000 * 2 ** i, 10_000); // full jitter, capped await scheduler.wait(delay, { signal }); // or setTimeout-promise with signal } } } ``` Retrying on every error (including 400s and bugs) hammers dependencies and hides defects — classify first. Libraries: `p-retry` does this correctly; don't hand-roll in more than one place. ## Race conditions in async code `await` is a suspension point — shared state may change across it. ```ts // BAD — check-then-act across await; two concurrent calls both insert if (!(await db.userExists(email))) await db.insertUser(email); // TOCTOU // GOOD — atomic at the source of truth: UNIQUE constraint + upsert/conflict handling // BAD — stale write: slower older request overwrites newer result onChange: async (q) => setResults(await search(q)); // GOOD — abort the previous request onChange: (q) => { ac.abort(); ac = new AbortController(); search(q, ac.signal).then(setResults).catch(ignoreAbort); } ``` - In-memory mutexes (`async-mutex`) only serialize within one process — cross-instance invariants belong in the DB/queue. - Cache stampede: memoize the promise, not the value, so concurrent callers share one in-flight request: ```ts const inflight = new Map<string, Promise<User>>(); function getUser(id: string) { let p = inflight.get(id); if (!p) { p = fetchUser(id).finally(() => inflight.delete(id)); inflight.set(id, p); } return p; } ``` ## Async generator cleanup and resource safety Generators suspended at `yield` still hold resources. A consumer that `break`s or throws triggers the generator's `return()` — put cleanup in `finally`: ```ts async function* readBatches(db: Db, signal: AbortSignal) { const cursor = await db.openCursor(); try { while (!signal.aborted) { const batch = await cursor.next(); if (!batch) return; yield batch; } } finally { await cursor.close(); // runs on break/throw/return — guaranteed } } ``` - ES2026 explicit resource management generalizes this: `await using cursor = await db.openCursor();` with `[Symbol.asyncDispose]` on the resource — adopt for locks, files, connections as the runtimes/tsconfig (`lib: esnext.disposable`) allow. - Browser-side last-resort rejection telemetry: `window.addEventListener('unhandledrejection', e => { report(e.reason); e.preventDefault(); })` — report, don't suppress silently; this is monitoring, not error handling. ## Deferred patterns worth knowing - `Promise.withResolvers()` (ES2024) replaces the deferred anti-boilerplate when bridging callback/event worlds: ```ts const { promise, resolve, reject } = Promise.withResolvers<Payload>(); socket.once('reply', resolve); socket.once('error', reject); return promise; ``` - Event-to-promise: `once(emitter, 'event', { signal })` from `node:events`; in browsers, wrap `addEventListener(..., { once: true, signal })`. - Queue/serialize without a library: chain onto a stored promise — `queue = queue.then(() => task())` — each task starts after the previous settles; add a `.catch` so one failure doesn't poison the chain. - Async cleanup that must not be cancelled (audit flush, lock release): run it in `finally`, and if it's itself async inside an aborted context, detach it deliberately with its own timeout — don't pass the already-aborted signal. ## Audit checklist - [ ] `@typescript-eslint/no-floating-promises` + `no-misused-promises` enabled and passing — if not, HIGH; floating promises are silent data loss. - [ ] `grep -rn "Promise.all(" src/` — for each: are the ops independent? Should be `allSettled`? Is parallelism unbounded over user-controlled set sizes (HIGH — self-DoS)? - [ ] `grep -rn "await" src/ | grep -n "for (\|for(" -B0` → review loops: `grep -rn -A3 "for (const .* of" src/ | grep "await"` — sequential awaits that should be parallel (MEDIUM perf). - [ ] `grep -rn "fetch(" src/ | grep -v "signal"` — fetches without abort/timeout (MEDIUM; HIGH server-side where a hung upstream pins resources). - [ ] `grep -rn "addEventListener" src/` — paired removal or `{ signal }`? Unremoved listeners on long-lived targets = memory leak (MEDIUM). - [ ] `grep -rn "new Promise(async" src/` — lost rejections (HIGH). - [ ] `grep -rn "setInterval\|setTimeout" src/` — cleared on teardown? Async callbacks with try/catch? - [ ] `grep -rn "forEach(async\|map(async" src/` — `map(async` without surrounding `Promise.all` = floating (HIGH); `forEach(async` always wrong. - [ ] `grep -rn "process.nextTick" src/` — app-code use is a smell (LOW). - [ ] `grep -rn "\.pipe(" src/` — Node pipes without `pipeline()` error handling (MEDIUM). - [ ] Check-then-act across `await` on shared resources (manual review around `await` + `if` patterns) — TOCTOU (HIGH where it guards uniqueness/money). - [ ] Top-level `await` in shared/library modules (`grep -rn "^await \|^const .* = await" src/` at module scope) — startup coupling (LOW/MEDIUM). -
04-node-backend.md 15.3 KB
# Node.js Backend ## Runtime choice - **Node LTS** (currently 24 active LTS, 22 in maintenance; 26 is Current and becomes LTS Oct 2026 — it ships Temporal enabled by default and undici 8): default for production backends. Largest ecosystem compatibility, slowest-moving, best observability story. Pin the major in `package.json` `engines` and `.nvmrc`/`.node-version`; CI must run the pinned version. From Node 27 the release cycle is annual and every major reaches LTS after six months as Current. - **Bun**: fast installs/startup/test runner; fine for tooling, scripts, and apps you've load-tested on it. Verify native-addon and edge-case Node-API compat before betting production on it. - **Deno**: strong security model (permission flags), built-in TS. Choose when its model fits; ecosystem friction has shrunk with npm compat but still exists. - Decision rule: pick per-project, write runtime-neutral code (Web APIs: `fetch`, Web Streams, Web Crypto, `AbortController`) so the choice stays reversible. Avoid runtime-specific APIs in shared libraries. ## Use the platform — drop unnecessary deps Node now ships what used to require packages. Every dep removed is supply-chain and maintenance surface removed. | Dependency | Built-in replacement (Node ≥20/22) | |---|---| | axios/node-fetch/got | global `fetch` (undici) | | nodemon | `node --watch` | | dotenv | `node --env-file=.env` (≥20.6) | | jest/mocha (for libs/simple apps) | `node:test` + `node:assert` | | chalk (basic) | `styleText` from `node:util` | | uuid | `crypto.randomUUID()` | | minimist/yargs (simple CLIs) | `util.parseArgs` | | glob (simple) | `fs.glob` (≥22) | | ws client (basic) | global `WebSocket` (≥22) | ```bash node --watch --env-file=.env src/server.ts # Node ≥22.18/≥24 runs TS directly (type stripping, unflagged) node --test --experimental-test-coverage ``` Keep deps that earn their weight (pino, zod, drizzle/prisma, fastify). The bar: a dep must do something nontrivial that the platform doesn't. ## Env and config: parse once, crash fast Never sprinkle `process.env.X` through the codebase — env vars are `string | undefined`, typos are silent, and defaults scatter. ```ts // config.ts — the only file allowed to touch process.env import { z } from 'zod'; const Env = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']), PORT: z.coerce.number().int().min(1).max(65535).default(3000), DATABASE_URL: z.string().url(), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), STRIPE_KEY: z.string().min(1), }); const parsed = Env.safeParse(process.env); if (!parsed.success) { console.error('Invalid environment:', parsed.error.flatten().fieldErrors); process.exit(1); // crash at boot, not at 3am when the code path is hit } export const config = Object.freeze(parsed.data); ``` - Boot-time crash on bad config is a feature: the orchestrator restarts and alerts; a lazy crash mid-request loses data. - Secrets never in code or committed `.env`; inject via secret manager/orchestrator. `.env` is local-dev only and gitignored. - No `NODE_ENV === 'production'` branches scattered in logic — derive named flags in config (`config.isDev`) and branch on those. ## HTTP server hardening Defaults are unsafe: Node's http server has lenient timeouts; frameworks accept huge bodies. ```ts import { createServer } from 'node:http'; const server = createServer(app); server.requestTimeout = 30_000; // whole request (default 300s — too long) server.headersTimeout = 10_000; // slowloris defense (must be < requestTimeout) server.keepAliveTimeout = 65_000; // > LB idle timeout (ALB 60s) to avoid 502 races server.maxRequestsPerSocket = 1000; ``` - **Body limits**: cap JSON/body size at the framework (`express.json({ limit: '100kb' })`, Fastify `bodyLimit`). Unbounded bodies = trivial memory DoS. Cap per route by actual need. - **Behind a proxy**: set `trust proxy` correctly (exact hop count, not `true`) or rate limiting keys on spoofable `X-Forwarded-For`. - **Headers**: `helmet` (or hand-set: `HSTS`, `X-Content-Type-Options: nosniff`, frame-ancestors via CSP). Disable `X-Powered-By`. - **Rate limit** auth and expensive endpoints (`rate-limiter-flexible` backed by Redis when multi-instance — in-memory limits don't survive horizontal scaling). - **Validation**: every route parses body/query/params with a schema before touching them (rules/01, rules/05). Fastify + zod type-provider, or tRPC, makes this structural. - **Compression**: gate on response size; never compress encrypted/random content; beware BREACH when reflecting secrets in compressed responses. - Prefer Fastify over Express for new services: schema-first validation, structured logging built in (pino), 2-3× throughput, maintained. ## Graceful shutdown Kubernetes/ECS send SIGTERM and wait (default 30s) before SIGKILL. Dropping in-flight requests on deploy is a self-inflicted incident. ```ts const server = app.listen(config.PORT); const shutdown = async (signal: string) => { logger.info({ signal }, 'shutting down'); server.close(() => logger.info('http closed')); // stop accepting, finish in-flight server.closeIdleConnections(); setTimeout(() => { logger.error('forced exit'); process.exit(1); }, 25_000).unref(); await jobQueue.stop(); // stop pulling new work await db.end(); // then close pools process.exit(0); }; process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT')); ``` Order matters: (1) fail readiness probe / stop accepting, (2) drain in-flight with a deadline shorter than the orchestrator's, (3) close DB/queue/redis, (4) exit 0. `setTimeout(...).unref()` so the safety timer doesn't itself hold the process open. Long-lived connections (SSE/WebSocket) need explicit termination — `server.close` waits for them forever; track and end them. ## Health checks and readiness Liveness ≠ readiness. Liveness: "is the process alive" — answer cheaply, no dependency checks (a DB blip must not get you killed and restarted in a loop). Readiness: "should I receive traffic" — checks pool health, returns 503 during shutdown drain. ```ts let ready = true; // flipped false first thing in shutdown() app.get('/healthz', (_req, res) => res.status(200).send('ok')); app.get('/readyz', async (_req, res) => { if (!ready) return res.status(503).send('draining'); const dbOk = await db.ping().then(() => true, () => false); res.status(dbOk ? 200 : 503).send(dbOk ? 'ok' : 'db'); }); ``` Flip readiness to 503 at the start of shutdown, then wait one probe period before `server.close()` so the LB stops routing first — this is what makes zero-downtime deploys actually zero-downtime. ## Request context: AsyncLocalStorage Propagate request ID / user / trace context without threading a `ctx` parameter through every signature. ```ts import { AsyncLocalStorage } from 'node:async_hooks'; const requestContext = new AsyncLocalStorage<{ reqId: string; userId?: string }>(); app.use((req, _res, next) => { requestContext.run({ reqId: req.headers['x-request-id'] ?? crypto.randomUUID() }, next); }); // anywhere downstream — no parameter drilling export const log = (obj: object, msg: string) => logger.info({ ...requestContext.getStore(), ...obj }, msg); ``` It survives `await`, timers, and promise chains. Use it for logging context and tracing only — not as a grab-bag service locator (hidden dependencies become untestable). OpenTelemetry's Node SDK rides the same mechanism; adopt OTel for traces rather than hand-rolling. ## Process-level error policy ```ts process.on('unhandledRejection', (reason) => { logger.fatal({ err: reason }, 'unhandled rejection'); throw reason; // escalate to uncaughtException path — same policy }); process.on('uncaughtException', (err) => { logger.fatal({ err }, 'uncaught exception — exiting'); // flush logs/telemetry synchronously if needed, then: process.exit(1); }); ``` Policy: **log, then die**. After an uncaught exception the process state is undefined (half-finished writes, corrupted singletons) — continuing risks data corruption worse than a restart. The orchestrator's job is restarting; your job is exiting loudly. Never install an `uncaughtException` handler that swallows and continues (HIGH finding). Per-request errors belong in framework error handlers — they must never reach the process level. - `process.on('warning')` → log it (catches MaxListenersExceeded, deprecations). - Don't call `process.exit()` in normal flow — it skips pending I/O and `finally` blocks; let the loop drain or use exit codes from the shutdown path only. ## Structured logging: pino `console.log` in services is unsearchable, unleveled, and synchronous-ish under load. pino writes newline-JSON, fast, with levels and redaction. ```ts import { pino } from 'pino'; export const logger = pino({ level: config.LOG_LEVEL, redact: { paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], censor: '[redacted]' }, // dev only: transport: { target: 'pino-pretty' } }); // Structured fields first, message second — never string-interpolate data logger.info({ userId, orderId, durationMs }, 'order created'); logger.error({ err }, 'payment failed'); // `err` key serializes stack + cause chain ``` - One child logger per request with a request ID: `req.log = logger.child({ reqId })` (Fastify does this automatically). Propagate the ID to downstream calls (`AsyncLocalStorage` for context without parameter drilling). - Redact secrets at the logger, not by hoping call sites remember. - Never log: tokens, passwords, full card numbers, raw request bodies on auth routes, PII beyond need. - `pino-pretty` is a dev dependency/CLI, not production config. Production emits raw JSON to stdout; the platform ships it. ## Worker pools and not blocking the loop One Node process = one JS thread. A 200ms synchronous task means every concurrent request waits 200ms. - Known CPU work (hashing, image resize, PDF gen, big JSON.parse, compression): `piscina` worker pool (rules/03). Size pool ≈ cores − 1. - `crypto.scrypt`/`bcrypt` async variants use the libuv threadpool — never the `*Sync` variants in request paths. Bump `UV_THREADPOOL_SIZE` (default 4!) if crypto/DNS/fs-heavy. - Banned in request paths: `fs.*Sync`, `child_process.execSync`, `zlib.*Sync`, `JSON.parse` of multi-MB payloads (cap body size instead), synchronous template rendering of huge documents. - Detect blocking in production: monitor event-loop delay with `perf_hooks.monitorEventLoopDelay()` (alert at p99 > ~100ms), or `blocked-at` in staging to get stacks. - Multi-core: prefer N processes via the orchestrator (K8s replicas) over in-process `cluster`; keep processes single-purpose. ## Background work in services - In-process `setInterval` jobs are lost on restart, duplicated across replicas, and drift. Anything that must run exactly/at-least once per schedule belongs in a job queue (BullMQ on Redis, pg-boss on Postgres, or the platform's scheduler) with: idempotent handlers, explicit retry/backoff policy, dead-letter handling, and per-job timeouts. - If a lightweight in-process ticker is genuinely fine (cache refresh, metrics flush): wrap the callback in try/catch (a thrown error in a bare `setInterval` callback is an uncaught exception → process exit policy kicks in), `.unref()` it so it can't hold shutdown, and guard against overlap (skip if previous run still in flight). ```ts let running = false; const timer = setInterval(() => { if (running) return; running = true; refreshCache().catch(e => logger.error({ err: e }, 'refresh failed')).finally(() => { running = false; }); }, 30_000); timer.unref(); ``` - Long-running request work (report generation, imports): return `202 Accepted` + job ID + status endpoint; don't hold an HTTP request open for minutes against every timeout in the chain. ## Outbound calls: pools, timeouts, retries Your service is only as reliable as its slowest dependency. Every outbound call gets: - **Timeout**: `AbortSignal.timeout(ms)` on fetch; statement timeout on DB queries. No infinite waits — a hung upstream plus no timeout equals your own outage. - **Bounded retries with jittered backoff**, idempotent operations only; honor `Retry-After`. Retrying non-idempotent POSTs duplicates orders — use idempotency keys. - **Connection pooling**: undici `Agent`/`Pool` for high-volume HTTP to fixed origins; DB pool sized deliberately (start ~10 per instance; pool_size × instances must stay under the DB's max_connections — the default-100 Postgres ceiling is hit by autoscaling, not load). - **Circuit breaking** on flapping dependencies (opossum) so you fail fast instead of queueing doomed work. ```ts const res = await fetch(upstream, { signal: AbortSignal.timeout(3000) }); if (res.status >= 500) throw new UpstreamError(res.status); // retry layer decides ``` ## Native ESM in Node - `"type": "module"` in package.json. `__dirname`/`__filename` don't exist — use `import.meta.dirname` / `import.meta.filename` (Node ≥20.11), or `new URL('./file', import.meta.url)` for asset paths. - JSON imports: `import data from './data.json' with { type: 'json' }`. - Don't mix: a stray `require` in ESM throws; CJS deps import fine via default import. Publishing libraries: ship ESM; add CJS only if your consumers truly need it (use tsup/unbuild dual output, verify with `attw`). - Dynamic `import()` works in both module systems — it's the migration bridge and the lazy-loading tool. ## Audit checklist - [ ] `grep -rn "process.env" src/ --include="*.ts" | grep -v "config\|env.ts"` — env access outside the config module (MEDIUM); no schema validation of env at boot (HIGH). - [ ] `grep -rn "Sync(" src/ | grep -v "test\|script"` — `*Sync` calls in server code (HIGH in request paths). - [ ] Server timeouts: `grep -rn "headersTimeout\|requestTimeout\|keepAliveTimeout" src/` — absent = slowloris-exposed defaults (MEDIUM). - [ ] Body limits configured (`grep -rn "bodyLimit\|limit:" src/`) — unbounded body parsing (HIGH, DoS). - [ ] `grep -rn "SIGTERM" src/` — no graceful shutdown handler = dropped requests on every deploy (MEDIUM). - [ ] `grep -rn "uncaughtException" src/` — handler that doesn't exit (HIGH); no `unhandledRejection` policy at all (MEDIUM). - [ ] `grep -rn "console.log\|console.error" src/ | grep -v test` — in services, replace with pino (LOW; MEDIUM if logging objects with secrets). - [ ] Logger redaction configured? `grep -rn "redact" src/` — logging auth headers/bodies without redaction (HIGH). - [ ] `grep -rn "trust proxy" src/` and rate-limiter keying — spoofable client IP (MEDIUM). - [ ] `package.json`: `engines.node` pinned; deps that duplicate platform built-ins (axios, dotenv, uuid, nodemon) — removable (LOW). - [ ] `grep -rn "bcrypt.hashSync\|scryptSync\|pbkdf2Sync" src/` — sync crypto in request path (HIGH). - [ ] `grep -rn "process.exit" src/ | grep -v "config\|shutdown"` — exits mid-flow skipping cleanup (MEDIUM). - [ ] Readiness vs liveness probes distinct; readiness flips during drain (`grep -rn "readyz\|readiness" src/`) — single do-everything healthcheck (LOW/MEDIUM). - [ ] Outbound fetch/DB calls without timeouts (`grep -rn "fetch(" src/ | grep -v signal`; DB client statement_timeout) — MEDIUM, HIGH for critical paths. - [ ] Retry logic on non-idempotent operations without idempotency keys (MEDIUM/HIGH if money). - [ ] DB pool size × replica count vs database max_connections — documented anywhere? (LOW). -
05-security.md 20.1 KB
# JavaScript/TypeScript Security Severity here maps to exploitability: attacker-controlled input reaching a sink = CRITICAL/HIGH; hardening gaps = MEDIUM; defense-in-depth = LOW. ## XSS: know your sinks XSS = untrusted data reaching an HTML/JS execution sink. Frameworks escape by default; every escape hatch is a sink. Sinks to treat as hostile-by-default: - `element.innerHTML`, `outerHTML`, `insertAdjacentHTML`, `document.write` - React `dangerouslySetInnerHTML`, Vue `v-html`, Angular `bypassSecurityTrust*`, Svelte `{@html}` - `eval`, `new Function`, string args to `setTimeout`/`setInterval` - `<a href>`/`location` assignment with user data — `javascript:` URLs - jQuery `$(userInput)`, `.html()` ```tsx // BAD — stored XSS <div dangerouslySetInnerHTML={{ __html: user.bio }} /> el.innerHTML = `<b>${query}</b>`; // GOOD — default escaping; textContent for DOM <div>{user.bio}</div> el.textContent = query; // HTML genuinely required (rich text/markdown)? Sanitize with DOMPurify at render time import DOMPurify from 'dompurify'; <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }) }} /> // BAD — javascript: URL slips through React's escaping <a href={user.website}>site</a> // GOOD — allowlist protocols const safeUrl = (u: string) => { try { const p = new URL(u); return ['https:', 'http:', 'mailto:'].includes(p.protocol) ? u : '#'; } catch { return '#'; } }; ``` - Sanitize at output/render, not at input (input-sanitized data gets corrupted and re-encoded wrongly across contexts). - Server-rendered HTML embedding JSON state: `JSON.stringify(state).replaceAll('<', '\\u003c')` to block `</script>` breakout. - Adopt Trusted Types where targets allow (`require-trusted-types-for 'script'` CSP) — it turns DOM-sink misuse into runtime errors. - `eval`/`new Function` on anything dynamic is CRITICAL. There is no safe "sandboxed eval" in-process (Node `vm` is NOT a security boundary — escapes are trivial; use isolated processes/`isolated-vm`/WASM). ## CSP integration Ship a Content-Security-Policy on every HTML response; it converts many XSS bugs from CRITICAL to mitigated. ``` Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; ``` - Nonce-based `strict-dynamic` beats allowlist CSP (allowlists are bypassable via JSONP/open redirects on allowed hosts). Generate a fresh nonce per response; templating/framework injects it on `<script>` tags. - `'unsafe-inline'` in `script-src` makes CSP decorative (finding: MEDIUM). `'unsafe-eval'` enables the eval family — required only by legacy libs; eliminate. - Roll out with `Content-Security-Policy-Report-Only` + a report endpoint, then enforce. - `frame-ancestors` replaces `X-Frame-Options`; `base-uri 'none'` blocks `<base>` hijack of relative script URLs. ## Prototype pollution Writing to `__proto__`/`constructor.prototype` via attacker-controlled keys poisons every object — leading to auth bypass (`{}.isAdmin === true`), DoS, sometimes RCE via gadget chains. Vulnerable patterns: recursive merge/extend/clone of untrusted JSON, `obj[a][b] = v` with attacker-controlled `a`, lodash `set`/`merge` with untrusted paths, query-string parsers building nested objects. ```ts // BAD — classic vulnerable deep merge function merge(target: any, src: any) { for (const k in src) { if (typeof src[k] === 'object') merge(target[k] ??= {}, src[k]); // k = "__proto__" pollutes else target[k] = src[k]; } } // GOOD — defenses, in order of preference: // 1. Don't deep-merge untrusted input. Parse with zod (strips unknown keys, fixed shape). const body = BodySchema.parse(await req.json()); // 2. If you must merge dynamic keys: block the dangerous ones and use null-prototype targets const DANGEROUS = new Set(['__proto__', 'constructor', 'prototype']); if (DANGEROUS.has(key)) continue; const map = Object.create(null); // no prototype to pollute // 3. Maps for dynamic keyed storage (rules/02). 4. node --disable-proto=delete as hardening. ``` - `JSON.parse` itself is safe (`__proto__` becomes an own property) — the pollution happens in subsequent merge/assign logic. - Check dependencies: historic offenders are deep-merge utilities, config loaders, and qs-style parsers. `Object.freeze(Object.prototype)` is a blunt last-resort hardening some services use. ## npm supply chain The dependency tree is your attack surface; install scripts run arbitrary code on `npm install` (developer machines and CI). Worm campaigns like the 2025 Shai-Hulud worm spread exactly this way; the March 2026 axios compromise published malicious versions with a stolen npm token, bypassing the project's trusted-publishing setup — caught within a day, which is exactly what install cooldowns absorb; and the June 2026 Miasma wave (a Shai-Hulud derivative, 32+ `@redhat-cloud-services` packages) ran code at install via a phantom `binding.gyp` — the implicit `node-gyp rebuild` needs no declared install script — published through a compromised OIDC trusted-publishing workflow with valid SLSA provenance. - **Lockfile committed and exact**: `package-lock.json`/`pnpm-lock.yaml` in git; CI installs with `npm ci` / `pnpm install --frozen-lockfile` — never bare `npm install` in CI. - **Disable install scripts by default**: `npm config set ignore-scripts true` (or `.npmrc: ignore-scripts=true`) — this also skips the implicit `binding.gyp`/node-gyp path; pnpm ≥10 blocks them by default with an allowlist — `allowBuilds` since **v11**, which **removed** `onlyBuiltDependencies`/`neverBuiltDependencies`/`ignoreDepScripts`; keep `strictDepBuilds` (default `true` since v10.3.0) so an unreviewed build script **exits non-zero** instead of warning; npm ≥12 (July 2026) blocks dependency install scripts including implicit node-gyp rebuilds by default, and refuses git and remote-URL tarball dependencies unless `--allow-git`/`--allow-remote` — build the allowlist before upgrading with `npm approve-scripts` (npm ≥11.16 warns). Allow per-package only what genuinely needs to build (esbuild, sharp). - **Cooldown**: don't install or auto-merge dependency updates the day they publish; most hijacked versions are caught within days. Package managers now enforce this natively: pnpm 11 defaults `minimumReleaseAge` to 1440 minutes (1 day — don't opt out without reason); npm CLI ≥11.10 has `min-release-age` (days) in config; plus Renovate `minimumReleaseAge` (e.g. `7 days`) / Dependabot cooldown for update PRs. - **Provenance & audit**: prefer packages publishing npm provenance (Sigstore attestation) — but provenance proves the publish path, not code safety: Miasma shipped malware with valid SLSA attestations from a compromised trusted-publishing workflow; `npm audit --omit=dev` in CI with a triage policy (fail on high/critical with no fix-path exception file); `osv-scanner` or Socket for behavioral flags (new maintainer, install script added, network in install). - **Minimal deps**: every dep is trust granted to its maintainers and their deps transitively. Before adding: is it <100 lines you could own? Does the platform do it (rules/04 table)? Check maintenance, weekly downloads, dependency count. - **Typosquatting**: verify exact names on add; scoped packages (`@org/x`) reduce risk. Pin GitHub Actions to commit SHAs, not tags. - **Publishing**: trusted publishing (OIDC from CI — `npm trust` since CLI 11.10 configures it across packages in bulk) over long-lived tokens; npm's staged publishing adds a human 2FA approval gate before a version goes live — enable it for high-blast-radius packages (a stolen token alone then can't ship a release). `files` allowlist in package.json so secrets/configs never ship in the tarball. ## ReDoS Backtracking regexes with nested/overlapping quantifiers go exponential on crafted input — one request pins a CPU (and on Node, the whole event loop: total DoS). ```ts // BAD — (a+)+ catastrophic backtracking; 30 chars of 'aaaa...!' hangs the process const valid = /^(\w+\s?)*$/.test(userInput); // BAD — overlapping alternation /^(.*,)*.*$/ // GOOD options: // 1. Linear-time by construction: no nested quantifiers over overlapping sets const valid = /^[\w\s]*$/.test(userInput); // 2. Length-cap input BEFORE regexing if (input.length > 256) reject(); // 3. Non-backtracking engine for complex patterns: RE2 (node-re2) // 4. Don't regex what a parser should parse (emails: maxlength + one '@' + send a verification mail) ``` - Lint: `eslint-plugin-regexp` (includes ReDoS detection) or `recheck`/`redos-detector` in CI on any regex touching user input. - The `v`/`u` flags don't fix backtracking. Regexes in hot paths compiled once (top-level `const`), not per call. ## Tokens and client-side auth - **Session/refresh tokens never in `localStorage`/`sessionStorage`** — any XSS exfiltrates them. Use cookies: `HttpOnly; Secure; SameSite=Lax` (or `Strict`), `Path` scoped, `__Host-` prefix. - SameSite is CSRF defense-in-depth, not complete: keep CSRF tokens (or strictly enforce custom-header + CORS preflight) for state-changing routes if any non-SameSite path exists. - If an SPA must hold an access token in JS (third-party API): keep it in memory only, short-lived (≤15min), refresh via httpOnly-cookie refresh token; accept that XSS can use (not just steal) it — XSS prevention remains the real control. - **Verify JWTs server-side properly**: pin the algorithm (`{ algorithms: ['RS256'] }` — never accept `alg` from the token; `none` and HS/RS confusion attacks), validate `iss`, `aud`, `exp`, clock skew. Use `jose`. Don't put secrets in JWT payloads — they're only base64. - Authorization on every request server-side; client-side route guards are UX, not security. ```ts // Setting the session cookie — the full attribute set, not a subset res.setHeader('Set-Cookie', `__Host-session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`); ``` - `__Host-` prefix forces Secure + no Domain attribute + Path=/ — blocks subdomain cookie-tossing. - Rotate session IDs on login/privilege change (session fixation); server-side revocation list or short-lived JWT + refresh rotation with reuse detection. - CSRF for cookie-authed JSON APIs: require a custom header (e.g. `X-Requested-With`) and strict CORS — preflight enforcement makes cross-origin forgery fail; forms still need synchronizer tokens. - CORS: never `Access-Control-Allow-Origin: *` with `Allow-Credentials: true` (browsers reject it; reflecting `Origin` unvalidated recreates the hole — allowlist exact origins). ## Secrets in code and client bundles - Anything in frontend code ships to the attacker: API keys in `VITE_*`/`NEXT_PUBLIC_*` vars are public by definition — only put genuinely-public keys there (analytics write keys, maps keys with referrer locks). Server-only secrets must never appear in client-reachable modules; Next.js server/client boundary violations leak env into the bundle. - `grep` the built bundle for key prefixes (`sk_live`, `AKIA`, `ghp_`, `AIza`) as a release gate; gitleaks/trufflehog pre-commit and in CI for the repo itself. - Error responses: never echo stack traces, SQL, or internal paths to clients in production — log them server-side with the request ID, return the ID in the error body for correlation. ## postMessage and cross-origin ```ts // BAD — any window can send this; acting on it = universal XSS/state tampering window.addEventListener('message', (e) => applySettings(e.data)); // BAD — broadcasting secrets to whoever holds the window otherWindow.postMessage(token, '*'); // GOOD — verify origin on receive, target origin on send, validate payload window.addEventListener('message', (e) => { if (e.origin !== 'https://trusted.example.com') return; const msg = MessageSchema.safeParse(e.data); if (msg.success) handle(msg.data); }); iframe.contentWindow?.postMessage(payload, 'https://child.example.com'); ``` Treat `e.data` as untrusted input even from trusted origins (the trusted page may itself be compromised). Same discipline for `BroadcastChannel` and `window.opener` (use `rel="noopener"` on external links). ## Command injection via child_process ```ts // BAD — exec runs through a shell; filename = "x; rm -rf /" executes exec(`convert ${filename} out.png`); // BAD — shell:true reintroduces the hole in spawn spawn('convert', [filename], { shell: true }); // GOOD — execFile/spawn with array args, no shell: arguments are never parsed import { execFile } from 'node:child_process'; const { stdout } = await promisify(execFile)('convert', [filename, 'out.png'], { timeout: 10_000 }); ``` - `exec`/`execSync` with any interpolated value is CRITICAL. `execFile`/`spawn` (no `shell`) pass args directly to the binary. - Still validate the arg itself: allowlist characters/paths (argument injection — `--flag`-shaped filenames — can still subvert tools; prepend `--` where supported). - Path traversal cousin: joining user input into paths — `const p = path.resolve(base, name); if (!p.startsWith(base + path.sep)) reject();` - Same family: never interpolate into SQL (parameterized queries only), `Function`, YAML `load` (use `safeLoad` semantics), or `vm`. - **Node deprecated the dangerous spelling itself.** `DEP0190` — "Passing `args` to the `node:child_process` module's `execFile()` and `spawn()` methods with the `shell` option enabled is deprecated … the arguments are not properly escaped when passed to the shell". Measured on Node 22: `spawnSync('/bin/echo', ['$HOME'], {shell:true})` prints the expanded home directory **and emits the DEP0190 runtime warning**, while the same call without `shell` prints the literal `$HOME`. Treat a DEP0190 warning in logs or CI as a finding, not noise — it marks a live injection sink. - **`timeout:` bounds your wait, not the process tree, and may report no error.** Measured on Node 22 with a child that exits immediately after spawning a grandchild holding stdout: `execFile(..., {timeout: 300})` called back at **305 ms** — the deadline works — but the **grandchild was still running**, and `err` was **`null`**, because the direct child had exited 0. So the caller cannot tell from the callback that the deadline fired at all. If the child may fork, spawn it with `detached: true` and kill the process **group** (`process.kill(-child.pid)`), and decide explicitly what a timeout means for your caller rather than inferring it from `err`. ## Open redirects and file uploads Open redirect (`/login?next=https://evil.example`) launders phishing through your domain and chains into OAuth token theft. ```ts // BAD res.redirect(req.query.next as string); // GOOD — relative-path allowlist; reject absolute/protocol-relative const next = String(req.query.next ?? '/'); res.redirect(next.startsWith('/') && !next.startsWith('//') && !next.includes('\\') ? next : '/'); ``` File uploads: - Validate by magic bytes (`file-type` package), not extension or client `Content-Type`; both are attacker-controlled. - Generate server-side filenames (`crypto.randomUUID()` + validated extension); never use the client filename in paths (traversal) or HTML (XSS). - Size-cap at the parser (multipart limits), store outside the web root / in object storage, serve with `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff` for user content; SVGs are XSS vectors — sanitize or serve from a sandboxed origin. - Images: re-encode (sharp) to strip embedded payloads/EXIF. ## SSRF and server-side validation - Every inbound payload schema-parsed at the boundary (rules/01) — including webhooks (verify signatures: Stripe/GitHub HMAC) and headers you act on. - User-supplied URLs the server fetches (webhooks, importers, avatars): allowlist protocols (`https:` only), resolve DNS and block private ranges (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 — cloud metadata `169.254.169.254`), block redirects-to-private (re-check after each redirect, `redirect: 'manual'`), pin timeouts and response-size caps. Library: `ssrf-req-filter` or equivalent egress proxy. - Mass assignment: never `Model.update(req.body)` — schema-pick the allowed fields (`z.object({...}).strict()`). ## Timing and crypto hygiene - Compare secrets (HMAC signatures, API keys, tokens) with `crypto.timingSafeEqual` (equal-length buffers — hash both sides first if lengths vary), never `===` (timing oracle). - Randomness for anything security-relevant (tokens, IDs in URLs, reset codes): `crypto.randomUUID()` / `crypto.getRandomValues()` / `crypto.randomBytes` — never `Math.random()` (predictable, seedable state recovery is practical). - Password hashing: argon2id (or scrypt/bcrypt with sane cost), async variants only (rules/04); never SHA-256-of-password, never homegrown. - Web Crypto (`crypto.subtle`) for in-app encryption/signing; AES-GCM with unique IVs per encryption (IV reuse with GCM is catastrophic); keys from KMS/secret manager, not constants. ## Audit checklist - [ ] `grep -rn "innerHTML\|outerHTML\|insertAdjacentHTML\|document.write" src/` — each with non-constant input is HIGH/CRITICAL; constant strings LOW. - [ ] `grep -rn "dangerouslySetInnerHTML\|v-html\|{@html}\|bypassSecurityTrust" src/` — sanitized with DOMPurify at render? Unsanitized user/db content = CRITICAL (stored XSS). - [ ] `grep -rn "eval(\|new Function(\|setTimeout(['\"\`]\|setInterval(['\"\`]" src/` — CRITICAL with dynamic input. - [ ] `grep -rn "href={" src/ --include="*.tsx"` — user-controlled hrefs without protocol allowlist (`javascript:`) = HIGH. - [ ] `grep -rn "localStorage.setItem\|sessionStorage.setItem" src/ | grep -i "token\|jwt\|session\|auth\|key"` — HIGH. - [ ] `grep -rn "postMessage" src/` — `'*'` target with sensitive data (HIGH); message listener without origin check (HIGH). - [ ] `grep -rn "exec(\|execSync(" src/` — template/concat input = CRITICAL; migrate to execFile array form. - [ ] `grep -rn "child_process" src/ | grep "shell"` — `shell: true` (HIGH). - [ ] Deep-merge of request data: `grep -rn "merge(\|deepmerge\|Object.assign" src/` near `req.body`/`json()` — prototype pollution exposure (HIGH); `grep -rn "__proto__" src/` in tests/guards is good signal of awareness. - [ ] `grep -rn "jwt.verify\|jwtVerify" src/` — algorithm pinned? `aud`/`iss` checked? `grep -rn "algorithms" src/` absent = HIGH. - [ ] Regex on user input: `grep -rn "new RegExp(" src/` (dynamic patterns = ReDoS + injection risk, HIGH); run `eslint-plugin-regexp`/recheck over static patterns. - [ ] CSP present? `grep -rn "Content-Security-Policy" src/` — absent on HTML-serving apps (MEDIUM); contains `unsafe-inline`/`unsafe-eval` in script-src (MEDIUM). - [ ] Lockfile in git; CI uses `npm ci`/frozen lockfile; install scripts blocked (`.npmrc` `ignore-scripts`, pnpm allowlist, or npm ≥12 defaults + committed `approve-scripts` allowlist); install cooldown active (pnpm 11 `minimumReleaseAge` default, npm ≥11.10 `min-release-age`, or Renovate/Dependabot) (each absent: MEDIUM). - [ ] `grep -n "git+\|git://\|github:\|https://.*\.tgz" package.json` — git-URL or remote-tarball dependencies: unauditable, refused by npm ≥12 without `--allow-git`/`--allow-remote` (MEDIUM). - [ ] Server-side fetch of user-supplied URLs: private-IP/metadata blocking + redirect handling (absent = HIGH, SSRF). - [ ] Webhook handlers: signature verification before parsing (absent = HIGH). - [ ] `grep -rn "Allow-Origin" src/` — `*` with credentials or unvalidated Origin reflection (HIGH). - [ ] Built client bundle: `grep -rE "sk_live|AKIA|ghp_|-----BEGIN" dist/` — leaked secrets (CRITICAL). Repo: gitleaks in CI (absent = MEDIUM). - [ ] `grep -rn "NEXT_PUBLIC_\|VITE_" src/ .env*` — server secrets under public prefixes (CRITICAL). - [ ] Production error handler leaks stacks/SQL to clients (`grep -rn "err.stack\|error.stack" src/` in response paths) — MEDIUM. - [ ] Cookies: `grep -rn "Set-Cookie\|res.cookie" src/` — missing HttpOnly/Secure/SameSite on session cookies (HIGH). - [ ] `grep -rn "res.redirect\|window.location.*=\|location.href.*=" src/` with request-derived values — open redirect (MEDIUM/HIGH near auth flows). - [ ] Upload handlers: extension/MIME-only validation, client filename used in path (`grep -rn "originalname\|file.name" src/`) — HIGH. - [ ] `grep -rn "Math.random" src/` near token/id/code generation — HIGH; `grep -rn "=== .*signature\|signature ===" src/` — timing-unsafe compare (MEDIUM). -
06-performance.md 16.3 KB
# Performance: Bundles, React, Memory, Event Loop Measure before optimizing: bundle analyzer for size, React Profiler/DevTools for renders, Chrome Performance + heap snapshots for memory, `monitorEventLoopDelay` for Node. Optimizations without a measurement are style choices. ## Bundle discipline Every shipped KB is parse/compile/execute time on the median phone, not just transfer. - **Budget and analyze in CI**: `rollup-plugin-visualizer` / `webpack-bundle-analyzer` / `vite-bundle-visualizer`; budget per route (e.g. ≤200KB gz initial JS) enforced with `size-limit` so regressions fail PRs. - **Tree-shaking-friendly code**: ESM only; no module-level side effects (top-level mutations, auto-registration); `"sideEffects": false` in package.json (list CSS/polyfill exceptions). Barrel files (`index.ts` re-exporting everything) defeat dev-server perf and often shaking — import from the specific module, or use `optimizePackageImports`/eslint `no-barrel-files` policy. ```ts // BAD — pulls whole lib if package isn't shakeable; barrels chain-load the world import _ from 'lodash'; import { Button } from '@/components'; // barrel of 200 components // GOOD import debounce from 'lodash-es/debounce'; // or write the 10-liner yourself import { Button } from '@/components/button'; ``` - **Dynamic import for conditional weight**: routes, modals, charts, editors, anything below the fold or behind interaction. ```tsx const ChartPanel = lazy(() => import('./ChartPanel')); // + <Suspense fallback> // Non-React: const { parse } = await import('heavy-parser'); ``` - **Dependency weight check before adding**: bundlephobia/pkg-size; prefer date-fns over moment, valibot/zod-mini where bundle-critical. Duplicate-version check: `pnpm dedupe`, analyzer's duplicates view. - Ship modern JS (`target: 'es2022'`-ish browserslist) — transpiling classes/async down for dead browsers costs 20-30% size. - Fonts/images dwarf JS in LCP terms — but that's outside this file's scope; don't let JS micro-opts distract from a 2MB hero image. ## Startup: loading and hydration - `<script type="module">` defers by default; never blocking scripts in `<head>` without `defer`/`async`. `modulepreload` the critical graph; `rel=preconnect` to API/CDN origins. - Hydration cost scales with shipped component tree: RSC/islands architectures (Next App Router, Astro) exist to ship less of it — interactivity leaves only (`'use client'` discipline below). - Lazy-hydrate below-the-fold islands (visible-trigger) where the framework supports it; don't lazy-load anything needed for first interaction (you trade LCP for INP). - Third-party scripts are the usual LCP/INP killers: load analytics/chat after interactive (`next/script strategy="lazyOnload"` or equivalent), or move to a worker via Partytown when compatible. - Prefetch on intent: route prefetch on link hover/viewport (framework default — verify it's not disabled), data prefetch for the predictable next step. Cheap wins that beat any memoization. ### Profiling toolbox | Question | Tool | |---|---| | What's in the bundle? | vite-bundle-visualizer / webpack-bundle-analyzer, `size-limit` in CI | | Why is this interaction slow? | Chrome Performance panel (look for long tasks), React Profiler | | What re-rendered and why? | React DevTools Profiler, "record why" enabled | | Where's the memory going? | DevTools Memory: snapshot diff, allocation timeline | | Is the Node loop blocked? | `monitorEventLoopDelay`, clinic flame / 0x under load | | Real-user numbers? | web-vitals → RUM (LCP, INP, CLS) — lab numbers lie about phones | ## React rendering A render is a function call, not a DOM write — cheap-ish, but O(subtree) and they cascade. Hunt structural causes before memoizing. Ordered playbook: 1. **State down**: state used by one subtree must live there, not in a page-level component re-rendering everything per keystroke. 2. **Children as props / composition**: a component taking `children` doesn't re-render them when its own state changes — lift expensive subtrees out of frequently-updating wrappers. ```tsx // BAD — every tick re-renders <ExpensiveTree> function Page() { const t = useTick(); return <div><Clock t={t} /><ExpensiveTree /></div>; } // GOOD — move ticking state into Clock; ExpensiveTree untouched function Page() { return <div><Clock /><ExpensiveTree /></div>; } ``` 3. **Subscribe narrowly**: context splits (state vs dispatch), or selector-based stores (zustand/jotai) so components re-render on their slice only. A single fat context is the classic whole-app re-render. 4. **Then memoize, judiciously**: `React.memo` on expensive leaf/list components; `useMemo` for expensive computations; `useCallback` only to stabilize props of memoized children or effect deps. Memoizing everything adds comparison cost + complexity for nothing — and one unstable prop (inline object/array) silently voids `memo`. React Compiler 1.0 (stable since Oct 2025) auto-memoizes — when it's enabled, delete manual memo noise rather than adding more. 5. **Verify with the Profiler** (record → interact → check "why did this render") before and after. Other React essentials: - **Keys**: stable identity (`item.id`), never array index for reorderable/insertable lists (state and DOM get misattached), never `Math.random()` (full remount per render). - **Transitions**: wrap non-urgent updates (`startTransition`, `useDeferredValue` for derived expensive renders) so typing stays responsive while results lag gracefully. - **Server Components mental model** (Next.js App Router etc.): RSC run on the server only and ship zero JS — default everything server; add `'use client'` only at interactivity leaves. Don't pass non-serializable props across the boundary; push `'use client'` boundaries as deep as possible. Data-fetch in server components (no client waterfall, no useEffect-fetch). - **Effects**: `useEffect` is for synchronizing with external systems, not for derived state (compute during render) or event logic (put in handler). Effect-chains (`setState` in effect triggering next effect) cause render cascades — derive instead. `useEffectEvent` (React 19.2+) reads latest props/state inside an effect without adding them to deps — use it instead of dep-list gymnastics or stale-closure refs. - Avoid layout thrash: batch DOM reads then writes; in raw-DOM code interleaved `offsetHeight`/style writes force sync reflow per iteration. ### Re-render hunting workflow 1. React DevTools Profiler → enable "Record why each component rendered" → record the slow interaction. 2. Sort commits by duration; in the flamegraph find wide bars that shouldn't have rendered (props visually unchanged). 3. Common causes, in observed frequency order: - New object/array/function identity per render passed as prop: `<List style={{ margin: 8 }} onSelect={() => ...} />` — hoist constants, memoize handlers only when the child is memoized. - Context value rebuilt each render: `<Ctx.Provider value={{ user, setUser }}>` — memoize the value object, or split into two contexts. - Store subscription too broad: `const state = useStore()` instead of `useStore(s => s.cartCount)`. - Parent state that belongs in a child (form input state at page level). 4. Fix the structural cause; re-profile; only then add `memo` to remaining hot leaves. ```tsx // BAD — context identity changes every render; every consumer re-renders function App() { const [user, setUser] = useState<User | null>(null); return <UserCtx.Provider value={{ user, setUser }}>{children}</UserCtx.Provider>; } // GOOD — stable value; even better: separate state and dispatch contexts const value = useMemo(() => ({ user, setUser }), [user]); ``` Measure interaction health with INP (Interaction to Next Paint): long tasks >50ms between input and paint are the budget violations. `PerformanceObserver` with `{ type: 'event', durationThreshold: 40 }` or web-vitals library in RUM; break up long handlers with `await scheduler.yield()` between logical phases. ## Long lists: virtualization DOM nodes are the cost: 10k rows × 20 nodes kills layout/paint regardless of framework. - > ~200-500 rendered items (or heavy rows) → virtualize: `@tanstack/virtual`, `react-window`. Renders only the viewport ± overscan. - CSS `content-visibility: auto` + `contain-intrinsic-size` is a zero-JS alternative for long static pages. - Paginate/infinite-scroll at the data layer too — don't ship 50k records to the client to virtualize their DOM. - Virtualization breaks Ctrl-F and screen-reader sequence; for short lists just render them. ## Debounce and throttle - **Debounce** (trailing): act after input settles — search-as-you-type (200-300ms), autosave, resize-end. - **Throttle**: act at most every N ms during continuous events — scroll position, drag, mousemove. - Prefer platform primitives when they fit: `IntersectionObserver` over scroll handlers, `ResizeObserver` over resize handlers — they're off-main-thread scheduled and don't need throttling. ```tsx // React: keep the timer in a ref; cancel on unmount; or use TanStack Pacer/use-debounce const debouncedSearch = useMemo(() => debounce((q: string) => run(q), 250), []); useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); ``` Recreating the debounced fn every render (inline `debounce(...)` in the component body without memo) resets the timer each keystroke — the #1 debounce bug. For data fetching, debounce + abort previous request (rules/03) together. ## Caching and memoization (non-React) - Memoize pure-expensive functions with bounded caches; an unbounded memo on user-keyed input is a leak (below). ```ts import { LRUCache } from 'lru-cache'; const cache = new LRUCache<string, Report>({ max: 500, ttl: 60_000 }); async function getReport(id: string): Promise<Report> { const hit = cache.get(id); if (hit) return hit; const r = await buildReport(id); cache.set(id, r); return r; } ``` - Cache the promise, not just the value, to collapse concurrent misses (stampede pattern, rules/03). - Invalidate explicitly on write paths or accept TTL staleness deliberately — "cache + forgot invalidation" bugs masquerade as data corruption. - HTTP layer first: `Cache-Control`/`ETag` on API responses and CDN caching beat in-process caches for read-heavy public data. ## Memory leaks Long-lived references are the leak; SPAs and Node servers never get the page-refresh absolution. Usual suspects: - **Listeners/observers on long-lived targets** (`window`, `document`, sockets, emitters) added per component/request and never removed → remove in cleanup, or `addEventListener(..., { signal })` + one `abort()` (rules/03). Disconnect `IntersectionObserver`/`MutationObserver`/`ResizeObserver`. - **Timers**: `setInterval` without `clearInterval` keeps its closure (and everything it captures) alive forever. - **Closures over large scopes**: a small callback capturing a huge parsed payload pins it; extract what you need into locals before creating the long-lived closure. - **Module-level caches without bounds**: `const cache = new Map()` growing per-key forever in a server = slow OOM. Bound it (LRU — `lru-cache`), or key by object with `WeakMap` so entries die with their keys. - **Detached DOM**: keeping element refs (in arrays, maps, closures) after removal from the document retains whole subtrees. Heap snapshot → search "Detached". - **Node-specific**: per-request data stuffed into module/global scope; `EventEmitter` listeners accumulating (MaxListenersExceededWarning is a leak smell, not a limit to raise blindly); unbounded in-flight maps without `finally` cleanup. ```ts // BAD — closure pins the whole 50MB parsed report for the life of the listener const report = await parseHugeReport(file); emitter.on('tick', () => updateBadge(report.summary.count)); // GOOD — capture only the scalar const count = (await parseHugeReport(file)).summary.count; emitter.on('tick', () => updateBadge(count)); ``` Detection workflow (browser and Node `--inspect` alike): 1. Heap snapshot → perform the suspected-leaking action 3-5× → snapshot again. 2. Comparison view, sort by retained-size delta; look for arrays/maps/closures growing linearly with actions, and "Detached" DOM entries. 3. Follow the retainer chain upward to the root holding the reference — that's the fix site, not the leaked object's class. 4. Node services: export `process.memoryUsage().heapUsed`/RSS to metrics; alert on monotonic growth across hours; capture snapshots on signal (`v8.writeHeapSnapshot()` behind an admin endpoint) when it fires. ## Node event-loop blocking One blocked loop = every request stalled (full treatment in rules/04). Performance-audit angle: - Instrument: `perf_hooks.monitorEventLoopDelay()` histogram exported to metrics; alert p99 > 100ms. - Identify: clinic.js flame / `0x` flamegraphs under load; `blocked-at` in staging for stacks. - Fix order: cap input sizes → move CPU work to `piscina` workers → chunk unavoidable loops with `setImmediate` yields → cache the computation. - `JSON.stringify` of huge objects in logging/serialization is a stealth blocker — log IDs and summaries, not payload dumps. ```ts // Minimal loop-lag detector — cheap enough for production import { monitorEventLoopDelay } from 'node:perf_hooks'; const h = monitorEventLoopDelay({ resolution: 20 }); h.enable(); setInterval(() => { metrics.gauge('event_loop_p99_ms', h.percentile(99) / 1e6); h.reset(); }, 10_000).unref(); ``` ## Offloading and scheduling on the main thread - CPU work >50ms in the browser (parsing, diffing, search indexing, image manipulation): Web Worker (rules/03) — the main thread is for UI. Comlink removes the postMessage ceremony. - Truly-idle work (analytics aggregation, prefetch warmup): `requestIdleCallback` (with a timeout fallback) — never for anything user-visible. - Animation reads/writes: `requestAnimationFrame`; CSS transforms/opacity (compositor-only) over layout-triggering properties; `will-change` sparingly. - Chunked processing keeps input responsive: process N items → `await scheduler.yield()` → continue; combine with `AbortSignal` so navigation cancels the rest. ## Micro-level idioms that matter at scale only In hot paths (per-row in 100k iterations, per-frame): avoid spread-accumulator `reduce` (O(n²), rules/02), reuse compiled regexes, prefer `for...of` over chained array methods, avoid `try/catch`-free claims (modern engines made try cheap — don't contort code for it), and don't `delete obj.prop` in hot objects (deopts shapes; set to `undefined` or use Maps). Outside hot paths, write the readable version. ## Audit checklist - [ ] Bundle analyzer wired and a size budget enforced in CI (`size-limit`/bundlesize)? Absent on a frontend app = MEDIUM. - [ ] `grep -rn "from 'lodash'\|from \"lodash\"" src/` (non-`lodash-es`) and `from 'moment'` — heavy/cjs imports (MEDIUM). `grep -rn "import \* as" src/` — namespace imports of large libs. - [ ] `grep -rln "export \* from" src/` — barrel files on hot paths (LOW/MEDIUM). - [ ] `grep -rn "key={index}\|key={i}\|key={idx}" src/ --include="*.tsx"` — index keys on mutable lists (MEDIUM); `key={Math.random()` (HIGH — remount storm). - [ ] `grep -rn "useEffect" src/ --include="*.tsx" | wc -l` high relative to components → review for derived-state effects and fetch waterfalls (MEDIUM). - [ ] `grep -rn "useMemo\|useCallback\|React.memo" src/` — blanket memoization with unstable deps (inline objects in props of memoized components) = dead weight (LOW); missing memo on expensive list rows that profile hot (MEDIUM). - [ ] `grep -rn "'use client'" app/ src/` — at layout/page top level wholesale = RSC benefits discarded (MEDIUM in App Router projects). - [ ] Long lists: components mapping >hundreds of rows without virtualization (`grep -rn "\.map(" src/ --include="*.tsx"` + check data sizes) — MEDIUM. - [ ] `grep -rn "addEventListener" src/` without matching remove/`{ signal }`; `grep -rn "setInterval" src/` without `clearInterval` — leaks (MEDIUM). - [ ] `grep -rn "new Map()\|new Map<" src/` at module scope in server code — unbounded cache check (MEDIUM if grows per-request/per-user key). - [ ] `grep -rn "onScroll\|onMouseMove\|onResize\|addEventListener('scroll'" src/` — unthrottled continuous handlers doing layout reads (MEDIUM); could be Intersection/ResizeObserver. - [ ] Node: event-loop delay metric exported? `grep -rn "monitorEventLoopDelay" src/` — absent in a latency-sensitive service (LOW/MEDIUM). - [ ] `grep -rn "JSON.stringify" src/` in logging/hot request paths over large objects (MEDIUM). -
07-testing-and-tooling.md 14.6 KB
# Testing & Tooling ## Test runner: vitest (apps), node:test (deps-free libs) Vitest (v4 current): native ESM/TS, vite-config reuse, watch mode, `projects` for multi-config repos (the old `workspace` option was removed in v4), jest-compatible API. Vitest 4 also stabilized Browser Mode (via `@vitest/browser-playwright` etc.) — real-browser component tests where jsdom fidelity isn't enough. Don't start new projects on jest (CJS-era transform pain). Pure libraries with zero build can use `node:test` and skip the dependency entirely. ```ts // vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', // 'jsdom'/'happy-dom' only for component tests (use `projects` to split) coverage: { provider: 'v8', thresholds: { lines: 80, branches: 75 } }, restoreMocks: true, // auto-restore between tests — prevents cross-test mock bleed setupFiles: ['./test/setup.ts'], }, }); ``` Practices: - Structure: Arrange-Act-Assert; one behavior per test; name as behavior (`'rejects expired tokens'`), not method names (`'test verifyToken'`). - Specific matchers: `toEqual` for deep structural, `toBe` for identity/primitives, `toMatchObject` for partial, `expect(fn).rejects.toThrow(SpecificError)` for async errors. Never `expect(await fn().catch(e => e)).toBeDefined()`-style mush. - Snapshot tests only for genuinely stable serialized output (CLI output, codegen); component snapshot sprawl is change-detector noise — assert specific things instead. - Fake time explicitly: `vi.useFakeTimers()` + `vi.setSystemTime()`; restore in `afterEach`. Flaky time/`Date.now` math in tests is a bug factory. - Test data via factories with overrides (`makeUser({ role: 'admin' })`), not 50-line fixture JSON copies. - `it.each` for input/output tables; property-based testing (`fast-check`) for parsers/serializers/invariants. - Mock module boundaries (`vi.mock`) sparingly — heavy module-mocking is a design smell; prefer injecting dependencies (function params, constructor args) so tests pass fakes naturally. ### Shape of the suite Honeycomb, not pyramid, for typical web services: most value sits in integration-level tests (route handler → real validation → in-memory/testcontainer DB → response). Pure-unit-test what is genuinely algorithmic (parsers, pricing, reducers); E2E only the money paths. Signs of an inverted suite: hundreds of tests mocking every collaborator, green CI, production bugs in the seams. ```ts // Integration over a real boundary — fastify.inject hits routing, schema, handler, serializer const res = await app.inject({ method: 'POST', url: '/orders', payload: { sku: 'A1', qty: 2 } }); expect(res.statusCode).toBe(201); expect(OrderSchema.parse(res.json())).toMatchObject({ sku: 'A1' }); ``` - Database tests: testcontainers (real Postgres in Docker) over fragile mocks of the query builder; transaction-per-test rollback for speed. - Determinism rules: no real network (MSW errors on it), no real time (fake timers), no shared mutable fixtures, no test-order dependence (`vitest --sequence.shuffle` in CI surfaces it). - A flaky test is a P1 on the suite: quarantine immediately, fix or delete within days — tolerated flake trains the team to ignore red. ## Testing-library: test behavior, not implementation Tests should survive refactors that don't change behavior. Query like a user, assert what the user sees. ```tsx // BAD — implementation-coupled: breaks on rename/restructure, passes when a11y is broken const { container } = render(<Login />); fireEvent.click(container.querySelector('.submit-btn')!); expect(setStateSpy).toHaveBeenCalledWith({ loading: true }); // GOOD — role-based queries + userEvent + visible outcome const user = userEvent.setup(); render(<Login />); await user.type(screen.getByLabelText(/email/i), 'a@b.co'); await user.click(screen.getByRole('button', { name: /sign in/i })); expect(await screen.findByRole('alert')).toHaveTextContent(/invalid credentials/i); ``` - Query priority: `getByRole` > `getByLabelText` > `getByPlaceholderText` > `getByText` > `getByTestId` (last resort). Unreachable-by-role often means inaccessible markup — fix the component. - `userEvent` over `fireEvent` (simulates real event sequences: focus, keydown, input). - Async: `await screen.findBy...` / `waitFor` for assertions; never arbitrary `setTimeout` sleeps. Don't `waitFor` with side effects inside the callback. - Don't assert internal state, spy on setState, or shallow-render. Don't test "renders without crashing" only. - The same philosophy applies server-side: test handlers via HTTP (`fastify.inject`, supertest) against responses, not by spying on internals. ## Network: MSW Mock at the network boundary, not the fetch wrapper — tests then exercise your real client code (serialization, error handling, retries). ```ts // test/handlers.ts import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/users/:id', ({ params }) => HttpResponse.json({ id: params.id, email: 'a@b.co' })), ]; // test/setup.ts import { setupServer } from 'msw/node'; export const server = setupServer(...handlers); beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); // fail on unmocked calls afterEach(() => server.resetHandlers()); afterAll(() => server.close()); // per-test override for error paths server.use(http.get('/api/users/:id', () => HttpResponse.json({}, { status: 500 }))); ``` - `onUnhandledRequest: 'error'` — silent passthrough hides missing mocks and accidental real network in CI. - Same handlers reusable in the browser (`setupWorker`) for Storybook/dev. - Don't mock your own modules to avoid network (`vi.mock('./api')`) — that skips the very code most likely to be wrong. ## E2E: Playwright Unit/integration tests for logic breadth; a thin Playwright layer for critical user journeys (signup, checkout, the money paths) — not a port of every unit case. - Web-first assertions auto-retry: `await expect(page.getByRole('button')).toBeEnabled()` — never `page.waitForTimeout` (flake generator, grep-able finding). - Same locator philosophy as testing-library: `getByRole`/`getByLabel` over CSS/XPath. - Isolate state: storageState fixture per auth role (login once via API, reuse); each test owns its data; no inter-test order dependence. - `trace: 'on-first-retry'` for CI debugging; run against production builds; shard in CI. - API-seed test data; don't drive setup through the UI. ## ESLint flat config + typescript-eslint strict Flat config (`eslint.config.js`) is the only supported format since ESLint 9; ESLint 10 (Feb 2026) removes the eslintrc system entirely, resolves config from each linted file's directory (multiple configs per run — monorepo-friendly), and requires Node ≥20.19. typescript-eslint v8 supports ESLint 9 and 10. Type-aware strict preset catches real bugs (floating promises, unsafe any-flow) that syntax-only linting can't. ```js // eslint.config.js import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.strictTypeChecked, // not just "recommended" ...tseslint.configs.stylisticTypeChecked, { languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } }, rules: { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/switch-exhaustiveness-check': 'error', '@typescript-eslint/consistent-type-imports': 'error', 'eqeqeq': ['error', 'always'], 'no-restricted-syntax': ['error', { selector: "CallExpression[callee.name='eval']", message: 'eval is banned' }], }, }, { files: ['**/*.test.ts'], rules: { '@typescript-eslint/no-unsafe-assignment': 'off' } }, ); ``` - `projectService: true` (replaces `project: true` boilerplate) enables type-aware rules with good perf. - Formatting belongs to Prettier (or Biome): no stylistic-format ESLint rules fighting the formatter. Biome is a fast single-tool alternative when its rule coverage suffices — but typescript-eslint's type-aware rules have no Biome equivalent yet; security-sensitive repos keep typescript-eslint. - Useful plugins: `eslint-plugin-regexp` (ReDoS), `eslint-plugin-react-hooks` (v6+: flat-config presets, React-Compiler-powered rules — `recommended-latest` to opt in), `eslint-plugin-jsx-a11y`, `eslint-plugin-import-x` (cycles: `import-x/no-cycle`). - Downgrading errors to warnings "to get CI green" creates a permanent warning swamp — fix or explicitly disable per-line with a reason comment. ## Formatting and pre-commit - One formatter, zero debate: Prettier (default) or Biome format (faster, one tool with its linter). Config committed; editor format-on-save; CI checks `--check`. No ESLint formatting rules alongside (`eslint-config-prettier` if any legacy stylistic rules linger). - Pre-commit via lefthook/husky + lint-staged: format + eslint --fix on staged files only. Keep hooks <5s — slow hooks get `--no-verify`'d into irrelevance; typecheck and tests belong in CI, not pre-commit. ```yaml # lefthook.yml pre-commit: parallel: true commands: lint: { glob: '*.{ts,tsx}', run: 'eslint --fix {staged_files} && git add {staged_files}' } format: { glob: '*.{ts,tsx,json,md}', run: 'prettier --write {staged_files} && git add {staged_files}' } ``` - TS build perf in CI: `tsc -b --incremental` with restored `.tsbuildinfo` cache; in monorepos, run typecheck per-package via turborepo/nx so only affected packages pay. ## Dead code: knip Knip finds unused files, exports, types, and dependencies — the stuff `tsc` can't see because exported-but-never-imported is still "used" to the compiler. ```jsonc // knip.json { "entry": ["src/index.ts", "src/cli.ts"], "project": ["src/**/*.ts"] } ``` - Run in CI (`knip --reporter compact`); triage with `--include dependencies` first (unused deps = supply-chain surface, rules/05), then files, then exports. - Pairs with `tsc --noUnusedLocals --noUnusedParameters` (intra-file) — knip covers inter-file. - Knip's output is a **candidate list, not a finding**: a package loaded dynamically (string `require`, plugin manifest, DI by convention) reads as unused, and a package whose symbol is referenced only on an unreachable branch reads as used. Prove each candidate by deleting it in a scratch copy and running the real build + suite — `sota-devsecops` rules/10. - Unused exports kill tree-shaking analysis precision and mislead readers; deleting code is a feature. ## Library publishing: publint + arethetypeswrong Broken `exports`/types maps are the top npm-library bug class (works in dev, breaks in consumers' bundlers or `NodeNext` resolution). ```jsonc // package.json for an ESM-first library { "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist"], "sideEffects": false } ``` - `npx publint` — validates packaging (exports map, file presence, ESM/CJS field correctness). - `npx @arethetypeswrong/cli --pack` — validates types resolve under every resolution mode (node16 ESM/CJS, bundler); catches "false ESM" and masquerading-CJS d.ts. - Both run in CI before publish. `files` allowlist prevents leaking `.env`/configs into tarballs. - Dual ESM/CJS only if consumers demand it (tsup/unbuild make it tolerable); otherwise ESM-only and say so in the README. ## Type-level testing Libraries and complex generics deserve type tests — types are API surface and they regress silently. ```ts import { expectTypeOf } from 'vitest'; test('parse narrows to branded id', () => { expectTypeOf(parseUserId('x')).toEqualTypeOf<UserId>(); // @ts-expect-error — plain string must not be assignable const _: UserId = 'raw'; }); ``` `vitest --typecheck` runs these; `@ts-expect-error` lines double as negative type assertions. For published libraries, snapshot the public API surface with `api-extractor` or `tsd` so breaking type changes show up in review. ## CI gates (the minimum bar) Order fast→slow, all blocking: `tsc --noEmit` → eslint → unit/integration (vitest, coverage thresholds) → knip → build → playwright smoke. Plus `npm audit`/osv-scan and lockfile-frozen install (rules/05). A repo where `tsc --noEmit` isn't in CI will accumulate `as any` until types are decorative. ```yaml # .github/workflows/ci.yml — minimal blocking pipeline steps: - uses: actions/checkout@<pinned-sha> - uses: actions/setup-node@<pinned-sha> with: { node-version-file: '.nvmrc', cache: 'pnpm' } - run: pnpm install --frozen-lockfile - run: pnpm tsc --noEmit - run: pnpm eslint . --max-warnings 0 - run: pnpm vitest run --coverage - run: pnpm knip - run: pnpm build ``` `--max-warnings 0` keeps warnings from becoming wallpaper. Cache pnpm store, not node_modules. Pin action SHAs (rules/05). ## Audit checklist - [ ] CI runs typecheck, lint, tests as blocking steps — `cat .github/workflows/*.yml | grep -E "tsc|eslint|vitest|test"`; missing typecheck gate = MEDIUM. - [ ] ESLint config is flat + `strictTypeChecked` (type-aware): `grep -rn "strictTypeChecked\|projectService" eslint.config.*` — syntax-only linting in a TS repo = MEDIUM. - [ ] `grep -rn "eslint-disable" src/ | grep -v -- "--"` — disables without reason comments; count trend (LOW each, MEDIUM in volume). - [ ] `grep -rn "querySelector\|container\." src/**/*.test.tsx` and `getByTestId` density — implementation-coupled tests (LOW/MEDIUM). - [ ] `grep -rn "fireEvent" src/` in component tests — should be `userEvent` (LOW). - [ ] `grep -rn "waitForTimeout\|setTimeout" e2e/ tests/` — sleep-based waits = flake (MEDIUM). - [ ] `grep -rn "vi.mock(\|jest.mock(" src/ | wc -l` high vs test count — module-mock-heavy suite; check MSW present for network (`grep -rn "msw" package.json`) (MEDIUM if fetch wrappers are mocked instead). - [ ] MSW server with `onUnhandledRequest: 'error'`? Real network calls in unit tests (`grep -rn "localhost\|https://" src/**/*.test.ts`) = MEDIUM (flaky + slow). - [ ] Coverage thresholds configured and honest (no `**/index.ts` exclusion games) — absent = LOW; tests asserting nothing (`grep -rn "expect(" -L` on test files) = HIGH for the affected area. - [ ] knip (or equivalent) in CI? Run `npx knip` during audit — large unused-dependency list = MEDIUM (supply-chain surface). - [ ] Libraries: `npx publint && npx @arethetypeswrong/cli --pack` clean; `files` allowlist present — failures = HIGH for published packages. - [ ] Snapshot test sprawl: `grep -rln "toMatchSnapshot" src/ | wc -l` — high count = change-detector suite (LOW/MEDIUM). - [ ] Test factories vs giant fixtures; fake timers restored (`restoreMocks: true` or explicit afterEach) — mock bleed causes order-dependent flake (MEDIUM).
-
-
SKILL.md 8 KB
--- name: sota-javascript-typescript description: State-of-the-art JavaScript and TypeScript engineering (2026) for both writing and auditing code. Covers strict TypeScript configuration and type design, language idioms and pitfalls, async patterns, Node.js backends, JS/TS-specific security (XSS, prototype pollution, supply chain, injection), frontend/React and Node performance, and testing/tooling. Use whenever building, reviewing, refactoring, or security-auditing code involving JavaScript, TypeScript, Node, npm, React, frontend code, tsconfig, package.json, vitest, or any .ts/.tsx/.js/.mjs files. --- # SOTA JavaScript / TypeScript Engineering ## Purpose This skill encodes 2026 state-of-the-art for JS/TS so generated code is strict, secure, and fast by default — and so audits of existing code find the bug classes that actually bite: untyped boundaries, floating promises, XSS sinks, prototype pollution, supply-chain gaps, event-loop blocking, and leak-prone listeners. It has two operating modes; pick one explicitly at the start of a task. Baseline assumptions (mid-2026): TypeScript ≥5.9 strict (6.0 = last JS-based compiler; 7.0 Go-native stable since July 2026, shipped as the regular `typescript` package — frameworks embedding the compiler API via Volar, e.g. Vue/Angular/Astro/Svelte, stay on 6.0 until a stable plugin API lands), ESM-first, Node LTS ≥22 (24 = active LTS; Node 26 ships Temporal by default), ES2024+ available, React 19.2-era with Server Components and React Compiler 1.0 where relevant, vitest 4 + flat-config ESLint (v9/v10). ## BUILD mode (writing or modifying code) 1. **Read the relevant rules files first** (index below) for the area you're touching. Don't generate from memory what a rules file specifies. 2. **Defaults unless the codebase dictates otherwise**: strict tsconfig (rules/01), ESM, `unknown` over `any`, discriminated unions for state, zod/valibot parse at every untrusted boundary, `??`/`?.` discipline, AbortController on cancellable ops, pino logging in services, Web APIs over deps. 3. **Match the host codebase** for style, framework, and structure — but do not replicate its security bugs or `any`-sprawl into new code. New code meets the bar even in old repos. 4. **Boundary rule**: every input from outside the type system (HTTP, env, JSON.parse, storage, postMessage, DB without typed client) is parsed with a schema before use. No `as T` on external data. 5. **Finish the job**: new code compiles under `tsc --noEmit`, passes lint, and ships with behavior-level tests (rules/07). Handle the error path of every async call — no floating promises. 6. When a requirement conflicts with a rule (e.g., legacy CJS, jest), follow the codebase and note the deviation; don't silently half-apply both. ## AUDIT mode (reviewing existing code) Scope first (frontend? Node service? library?), then read the matching rules files and run their audit checklists — each ends with grep/eslint hunt patterns. Validate findings: confirm attacker-controlled data actually reaches the sink, confirm the perf issue is on a hot path. No speculative findings. **Severity conventions:** - **CRITICAL** — remotely exploitable now: untrusted input reaching eval/innerHTML/exec/SQL, auth bypass, secrets exfiltratable via XSS. - **HIGH** — exploitable with conditions, or guaranteed-corruption bug class: missing boundary validation, prototype-pollution-prone merge of request data, floating promises dropping errors, tokens in localStorage, unbounded request bodies, float money math, sync crypto blocking the loop. - **MEDIUM** — weakened posture or latent defect: missing strict tsconfig flags, no graceful shutdown, missing CSP/timeouts, `||` vs `??` on falsy-valid values, index keys on mutable lists, unbounded caches, missing supply-chain controls. - **LOW** — hygiene/debt: dead deps, `hasOwnProperty`, console.log in services, snapshot sprawl, missing memoization on profiled-hot paths. **Finding format:** ``` [SEVERITY] Title (CWE-xxx if security) File: path/to/file.ts:42 Issue: what is wrong, in one or two sentences Evidence: the offending snippet Impact: what an attacker/user/operator experiences Fix: concrete change (code if short) ``` Order the report CRITICAL→LOW, deduplicate repeated patterns into one finding with a file list, and end with the top 3 systemic recommendations (e.g., "enable noUncheckedIndexedAccess", "adopt MSW", "add zod to route boundaries"). ## Rules index | File | Read this when... | |---|---| | [rules/01-typescript-config-and-types.md](rules/01-typescript-config-and-types.md) | touching tsconfig; designing types/interfaces; seeing `any`/casts; modeling state; validating input shape; setting up a library or monorepo; deciding zod-vs-types questions | | [rules/02-language-idioms.md](rules/02-language-idioms.md) | writing any JS/TS logic: equality, `??`/`?.`, array methods, immutability, Map/Set, **in-band sentinels (absence encoded as `-1`/`0`/`""`)** — **`-1` lies where `NaN` poisons**, error classes and Result types, generators, dates (Temporal), money/number precision | | [rules/03-async-patterns.md](rules/03-async-patterns.md) | anything with promises/async: combinator choice, floating promises, AbortController/timeouts, event-loop ordering, top-level await, workers, streams, async race conditions | | [rules/04-node-backend.md](rules/04-node-backend.md) | building/auditing Node services: runtime choice, dropping deps for built-ins, env config, HTTP hardening (timeouts/body limits), graceful shutdown, process error policy, pino | | [rules/05-security.md](rules/05-security.md) | any security-relevant code or audit: XSS sinks, CSP, prototype pollution, npm supply chain, ReDoS, token storage/JWT, postMessage, child_process injection, SSRF | | [rules/06-performance.md](rules/06-performance.md) | bundle size, React re-renders/keys/RSC, virtualization, debounce/throttle, memory leaks, Node event-loop blocking, profiling before optimizing | | [rules/07-testing-and-tooling.md](rules/07-testing-and-tooling.md) | writing tests or setting up tooling: vitest, testing-library behavior testing, MSW, Playwright, ESLint flat + typescript-eslint strict, knip, publint/attw, CI gates. **Test *strategy* — suite shape, TDD, doubles, test data, flake policy — lives in `sota-testing`; load it for any build that writes logic. This file owns JS/TS runner mechanics only.** | For a full audit, read 01→07 in order; security-focused audits prioritize 05, 01, 03, 04. ## Top 10 non-negotiables 1. **`strict: true` + `noUncheckedIndexedAccess`** in every tsconfig; no `any`, no `@ts-ignore` — `unknown` + narrowing, `@ts-expect-error` with reason. 2. **Parse, don't cast, at boundaries**: zod/valibot on every HTTP body/query, env var, JSON.parse, webhook, postMessage payload. Types inside, schemas at the edge. 3. **No floating promises**: every promise awaited or explicitly `.catch`-handled; `@typescript-eslint/no-floating-promises` as error. `allSettled` for independent work; bounded concurrency. 4. **Discriminated unions for state**, exhaustive `switch` with `never` default — no boolean-soup interfaces with optional data/error pairs. 5. **`===` always; `??`/`?.` over `||`/`&&`** for null-handling; immutable updates (`toSorted`, `structuredClone`, spread) on shared data. 6. **Errors are `Error` subclasses with `cause`**; never throw strings; never swallow with empty catch; Node policy = log fatally then exit on uncaught. 7. **XSS sinks are forbidden by default**: no `innerHTML`/`dangerouslySetInnerHTML` with non-constant input unless DOMPurify-sanitized at render; no eval family ever; CSP on HTML responses. 8. **No secrets in localStorage; no shell interpolation**: httpOnly cookies for tokens; `execFile`/`spawn` array-args, never `exec` with template strings; parameterized SQL. 9. **Supply chain controlled**: committed lockfile + `npm ci`, install scripts disabled/allowlisted, update cooldown, minimal deps — prefer platform built-ins (fetch, node:test, crypto.randomUUID). 10. **AbortController + timeouts on all I/O**; never block the event loop (>50ms CPU → worker; no `*Sync` in request paths); listeners and intervals always cleaned up.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.