@dunx/http 2.5.0 → 3.0.1

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 CHANGED
@@ -5,8 +5,7 @@ controllers **and WebSocket gateways**, standard decorators, and no JavaScript
5
5
  router - Bun's native `routes` does path params and per-method dispatch in Zig.
6
6
 
7
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`.
8
+ `listen()`, one server, one port. No `express`, no `ws`, no `socket.io`.
10
9
 
11
10
  ## Install
12
11
 
@@ -47,927 +46,50 @@ export class UsersController {
47
46
  @Module({ controllers: [UsersController], providers: [UsersService] })
48
47
  export class UsersModule {}
49
48
 
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** - the usual
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
- `internal/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
148
- all of them put together.
149
-
150
- So validation is not where a slow endpoint's time goes, and swapping zod for a
151
- compiled validator buys about 7% of a small request - worth having if a profile
152
- points at it, though not worth restructuring for. zod is what `@dunx/openapi`
153
- reads schemas from (via `z.toJSONSchema`), and it is the default for that
154
- reason rather than a performance one.
155
-
156
- Three fields, though: a deeply nested schema would very likely separate these
157
- engines much further.
158
-
159
- Two of the five ship no `~standard` property. Bridging one is small enough to inline -
160
- this is the whole of it:
161
-
162
- ```ts
163
- const compiled = TypeCompiler.Compile(Person);
164
-
165
- const PersonSchema: StandardSchemaV1<unknown, Static<typeof Person>> = {
166
- '~standard': {
167
- version: 1,
168
- vendor: 'typebox',
169
- validate: (value) =>
170
- compiled.Check(value)
171
- ? { value }
172
- : {
173
- issues: [...compiled.Errors(value)].map((error) => ({
174
- message: error.message,
175
- path: error.path.slice(1).split('/'),
176
- })),
177
- },
178
- },
179
- };
180
- ```
181
-
182
- Full numbers, methodology and the ajv version:
183
- [`internal/bench/README.md`](../../internal/bench/README.md), "Validation cost".
184
-
185
- ## Route metadata and scoped middleware
186
-
187
- A decorator annotates a route; a guard reads the annotation back. Metadata on its
188
- own enforces nothing, so `@Roles` needs a guard that looks at it, and
189
- why one global guard plus `@Public()` is the combination worth learning.
190
-
191
- ```ts
192
- import {
193
- Controller,
194
- Get,
195
- HttpError,
196
- HttpStatusCode,
197
- Patch,
198
- Post,
199
- Public,
200
- PUBLIC,
201
- Roles,
202
- ROLES,
203
- UseGuards,
204
- type Middleware,
205
- type Next,
206
- type RouteContext,
207
- } from '@dunx/http';
208
-
209
- // Global. `ctx.get(PUBLIC)` is the only thing that can tell an opted-out route
210
- // apart from one that needs credentials.
211
- export class AuthGuard implements Middleware {
212
- handle(req: BunRequest, ctx: RouteContext, next: Next) {
213
- if (ctx.get(PUBLIC)) return next();
214
- if (!req.headers.get('authorization')) {
215
- throw new HttpError(HttpStatusCode.UNAUTHORIZED, 'No credentials');
216
- }
217
- return next();
218
- }
219
- }
220
-
221
- // A guard is middleware that throws. There is no `CanActivate`.
222
- export class RolesGuard implements Middleware {
223
- handle(req: BunRequest, ctx: RouteContext, next: Next) {
224
- const required = ctx.get(ROLES);
225
- if (!required) return next();
226
- if (!required.includes(roleOf(req))) {
227
- throw new HttpError(HttpStatusCode.FORBIDDEN, 'Forbidden');
228
- }
229
- return next();
230
- }
231
- }
232
-
233
- @Roles('admin') // a class-level default
234
- @Controller('reports')
235
- export class ReportsController {
236
- @Public() // overrides the class-level @Roles for this route
237
- @Get('/health')
238
- health() {
239
- return { ok: true };
240
- }
241
-
242
- @UseGuards(RolesGuard) // reads the class-level @Roles('admin')
243
- @Post('/')
244
- create(input: Input<typeof createReport>) {}
245
-
246
- @Roles('editor') // the method wins over the class
247
- @UseGuards(RolesGuard)
248
- @Patch('/:id')
249
- rename(input: Input<typeof renameReport>) {}
250
- }
251
-
252
- const app = await HttpFactory.create(AppModule, { middleware: [AuthGuard] });
253
- ```
254
-
255
- ### `RouteContext`
256
-
257
- The second argument to `handle`. Built **once per route at boot** and closed over
258
- by the chain, so `get` is a `Map` lookup over an already-merged record - not a
259
- prototype walk, and nothing is resolved per request.
260
-
261
- | Member | Is |
262
- | -------------- | --------------------------------------------------------- |
263
- | `controller` | The controller class's name |
264
- | `handler` | The method's name |
265
- | `method` | `'GET' \| 'POST' \| ...` |
266
- | `path` | The mounted path, prefixes applied |
267
- | `get(key)` | The metadata value, or `undefined` |
268
-
269
- `get` resolves the **handler's** metadata first and the **controller class's**
270
- second - the usual override direction for handler-over-class metadata.
271
-
272
- ### Your own keys
273
-
274
- `@Roles` and `@Public` are three lines each over the generic setter, and a key of
275
- your own costs the same:
276
-
277
- ```ts
278
- import { meta, metaKey } from '@dunx/http';
279
-
280
- const TENANT = metaKey<string>('tenant');
281
- export const Tenant = (name: string) => meta(TENANT, name);
282
-
283
- // …and in a guard: ctx.get(TENANT)
284
- ```
285
-
286
- `metaKey` mints a fresh symbol per call, so two libraries that both name a key
287
- `roles` never read each other's value. `meta` is valid on a **method or a class**;
288
- there are no parameter decorators in the standard proposal, so there is nothing
289
- else it could attach to.
290
-
291
- ### Ordering and inheritance
292
-
293
- - **Chain order**: global (`HttpOptions.middleware`, then `use()`), then the
294
- controller's `@UseGuards`, then the method's. Outermost first.
295
- - Guards are resolved **from the container**, exactly like global middleware, so a
296
- guard gets constructor injection and one instance is shared by every route that
297
- declares it.
298
- - A subclass inherits its base's class-level metadata and guards, and its own
299
- additions never reach the base or a sibling: every write copies the record and
300
- defines an **own** property. Nothing accumulates at class-definition time, so
301
- there is no ordering dependence and no cross-file leak.
302
- - Two `@UseGuards` on one target read top to bottom. Two of one metadata key read
303
- bottom-up, so the topmost decorator wins.
304
-
305
- ## Request logging, on by default
306
-
307
- Every request produces **one** structured entry, request and response together:
308
-
309
- ```json
310
- {
311
- "level": "info",
312
- "message": "POST /api/users 201",
313
- "requestId": "b1f0…",
314
- "method": "POST",
315
- "event": "/api/users",
316
- "flow": "http",
317
- "context": "UsersController.create",
318
- "request": { "userAgent": "curl/8.5.0" },
319
- "statusCode": 201,
320
- "elapsedMs": 5
321
- }
322
- ```
323
-
324
- One entry per request, never two. The common arrangement logs on the way in from a middleware and on
325
- the way out from an interceptor, because they are different classes and the
326
- interceptor cannot see what the middleware saw. Here they are the same closure, so
327
- there is no pair to correlate by `requestId` to find out how a call ended. A 4xx
328
- logs at `warn`, a 5xx at `error`.
329
-
330
- It needs no configuration: `Logger` and `RequestContext` are `@dunx/core`
331
- contracts with default bindings, so this works in an app that imported no logging
332
- module. Import `@dunx/infra/logger` and the same entries go through
333
- `@arkv/logger` - sanitized, masked, optionally to a rotating file - with nothing
334
- here changing.
335
-
336
- Everything the **handler** logs in between carries the same `requestId`, `method`,
337
- `event` and `context`, because the whole call runs inside `runWithContext`. An
338
- inbound `x-request-id` is honoured so a trace survives across services - if it is a
339
- UUID; anything else is a caller-supplied string that would end up in every line, so
340
- it is replaced by a fresh one. Either way it is returned on the response.
341
-
342
- `ignore` skips a path **entirely** - no entry, no request id, no async scope - which
343
- is what makes it free. `correlateIgnored: true` keeps the id and the scope on those
344
- paths and still writes no entry, which is "do not log the health check but do keep
345
- its request id".
346
-
347
- `correlate: false` drops the async scope for **every** path. The entry itself is
348
- unchanged - the same five fields are written straight onto it - so only the lines a
349
- handler writes in between lose their `requestId`. That scope is +0.91 µs, 17% of what
350
- request logging costs, and an app whose handlers never log pays it for nothing.
351
-
352
- ### Bodies are off by default, and what that costs
353
-
354
- `requestBody` and `responseBody` default to **`false`**. Turning either on means a
355
- `clone().text()` - a second copy of every payload, buffered and parsed, on the hot
356
- path. Measured in `internal/bench`, both on cost roughly two thirds of the throughput
357
- on the `validate` scenario. The response body is also the field most likely to
358
- carry a secret, so this is the right default twice over.
359
-
360
- Turn them on in development, where seeing the payload is the point:
361
-
362
- ```ts
363
- // Off entirely - what the benchmark's primary `dunx` subject uses, since no other
364
- // framework in that suite logs anything.
365
- HttpFactory.create(AppModule, { requestLogging: false });
366
-
367
- // Development: show me everything.
368
- HttpFactory.create(AppModule, {
369
- requestLogging: { requestBody: true, responseBody: true },
370
- });
371
-
372
- // Production: skip the health check the load balancer polls every second.
373
- HttpFactory.create(AppModule, {
374
- requestLogging: { ignore: ['/health'], maxBodyLength: 512 },
375
- });
376
- ```
377
-
378
- **Even at its cheapest, a log line is not free.** `internal/bench` carries
379
- `dunx` and `dunx-logging` as separate subjects for exactly this reason: with
380
- logging off dunx runs at 81-100% of raw `Bun.serve` depending on the scenario,
381
- and with it on, 40-45%.
382
-
383
- The remainder is `JSON.stringify` plus a `write` per request inside an
384
- `AsyncLocalStorage` scope. If you need the last of the throughput, turn it off
385
- and sample at the edge instead - but know what you gave up.
386
-
387
- ### Unmatched paths are logged too
388
-
389
- `Bun.serve({ routes })` answers a miss itself, so nothing in the middleware chain
390
- would ever see a 404 - invisible to logging, metrics and tracing. `listen()`
391
- installs one `fetch` fallback that runs the global middleware and returns
392
- `{"error":"NOT_FOUND","status":404}`.
393
-
394
- This is not a JavaScript router. Bun still does every bit of the matching; the
395
- fallback runs only after it has decided nothing matched.
396
-
397
- ### The zero-overhead path
398
-
399
- A route with **no middleware and no CORS** is dispatched by a handler in which
400
- nothing is `async`. It returns a `Response` rather than a `Promise<Response>`
401
- wherever it has nothing to wait for - Bun accepts either.
402
-
403
- The general path awaits the input reader, the handler and the response
404
- coercion, and for most shapes those awaits are on values that were never
405
- thenable, each costing an async frame and a microtask tick for nothing.
406
-
407
- | Route shape | What it costs |
408
- | ---------------------------------------- | ----------------------------------------- |
409
- | no schemas | no promise at all |
410
- | `query` and/or `params`, sync validator | no promise at all - read and validated inline |
411
- | `body` declared | one promise link, for `req.json()` |
412
-
413
- Measured in `internal/bench`: `plaintext` 89.5% -> 97.2% of raw `Bun.serve` when this
414
- covered only schema-less routes, and `validate` 84.0% -> 92.3% once it was extended
415
- to routes that read input. A handler that *does* return a promise, or a validator
416
- that does, is adopted rather than wrapped - nothing about this is conditional on
417
- writing sync code.
418
-
419
- Adding middleware - including `requestLogging` - opts a route back into the async
420
- path, because middleware is `async` by contract.
421
-
422
- ## Health checks and draining
423
-
424
- Two routes, `/health/live` and `/health/ready`, and the drain phase that makes the
425
- second one worth having.
426
-
427
- ```ts
428
- import {
429
- DatabaseIndicator,
430
- HealthModule,
431
- MemoryIndicator,
432
- MemoryOptions,
433
- RedisIndicator,
434
- } from '@dunx/http';
435
-
436
- HealthModule.forRootAsync({
437
- useFactory: (db: DbConnection, redis: RedisConnection) => ({
438
- readiness: [new DatabaseIndicator(db), new RedisIndicator(redis)],
439
- liveness: [
440
- new MemoryIndicator(new MemoryOptions({ maxRssBytes: 512 * 1024 ** 2 })),
441
- ],
442
- drainDelayMs: 15_000,
443
- }),
444
- inject: [DbConnection, RedisConnection],
445
- });
446
- ```
447
-
448
- The report is one list, so finding the unhappy check is one place to look:
449
-
450
- ```json
451
- {
452
- "status": "up",
453
- "draining": false,
454
- "uptimeMs": 41233,
455
- "checks": [{ "name": "database", "state": "up", "critical": true, "ms": 1 }]
456
- }
457
- ```
458
-
459
- `up` is 200 and anything else is 503, which is what an orchestrator reads to stop
460
- routing without restarting.
461
-
462
- **Readiness fails before the port closes**, and that ordering is the feature.
463
- `Readiness` implements `@dunx/core`'s `OnDrain`, which runs while the server is
464
- still accepting. Every `onShutdown` hook runs after `server.stop()` has resolved, so
465
- a probe answering from one answers on a closed socket.
466
-
467
- `drainDelayMs` keeps readiness failing for a few probe intervals before the socket
468
- goes, because a load balancer notices on its own schedule.
469
-
470
- Liveness deliberately keeps passing while draining. A pod that is shutting down does
471
- not need restarting, and reporting `down` there invites a SIGKILL mid-drain.
472
-
473
- **Three states, not two.** A check that throws is `down`; one that outruns
474
- `timeoutMs` is `unknown`, because a probe that did not answer has told you nothing.
475
- `unknown` on a critical check fails readiness and on a non-critical one it does not.
476
-
477
- **`critical: false` reports without shedding traffic.** `MemoryIndicator` and
478
- `DiskIndicator` ship that way: a disk at 91% is worth seeing, and pulling the pod
479
- out of rotation does not help, since no other pod's disk is emptier. A memory
480
- ceiling belongs on liveness, where the orchestrator restarts the process.
481
-
482
- `Readiness` is injectable, so a handler can `hold('migrating')` and `release()` to
483
- take the pod out of rotation without shutting down. A `release()` does not undo a
484
- shutdown.
485
-
486
- There is no startup probe. The port already answers that: `create()` finishes every
487
- `onInit` before `listen()` binds, so connection refused *is* "not started yet".
488
-
489
- `MemoryIndicator` uses `process.memoryUsage()` at 5.96 us. `jsc.heapStats()` walks
490
- every live object at 2.2 ms and up, and `Bun.generateHeapSnapshot()` is hundreds of
491
- milliseconds, so neither belongs on a path scraped every two seconds.
492
-
493
- Bun ships no disk API, so `DiskIndicator` is `node:fs`'s **async** `statfs` at 167 us
494
- rather than `statfsSync` at 1.85 us. A stalled network mount blocks the loop for as
495
- long as it stalls, and a health check is what gets called while one is stalling.
496
-
497
- `routes: false` binds everything and mounts nothing, for an app answering on its own
498
- paths.
499
-
500
- ## App-level configuration
501
-
502
- `create()` boots the container and discovers routes; `listen()` is what builds the
503
- `Bun.serve` route table. So everything between the two still gets to affect it:
504
-
505
- ```ts
506
- const app = await HttpFactory.create(AppModule);
507
- app.setGlobalPrefix('api');
508
- app.use(AuditMiddleware);
509
- app.set('trust proxy', true);
510
- app.enableCors({ origin: 'https://example.com', credentials: true });
511
- await app.listen(3000);
512
- ```
513
-
514
- Calling any of them **after** `listen()` throws. The route table and the middleware
515
- chain are folded into one closure per route when the server binds, so a late call
516
- could only ever be a silent no-op - the failure mode worth trading for an error.
517
-
518
- | Hook | Effect |
519
- | ------------------------ | ----------------------------------------------------------------------------- |
520
- | `setGlobalPrefix(p)` | Prefixes every discovered route. Slashes normalised; last call wins |
521
- | `use(...middleware)` | Appends container-resolved `Ctor<Middleware>`, so it can inject |
522
- | `set(key, value)` | Typed settings - a key must exist on `AppSettings`, so a typo is a type error |
523
- | `setting(key)` | Reads one back |
524
- | `enableCors(options?)` | Response headers plus an `OPTIONS` preflight per path. Last call wins |
525
- | `clientIp(req)` | The `inject(ClientAddress)` singleton, honouring `'trust proxy'` |
526
- | `listen(port?)` | Builds the table, binds. A second call throws |
527
-
528
- ### Precedence
529
-
530
- - **Middleware order**: `HttpOptions.middleware` first (outermost), then each
531
- `use()` call in the order it was made, then a controller's `@UseGuards`, then a
532
- method's - innermost. Outermost sees the request first and the response last.
533
- - **Port**: the `listen(port)` argument, else `HttpOptions.port`, else `3000`.
534
- - **Error mapper**: `HttpOptions.onError`; there is no imperative equivalent.
535
- - **Overrides**: `HttpOptions.overrides` is core's `AppOptions.overrides`, passed
536
- straight through, bindings replaced in place, as `@dunx/testing`'s
537
- `createTestServer` uses.
538
- - **Repeated calls**: `setGlobalPrefix`, `set` and `enableCors` all replace, so the
539
- last call wins. `use()` appends.
540
- - **Collisions**: rejected at `create()`, and re-checked at `listen()` against the
541
- final prefixed paths. A uniform prefix cannot introduce a collision the
542
- unprefixed paths did not already have, so the early check is complete.
543
-
544
- ### CORS and preflight
545
-
546
- `Bun.serve({ routes })` answers a method miss with `404`, so a preflight can
547
- never be inferred - `enableCors()` mounts an explicit `OPTIONS` handler on
548
- every path, built at boot from the methods that path actually declares.
549
- `origin` takes a string, a list, or a predicate; anything not allowed gets
550
- **no** CORS headers at all, and the absence is what makes the browser block it.
551
-
552
- `'*'` is the default, and because a browser rejects `*` alongside credentials,
553
- `credentials: true` reflects the caller's origin instead. `allowedHeaders`
554
- defaults to echoing `Access-Control-Request-Headers`. Headers are applied
555
- outside the error mapper, so a mapped `500` still carries them.
556
-
557
- ### Client IP
558
-
559
- `ClientAddress` needs no registration - every class is injectable, and `listen()`
560
- hands the resolved singleton the live server:
561
-
562
- ```ts
563
- export class AuditMiddleware implements Middleware {
564
- constructor(private readonly address: ClientAddress) {}
565
-
566
- async handle(req: BunRequest, ctx: RouteContext, next: Next) {
567
- console.log(this.address.of(req));
568
- return next();
569
- }
570
- }
571
- ```
572
-
573
- `of(req)` returns the first `X-Forwarded-For` entry when `'trust proxy'` is set and
574
- the header is present, otherwise `server.requestIP(req)?.address`. Leave the
575
- setting off unless a proxy you control rewrites the header: a direct client can
576
- send whatever it likes.
577
-
578
- ## WebSocket gateways
579
-
580
- A gateway is a normal injectable class declared in `@Module({ providers })` - there
581
- is no second list and no module to configure. `HttpFactory` finds it by its
582
- `@Gateway` marker, and `listen()` mounts it on the same server as the routes:
583
-
584
- ```ts
585
- import { Module } from '@dunx/core';
586
- import {
587
- Gateway,
588
- HttpFactory,
589
- OnClose,
590
- OnMessage,
591
- OnOpen,
592
- PubSub,
593
- type Socket,
594
- } from '@dunx/http';
595
-
596
- @Gateway('/chat')
597
- export class ChatGateway {
598
- constructor(private readonly pubsub: PubSub) {}
599
-
600
- @OnOpen()
601
- opened(socket: Socket) {
602
- socket.send('welcome');
603
- }
604
-
605
- @OnMessage('chat.join')
606
- join(room: string, socket: Socket) {
607
- socket.subscribe(room); // Bun's own pub/sub
608
- return { joined: room }; // returned values are replied to the sender
609
- }
610
-
611
- @OnMessage('chat.say')
612
- say(payload: { room: string; text: string }) {
613
- this.pubsub.publishEvent(payload.room, 'chat.said', payload.text);
614
- }
615
-
616
- @OnClose()
617
- closed(socket: Socket, code: number) {
618
- console.log(`${socket.data.path} closed with ${code}`);
619
- }
620
- }
621
-
622
- @Module({ controllers: [NotesController], providers: [ChatGateway] })
623
- export class AppModule {}
624
-
625
- const app = await HttpFactory.create(AppModule, {
626
- websocket: { idleTimeout: 120 },
627
- });
628
- await app.listen(3000); // /notes over HTTP and /chat over WebSocket
629
- ```
630
-
631
- Constructor injection, `inject()`, `OnInit` and `OnShutdown` all work in a gateway,
632
- because the container builds it like anything else. `app.gatewayPaths` is every
633
- path that upgrades.
634
-
635
- ### Handlers
636
-
637
- | Decorator | Signature | Notes |
638
- | ------------------- | ------------------------------------- | -------------------------------------------------------------- |
639
- | `@Gateway(path)` | class | Required - it is what marks the provider as a gateway |
640
- | `@OnUpgrade()` | `(req: BunRequest)` | Return a `Response` to refuse; anything else becomes `context` |
641
- | `@OnOpen()` | `(socket)` | |
642
- | `@OnMessage(event)` | `(data, socket)` | Routed by envelope event name |
643
- | `@OnMessage()` | `(message: string \| Buffer, socket)` | The raw catch-all |
644
- | `@OnClose()` | `(socket, code, reason)` | |
645
- | `@OnDrain()` | `(socket)` | Backpressure relieved |
646
- | `@OnPing()` | `(data, socket)` | Bun still answers with a pong |
647
- | `@OnPong()` | `(data, socket)` | |
648
-
649
- Handlers may be `async`. A returned value is sent to the sender - under the same
650
- event name for `@OnMessage(event)`, verbatim (or JSON) for the raw handler, and
651
- never for a lifecycle handler. Return `undefined` to send nothing.
652
-
653
- `socket` is Bun's `ServerWebSocket`, unwrapped: `send`, `subscribe`, `unsubscribe`,
654
- `isSubscribed`, `subscriptions`, `publish`, `cork`, `ping`, `close`,
655
- `getBufferedAmount` are its own methods. `socket.data.path` is the gateway path;
656
- `socket.data.context` is whatever `@OnUpgrade` returned.
657
-
658
- ### The upgrade is a route
659
-
660
- `server.upgrade()` is called from inside a native route handler, so the gateway's
661
- path is matched by Bun's router like any other path, and **no `fetch` handler is
662
- needed** for a socket to connect. Consequences, all measured:
663
-
664
- - A gateway path may be a **pattern**: `@Gateway('/room/:room')` works, and
665
- `@OnUpgrade()` is handed the `BunRequest`, so `req.params.room` is readable there
666
- and can be returned as the connection's `context`.
667
- - A plain `GET` on a gateway path is **426**; any other method is Bun's native
668
- **404**, because the upgrade is mounted as a `GET`. A path no gateway and no
669
- controller serves is the same native 404 - there is nothing to fall through to.
670
- - A path claimed by both a gateway and a controller route is a **boot error**
671
- naming both, since one of the two would otherwise be dropped from the table.
672
- - `setGlobalPrefix()` moves routes, **not** gateways. A gateway path is the exact
673
- pathname a client dials.
674
-
675
- ### Discovery, and what is a boot error
676
-
677
- Handlers are discovered at boot by walking each gateway instance's prototype chain,
678
- so an abstract base gateway's handlers are inherited by every subclass and an
679
- undecorated override still dispatches to the override. Nothing is read per message:
680
- the handler table, the `websocket` object, and one upgrade closure per gateway are
681
- built once.
682
-
683
- These throw at boot rather than picking a winner:
684
-
685
- - two handlers claiming one event or one lifecycle slot, named individually
686
- - two gateways on one path, named individually
687
- - a `@Gateway` class with no handlers at all
688
- - a handler-declaring provider that is **not** a `@Gateway` - it could never
689
- receive a frame, so it is an error instead of a silent no-op
690
-
691
- ### The envelope
692
-
693
- Named events need a way to say which event a frame is, so `@dunx/http` defines the
694
- smallest one that works:
695
-
696
- ```json
697
- { "event": "chat.say", "data": { "room": "general", "text": "hi" } }
698
- ```
699
-
700
- It is **opt-in**: a frame is only parsed for a gateway that declares at least
701
- one `@OnMessage(event)` handler. A gateway with only a raw `@OnMessage()` never
702
- sees JSON it did not ask for.
703
-
704
- Binary frames, invalid JSON, a non-object, a missing `event`, and an event no
705
- handler claims all fall through to the raw handler - and are ignored if there
706
- is none. Nothing is ever replied to the sender that a handler did not return.
707
-
708
- `encode(event, data)` and `decode(frame)` are exported, so a client can share them.
709
- A handler's payload parameter type states what you expect to receive, with no runtime
710
- guarantee: the frame's `data` is handed over as it arrived.
711
-
712
- ### Pub/sub
713
-
714
- Topics live in Bun rather than in a JavaScript map. A socket joins one with
715
- `socket.subscribe(topic)` and leaves with `socket.unsubscribe(topic)`; both are
716
- native methods on the socket you already hold.
717
-
718
- `PubSub` is the injectable side, for publishing without a socket. `HttpFactory`
719
- binds it around your root module, so nothing has to be imported or registered -
720
- listing it in `providers` as well is the container's duplicate-binding error:
721
-
722
- ```ts
723
- class Notifier {
724
- constructor(private readonly pubsub: PubSub) {}
725
-
726
- ship(version: string) {
727
- this.pubsub.publishEvent('releases', 'shipped', { version }); // envelope
728
- this.pubsub.publish('releases', 'raw frame'); // string or BufferSource
729
- return this.pubsub.subscriberCount('releases');
730
- }
731
- }
732
- ```
733
-
734
- `publish` returns the bytes sent, `0` if the message was dropped, `-1` under
735
- backpressure - Bun's own status. It goes through `server.publish`, which reaches
736
- **every** subscriber including the socket whose handler triggered it (unlike
737
- `socket.publish`, which honours `publishToSelf`). Publishing before the server is
738
- listening throws saying so.
739
-
740
- ### Socket options
741
-
742
- ```ts
743
- await HttpFactory.create(AppModule, {
744
- websocket: {
745
- idleTimeout: 120, // seconds; Bun rejects anything above 960
746
- maxPayloadLength: 16 * 1024 * 1024,
747
- backpressureLimit: 1024 * 1024,
748
- closeOnBackpressureLimit: false,
749
- perMessageDeflate: true,
750
- publishToSelf: false,
751
- sendPings: true,
752
- onError: (error, socket) => console.error(socket.data.path, error),
753
- },
754
- });
755
- ```
756
-
757
- Everything but `onError` is Bun's `websocket` option of the same name, and the type
758
- is `Pick`ed from Bun's own so the two cannot drift. They are server-wide, which is
759
- why they sit beside `middleware` and `onError` on the factory rather than on a
760
- module. `onError` catches a throwing or rejecting handler and the socket stays open;
761
- the default logs.
762
-
763
- ### Shutdown with a live socket
764
-
765
- Measured: a graceful `server.stop()` waits for open connections, and a
766
- WebSocket does not close on its own - so it **never resolves** while a socket
767
- is open. An app with at least one gateway therefore force-stops (`stop(true)`)
768
- in `shutdown()`, and those clients see a `1006` close. An app with no gateways
769
- still stops gracefully.
770
-
771
- Bun also delivers an empty close `reason` to `@OnClose` once a socket has
772
- exchanged frames, whatever the client passed; the `code` is reliable.
773
-
774
- ### Multi-node fan-out
775
-
776
- Bun's pub/sub is per-process, so two nodes behind a load balancer each reach only
777
- their own sockets. A **relay** fixes that: `PubSub.publish` fans out locally as
778
- always and also hands the message to the other nodes, which fan out locally too.
779
-
780
- ```ts
781
- import { HttpFactory, RedisRelay } from '@dunx/http';
782
-
783
- const app = await HttpFactory.create(AppModule, {
784
- relay: new RedisRelay({ url: 'redis://localhost:6379' }),
785
- relayChannel: 'my-app:ws', // default 'dunx:ws'
786
- });
787
- ```
788
-
789
- That is the whole opt-in. `RedisRelay` is `Bun.RedisClient` - a Bun global - so this
790
- adds **no dependency**, and with no `relay` configured nothing here runs at all.
791
-
792
- Nothing else changes. `socket.subscribe(topic)` is still Bun's, and a topic no
793
- socket on this node joined simply costs a `server.publish` that reaches nobody.
794
-
795
- **Exactly once.** Redis delivers a publish back to the application that made it, so
796
- a frame carries the publishing process's id and the receiving side drops its own.
797
- Without that, every client on the publishing node would get the message twice. A
798
- node that receives a relayed frame publishes it **locally only** and never re-relays.
799
-
800
- **Absence is tolerated.** With Redis unreachable the app still boots, still fans out
801
- locally, and logs one warning rather than one per publish. A malformed URL throws at
802
- construction instead, because that is a config bug and degrading silently would hide
803
- it.
804
-
805
- #### Bringing your own connection
806
-
807
- `PubSubRelay` is two methods, so anything that already talks to a broker fits:
808
-
809
- ```ts
810
- interface PubSubRelay {
811
- publish(channel: string, message: string): unknown;
812
- subscribe(channel: string, listener: (message: string) => void): unknown;
813
- close?(): unknown; // only if the relay owns the connection
814
- }
815
- ```
816
-
817
- `@dunx/infra`'s `RedisConnection` satisfies it **structurally**, with no adapter and
818
- no dependency between the two packages. It has to come out of the container, so it
819
- goes through `relayThrough` rather than the factory option:
820
-
821
- ```ts
822
49
  const app = await HttpFactory.create(AppModule);
823
- await app.get(PubSub).relayThrough(app.get(RedisConnection), {
824
- channel: 'my-app:ws',
825
- });
50
+ app.enableShutdownHooks();
826
51
  await app.listen(3000);
827
52
  ```
828
53
 
829
- Only one relay per `PubSub` - a second `relayThrough` throws, because two
830
- subscriptions on one channel is the other way to deliver everything twice.
831
-
832
- `socket.publish(topic, data)` is Bun's own method and stays local; anything that must
833
- cross nodes goes through `PubSub`. `subscriberCount` is local too - Bun cannot count
834
- another node's sockets.
835
-
836
- `maxRetries` on `RedisRelay` defaults to `0`: a
837
- `Bun.RedisClient` that never connects keeps a retry timer alive past `close()` and
838
- the process then never exits. Raise it when Redis is a hard requirement and you want
839
- Bun's reconnection.
54
+ ## What is here
840
55
 
841
- ## Status codes
56
+ The guide is canonical for every row; this table is the index.
842
57
 
843
- `HttpStatusCode` is a frozen object rather than an `enum`, one name serving as both the
844
- value and the type, so it reads like an enum and erases like a constant:
58
+ | Area | What it covers | Guide |
59
+ | ----------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------- |
60
+ | Controllers and routing | Verb decorators, path params, prefixes, status codes | [Controllers](../../docs/guide/05-controllers.md) |
61
+ | Typed input | `body`, `query`, `params` over Standard Schema | [Validation](../../docs/guide/06-validation.md) |
62
+ | Middleware and guards | One extension point, `@UseGuards`, `@Roles`, `@Public` | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
63
+ | WebSocket gateways | `@Gateway`, handlers, `PubSub`, multi-node relay | [WebSockets](../../docs/guide/09-websockets.md) |
64
+ | Request logging | One structured entry per request, on by default | [Logging](../../docs/guide/13-logging.md) |
65
+ | Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/20-health-checks.md) |
66
+ | Throttling | `@Throttle`, `@SkipThrottle`, memory and Redis counters | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
67
+ | Static files | `Bun.file` behind a mount, with a cache policy | [Deployment](../../docs/guide/19-deployment.md) |
68
+ | Compression | zstd and gzip on Bun's own compressors | [Deployment](../../docs/guide/19-deployment.md) |
845
69
 
846
- ```ts
847
- import { HttpError, HttpStatusCode, type HttpStatusName } from '@dunx/http';
848
-
849
- throw new HttpError(HttpStatusCode.NOT_FOUND, 'No such user');
850
-
851
- const code: HttpStatusCode = HttpStatusCode.CONFLICT; // 200 | 201 | ... | 504
852
- const name: HttpStatusName = 'CONFLICT'; // 'OK' | 'CREATED' | ...
853
- ```
70
+ ## Subpaths
854
71
 
855
- `HttpError.status` stays `number`, so an uncommon code the table omits (451, 507)
856
- still works.
857
-
858
- ## Calling out: `@dunx/http/client`
859
-
860
- The outbound half, on a subpath because `HttpFactory` in the root barrel already
861
- means the inbound direction:
862
-
863
- ```ts
864
- import { HttpFactory } from '@dunx/http'; // serving
865
- import { HttpModule, HttpService } from '@dunx/http/client'; // calling out
866
- ```
867
-
868
- ```ts
869
- @Module({
870
- imports: [
871
- HttpModule.forRootAsync({
872
- useFactory: (config: AppConfigService) => ({
873
- baseUrl: config.get('upstream').url,
874
- timeoutMs: 5_000,
875
- retry: { maxRetries: 3, retryDelayMs: 500 },
876
- }),
877
- inject: [AppConfigService],
878
- }),
879
- ],
880
- })
881
- export class UpstreamModule {}
882
- ```
883
-
884
- ```ts
885
- export class Rates {
886
- constructor(private readonly http: HttpService) {}
887
-
888
- async latest(base: string): Promise<Quote> {
889
- return this.http.get<Quote>('/rates/{base}', {
890
- pathParams: { base },
891
- queryParams: { precision: 4 },
892
- });
893
- }
894
- }
895
- ```
896
-
897
- `fetch` and nothing else underneath: it is a Web standard Bun implements natively,
898
- so `axios` and `node-fetch` are banned repo-wide and there is no
899
- client dependency to justify. What the service adds is the part every caller
900
- otherwise rewrites slightly differently.
901
-
902
- | | |
903
- | ---------------------- | ------------------------------------------------------------------------------------------------- |
904
- | **Timeout** | `AbortSignal.timeout`, combined with a caller's own signal through `AbortSignal.any` |
905
- | **Retry** | Exponential backoff with jitter from `crypto.getRandomValues`, and `Bun.sleep` between attempts |
906
- | **`Retry-After`** | Honoured over the computed backoff, in seconds or as an HTTP date, still capped by the ceiling |
907
- | **URLs** | `buildUrl` and `interpolate` from `@arkv/shared`, so `{param}` and query building are not rewritten |
908
- | **Tracing** | The inbound request id is forwarded as `x-request-id`, so one trace spans both services |
909
- | **Bun-only** | `proxy`, `tls`, `unix`, `decompress` passed straight through to `fetch` |
910
- | **SSE** | `streamSse` yields each `data:` payload; never retried |
911
-
912
- ### A failure is not your status
913
-
914
- A non-2xx throws `FetchError`, which is **not** an `HttpError`:
915
-
916
- ```ts
917
- try {
918
- return await this.http.get<User>(`/users/${id}`);
919
- } catch (error) {
920
- if (error instanceof FetchError && error.status === 404) return null;
921
- throw new HttpError(HttpStatusCode.BAD_GATEWAY, 'user service unavailable');
922
- }
923
- ```
924
-
925
- An `HttpError` is the inbound contract - the error mapper reads its status and
926
- answers with it - so an upstream 401 arriving as `HttpError(401)` would tell *your*
927
- client they are unauthorized, when what happened is that your service could not
928
- authenticate upstream. Unhandled, a `FetchError` becomes a 500, which is honest;
929
- only the caller knows whether 404 means "gone" or "not my problem".
930
-
931
- A request that never got a response - DNS, refused connection, TLS, or the timeout -
932
- throws `FetchTransportError` instead, with `aborted` saying which. An abort is never
933
- retried: the budget for that call is already spent.
934
-
935
- ### Several upstreams
936
-
937
- A named client binds its own options, so two can coexist alongside one default:
938
-
939
- ```ts
940
- imports: [
941
- HttpModule.forRoot({ baseUrl: internal }),
942
- HttpModule.forRoot({ name: 'stripe', baseUrl: stripe, timeoutMs: 10_000 }),
943
- ];
944
-
945
- class Payments {
946
- readonly stripe = inject(httpClient('stripe'));
947
- }
948
- ```
72
+ | Subpath | Contains |
73
+ | ---------------------- | ----------------------------------------------------------------- |
74
+ | `@dunx/http` | Everything above |
75
+ | `@dunx/http/client` | The outbound half: `HttpService`, retry with backoff, `HttpModule` |
76
+ | `@dunx/http/internal` | The framework's own plumbing. No stability promise |
949
77
 
950
- `inject()` in a field initialiser rather than a constructor parameter, because a
951
- `Token` is not a constructor type.
78
+ `@dunx/http/internal` holds route-table construction, the middleware fold, the
79
+ relay codec and the discovery readers - what `@dunx/dashboard`, `@dunx/mcp` and
80
+ `@dunx/openapi` call and an app does not. It is the only place they are exported
81
+ from, and it may change in any release.
952
82
 
953
83
  ## Notes
954
84
 
955
- - Routes are discovered at boot by walking each controller's prototype chain, so an
956
- abstract base controller's `@Get` methods are inherited by every subclass.
957
- - A duplicate method + path **throws at boot** naming both handlers. Bun would
85
+ - Routes are discovered at boot by walking each controller's prototype chain, so
86
+ an abstract base controller's `@Get` methods are inherited by every subclass.
87
+ - A duplicate method and path throws at boot naming both handlers. Bun would
958
88
  otherwise silently keep one.
959
- - Middleware is a class with `handle(req, ctx, next)`, resolved from the container so
960
- it can `inject()`. Chains are folded into one closure per route at boot, and `ctx`
961
- is the route that closure belongs to.
962
89
  - Handlers may return a `Response`, any JSON-serialisable value, or `undefined`
963
- for `204`.
964
- - Schemas, parsers and the status are resolved in `buildRoutes` at boot, into the
965
- same closure the middleware chain folds into. Per request the framework parses
966
- and validates what was declared, calls the method, wraps the return, and maps a
967
- throw - no metadata read, no lookup, no DI.
968
- - Gateways use the same marker-plus-prototype-scan discovery as routes, and go into
969
- the same route table. `withUpgradeRoutes` and `buildWebSocket` are exported for
970
- anyone assembling `Bun.serve` themselves.
90
+ for a 204.
91
+ - Schemas, parsers and the status resolve at boot into the same closure the
92
+ middleware chain folds into, so a request reads no metadata and does no lookup.
971
93
 
972
94
  ## License
973
95