The HTTP layer

Edit docs/architecture/http.md on GitHub

The 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:

  1. Collect controllers from @Module({ controllers }) across the import graph
  2. Discover routes per controller by walking the constructed instance's prototype chain (see Route discovery). Zero routes throws
  3. Join controller prefix + method path, normalize
  4. Detect collisions and throw - Bun silently lets one route win
  5. Build the routes object; each handler is a closure over the already-constructed instance and its bound method, with the middleware chain already folded in
  6. 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:

  • ThrottleGuard rate-limits every route and is tuned per route by @Throttle metadata. 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.
  • AuditContextMiddleware looked 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's AuthModule rather than the app's AccountsModule. 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, RoutePrefix and ClaimedRoutes, each of which listen() attaches state to once the server exists
  • RequestLoggingMiddleware, MetricsMiddleware and SocketLoggingMiddleware, 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. @Controller is 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 with Object.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

TS
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:     0

The 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 -> refused

The 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.
  • psubscribe is 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() before close();
  • a subscribe() that failed to connect - fixed by connect() before subscribe(), 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.