expediate 1.0.5 → 1.0.6
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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/README.md +278 -779
- package/dist/apis.d.ts +372 -12
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +483 -65
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +2290 -807
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +5 -5
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +11 -0
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +9 -9
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/middleware.js.map +1 -1
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +161 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +228 -80
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +156 -13
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +214 -71
- package/dist/openapi.js.map +1 -1
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +7 -530
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +128 -375
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +77 -22
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +30 -8
- package/dist/cjs/apis.js +0 -327
- package/dist/cjs/git.js +0 -293
- package/dist/cjs/jwt-auth.js +0 -532
- package/dist/cjs/middleware.js +0 -511
- package/dist/cjs/mimetypes.json +0 -1
- package/dist/cjs/misc.js +0 -787
- package/dist/cjs/openapi.js +0 -485
- package/dist/cjs/router.js +0 -898
- package/dist/cjs/static.js +0 -669
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Body Parsing
|
|
2
|
+
|
|
3
|
+
Expediate ships seven body-parsing utilities: middleware factories for the most common content types, an auto-detecting catch-all, and a streaming multipart generator.
|
|
4
|
+
|
|
5
|
+
All body-parsing middleware must be registered **before** route handlers that read `req.body`.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Content-type pass-through behaviour
|
|
10
|
+
|
|
11
|
+
Typed parsers (`json()`, `formData()`, `formEncoded()`) follow Express behaviour: when the request carries a `Content-Type` that does not match what the parser handles, the parser calls `next()` and lets subsequent middleware handle the request. This makes it safe to stack parsers globally:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
app.use(json()); // handles application/json
|
|
15
|
+
app.use(formEncoded()); // handles application/x-www-form-urlencoded
|
|
16
|
+
// A request with Content-Type: multipart/form-data passes both and falls through
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`parseBody()` is the strict catch-all — it handles all supported types and returns `415 Unsupported Media Type` for anything else.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## `json()`
|
|
24
|
+
|
|
25
|
+
Parses `application/json` bodies and populates `req.body`.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { json } from 'expediate';
|
|
29
|
+
|
|
30
|
+
app.use(json());
|
|
31
|
+
|
|
32
|
+
app.post('/data', (req, res) => {
|
|
33
|
+
res.json({ received: (req as any).body });
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Option | Type | Default | Description |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| `limit` | `string \| number` | `'100kb'` | Max body size. Accepts `'10kb'`, `'2mb'`, `'1gb'`, or bytes |
|
|
40
|
+
| `inflate` | `boolean` | `true` | Accept gzip/deflate encoded bodies |
|
|
41
|
+
| `reviver` | `Reviver \| null` | `null` | Second argument to `JSON.parse` |
|
|
42
|
+
| `strict` | `boolean` | `true` | When `true`, rejects a top-level JSON primitive (bare string, number, boolean, or `null`) with `400 Bad Request`; only objects and arrays are accepted |
|
|
43
|
+
|
|
44
|
+
**Error responses:**
|
|
45
|
+
|
|
46
|
+
| Status | Cause |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `400 Bad Request` | Malformed JSON or parse error |
|
|
49
|
+
| `413 Content Too Large` | Body exceeds `limit` |
|
|
50
|
+
| `415 Unsupported Media Type` | Unknown `Content-Encoding` (e.g. `br`) |
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## `formData()`
|
|
55
|
+
|
|
56
|
+
Parses `multipart/form-data` bodies. Populates `req.body` with an array of `FormPart` objects:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface FormPart {
|
|
60
|
+
headers: Record<string, string>; // part headers, keys lowercased
|
|
61
|
+
content: Buffer; // raw part body
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { formData } from 'expediate';
|
|
67
|
+
|
|
68
|
+
app.post('/upload', formData(), (req, res) => {
|
|
69
|
+
const parts = (req as any).body as FormPart[];
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
const disp = part.headers['content-disposition'];
|
|
72
|
+
console.log(disp, '-', part.content.length, 'bytes');
|
|
73
|
+
}
|
|
74
|
+
res.status(201).end();
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Accepts the same `limit` and `inflate` options as `json()`.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## `formEncoded()`
|
|
83
|
+
|
|
84
|
+
Parses `application/x-www-form-urlencoded` bodies. Repeated keys produce arrays:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { formEncoded } from 'expediate';
|
|
88
|
+
|
|
89
|
+
app.post('/form', formEncoded(), (req, res) => {
|
|
90
|
+
const { username, tags } = (req as any).body;
|
|
91
|
+
// tags: string | string[] (array when key is repeated)
|
|
92
|
+
res.json({ username, tags });
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## `raw()`
|
|
99
|
+
|
|
100
|
+
Reads the request body as a `Buffer` without parsing it, and populates `req.body`.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { raw } from 'expediate';
|
|
104
|
+
|
|
105
|
+
app.post('/upload', raw(), (req, res) => {
|
|
106
|
+
const buf = (req as any).body as Buffer;
|
|
107
|
+
res.json({ bytes: buf.length });
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Defaults to `application/octet-stream`; override with `opts.type` (e.g. `raw({ type: 'image/*' })`). Accepts the same `limit`, `inflate`, `type`, and `verify` options as the other parsers. Requests without a body, or whose `Content-Type` doesn't match, pass through to `next()` unchanged.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## `text()`
|
|
116
|
+
|
|
117
|
+
Decodes the request body as a string (using the charset from `Content-Type`) and populates `req.body`.
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { text } from 'expediate';
|
|
121
|
+
|
|
122
|
+
app.post('/note', text(), (req, res) => {
|
|
123
|
+
const body = (req as any).body as string;
|
|
124
|
+
res.send(`received ${body.length} chars`);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Defaults to `text/plain`; override with `opts.type` (e.g. `text({ type: 'text/*' })`). Requests without a body, or whose `Content-Type` doesn't match, pass through to `next()` unchanged. Bodies over `limit` get `413 Content Too Large`; decoding errors get `500 Internal Server Error`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## `parseBody()`
|
|
133
|
+
|
|
134
|
+
Auto-detects the `Content-Type` and dispatches to the appropriate parser.
|
|
135
|
+
|
|
136
|
+
| Content-Type | Result in `req.body` |
|
|
137
|
+
|---|---|
|
|
138
|
+
| `application/json` | Parsed JS value |
|
|
139
|
+
| `multipart/form-data` | `FormPart[]` |
|
|
140
|
+
| `application/x-www-form-urlencoded` | `Record<string, string \| string[]>` |
|
|
141
|
+
| `text/plain` | Decoded string |
|
|
142
|
+
| Anything else | `415 Unsupported Media Type` |
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import { parseBody } from 'expediate';
|
|
146
|
+
|
|
147
|
+
app.use(parseBody()); // handles all four types; 415 for anything else
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## `streamFormData()`
|
|
153
|
+
|
|
154
|
+
An async generator that yields each `multipart/form-data` part as a stream, without buffering the entire body first. Ideal for large file uploads.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { streamFormData } from 'expediate';
|
|
158
|
+
import type { FormPartStream } from 'expediate';
|
|
159
|
+
|
|
160
|
+
app.post('/upload', async (req, res) => {
|
|
161
|
+
for await (const part of streamFormData(req)) {
|
|
162
|
+
const disp = part.headers['content-disposition'];
|
|
163
|
+
// part.stream is a Readable
|
|
164
|
+
const chunks: Buffer[] = [];
|
|
165
|
+
for await (const chunk of part.stream) chunks.push(chunk);
|
|
166
|
+
const content = Buffer.concat(chunks);
|
|
167
|
+
console.log(disp, content.length, 'bytes');
|
|
168
|
+
}
|
|
169
|
+
res.send('ok');
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
`FormPartStream` shape:
|
|
174
|
+
```ts
|
|
175
|
+
interface FormPartStream {
|
|
176
|
+
headers: Record<string, string>;
|
|
177
|
+
stream: Readable;
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## `req.json()`, `req.text()`, `req.formData()`
|
|
184
|
+
|
|
185
|
+
These are lower-level request methods attached directly to the request object by the router (no middleware needed). They return a `Promise` and resolve to `null` when the request has no body.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
app.post('/data', async (req, res) => {
|
|
189
|
+
const body = await req.json(); // parse as JSON
|
|
190
|
+
const text = await req.text(); // read as string
|
|
191
|
+
const parts = await req.formData(); // parse as multipart
|
|
192
|
+
res.json({ ok: true });
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
When a body-parser middleware has already consumed the stream, these methods return the cached `req.body` value.
|
|
197
|
+
|
|
198
|
+
### Options (`BodyOptions`)
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
interface BodyOptions {
|
|
202
|
+
limit?: string | number; // default: '100kb'
|
|
203
|
+
inflate?: boolean; // default: true — accept gzip/deflate/br
|
|
204
|
+
reviver?: Reviver | null; // default: null — JSON.parse reviver
|
|
205
|
+
strict?: boolean; // default: true — json() only: reject bare top-level primitives
|
|
206
|
+
type?: BodyTypeMatcher; // override the content-type this parser matches
|
|
207
|
+
verify?: VerifyFn; // (req, res, buf, encoding) => void; throw to reject
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Compression support
|
|
214
|
+
|
|
215
|
+
Request body decompression is controlled by the `inflate` option (default `true`). Supported `Content-Encoding` values:
|
|
216
|
+
|
|
217
|
+
- `gzip`
|
|
218
|
+
- `deflate`
|
|
219
|
+
- `identity` (no-op)
|
|
220
|
+
|
|
221
|
+
- `br` (Brotli)
|
|
222
|
+
|
|
223
|
+
Unknown `Content-Encoding` values produce `415 Unsupported Media Type`.
|
package/docs/errors.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# Error Handling
|
|
2
|
+
|
|
3
|
+
Expediate routes every failure through a single, predictable **error channel**. A middleware can throw, an `async` middleware can reject, or any middleware can call `next(err)` — all three converge on the same path. From there the error flows through an ordered chain of handlers, falls back to a terminal handler, and ultimately **bubbles up to parent routers** until something responds.
|
|
4
|
+
|
|
5
|
+
This document describes that channel end to end: what enters it, how `error()` and `onError()` differ, the exact resolution order, how bubbling crosses `use()` boundaries, and how `apiBuilder` layers its own handling on top.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What enters the error channel
|
|
10
|
+
|
|
11
|
+
Three things put an error onto the channel for a given request:
|
|
12
|
+
|
|
13
|
+
1. **A synchronous throw** inside any middleware or route handler.
|
|
14
|
+
2. **A rejected `Promise`** returned by an `async` middleware (or one that returns a promise).
|
|
15
|
+
3. **An explicit `next(err)`** call with a non-null argument.
|
|
16
|
+
|
|
17
|
+
All three are caught by the router's internal `invoke()` wrapper:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const invoke = (mw, nextFn) => {
|
|
21
|
+
try {
|
|
22
|
+
const ret = mw(req, res, nextFn);
|
|
23
|
+
if (ret instanceof Promise) ret.catch(invokeErrorHandler); // async rejection
|
|
24
|
+
} catch (e) {
|
|
25
|
+
invokeErrorHandler(e); // sync throw
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Calling `next(err)` short-circuits the dispatch loop: remaining route layers for the request are skipped and control jumps straight into the error channel.
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
app.use('/protected', (req, _res, next) => {
|
|
34
|
+
if (!req.headers.authorization) return next(new Error('Unauthorized'));
|
|
35
|
+
next(); // no argument → continue normal routing
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> **Async caveat.** Only the promise *returned* by a middleware is tracked. If a middleware calls `next()` and then throws **later** from a detached callback — a `setTimeout`, an event emitter, a stray `.then()` — that throw escapes the framework. Keep error-prone async work in the returned promise chain (use `async`/`await` or `return somePromise`).
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Two ways to handle errors
|
|
44
|
+
|
|
45
|
+
Expediate offers two registration APIs. They compose: `error()` handlers run first as an ordered chain, and `onError()` is the single terminal fallback when the chain does not respond.
|
|
46
|
+
|
|
47
|
+
### `router.error()` — ordered, escapable chain
|
|
48
|
+
|
|
49
|
+
`error()` registers an **error middleware**. Unlike a normal middleware, the error value is the **first** argument — a deliberate signal that this function runs on the error channel, not the normal request pipeline.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
type ErrorMiddleware = (
|
|
53
|
+
err: unknown,
|
|
54
|
+
req: RouterRequest,
|
|
55
|
+
res: RouterResponse,
|
|
56
|
+
next: NextFunction,
|
|
57
|
+
) => void;
|
|
58
|
+
|
|
59
|
+
app.error((err, req, res, next) => { /* … */ });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each handler does one of two things:
|
|
63
|
+
|
|
64
|
+
- **End the response** (`res.status(...).json(...)`, `res.end()`, …) — the error is handled, and the chain stops.
|
|
65
|
+
- **Call `next`** to pass control along the error channel:
|
|
66
|
+
- `next()` — forward the **same** error to the next error middleware.
|
|
67
|
+
- `next(newErr)` — forward a **replacement** error instead.
|
|
68
|
+
|
|
69
|
+
Handlers run in **registration order**. The first one to end the response wins; later ones never run.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// Run in order. The first to respond ends the chain.
|
|
73
|
+
app.error((err, _req, res, next) => {
|
|
74
|
+
if ((err as any)?.status === 404) return res.status(404).json({ error: 'Not Found' });
|
|
75
|
+
next(err); // not ours — forward unchanged
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
app.error((err, _req, res, next) => {
|
|
79
|
+
if (err instanceof ValidationError) return res.status(400).json({ fields: err.fields });
|
|
80
|
+
next(err); // still not ours — forward
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.error((err, _req, res, _next) => {
|
|
84
|
+
res.status((err as any)?.status ?? 500).json({ error: String(err) }); // catch-all
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A throw **inside** an error handler is itself caught and forwarded to the next handler in the chain (with the newly thrown value), so a buggy handler degrades gracefully instead of crashing the request.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
app.error((_err, _req, _res, _next) => { throw new Error('logger exploded'); });
|
|
92
|
+
app.error((err, _req, res, _next) => {
|
|
93
|
+
// err is now Error('logger exploded') — the previous handler's throw
|
|
94
|
+
res.status(500).end('recovered');
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
You may register as many `error()` handlers as you like; they accumulate.
|
|
99
|
+
|
|
100
|
+
### `router.onError()` — single terminal fallback
|
|
101
|
+
|
|
102
|
+
`onError()` registers **one** simple, terminal handler. It has no `next` — it cannot forward, and it does not bubble. It exists for the common case where you want a single catch-all and nothing more.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
type ErrorHandler = (err: unknown, req: RouterRequest, res: RouterResponse) => void;
|
|
106
|
+
|
|
107
|
+
app.onError((err, _req, res) => {
|
|
108
|
+
const status = (err as any)?.status ?? 500;
|
|
109
|
+
res.status(status).json({ error: String(err) });
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Calling `onError()` again **replaces** the previous handler (only one is ever active). It runs only after the `error()` chain has been exhausted without responding.
|
|
114
|
+
|
|
115
|
+
| | `error()` | `onError()` |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| Argument order | `(err, req, res, next)` | `(err, req, res)` |
|
|
118
|
+
| How many | Many (ordered chain) | One (replaces previous) |
|
|
119
|
+
| Can forward / escalate | Yes, via `next` | No |
|
|
120
|
+
| Participates in bubbling | Yes (forwarding) | No — terminal for its router |
|
|
121
|
+
| Typical use | Layered, type-specific handling | A single catch-all |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Resolution order
|
|
126
|
+
|
|
127
|
+
When an error enters a router's channel, it is resolved through this cascade:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
error() → error() → … (ordered chain; each may respond or forward)
|
|
131
|
+
│
|
|
132
|
+
▼ (chain exhausted, response not ended)
|
|
133
|
+
onError() (terminal fallback for this router, if set)
|
|
134
|
+
│
|
|
135
|
+
▼ (not set / did not respond)
|
|
136
|
+
done(err) (bubble to the PARENT router's channel)
|
|
137
|
+
│
|
|
138
|
+
▼ (no parent — top-level router)
|
|
139
|
+
default 500 (console.warn + "Error <method> <url>")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Step by step:
|
|
143
|
+
|
|
144
|
+
1. **`error()` chain.** Each registered error middleware runs in order. If one ends the response, resolution stops.
|
|
145
|
+
2. **`onError()` fallback.** If the chain forwards all the way through (or there are no `error()` handlers) without ending the response, the single `onError()` handler runs — if one is registered. If `onError()` itself throws, a plain `500` is sent as a last resort.
|
|
146
|
+
3. **Bubble to the parent.** If neither responded **and** this router was mounted inside another via `use()`, the error is handed to the parent router's channel (see below).
|
|
147
|
+
4. **Default 500.** A top-level router with nothing left to try logs the error with `console.warn` and sends `Error <method> <url>` with status `500`.
|
|
148
|
+
|
|
149
|
+
At every step there is a guard: if the response has already been ended (`res.writableEnded`), the channel stops immediately. Handlers are expected to end the response unless they forward via `next`.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Bubbling to parent routers
|
|
154
|
+
|
|
155
|
+
This is the most important behavior to understand when composing routers. **An error a sub-router does not handle does not die there — it climbs to the router that mounted it.**
|
|
156
|
+
|
|
157
|
+
Mechanically, when you mount a router with `app.use('/path', child)`, the parent passes its own `next` into the child as the child's `done` callback. When the child's channel is exhausted without responding, it calls `done(err)`, which is the parent's `next(err)` — and that drops the error onto the *parent's* error channel. The same continuation also restores `req.path` and `req.baseUrl` so the parent handler sees the original, un-stripped path.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const api = createRouter();
|
|
161
|
+
api.get('/items/:id', () => { throw new Error('db down'); }); // no handler here
|
|
162
|
+
|
|
163
|
+
const app = createRouter();
|
|
164
|
+
app.use('/api', api);
|
|
165
|
+
|
|
166
|
+
// The failure raised inside `api` bubbles up to here:
|
|
167
|
+
app.error((err, _req, res, _next) =>
|
|
168
|
+
res.status(500).json({ error: String(err) }));
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Bubbling is transitive — an error climbs through **every** level of nesting until something responds or it reaches the top:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const grandchild = createRouter();
|
|
175
|
+
grandchild.get('/fail', () => { throw new Error('3 levels down'); });
|
|
176
|
+
|
|
177
|
+
const child = createRouter();
|
|
178
|
+
child.use('/gc', grandchild);
|
|
179
|
+
|
|
180
|
+
const root = createRouter();
|
|
181
|
+
root.use('/c', child);
|
|
182
|
+
root.error((err, _req, res, _next) => res.status(500).send(String(err)));
|
|
183
|
+
|
|
184
|
+
// GET /c/gc/fail → bubbles grandchild → child → root, handled at root.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### A child handler stops the bubble
|
|
188
|
+
|
|
189
|
+
A sub-router's **own** handlers take precedence. If a child's `error()` chain (or its `onError()`) ends the response, the error never reaches the parent:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const child = createRouter();
|
|
193
|
+
child.error((_err, _req, res, _next) => res.status(400).send('child handled'));
|
|
194
|
+
child.get('/x', () => { throw new Error('boom'); });
|
|
195
|
+
|
|
196
|
+
const parent = createRouter();
|
|
197
|
+
parent.error((_err, _req, res, _next) => res.status(500).send('parent')); // never runs for /x
|
|
198
|
+
parent.use('/child', child);
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
> **Design note — `onError()` blocks bubbling.** A child's `onError()` is *terminal for that router*: once it responds, nothing bubbles. If you want a sub-router to **log but still escalate**, do not use `onError()` — use an `error()` handler that inspects the error and then calls `next(err)`:
|
|
202
|
+
>
|
|
203
|
+
> ```ts
|
|
204
|
+
> child.error((err, req, _res, next) => {
|
|
205
|
+
> logger.warn({ path: req.path, err });
|
|
206
|
+
> next(err); // decline to respond → bubble to the parent
|
|
207
|
+
> });
|
|
208
|
+
> ```
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## Top-level routers and the default 500
|
|
213
|
+
|
|
214
|
+
A router becomes "top-level" when it is invoked without a `done` callback — most commonly via `http.createServer(app.listener)` or `app.listen()`. With no parent to bubble to and no handler registered, the default behavior is:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
console.warn(err);
|
|
218
|
+
res.status(500).end(`Error ${method} ${url}`);
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
So even with zero configuration, an uncaught error always produces a `500` rather than a hung request. Registering an `error()` or `onError()` handler on the top-level router lets you replace this default with structured output, logging, error tracking, etc.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## `apiBuilder` error handling
|
|
226
|
+
|
|
227
|
+
`apiBuilder` returns a `Router`, so everything above applies — but it adds its own layer on top, because service handlers signal failure by throwing/rejecting an **`ApiError`** rather than touching `res` directly.
|
|
228
|
+
|
|
229
|
+
### `ApiError` → HTTP translation
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
interface ApiError {
|
|
233
|
+
status?: number; // default 500
|
|
234
|
+
data?: unknown; // JSON body — takes precedence over message
|
|
235
|
+
message?: string; // plain-text body
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Any value thrown or rejected by a handler, **guard**, **auth check**, or **request validation** is translated automatically:
|
|
240
|
+
|
|
241
|
+
- `{ status, data }` → that `status`, with `data` serialized as a JSON body.
|
|
242
|
+
- `{ status, message }` → that `status`, with `message` as a plain-text body.
|
|
243
|
+
- Anything else (a bare `Error`, a string, …) → `500`, body `message` if present, else `"Internal error"`.
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
const service: ServiceDefinition<State> = {
|
|
247
|
+
GET: {
|
|
248
|
+
'/items/:id': function (this: State, ctx) {
|
|
249
|
+
const item = this.items[ctx.params.id];
|
|
250
|
+
if (!item) throw { status: 404, message: 'Not found' } satisfies ApiError;
|
|
251
|
+
return item; // truthy → 200 + JSON
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### The `service.onError` hook
|
|
258
|
+
|
|
259
|
+
Add an `onError` hook to the service definition to **inspect, log, or reshape** errors *before* the default translation runs. Unlike a router-level `error()` handler, this hook receives the full `ApiContext`, so it has access to `ctx.path`, `ctx.params`, `ctx.user`, `ctx.state`, and the request.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
onError?(err: unknown, ctx: ApiContext<any>, req: RouterRequest): void | ApiError;
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Its return value decides what happens next:
|
|
266
|
+
|
|
267
|
+
| Hook does… | Result |
|
|
268
|
+
|---|---|
|
|
269
|
+
| **returns `undefined`** | The original error proceeds to the default `ApiError` → HTTP translation. Ideal for **log-only**. |
|
|
270
|
+
| **returns an `ApiError`** | That value is sent **instead** of the original — reshape, mask internals, attach a correlation id, downgrade a status, etc. |
|
|
271
|
+
| **throws** | The thrown value is **escalated** to the surrounding app's error channel (`router.error()` / `onError()`) via `next(err)`, instead of being answered locally. |
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
const service: ServiceDefinition<State> = {
|
|
275
|
+
onError(err, ctx, req) {
|
|
276
|
+
metrics.increment('api.error', { path: ctx.path });
|
|
277
|
+
logger.error({ reqId: req.id, user: ctx.user, err });
|
|
278
|
+
|
|
279
|
+
if (err instanceof DbTimeout)
|
|
280
|
+
return { status: 503, message: 'Please retry shortly' }; // reshape
|
|
281
|
+
|
|
282
|
+
if (err instanceof FatalConfigError)
|
|
283
|
+
throw err; // escalate to app.error() — let a process-wide handler decide
|
|
284
|
+
|
|
285
|
+
// return nothing → default ApiError translation
|
|
286
|
+
},
|
|
287
|
+
// …routes, guards, auth…
|
|
288
|
+
};
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
This gives you a three-way choice at the boundary of every API: handle-and-translate (the default), handle-and-reshape (return), or defer to the wider application (throw). Because the hook runs inside the per-route `catch` where `ctx` is still in scope, the escalation path threads through the same router bubbling described above: a thrown hook → `next(err)` on the api router → (no api-level handler) → bubble to the parent app's `error()` chain.
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Recipes
|
|
296
|
+
|
|
297
|
+
### Structured JSON errors at the top level
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
app.error((err, req, res, _next) => {
|
|
301
|
+
const status = (err as any)?.status ?? 500;
|
|
302
|
+
res.status(status).json({
|
|
303
|
+
error: (err as any)?.message ?? 'Internal Server Error',
|
|
304
|
+
path: req.path,
|
|
305
|
+
status,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
### Type-specific handlers, ordered
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
app.error((err, _req, res, next) =>
|
|
314
|
+
err instanceof NotFoundError ? res.status(404).json({ error: 'Not Found' }) : next(err));
|
|
315
|
+
|
|
316
|
+
app.error((err, _req, res, next) =>
|
|
317
|
+
err instanceof AuthError ? res.status(401).json({ error: 'Unauthorized' }) : next(err));
|
|
318
|
+
|
|
319
|
+
app.error((err, _req, res, _next) =>
|
|
320
|
+
res.status(500).json({ error: String(err) })); // fallback
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### Log in a sub-router, respond at the root
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
child.error((err, req, _res, next) => { logger.warn({ path: req.path, err }); next(err); });
|
|
327
|
+
|
|
328
|
+
root.use('/child', child);
|
|
329
|
+
root.error((err, _req, res, _next) => res.status(500).json({ error: String(err) }));
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### Mask internal errors but keep real ones in an API
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
const service: ServiceDefinition = {
|
|
336
|
+
onError(err) {
|
|
337
|
+
if ((err as ApiError)?.status) return; // intentional ApiError → pass through
|
|
338
|
+
return { status: 500, message: 'Something went wrong' }; // hide unexpected internals
|
|
339
|
+
},
|
|
340
|
+
// …
|
|
341
|
+
};
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
---
|
|
345
|
+
|
|
346
|
+
## Quick reference
|
|
347
|
+
|
|
348
|
+
| Concern | API |
|
|
349
|
+
|---|---|
|
|
350
|
+
| Ordered, forwardable error handlers | `app.error((err, req, res, next) => …)` |
|
|
351
|
+
| Single terminal fallback | `app.onError((err, req, res) => …)` |
|
|
352
|
+
| Raise an error from a middleware | `next(err)` (non-null argument) |
|
|
353
|
+
| Custom 404 (no route matched) | catch-all last: `app.all('/**', (req, res) => …)` |
|
|
354
|
+
| API error → HTTP response | `throw { status, message }` / `throw { status, data }` |
|
|
355
|
+
| Inspect / reshape / escalate API errors | `service.onError(err, ctx, req)` |
|
|
356
|
+
|
|
357
|
+
**Resolution order:** `error()` chain → `onError()` fallback → bubble to parent → default `500`.
|
|
358
|
+
|
|
359
|
+
See also: [router.md](router.md) for sub-router mounting and `req.baseUrl`, and [api-builder.md](api-builder.md) for the full service pipeline.
|
|
Binary file
|