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

@lilsnibbi/logger

v1.0.0

Published

Structured console logger for the Bun runtime, with no styling dependency.

Readme

@lilsnibbi/logger

Structured console logger for the Bun runtime. Ships raw TypeScript - no build step, no compiled output, and no styling dependency: the ANSI escapes are written directly.

bun add @lilsnibbi/logger
import { Logger } from "@lilsnibbi/logger";

const logger = new Logger({ name: "api", level: "NOTIF" });

logger.notif("listening on :3000");
logger.alert("disk almost full");
logger.error(new Error("query timed out")); // prints the full stack
logger.divider("STARTUP");

A line leads with its timestamp and a level marker — a filled badge by default, or a coloured bar and glyph with layout: "bar":

[Tue 19:38:23.644]  NOTIF  api › listening on :3000
[Tue 19:38:23.644] ▌ ✓ api › listening on :3000

symbols replaces the glyphs the bar draws, which default to , , and ×:

new Logger({ name: "api", layout: "bar", symbols: { NOTIF: "→" } });

layout: "box" frames the record instead. The timestamp and level sit on the header rule, every line of the value gets its own edge, and each rule carries text in its corners:

┌ 19:38:23 NOTIF ────────────────────────────────────── src/api.ts:19 ┐
│ listening on :3000
└─ GET /checkout ──────────────────────────────────────── Auth ID: 41 ┘
const logger = new Logger({
  name: "api",
  layout: "box",
  box: {
    width: 72,                                   // default: the terminal's
    topRight: "src/api.ts:19",                   // default: the logger's name
    bottomLeft: "GET /checkout",
    bottomRight: (record) => `Auth ID: ${session(record)}`,
    spacing: true,
  },
});

Each corner takes a string, or a function of the {@link LogRecord} being drawn for something that changes per line — a route, a request id, whoever is signed in. Pass "" to leave a corner empty. Corners take their columns out of the rule rather than hanging off the end of it, and are truncated to what the rule can spare, so the frame always closes in the column it says it will.

The box carries a lighter timestamp than the other layouts — 19:38:23, no weekday, no milliseconds, no brackets — since the header rule is already carrying the level and a corner. spacing opens each box with a blank line, so consecutive records are separated by one; log files are never padded. Width falls back to 80 columns where the stream has none, and is never drawn narrower than 32.

Levels are ordered DEBUG < NOTIF < ALERT < ERROR; anything below level is dropped. Every method takes a trailing options bag, and true in its place is shorthand for { raw: true } — return the formatted string instead of emitting it:

const line = logger.alert("disk almost full", true);

The return type is string | undefined — a message filtered out by the current level returns nothing. A raw call is side-effect free: nothing reaches the terminal, the log file or transport.

Lines are written straight to process.stdout and process.stderr rather than through console. DEBUG and NOTIF go to stdout, ALERT and ERROR to stderr, so 2>/dev/null still leaves you with the routine output.

Per-call overrides

That options bag is the whole configuration, not just raw. Anything you can pass to the constructor can be passed to a single call instead, which is how a box carries per-request detail without a logger per request:

const logger = new Logger({ name: "api", layout: "box" });

logger.notif("cache miss", {
  box: { topRight: "GET /users/42", bottomRight: "312ms" },
});

logger.debug(payload, { layout: "bar", name: "api:body" });
logger.error(err, { raw: true, theme: { message: { dim: true } } });
┌ 19:38:23 NOTIF ─────────────────────────────────────── GET /users/42 ┐
│ cache miss
└──────────────────────────────────────────────────────────────  312ms ┘

Overrides are resolved for that record and thrown away, so nothing shared is mutated and two concurrent requests cannot overwrite each other's corners. box, theme and symbols merge into the logger's own — override one corner and the rest of the box configuration stands — while every other field replaces its counterpart.

file and stackTraceLimit are the two settings that cannot be overridden: one owns a file descriptor for the life of the logger, the other is a process-wide V8 setting read when an error is thrown rather than when it is logged. For a lasting change instead of a per-record one, setLevel, setTheme and the name, level and silent properties are writable at runtime, and child() clones a logger with different options.

Colour

Colours are written as ANSI escapes directly; there is no styling dependency. colors defaults to "auto", which honours NO_COLOR, FORCE_COLOR, TERM=dumb and whether stdout is a TTY. Pass true or false to decide it yourself.

Every part of a line is a themeable token — the four level names, plus timestamp, name, separator, boxNote, message, divider, dividerText, errorName, errorMessage, causeLabel, stackBranch, stackFunction, stackFile, stackLocation and stackNote:

const logger = new Logger({
  name: "api",
  theme: {
    NOTIF: { color: "#7aa2f7", bold: true },
    timestamp: { color: [88, 91, 112] },
    ERROR: { color: "white", background: "darkred", bold: true },
  },
});

logger.setTheme({ ALERT: { color: "orange", underline: true } });

The four level tokens style the badge, so their background is what fills it and their color only has to stay legible against it. The "bar" layout has no block to fill and draws that background colour as the bar and glyph instead.

A style takes color, background and the bold, dim, italic, underline, inverse and strikethrough attributes. The sixteen ANSI colour names (red, gray, brightCyan, …) map onto the terminal's own palette; anything else — hex, rgb(), hsl(), a CSS colour name, an [r, g, b] tuple — is parsed by Bun.color and emitted as truecolor. An entry replaces the default for that token outright, so spread DEFAULT_THEME.ERROR if you only want to change part of it.

paint, stripAnsi and colorSupported are exported if you need the same styling elsewhere.

Stack traces

An Error is rendered as an indented tree with its name, message, extra own properties, cause chain and AggregateError.errors. Nothing is filtered: native, anonymous, node:internal and eval frames all survive, and a frame that cannot be parsed is printed verbatim. Paths lose their file:// prefix, get forward slashes, and are made relative to process.cwd() unless relativePaths: false.

[Tue 19:38:23.644]  ERROR  api › Error: request failed
  └─ outer src/api.ts (L25 C13)
  ▼ caused by TypeError: x is not a function
    ├─ inner src/db.ts (L17 C12)
    └─ outer src/api.ts (L23 C3)

V8 keeps only the first ten frames by default. Set stackTraceLimit to capture everything — it assigns the global Error.stackTraceLimit, so it is opt-in:

new Logger({ name: "api", stackTraceLimit: Number.POSITIVE_INFINITY });

Log files

file mirrors output to disk, with rotation and retention handled for you. Writes are synchronous, so entries and rotations never race each other.

const logger = new Logger({
  name: "api",
  level: "NOTIF",
  file: {
    directory: "logs",
    filename: "api",
    level: "DEBUG",   // keep more detail on disk than on screen
    rotate: "daily",  // "never" | "hourly" | "daily"
    maxSize: 5 * 1024 * 1024,
    maxFiles: 7,
    maxAge: 30,       // days; 0 disables
    json: false,
    colors: false,
  },
});

await logger.flush();
await logger.close();

Daily rotation writes api-2026-08-25.log; a file that passes maxSize is renamed to api-2026-08-25.1.log and a fresh one takes its place, with the index counting up so a higher number is always newer. maxFiles counts the active file. json: true writes one object per line — time, level, name, message, and an error object when the value was an Error. Filesystem failures go to onError instead of being thrown.

Creating the directory and opening the file is the write-permission check. If the filesystem refuses it — a read-only directory, a path the process has no rights to — the failure is reported once, logger.fileWritable turns false, and file output stops there. Later records cost nothing and the console carries on as normal.

Hooks

const logger = new Logger({
  name: "api",
  serialize: (value) => (value instanceof Date ? value.toISOString() : undefined),
  format: (parts, ctx) => `${ctx.paint(parts.level, parts.level)} ${parts.message}`,
  filter: (record) => record.value !== "spam",
  transport: (line, record) => ship(record.level, line),
});
  • serialize renders one value; return undefined to fall back to the built-in handling.
  • format builds the whole line from parts (timestamp, name, level, message) and a context carrying the record, the destination ("console" or "file"), whether colour is on, and paint/style helpers bound to the active theme. It runs once per destination.
  • filter drops a record before it is formatted.
  • transport receives every emitted line with the colour stripped.

Everything else

const worker = logger.child("worker", { level: "DEBUG" });

logger.time("query");
logger.timeEnd("query");        // logs "query took 12.40ms", returns 12.4

logger.silent = true;

A child keeps its parent's configuration, shares its log file rather than opening a second handle on it, and closing a child leaves that shared file open. Other options: includeTimestamps, timeformat, dividerWidth, inspectDepth, silent.

Development

bun test        # run the suite
bun run check   # tsc --noEmit + biome
bun run pretty  # format
bun demo.ts     # one of every output the logger can produce

License

MIT