@glandjs/http@1.1.0-beta
Middleware
The onion, path scoping, useRaw and the Express caveat.
# pnpm pnpm add @glandjs/http @glandjs/express @glandjs/fastify @glandjs/koa @glandjs/hono @glandjs/node# npm npm install @glandjs/http @glandjs/express @glandjs/fastify @glandjs/koa @glandjs/hono @glandjs/node# yarn yarn add @glandjs/http @glandjs/express @glandjs/fastify @glandjs/koa @glandjs/hono @glandjs/node# bun bun add @glandjs/http @glandjs/express @glandjs/fastify @glandjs/koa @glandjs/hono @glandjs/node| Package | @glandjs/http |
| Version | 1.1.0-beta |
| Repository | glandjs/http |
| License | MIT |
| Node | >= 20 |
| Dependencies | @glandjs/core ^1.0.3-beta · @glandjs/events ^1.1.0 · @medishn/toolkit ^1.0.4 · tslib ^2.8.1 |
| Peer dependencies | @glandjs/common ^1.0.3-beta · reflect-metadata ^0.2.2 |
Gland’s middleware is a promise-based onion. It is the Koa model rather than the Express one, and the choice is deliberate — see The Express caveat for what that costs on one adapter.
The shape
Section titled “The shape”app.use(async (ctx, next) => { const started = Date.now(); await next(); // everything downstream ctx.setHeader('x-duration', String(Date.now() - started));});await next() waits for the rest of the chain. A downstream throw reaches this
try/catch:
app.use(async (ctx, next) => { try { await next(); } catch (error) { // Recover, and answer instead of propagating. ctx.status(503).json({ error: 'upstream unavailable' }); }});To hand an error to the chain — which is what next(err) is for:
if (!ctx.getHeader('authorization')) { await ctx.next(new HttpException(401, { detail: 'Missing bearer token' }));}Path scoping
Section titled “Path scoping”app.use('/api', rateLimit);app.use(['/admin', '/internal'], audit);The prefix is matched on a segment boundary: /api matches /api and
/api/x, and not /apixyz. The check runs per request rather than by mounting
once per path, so an array mounts a single entry.
The two kinds
Section titled “The two kinds”app.use(async (ctx, next) => { … }); // Gland, on the onionapp.useRaw(compression()); // framework, unmolestedapp.useStaticAssets('./public'); // a static mountuseRaw is for anything Gland does not wrap: express.static(),
@fastify/helmet(), a rate limiter with its own store. It is mounted in call
order alongside use, so interleaving behaves the way it reads.
What each adapter accepts:
use |
useRaw |
|
|---|---|---|
| express | yes | yes, (req, res, next) |
| fastify | yes | no — register a plugin |
| koa | yes | yes, (ctx, next) |
| hono | yes | yes, (c, next); headers it adds are merged |
| node | yes | no — there is no use() |
useRaw on Fastify and node:http is reported rather than ignored, because a
silently-dropped compression() leaves an application that believes it is
compressed.
When a middleware does not call next
Section titled “When a middleware does not call next”The chain stops. Everything after it — including the route handler — does not run, and what the client receives is what the middleware wrote.
app.use(async (ctx, next) => { if (ctx.method === 'OPTIONS') { ctx.status(204).end(); // the preflight is answered here return; // and `next()` is deliberately not called } await next();});This is how CORS works, and how a cache hit or a rate-limit rejection avoids reaching a handler that would do work whose result is not needed.
The three kinds of registration are buffered and mounted together, in this order, when the server starts:
- the framework’s own configuration
- the middleware queue, in call order
- the routes
- the catch-all 404, behind everything
So this reads top to bottom and behaves top to bottom:
app.useStaticAssets('./public'); // 1stapp.useRaw(compression()); // 2ndapp.use(requireAuth); // 3rdapp.use('/api', rateLimit); // 4thapp.enableCors(); // 5thapp.get('/health', handler); // after all of themNote that app.get() may appear before app.use() in your source and the
middleware still runs first. connectTo() has to come before use() — it is
what produces the application — so the routes are registered before the
middleware is declared; buffering is what makes the call order irrelevant.
The one that catches people is the 404. It is mounted after the routes, because a catch-all mounted before them answers first and returns “not found” for everything.
The Express caveat
Section titled “The Express caveat”Express’s next() is a hand-off, not a call. The bridge resolves as soon as
Express moves downstream:
// Koa, Hono, node:http — and Fastify, via its composed hook.app.use(async (ctx, next) => { const started = Date.now(); await next(); // the downstream half has finished metrics.timing('route', Date.now() - started);});
// Express — `metrics.timing` records the hand-off, not the route.What still works on Express:
next(err)reaches the error handler- the error handler runs
- path scoping
- ordering against
useRaw
What does not: measuring or transforming anything after the hand-off, from upstream middleware.
The workarounds, in the order worth trying:
-
Move it downstream. Timing belongs in a middleware registered after the one you want to measure, or in the handler.
-
Use the lifecycle events, which are transport-agnostic and carry a duration computed by the pipeline after the response:
app.on(HttpEvent.RequestEnd, ({ method, path, status, duration }) => {metrics.timing('http.request', duration, { method, path, status });}); -
Use another adapter if the onion is the point.
Built in
Section titled “Built in”app.enableCors({ origin: 'https://example.com', credentials: true });One implementation for all five adapters, because a CORS policy that differs per framework is a data leak waiting to happen. See CORS.
Errors
Section titled “Errors”app.setErrorHandler((error, ctx) => { if (error instanceof HttpException) return errorHandler(error, ctx); ctx.json({ error: ctx.requestId }, 500);});The default renders RFC 7807 problem details and drops the message of anything
that is not an HttpException. See Errors.
Writing your own
Section titled “Writing your own”withErrors makes the return path and the throw path symmetric, so a handler can
return ctx.throw(404) or throw new NotFoundException() with the same result:
import { withErrors } from '@glandjs/http';
app.get( '/orders/:id', withErrors(async (ctx) => { const order = await ctx.call('db:order:find', ctx.params.id); if (!order) return ctx.throw(HttpStatus.NOT_FOUND); return order; }),);bodyLimit rejects an oversized body before the parsers run, because a parser
that has already buffered a 2 GB upload has already lost:
app.use(bodyLimit(1_000_000));