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

@monochromatic-dev/module-logger

v0.4.0

Published

Zero-config multi-sink logger with tagged composition. Auto-discovers console, sessionStorage, and localStorage backends at import time, plus the file backend under Node and IndexedDB in browsers, with no dynamic imports in either build.

Readme

module-logger

Zero-config multi-sink logger with tagged composition. Works immediately with no setup call: the first log or flush call builds the default logger and auto-discovers available backends for the current runtime, and records emitted while async backend verification is still pending replay to those backends as soon as they verify. Importing the package runs no discovery, no timers, and no I/O.

Usage

import { tagged, } from '@monochromatic-dev/module-logger';

const l = tagged({ tag: 'http', },);
l.info('server started on port 3000',);
// console output: [info] [2026-03-11T...] [http] server started on port 3000

Composing tags

Pass a parent logger via the l parameter to build hierarchical prefixes:

const l = tagged({ tag: 'http', },);
const rl = tagged({ tag: 'retry', l, },);
rl.warn('attempt 3 failed',);
// [warn] [...] [http] [retry] attempt 3 failed

Composed tags render root-first because each wrapper prepends before delegating to its parent logger.

Convention: use myFn.name as the tag so prefixes stay in sync with refactors.

function handleRequest({ l, }: { l: Logger; },): void {
  const rl = tagged({ tag: handleRequest.name, l, },);
  rl.info('received',);
}

Default logger (no tags)

Import the singleton directly when tags are not needed:

import { logger, } from '@monochromatic-dev/module-logger';

logger.error('unexpected shutdown',);

Runtime support

Node 24 or newer (the build calls Error.isError), plus current browsers, Deno, and Bun for the sinks whose verify finds a backend there.

Global-scope-restricted runtimes

Cloudflare Workers (and any runtime that forbids timers, I/O, and random values in global scope) can import the root entry and tagged freely: nothing is built at import, so no sink probe and no timer runs in global scope. The first log or flush call inside a handler builds the default logger and verifies its sinks there. A Worker that wants a logger scoped to one request can still build its own with createLogger over sinks.createConsoleSink() and hand flush() to ctx.waitUntil.

The published package exposes the built artifact only. The /ts source subpath used inside this workspace is stripped at publish time, because Node refuses .ts files under node_modules.

The root entry is platform-neutral and is built twice. The node export condition serves a build whose default logger includes the file sink, with static node:fs/promises and node:path imports, and which carries no browser storage code. Every other resolution (default) serves a build whose default logger includes the IndexedDB sink and which references no Node module. Neither build contains a dynamic import(), and a unit test reads every chunk of both builds to keep it that way. The root types are identical on both conditions. A Node consumer whose bundler resolves the default condition gets the neutral build and no file logging.

Log levels

Six levels, each mapping to a dedicated method: trace, debug, info, warn, error, fatal.

All methods accept a single string argument. The caller owns serialization; template literals cover the common case and keep the logger free of stringify opinions.

l.info(`status ${code} for ${url}`,);

The console sink silences debug and trace by default in non-browser environments. Set MONOCHROMATIC_VERBOSE=true or pass --verbose to enable them. In browsers, verbose mode is enabled automatically because DevTools already provides its own log-level filtering.

Sinks

The default logger writes to all available sinks simultaneously. Availability is verified at module load, every sink concurrently, each under its own time limit (verifyTimeoutMs, default DEFAULT_VERIFY_TIMEOUT_MS, 5000 ms); a sink that does not answer in time counts as unavailable, so one hung backend probe cannot starve the others. Records emitted while an async sink is still being verified are replayed to that sink when it becomes available. That startup buffer holds at most STARTUP_BUFFER_CAP records (10000, exported); past the cap the oldest buffered record is dropped, and once every sink has answered one warn record naming the dropped count is written to every available sink.

  • console: formats as [level] [ISO timestamp] message; maps levels to corresponding console.* methods, except debug writes to process.stderr when process.stderr.write is available
  • file: Node. js only; walks up from process.cwd() to the nearest ancestor node_modules/, then appends JSONL records to <that dir>/node_modules/.monochromatic/{timestamp}.log.jsonl via node:fs/promises. When no ancestor node_modules/ exists, the sink is marked unavailable rather than creating one at cwd; this prevents stray log directories from landing inside build output or other non-project trees when a script is invoked from an unexpected cwd
  • IndexedDB: browser only; buffers records through the same shared policy and flush triggers as the sessionStorage sink and stores each newline-joined JSONL batch as one string value per transaction in the monochromatic.log database (batch store, auto-incremented keys, so concurrent tabs serialize without any key scheme); records are readable the moment their transaction settles, DevTools Application tab included, and survive tab close and browser restart; retention trims oldest-first past 2048 stored batches; logger.flush() settles every issued batch transaction
  • OPFS (exported but not a default): browser only; buffers records and appends them to the Origin Private File System as newline-joined JSONL batches, one queued stream write per batch, under the same flush triggers as the sessionStorage sink; keeps a FileSystemWritableFileStream open for the session, and logger.flush() settles every issued batch write; staged stream content only becomes the file on a close a crash never performs, which is why the IndexedDB sink holds the default persistent-browser slot instead (see DECISIONS.md)
  • sessionStorage: available wherever globalThis.sessionStorage exists (browsers, Node, Deno); buffers records and stores them as newline-joined JSONL batches under monochromatic.log.{n} keys with an auto-incrementing counter; one uniform write path on every runtime flushes a batch when it reaches 32 KiB, when a record's severity is warn or worse, after 250 ms of quiet, on pagehide/document-hidden where those events exist, and on logger.flush(); caps its own footprint at half the runtime's default sessionStorage quota (a measured per-runtime heuristic: 5 MiB on Node and the browser engines, 10 MiB on Deno), evicting its oldest batches first and reclaiming further space reactively if the real store still overflows
  • localStorage: available wherever globalThis.localStorage round-trips (browsers, Deno, Node launched with --localstorage-file; flagless Node skips the probe silently); buffers through the same shared policy and flush triggers as the sessionStorage sink, but stores each batch under a run-scoped key (monochromatic.log.{stamp}.{nonce}.{index}) because localStorage is shared across tabs and survives restarts; adopts entries left by earlier runs and evicts oldest-first, capping the combined footprint at half the runtime's default localStorage quota (5 MiB on Node and the browser engines, just under 10 MiB on Deno); the one web storage sink whose records remain inspectable after tab close or a full browser restart
  • noop: discards all records; a stand-in that disables logging without removing log calls

Each sink is a factory. The cross-platform ones, createConsoleSink(), createSessionStorageSink(), createLocalStorageSink(), and createNoopSink(), are exported under the sinks namespace of the root entry. createFileSink() is exported from @monochromatic-dev/module-logger/node, and createIndexedDbSink() and createOpfsSink() from @monochromatic-dev/module-logger/browser, so importing a platform-only sink is the consumer's own assertion of the platform. A sink instance keeps its own buffers, streams, and counters, so independent loggers never share state.

Async sinks are fire-and-forget; the log call never blocks the caller. Call logger.flush() before assertions or process shutdown to wait for startup verification, pending sink writes, and sink-owned flush hooks. A sink is dropped only when its verify reports the backend unavailable (resolves false or rejects); a sink whose flush hook rejects is also dropped. Individual write failures are the sink's own concern and do not disable the backend, so one transient I/O hiccup never silently kills a sink for the rest of the run.

Console output safety

Log text can carry attacker-influenced content, and a terminal treats control characters as commands (clear screen, set title, move the cursor, write the clipboard). The console sink renders every C0 control except newline and tab, DEL, and every C1 control as a \uXXXX escape before the text reaches console.* or process.stderr, so the attempted sequence stays visible but inert. Newlines and tabs pass through because multi-line messages are core. The JSONL sinks need no such step: JSON.stringify already escapes control characters.

Log record format

Every sink receives a LogRecord:

type LogRecord = {
  level: Level; // 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
  message: string;
  timestamp: number; // Date.now()
};

File, IndexedDB, OPFS, sessionStorage, and localStorage sinks write records as one JSON object per line (JSONL).

Error handling

  • The default logger is built by the first log or flush call, never at import; there is no readiness promise to await, because flush() awaits verification and startup replay internally
  • logger.flush() awaits startup verification, pending sink writes, and sink-owned flush hooks, all under one deadline (flushDeadlineMs, default DEFAULT_FLUSH_DEADLINE_MS, 5000 ms). When the deadline elapses the logger reports one console.warn breadcrumb, drops the in-flight writes from its view (sinks expose no cancellation, so the work continues in the background), and resolves, so a wedged backend cannot hang a shutdown
  • Throws at log time once initialization has completed with no available backend. The console sink verifies wherever console and queueMicrotask exist, so this is reachable only through createLogger with sinks that all fail verification
  • A sink is dropped when its verify reports unavailable, runs past verifyTimeoutMs, or its flush hook rejects; remaining sinks continue, and a late verify answer after the limit is ignored
  • Individual write failures are handled per sink and do not disable the backend
  • Records logged before every sink has answered buffer under STARTUP_BUFFER_CAP; on overflow the oldest is dropped and the count is reported as one warn record after initialization, never silently

Custom loggers

The default logger is createLogger applied to the default sink set, and it stays zero-config. createLogger is also exported for building a logger over an explicit sink list, for example to write to a fixed subset of backends or to inject a fake in tests.

import { createLogger, sinks, } from '@monochromatic-dev/module-logger';

const { logger, initPromise, } = createLogger({ sinks: [sinks.createNoopSink()], },);
logger.info('goes nowhere');
await initPromise; // optional; flush() awaits it internally

Raise the flush deadline for a slow but working backend, such as a network filesystem:

import { createLogger, } from '@monochromatic-dev/module-logger';
import { createFileSink, } from '@monochromatic-dev/module-logger/node';

const { logger, } = createLogger({
  sinks: [createFileSink(),],
  flushDeadlineMs: 30_000,
  verifyTimeoutMs: 30_000,
},);

A custom sink is any object satisfying the Sink interface, a single self-describing adapter carrying verify, write, and an optional flush:

import type { LogRecord, Sink, } from '@monochromatic-dev/module-logger';

function createArraySink(): { records: LogRecord[]; sink: Sink; } {
  const records: LogRecord[] = [];
  const sink: Sink = {
    verify: () => Promise.resolve(true),
    write: (record) => {
      records.push(record);
      return Promise.resolve();
    },
  };
  return { records, sink, };
}

verify, write, and flush are all async (returning Promise) so the logger awaits them uniformly.

Design decisions

See DECISIONS.md for rationale on:

  • No sub-logger hierarchy; per-component filtering is a log viewer problem
  • String-only messages; callers own serialization; no auto-stringify
  • Sinks are self-describing factories; the logger owns availability
  • Write failures do not disable a sink; only verify failure does
  • localStorage and IndexedDB sink designs and measurements
  • Console output neutralizes control characters
  • flush() has a deadline
  • Sinks verify concurrently under a time limit
  • The startup buffer is bounded and overflow is reported
  • Platform-specific sinks live behind ./node and ./browser
  • Zero-config at import, no configure step (the logtape migration observations)

Source files

  • src/types.ts: Logger, LogRecord, Sink, SinkFlush, Verify, Level type definitions
  • src/create-logger.ts: createLogger({ sinks }) orchestration (verify, startup replay, flush)
  • src/logger.ts: default singleton built by applying createLogger to the default sinks
  • src/node.ts: the ./node subpath entry, createFileSink()
  • src/browser.ts: the ./browser subpath entry, createIndexedDbSink() and createOpfsSink()
  • src/default-sinks.node.ts and src/default-sinks.neutral.ts: the two default sink lists, selected at build time through the #default-sinks entry of package.json imports
  • src/artifact-platform-split.unit.test.ts: guard that reads every chunk of both builds and rejects dynamic imports and cross-platform leaks
  • src/tagged.ts: tagged() wrapper for composable prefixes
  • src/sink/console.ts: createConsoleSink(), verbose-mode gating and microtask batching
  • src/sink/console-control-chars.ts: control-character neutralization for console-bound text
  • src/sink/file.ts: createFileSink(), Node. js file sink (JSONL via appendFile)
  • src/sink/indexed-db.ts: createIndexedDbSink(), default persistent-browser sink (one transaction per batch, retention trim)
  • src/sink/indexed-db-util.ts: promise bridges for the event-based IndexedDB API
  • src/sink/opfs.ts: createOpfsSink(), opt-in browser OPFS sink with persistent writable stream (batched writes)
  • src/sink/record-buffer.ts: buffering stage shared by the OPFS and sessionStorage sinks (byte cap, severity flush, quiet-period deadline, page lifecycle)
  • src/sink/session-storage.ts: createSessionStorageSink(), cross-runtime web storage sink (buffering, flush triggers)
  • src/sink/session-storage-store.ts: persistence engine behind it (key allocation, footprint accounting, quota eviction)
  • src/sink/local-storage.ts: createLocalStorageSink(), cross-runtime persistent web storage sink (same buffering, run-scoped keys)
  • src/sink/local-storage-store.ts: persistence engine behind it (run identity, prior-run adoption, cross-run oldest-first eviction)
  • src/sink/local-storage-key.ts: run-scoped key building, strict parsing, and eviction ordering
  • src/sink/local-storage-quota.ts and src/sink/session-storage-quota.ts: fill-probed per-runtime quota tables
  • src/sink/web-storage-runtime.ts and src/sink/web-storage-quota-error.ts: host-runtime detection and quota-overflow recognition shared by both web storage engines
  • src/sink/noop.ts: createNoopSink(), discards all records