npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 a component tag. SLF4J LoggerFactory.getLogger(X.class) ergonomic, with Pino's flyweight child() so the runtime cost is essentially free.
  • 🧵 Java-MDC-style correlation — a per-request ref attaches itself to every log call via AsyncLocalStorage. 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 duplicate pino-pretty streams.
  • 🚦 Central error handling for Express + Next.js App Router.
  • 🔖 Short reference IDs attached to every error response, shown in toasts.
  • 🍞 Tiny fetch wrapper that throws on non-2xx and triggers a toast.
  • 🛡️ React ErrorBoundary that never renders a blank screen.
  • 🗃️ Postgres SQLSTATE → HTTP status mapping (23505409, 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-basics

Peer 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 a devDependency (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` tag

For 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.log

Rationale: 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-pretty via require at runtime. If your bundler tries to resolve it at build time, you'll see "Module not found". The fix is to mark pino-pretty (and pino) as external — see the next.config.js snippet 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

  1. Node.js runtime only for the logger. AsyncLocalStorage is a Node builtin. Next.js Edge Runtime has limited support; keep export const runtime = "nodejs" on routes that use log (the default for DB-touching routes anyway).

  2. ALS overhead is negligible. Node 20+ optimizes .getStore() to a no-op when no frame is active. The mixin runs once per log line.

  3. 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.

  4. Don't mutate the store from outside setContext. The helper does a shallow Object.assign; if you swap the object reference, you break other frames that share the same store.

  5. Edge cases for ALS propagation. await, .then, setTimeout, setImmediate, and queueMicrotask all propagate. A few very old EventEmitter-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 typecheck

Publishing (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 --tags

Consumers update with:

pnpm update @chongbei/web-basics
# or
npm update @chongbei/web-basics