node-comfortv2.0.0

Recipes

Complete examples that combine several namespaces. Copy them, then trim what you don't need. The ones that use await at the top level are ES modules (.mjs, or "type": "module" in your package.json).

A configuration you can trust

Check everything at startup, fail with the full list of problems, and use typed values everywhere else.

// config.js
const nc = require("@ix-xs/node-comfort");

module.exports = nc.env.validate({
  NODE_ENV: { type: "enum", values: ["development", "production", "test"], default: "development" },
  PORT: { type: "port", default: 3000 },
  DATABASE_PATH: { type: "string", default: "data/app.sqlite" },
  JWT_SECRET: { type: "string", minLength: 32 },
  CORS_ORIGINS: { type: "list", default: [] },
  REQUEST_TIMEOUT: { type: "duration", default: "10s" },
});

A command-line tool

#!/usr/bin/env node
// build-images.mjs
import nc from "@ix-xs/node-comfort";

const { cli } = nc;

const { flags, positionals } = cli.args({
  out: { type: "string", short: "o", default: "dist", description: "Output folder" },
  minify: { type: "boolean", description: "Minify the output" },
  concurrency: { type: "number", short: "c", default: 4 },
}, { name: "build-images", usage: "<folder> [options]", version: "1.0.0" });

const source = positionals[0] ?? (await cli.prompt("Folder to process?", { default: "images" }));
const files = nc.glob("**/*.{png,jpg}", { cwd: source, absolute: true });

if (!files.length) {
  nc.warn(`No images in ${source}`);
  process.exit(0);
}
if (cli.isInteractive() && !(await cli.confirm(`Process ${files.length} images?`, { default: true }))) process.exit(0);

const bar = cli.progress({ total: files.length, format: "{bar} {percent}% {label}" });
await nc.async.forEach(files, async (file) => {
  await optimize(file, nc.fs.createPath(`${flags.out}/${nc.str.slugify(file)}`));
  bar.tick(1, file);
}, { concurrency: flags.concurrency });
bar.stop();

nc.box(`${files.length} images written to ${flags.out}`, { title: "done", borderColor: "green" });

An API client with retries and caching

// github.mjs
import nc from "@ix-xs/node-comfort";

const github = nc.http.create({
  baseURL: "https://api.github.com/",
  headers: { "x-github-api-version": "2022-11-28" },
  auth: { bearer: process.env.GITHUB_TOKEN },
  timeout: "10s",
  retry: { attempts: 4 },
});

const repos = new nc.Cache({ max: 500, ttl: "10m" });

async function getRepo(fullName) {
  return repos.getOrSet(fullName, async () => (await github.get(`repos/${fullName}`)).data);
}

const names = ["nodejs/node", "microsoft/TypeScript", "vitejs/vite"];
const results = await nc.async.map(names, getRepo, { concurrency: 2 });
nc.table(results.map((r) => ({ name: r.full_name, stars: nc.num.abbreviate(r.stargazers_count) })));

Validating a request body

Works with any framework. Here with Node's own http module:

const http = require("node:http");
const nc = require("@ix-xs/node-comfort");
const { schema: s } = nc;

const CreateUser = s.object({
  email: s.string().trim().email().toLowerCase(),
  password: s.string().min(12),
  name: s.string().trim().min(1).max(80),
  newsletter: s.boolean().default(false),
});

http.createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;

  const result = CreateUser.safeParse(nc.JSONParse(body, null));
  if (!result.success) {
    res.writeHead(400, { "content-type": "application/json" });
    return res.end(JSON.stringify({ errors: result.error.flatten() }));
  }

  const passwordHash = await nc.crypto.hashPassword(result.data.password);
  // save { ...result.data, passwordHash } ...
  res.writeHead(201).end();
}).listen(3000);

A small database layer

const nc = require("@ix-xs/node-comfort");

const db = new nc.SQLite("data/app.sqlite");

db.migrate([
  `CREATE TABLE users (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     email TEXT NOT NULL UNIQUE,
     password_hash TEXT NOT NULL,
     settings JSON NOT NULL DEFAULT '{}',
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
   )`,
  "CREATE INDEX idx_users_created ON users (created_at)",
]);

const users = db.table("users");

async function register(email, password) {
  try {
    const { lastInsertRowid } = users.insert({ email, password_hash: await nc.crypto.hashPassword(password) });
    return Number(lastInsertRowid);
  } catch (error) {
    if (error.sqliteCode === "SQLITE_CONSTRAINT_UNIQUE") throw new Error("This email is already registered");
    throw error;
  }
}

async function login(email, password) {
  const user = users.get({ email });
  if (!user || !(await nc.crypto.verifyPassword(password, user.password_hash))) return null;
  return nc.crypto.signJWT({ sub: String(user.id) }, process.env.JWT_SECRET, { expiresIn: "1h" });
}

// nightly backup, kept for 7 days
nc.time.cron("0 3 * * *", () => {
  db.backup(`backups/app-${nc.time.format(new Date(), "YYYY-MM-DD")}.sqlite`);
  for (const file of nc.glob("backups/*.sqlite", { absolute: true })) {
    if (nc.time.diff(Date.now(), nc.stat(file).mtime, "days") > 7) nc.remove(file);
  }
});

Background jobs

const nc = require("@ix-xs/node-comfort");

const jobs = nc.async.queue({ concurrency: 3, timeout: "2m" });
const log = nc.createLogger({ scope: "jobs" });

nc.time.every("1m", async () => {
  const pending = db.getAll("emails", { sent_at: null }, { limit: 50 });
  for (const email of pending) {
    jobs.add(() => nc.func.retry(() => send(email), { attempts: 3, delay: 2000 }))
      .then(() => db.update("emails", { sent_at: new Date() }, { id: email.id }))
      .catch((error) => log.error(`Email ${email.id} failed`, error));
  }
}, { immediate: true, onError: (error) => log.error(error) });

A clean shutdown

const nc = require("@ix-xs/node-comfort");

const server = app.listen(config.PORT, () => nc.success(`Listening on http://localhost:${config.PORT}`));

nc.sys.onShutdown(async () => {
  nc.info("Shutting down");
  await new Promise((done) => server.close(done)); // stop accepting requests
  await jobs.onIdle();                             // let running jobs finish
  db.close();
}, { timeout: "20s" });

Handlers run in reverse order of registration, and a second Ctrl+C exits immediately.

Structured logs in production

const nc = require("@ix-xs/node-comfort");

nc.configure({
  format: nc.env.isProduction() ? "json" : "pretty",
  fields: { service: "billing", version: require("./package.json").version },
  file: nc.env.isProduction() ? undefined : "logs/dev.log",
});

const requestLog = nc.createLogger({ scope: "http" });
requestLog.info("GET /invoices", { status: 200, ms: 14 });
node-comfort v2.0.0Edit this page on GitHub