Fetching latest headlinesโ€ฆ
I built a CLI that roasts HTTP status codes instead of just logging them
NORTH AMERICA
๐Ÿ‡บ๐Ÿ‡ธ United Statesโ€ขApril 18, 2026

I built a CLI that roasts HTTP status codes instead of just logging them

3 views0 likes0 comments
Originally published byDev.to

INTERNAL SERVER ERROR

Terminal output during local dev is boring. A 500 error scrolls by as a single dim line and you barely notice it. A 429 from a rate limiter looks identical to a successful 200. Your server is quietly screaming and your log file is a wall of near-identical text.

I got fed up with this over a weekend and built roastttp โ€” a tiny CLI and Express middleware that reacts to HTTP status codes with dry, dev-sarcastic one-liners and ASCII art.

npx roastttp https://httpbin.org/status/500
POST /api/payment  โ†’ 500 INTERNAL SERVER ERROR ยท 2341ms

         (   .    )   (     )
          )           (       )
             .  '   .   '  .
         (    , )       (.   )
       ). , ( .   (  ) ( , ')
      (_,) . ), ) _) _,')  (, )
      โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
         SOMETHING IS ON FIRE

  ๐Ÿ”ฅ  INTERNAL SERVER ERROR (า‚โ—ก_โ—ก)
  "somewhere, a try/except ate an error and pretended everything was fine"

It's up on npm at 20KB with zero runtime dependencies: npm install roastttp.

The design rabbit hole

The first version of this in my head was "GIFs for each status code." I spent a full evening researching how to render GIFs in a terminal before realizing three things:

One, terminal image protocols (iTerm2, Kitty, Sixel) render static images, not animated GIFs. Even in a modern terminal you'd be showing a single frame. So why fetch a GIF at all?

Two, Tenor's API is being sunset by Google in September 2026. Giphy's free tier is rate-limited to 100 requests per hour and requires a "Powered by GIPHY" attribution badge, which is weird to render in a terminal. Both platforms' ToS explicitly restrict commercial use of bundled content.

Three, even if I solved those, I'd still be shipping other people's content and hoping nobody's lawyer notices. Indie project, fragile foundation.

The conclusion was obvious in retrospect: go all-in on text. ASCII art, emojis, kaomoji, and hand-written one-liners. It's universal, it's legal, it's distinctive, and honestly it's funnier than a Drake reaction GIF in 2026.

The voice

This was the hardest part, and the most important. My first pass had Gen Z internet voice โ€” "giving 500 energy", "and I don't know her", "bestie." I showed it to a dev friend and she said it didn't land. It felt like a marketing team impersonating a programmer.

So I rewrote everything in a dev-sarcastic register. Dry, weary, specific to real dev pain. Examples:

404: "check the path. check it again. it's still wrong."

500: "somewhere, a try/except ate an error and pretended everything was fine"

429: "your retry logic does not include backoff. fix that."

503: "503: the status page has not been updated. it never is."

422: "your payload parsed. your payload is still wrong."

The voice rules I codified in CONTRIBUTING.md:

  • Punch up at abstractions (stack traces, JIRA, YAML, cache), not at people
  • Under 80 characters when possible
  • No proper nouns โ€” no company names, no public figures
  • No religion, politics, or identity humor
  • Dev-specific beats generic โ€” "Redis is tired. Redis needs a minute." beats "server busy, try later."

This is the kind of thing you can't A/B test; you just write a hundred lines, delete eighty, and trust your ear.

The technical decisions

A handful of small choices that I think made this punch above its weight:

Zero runtime dependencies. The whole thing is TypeScript compiling to a single dist/ folder. ANSI colors are done by hand (it's ~30 lines). Arg parsing is done by hand. This felt excessive at first but it means npm install roastttp is under 100ms and the package is 20KB instead of a quarter megabyte.

Catalog as JSON, not code. The reactions live in src/data/reactions.json, not in TypeScript. This way people who want to contribute roast lines never have to touch TypeScript โ€” they submit a one-line diff to a JSON file. Turning contribution friction to near-zero matters a lot for humor projects where the community is the product.

Three-tier rendering. Light codes (2xx, 3xx) get a single line. Medium codes (4xx) get a small ASCII box. Heavy codes (5xx) get a full dramatic scene. This scales visual weight to how catastrophic the error actually is, which matches how developers emotionally experience them anyway.

Structural typing for the Express adapter. I didn't want @types/express as a dependency. So the middleware's ReqLike and ResLike interfaces are structural โ€” they match both Node's raw http.IncomingMessage and Express's Request. Zero install cost, works in both contexts.

interface ReqLike {
  method?: string;
  originalUrl?: string;
  url?: string;
}

interface ResLike {
  statusCode: number;
  on(event: 'finish' | 'close', cb: () => void): void;
}

Strict TypeScript everywhere. noUncheckedIndexedAccess caught two real bugs during development: accessing .label on a fallback reaction that didn't have one, and indexing into the roasts array without null-checking. Strict mode earns its keep.

The Express middleware

This is where I think roastttp gets actually useful, not just funny. One line, your existing access logs are untouched, reactions only fire on 4xx and 5xx by default:

import express from 'express';
import { roastttp } from 'roastttp/express';

const app = express();
app.use(roastttp());

app.get('/ok', (_req, res) => res.send('ok'));
app.get('/boom', (_req, res) => res.status(500).send('bad day'));

Options for when you want to tune it:

app.use(roastttp({
  reactOn: [404, 500],    // specific codes, or a predicate
  rarity: 0.3,            // 30% chance โ€” prevents habituation in busy servers
  silent2xx: true,        // default: don't roast successes
}));

The rarity knob surprised me with how much it helped. If every 500 roasted, I'd mentally filter them out within an hour. At 30% rarity they stay novel, which is the whole point.

What I'm not building (yet)

I got the ship-lean discipline from a previous project. A week of polishing pre-launch is a week of not learning what people actually want. So roastttp v0.1 shipped deliberately incomplete:

  • No Next.js adapter yet. Next.js's logger requires monkey-patching (see next-logger), which is fragile across Next versions. Waiting for demand before taking on that maintenance burden.
  • No sound effects. Terminal beeps are jarring, break SSH/CI, and would require a native dependency. The aesthetic is quiet, dry humor.
  • No AI-generated dynamic roasts. Tempting (I've built tools that call the Claude API for dynamic text) but ships complexity on day one. The hand-written catalog is fine.
  • No theme/meme packs. Planned for v0.2 as a paid tier โ€” "pirate mode," "shakespeare mode," "Nepali-localized mode." But v0.1 ships with the one canonical voice.

Try it

# zero-install try
npx roastttp https://your-api.com

# preview the full gallery
npx roastttp --preview

# render a specific code without a network call
npx roastttp --code 418

Repo: github.com/clashrelated/roastttp

PRs for new roast lines are welcome โ€” it's a one-line JSON diff, and the CONTRIBUTING guide covers the voice rules. If you ship it in your own project, tag me. I want to see the wild places this ends up.

Comments (0)

Sign in to join the discussion

Be the first to comment!