One HTTP layer, five transports. The same controller, the same middleware and the same assertions on Express, Fastify, Koa, Hono and node:http.
HTTP is not your application β it is a transport. Gland treats it that way.
@glandjs/http is Gland's HTTP layer. It contains no server, no router and no
framework import: the contracts an adapter implements, the behaviour every
adapter shares, and the decorators that let a controller declare a route.
The transports are separate packages. Swapping one is a one-line import change, and the controller does not move:
| Package | Framework | Middleware onion | Extended methods | Runtime |
|---|---|---|---|---|
@glandjs/express |
Express 5 | hand-off | via a method guard | Node |
@glandjs/fastify |
Fastify 5 | real, inside one hook | most WebDAV | Node |
@glandjs/koa |
Koa 3 | real | native | Node |
@glandjs/hono |
Hono 4 | real | native | Node and the edge |
@glandjs/node |
node:http |
real | native | Node, zero dependencies |
- π One contract, five adapters β and a test suite that boots one application on all five and asserts the same twenty-odd behaviours
- π§
A promise-based middleware onion β
await next()waits, and a downstreamthrowreaches an upstreamcatch - π― A return value is the response β
return product,return ctx.throw(404),return Readable.from(β¦); one coercion function, soreturn 42means the same thing everywhere - π§© An abstract context β an adapter cannot implement it partially, which is what makes a transport change a refactor rather than a rewrite
- π‘οΈ RFC 7807 errors, with the message dropped for anything that is not an
HttpException, because a stack trace in a response body is a disclosure bug - π One CORS implementation for every adapter, with
Vary: Originon by default and thecredentials+origin: '*'contradiction rejected at boot - π‘ Server-sent events that actually frame correctly β a multi-line payload
is split across
data:lines, and a heartbeat keeps intermediaries from idling the connection out - π A lifecycle bus β
request:start,request:end,route:miss,server:listening, and anendthat fires on the failure path too - πͺΆ A zero-dependency adapter β
@glandjs/nodehas no framework and no runtime dependencies, and is the reference implementation of the contract
npm install @glandjs/core @glandjs/common @glandjs/http @glandjs/express reflect-metadatareflect-metadata is a peer dependency, not an optional one, and
experimentalDecorators / emitDecoratorMetadata are mandatory compiler
options.
import { Controller, Channel, Module, On } from '@glandjs/common';
import { Get, Post, HttpReply } from '@glandjs/http';
import { GlandFactory } from '@glandjs/core';
import { ExpressBroker, type ExpressContext } from '@glandjs/express';
interface Events {
'math:add': { a: number; b: number };
}
@Controller('/products')
export class ProductController {
@Get('/:id')
async find(ctx: ExpressContext<Events>) {
const product = await ctx.call('db:product:find', { id: ctx.params.id });
if (!product) return ctx.throw(404, { detail: 'No such product' });
return product; // an object becomes JSON
}
@Post('/')
async create(ctx: ExpressContext<Events>) {
const product = await ctx.call('db:product:create', ctx.body);
return HttpReply.json({ product }, { status: 201, headers: { location: `/products/${product.id}` } });
}
}
@Module({ controllers: [ProductController] })
export class AppModule {}
async function main() {
const { app, shutdown } = await GlandFactory.create(AppModule);
const http = app.connectTo(ExpressBroker, { poweredBy: false });
http.json();
http.enableCors({ origin: 'https://app.example.com' });
http.use(async (ctx, next) => {
const started = Date.now();
await next();
console.log(`${ctx.method} ${ctx.path} ${Date.now() - started}ms`);
});
http.listen(3000);
const stop = async (signal: string) => {
await http.close();
await shutdown(signal);
process.exit(0);
};
process.on('SIGTERM', () => void stop('SIGTERM'));
process.on('SIGINT', () => void stop('SIGINT'));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});app.connectTo(FastifyBroker), KoaBroker, HonoBroker or NodeBroker instead,
and nothing else changes.
Everything that has to be identical lives in @glandjs/http, and everything that
needs framework knowledge lives in an adapter:
In @glandjs/http |
In an adapter |
|---|---|
| reply coercion | building a context |
| the middleware onion | putting a route on a router |
| the request lifecycle | mounting one framework middleware |
| CORS, cookies, problem details | writing a reply to a response |
| path normalisation, SSE framing | binding and releasing the socket |
That is why an adapter is a few hundred lines, and why the layer can be tested once and trusted five times. Architecture β Overview.
Start with docs/README.md β it has a reading path. The rest:
| Getting started | A working service, end to end |
| Choosing an adapter | The five, and which to reach for |
| Controllers and routes | Every method, the prefix rules |
| The context | Every member, grouped |
| Middleware | The onion, scoping, the Express caveat |
| Replies | What a handler may return |
| Errors | Problem details, and what is never leaked |
| CORS | One policy, five adapters |
| Bodies and uploads | The parsers, the limit, the maxAge trap |
| Lifecycle events | The bus, and the five worth wiring up |
| Deployment | TLS, proxies, shutdown, health checks |
| Testing | Unit vs. integration, and the contract suite |
| Migrating to 1.1 | What changed, and what to change |
| API reference | Every public export |
| Adapter matrix | What each transport can and cannot do |
| Architecture | The five ideas the layer is built from |
| Writing an adapter | Adding a sixth transport |
One runnable service per adapter. The controller, the channel and the module are
identical in all five β only the import and connectTo() differ.
npx tsx examples/express/hello-world.ts
npx tsx examples/fastify/hello-world.ts
npx tsx examples/koa/hello-world.ts
npx tsx examples/hono/hello-world.ts
npx tsx examples/node/hello-world.tscurl localhost:3000/hello/world
curl localhost:3000/sum/2/3
curl -X POST localhost:3000/echo -H 'content-type: application/json' -d '{"a":1}'pnpm install
pnpm build # all six packages
pnpm test # unit + the five-transport contract suite
pnpm typecheck
pnpm lintSee Contributing.
MIT. See LICENSE.
