The HTTP layer
Edit docs/architecture/http.md on GitHubThe Bun.serve adapter, how routes are discovered, and multi-node websocket fan-out.
HTTP adapter (@dunx/http)
HttpFactory.create(RootModule, options?) boots the container via
AppFactory.create, then:
- Collect controllers from
@Module({ controllers })across the import graph - Discover routes per controller by walking the constructed instance's prototype chain (see Route discovery). Zero routes throws
- Join controller prefix + method path, normalize
- Detect collisions and throw - Bun silently lets one route win
- Build the
routesobject; each handler is a closure over the already-constructed instance and its bound method, with the middleware chain already folded in - Hand to
Bun.serve
Step 2 needs the instance, not just the class, so the handler can be bound off it. Binding off the instance is what makes an undecorated override in a subclass dispatch correctly. That ordering is guaranteed: container resolution is eager and completes first.
Middleware is a class with handle(req, ctx, next), resolved from the container
so it gets constructor injection. It has three homes, one per scope it can belong
to: HttpFactory.create / app.use() for app-wide, @Module({ middleware }) for
the routes that module's own controllers declare, and @UseGuards(...) for a class
or a method.
Ordering is global outermost, then the declaring module's, then class-level, then
method-level, then validation, then the handler - and back out through all of it.
There is no ancestor layer: importing a module never changes the request path of the
importer's routes. @dunx/http's lifecycle suite pins the whole order
in one request.
What module middleware is actually for, measured against a real app
Module middleware was designed for the feature-scoped guard: a module providing a
guard for its own routes, injecting providers it keeps private. Migrating
dunx-template says that case is rarer than it looks, and that the design's own
prediction about which of its guards would move was wrong both times:
ThrottleGuardrate-limits every route and is tuned per route by@Throttlemetadata. That is the global-guard-plus-metadata shape working as intended, and splitting it per feature would give every feature a limiter it could forget.AuditContextMiddlewarelooked like the strongest candidate, since only one table carries audit triggers. But the writes to it include better-auth's own sign-up route, whose controller lives inside@dunx/auth'sAuthModulerather than the app'sAccountsModule. With no ancestor layer, scoping the stamp would have silently stopped attributing sign-ups while the trigger still fired, with the previous request's actor id.
That second one is the first concrete cost the "no ancestor layer" decision has paid, and it is still the right decision: the alternative surprise - importing a module changes the importer's request path - is worse and unbounded.
What did move was something the design never named. The app's queue
controller had a private degrades() helper wrapped around all five of its
route bodies, translating an unreachable broker into a 503.
That is a per-controller @Catch filter written by hand: the one thing
HttpOptions.middleware could not express. It is now one
@Module({ middleware }) line.
The generalisation: the first real use of module middleware was a filter, not a guard. A guard that applies to exactly one feature is uncommon, because a global guard reading route metadata already covers it. An error translation that only makes sense for one feature's routes is common, and had nowhere to live.
The framework's own services are bound, not self-bound
HttpFactory wraps the app's root in a global: true module that binds and
exports five services and three middlewares:
PubSub,ClientAddress,RequestMetrics,RoutePrefixandClaimedRoutes, each of whichlisten()attaches state to once the server existsRequestLoggingMiddleware,MetricsMiddlewareandSocketLoggingMiddleware, all bound whether or not the options enable them, so the decision of which run is read off the resolved options instead of made before the container exists
An unbound class self-binds into whichever scope asks first. A second
module injecting ClientAddress was therefore a boot error naming the first
module, and even with a single consumer, listen() could attach the live
server to an instance nothing else held.
They are framework services with no module for an app to import, and the
global scope exists for exactly that. @dunx/http's client-address suite
pins it.
Per request the framework does exactly four things: validate declared schemas,
call the method, pass a Response through or wrap the return in
Response.json(), and map thrown errors. No lookup, no DI, no metadata read
happens on that path: route metadata and the RouteContext join the
boot-time set, not the per-request one.
The context is frozen and shared by every request to its route. ctx.get is
a Map lookup over an already-merged record, rather than a prototype walk.
Route discovery
Both original candidates were measured and both fail - see Verified
constraints. ctx.metadata is unreadable without polyfilling Symbol.metadata
and shares mutable state up the prototype chain. A global pending array drained by
the class decorator hands a base class's routes to whichever subclass is defined
first, and leaks decorated methods across files.
Both were accumulators: they recorded routes at class-definition time and needed a class decorator to close the record. Every failure above traces to that. So stop accumulating.
A method decorator sets a symbol property on the function it receives and returns
it. Nothing is recorded anywhere else. At boot the adapter discovers routes by
inspection: walk Object.getPrototypeOf from the controller's prototype, reading
Object.getOwnPropertyDescriptors at each level. A marked descriptor.value is a
route; most-derived wins on a repeated name. The walk is core's markedMethods,
which queue and AMQP handler discovery share.
Consequences, all measured:
- No accumulator, so no ordering dependence and no cross-file leak. An undecorated class's marked methods are never reached, because its prototype is in no other class's chain.
- No class decorator is required at all - and so no
@Routes(). Inheriting from an undecorated abstract base works for any number of subclasses.@Controlleris reduced to supplying a prefix and may be omitted;@Module({ controllers })is what registers a controller. The prefix is read through the prototype chain rather than withObject.hasOwn, so a subclass inherits its base's prefix and two subclasses of one decorated base collide loudly at boot instead of silently mounting at the root. - Overriding a decorated base method without re-decorating works. The own undecorated member does not shadow discovery, and dispatch resolves through the prototype chain to the override.
- Every route comes from the one walk, so collision detection sees all of them and a controller resolving to zero routes can throw.
No Symbol.metadata, no polyfill, no import-order dependence.
Who knows the global prefix
routesOf walks prototypes, so it reads the path a @Get declared. The prefix is
applied at listen(), and neither of the two places it could come from answers
what the router ended up with: HttpOptionsProvider.prefix is a getter read once
during option resolution, and setGlobalPrefix() may run after create().
So listen() attaches the resolved value to RoutePrefix, which is bound in the
global wrapper for the reason above. @dunx/dashboard injects it and its route
panel reported /notes for a route served at /api/notes without it, which an
operator would copy and get a 404 from. That package's own suite pins both
halves.
Gateways are the half that must not be prefixed. listen() builds the upgrade
table from the gateway paths untouched, so @Gateway('/chat') is served at
/chat whatever the routes carry. Applying the prefix to both would have broken
the panel it was fixing, which is why that suite asserts a socket upgrade against
the path the panel reports.
@dunx/mcp reads the same routes and cannot do this: it never boots the app, so
there is no listen() to have resolved anything. It reports what the code
declares.
Declined as the default: trailing-slash normalisation
GET /t is a 200 and GET /t/ is a 404. That is still the default, and
strict: false on HttpFactory.create is how an app changes it.
joinPath already normalises the declared side, so @Get('sub/')
becomes /t/sub, and both spellings are never live at once.
The inbound side belongs to Bun. By the time anything in dunx can see that
nothing matched, it is inside the fetch fallback, which holds middleware
and the error mapper and no route patterns. Stripping the slash and
re-dispatching there would mean matching /t/7/ against /t/:id in
JavaScript: exactly the router this repo will not write. That half stays
declined, and it is what express and hono do internally.
Registering /t/ as a second entry in the Bun.serve table is the other
option, and the one strict: false takes. It was first rejected as blast
radius: it doubles a table that collision detection, gateway-path checking and
the CORS OPTIONS mounting all walk.
None of the three walk it. Collision detection and the gateway-path check both
read the discovered routes rather than the table, so alias insertion order does
not affect them. The preflight is mounted while the table is built, so an alias
added afterwards inherits it. withUpgradeRoutes then assigns each gateway path
outright, so an upgrade wins a key an alias took.
The alias is a key rather than a route: one per-method object under two names,
so the metrics series, the request log and the OpenAPI document all say
/t/:id.
/ and any path holding a * are left alone. // is a path Bun matches as
neither, and a wildcard mount already matches its own trailing slash, so
@dunx/auth at /api/auth/* serves /api/auth/ without one.
The default stays strict because that is what Bun.serve does. An earlier
version of this note said "Nest, Express and Fastify all normalise", which is
wrong about Fastify - its ignoreTrailingSlash is off by default, and hono's
strict is on. The field is three to two, and strict is hono's name for this
switch. The per-framework table is in
constraints.
Multi-node websocket fan-out (@dunx/http)
PubSub.publish is server.publish, which is Bun's own pub/sub and therefore
per-process. Two nodes behind a load balancer each fan out to their own sockets and
to nobody else's. The fix is a relay: publish locally and hand the message to
the other nodes, which then publish locally too.
The contract is two methods, and it lives in @dunx/http
interface PubSubRelay {
publish(channel: string, message: string): unknown;
subscribe(channel: string, listener: (message: string) => void): unknown;
close?(): unknown;
}Default: nothing. With no relay configured PubSub is byte-for-byte the code it was
before - one server.publish call and no branch that costs anything measurable.
The Redis-backed implementation, RedisRelay, uses Bun.RedisClient directly.
It is a Bun global, so this adds no dependency to @dunx/http. The package
carries one, @arkv/shared, for the outbound client behind ./client; the relay path
does not touch it.
The rejected alternative was putting it in @dunx/infra/redis, where the
connection handling, retry policy and error classification already exist. It
was rejected on the dependency direction: the relay has to be reachable from
PubSub, PubSub is @dunx/http, and @dunx/infra must not depend on the
web layer.
That coupling has now been refused three times - for the request logger, for
@dunx/auth, and here - and for the same reason each time. @dunx/infra is
what a CLI script, a seeder or a queue worker imports, and none of those have
an HTTP server.
The cost accepted is a small amount of relay-specific connection glue that does
not reuse @dunx/infra/redis's general-purpose client: URL validation, the
maxRetries default, lazy client creation, and unsubscribe-before-close. That is
about 60 lines, and it buys a package with one dependency.
An app that would rather reuse its existing connections can satisfy the two
methods itself. @dunx/infra's RedisConnection already does, structurally:
publish(channel, message) and subscribe(channel, listener) are its own names
and shapes, so app.get(PubSub).relayThrough(app.get(RedisConnection)) typechecks
with no adapter between them.
This is the same @dunx/auth RedisStore precedent: declare the shape, and
let the app supply anything that fits. examples/full runs its second node
that way on purpose.
A Postgres relay over LISTEN/NOTIFY
Bun.SQL has both of PubSubRelay's operations under other names: notify for
publish, listen for subscribe, and unlisten on the returned handle for close. So
this is not the structural fit RedisConnection is, where the names already match.
It is a class of about thirty lines holding one Bun.SQL and renaming three calls.
Bun.SQL is a global, so it would add no dependency to @dunx/http.
Measured on Bun 1.4.0 (rev 34cbb9a40) against Postgres 17, with two real nodes: a
gateway on each, a client connected to node B alone, publishEvent called on node A,
and relayChannel left at its default:
frames on the node-b client: [ "{\"event\":\"greet\",\"data\":{\"from\":\"node-a\"}}" ]
local fan-out on node a: 0The 0 carries the result. Node A had no local subscriber, so the frame reached that
client across the channel or not at all. dunx:ws needs no respelling for Postgres;
Bun quotes the identifier.
One behaviour differs from the Redis relay, and an app moving between the two has to
size its frames for it. Postgres caps a NOTIFY payload at 7999 bytes, and the relay
envelope adds an origin id and a topic on top of the frame:
frame 7925 bytes -> relayed
frame 7935 bytes -> refusedThe refusal is reported. notify() rejects with ERR_POSTGRES_SERVER_ERROR and
22023, #outbound takes its promise branch because Query extends Promise, and
the app gets one logger.warn per degradation:
the websocket relay could not publish. Fan-out is local to this process until it recovers.Redis has no comparable ceiling.
The backend is a method, not a field
PostgresRelay ships beside RedisRelay, and WsRelayModule grew
forPostgres/forPostgresAsync beside forRoot/forRootAsync. Both relays
extend WsRelay, an abstract class the module binds to whichever one it built,
so an HttpOptionsProvider naming WsRelay does not change when the backend does.
The choice sits in the method name, and one measurement forces it.
Providers are instantiated eagerly: a factory bound to a token runs during
AppFactory.create, measured on a module whose unused factory threw at boot.
A single forRootAsync reading its backend from the resolved settings would
still declare both bindings up front, so the unpicked one would be a
RedisRelay constructed against a Postgres url. Two methods declare two
static binding sets instead.
The cost is that the backend cannot come from validated config, since config is resolved after the module graph is built. An app deciding at run time reads the environment where it declares the import.
PostgresRelay holds max + 1 connections while it listens. Bun.SQL dedicates
one to the LISTEN and leaves the pool to answer notify: with max: 1,
pg_stat_activity shows two rows for the relay and one once it closes. Budget two
connections per replica at the default.
One channel, because psubscribe does not work
Frames for every topic travel on one broker channel, not one channel per topic. Two reasons, both forced:
- A node cannot know which topics its sockets joined.
socket.subscribe(topic)goes straight into Bun, and there is no hook and no way to enumerate it - so subscribing to a Redis channel when a topic gains its first local member is not implementable. psubscribeis unusable on Bun 1.3.14 (see bun-apis.md), so a wildcard subscription is not available either.
The cost is that every node reads every relayed frame and drops the ones for
topics it has no local subscriber on - a server.publish returning 0. Two
apps sharing one Redis need two channels. relayChannel exists for that.
Duplicate delivery is the failure mode, and an origin id is the defence
Redis delivers a published message to every subscriber of the channel, the publishing application included - a relay's own subscribe connection receives what its publish connection just sent. Fanning that out locally a second time would deliver twice to every client on the publishing node, which is worse than not having the feature.
So every frame carries the publishing process's id (Bun.randomUUIDv7, once per
PubSub instance) and the inbound path drops a frame whose origin is its own. The
other half of the rule is that the inbound path calls server.publish and
nothing else - re-relaying there would put the frame back on the channel that
delivered it, forever.
relayThrough throws on a second call rather than replacing the relay: two
subscriptions on one channel is the other way to get every message twice.
The guard is a test that asserts exactly one delivery per subscriber with
relaying on - the relay suite, once over an in-memory bus and
once over real Redis with two Bun.serve instances and a client on each. Both fail
with two frames if the origin check is removed, which was verified by removing it.
Two connections, and why maxRetries defaults to 0
A Bun.RedisClient in subscriber mode rejects every data command and throws
synchronously doing it, so the subscription cannot share the socket that publishes.
RedisRelay opens two, lazily - the same pubClient/subClient split
nestjs-template's socket.io adapter makes.
maxRetries defaults to 0 because a client that never connects keeps a retry
timer alive past close() and the process then never exits. A relay is exactly
the connection most likely to be absent, so the default has to be the one that lets
an app boot, degrade and still exit. Raising it opts into Bun's reconnection and
accepts that hazard.
maxRetries: 0 is not sufficient on its own, and finding out cost real time. Two
further Bun.RedisClient behaviours hold the event loop open past close(), and the
symptom of both is a service that shut down cleanly and then hangs forever:
- a client that entered subscriber mode - fixed by
unsubscribe()beforeclose(); - a
subscribe()that failed to connect - fixed byconnect()beforesubscribe(), which fails first and releases cleanly.unsubscribe()cannot rescue this one, because the client is not in subscriber mode.
Both were already latent in @dunx/infra/redis and are fixed there too. The
measurements are in bun-apis.md.
The guards have to be spawn-based tests, because bun test exits the
runner process itself and a held-open event loop is invisible from inside
the suite. This bug went unnoticed until an example app tried to shut down.
Absence is tolerated at every step: a failed subscribe reports once and leaves local fan-out untouched; a failed publish reports once and not again until one succeeds; the app boots either way. A malformed URL, by contrast, throws at construction - that is a config bug, and degrading silently would turn a typo into single-node fan-out nobody notices.
What the relay does not cover
socket.publish(topic, data) is Bun's own method on the socket and does not go
through PubSub, so it stays local. Anything that must cross nodes goes through
PubSub. subscriberCount is local too - Bun counts its own sockets and cannot
count another node's.
HTTP options as a provider
HttpFactory.create(root, options) builds the container, so its options
argument has to be complete before a single provider exists. An app whose request
logging follows its own configuration therefore validated that configuration
twice: once in main.ts to build the argument, and once through ConfigModule
for everything else. examples/full opened with validate(Bun.env).log, and the
container had no view of that second copy.
HttpOptionsProvider carries the same settings and is resolved from the
container. HttpFactory promotes a default binding; a module binding a subclass
replaces it, and that subclass injects ConfigService like anything else.
Three decisions in it:
The argument wins, field by field. A create() argument already meant
something before this existed, so a provider that outranked it would change what
an existing app does. A field the argument omits is the provider's to answer,
which lets configuration move into the container one field at a time.
Promoted rather than bound in the HTTP scope. AppOptions.promote is the
mechanism core already used for Logger and RequestContext: the binding is
laid into every scope holding no view of its own, and a module declaring the
token replaces it with no shadowing warning.
Binding it in HttpModule's own providers was tried first, and failed
differently. It put the token in that scope's own map, which warned every
app that declared a subclass, while the HTTP scope kept resolving its own
default because a scope prefers what it owns.
The imperative methods are unchanged. setGlobalPrefix, enableCors, set
and enableShutdownHooks all still work, and each now has a field alongside it.
The fields are applied in the constructor, so a later method call overrides one.
Both logging middlewares are now bound unconditionally. requestLogging: false
used to decide whether the middleware provider was bound at all, and that decision
had to be made before the container existed; whether the middleware sits in the
chain is read off the resolved options at listen(). Binding one that goes unused
costs a constructor call at boot.