Plip

Zero-dependency logger for Node and the browser

shipped2025 — present

stack
TypeScriptBunNodeESMCommonJSzero depsVitePress
license
MIT
links
reponpmdocs

most loggers want configuring before they're useful. console.log wants nothing and gives you grey mush. plip sits in between. import it, call it, read the output. configure it later if you need to.

typescript
import { plip } from '@ru-dr/plip';

plip.info("Welcome to Plip!");
plip.success("Everything is working perfectly");
plip.warn("This might need your attention");
plip.error("Something went wrong");

no runtime dependencies. ESM and CommonJS builds, both with types. seven levels: verbose, trace, debug, info, success, warn, error. success is there because "it worked" is a thing you actually want to say, and most loggers make you say it in info.


server and browser want different things

a server wants timestamps and JSON that a log aggregator can parse. a browser wants colour and shape so you can scan it. most loggers pick one and leave you to argue with the defaults.

plip ships both as presets: ssrLogger and csrLogger, with factory functions if you want to change them. CSR is the default, since the first place you use a logger is usually the browser tab in front of you.

it detects Node, the browser and Deno. Deno has a trap worth knowing about — it ships a process shim, so you have to check for Deno first or you'll think you're in Node. reading env vars has the same shape of problem, since a browser bundle without a process shim throws on import. both reads are guarded.


the colour code

the colour layer is hand-written ANSI. that's most of why there are no dependencies. two things it gets right that the obvious version doesn't.

nested styles don't get cut short. wrapping text in a colour code is easy until the text already contains a reset, which ends your outer style early. plip reopens the inner reset so the outer one survives.

JSON highlighting matches whole tokens, not punctuation. a string value with a quote or a colon inside it would otherwise get highlighted wrong.

colour detection respects NO_COLOR, FORCE_COLOR, isTTY, TERM, and the CI variables for GitHub Actions, GitLab, CircleCI and Travis. nobody wants escape codes in a log file.


transports

four: console, file, remote, browser. flush() on the logger drains them, so a process can finish writing before it exits. the interesting part is what each one does when things go wrong.

  • file queues each write behind the one still running, instead of dropping the entry. concurrent logs can't interleave into garbage.
  • remote batches ten entries or five seconds, whichever comes first, and stops buffering at a thousand. an endpoint that stays down can't eat memory. its timer is unref'd, so it never holds a Node process open just to flush logs.
  • browser trims to its size limit as it goes rather than re-stringifying the whole buffer every time it drops something.

withColors(), withContext() and child() return a new logger instead of changing the one you called them on. child loggers share the parent's transports, so a family of loggers has one transport lifecycle rather than a private copy each.


what v2 fixed

v2 is a good lesson in config that lies. withColors(), withSyntaxHighlighting() and levels() did nothing to console output. the console transport was re-formatting every entry using the config it was built with, so anything you changed afterwards got thrown away. enableTimestamp and enableStructuredOutput were accepted and then ignored. transport failures disappeared into Promise.allSettled.

the fix was to stop copying state into the transport at all. the logger already decides what to filter, so the transport doesn't need its own snapshot of it — and a snapshot is exactly what went stale on child loggers.

coverage moved with it. RemoteTransport 6% to 93%, BrowserTransport 32% to 100%, FileTransport 73% to 100%, adapters roughly 50% to 99%. the suite went from 78 tests to 147. there's more test code than source code now.

v2 also dropped emoji from the output. lines are plain [LEVEL] message. the emoji were fun for about a week and unreadable in a log aggregator forever.


one thing to watch

production settings are worked out when the package is first imported, so NODE_ENV has to be set before that happens. in production everything is off until you turn it back on.

silent-by-default is the safe way to get it wrong, but the import order will bite someone, so it's written down rather than left to be discovered.


framework bits

adapters for React and Next.js. the Next.js one strips authorization, cookie, x-api-key and x-auth-token out of request headers before they reach a log line.

worth being clear about the limit: that's request headers in one adapter. plip does not scrub your log payloads. message text is written as given, and the security policy says so.


running it

bash
npm install @ru-dr/plip

Node 16 or newer. CI typechecks, lints, runs the tests with coverage, builds, and then actually imports the built package both ways — a real require() and a real dynamic import() — to check the entry points resolve. a broken exports map otherwise shows up in someone else's project instead of mine. the same checks run before publish.

MIT.

back to work