@ndiinginc/router 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -66
- package/package.json +1 -1
- package/src/app.js +23 -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 +2 -7
- package/src/response.js +10 -1
- package/src/router.js +42 -46
- package/src/util.js +9 -14
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);
|
|
@@ -51,23 +51,19 @@ app.listen(3000);
|
|
|
51
51
|
|
|
52
52
|
Yang diekspor adalah class `App`, dengan tiga properti tambahan:
|
|
53
53
|
|
|
54
|
-
| Export
|
|
55
|
-
|
|
|
56
|
-
| `App`
|
|
54
|
+
| Export | Keterangan |
|
|
55
|
+
| ------------ | ---------------------------------------- |
|
|
56
|
+
| `App` | Router + HTTP server (`listen`) |
|
|
57
57
|
| `App.Router` | Router tanpa server, untuk nested router |
|
|
58
|
-
| `App.json`
|
|
59
|
-
| `App.cors`
|
|
58
|
+
| `App.json` | Middleware JSON body parser |
|
|
59
|
+
| `App.cors` | Middleware CORS |
|
|
60
60
|
|
|
61
61
|
## Routing
|
|
62
62
|
|
|
63
63
|
Semua method mengembalikan `this`, jadi bisa di-chain.
|
|
64
64
|
|
|
65
65
|
```js
|
|
66
|
-
app.get("/users", handler)
|
|
67
|
-
.post("/users", handler)
|
|
68
|
-
.put("/users/:id", handler)
|
|
69
|
-
.patch("/users/:id", handler)
|
|
70
|
-
.delete("/users/:id", handler);
|
|
66
|
+
app.get("/users", handler).post("/users", handler).put("/users/:id", handler).patch("/users/:id", handler).delete("/users/:id", handler);
|
|
71
67
|
```
|
|
72
68
|
|
|
73
69
|
Method yang tersedia: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `trace`, `connect`, `query`, dan `use` (semua method).
|
|
@@ -82,6 +78,8 @@ app.get("/profile", authenticate, loadUser, (req, res) => {
|
|
|
82
78
|
|
|
83
79
|
### Parameter dinamis
|
|
84
80
|
|
|
81
|
+
Nilai parameter otomatis di-decode dengan `decodeURIComponent`. Jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
|
|
82
|
+
|
|
85
83
|
```js
|
|
86
84
|
app.get("/posts/:postId/comments/:commentId", (req, res) => {
|
|
87
85
|
const { postId, commentId } = req.params;
|
|
@@ -118,8 +116,7 @@ app.use((req, res) => {
|
|
|
118
116
|
// Scoped: jalan untuk /admin dan semua sub-path-nya
|
|
119
117
|
app.use("/admin", async (req, res) => {
|
|
120
118
|
if (req.headers.authorization !== "secret") {
|
|
121
|
-
res.
|
|
122
|
-
res.json({ error: "Unauthorized" });
|
|
119
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
123
120
|
}
|
|
124
121
|
});
|
|
125
122
|
```
|
|
@@ -149,7 +146,7 @@ app.use("/admin", admin);
|
|
|
149
146
|
// GET /admin/users/42 -> { id: "42" }
|
|
150
147
|
```
|
|
151
148
|
|
|
152
|
-
>
|
|
149
|
+
> 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
150
|
|
|
154
151
|
## Error Handling
|
|
155
152
|
|
|
@@ -161,8 +158,7 @@ app.get("/boom", () => {
|
|
|
161
158
|
});
|
|
162
159
|
|
|
163
160
|
app.use((err, req, res) => {
|
|
164
|
-
res.statusCode
|
|
165
|
-
res.json({ error: { message: err.message } });
|
|
161
|
+
res.status(err.statusCode || 500).json({ error: { message: err.message } });
|
|
166
162
|
});
|
|
167
163
|
```
|
|
168
164
|
|
|
@@ -170,23 +166,18 @@ app.use((err, req, res) => {
|
|
|
170
166
|
|
|
171
167
|
### Menentukan status code
|
|
172
168
|
|
|
173
|
-
|
|
169
|
+
Status code diambil dari properti `statusCode` pada error. Tanpa properti itu, hasilnya `500`.
|
|
174
170
|
|
|
175
171
|
```js
|
|
176
|
-
// 1. Properti statusCode pada error
|
|
177
172
|
app.get("/admin", () => {
|
|
178
173
|
const err = new Error("Forbidden");
|
|
179
174
|
err.statusCode = 403;
|
|
180
175
|
throw err;
|
|
181
176
|
});
|
|
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
177
|
```
|
|
189
178
|
|
|
179
|
+
> `res.statusCode` atau `res.status()` yang diset **sebelum** error dilempar tidak dipakai oleh respons default. Gunakan `err.statusCode`, atau tangani sendiri di error handler.
|
|
180
|
+
|
|
190
181
|
### Respons default
|
|
191
182
|
|
|
192
183
|
Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
@@ -196,35 +187,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
196
187
|
```
|
|
197
188
|
|
|
198
189
|
- Tidak ada route cocok: `404 Not Found`
|
|
199
|
-
- Error
|
|
200
|
-
-
|
|
190
|
+
- Error dengan `statusCode`: status tersebut dipakai
|
|
191
|
+
- Error tanpa `statusCode`: `500`
|
|
201
192
|
- Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
|
|
202
|
-
|
|
203
193
|
- Field `stack` hanya disertakan saat `NODE_ENV=development`
|
|
204
194
|
|
|
205
195
|
## Timeout
|
|
206
196
|
|
|
207
|
-
|
|
197
|
+
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
198
|
|
|
209
199
|
```js
|
|
210
|
-
app
|
|
211
|
-
|
|
200
|
+
const app = new App({ timeout: 5000 });
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- Tanpa opsi `timeout` (atau bernilai `0`), tidak ada batas waktu
|
|
204
|
+
- 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
|
|
205
|
+
|
|
206
|
+
### `req.signal`
|
|
207
|
+
|
|
208
|
+
Saat `timeout` aktif, `req.signal` adalah `AbortSignal` yang di-abort ketika timeout terjadi (reason-nya error 504). Teruskan ke operasi yang mendukungnya agar pekerjaan ikut dibatalkan:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
app.get("/proxy", async (req, res) => {
|
|
212
|
+
const upstream = await fetch("https://example.com/api", { signal: req.signal });
|
|
213
|
+
res.json(await upstream.json());
|
|
212
214
|
});
|
|
213
215
|
```
|
|
214
216
|
|
|
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
|
|
217
|
+
`req.signal` bernilai `undefined` jika `timeout` tidak diaktifkan.
|
|
218
218
|
|
|
219
219
|
## Request
|
|
220
220
|
|
|
221
|
-
| Properti
|
|
222
|
-
|
|
|
223
|
-
| `req.path`
|
|
224
|
-
| `req.query`
|
|
225
|
-
| `req.params` | Parameter dari route dinamis (sudah di-decode
|
|
226
|
-
| `req.body`
|
|
227
|
-
| `req.
|
|
221
|
+
| Properti | Keterangan |
|
|
222
|
+
| ------------ | -------------------------------------------------------------------------- |
|
|
223
|
+
| `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) |
|
|
226
|
+
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
227
|
+
| `req.signal` | `AbortSignal` yang di-abort saat timeout (hanya jika `timeout` aktif) |
|
|
228
228
|
|
|
229
229
|
```js
|
|
230
230
|
// GET /search?tag=a&tag=b&page=2
|
|
@@ -233,14 +233,21 @@ app.get("/search", (req, res) => {
|
|
|
233
233
|
});
|
|
234
234
|
```
|
|
235
235
|
|
|
236
|
+
`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(...)`.
|
|
237
|
+
|
|
236
238
|
`Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
|
|
237
239
|
|
|
238
240
|
## Response
|
|
239
241
|
|
|
240
|
-
| Method
|
|
241
|
-
|
|
|
242
|
-
| `res.
|
|
243
|
-
| `res.
|
|
242
|
+
| Method | Keterangan |
|
|
243
|
+
| ------------------ | ----------------------------------------------------------- |
|
|
244
|
+
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
|
|
245
|
+
| `res.send(chunk)` | Kirim response dan akhiri |
|
|
246
|
+
| `res.json(value)` | Set `Content-Type: application/json`, serialize, lalu kirim |
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
res.status(201).json({ created: true });
|
|
250
|
+
```
|
|
244
251
|
|
|
245
252
|
`Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
|
|
246
253
|
|
|
@@ -252,8 +259,8 @@ app.get("/search", (req, res) => {
|
|
|
252
259
|
app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
|
|
253
260
|
```
|
|
254
261
|
|
|
255
|
-
| Opsi
|
|
256
|
-
|
|
|
262
|
+
| Opsi | Default | Keterangan |
|
|
263
|
+
| ------- | -------- | ------------------------------- |
|
|
257
264
|
| `limit` | `102400` | Ukuran body maksimum dalam byte |
|
|
258
265
|
|
|
259
266
|
Perilaku:
|
|
@@ -278,36 +285,42 @@ app.use(
|
|
|
278
285
|
);
|
|
279
286
|
```
|
|
280
287
|
|
|
281
|
-
| Opsi
|
|
282
|
-
|
|
|
283
|
-
| `origin`
|
|
284
|
-
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true`
|
|
285
|
-
| `headers`
|
|
286
|
-
| `methods`
|
|
287
|
-
| `maxAge`
|
|
288
|
+
| Opsi | Default | Keterangan |
|
|
289
|
+
| ------------- | ------- | -------------------------------------------------------------- |
|
|
290
|
+
| `origin` | `"*"` | `"*"`, string origin, atau `null` |
|
|
291
|
+
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
292
|
+
| `headers` | - | `"*"` atau array nama header |
|
|
293
|
+
| `methods` | - | `"*"` atau array nama method |
|
|
294
|
+
| `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
|
|
288
295
|
|
|
289
296
|
Perilaku:
|
|
290
297
|
|
|
291
298
|
- Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
|
|
299
|
+
- `Access-Control-Allow-Headers` dan `Access-Control-Allow-Methods` hanya dikirim pada preflight, jika nilai yang diminta ada di daftar
|
|
292
300
|
- Dengan `origin: "*"` dan `credentials: true`, origin dari request dipantulkan dan header `Vary: Origin` ditambahkan
|
|
293
301
|
- Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
|
|
294
302
|
- Pada request non-preflight, `headers` (jika array) dikirim sebagai `Access-Control-Expose-Headers`
|
|
295
303
|
|
|
296
|
-
|
|
304
|
+
> `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
|
|
305
|
+
|
|
306
|
+
## Opsi
|
|
297
307
|
|
|
298
308
|
```js
|
|
299
|
-
const app = new App({ size: 1000 });
|
|
309
|
+
const app = new App({ size: 1000, timeout: 5000 });
|
|
300
310
|
```
|
|
301
311
|
|
|
302
|
-
| Opsi
|
|
303
|
-
|
|
|
304
|
-
| `size`
|
|
312
|
+
| Opsi | Default | Keterangan |
|
|
313
|
+
| --------- | ------- | ----------------------------------------------------------------------------- |
|
|
314
|
+
| `size` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path` |
|
|
315
|
+
| `timeout` | - | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
316
|
+
|
|
317
|
+
`Router` hanya menerima `size`.
|
|
305
318
|
|
|
306
319
|
## API
|
|
307
320
|
|
|
308
321
|
### `new App([options])` / `new Router([options])`
|
|
309
322
|
|
|
310
|
-
Membuat instance baru. `App` adalah `Router` ditambah `listen()`.
|
|
323
|
+
Membuat instance baru. `App` adalah `Router` ditambah `listen()` dan dukungan `timeout`.
|
|
311
324
|
|
|
312
325
|
### `router.use([path], ...handlers | router)`
|
|
313
326
|
|
|
@@ -338,11 +351,11 @@ console.log(server.address().port);
|
|
|
338
351
|
|
|
339
352
|
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
340
353
|
|
|
341
|
-
|
|
|
342
|
-
|
|
|
343
|
-
| Fastify
|
|
344
|
-
| **@ndiinginc/router** | 29.735
|
|
345
|
-
| Express
|
|
354
|
+
| | Req/sec (avg) | Latency (avg) |
|
|
355
|
+
| --------------------- | ------------- | ------------- |
|
|
356
|
+
| Fastify | 31.475 | 31,27 ms |
|
|
357
|
+
| **@ndiinginc/router** | 29.735 | 33,12 ms |
|
|
358
|
+
| Express | 20.211 | 48,93 ms |
|
|
346
359
|
|
|
347
360
|
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
348
361
|
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -11,13 +11,26 @@ 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
|
-
await Promise.race([handlerPromise, timeoutPromise]).finally(() =>
|
|
30
|
+
await Promise.race([handlerPromise(), timeoutPromise]).finally(() => {
|
|
31
|
+
signal.removeEventListener("abort", handleAbort);
|
|
32
|
+
clearTimeout(timeout);
|
|
33
|
+
});
|
|
21
34
|
}
|
|
22
35
|
|
|
23
36
|
/**
|
|
@@ -26,22 +39,19 @@ class App extends Router {
|
|
|
26
39
|
*/
|
|
27
40
|
async handle(req, res) {
|
|
28
41
|
const { params, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
29
|
-
req.params = params;
|
|
30
|
-
|
|
42
|
+
req.params = Object.assign(Object.create(null), params);
|
|
31
43
|
let err;
|
|
32
44
|
try {
|
|
33
|
-
const handlerPromise =
|
|
45
|
+
const handlerPromise = async () => {
|
|
34
46
|
for (const handler of handlers) {
|
|
35
47
|
if (res.writableEnded) break;
|
|
36
48
|
await handler(req, res);
|
|
37
49
|
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else await handlerPromise;
|
|
50
|
+
};
|
|
51
|
+
if (this.timeout) await this.withTimeout(req, handlerPromise);
|
|
52
|
+
else await handlerPromise();
|
|
42
53
|
} catch (error) {
|
|
43
54
|
err = error;
|
|
44
|
-
|
|
45
55
|
try {
|
|
46
56
|
for (const handler of errorHandlers) {
|
|
47
57
|
if (res.writableEnded) break;
|
|
@@ -51,14 +61,9 @@ class App extends Router {
|
|
|
51
61
|
err = new Error(error.message, { cause: err });
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
|
-
|
|
55
64
|
if (!res.writableEnded) {
|
|
56
65
|
if (!err) err = ErrorResponse.status(404);
|
|
57
|
-
|
|
58
|
-
if (!res.statusCode || res.statusCode < 400) {
|
|
59
|
-
res.statusCode = err.statusCode || 500;
|
|
60
|
-
}
|
|
61
|
-
|
|
66
|
+
res.statusCode = err.statusCode || 500;
|
|
62
67
|
const error = { message: err.message };
|
|
63
68
|
if (process.env.NODE_ENV === "development") {
|
|
64
69
|
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
24
|
this._query = {};
|
|
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
|
@@ -3,8 +3,8 @@ const { Cache } = require("./cache.js");
|
|
|
3
3
|
|
|
4
4
|
class Router {
|
|
5
5
|
constructor(options = {}) {
|
|
6
|
+
this.timeout = options.timeout ?? 0;
|
|
6
7
|
this.cacheSize = options.size ?? 1000;
|
|
7
|
-
|
|
8
8
|
this.root = new Node();
|
|
9
9
|
this.seq = 0;
|
|
10
10
|
this.cache = new Cache(this.cacheSize);
|
|
@@ -19,9 +19,7 @@ class Router {
|
|
|
19
19
|
handlers = [path, ...handlers];
|
|
20
20
|
path = null;
|
|
21
21
|
}
|
|
22
|
-
|
|
23
22
|
let node = this.root;
|
|
24
|
-
|
|
25
23
|
if (path !== null) {
|
|
26
24
|
for (const segment of this.split(path)) {
|
|
27
25
|
node = node.add(segment);
|
|
@@ -31,10 +29,13 @@ class Router {
|
|
|
31
29
|
for (const handler of handlers) {
|
|
32
30
|
if (handler.root) {
|
|
33
31
|
node.merge(handler.root, this.seq);
|
|
34
|
-
this.seq
|
|
32
|
+
this.seq += handler.root.getMaxSeq() + 1;
|
|
35
33
|
} else {
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
node.handlers.getOrInsert(method, []).push({
|
|
35
|
+
handler,
|
|
36
|
+
seq: this.seq++,
|
|
37
|
+
isErrorHandler: handler.length === 3,
|
|
38
|
+
});
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
}
|
|
@@ -94,6 +95,27 @@ class Router {
|
|
|
94
95
|
return this;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
matchRegExp(node, input, params) {
|
|
99
|
+
let matched;
|
|
100
|
+
for (const [, child] of node.children) {
|
|
101
|
+
if (!child.pattern) continue;
|
|
102
|
+
const match = child.pattern.exec(input);
|
|
103
|
+
if (match) {
|
|
104
|
+
matched = child;
|
|
105
|
+
for (const name in match.groups) {
|
|
106
|
+
const raw = match.groups[name];
|
|
107
|
+
try {
|
|
108
|
+
params[name] = decodeURIComponent(raw);
|
|
109
|
+
} catch {
|
|
110
|
+
params[name] = raw;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return matched;
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
/**
|
|
98
120
|
* @param {'GET'|'QUERY'|'HEAD'|'OPTIONS'|'TRACE'|'PUT'|'DELETE'|'POST'|'PATCH'|'CONNECT'} method
|
|
99
121
|
* @param {String} path
|
|
@@ -101,73 +123,47 @@ class Router {
|
|
|
101
123
|
*/
|
|
102
124
|
find(method, path) {
|
|
103
125
|
const key = `${method} ${path}`;
|
|
104
|
-
|
|
105
126
|
const cached = this.cache.get(key);
|
|
106
127
|
if (cached) return cached;
|
|
107
|
-
|
|
108
128
|
let node = this.root;
|
|
109
|
-
|
|
110
129
|
let params = Object.create(null);
|
|
111
130
|
const handlers = {};
|
|
112
131
|
const errorHandlers = {};
|
|
113
|
-
|
|
114
132
|
for (const input of this.split(path)) {
|
|
115
133
|
let matched = node.children.get(input);
|
|
116
|
-
|
|
117
134
|
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
|
-
}
|
|
135
|
+
matched = this.matchRegExp(node, input, params);
|
|
134
136
|
}
|
|
135
|
-
|
|
136
137
|
if (!matched) {
|
|
137
138
|
node = null;
|
|
138
139
|
params = Object.create(null);
|
|
139
140
|
break;
|
|
140
141
|
}
|
|
141
|
-
|
|
142
142
|
node = matched;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
if (node.handlers.has("ALL")) {
|
|
144
|
+
for (const entry of node.handlers.get("ALL")) {
|
|
145
|
+
if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
|
|
146
|
+
else handlers[entry.seq] = entry.handler;
|
|
147
|
+
}
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
150
|
+
if (this.root.handlers.has("ALL")) {
|
|
151
|
+
for (const entry of this.root.handlers.get("ALL")) {
|
|
152
|
+
if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
|
|
153
|
+
else handlers[entry.seq] = entry.handler;
|
|
154
|
+
}
|
|
155
155
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
handlers[handler.seq] = handler;
|
|
156
|
+
if (node && node.handlers.has(method)) {
|
|
157
|
+
for (const entry of node.handlers.get(method)) {
|
|
158
|
+
handlers[entry.seq] = entry.handler;
|
|
160
159
|
}
|
|
161
160
|
}
|
|
162
|
-
|
|
163
161
|
const result = {
|
|
164
162
|
params,
|
|
165
163
|
handlers: Object.values(handlers),
|
|
166
164
|
errorHandlers: Object.values(errorHandlers),
|
|
167
165
|
};
|
|
168
|
-
|
|
169
166
|
this.cache.set(key, result);
|
|
170
|
-
|
|
171
167
|
return result;
|
|
172
168
|
}
|
|
173
169
|
}
|
package/src/util.js
CHANGED
|
@@ -3,37 +3,32 @@ const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
|
|
|
3
3
|
|
|
4
4
|
function parseQueryString(queryString) {
|
|
5
5
|
const result = Object.create(null);
|
|
6
|
-
|
|
7
6
|
let match;
|
|
8
7
|
while ((match = QUERY_STRING_REGEX.exec(queryString)) !== null) {
|
|
9
8
|
const [, rawName = "", rawValue = ""] = match;
|
|
10
|
-
|
|
11
|
-
let name
|
|
9
|
+
let name;
|
|
12
10
|
try {
|
|
13
|
-
name = decodeURIComponent(rawName)
|
|
11
|
+
name = decodeURIComponent(rawName);
|
|
14
12
|
} catch (error) {
|
|
15
|
-
name=rawName
|
|
13
|
+
name = rawName;
|
|
16
14
|
}
|
|
17
|
-
|
|
18
|
-
let value
|
|
15
|
+
let value;
|
|
19
16
|
try {
|
|
20
|
-
value = decodeURIComponent(rawValue)
|
|
17
|
+
value = decodeURIComponent(rawValue);
|
|
21
18
|
} catch (error) {
|
|
22
|
-
value=rawValue
|
|
19
|
+
value = rawValue;
|
|
23
20
|
}
|
|
24
|
-
|
|
25
|
-
if (result[name]) {
|
|
21
|
+
if (Object.hasOwn(result, name)) {
|
|
26
22
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
27
23
|
else result[name] = [result[name], value];
|
|
28
24
|
} else result[name] = value;
|
|
29
25
|
}
|
|
30
|
-
|
|
31
26
|
return result;
|
|
32
27
|
}
|
|
33
28
|
exports.parseQueryString = parseQueryString;
|
|
34
29
|
|
|
35
30
|
function parseUrl(url) {
|
|
36
|
-
const [,
|
|
37
|
-
return { path, queryString };
|
|
31
|
+
const [, rawPath = "", , queryString = ""] = URL_REGEX.exec(url);
|
|
32
|
+
return { path: rawPath || "/", queryString };
|
|
38
33
|
}
|
|
39
34
|
exports.parseUrl = parseUrl;
|