my-bad
v0.2.8
Published
Beautiful dev-server error pages: HTML, ANSI and JSON from one sourcemapped report
Readme
my-bad
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-badUsage
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.mapsidecars and inlinesourceMappingURLcomments (Nitro dev builds, tsdown/rollup output). When the frames can come from an untrusted source, such as a browser-submitted stack, confine it withfsLoader({ roots: [cwd] })orfsLoader({ canRead }); it reads any absolute path by default.sourceMapLoader({ getSourceMap })takes maps from memory, for module runners (nitroApp.ssrSourceMaps.getSourceMapin Nuxt,runner.moduleCache.getSourceMapin vite-node).viteLoader(server)frommy-bad/viteuses the module graph of aViteDevServer.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 badgeFile 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 barVite
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:viteCredits
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.
