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