@dunx/http 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,779 @@
1
+ # @dunx/http
2
+
3
+ `Bun.serve` adapter for [dunx](https://github.com/petarzarkov/dunx). Class-based
4
+ controllers **and WebSocket gateways**, standard decorators, and no JavaScript
5
+ router — Bun's native `routes` does path params and per-method dispatch in Zig.
6
+
7
+ `Bun.serve` takes `routes` and `websocket` in one call, so both live here: one
8
+ `listen()`, one server, one port. Zero dependencies beyond `@dunx/core` — no
9
+ `express`, no `ws`, no `socket.io`.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ bun add @dunx/http @dunx/core
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { inject, Module } from '@dunx/core';
21
+ import { Controller, Get, HttpFactory, Post, type Input } from '@dunx/http';
22
+ import { z } from 'zod'; // or Valibot, or ArkType, or none at all
23
+
24
+ const createUser = { body: z.object({ name: z.string() }) } as const;
25
+ const oneUser = { params: z.object({ id: z.coerce.number() }) } as const;
26
+
27
+ @Controller('users')
28
+ export class UsersController {
29
+ readonly #users = inject(UsersService);
30
+
31
+ @Get('/')
32
+ list() {
33
+ return this.#users.findAll(); // plain values become Response.json()
34
+ }
35
+
36
+ @Get('/:id', oneUser)
37
+ one(input: Input<typeof oneUser>) {
38
+ return this.#users.find(input.params.id); // a number, already validated
39
+ }
40
+
41
+ @Post('/', createUser)
42
+ create(input: Input<typeof createUser>) {
43
+ return this.#users.create(input.body.name); // 201, no Response.json()
44
+ }
45
+ }
46
+
47
+ @Module({ controllers: [UsersController], providers: [UsersService] })
48
+ export class UsersModule {}
49
+
50
+ const app = await HttpFactory.create(AppModule, { port: 3000 });
51
+ app.enableShutdownHooks();
52
+ await app.listen();
53
+ ```
54
+
55
+ ## Typed input
56
+
57
+ The second argument to any verb declares what the route accepts. Declaring a
58
+ schema is what makes the matching `input` field exist, get parsed and get
59
+ validated; omitting one means the framework never touches it.
60
+
61
+ ```ts
62
+ const createNote = { body: CreateNote, status: HttpStatusCode.CREATED } as const;
63
+
64
+ @Post('/', createNote)
65
+ create(input: Input<typeof createNote>): Note {
66
+ return this.notes.add(input.body.text); // typed, already validated
67
+ }
68
+ ```
69
+
70
+ `Input<typeof opts>` has to be written out. A standard method decorator can
71
+ **check** a handler's parameter type but cannot contextually type an unannotated
72
+ one, so the annotation is required — and it is a type-level function over the
73
+ options object, so each type is still declared exactly once. A wrong annotation is
74
+ a compile error naming the mismatched property; an unannotated parameter is
75
+ `TS7006`.
76
+
77
+ | Field | Source | Declared by |
78
+ | -------------- | ------------------------------------- | ----------- |
79
+ | `input.req` | the `BunRequest` — always present | always |
80
+ | `input.body` | parsed by `content-type`, then validated | `body` |
81
+ | `input.query` | `new URL(req.url).searchParams` | `query` |
82
+ | `input.params` | `req.params` | `params` |
83
+
84
+ With no options at all, annotate `Input<RouteSchemas>` for the request, or take no
85
+ parameter. Path params without a `params` schema stay on `input.req.params`.
86
+
87
+ Validation is the **Standard Schema** spec (`~standard.validate`, sync or async),
88
+ restated in this package's own types — so Zod 4, Valibot and ArkType all work and
89
+ `@dunx/http` still has zero dependencies. Anything with a `~standard` property
90
+ qualifies, including a hand-written object; see `examples/full`.
91
+
92
+ ### Body parsing
93
+
94
+ Parsed only when `body` is declared, by media type:
95
+
96
+ | `content-type` | `input.body` before validation |
97
+ | ----------------------------------- | ---------------------------------------- |
98
+ | `application/json`, `*+json`, none | `req.json()` |
99
+ | `application/x-www-form-urlencoded` | fields; a repeated key becomes an array |
100
+ | `multipart/form-data` | fields and `File`s, same repeat rule |
101
+ | `text/*` | `req.text()` — a string |
102
+ | anything else | **415**, nothing read |
103
+
104
+ A body the caller mangled is a **400** (`Malformed application/json body`), never a
105
+ 500. A missing `content-type` reads as JSON, because a 415 there would only hide
106
+ the schema error that is about to be more useful.
107
+
108
+ ### Response wrapping
109
+
110
+ | Handler returns | Response |
111
+ | ----------------- | -------------------------------------------- |
112
+ | a `Response` | passed through untouched — the escape hatch |
113
+ | `undefined`/`null`| `204`, no body |
114
+ | anything else | `Response.json(value)` at the status below |
115
+
116
+ Status precedence: `options.status`, else **201 for POST**, else **200** — Nest's
117
+ rule. A thrown `HttpError` still goes through the error mapper.
118
+
119
+ ### Validation failures
120
+
121
+ A rejected schema is a `ValidationError` — a `400` whose body carries every issue,
122
+ with the path flattened to dots (both `['a', 0]` and `[{ key: 'a' }, { key: 0 }]`
123
+ render as `a.0`):
124
+
125
+ ```json
126
+ {
127
+ "error": "Invalid body",
128
+ "status": 400,
129
+ "issues": [{ "message": "name must be a non-empty string", "path": "name" }]
130
+ }
131
+ ```
132
+
133
+ ### Which validator to use
134
+
135
+ Any of them. This is measured rather than asserted — `bun run validation` in
136
+ `tools/bench` runs the same dunx app and the same schema shape with only the library
137
+ behind `~standard` changed, and reports what each one costs per request:
138
+
139
+ | Validator | costs | `~standard` |
140
+ | --------------------------- | -------: | ----------------------- |
141
+ | TypeBox, `TypeCompiler` AOT | ~0.00 µs | needs a ~10-line bridge |
142
+ | ajv, compiled JSON Schema | 0.34 µs | needs a ~10-line bridge |
143
+ | ArkType | 0.42 µs | built in |
144
+ | Valibot | 0.89 µs | built in |
145
+ | zod | 0.94 µs | built in |
146
+
147
+ **`await req.json()` on the same request costs 3.10 µs**, which is more than all of
148
+ them put together. So validation is not where a slow endpoint's time goes, and
149
+ swapping zod for a compiled validator buys about 7% of a small request — worth having
150
+ if a profile points at it, not worth restructuring for. zod is what `@dunx/openapi`
151
+ reads schemas from (via `z.toJSONSchema`), and it is the default for that reason
152
+ rather than a performance one. Three fields, though: a deeply nested schema would very
153
+ likely separate these engines much further.
154
+
155
+ Two of the five ship no `~standard` property. Bridging one is small enough to inline —
156
+ this is the whole of it:
157
+
158
+ ```ts
159
+ const compiled = TypeCompiler.Compile(Person);
160
+
161
+ const PersonSchema: StandardSchemaV1<unknown, Static<typeof Person>> = {
162
+ '~standard': {
163
+ version: 1,
164
+ vendor: 'typebox',
165
+ validate: (value) =>
166
+ compiled.Check(value)
167
+ ? { value }
168
+ : {
169
+ issues: [...compiled.Errors(value)].map((error) => ({
170
+ message: error.message,
171
+ path: error.path.slice(1).split('/'),
172
+ })),
173
+ },
174
+ },
175
+ };
176
+ ```
177
+
178
+ Full numbers, methodology and the ajv version:
179
+ [`tools/bench/README.md`](../../tools/bench/README.md), "Validation cost".
180
+
181
+ ## Route metadata and scoped middleware
182
+
183
+ A decorator annotates a route; a guard reads the annotation back. Metadata on its
184
+ own enforces nothing — which is why `@Roles` needs a guard that looks at it, and
185
+ why one global guard plus `@Public()` is the combination worth learning.
186
+
187
+ ```ts
188
+ import {
189
+ Controller,
190
+ Get,
191
+ HttpError,
192
+ HttpStatusCode,
193
+ Patch,
194
+ Post,
195
+ Public,
196
+ PUBLIC,
197
+ Roles,
198
+ ROLES,
199
+ UseGuards,
200
+ type Middleware,
201
+ type Next,
202
+ type RouteContext,
203
+ } from '@dunx/http';
204
+
205
+ // Global. `ctx.get(PUBLIC)` is the only thing that can tell an opted-out route
206
+ // apart from one that needs credentials.
207
+ export class AuthGuard implements Middleware {
208
+ handle(req: BunRequest, ctx: RouteContext, next: Next) {
209
+ if (ctx.get(PUBLIC)) return next();
210
+ if (!req.headers.get('authorization')) {
211
+ throw new HttpError(HttpStatusCode.UNAUTHORIZED, 'No credentials');
212
+ }
213
+ return next();
214
+ }
215
+ }
216
+
217
+ // A guard is middleware that throws. There is no `CanActivate`.
218
+ export class RolesGuard implements Middleware {
219
+ handle(req: BunRequest, ctx: RouteContext, next: Next) {
220
+ const required = ctx.get(ROLES);
221
+ if (!required) return next();
222
+ if (!required.includes(roleOf(req))) {
223
+ throw new HttpError(HttpStatusCode.FORBIDDEN, 'Forbidden');
224
+ }
225
+ return next();
226
+ }
227
+ }
228
+
229
+ @Roles('admin') // a class-level default
230
+ @Controller('reports')
231
+ export class ReportsController {
232
+ @Public() // overrides the class-level @Roles for this route
233
+ @Get('/health')
234
+ health() {
235
+ return { ok: true };
236
+ }
237
+
238
+ @UseGuards(RolesGuard) // reads the class-level @Roles('admin')
239
+ @Post('/')
240
+ create(input: Input<typeof createReport>) {}
241
+
242
+ @Roles('editor') // the method wins over the class
243
+ @UseGuards(RolesGuard)
244
+ @Patch('/:id')
245
+ rename(input: Input<typeof renameReport>) {}
246
+ }
247
+
248
+ const app = await HttpFactory.create(AppModule, { middleware: [AuthGuard] });
249
+ ```
250
+
251
+ ### `RouteContext`
252
+
253
+ The second argument to `handle`. Built **once per route at boot** and closed over
254
+ by the chain, so `get` is a `Map` lookup over an already-merged record — not a
255
+ prototype walk, and nothing is resolved per request.
256
+
257
+ | Member | Is |
258
+ | -------------- | --------------------------------------------------------- |
259
+ | `controller` | The controller class's name |
260
+ | `handler` | The method's name |
261
+ | `method` | `'GET' \| 'POST' \| ...` |
262
+ | `path` | The mounted path, prefixes applied |
263
+ | `get(key)` | The metadata value, or `undefined` |
264
+
265
+ `get` resolves the **handler's** metadata first and the **controller class's**
266
+ second — the same override direction as Nest's `Reflector.getAllAndOverride`.
267
+
268
+ ### Your own keys
269
+
270
+ `@Roles` and `@Public` are three lines each over the generic setter, and a key of
271
+ your own costs the same:
272
+
273
+ ```ts
274
+ import { meta, metaKey } from '@dunx/http';
275
+
276
+ const TENANT = metaKey<string>('tenant');
277
+ export const Tenant = (name: string) => meta(TENANT, name);
278
+
279
+ // …and in a guard: ctx.get(TENANT)
280
+ ```
281
+
282
+ `metaKey` mints a fresh symbol per call, so two libraries that both name a key
283
+ `roles` never read each other's value. `meta` is valid on a **method or a class**;
284
+ there are no parameter decorators in the standard proposal, so there is nothing
285
+ else it could attach to.
286
+
287
+ ### Ordering and inheritance
288
+
289
+ - **Chain order**: global (`HttpOptions.middleware`, then `use()`), then the
290
+ controller's `@UseGuards`, then the method's. Outermost first.
291
+ - Guards are resolved **from the container**, exactly like global middleware, so a
292
+ guard gets constructor injection and one instance is shared by every route that
293
+ declares it.
294
+ - A subclass inherits its base's class-level metadata and guards, and its own
295
+ additions never reach the base or a sibling: every write copies the record and
296
+ defines an **own** property. Nothing accumulates at class-definition time, so
297
+ there is no ordering dependence and no cross-file leak.
298
+ - Two `@UseGuards` on one target read top to bottom. Two of one metadata key read
299
+ bottom-up, so the topmost decorator wins.
300
+
301
+ ## Request logging, on by default
302
+
303
+ Every request produces **one** structured entry, request and response together:
304
+
305
+ ```json
306
+ {
307
+ "level": "info",
308
+ "message": "POST /api/users 201",
309
+ "requestId": "b1f0…",
310
+ "method": "POST",
311
+ "event": "/api/users",
312
+ "flow": "http",
313
+ "context": "UsersController.create",
314
+ "request": { "userAgent": "curl/8.5.0" },
315
+ "statusCode": 201,
316
+ "elapsedMs": 5
317
+ }
318
+ ```
319
+
320
+ One entry, not two, is the point. Nest logs on the way in from a middleware and on
321
+ the way out from an interceptor, because they are different classes and the
322
+ interceptor cannot see what the middleware saw. Here they are the same closure, so
323
+ there is no pair to correlate by `requestId` to find out how a call ended. A 4xx
324
+ logs at `warn`, a 5xx at `error`.
325
+
326
+ It needs no configuration: `Logger` and `RequestContext` are `@dunx/core`
327
+ contracts with default bindings, so this works in an app that imported no logging
328
+ module. Import `@dunx/infra/logger` and the same entries go through
329
+ `@arkv/logger` — sanitized, masked, optionally to a rotating file — with nothing
330
+ here changing.
331
+
332
+ Everything the **handler** logs in between carries the same `requestId`, `method`,
333
+ `event` and `context`, because the whole call runs inside `runWithContext`. An
334
+ inbound `x-request-id` is honoured so a trace survives across services; otherwise
335
+ one is minted and returned on the response.
336
+
337
+ ### Bodies are off by default, and what that costs
338
+
339
+ `requestBody` and `responseBody` default to **`false`**. Turning either on means a
340
+ `clone().text()` — a second copy of every payload, buffered and parsed, on the hot
341
+ path. Measured in `tools/bench`, both on cost roughly two thirds of the throughput
342
+ on the `validate` scenario. The response body is also the field most likely to
343
+ carry a secret, so this is the right default twice over.
344
+
345
+ Turn them on in development, where seeing the payload is the point:
346
+
347
+ ```ts
348
+ // Off entirely — what the benchmark's primary `dunx` subject uses, since no other
349
+ // framework in that suite logs anything.
350
+ HttpFactory.create(AppModule, { requestLogging: false });
351
+
352
+ // Development: show me everything.
353
+ HttpFactory.create(AppModule, {
354
+ requestLogging: { requestBody: true, responseBody: true },
355
+ });
356
+
357
+ // Production: skip the health check the load balancer polls every second.
358
+ HttpFactory.create(AppModule, {
359
+ requestLogging: { ignore: ['/health'], maxBodyLength: 512 },
360
+ });
361
+ ```
362
+
363
+ **Even at its cheapest, a log line is not free.** `tools/bench` carries `dunx` and
364
+ `dunx-logging` as separate subjects for exactly this reason: with logging off dunx
365
+ runs at 81–100% of raw `Bun.serve` depending on the scenario, and with it on, 40–45%.
366
+ The remainder is `JSON.stringify` plus a `write` per request inside an
367
+ `AsyncLocalStorage` scope. If you need the last of the throughput, turn it off and
368
+ sample at the edge instead — but know what you gave up.
369
+
370
+ ### Unmatched paths are logged too
371
+
372
+ `Bun.serve({ routes })` answers a miss itself, so nothing in the middleware chain
373
+ would ever see a 404 — invisible to logging, metrics and tracing. `listen()`
374
+ installs one `fetch` fallback that runs the global middleware and returns
375
+ `{"error":"NOT_FOUND","status":404}`.
376
+
377
+ This is not a JavaScript router. Bun still does every bit of the matching; the
378
+ fallback runs only after it has decided nothing matched.
379
+
380
+ ### The zero-overhead path
381
+
382
+ A route with **no middleware and no CORS** is dispatched by a handler in which
383
+ nothing is `async`. It returns a `Response` rather than a `Promise<Response>`
384
+ wherever it has nothing to wait for — Bun accepts either. The general path awaits the
385
+ input reader, the handler and the response coercion, and for most shapes those awaits
386
+ are on values that were never thenable, each costing an async frame and a microtask
387
+ tick for nothing.
388
+
389
+ | Route shape | What it costs |
390
+ | ---------------------------------------- | ----------------------------------------- |
391
+ | no schemas | no promise at all |
392
+ | `query` and/or `params`, sync validator | no promise at all — read and validated inline |
393
+ | `body` declared | one promise link, for `req.json()` |
394
+
395
+ Measured in `tools/bench`: `plaintext` 89.5% -> 97.2% of raw `Bun.serve` when this
396
+ covered only schema-less routes, and `validate` 84.0% -> 92.3% once it was extended
397
+ to routes that read input. A handler that *does* return a promise, or a validator
398
+ that does, is adopted rather than wrapped — nothing about this is conditional on
399
+ writing sync code.
400
+
401
+ Adding middleware — including `requestLogging` — opts a route back into the async
402
+ path, because middleware is `async` by contract.
403
+
404
+ ## App-level configuration
405
+
406
+ `create()` boots the container and discovers routes; `listen()` is what builds the
407
+ `Bun.serve` route table. So everything between the two still gets to affect it:
408
+
409
+ ```ts
410
+ const app = await HttpFactory.create(AppModule);
411
+ app.setGlobalPrefix('api');
412
+ app.use(AuditMiddleware);
413
+ app.set('trust proxy', true);
414
+ app.enableCors({ origin: 'https://example.com', credentials: true });
415
+ await app.listen(3000);
416
+ ```
417
+
418
+ Calling any of them **after** `listen()` throws. The route table and the middleware
419
+ chain are folded into one closure per route when the server binds, so a late call
420
+ could only ever be a silent no-op — the failure mode worth trading for an error.
421
+
422
+ | Hook | Effect |
423
+ | ------------------------ | ----------------------------------------------------------------------------- |
424
+ | `setGlobalPrefix(p)` | Prefixes every discovered route. Slashes normalised; last call wins |
425
+ | `use(...middleware)` | Appends container-resolved `Ctor<Middleware>`, so it can inject |
426
+ | `set(key, value)` | Typed settings — a key must exist on `AppSettings`, so a typo is a type error |
427
+ | `setting(key)` | Reads one back |
428
+ | `enableCors(options?)` | Response headers plus an `OPTIONS` preflight per path. Last call wins |
429
+ | `clientIp(req)` | The `inject(ClientAddress)` singleton, honouring `'trust proxy'` |
430
+ | `listen(port?)` | Builds the table, binds. A second call throws |
431
+
432
+ ### Precedence
433
+
434
+ - **Middleware order**: `HttpOptions.middleware` first (outermost), then each
435
+ `use()` call in the order it was made, then a controller's `@UseGuards`, then a
436
+ method's — innermost. Outermost sees the request first and the response last.
437
+ - **Port**: the `listen(port)` argument, else `HttpOptions.port`, else `3000`.
438
+ - **Error mapper**: `HttpOptions.onError`; there is no imperative equivalent.
439
+ - **Overrides**: `HttpOptions.overrides` is core's `AppOptions.overrides`, passed
440
+ straight through — bindings replaced in place, which is what `@dunx/testing`'s
441
+ `createTestServer` uses.
442
+ - **Repeated calls**: `setGlobalPrefix`, `set` and `enableCors` all replace, so the
443
+ last call wins. `use()` appends.
444
+ - **Collisions**: rejected at `create()`, and re-checked at `listen()` against the
445
+ final prefixed paths. A uniform prefix cannot introduce a collision the
446
+ unprefixed paths did not already have, which is why the early check is complete.
447
+
448
+ ### CORS and preflight
449
+
450
+ `Bun.serve({ routes })` answers a method miss with `404`, so a preflight can never
451
+ be inferred — `enableCors()` mounts an explicit `OPTIONS` handler on every path,
452
+ built at boot from the methods that path actually declares. `origin` takes a
453
+ string, a list, or a predicate; anything not allowed gets **no** CORS headers at
454
+ all, which is what makes the browser block it. `'*'` is the default, and because a
455
+ browser rejects `*` alongside credentials, `credentials: true` reflects the
456
+ caller's origin instead. `allowedHeaders` defaults to echoing
457
+ `Access-Control-Request-Headers`. Headers are applied outside the error mapper, so
458
+ a mapped `500` still carries them.
459
+
460
+ ### Client IP
461
+
462
+ `ClientAddress` needs no registration — every class is injectable, and `listen()`
463
+ hands the resolved singleton the live server:
464
+
465
+ ```ts
466
+ export class AuditMiddleware implements Middleware {
467
+ constructor(private readonly address: ClientAddress) {}
468
+
469
+ async handle(req: BunRequest, ctx: RouteContext, next: Next) {
470
+ console.log(this.address.of(req));
471
+ return next();
472
+ }
473
+ }
474
+ ```
475
+
476
+ `of(req)` returns the first `X-Forwarded-For` entry when `'trust proxy'` is set and
477
+ the header is present, otherwise `server.requestIP(req)?.address`. Leave the
478
+ setting off unless a proxy you control rewrites the header: a direct client can
479
+ send whatever it likes.
480
+
481
+ ## WebSocket gateways
482
+
483
+ A gateway is a normal injectable class declared in `@Module({ providers })` — there
484
+ is no second list and no module to configure. `HttpFactory` finds it by its
485
+ `@Gateway` marker, and `listen()` mounts it on the same server as the routes:
486
+
487
+ ```ts
488
+ import { Module } from '@dunx/core';
489
+ import {
490
+ Gateway,
491
+ HttpFactory,
492
+ OnClose,
493
+ OnMessage,
494
+ OnOpen,
495
+ PubSub,
496
+ type Socket,
497
+ } from '@dunx/http';
498
+
499
+ @Gateway('/chat')
500
+ export class ChatGateway {
501
+ constructor(private readonly pubsub: PubSub) {}
502
+
503
+ @OnOpen()
504
+ opened(socket: Socket) {
505
+ socket.send('welcome');
506
+ }
507
+
508
+ @OnMessage('chat.join')
509
+ join(room: string, socket: Socket) {
510
+ socket.subscribe(room); // Bun's own pub/sub
511
+ return { joined: room }; // returned values are replied to the sender
512
+ }
513
+
514
+ @OnMessage('chat.say')
515
+ say(payload: { room: string; text: string }) {
516
+ this.pubsub.publishEvent(payload.room, 'chat.said', payload.text);
517
+ }
518
+
519
+ @OnClose()
520
+ closed(socket: Socket, code: number) {
521
+ console.log(`${socket.data.path} closed with ${code}`);
522
+ }
523
+ }
524
+
525
+ @Module({ controllers: [NotesController], providers: [ChatGateway] })
526
+ export class AppModule {}
527
+
528
+ const app = await HttpFactory.create(AppModule, {
529
+ websocket: { idleTimeout: 120 },
530
+ });
531
+ await app.listen(3000); // /notes over HTTP and /chat over WebSocket
532
+ ```
533
+
534
+ Constructor injection, `inject()`, `OnInit` and `OnShutdown` all work in a gateway,
535
+ because the container builds it like anything else. `app.gatewayPaths` is every
536
+ path that upgrades.
537
+
538
+ ### Handlers
539
+
540
+ | Decorator | Signature | Notes |
541
+ | ------------------- | ------------------------------------- | -------------------------------------------------------------- |
542
+ | `@Gateway(path)` | class | Required — it is what marks the provider as a gateway |
543
+ | `@OnUpgrade()` | `(req: BunRequest)` | Return a `Response` to refuse; anything else becomes `context` |
544
+ | `@OnOpen()` | `(socket)` | |
545
+ | `@OnMessage(event)` | `(data, socket)` | Routed by envelope event name |
546
+ | `@OnMessage()` | `(message: string \| Buffer, socket)` | The raw catch-all |
547
+ | `@OnClose()` | `(socket, code, reason)` | |
548
+ | `@OnDrain()` | `(socket)` | Backpressure relieved |
549
+ | `@OnPing()` | `(data, socket)` | Bun still answers with a pong |
550
+ | `@OnPong()` | `(data, socket)` | |
551
+
552
+ Handlers may be `async`. A returned value is sent to the sender — under the same
553
+ event name for `@OnMessage(event)`, verbatim (or JSON) for the raw handler, and
554
+ never for a lifecycle handler. Return `undefined` to send nothing.
555
+
556
+ `socket` is Bun's `ServerWebSocket`, unwrapped: `send`, `subscribe`, `unsubscribe`,
557
+ `isSubscribed`, `subscriptions`, `publish`, `cork`, `ping`, `close`,
558
+ `getBufferedAmount` are its own methods. `socket.data.path` is the gateway path;
559
+ `socket.data.context` is whatever `@OnUpgrade` returned.
560
+
561
+ ### The upgrade is a route
562
+
563
+ `server.upgrade()` is called from inside a native route handler, so the gateway's
564
+ path is matched by Bun's router like any other path, and **no `fetch` handler is
565
+ needed** for a socket to connect. Consequences, all measured:
566
+
567
+ - A gateway path may be a **pattern**: `@Gateway('/room/:room')` works, and
568
+ `@OnUpgrade()` is handed the `BunRequest`, so `req.params.room` is readable there
569
+ and can be returned as the connection's `context`.
570
+ - A plain `GET` on a gateway path is **426**; any other method is Bun's native
571
+ **404**, because the upgrade is mounted as a `GET`. A path no gateway and no
572
+ controller serves is the same native 404 — there is nothing to fall through to.
573
+ - A path claimed by both a gateway and a controller route is a **boot error**
574
+ naming both, since one of the two would otherwise be dropped from the table.
575
+ - `setGlobalPrefix()` moves routes, **not** gateways. A gateway path is the exact
576
+ pathname a client dials.
577
+
578
+ ### Discovery, and what is a boot error
579
+
580
+ Handlers are discovered at boot by walking each gateway instance's prototype chain,
581
+ so an abstract base gateway's handlers are inherited by every subclass and an
582
+ undecorated override still dispatches to the override. Nothing is read per message:
583
+ the handler table, the `websocket` object, and one upgrade closure per gateway are
584
+ built once.
585
+
586
+ These throw at boot rather than picking a winner:
587
+
588
+ - two handlers claiming one event or one lifecycle slot, named individually
589
+ - two gateways on one path, named individually
590
+ - a `@Gateway` class with no handlers at all
591
+ - a handler-declaring provider that is **not** a `@Gateway` — it could never
592
+ receive a frame, so it is an error instead of a silent no-op
593
+
594
+ ### The envelope
595
+
596
+ Named events need a way to say which event a frame is, so `@dunx/http` defines the
597
+ smallest one that works:
598
+
599
+ ```json
600
+ { "event": "chat.say", "data": { "room": "general", "text": "hi" } }
601
+ ```
602
+
603
+ It is **opt-in**: a frame is only parsed for a gateway that declares at least one
604
+ `@OnMessage(event)` handler. A gateway with only a raw `@OnMessage()` never sees
605
+ JSON it did not ask for. Binary frames, invalid JSON, a non-object, a missing
606
+ `event`, and an event no handler claims all fall through to the raw handler — and
607
+ are ignored if there is none. Nothing is ever replied to the sender that a handler
608
+ did not return.
609
+
610
+ `encode(event, data)` and `decode(frame)` are exported, so a client can share them.
611
+ A handler's payload parameter type is what you expect to receive, not a runtime
612
+ guarantee: the frame's `data` is handed over as it arrived.
613
+
614
+ ### Pub/sub
615
+
616
+ Topics live in Bun, not in a JavaScript map. A socket joins one with
617
+ `socket.subscribe(topic)` and leaves with `socket.unsubscribe(topic)`; both are
618
+ native methods on the socket you already hold.
619
+
620
+ `PubSub` is the injectable side, for publishing without a socket. `HttpFactory`
621
+ binds it around your root module, so nothing has to be imported or registered —
622
+ listing it in `providers` as well is the container's duplicate-binding error:
623
+
624
+ ```ts
625
+ class Notifier {
626
+ constructor(private readonly pubsub: PubSub) {}
627
+
628
+ ship(version: string) {
629
+ this.pubsub.publishEvent('releases', 'shipped', { version }); // envelope
630
+ this.pubsub.publish('releases', 'raw frame'); // string or BufferSource
631
+ return this.pubsub.subscriberCount('releases');
632
+ }
633
+ }
634
+ ```
635
+
636
+ `publish` returns the bytes sent, `0` if the message was dropped, `-1` under
637
+ backpressure — Bun's own status. It goes through `server.publish`, which reaches
638
+ **every** subscriber including the socket whose handler triggered it (unlike
639
+ `socket.publish`, which honours `publishToSelf`). Publishing before the server is
640
+ listening throws saying so.
641
+
642
+ ### Socket options
643
+
644
+ ```ts
645
+ await HttpFactory.create(AppModule, {
646
+ websocket: {
647
+ idleTimeout: 120, // seconds; Bun rejects anything above 960
648
+ maxPayloadLength: 16 * 1024 * 1024,
649
+ backpressureLimit: 1024 * 1024,
650
+ closeOnBackpressureLimit: false,
651
+ perMessageDeflate: true,
652
+ publishToSelf: false,
653
+ sendPings: true,
654
+ onError: (error, socket) => console.error(socket.data.path, error),
655
+ },
656
+ });
657
+ ```
658
+
659
+ Everything but `onError` is Bun's `websocket` option of the same name, and the type
660
+ is `Pick`ed from Bun's own so the two cannot drift. They are server-wide, which is
661
+ why they sit beside `middleware` and `onError` on the factory rather than on a
662
+ module. `onError` catches a throwing or rejecting handler and the socket stays open;
663
+ the default logs.
664
+
665
+ ### Shutdown with a live socket
666
+
667
+ Measured: a graceful `server.stop()` waits for open connections, and a WebSocket
668
+ does not close on its own — so it **never resolves** while a socket is open. An app
669
+ with at least one gateway therefore force-stops (`stop(true)`) in `shutdown()`, and
670
+ those clients see a `1006` close. An app with no gateways still stops gracefully.
671
+ Bun also delivers an empty close `reason` to `@OnClose` once a socket has exchanged
672
+ frames, whatever the client passed; the `code` is reliable.
673
+
674
+ ### Multi-node fan-out
675
+
676
+ Bun's pub/sub is per-process, so two nodes behind a load balancer each reach only
677
+ their own sockets. A **relay** fixes that: `PubSub.publish` fans out locally as
678
+ always and also hands the message to the other nodes, which fan out locally too.
679
+
680
+ ```ts
681
+ import { HttpFactory, RedisRelay } from '@dunx/http';
682
+
683
+ const app = await HttpFactory.create(AppModule, {
684
+ relay: new RedisRelay({ url: 'redis://localhost:6379' }),
685
+ relayChannel: 'my-app:ws', // default 'dunx:ws'
686
+ });
687
+ ```
688
+
689
+ That is the whole opt-in. `RedisRelay` is `Bun.RedisClient` — a Bun global — so this
690
+ adds **no dependency**, and with no `relay` configured nothing here runs at all.
691
+
692
+ Nothing else changes. `socket.subscribe(topic)` is still Bun's, and a topic no
693
+ socket on this node joined simply costs a `server.publish` that reaches nobody.
694
+
695
+ **Exactly once.** Redis delivers a publish back to the application that made it, so
696
+ a frame carries the publishing process's id and the receiving side drops its own.
697
+ Without that, every client on the publishing node would get the message twice. A
698
+ node that receives a relayed frame publishes it **locally only** and never re-relays.
699
+
700
+ **Absence is tolerated.** With Redis unreachable the app still boots, still fans out
701
+ locally, and logs one warning rather than one per publish. A malformed URL throws at
702
+ construction instead, because that is a config bug and degrading silently would hide
703
+ it.
704
+
705
+ #### Bringing your own connection
706
+
707
+ `PubSubRelay` is two methods, so anything that already talks to a broker fits:
708
+
709
+ ```ts
710
+ interface PubSubRelay {
711
+ publish(channel: string, message: string): unknown;
712
+ subscribe(channel: string, listener: (message: string) => void): unknown;
713
+ close?(): unknown; // only if the relay owns the connection
714
+ }
715
+ ```
716
+
717
+ `@dunx/infra`'s `RedisConnection` satisfies it **structurally**, with no adapter and
718
+ no dependency between the two packages. It has to come out of the container, so it
719
+ goes through `relayThrough` rather than the factory option:
720
+
721
+ ```ts
722
+ const app = await HttpFactory.create(AppModule);
723
+ await app.get(PubSub).relayThrough(app.get(RedisConnection), {
724
+ channel: 'my-app:ws',
725
+ });
726
+ await app.listen(3000);
727
+ ```
728
+
729
+ Only one relay per `PubSub` — a second `relayThrough` throws, because two
730
+ subscriptions on one channel is the other way to deliver everything twice.
731
+
732
+ `socket.publish(topic, data)` is Bun's own method and stays local; anything that must
733
+ cross nodes goes through `PubSub`. `subscriberCount` is local too — Bun cannot count
734
+ another node's sockets.
735
+
736
+ `maxRetries` on `RedisRelay` defaults to `0`, and that is deliberate: a
737
+ `Bun.RedisClient` that never connects keeps a retry timer alive past `close()` and
738
+ the process then never exits. Raise it when Redis is a hard requirement and you want
739
+ Bun's reconnection.
740
+
741
+ ## Status codes
742
+
743
+ `HttpStatusCode` is a frozen object, not an `enum` — one name serving as both the
744
+ value and the type, so it reads like an enum and erases like a constant:
745
+
746
+ ```ts
747
+ import { HttpError, HttpStatusCode, type HttpStatusName } from '@dunx/http';
748
+
749
+ throw new HttpError(HttpStatusCode.NOT_FOUND, 'No such user');
750
+
751
+ const code: HttpStatusCode = HttpStatusCode.CONFLICT; // 200 | 201 | ... | 504
752
+ const name: HttpStatusName = 'CONFLICT'; // 'OK' | 'CREATED' | ...
753
+ ```
754
+
755
+ `HttpError.status` stays `number`, so an uncommon code the table omits (451, 507)
756
+ still works.
757
+
758
+ ## Notes
759
+
760
+ - Routes are discovered at boot by walking each controller's prototype chain, so an
761
+ abstract base controller's `@Get` methods are inherited by every subclass.
762
+ - A duplicate method + path **throws at boot** naming both handlers. Bun would
763
+ otherwise silently keep one.
764
+ - Middleware is a class with `handle(req, ctx, next)`, resolved from the container so
765
+ it can `inject()`. Chains are folded into one closure per route at boot, and `ctx`
766
+ is the route that closure belongs to.
767
+ - Handlers may return a `Response`, any JSON-serialisable value, or `undefined`
768
+ for `204`.
769
+ - Schemas, parsers and the status are resolved in `buildRoutes` at boot, into the
770
+ same closure the middleware chain folds into. Per request the framework parses
771
+ and validates what was declared, calls the method, wraps the return, and maps a
772
+ throw — no metadata read, no lookup, no DI.
773
+ - Gateways use the same marker-plus-prototype-scan discovery as routes, and go into
774
+ the same route table. `withUpgradeRoutes` and `buildWebSocket` are exported for
775
+ anyone assembling `Bun.serve` themselves.
776
+
777
+ ## License
778
+
779
+ MIT