@ndiinginc/router 4.5.0 → 4.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -33
- package/package.json +1 -1
- package/src/app.js +29 -18
- package/src/error.js +8 -8
- package/src/index.js +1 -1
- package/src/middleware/cors.js +10 -16
- package/src/middleware/json.js +4 -15
- package/src/node.js +14 -36
- package/src/polyfill.js +21 -21
- package/src/request.js +3 -8
- package/src/response.js +10 -1
- package/src/router.js +38 -46
- package/src/util.js +23 -20
package/README.md
CHANGED
|
@@ -11,9 +11,9 @@ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsu
|
|
|
11
11
|
- Nested router dengan `use(path, router)`
|
|
12
12
|
- Middleware global dan per-path, mendukung `async`
|
|
13
13
|
- Error handler dengan signature `(err, req, res)`
|
|
14
|
-
- Timeout per request
|
|
14
|
+
- Timeout per request yang menghasilkan `504`, dengan `req.signal` untuk pembatalan kooperatif
|
|
15
15
|
- `req.path`, `req.query`, `req.params`, `req.body`
|
|
16
|
-
- Helper `res.send()
|
|
16
|
+
- Helper `res.status()`, `res.send()`, dan `res.json()`
|
|
17
17
|
- Middleware bawaan: `json()` (dengan limit dan proteksi prototype pollution) dan `cors()`
|
|
18
18
|
- Semua HTTP method, termasuk `QUERY`
|
|
19
19
|
|
|
@@ -43,7 +43,7 @@ app.get("/users/:id", (req, res) => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
app.post("/users", (req, res) => {
|
|
46
|
-
res.json({ received: req.body });
|
|
46
|
+
res.status(201).json({ received: req.body });
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
app.listen(3000);
|
|
@@ -82,6 +82,8 @@ app.get("/profile", authenticate, loadUser, (req, res) => {
|
|
|
82
82
|
|
|
83
83
|
### Parameter dinamis
|
|
84
84
|
|
|
85
|
+
Nilai parameter otomatis di-decode dengan `decodeURIComponent`. Jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
|
|
86
|
+
|
|
85
87
|
```js
|
|
86
88
|
app.get("/posts/:postId/comments/:commentId", (req, res) => {
|
|
87
89
|
const { postId, commentId } = req.params;
|
|
@@ -118,8 +120,7 @@ app.use((req, res) => {
|
|
|
118
120
|
// Scoped: jalan untuk /admin dan semua sub-path-nya
|
|
119
121
|
app.use("/admin", async (req, res) => {
|
|
120
122
|
if (req.headers.authorization !== "secret") {
|
|
121
|
-
res.
|
|
122
|
-
res.json({ error: "Unauthorized" });
|
|
123
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
123
124
|
}
|
|
124
125
|
});
|
|
125
126
|
```
|
|
@@ -149,7 +150,7 @@ app.use("/admin", admin);
|
|
|
149
150
|
// GET /admin/users/42 -> { id: "42" }
|
|
150
151
|
```
|
|
151
152
|
|
|
152
|
-
>
|
|
153
|
+
> Isi sub-router disalin saat `use()` dipanggil. Daftarkan semua route ke sub-router **sebelum** di-mount; route yang ditambahkan setelahnya tidak ikut. Sub-router yang sama boleh di-mount di beberapa path.
|
|
153
154
|
|
|
154
155
|
## Error Handling
|
|
155
156
|
|
|
@@ -161,8 +162,7 @@ app.get("/boom", () => {
|
|
|
161
162
|
});
|
|
162
163
|
|
|
163
164
|
app.use((err, req, res) => {
|
|
164
|
-
res.statusCode
|
|
165
|
-
res.json({ error: { message: err.message } });
|
|
165
|
+
res.status(err.statusCode || 500).json({ error: { message: err.message } });
|
|
166
166
|
});
|
|
167
167
|
```
|
|
168
168
|
|
|
@@ -170,23 +170,18 @@ app.use((err, req, res) => {
|
|
|
170
170
|
|
|
171
171
|
### Menentukan status code
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
Status code diambil dari properti `statusCode` pada error. Tanpa properti itu, hasilnya `500`.
|
|
174
174
|
|
|
175
175
|
```js
|
|
176
|
-
// 1. Properti statusCode pada error
|
|
177
176
|
app.get("/admin", () => {
|
|
178
177
|
const err = new Error("Forbidden");
|
|
179
178
|
err.statusCode = 403;
|
|
180
179
|
throw err;
|
|
181
180
|
});
|
|
182
|
-
|
|
183
|
-
// 2. Set res.statusCode sebelum throw
|
|
184
|
-
app.get("/limit", (req, res) => {
|
|
185
|
-
res.statusCode = 429;
|
|
186
|
-
throw new Error("Too Many Requests");
|
|
187
|
-
});
|
|
188
181
|
```
|
|
189
182
|
|
|
183
|
+
> `res.statusCode` atau `res.status()` yang diset **sebelum** error dilempar tidak dipakai oleh respons default. Gunakan `err.statusCode`, atau tangani sendiri di error handler.
|
|
184
|
+
|
|
190
185
|
### Respons default
|
|
191
186
|
|
|
192
187
|
Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
@@ -196,35 +191,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
196
191
|
```
|
|
197
192
|
|
|
198
193
|
- Tidak ada route cocok: `404 Not Found`
|
|
199
|
-
- Error
|
|
200
|
-
-
|
|
194
|
+
- Error dengan `statusCode`: status tersebut dipakai
|
|
195
|
+
- Error tanpa `statusCode`: `500`
|
|
201
196
|
- Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
|
|
202
|
-
|
|
203
197
|
- Field `stack` hanya disertakan saat `NODE_ENV=development`
|
|
204
198
|
|
|
205
199
|
## Timeout
|
|
206
200
|
|
|
207
|
-
|
|
201
|
+
Aktifkan lewat opsi `timeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, error `504 Gateway Timeout` dilempar dan diteruskan ke error handler.
|
|
208
202
|
|
|
209
203
|
```js
|
|
210
|
-
app
|
|
211
|
-
|
|
204
|
+
const app = new App({ timeout: 5000 });
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
- Tanpa opsi `timeout` (atau bernilai `0`), tidak ada batas waktu
|
|
208
|
+
- Handler yang sudah berjalan tidak dihentikan paksa. Tetapi setelah response dikirim, `res.json()` dan `res.send()` mengabaikan penulisan berikutnya, dan handler selanjutnya di chain tidak dipanggil
|
|
209
|
+
|
|
210
|
+
### `req.signal`
|
|
211
|
+
|
|
212
|
+
Saat `timeout` aktif, `req.signal` adalah `AbortSignal` yang di-abort ketika timeout terjadi (reason-nya error 504) atau ketika salah satu handler melempar error (reason-nya error tersebut). Teruskan ke operasi yang mendukungnya agar pekerjaan ikut dibatalkan:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
app.get("/proxy", async (req, res) => {
|
|
216
|
+
const upstream = await fetch("https://example.com/api", { signal: req.signal });
|
|
217
|
+
res.json(await upstream.json());
|
|
212
218
|
});
|
|
213
219
|
```
|
|
214
220
|
|
|
215
|
-
|
|
216
|
-
- Harus diset **secara sinkron di middleware pertama** pada chain, karena dibaca sesaat setelah chain mulai berjalan
|
|
217
|
-
- Timeout tidak membatalkan handler yang sedang berjalan; hanya menghentikan penantian dan mengirim error
|
|
221
|
+
`req.signal` bernilai `undefined` jika `timeout` tidak diaktifkan.
|
|
218
222
|
|
|
219
223
|
## Request
|
|
220
224
|
|
|
221
225
|
| Properti | Keterangan |
|
|
222
226
|
| --- | --- |
|
|
223
227
|
| `req.path` | Pathname tanpa query string |
|
|
224
|
-
| `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array |
|
|
225
|
-
| `req.params` | Parameter dari route dinamis (sudah di-decode
|
|
228
|
+
| `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
|
|
229
|
+
| `req.params` | Parameter dari route dinamis (sudah di-decode) |
|
|
226
230
|
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
227
|
-
| `req.
|
|
231
|
+
| `req.signal` | `AbortSignal` yang di-abort saat timeout atau error handler (hanya jika `timeout` aktif) |
|
|
228
232
|
|
|
229
233
|
```js
|
|
230
234
|
// GET /search?tag=a&tag=b&page=2
|
|
@@ -233,15 +237,22 @@ app.get("/search", (req, res) => {
|
|
|
233
237
|
});
|
|
234
238
|
```
|
|
235
239
|
|
|
240
|
+
`req.query` dan `req.params` memakai object tanpa prototype, jadi gunakan `Object.hasOwn(req.query, "key")` atau `"key" in req.query`, bukan `req.query.hasOwnProperty(...)`.
|
|
241
|
+
|
|
236
242
|
`Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
|
|
237
243
|
|
|
238
244
|
## Response
|
|
239
245
|
|
|
240
246
|
| Method | Keterangan |
|
|
241
247
|
| --- | --- |
|
|
248
|
+
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
|
|
242
249
|
| `res.send(chunk)` | Kirim response dan akhiri |
|
|
243
250
|
| `res.json(value)` | Set `Content-Type: application/json`, serialize, lalu kirim |
|
|
244
251
|
|
|
252
|
+
```js
|
|
253
|
+
res.status(201).json({ created: true });
|
|
254
|
+
```
|
|
255
|
+
|
|
245
256
|
`Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
|
|
246
257
|
|
|
247
258
|
## Middleware Bawaan
|
|
@@ -280,7 +291,7 @@ app.use(
|
|
|
280
291
|
|
|
281
292
|
| Opsi | Default | Keterangan |
|
|
282
293
|
| --- | --- | --- |
|
|
283
|
-
| `origin` | `"*"` | `"*"`,
|
|
294
|
+
| `origin` | `"*"` | `"*"`, satu origin, daftar dipisah koma, pola dengan `*` (misalnya `https://*.example.com`), atau `null` |
|
|
284
295
|
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
285
296
|
| `headers` | - | `"*"` atau array nama header |
|
|
286
297
|
| `methods` | - | `"*"` atau array nama method |
|
|
@@ -289,25 +300,31 @@ app.use(
|
|
|
289
300
|
Perilaku:
|
|
290
301
|
|
|
291
302
|
- Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
|
|
292
|
-
-
|
|
303
|
+
- `Access-Control-Allow-Headers` dan `Access-Control-Allow-Methods` hanya dikirim pada preflight, jika nilai yang diminta ada di daftar
|
|
304
|
+
- Jika `origin` berupa daftar atau pola (atau `"*"` dengan `credentials: true`), origin dari request dipantulkan bila cocok. Pencocokan dilakukan pada seluruh string (tanpa memperhatikan huruf besar/kecil), dan header `Vary: Origin` ditambahkan pada request non-preflight
|
|
293
305
|
- Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
|
|
294
306
|
- Pada request non-preflight, `headers` (jika array) dikirim sebagai `Access-Control-Expose-Headers`
|
|
295
307
|
|
|
296
|
-
|
|
308
|
+
> `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
|
|
309
|
+
|
|
310
|
+
## Opsi
|
|
297
311
|
|
|
298
312
|
```js
|
|
299
|
-
const app = new App({ size: 1000 });
|
|
313
|
+
const app = new App({ size: 1000, timeout: 5000 });
|
|
300
314
|
```
|
|
301
315
|
|
|
302
316
|
| Opsi | Default | Keterangan |
|
|
303
317
|
| --- | --- | --- |
|
|
304
318
|
| `size` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path` |
|
|
319
|
+
| `timeout` | - | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
320
|
+
|
|
321
|
+
`Router` hanya menerima `size`.
|
|
305
322
|
|
|
306
323
|
## API
|
|
307
324
|
|
|
308
325
|
### `new App([options])` / `new Router([options])`
|
|
309
326
|
|
|
310
|
-
Membuat instance baru. `App` adalah `Router` ditambah `listen()`.
|
|
327
|
+
Membuat instance baru. `App` adalah `Router` ditambah `listen()` dan dukungan `timeout`.
|
|
311
328
|
|
|
312
329
|
### `router.use([path], ...handlers | router)`
|
|
313
330
|
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -11,13 +11,32 @@ const SERVER_OPTIONS = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
class App extends Router {
|
|
14
|
-
async withTimeout(
|
|
15
|
-
|
|
14
|
+
async withTimeout(req, handlerPromise) {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
const { signal } = controller;
|
|
17
|
+
|
|
18
|
+
req.signal = signal;
|
|
19
|
+
|
|
20
|
+
const timeout = setTimeout(() => {
|
|
21
|
+
controller.abort(ErrorResponse.status(504));
|
|
22
|
+
}, this.timeout);
|
|
23
|
+
|
|
24
|
+
let handleAbort;
|
|
16
25
|
const timeoutPromise = new Promise((_, reject) => {
|
|
17
|
-
|
|
26
|
+
handleAbort = () => reject(signal.reason);
|
|
27
|
+
signal.addEventListener("abort", handleAbort, { once: true });
|
|
18
28
|
});
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
const promise = handlerPromise().catch((error) => {
|
|
31
|
+
if (!signal.aborted) {
|
|
32
|
+
controller.abort(error);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
await Promise.race([promise, timeoutPromise]).finally(() => {
|
|
37
|
+
signal.removeEventListener("abort", handleAbort);
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
});
|
|
21
40
|
}
|
|
22
41
|
|
|
23
42
|
/**
|
|
@@ -26,22 +45,19 @@ class App extends Router {
|
|
|
26
45
|
*/
|
|
27
46
|
async handle(req, res) {
|
|
28
47
|
const { params, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
29
|
-
req.params = params;
|
|
30
|
-
|
|
48
|
+
req.params = Object.assign(Object.create(null), params);
|
|
31
49
|
let err;
|
|
32
50
|
try {
|
|
33
|
-
const handlerPromise =
|
|
51
|
+
const handlerPromise = async () => {
|
|
34
52
|
for (const handler of handlers) {
|
|
35
53
|
if (res.writableEnded) break;
|
|
36
54
|
await handler(req, res);
|
|
37
55
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else await handlerPromise;
|
|
56
|
+
};
|
|
57
|
+
if (this.timeout) await this.withTimeout(req, handlerPromise);
|
|
58
|
+
else await handlerPromise();
|
|
42
59
|
} catch (error) {
|
|
43
60
|
err = error;
|
|
44
|
-
|
|
45
61
|
try {
|
|
46
62
|
for (const handler of errorHandlers) {
|
|
47
63
|
if (res.writableEnded) break;
|
|
@@ -51,14 +67,9 @@ class App extends Router {
|
|
|
51
67
|
err = new Error(error.message, { cause: err });
|
|
52
68
|
}
|
|
53
69
|
}
|
|
54
|
-
|
|
55
70
|
if (!res.writableEnded) {
|
|
56
71
|
if (!err) err = ErrorResponse.status(404);
|
|
57
|
-
|
|
58
|
-
if (!res.statusCode || res.statusCode < 400) {
|
|
59
|
-
res.statusCode = err.statusCode || 500;
|
|
60
|
-
}
|
|
61
|
-
|
|
72
|
+
res.statusCode = err.statusCode || 500;
|
|
62
73
|
const error = { message: err.message };
|
|
63
74
|
if (process.env.NODE_ENV === "development") {
|
|
64
75
|
error.stack = err.stack;
|
package/src/error.js
CHANGED
|
@@ -5,21 +5,21 @@ const http = require("node:http");
|
|
|
5
5
|
class ErrorResponse extends Error {
|
|
6
6
|
constructor({ statusCode, message } = {}, options) {
|
|
7
7
|
super(message, options);
|
|
8
|
-
|
|
9
8
|
this.name = this.constructor.name;
|
|
10
|
-
|
|
11
9
|
if (Error.captureStackTrace) {
|
|
12
10
|
Error.captureStackTrace(this, this.constructor);
|
|
13
11
|
}
|
|
14
|
-
|
|
15
12
|
this.statusCode = statusCode;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
|
-
static status(statusCode) {
|
|
19
|
-
return new ErrorResponse(
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
static status(statusCode, options) {
|
|
16
|
+
return new ErrorResponse(
|
|
17
|
+
{
|
|
18
|
+
statusCode,
|
|
19
|
+
message: http.STATUS_CODES[statusCode],
|
|
20
|
+
},
|
|
21
|
+
options,
|
|
22
|
+
);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
package/src/index.js
CHANGED
package/src/middleware/cors.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
function pattern(string) {
|
|
2
2
|
return new RegExp(
|
|
3
|
-
|
|
3
|
+
`^(${String(string)
|
|
4
4
|
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
5
5
|
.replace(/ /g, "")
|
|
6
6
|
.replace(/,/g, "|")
|
|
7
|
-
.replace(/\\\*/g, ".*")})
|
|
7
|
+
.replace(/\\\*/g, ".*")})$`,
|
|
8
8
|
"i",
|
|
9
9
|
);
|
|
10
10
|
}
|
|
@@ -42,7 +42,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
42
42
|
function setAllowHeaders(req, res) {
|
|
43
43
|
const reqHeaders = req.headers["access-control-request-headers"];
|
|
44
44
|
if (!reqHeaders) return;
|
|
45
|
-
|
|
46
45
|
let value;
|
|
47
46
|
if (headers === "*" && !credentials) {
|
|
48
47
|
value = "*";
|
|
@@ -59,7 +58,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
59
58
|
function setAllowMethods(req, res) {
|
|
60
59
|
const reqMethod = req.headers["access-control-request-method"];
|
|
61
60
|
if (!reqMethod) return;
|
|
62
|
-
|
|
63
61
|
let value;
|
|
64
62
|
if (methods === "*" && !credentials) {
|
|
65
63
|
value = "*";
|
|
@@ -73,25 +71,21 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
73
71
|
* @param {import('http').IncomingMessage} req
|
|
74
72
|
* @param {import('http').ServerResponse} res
|
|
75
73
|
*/
|
|
76
|
-
function setAllowOrigin(req, res) {
|
|
74
|
+
function setAllowOrigin(req, res, preflight = true) {
|
|
77
75
|
const reqOrigin = req.headers["origin"];
|
|
78
76
|
|
|
79
77
|
let value;
|
|
80
78
|
let explicit;
|
|
81
|
-
if (origin === "*") {
|
|
82
|
-
|
|
83
|
-
else {
|
|
84
|
-
value = reqOrigin;
|
|
85
|
-
explicit = true;
|
|
86
|
-
}
|
|
79
|
+
if (origin === "*" && !credentials) {
|
|
80
|
+
value = "*";
|
|
87
81
|
} else if (origin === null) {
|
|
88
82
|
value = "null";
|
|
89
|
-
} else if (
|
|
90
|
-
value =
|
|
83
|
+
} else if (ORIGIN_PATTERN.test(reqOrigin)) {
|
|
84
|
+
value = reqOrigin;
|
|
91
85
|
explicit = true;
|
|
92
86
|
}
|
|
93
87
|
if (value) res.setHeader("Access-Control-Allow-Origin", value);
|
|
94
|
-
if (explicit) res.appendHeader("Vary", "Origin");
|
|
88
|
+
if (!preflight && explicit) res.appendHeader("Vary", "Origin");
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
/**
|
|
@@ -122,16 +116,16 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
122
116
|
*/
|
|
123
117
|
return (req, res) => {
|
|
124
118
|
setAllowCredentials(res);
|
|
125
|
-
setAllowOrigin(req, res);
|
|
126
119
|
if (req.method === "OPTIONS") {
|
|
120
|
+
setAllowOrigin(req, res);
|
|
127
121
|
setAllowHeaders(req, res);
|
|
128
122
|
setAllowMethods(req, res);
|
|
129
123
|
setMaxAge(res);
|
|
130
|
-
|
|
131
124
|
res.statusCode = 204;
|
|
132
125
|
res.setHeader("Content-Length", "0");
|
|
133
126
|
res.end();
|
|
134
127
|
} else {
|
|
128
|
+
setAllowOrigin(req, res, false);
|
|
135
129
|
setExposeHeaders(res);
|
|
136
130
|
}
|
|
137
131
|
};
|
package/src/middleware/json.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const http = require("node:http");
|
|
2
|
+
const { ErrorResponse } = require("../error.js");
|
|
2
3
|
|
|
3
|
-
// middleware
|
|
4
4
|
function json({ limit = 100 * 1024 } = {}) {
|
|
5
5
|
const reviver = (key, value) => {
|
|
6
6
|
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
@@ -8,46 +8,35 @@ function json({ limit = 100 * 1024 } = {}) {
|
|
|
8
8
|
}
|
|
9
9
|
return value;
|
|
10
10
|
};
|
|
11
|
-
|
|
12
11
|
return async (req, res) => {
|
|
13
12
|
if (!["POST", "PUT", "PATCH", "QUERY"].includes(req.method)) {
|
|
14
13
|
return;
|
|
15
14
|
}
|
|
16
|
-
|
|
17
15
|
const contentType = req.headers["content-type"];
|
|
18
16
|
if (!contentType || !contentType.includes("application/json")) {
|
|
19
17
|
return;
|
|
20
18
|
}
|
|
21
|
-
|
|
22
19
|
const contentLength = parseInt(req.headers["content-length"], 10);
|
|
23
20
|
if (contentLength && contentLength > limit) {
|
|
24
|
-
|
|
25
|
-
throw new Error(http.STATUS_CODES[res.statusCode]);
|
|
21
|
+
throw ErrorResponse.status(413);
|
|
26
22
|
}
|
|
27
|
-
|
|
28
23
|
let chunkLength = 0;
|
|
29
|
-
|
|
30
24
|
const chunks = [];
|
|
31
|
-
|
|
32
25
|
for await (const chunk of req) {
|
|
33
26
|
chunkLength += chunk.length;
|
|
34
27
|
if (chunkLength > limit) {
|
|
35
|
-
|
|
36
|
-
throw new Error(http.STATUS_CODES[res.statusCode]);
|
|
28
|
+
throw ErrorResponse.status(413);
|
|
37
29
|
}
|
|
38
30
|
chunks.push(chunk);
|
|
39
31
|
}
|
|
40
|
-
|
|
41
32
|
if (chunks.length === 0) {
|
|
42
33
|
req.body = null;
|
|
43
34
|
return;
|
|
44
35
|
}
|
|
45
|
-
|
|
46
36
|
try {
|
|
47
37
|
req.body = JSON.parse(Buffer.concat(chunks), reviver);
|
|
48
38
|
} catch (error) {
|
|
49
|
-
|
|
50
|
-
throw new Error("Invalid JSON body");
|
|
39
|
+
throw new ErrorResponse({ statusCode: 400, message: "Invalid JSON body" }, { cause: error });
|
|
51
40
|
}
|
|
52
41
|
};
|
|
53
42
|
}
|
package/src/node.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const { Pattern } = require("./pattern.js");
|
|
2
2
|
|
|
3
|
-
|
|
4
3
|
class Node {
|
|
5
4
|
constructor(segment = "") {
|
|
6
5
|
this.hasRegExpChildren = false;
|
|
@@ -11,58 +10,37 @@ class Node {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
add(segment) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
return this.children.getOrInsertComputed(segment, () => {
|
|
14
|
+
const node = new Node(segment);
|
|
15
|
+
if (node.pattern) this.hasRegExpChildren = true;
|
|
16
|
+
return node;
|
|
17
|
+
});
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* @param {Node} node
|
|
21
|
-
*/
|
|
22
20
|
merge(node, offset) {
|
|
23
|
-
for (const [method,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
for (const [method, entries] of node.handlers) {
|
|
22
|
+
const target = this.handlers.getOrInsert(method, []);
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
target.push({ ...entry, seq: entry.seq + offset });
|
|
27
25
|
}
|
|
28
26
|
}
|
|
29
|
-
|
|
30
27
|
for (const [segment, child] of node.children) {
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
this.children.set(segment, child);
|
|
35
|
-
} else {
|
|
36
|
-
this.children.get(segment).merge(child, offset);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
setSeqOffset(offset) {
|
|
42
|
-
for (const [, handlers] of this.handlers) {
|
|
43
|
-
for (const handler of handlers) {
|
|
44
|
-
handler.seq += offset;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const [, child] of this.children) {
|
|
49
|
-
child.setSeqOffset(offset);
|
|
28
|
+
const target = this.children.getOrInsertComputed(segment, () => new Node(segment));
|
|
29
|
+
if (target.pattern) this.hasRegExpChildren = true;
|
|
30
|
+
target.merge(child, offset);
|
|
50
31
|
}
|
|
51
32
|
}
|
|
52
33
|
|
|
53
34
|
getMaxSeq() {
|
|
54
35
|
let max = -1;
|
|
55
|
-
|
|
56
36
|
for (const [, handlers] of this.handlers) {
|
|
57
|
-
for (const
|
|
58
|
-
max = Math.max(max,
|
|
37
|
+
for (const entry of handlers) {
|
|
38
|
+
max = Math.max(max, entry.seq);
|
|
59
39
|
}
|
|
60
40
|
}
|
|
61
|
-
|
|
62
41
|
for (const [, child] of this.children) {
|
|
63
42
|
max = Math.max(max, child.getMaxSeq());
|
|
64
43
|
}
|
|
65
|
-
|
|
66
44
|
return max;
|
|
67
45
|
}
|
|
68
46
|
}
|
package/src/polyfill.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
// Polyfill for Map.prototype.getOrInsert
|
|
2
|
-
if (!Map.prototype.getOrInsert) {
|
|
3
|
-
Map.prototype.getOrInsert = function (key, defaultValue) {
|
|
4
|
-
if (!this.has(key)) {
|
|
5
|
-
this.set(key, defaultValue);
|
|
6
|
-
}
|
|
7
|
-
return this.get(key);
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
// Polyfill for Map.prototype.getOrInsertComputed
|
|
11
|
-
if (!Map.prototype.getOrInsertComputed) {
|
|
12
|
-
Map.prototype.getOrInsertComputed = function (key,
|
|
13
|
-
if (typeof
|
|
14
|
-
throw new TypeError(
|
|
15
|
-
}
|
|
16
|
-
if (!this.has(key)) {
|
|
17
|
-
this.set(key,
|
|
18
|
-
}
|
|
19
|
-
return this.get(key);
|
|
20
|
-
};
|
|
21
|
-
}
|
|
1
|
+
// Polyfill for Map.prototype.getOrInsert
|
|
2
|
+
if (!Map.prototype.getOrInsert) {
|
|
3
|
+
Map.prototype.getOrInsert = function (key, defaultValue) {
|
|
4
|
+
if (!this.has(key)) {
|
|
5
|
+
this.set(key, defaultValue);
|
|
6
|
+
}
|
|
7
|
+
return this.get(key);
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
// Polyfill for Map.prototype.getOrInsertComputed
|
|
11
|
+
if (!Map.prototype.getOrInsertComputed) {
|
|
12
|
+
Map.prototype.getOrInsertComputed = function (key, callback) {
|
|
13
|
+
if (typeof callback !== "function") {
|
|
14
|
+
throw new TypeError("The callback argument must be a function");
|
|
15
|
+
}
|
|
16
|
+
if (!this.has(key)) {
|
|
17
|
+
this.set(key, callback(key));
|
|
18
|
+
}
|
|
19
|
+
return this.get(key);
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/request.js
CHANGED
|
@@ -6,16 +6,13 @@ class Request extends http.IncomingMessage {
|
|
|
6
6
|
_parsed;
|
|
7
7
|
_query;
|
|
8
8
|
params = {};
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
signal;
|
|
11
10
|
get path() {
|
|
12
11
|
if (this._path) return this._path;
|
|
13
|
-
|
|
14
|
-
if (this.url.indexOf("?") === -1) {
|
|
12
|
+
if (!/[?#]/.test(this.url)) {
|
|
15
13
|
this._path = this.url;
|
|
16
14
|
return this._path;
|
|
17
15
|
}
|
|
18
|
-
|
|
19
16
|
this._parsed = parseUrl(this.url);
|
|
20
17
|
this._path = this._parsed.path;
|
|
21
18
|
return this._path;
|
|
@@ -23,12 +20,10 @@ class Request extends http.IncomingMessage {
|
|
|
23
20
|
|
|
24
21
|
get query() {
|
|
25
22
|
if (this._query) return this._query;
|
|
26
|
-
|
|
27
23
|
if (!this._parsed) {
|
|
28
|
-
this._query =
|
|
24
|
+
this._query = Object.create(null);
|
|
29
25
|
return this._query;
|
|
30
26
|
}
|
|
31
|
-
|
|
32
27
|
this._query = parseQueryString(this._parsed.queryString);
|
|
33
28
|
return this._query;
|
|
34
29
|
}
|
package/src/response.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
const http = require("node:http");
|
|
2
2
|
|
|
3
3
|
class Response extends http.ServerResponse {
|
|
4
|
+
status(statusCode) {
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
return this;
|
|
7
|
+
}
|
|
4
8
|
send(chunk) {
|
|
9
|
+
if (this.writableEnded) return;
|
|
5
10
|
this.end(chunk);
|
|
6
11
|
}
|
|
7
|
-
|
|
8
12
|
json(value) {
|
|
13
|
+
if (this.writableEnded) return;
|
|
14
|
+
if (this.headersSent) {
|
|
15
|
+
this.end();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
9
18
|
this.setHeader("Content-Type", "application/json");
|
|
10
19
|
this.end(JSON.stringify(value));
|
|
11
20
|
}
|
package/src/router.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
const { Node } = require("./node.js");
|
|
2
2
|
const { Cache } = require("./cache.js");
|
|
3
|
+
const { decodePathParam } = require("./util.js");
|
|
3
4
|
|
|
4
5
|
class Router {
|
|
5
6
|
constructor(options = {}) {
|
|
7
|
+
this.timeout = options.timeout ?? 0;
|
|
6
8
|
this.cacheSize = options.size ?? 1000;
|
|
7
|
-
|
|
8
9
|
this.root = new Node();
|
|
9
10
|
this.seq = 0;
|
|
10
11
|
this.cache = new Cache(this.cacheSize);
|
|
@@ -19,9 +20,7 @@ class Router {
|
|
|
19
20
|
handlers = [path, ...handlers];
|
|
20
21
|
path = null;
|
|
21
22
|
}
|
|
22
|
-
|
|
23
23
|
let node = this.root;
|
|
24
|
-
|
|
25
24
|
if (path !== null) {
|
|
26
25
|
for (const segment of this.split(path)) {
|
|
27
26
|
node = node.add(segment);
|
|
@@ -31,10 +30,13 @@ class Router {
|
|
|
31
30
|
for (const handler of handlers) {
|
|
32
31
|
if (handler.root) {
|
|
33
32
|
node.merge(handler.root, this.seq);
|
|
34
|
-
this.seq
|
|
33
|
+
this.seq += handler.root.getMaxSeq() + 1;
|
|
35
34
|
} else {
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
node.handlers.getOrInsert(method, []).push({
|
|
36
|
+
handler,
|
|
37
|
+
seq: this.seq++,
|
|
38
|
+
isErrorHandler: handler.length === 3,
|
|
39
|
+
});
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -94,6 +96,22 @@ class Router {
|
|
|
94
96
|
return this;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
matchRegExp(node, input, params) {
|
|
100
|
+
let matched;
|
|
101
|
+
for (const [, child] of node.children) {
|
|
102
|
+
if (!child.pattern) continue;
|
|
103
|
+
const match = child.pattern.exec(input);
|
|
104
|
+
if (match) {
|
|
105
|
+
matched = child;
|
|
106
|
+
for (const name in match.groups) {
|
|
107
|
+
params[name] = decodePathParam(match.groups[name]);
|
|
108
|
+
}
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return matched;
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
/**
|
|
98
116
|
* @param {'GET'|'QUERY'|'HEAD'|'OPTIONS'|'TRACE'|'PUT'|'DELETE'|'POST'|'PATCH'|'CONNECT'} method
|
|
99
117
|
* @param {String} path
|
|
@@ -101,73 +119,47 @@ class Router {
|
|
|
101
119
|
*/
|
|
102
120
|
find(method, path) {
|
|
103
121
|
const key = `${method} ${path}`;
|
|
104
|
-
|
|
105
122
|
const cached = this.cache.get(key);
|
|
106
123
|
if (cached) return cached;
|
|
107
|
-
|
|
108
124
|
let node = this.root;
|
|
109
|
-
|
|
110
125
|
let params = Object.create(null);
|
|
111
126
|
const handlers = {};
|
|
112
127
|
const errorHandlers = {};
|
|
113
|
-
|
|
114
128
|
for (const input of this.split(path)) {
|
|
115
129
|
let matched = node.children.get(input);
|
|
116
|
-
|
|
117
130
|
if (!matched && node.hasRegExpChildren) {
|
|
118
|
-
|
|
119
|
-
if (!child.pattern) continue;
|
|
120
|
-
const match = child.pattern.exec(input);
|
|
121
|
-
if (match) {
|
|
122
|
-
matched = child;
|
|
123
|
-
for (const name in match.groups) {
|
|
124
|
-
const raw = match.groups[name];
|
|
125
|
-
try {
|
|
126
|
-
params[name] = decodeURIComponent(raw);
|
|
127
|
-
} catch {
|
|
128
|
-
params[name] = raw;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
131
|
+
matched = this.matchRegExp(node, input, params);
|
|
134
132
|
}
|
|
135
|
-
|
|
136
133
|
if (!matched) {
|
|
137
134
|
node = null;
|
|
138
135
|
params = Object.create(null);
|
|
139
136
|
break;
|
|
140
137
|
}
|
|
141
|
-
|
|
142
138
|
node = matched;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
139
|
+
if (node.handlers.has("ALL")) {
|
|
140
|
+
for (const entry of node.handlers.get("ALL")) {
|
|
141
|
+
if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
|
|
142
|
+
else handlers[entry.seq] = entry.handler;
|
|
143
|
+
}
|
|
148
144
|
}
|
|
149
145
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
146
|
+
if (this.root.handlers.has("ALL")) {
|
|
147
|
+
for (const entry of this.root.handlers.get("ALL")) {
|
|
148
|
+
if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
|
|
149
|
+
else handlers[entry.seq] = entry.handler;
|
|
150
|
+
}
|
|
155
151
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
handlers[handler.seq] = handler;
|
|
152
|
+
if (node && node.handlers.has(method)) {
|
|
153
|
+
for (const entry of node.handlers.get(method)) {
|
|
154
|
+
handlers[entry.seq] = entry.handler;
|
|
160
155
|
}
|
|
161
156
|
}
|
|
162
|
-
|
|
163
157
|
const result = {
|
|
164
158
|
params,
|
|
165
159
|
handlers: Object.values(handlers),
|
|
166
160
|
errorHandlers: Object.values(errorHandlers),
|
|
167
161
|
};
|
|
168
|
-
|
|
169
162
|
this.cache.set(key, result);
|
|
170
|
-
|
|
171
163
|
return result;
|
|
172
164
|
}
|
|
173
165
|
}
|
package/src/util.js
CHANGED
|
@@ -1,39 +1,42 @@
|
|
|
1
1
|
const URL_REGEX = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
2
2
|
const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
|
|
3
3
|
|
|
4
|
+
function decodeQueryParam(encoded) {
|
|
5
|
+
try {
|
|
6
|
+
return decodeURIComponent(encoded.replace(/\+/g, " "));
|
|
7
|
+
} catch (error) {
|
|
8
|
+
return encoded;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.decodeQueryParam = decodeQueryParam;
|
|
12
|
+
|
|
13
|
+
function decodePathParam(encoded) {
|
|
14
|
+
try {
|
|
15
|
+
return decodeURIComponent(encoded);
|
|
16
|
+
} catch {
|
|
17
|
+
return encoded;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.decodePathParam=decodePathParam
|
|
21
|
+
|
|
4
22
|
function parseQueryString(queryString) {
|
|
5
23
|
const result = Object.create(null);
|
|
6
|
-
|
|
7
24
|
let match;
|
|
8
25
|
while ((match = QUERY_STRING_REGEX.exec(queryString)) !== null) {
|
|
9
26
|
const [, rawName = "", rawValue = ""] = match;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
name = decodeURIComponent(rawName)
|
|
14
|
-
} catch (error) {
|
|
15
|
-
name=rawName
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
let value
|
|
19
|
-
try {
|
|
20
|
-
value = decodeURIComponent(rawValue)
|
|
21
|
-
} catch (error) {
|
|
22
|
-
value=rawValue
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (result[name]) {
|
|
27
|
+
const name = decodeQueryParam(rawName);
|
|
28
|
+
const value = decodeQueryParam(rawValue);
|
|
29
|
+
if (Object.hasOwn(result, name)) {
|
|
26
30
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
27
31
|
else result[name] = [result[name], value];
|
|
28
32
|
} else result[name] = value;
|
|
29
33
|
}
|
|
30
|
-
|
|
31
34
|
return result;
|
|
32
35
|
}
|
|
33
36
|
exports.parseQueryString = parseQueryString;
|
|
34
37
|
|
|
35
38
|
function parseUrl(url) {
|
|
36
|
-
const [,
|
|
37
|
-
return { path, queryString };
|
|
39
|
+
const [, rawPath = "", , queryString = ""] = URL_REGEX.exec(url);
|
|
40
|
+
return { path: rawPath || "/", queryString };
|
|
38
41
|
}
|
|
39
42
|
exports.parseUrl = parseUrl;
|