Node backend
Use when implementing the backend of a Node Web Only server - covers src/ module architecture, node:http + ws server, routing, serving a vendored public/ SPA, error handling, JSON-file storage, scrypt/cookie authentication, native-addon release implications, and config loadingFrom its SKILL.md
npx -y skills add Tanq16/claudex --skill node-backendAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 4 stars4 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
9.3 KB, ~2.2k tokens by cl100k_base, as published. Nobody here has run it
Node Backend
Architecture and implementation patterns for the backend of a Node Web Only server.
When to Use
Use this skill when:
- Structuring the
src/backend of a Node Web Only project - Building the
node:http+wsserver, routing, and static serving - Handling errors across ESM modules
- Designing JSON-file storage/persistence
- Adding password + session authentication
- Deciding the release path when a native addon is involved
Requires: node-foundations for project layout and principles.
Web Only constraint: Node Web Only is the only Node project type today — a process that is invoked and serves (HTTP + WebSocket) a vendored single-page frontend. There is no CLI Only, CLI + Web, or Library Node type yet; those are future/out-of-scope. The backend uses builtins first (node:http, node:crypto, node:fs, node:path, node:url) and adds a dependency only when a builtin genuinely cannot cover the need. Logging is console.* with manual level prefixes (INFO/ERROR/DEBUG), timestamped and sequential — no logging-framework dependency, no color. This mirrors the Go Web Only log-package discipline.
Start here — required reading
Read the Always file now, in full, before building the server — it carries the canonical server structure you'll be held to. Read the When file before the sub-task it names; a subagent may read it if you delegate that work.
Always:
./references/http-ws-server.md— the canonicalnode:http+wsserver: routing, static serving, upgrade, broadcast, shutdown
When adding password + session authentication:
./references/auth-patterns.md— scrypt hash/verify, signed-cookie sessions,users.json/state.jsonpersistence
Package/Module Architecture
Backend code lives under src/, organized by feature — not by technical layer:
src/
├── server.js # node:http server, upgrade handler, graceful shutdown
├── router.js # request routing + static serving from public/
├── auth.js # scrypt hashing, session cookies, users.json
├── state.js # state.json read/atomic-write
├── config.js # defaults + config.json deep-merge
└── ws.js # websocket message dispatch + broadcast
bin/app.js is a thin launcher: it reads the config path from argv, calls loadConfig, and hands off to start(config) in src/. Keep entry-point concerns (argv, process signals, process.exit) in bin/ and src/server.js; the feature modules stay pure.
ESM module boundaries
Pure ESM ("type": "module" in package.json): import/export only, no require. Each module exports the functions its callers need and nothing else — no default-export grab-bags. Import builtins with the node: prefix (import { createServer } from 'node:http'). Top-level await is available in entry modules; use it for one-time async setup (loading users, reading state) rather than wrapping the whole file in an IIFE.
HTTP + WebSocket Server
One node:http server handles both HTTP and the WebSocket upgrade — do not stand up a second listener. Create the server with a request handler that routes API paths and otherwise serves the vendored public/ SPA; attach a ws WebSocketServer({ noServer: true }) and complete the handshake yourself in the server's upgrade event so you can authenticate before accepting the socket.
const server = createServer((req, res) => route(req, res, config));
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
if (new URL(req.url, 'http://localhost').pathname !== '/ws') {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
Static serving reads files from public/ (the frontend node-frontend vendors), guards against path traversal, and falls back to index.html for unknown non-API paths so client-side routing works. Graceful shutdown lives in the launcher/start: close open sockets, server.close(), and force-exit after a bounded timeout. node-backend owns the canonical server — the full routing, static-serving, upgrade, broadcast, and shutdown code lives in ./references/http-ws-server.md; node-frontend references that file rather than redefining it.
Error Handling
Feature modules return or throw — they never call process.exit. process.exit and process.on('SIGTERM', ...) belong to the entry layer (bin/app.js, src/server.js start), the Node analog of Go's "context and logging at boundaries."
- Task-style helpers (storage, hashing, parsing) throw or return as-is — no logging, no wrapping for its own sake. Let the error carry its
code(ENOENT, etc.) so callers can branch on it. - The request handler is the boundary: wrap the route body in
try/catch, log withconsole.error('... ERROR ...'), and send a generic 500 — never leak stack traces or internal messages to the client. - An uncaught throw inside an async request handler will not crash a well-formed server if you catch at the route boundary; do not rely on
process.on('uncaughtException')as normal control flow.
Storage Pattern
Most Node Web Only projects are JSON-file backed — no database. Two distinct files with different lifecycles:
users.json— credentials, re-read on each login (not cached at boot), so an operator can edit users without restarting. Read-only from the server's perspective.state.json— the only durable server-written state (e.g. a mode flag). Written at mode0600, and written atomically via write-to-temp +rename, becauserenameis atomic on POSIX and prevents a crash mid-write from leaving a truncated JSON file.
export async function writeState(path, state) {
const tmp = `${path}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
await rename(tmp, path); // atomic replace; a crash leaves the old file intact
}
Full read/write helpers are in ./references/auth-patterns.md.
Authentication
Password + session-cookie auth, all from node:crypto — no auth framework.
- Password hashing:
scryptwith a per-user random salt; storesalt:derivedKey(hex). Verify withtimingSafeEqual. On an unknown username, still run a verify against a dummy hash so response time does not reveal whether the account exists. - Sessions: a signed token, not server-side session storage. Sign
base64url(payload)with HMAC-SHA256 using an ephemeral secret (randomBytes(32)) generated on boot and never persisted — so every restart invalidates all sessions, which is the intended trade-off for a single-process app. Validate by recomputing the HMAC and comparing withtimingSafeEqual, then checking expiry. - Cookies:
HttpOnly(no JS access),SameSite=Lax(CSRF mitigation for top-level navigations),Path=/, and a boundedMax-Age. SetSecuretoo when served behind TLS.
Complete hash/verify, session, and cookie helpers are in ./references/auth-patterns.md.
The native-addon note
A pure-JS Web Only app can ship as a single self-contained binary (bun build --compile or Node SEA — see project-ci-cd). The moment the backend needs a C++ N-API addon (e.g. node-pty for a real PTY, sharp, better-sqlite3), that single-binary path breaks: the addon is a platform-specific compiled .node file that the binary embedders cannot bundle and load the way they bundle JS. The release path then becomes the runtime-bundled tarball: a per-platform .tar.gz carrying the Node runtime + the compiled .node (+ any helper such as spawn-helper) + vendored assets, with a launcher that injects --config. Detailed toolchain, the debian:*-slim (glibc, never Alpine/musl) Docker base, and the make verify addon-smoke-test all live in project-ci-cd. Reach for a native addon only when a builtin or pure-JS dependency genuinely cannot do the job — it is a real cost to the release story.
Config
Built-in defaults, deep-merged with an optional user config.json; env/flags may override on top. Load once at boot and pass the resulting object into start.
const defaults = {
host: '127.0.0.1',
port: 8080,
usersFile: 'users.json',
stateFile: 'state.json',
};
export function loadConfig(path) {
const overrides = path ? JSON.parse(readFileSync(path, 'utf8')) : {};
return deepMerge(defaults, overrides);
}
The ephemeral session secret is not config — it is regenerated on every boot and never written to disk. Only durable state belongs in state.json; secrets and derived runtime values do not.
References
| File | Purpose |
|---|---|
./references/http-ws-server.md | Canonical node:http + ws server — routing, static serving from public/, upgrade handler, broadcast helper, graceful shutdown (referenced by node-frontend) |
./references/auth-patterns.md | scrypt hash/verify, signed-cookie sessions with an ephemeral HMAC secret, users.json + state.json atomic persistence |
What ships with it: 2 files
12.6 KB alongside SKILL.md
references/
- auth-patterns.md6.3 KB
- http-ws-server.md6.3 KB