@chongbei/web-basics
v0.9.0
Published
Minimal 'no silent failures' helpers for personal PERN / Next.js apps — structured logging, central error handling, toast-on-failure client, short ref IDs.
Maintainers
Readme
@chongbei/web-basics
Minimal "no silent failures" helpers for personal PERN / Next.js apps.
- 🪵 Structured JSON logging (Pino) with process guards.
- 📛 Per-module named child loggers —
getLogger("UserService")gives every line acomponenttag. SLF4JLoggerFactory.getLogger(X.class)ergonomic, with Pino's flyweightchild()so the runtime cost is essentially free. - 🧵 Java-MDC-style correlation — a per-request
refattaches itself to every log call viaAsyncLocalStorage. No parameter threading. - ♻️ HMR-safe singleton — root logger is cached on
globalThis, so Next.js dev reloads no longer create duplicate Pino instances, duplicate process guards, or duplicatepino-prettystreams. - 🚦 Central error handling for Express + Next.js App Router.
- 🔖 Short reference IDs attached to every error response, shown in toasts.
- 🍞 Tiny
fetchwrapper that throws on non-2xx and triggers a toast. - 🛡️ React
ErrorBoundarythat never renders a blank screen. - 🗃️ Postgres SQLSTATE → HTTP status mapping (
23505→409, etc.).
Why "Lite"? Full distributed tracing / correlation IDs through Nginx are overkill for solo/side projects. This package gives you 80% of the operational value with ~300 lines of code.
Install
pnpm add @chongbei/web-basics
# or
npm install @chongbei/web-basics
# or
yarn add @chongbei/web-basicsPeer deps you install alongside (only the ones you actually use):
- Server:
pino(always),pino-pretty(only if you want pretty/dev output),express≥ 5 (for PERN),next(for Next.js) - Client:
react, a toast library of your choice (react-hot-toast,sonner,@mui/material, etc.)
Note on
pino-pretty(0.8.0+): This is now a truly optional peer dep that's loaded lazily only when pretty output is requested. Production deployments using JSON output never resolve it — install it as adevDependency(npm i -D pino-pretty) and it won't ship in your production node_modules. If pretty mode is requested but the package isn't installed, you get a clear error message pointing at the install command.
Java-style logger
You can use the package's logger the same way SLF4J + MDC work in Java:
declare a named logger at module scope, call it directly, and let the
framework attach a correlation id (ref) to every line — no parameter
threading.
Recommended: getLogger("Component") — one named child per module
This is the primary way to use the logger. Equivalent to Java's
private static final Logger log = LoggerFactory.getLogger(X.class);.
// src/queries/insert.ts
import { getLogger } from "@chongbei/web-basics/server";
const log = getLogger("InsertQueries");
export async function createUser(data: InsertUser) {
log.debug({ table: "users_table" }, "db.insert users");
await db.insert(usersTable).values(data);
}Emitted line (in production JSON mode):
{
"level": 20,
"time": "2025-01-15T03:42:11.512Z",
"service": "my-app",
"env": "production",
"ref": "a3f9b1c2",
"component": "InsertQueries",
"table": "users_table",
"msg": "db.insert users"
}You now have three levels of automatic correlation on every line:
| Field | Set by | What it tells you |
| ----------- | -------------------------------------------------------------------------- | ---------------------- |
| service | configureLogger({ service }) or SERVICE_NAME env | Which app emitted it |
| ref | withRouteHandler / attachRef (AsyncLocalStorage MDC, per request) | Which request |
| component | getLogger("X") — Pino child({ component: "X" }) flyweight | Which module/subsystem |
So grep ref=a3f9b1c2 app.log reconstructs one user's request, and
grep component=PaymentService app.log reconstructs everything one
subsystem did across all users — both come for free.
getLogger returns a real Pino Logger, so log.level = "warn" works
per-component, and log.child({ ... }) works to bind further fields.
Quickstart: log global proxy
For tiny scripts, demos, or one-off prototypes:
import { log } from "@chongbei/web-basics/server";
log.info({ userId }, "fetched user"); // ref auto-attached, no `component` tagFor real apps, prefer getLogger("Component") so each line carries the
source module — much easier to filter when logs grow.
Bootstrap configuration: configureLogger(opts)
Optionally configure the singleton root logger ONCE at server startup,
before the first log line is emitted. Cleaner than relying on SERVICE_NAME
env vars and easier to grep for in code.
Next.js — use instrumentation.ts at the project root:
// instrumentation.ts (lives at the repo root, NOT inside app/)
export async function register() {
// Edge runtime can't use node:async_hooks — skip there.
if (process.env.NEXT_RUNTIME !== "nodejs") return;
// Defer-import so this module isn't pulled into the edge bundle.
const { configureLogger } = await import("@chongbei/web-basics/server");
configureLogger({ service: "my-app", level: "info" });
}Express — call it once before registering routes:
import { configureLogger, getLogger, attachRef } from "@chongbei/web-basics/server";
configureLogger({ service: "my-api", level: "info" });
const log = getLogger("server");
const app = express();
app.use(attachRef);
// …configureLogger is idempotent for matching options: calling it
twice with the same service + level returns the existing singleton,
so HMR re-running instrumentation.ts is harmless. If a prior call
already built the logger with different options (config drift), it
throws — earlier log lines used the prior config, and continuing would
mean two shapes in the same log file. The error message names both the
existing and requested options so you can spot the drift.
HMR safety
The root logger is cached on globalThis, so Next.js next dev can
re-evaluate any consumer module without creating duplicate Pino
instances, duplicate pino-pretty streams, or duplicate
process.on(unhandledRejection|uncaughtException) listeners. You don't
need a custom src/lib/logger.ts wrapping createLogger() with a
globalThis cache — the package handles this internally.
Java vs Node
| Java / SLF4J | Node + @chongbei/web-basics |
| ----------------------------------------------------- | ---------------------------------------------------------------- |
| ThreadLocal | AsyncLocalStorage |
| MDC.put("ref", r) (servlet filter) | requestContext.run({ ref }, …) (withRouteHandler / attachRef) |
| Pattern layout %X{ref} reads MDC | Pino mixin reads the ALS store |
| LoggerFactory.getLogger(X.class) | getLogger("X") — Pino flyweight child({ component: "X" }) |
| Per-class log level (logback.xml) | log.level = "warn" on the child |
| Method signatures stay clean | Handler / service / query signatures stay clean |
Key property of AsyncLocalStorage: the store survives await, .then,
setTimeout, EventEmitter callbacks, and even the process-level
uncaughtException handler. Once it's set at the request entry point,
it follows the request through the entire async tree.
Adding fields to the per-request context
The default context contains only ref. You can add more (e.g. userId
after authentication) with setContext:
import { setContext, log } from "@chongbei/web-basics/server";
export async function loginFlow(req: Request) {
const session = await authenticate(req);
setContext({ userId: session.user.id }); // appended to every subsequent log line
log.info("login ok");
// { ref: "…", userId: 42, msg: "login ok" }
}Advanced: use runWithContext to open a nested frame (e.g. inside a worker
thread or a background job where you control the entry):
import { runWithContext, createRef, log } from "@chongbei/web-basics/server";
export function runJob(jobId: string) {
runWithContext({ ref: createRef(), jobId }, async () => {
log.info("job started");
await doWork();
log.info("job done");
});
}Log streams (pm2-friendly error routing)
createLogger() duplicates every warn / error / fatal record to
stderr in addition to the usual stdout stream:
logger.info(...) → stdout → pm2 *-out.log
logger.warn(...) → stdout + stderr → pm2 *-out.log AND *-error.log
logger.error(...) → stdout + stderr → pm2 *-out.log AND *-error.logRationale: pm2 routes log files strictly by file descriptor (stdout →
-out.log, stderr → -error.log). Default Pino writes everything to
stdout, which leaves *-error.log empty — making it useless as an audit
trail. Split streams fix that without any application-side changes: your
existing logger.error(...) calls just start landing in -error.log
automatically.
A single logical event still produces one logical record — we duplicate to two OS streams, not two log lines on the same stream.
Opt out:
const logger = createLogger({ service: "my-api", splitStreams: false });ESM consumers (Next.js / Turbopack): split+pretty mode resolves
pino-prettyviarequireat runtime. If your bundler tries to resolve it at build time, you'll see "Module not found". The fix is to markpino-pretty(andpino) as external — see thenext.config.jssnippet below.
PERN quick-start
Server (Express)
import express from "express";
import {
configureLogger,
getLogger,
attachRef,
errorHandler,
HttpError,
getDefaultLogger,
} from "@chongbei/web-basics/server";
// Configure ONCE before anything logs (and before route registration).
configureLogger({ service: "my-api", level: "info" });
const log = getLogger("server");
const app = express();
app.use(express.json());
app.use(attachRef); // sets req.ref, X-Request-Ref header, and ALS frame
// Express 5 forwards rejected promises from async handlers to the error
// middleware natively — no asyncHandler wrapper required.
app.get("/users/:id", async (req, res) => {
log.info({ id: req.params.id }, "GET /users/:id"); // ref + component=server auto-attached
const user = await db.users.get(req.params.id);
if (!user) throw new HttpError(404, "USER_NOT_FOUND", "User not found");
res.json(user);
});
// errorHandler wants a real Logger — give it the singleton root.
app.use(errorHandler(getDefaultLogger()));
app.listen(3000, () => log.info("listening"));Error responses are always:
{ "error": { "code": "USER_NOT_FOUND", "message": "User not found", "ref": "a3f9b1c2" } }The ref in the body matches the ref on the log line — grep bridge from
user report to exact incident.
The same value is also exposed as the X-Request-Ref response header on
every response (success and error). That means a user reporting "the
page felt slow at 3:47 PM" can paste the ref straight from devtools'
Network tab — no need to have hit an error.
Client (React)
import { Toaster, toast } from "react-hot-toast";
import {
configureApi,
api,
ErrorBoundary,
} from "@chongbei/web-basics/client";
// Once, at app startup:
configureApi({
toast: {
error: (m) => toast.error(m),
warn: (m) => toast(m, { icon: "⚠️" }),
},
});
export function App() {
return (
<ErrorBoundary>
<Main />
<Toaster position="top-right" />
</ErrorBoundary>
);
}Then in components:
const user = await api<User>(`/api/users/${id}`);
// On non-2xx: ApiError is thrown AND a toast appears with "(Ref: a3f9b1c2)".FormData uploads
api() sniffs the body and only injects Content-Type: application/json
when you're actually sending JSON. For FormData, Blob, URLSearchParams,
ArrayBuffer, and ReadableStream bodies, it lets the browser set the
correct Content-Type — including the multipart boundary for FormData:
const form = new FormData();
form.append("file", file);
const result = await api<UploadResult>("/api/uploads", {
method: "POST",
body: form,
});
// ✅ Browser sets: Content-Type: multipart/form-data; boundary=----WebKit…If you want a specific Content-Type, pass it explicitly via headers —
caller-supplied headers always win.
Inspecting failed responses
ApiError.response is the raw Response object (when one was produced —
undefined for network errors). Useful for reading headers like
Retry-After:
try {
await api("/api/some-throttled-endpoint");
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
const retryAfter = err.response?.headers.get("retry-after");
// …
}
}Cookies / credentials
api() uses the browser's default credentials: "same-origin". To send
cookies or HTTP auth cross-origin, pass it explicitly:
await api("/api/secure", { credentials: "include" });Next.js quick-start
Bootstrap once via instrumentation.ts
Runs once per server process, before any route handler is loaded:
// instrumentation.ts (lives at the project root, NOT inside app/)
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { configureLogger } = await import("@chongbei/web-basics/server");
configureLogger({ service: "my-app", level: "info" });
}Server (Route Handler)
// app/api/users/[id]/route.ts
import {
withRouteHandler,
HttpError,
getLogger,
} from "@chongbei/web-basics/server";
const log = getLogger("api.users.[id]");
export const GET = withRouteHandler(async (_req, { params }) => {
const id = (await params).id;
log.info({ id }, "GET user");
// → service=my-app ref=<per-request> component=api.users.[id] id=…
const user = await db.users.get(id);
if (!user) throw new HttpError(404, "USER_NOT_FOUND", "User not found");
return Response.json(user);
});No ref anywhere in the handler. Calls to log.info / log.warn made
from any function GET eventually calls — including DB query helpers
three layers down — will all emit the same ref. If those helpers also
use getLogger("X"), each line additionally tells you which subsystem
emitted it.
Helpers further down the stack follow the same pattern:
// src/queries/insert.ts
import { getLogger } from "@chongbei/web-basics/server";
const log = getLogger("InsertQueries");
export async function createUser(data: InsertUser) {
log.debug({ table: "users_table" }, "db.insert users"); // ref + component
await db.insert(usersTable).values(data);
}Client
// app/providers.tsx
"use client";
import { Toaster, toast } from "sonner";
import { configureApi } from "@chongbei/web-basics/client";
import { useEffect } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
useEffect(() => {
configureApi({
toast: {
error: (m) => toast.error(m),
warn: (m) => toast.warning(m),
},
});
}, []);
return (
<>
{children}
<Toaster position="top-right" richColors />
</>
);
}For 404s + error boundaries use Next's built-in files
(app/not-found.tsx, app/error.tsx, app/global-error.tsx).
next.config.js — externalize Pino
Next 13+/Turbopack bundles server code; Pino's dynamic require for
pino-pretty fails under ESM if bundled. Mark the package + pino as
external so Node resolves them at runtime:
module.exports = {
serverExternalPackages: [
"@chongbei/web-basics",
"pino",
"pino-pretty",
"thread-stream",
"pino-worker",
"sonic-boom",
],
};API
Server
| Export | What it does |
| ------ | ------------ |
| getLogger(component) | ⭐ Recommended. Returns a Pino child of the singleton root with component bound. Equivalent to SLF4J LoggerFactory.getLogger(X.class). Pino child() is a flyweight — runtime cost is essentially free. |
| configureLogger(opts) | One-shot bootstrap of the singleton root logger (service, level, pretty, splitStreams). Idempotent when called repeatedly with the same service + level (HMR-safe). Throws on config drift — when called with different options after the logger was already built. Call from instrumentation.ts (Next.js) or before route registration (Express). |
| log | Lazily-constructed log proxy backed by the singleton root. Quickstart for tiny scripts/demos — does not carry a component tag. Prefer getLogger("X") for real apps. |
| getDefaultLogger() | Returns the singleton root Logger — useful when an API expects a concrete Logger instance (e.g. errorHandler(getDefaultLogger())). Cached on globalThis, HMR-safe under next dev. |
| createLogger(opts?) | (advanced) Build a fresh Pino logger with custom options. Independent of the singleton — use only when you genuinely need a separate logger instance. Always installs the AsyncLocalStorage mixin. |
| installProcessGuards(logger) | Logs unhandledRejection and exits(1) on uncaughtException. Called automatically the first time the singleton is built (via getLogger / log / configureLogger). |
| requestContext | The AsyncLocalStorage<RequestContext> instance. Usually you don't touch this directly. |
| runWithContext(ctx, fn) | Open a new context frame. Use for worker threads / cron jobs / test setups. |
| getContext() | Read the currently-active context (or undefined). |
| setContext(patch) | Shallow-merge extra fields into the current context (e.g. { userId }). No-op when called outside a frame. |
| attachRef | Express middleware — sets req.ref, writes the X-Request-Ref response header (so success responses are also grep-able by ref), AND opens an AsyncLocalStorage frame so all downstream log.* calls are tagged. Install early. |
| errorHandler(logger) | Central Express error middleware. Register last. Express 5 forwards rejected async-handler promises here automatically. |
| withRouteHandler(handler) | Next.js App Router wrapper. Opens an ALS frame keyed by ref, logs + shapes thrown errors. Uses the default log. |
| HttpError | Throw this to return a specific status: new HttpError(404, "X", "msg"). |
| createRef() | 8-char hex id. Used internally; exported for advanced use. |
Client
| Export | What it does |
| ------ | ------------ |
| configureApi({ toast }) | Wires in your toast library. Call once. |
| api<T>(path, opts?) | Fetch wrapper. Throws ApiError on non-2xx; shows a toast with (Ref: …) from the server response. |
| ApiError | { status, code, ref, message, details, response } — typed error instance. |
| ErrorBoundary | React boundary that renders a visible fallback (no blank screen). |
Caveats
Node.js runtime only for the logger.
AsyncLocalStorageis a Node builtin. Next.js Edge Runtime has limited support; keepexport const runtime = "nodejs"on routes that uselog(the default for DB-touching routes anyway).ALS overhead is negligible. Node 20+ optimizes
.getStore()to a no-op when no frame is active. Themixinruns once per log line.Keep the store small. It stays alive for the lifetime of the request's async tree. Store primitives (
ref,userId,tenant,traceparent) — not whole user objects or DB rows.Don't mutate the store from outside
setContext. The helper does a shallowObject.assign; if you swap the object reference, you break other frames that share the same store.Edge cases for ALS propagation.
await,.then,setTimeout,setImmediate, andqueueMicrotaskall propagate. A few very oldEventEmitter-based callback libraries that synchronously emit on the same tick can lose the frame — rare in modern code.
Development
cd packages/web-basics
npm install
npm run build # emits dist/ (ESM + CJS + .d.ts via tsup)
npm run typecheckPublishing (release flow — maintainer only)
cd packages/web-basics
# 1. bump version in package.json
npm version minor --no-git-tag-version
# 2. build & publish to npm (public scoped package)
npm install
npm run build
npm publish --access=public
# 3. commit + tag
git add -A && git commit -m "web-basics: v0.7.0"
git tag [email protected]
git push && git push --tagsConsumers update with:
pnpm update @chongbei/web-basics
# or
npm update @chongbei/web-basics