Router Deep Dive β
bunway's router uses Express-style ergonomics while staying true to Bun's Fetch APIs. This page explains the lifecycle so you can compose middleware, sub-routers, and custom responses with confidence.
Coming from Express?
If you've used Express routing, you already know bunway routing. The patterns are identical.
Anatomy of a request β
- Match β incoming requests match routes by HTTP method + path (supporting
:params). - Pipeline β global middleware β auto body parser β route-specific middleware/handlers.
- Execution β each handler receives
(req, res, next). Callnext()to continue the chain. - Finalization β the router chooses the final
Response(explicit return,res.last, or default 200) and merges header bags (e.g., CORS) before returning.
Registering routes β
Define routes using familiar HTTP verb helpers. Each handler receives the Express-compatible (req, res, next) signature:
const app = bunway();
app.get("/health", (req, res) => res.text("OK"));
app.post("/users", async (req, res) => res.created(await req.parseBody()));
app.patch("/users/:id", async (req, res) => {
const id = req.param("id");
const updates = await req.parseBody();
return res.json(await updateUser(id, updates));
});Multiple handlers β
Chain middleware the same way you would in Express:
app.get("/users/:id", authMiddleware, loadUser, (req, res) => {
res.json(req.locals.user);
});Each handler can perform work, populate req.locals, and call next() to continue:
const authMiddleware = (req, res, next) => {
const token = req.get("Authorization");
if (!token) return res.unauthorized();
req.locals.user = verifyToken(token);
next();
};Chainable routes β
Define multiple HTTP methods on the same path without repeating yourself. This is identical to Express's app.route() pattern:
app
.route("/users")
.get((req, res) => res.json(allUsers))
.post((req, res) => res.created(createUser(req.body)));
app
.route("/users/:id")
.get((req, res) => res.json(findUser(req.params.id)))
.put((req, res) => res.json(updateUser(req.params.id, req.body)))
.delete((req, res) => res.noContent());Before (repetitive) β
app.get("/users", listUsers);
app.post("/users", createUser);
app.get("/users/:id", showUser);
app.put("/users/:id", updateUser);
app.patch("/users/:id", patchUser);
app.delete("/users/:id", deleteUser);After (chainable) β
app
.route("/users")
.get(listUsers)
.post(createUser);
app
.route("/users/:id")
.get(showUser)
.put(updateUser)
.patch(patchUser)
.delete(deleteUser);All HTTP methods β
Chainable routes support all HTTP verbs:
app
.route("/test")
.get(handler)
.post(handler)
.put(handler)
.patch(handler)
.delete(handler)
.options(handler)
.head(handler)
.all(handler); // Responds to any methodMiddleware in chains β
Add middleware to individual methods:
app
.route("/admin/users")
.get(requireAuth, listUsers)
.post(requireAuth, requireAdmin, createUser);Works with routers β
Chainable routes work on both app and Router instances:
import { Router } from "bunway";
const api = new Router();
api
.route("/posts")
.get(listPosts)
.post(authMiddleware, createPost);
app.use("/api", api);
// Routes: GET /api/posts, POST /api/postsMiddleware ordering β
Global middleware runs in the order registered, followed by route-specific middleware:
app.use(cors()); // global
app.use(json()); // global
app.use(loggingMiddleware); // global
app.get("/secure", authMiddleware, (req, res) => {
res.ok(req.locals.user);
});- Global middleware runs before route-specific middleware.
req.isBodyParsed()lets you skip redundant parsing.- Middleware can return
Responseto short-circuit the pipeline (e.g., auth failures).
Sub-routers β
Group related endpoints into sub-routers for better organization:
import { Router } from "bunway";
const api = new Router();
api.get("/users", listUsers);
api.get("/users/:id", showUser);
app.use("/api", api);Sub-routers inherit parent middleware and can register their own router.use() handlers.
Sub-router inheritance
Middleware registered on the parent app runs before sub-router handlers. Add router-specific middleware inside the router for scoped behaviour.
Nested routers β
Routers can be nested multiple levels deep:
const admin = new Router();
admin.use(requireAdmin);
admin.get("/stats", getStats);
api.use("/admin", admin);
// Full path: /api/admin/statsmergeParams β
By default, child routers do not have access to route parameters defined in the parent. Pass mergeParams: true to inherit parent params:
import { Router } from "bunway";
const userRouter = new Router({ mergeParams: true });
userRouter.get("/posts", (req, res) => {
// req.params.userId is available from the parent mount
res.json({ userId: req.params.userId });
});
app.use("/users/:userId", userRouter);
// GET /users/123/posts β { "userId": "123" }Child params take precedence when the parent and child define the same parameter name.
Deep nesting works as expected β each router in the chain must have mergeParams: true to pass params through:
const orgRouter = new Router({ mergeParams: true });
const teamRouter = new Router({ mergeParams: true });
teamRouter.get("/members", (req, res) => {
res.json({ orgId: req.params.orgId, teamId: req.params.teamId });
});
orgRouter.use("/teams/:teamId", teamRouter);
app.use("/orgs/:orgId", orgRouter);
// GET /orgs/abc/teams/xyz/members β { "orgId": "abc", "teamId": "xyz" }Returning native responses
Handlers can always return Response objects straight from Fetch APIsβbunWay will still merge any middleware headers during finalization.
Error handling β
- Throw
HttpErrorfor explicit status/body/headers. - Throw/return
Responsefor fully custom responses. - Use
errorHandler()middleware for logging/mapping. - Unhandled errors fall back to a safe 500 JSON payload.
import { HttpError } from "bunway";
app.get("/secret", () => {
throw new HttpError(403, "Forbidden");
});404 behaviour β
Unmatched routes return:
HTTP/1.1 404 Not Found
Content-Type: application/json
{"error":"Not Found"}Customize by adding a catch-all route at the end:
app.use((req, res) => res.status(404).json({ error: "Route not found" }));Catch-all
Be sure to register catch-all handlers lastβbunway processes middleware in order, so earlier routes can short-circuit the response.
Body parser defaults β
Routers accept body parser defaults via constructor:
const router = new Router({
bodyParser: {
json: { limit: 2 * 1024 * 1024 },
text: { enabled: true },
},
});Handlers can override parsing dynamically with req.applyBodyParserOverrides().
Recipes β
Request logger β
app.use(async (req, res, next) => {
const start = performance.now();
await next();
const ms = (performance.now() - start).toFixed(1);
console.log(`${req.method} ${req.path} β ${res.statusCode} (${ms}ms)`);
});Or use the built-in logger:
import { logger } from "bunway";
app.use(logger("dev"));Admin-only sub-router β
import { Router, HttpError } from "bunway";
const admin = new Router();
admin.use((req, res, next) => {
if (req.get("Authorization") !== "super-secret") {
throw new HttpError(401, "Admin authorization required");
}
next();
});
admin.get("/stats", (req, res) => {
res.json({ uptime: process.uptime() });
});
app.use("/admin", admin);Per-request body parser override β
app.post("/webhook", async (req, res) => {
req.applyBodyParserOverrides({ text: { enabled: true }, json: { enabled: false } });
const payload = await req.parseBody();
return res.ok({ received: payload });
});Advanced patterns β
- Streaming: work directly with
await req.rawBody()orreq.original.bodyfor streams. - Locals: share data across middleware via
req.locals(e.g.,req.locals.user = user). - Async cleanup: run code after
await next()to implement logging, timers, or metrics.
Continue to Middleware Overview or explore type-level details in the API Reference.