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

expressy-bun

v0.5.0

Published

Express-like micro framework built directly on Bun.serve. Zero dependencies, no build step.

Readme

⚡ Expressy

An Express-like micro framework built directly on Bun.serve. Zero dependencies, no build step — it's just a handful of TypeScript files that Bun runs natively.

bun add expressy-bun
import expressy from "expressy-bun";

const app = expressy();

app.get("/", (req, res) => res.send("Hello from Bun!"));
app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));

app.listen(3000);

Why

Express carries years of Node-era baggage (30+ transitive dependencies, callback-based streams, no native TypeScript). Bun already ships an extremely fast HTTP server — Expressy just adds the ergonomics you actually use: routing, middleware, params, and res.json().

Coming from Express? Read MIGRATION.md — an honest breakdown of what's a drop-in, what needs changing, and what isn't supported.

Features

  • Express-style routing — app.get/post/put/patch/delete/head/options/all(path, ...handlers), app.route(path), app.param(name, fn), path arrays, RegExp paths, case-insensitive by default
  • Route params & wildcards — /users/:id, optional /users/:id?, /files/* (req.params[0], like Express 4), Express 5's /files/*splat and /users{/:id} groups
  • Middleware with next() — including path-scoped mounts, next("route") / next("router"), and Express-style 4-arity error handlers
  • Mountable routers — app.use("/api/notes", router), with mount-path params merging (/users/:userId/posts + /:postId)
  • Async everywhere — async handlers just work; rejections flow into your error middleware
  • Body parsers — json(), urlencoded(), text() and raw() built in, with limit (enforced while streaming) and qs-style extended options
  • Static files — serveStatic(dir) using Bun.file (zero-copy sendfile, automatic MIME types), with ETag / Last-Modified / 304, byte ranges (206) and Cache-Control
  • Content negotiation — req.accepts(), req.acceptsLanguages(), req.is() with type-is semantics, res.format()
  • Native sessions — session() with the express-session API (signed cookies, stores, regenerate/save/destroy)
  • View engines — app.engine(), app.set("view engine", ...), res.render() with app.locals/res.locals; nunjucks's express: option works out of the box
  • Settings — app.set/app.get/enable/disable, including trust proxy (X-Forwarded-For/-Proto/-Host)
  • fetch-native — the app is a fetch handler; handlers may also return a plain Response

API tour

Application

const app = expressy();          // or: new App()

const server = app.listen(3000);             // returns the Bun server
app.listen(3000, "0.0.0.0", () => {});       // Express's (port, hostname, callback) form works too
app.listen({ port: 3000, tls: { cert, key } }); // any Bun.serve option: tls, idleTimeout, maxRequestBodySize, unix, ...
server.close(() => {});                      // Express-style alias for server.stop()

// The app is a fetch handler, so these work too:
Bun.serve({ port: 3000, fetch: app.fetch });
export default app;                          // bun run index.ts
await app.fetch(new Request("http://x/"));   // perfect for tests

Routing

app.get("/notes/:id", (req, res) => { ... });
app.post("/notes", validate, create);        // multiple handlers per route
app.all("/anything", handler);               // every method
app.get("/users/:id?", handler);             // optional param
app.get(["/a", "/b"], handler);              // path arrays
app.get(/^\/files\/(\d+)$/, handler);        // RegExp → req.params[0]
app.get("/files/*", handler);                // req.params[0] (Express 4) — also req.params["*"]
app.get("/files/*splat", handler);           // req.params.splat (Express 5)
app.get("/users{/:id}", handler);            // optional group (Express 5)
app.route("/book").get(show).post(create);   // chainable per path
app.param("id", async (req, res, next, id) => { req.item = await load(id); next(); });

const api = new Router();                    // new Router({ caseSensitive: true, strict: true })
api.get("/", list);
api.get("/:id", show);
app.use("/api/notes", api);                  // api sees paths relative to the mount

Paths match case-insensitively and tolerate a trailing slash, like Express; app.enable("case sensitive routing") / app.enable("strict routing") opt out. Inside a route, next("route") skips to the next matching route and next("router") leaves the current router.

Request

| Property | Description | |---|---| | req.params | Route params (:id, *) — URL-decoded | | req.query | Parsed query string; repeated keys become arrays | | req.body | Set by json() / urlencoded() / text() / raw() middleware | | req.path, req.originalUrl, req.url, req.baseUrl | Current (mount-relative) path / original path+query / mount prefix | | req.method, req.headers, req.get(name) | req.headers is a plain lowercase-keyed object, like Node/Express | | req.cookies | Parsed Cookie header; j: JSON values written by res.cookie(name, object) come back decoded, like cookie-parser | | req.session, req.sessionID | Set by the session() middleware | | req.hostname, req.protocol, req.secure, req.ip | Connection info; honors the trust proxy setting | | req.is("json"), req.xhr | Content-Type check (type-is semantics: "json", "application/*", "+json") / X-Requested-With check | | req.accepts("json", "html"), req.acceptsLanguages(), req.acceptsEncodings(), req.acceptsCharsets() | Content negotiation on the Accept* headers, like Express | | req.subdomains, req.fresh, req.stale, req.range(size) | Same as Express (subdomain offset setting; fresh compares against the ETag / Last-Modified you set) | | await req.json() / req.text() / req.formData() | Manual body reading (text/json are cached) | | req.raw | The untouched fetch Request |

Response

res.status(201).json({ ok: true });  // honors app.set("json spaces", 2) / "json replacer"
res.send("<h1>html</h1>");        // strings → text/html, objects → JSON, Blob/BunFile pass through
res.text("plain"); res.html("<b>hi</b>");
res.set("X-Powered-By", "expressy").type("json");   // type() takes "json", ".png" or a MIME
res.setHeader("Content-Disposition", "attachment");  // Node-style aliases too
res.vary("Accept").location("/next").links({ next: "/p/2" });
res.redirect("/login");           // both (url, status) and Express's (status, url) work
res.sendStatus(404);              // "Not Found"
res.render("perfil", { user });   // via app.engine / view engine setting
res.format({ html: () => res.send("<b>hi</b>"), json: () => res.json({ hi: 1 }) });  // by Accept; 406 otherwise
await res.sendFile("report.pdf", { root: "./files" });   // root is a sandbox (403 on ../); ETag / 304 / Range built in
res.sendFile(path, (err) => next(err));                  // Express's callback form; options: maxAge, immutable, dotfiles, ...
await res.download("./files/report.pdf", "informe.pdf");  // Content-Disposition: attachment
res.attachment("data.csv");       // just the headers, then send the body yourself
res.cookie("session", token, { httpOnly: true, sameSite: "Lax", maxAge: 3_600_000 });
res.cookie("prefs", { theme: "dark" });  // objects are JSON-encoded (cookie-parser's j: prefix), decoded in req.cookies
res.clearCookie("session");
res.locals.user = currentUser;    // per-request template locals
res.onFinish((res) => log(res.statusCode));  // fires after the response is sent
res.end();                        // empty body

Sessions

Express-session-compatible, built in — same options, same signed-cookie wire format, same store contract (callback-based, so connect-mongo-style stores plug in):

import expressy, { session } from "expressy-bun";

app.use(session({
  secret: process.env.SESSION_SECRET!,   // string or [newest, ...older] for rotation
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 8 * 60 * 60 * 1000, httpOnly: true, sameSite: "lax", secure: "auto" },
  // store: new MyStore(),               // defaults to MemoryStore (dev only); `class MyStore extends session.Store`
  // unset: "destroy",                   // `delete req.session` removes it from the store
}));

app.post("/login", async (req, res) => {
  await req.session.regenerate();        // promise or callback style
  req.session.user = { name: "Kenia" };
  await req.session.save();
  res.redirect("/");
});
app.post("/logout", (req, res) => req.session.destroy(() => res.redirect("/login")));

Views

app.set("views", "./views");
app.set("view engine", "html");
app.engine("html", (path, locals, cb) => cb(null, myRender(path, locals)));
app.locals.site = "MyApp";                       // merged into every render
app.use((req, res, next) => { res.locals.user = req.session?.user; next(); });
app.get("/", (req, res) => res.render("home", { title: "Inicio" }));

Engines that install themselves through Express's View-class hook work as-is — e.g. nunjucks.configure("views", { express: app }).

Middleware & error handling

import expressy, { json, urlencoded, text, raw, serveStatic, HttpError } from "expressy-bun";

app.use(json({ limit: "1mb" }));       // req.body for application/json ({} when empty; strict by default)
app.use(urlencoded());                 // req.body for form posts
app.use(text());                       // req.body as a string for text/plain
app.use(raw());                        // req.body as a Buffer for application/octet-stream
app.use(serveStatic("./public", {      // falls through when no file matches
  maxAge: "1d",                        // Cache-Control (number in ms or "12h", "30m", ...)
  extensions: ["html"],                // /about → about.html
  // immutable: true, etag: true, lastModified: true, acceptRanges: true, dotfiles: "ignore",
  // index: "index.html", redirect: true (/dir → /dir/), fallthrough: true,
  // setHeaders: (res, path) => res.set("X-Static", "1"),
}));
app.use("/admin", requireAuth);        // path-scoped

app.get("/notes/:id", (req) => {
  throw new HttpError(404, "No such note");   // status-aware errors
});

// 4 arguments = error handler (same rule as Express)
app.use((err, req, res, next) => {
  const status = err instanceof HttpError ? err.status : 500;
  res.status(status).json({ error: err.message });
});

Anything unhandled falls back to a built-in 404 / error responder. Body parsers reject oversized bodies with a 413 as soon as the limit is crossed, even without a Content-Length header.

Testing without a server

Because the app is a fetch handler, tests need no ports and no sockets:

import { test, expect } from "bun:test";

test("hello", async () => {
  const res = await app.fetch(new Request("http://localhost/hello"));
  expect(res.status).toBe(200);
});

Run the suite: bun test