@dunx/http 2.5.0 → 3.0.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 +31 -909
- package/dist/chunk-25g22350.js +58 -0
- package/dist/chunk-ywdpxbkf.js +1573 -0
- package/dist/client/module.d.ts +2 -7
- package/dist/client/service.d.ts +11 -22
- package/dist/client.d.ts +8 -2
- package/dist/client.js +14 -134
- package/dist/compression/compression.d.ts +7 -12
- package/dist/compression/options.d.ts +11 -23
- package/dist/health/indicators.d.ts +5 -10
- package/dist/index.d.ts +20 -21
- package/dist/index.js +119 -1449
- package/dist/internal.d.ts +36 -0
- package/dist/internal.js +89 -0
- package/dist/route/metadata.d.ts +6 -9
- package/dist/route/schema.d.ts +27 -55
- package/dist/server/application.d.ts +33 -89
- package/dist/server/client-address.d.ts +6 -13
- package/dist/server/errors.d.ts +24 -55
- package/dist/server/request-id.d.ts +6 -11
- package/dist/server/request-logging.d.ts +29 -101
- package/dist/server/routes.d.ts +7 -19
- package/dist/server/trace-context.d.ts +6 -10
- package/dist/static/files.d.ts +5 -13
- package/dist/static/module.d.ts +10 -19
- package/dist/throttle/guard.d.ts +6 -9
- package/dist/throttle/module.d.ts +6 -8
- package/dist/throttle/store.d.ts +11 -20
- package/dist/ws/middleware.d.ts +12 -21
- package/dist/ws/redis-relay.d.ts +9 -19
- package/package.json +6 -2
- package/dist/chunk-jh7jk0bn.js +0 -164
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.
|
|
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
|
-
|
|
824
|
-
channel: 'my-app:ws',
|
|
825
|
-
});
|
|
50
|
+
app.enableShutdownHooks();
|
|
826
51
|
await app.listen(3000);
|
|
827
52
|
```
|
|
828
53
|
|
|
829
|
-
|
|
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
|
-
|
|
56
|
+
The guide is canonical for every row; this table is the index.
|
|
842
57
|
|
|
843
|
-
|
|
844
|
-
|
|
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
|
-
|
|
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
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
`
|
|
951
|
-
|
|
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. The barrel still re-exports it under a
|
|
81
|
+
`@deprecated` block and drops it in 4.0.
|
|
952
82
|
|
|
953
83
|
## Notes
|
|
954
84
|
|
|
955
|
-
- Routes are discovered at boot by walking each controller's prototype chain, so
|
|
956
|
-
abstract base controller's `@Get` methods are inherited by every subclass.
|
|
957
|
-
- A duplicate method
|
|
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
|
|
964
|
-
- Schemas, parsers and the status
|
|
965
|
-
|
|
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
|
|