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

my-bad

v0.2.8

Published

Beautiful dev-server error pages: HTML, ANSI and JSON from one sourcemapped report

Readme

my-bad

npm version npm downloads Github Actions Codecov

Beautiful dev-server error pages rendered to HTML, ANSI or JSON and live updated over SSE. ✨

my-bad turns an error into a plain JSON error report with sourcemapped frames, snippets, causes, component trace and request context.

You can then use my-bad to render that report as a full HTML page, a shadow-DOM overlay for an existing page, or ANSI for the terminal. A small SSE channel keeps browser-based pages live, meaning errors can be updated, the page can reload when the error is fixed, and additional warnings and server logs are surfaced as they occur.

The same report, rendered to the terminal with renderAnsi:

Install

npm install my-bad

Usage

import { createReport, fsLoader, renderAnsi, renderPage } from 'my-bad'
import { nuxtPreset } from 'my-bad/presets'

const report = await createReport(error, {
  cwd: process.cwd(),
  loaders: [fsLoader()],
  presets: [nuxtPreset({ versions: { nuxt: '4.0.0' } })],
  context: { event },
})

console.error(renderAnsi(report))

if (req.headers.accept?.includes('text/html')) {
  res.end(renderPage(report, { cwd: process.cwd(), channel: '/__my-bad' }))
}
else {
  res.end(JSON.stringify(report))
}

Diagnostics

An error that carries its own guidance is read without any configuration. fix becomes the report's hint, a docs URL becomes docsUrl, and sources (file:line:column strings) become app frames with snippets, or a Sources section when they cannot be resolved to a readable file. An error named after its own code is treated as self-describing: the heading shows the code once, and presets leave its docsUrl alone, including when it has none.

class Diagnostic extends Error {
  name = 'NUXT_E1001'
  code = 'NUXT_E1001'
  fix = 'Pass a non-empty `name` prop to `<Widget>`.'
  docs = 'https://nuxt.com/docs/4.x/errors/e1001'
  sources = ['pages/index.vue:2:16']
}

environment labels where the error happened ('Server', 'Client', or whatever your integration calls it) next to the error name.

Overlay

Inject the error UI into an existing page (for example a framework's rendered error page). It renders in a shadow root, so host styles cannot leak in, and can be minimised to a picture-in-picture thumbnail so the user can see the page behind it.

import { injectOverlay } from 'my-bad'

html = injectOverlay(html, report, { channel: '/__my-bad', startMinimized: status < 500 })

startMinimized: true always mounts minimised, even if the user last expanded an overlay in this origin. Errors arriving over the channel leave the current minimised state alone.

Pass requestId alongside the same id given to channel.setError so the channel only sends this page the errors its own request produced. Without it the page's path is matched against the request instead. Errors published with no request (compile errors, client runtime errors) reach every page.

Use injectOverlay rather than html.replace('</body>', ...): the inlined client contains $ sequences that a string replacement would interpret.

Serving the client separately

renderPage and renderOverlay inline the client script and stylesheet, which makes the output self-contained at the cost of ~60kB per response. Serve clientAssets yourself and pass the URLs to keep them out of the HTML and let the browser cache them:

import { clientAssets, renderPage } from 'my-bad'

// GET /__my-bad/client.js  -> clientAssets.script (text/javascript)
// GET /__my-bad/client.css -> clientAssets.styles (text/css)
renderPage(report, { assets: { script: '/__my-bad/client.js', styles: '/__my-bad/client.css' } })

Theme overrides stay inline, so a shared stylesheet still themes per response.

Theming

The page is dark by default and follows prefers-color-scheme. Everything is derived from a handful of CSS custom properties, so it's possible to restyle it without forking the stylesheet:

import { renderPage } from 'my-bad'
import { nuxtTheme } from 'my-bad/presets'

renderPage(report, { theme: nuxtTheme })

renderPage(report, {
  theme: {
    name: 'Acme',
    url: 'https://acme.dev',
    logo: '<svg viewBox="0 0 32 32">…</svg>', // drawn at 20px in currentColor
    accent: '#ff4f81',
    vars: { '--mb-bg': '#0b0b12', '--mb-font-sans': 'Inter, sans-serif' },
    css: '.mb-name { text-transform: uppercase }',
    scheme: 'dark', // force a scheme instead of following the user
  },
})

Tokens: --mb-accent, --mb-bg, --mb-fg, --mb-font-sans, --mb-font-mono, --mb-radius, plus the --mb-tk-* syntax colours. Text tiers, hairlines and surfaces are mixed from those, and the accent is deepened automatically for small text on light backgrounds.

Sourcemaps

Frames are mapped by loaders, tried in order:

  • fsLoader() reads .map sidecars and inline sourceMappingURL comments (Nitro dev builds, tsdown/rollup output). When the frames can come from an untrusted source, such as a browser-submitted stack, confine it with fsLoader({ roots: [cwd] }) or fsLoader({ canRead }); it reads any absolute path by default.
  • sourceMapLoader({ getSourceMap }) takes maps from memory, for module runners (nitroApp.ssrSourceMaps.getSourceMap in Nuxt, runner.moduleCache.getSourceMap in vite-node).
  • viteLoader(server) from my-bad/vite uses the module graph of a ViteDevServer.
  • passthroughLoader() only reads sources, for processes whose stacks are already mapped.

A frame is only mapped when the map has a segment on that exact generated line, so already-mapped stacks are never mapped twice.

Compile errors from a bundler often point into the module it transformed while naming the source file (rolldown failing to parse a compiled render function, say). Their position and code frame then move to frame.compiled, so the generated code is labelled as such rather than shown as the source of your file.

That is detected by comparing the frame's caret line, the one line the frame asserts describes the reported position, against the file on disk at the line number the frame gives it. Indentation is ignored, and a line Vite truncated for width is matched on the part that survived. If it matches, the frame is source; if it does not, the frame is generated. Neighbouring lines are not compared, because a bare } or }) matches almost any file by chance. Nothing changes when the file cannot be read, or the caret line is only punctuation, or there is no caret at all.

A thrower that already knows better can say so, and is believed without any comparison. This is honoured wherever the error sits in the chain, including as a cause at any depth, which is how a compile error wrapped in an HTTPError gets it right:

throw Object.assign(error, { compiled: true })

The same thing is available as an option for the top-level input, where compiled: { sourceLoc } also supplies a position you have mapped back to source yourself, to get a source snippet alongside the generated one:

await createReport(error, { kind: 'compile', compiled: { sourceLoc: { line: 6, column: 16 } } })

A marker on the error wins over the option, and both win over detection in either direction (false opts out entirely, and the file is not read).

Terminal

console.error(renderAnsi(report, { cwd, icon: false })) // `icon: false` when your logger prints its own badge

File locations are OSC 8 hyperlinks (via clickable-path) in terminals that support them.

Syntax highlighting

Snippets use a small built-in tokenizer. Plug in your own for richer colours in both HTML and ANSI:

const report = await createReport(error, {
  tokenizer: (line, lang) => myHighlighter(line, lang).map(token => ({ type: 'keyword', text: token.value })),
})

Live channel

import { createChannel } from 'my-bad/channel'
import { fileSink } from 'my-bad/sinks'

const channel = createChannel({ open: true, sink: fileSink('.nuxt/my-bad.jsonl') })
// `open: true` launches `LAUNCH_EDITOR` / `VISUAL` / `EDITOR` at the frame's line, falling back to the OS default app
// `open` requests are confined to `root` (default `process.cwd()`; the Vite plugin uses `server.fs.allow`),
// must name an existing file, and must be `application/json`; pass `root: false` to allow any path
// `open` may be a function instead, returning `false` to refuse a request

// The channel refuses requests a page on another origin made (`Sec-Fetch-Site`, else `Origin` must match the
// host addressed), and browser requests addressed to a host that is neither an IP literal, `localhost`,
// `*.localhost`, nor listed in `allowedHosts` (the Vite plugin passes `server.allowedHosts`).
// Requests with neither header (`curl`, editor integrations) are allowed.

// Node: mount at the channel base path
server.on('request', (req, res) => channel.handler(req, res).then(handled => handled || next()))
// or fetch-style: await channel.fetchHandler(request)

// Callers are trusted by default and may browse every retained report. Pass `{ trusted: false }` for a peer
// the host cannot vouch for (a connection forwarded into a container, a `--host` binding): the channel still
// connects and streams, but its `hello.history` and the `history` payloads hold only the reports that concern
// the page (a report naming a request id is matched by that id alone, never by path), `/history/:id` answers
// 404 for any other report, log entries reach it only when attributed to a request that concerns it, and
// privileged actions such as `open` are neither advertised nor accepted, so the
// page opens an `editor://` URL on the machine running the browser instead.
await channel.handler(req, res, { trusted: isLoopback(req) })

channel.setError(report) // pages swap content in place
channel.setError(report, requestId, `${method} ${url}`) // only pages rendered for that request swap; the rest list it in their history
channel.clearError() // pages reload, overlays dismiss
channel.warn(report) // toast
channel.log({ level: 'warn', text: 'careful' }) // streamed to the log drawer of trusted callers
channel.log({ level: 'warn', text: 'careful' }, requestId, `${method} ${url}`) // also streamed to the pages that request concerns
channel.progress({ phase: 'build', percent: 40, message: 'Building server' }) // progress bar

Vite

import { myBad, useMyBad } from 'my-bad/vite'

export default defineConfig({ plugins: [myBad()] })

The plugin maps frames through Vite's module graph, forwards compile errors from the HMR channel as kind: 'compile' reports, mounts the channel at /__my-bad, injects a tiny client into index.html so a running app shows the overlay, and clears it when the next update succeeds. Use useMyBad(server) from your own SSR middleware to build reports and pages with the same configuration.

The client script and stylesheet are served from /__my-bad/client.js and /__my-bad/client.css with content-hashed URLs, rather than inlined into every error page. Pass inlineClient: true for self-contained output.

Presets

my-bad/presets exports envPreset, vuePreset, requestPreset (alias h3Preset), nitroPreset and nuxtPreset. Presets contribute frame classification, sections (request, headers with redaction, route, environment), component traces and docs links for error codes.

nuxtPreset links codes such as E1001 or NUXT_E1001 to https://nuxt.com/docs/errors/e1001, which redirects to the current version; pass docsBase to point elsewhere.

💻 Development

git clone [email protected]:danielroe/my-bad.git
corepack enable

# run interactive tests
pnpm dev
# run accessibility audit (computed contrast, axe-core, target sizes, focus, motion, reflow)
pnpm test:a11y
# render screenshots of the ui
pnpm screenshot

# re-render the README assets in assets/ (autofix.ci keeps these current)
pnpm assets
# experiment in the playground
pnpm play
pnpm play:vite

Credits

This was inspired by youch, which is a phenomenal error page generator, and I would highly recommend it!

License

Made with ❤️

Published under MIT License.