@ndiinginc/router 4.8.0 → 4.9.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 +67 -47
- package/package.json +1 -1
- package/src/app.js +53 -44
- package/src/cache.js +1 -1
- package/src/index.js +5 -5
- package/src/middleware/cors.js +48 -57
- package/src/middleware/json.js +7 -6
- package/src/node.js +35 -40
- package/src/request.js +22 -17
- package/src/response.js +7 -10
- package/src/router.js +100 -67
- package/src/util.js +27 -25
- package/src/error.js +0 -26
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ 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, dengan `req.controller` (`AbortController`) untuk pembatalan kooperatif
|
|
15
15
|
- `req.path`, `req.query`, `req.params`, `req.body`
|
|
16
16
|
- Helper `res.status()`, `res.send()`, dan `res.json()`
|
|
17
17
|
- Middleware bawaan: `json()` (dengan limit dan proteksi prototype pollution) dan `cors()`
|
|
@@ -93,15 +93,21 @@ app.get("/posts/:postId/comments/:commentId", (req, res) => {
|
|
|
93
93
|
|
|
94
94
|
Satu segmen adalah satu parameter utuh; nama parameter tidak bisa digabung dengan teks lain dalam segmen yang sama (`:id.json` akan dianggap parameter bernama `id.json`).
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
Beberapa parameter dengan nama berbeda boleh ada di level yang sama. Router mencoba semuanya sesuai urutan pendaftaran, dan memakai yang cocok sampai akhir path:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
app.get("/users/:id", handler);
|
|
100
|
+
app.get("/users/:userId/posts", handler);
|
|
101
|
+
// GET /users/5/posts -> req.params.userId === "5"
|
|
102
|
+
```
|
|
97
103
|
|
|
98
104
|
### Wildcard
|
|
99
105
|
|
|
100
|
-
Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params
|
|
106
|
+
Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
|
|
101
107
|
|
|
102
108
|
```js
|
|
103
109
|
app.get("/files/*", (req, res) => {
|
|
104
|
-
res.json({ file: req.params
|
|
110
|
+
res.json({ file: req.params.wildcard });
|
|
105
111
|
});
|
|
106
112
|
|
|
107
113
|
// GET /files/images/logo.png -> { file: "images/logo.png" }
|
|
@@ -111,8 +117,8 @@ Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `
|
|
|
111
117
|
|
|
112
118
|
### Aturan matching
|
|
113
119
|
|
|
114
|
-
- Di setiap level, router mencoba **static dulu, lalu parameter, lalu wildcard**. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
|
|
115
|
-
- Jika cabang
|
|
120
|
+
- Di setiap level, router mencoba **static dulu, lalu parameter (sesuai urutan pendaftaran), lalu wildcard**. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
|
|
121
|
+
- Jika sebuah cabang gagal di tengah jalan, router mundur (backtrack) dan mencoba cabang berikutnya.
|
|
116
122
|
- Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
|
|
117
123
|
|
|
118
124
|
## Middleware
|
|
@@ -135,7 +141,7 @@ app.use("/admin", async (req, res) => {
|
|
|
135
141
|
|
|
136
142
|
Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
|
|
137
143
|
|
|
138
|
-
> Jika
|
|
144
|
+
> Jika tidak ada route yang cocok sepenuhnya, middleware pada bagian path yang cocok paling dalam tetap dijalankan sebelum respons 404. Contohnya, middleware `/admin` tetap jalan untuk `/admin/tidak-ada`, begitu juga middleware global.
|
|
139
145
|
|
|
140
146
|
## Nested Router
|
|
141
147
|
|
|
@@ -164,32 +170,32 @@ app.use("/admin", admin);
|
|
|
164
170
|
|
|
165
171
|
Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
app.get("/boom", () => {
|
|
169
|
-
throw new Error("Something broke");
|
|
170
|
-
});
|
|
173
|
+
> Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
|
|
171
174
|
|
|
172
|
-
|
|
173
|
-
|
|
175
|
+
### Menentukan status code
|
|
176
|
+
|
|
177
|
+
Status code diambil dari `res.statusCode`. Set sebelum melempar error:
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
app.get("/admin", (req, res) => {
|
|
181
|
+
res.status(403);
|
|
182
|
+
throw new Error("Forbidden");
|
|
174
183
|
});
|
|
175
184
|
```
|
|
176
185
|
|
|
177
|
-
|
|
186
|
+
Jika `res.statusCode` masih di bawah 400 saat error ditangani, hasilnya `500`. Properti `statusCode` pada objek error tidak dipakai.
|
|
178
187
|
|
|
179
|
-
###
|
|
188
|
+
### Error handler sendiri
|
|
180
189
|
|
|
181
|
-
|
|
190
|
+
Di error handler, `res.statusCode` masih bernilai awal (`200`) bila error dilempar tanpa mengatur status, jadi atur sendiri:
|
|
182
191
|
|
|
183
192
|
```js
|
|
184
|
-
app.
|
|
185
|
-
const
|
|
186
|
-
err.
|
|
187
|
-
throw err;
|
|
193
|
+
app.use((err, req, res) => {
|
|
194
|
+
const status = res.statusCode >= 400 ? res.statusCode : 500;
|
|
195
|
+
res.status(status).json({ error: { message: err.message } });
|
|
188
196
|
});
|
|
189
197
|
```
|
|
190
198
|
|
|
191
|
-
> `res.statusCode` atau `res.status()` yang diset **sebelum** error dilempar tidak dipakai oleh respons default. Gunakan `err.statusCode`, atau tangani sendiri di error handler.
|
|
192
|
-
|
|
193
199
|
### Respons default
|
|
194
200
|
|
|
195
201
|
Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
@@ -199,44 +205,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
199
205
|
```
|
|
200
206
|
|
|
201
207
|
- Tidak ada route cocok: `404 Not Found`
|
|
202
|
-
-
|
|
203
|
-
-
|
|
208
|
+
- `res.statusCode` sudah `>= 400`: dipakai apa adanya
|
|
209
|
+
- `res.statusCode` masih `< 400`: diganti `500`
|
|
204
210
|
- Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
|
|
205
211
|
- Field `stack` hanya disertakan saat `NODE_ENV=development`
|
|
206
212
|
|
|
207
213
|
## Timeout
|
|
208
214
|
|
|
209
|
-
Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, error `
|
|
215
|
+
Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, `res.statusCode` diset `502` dan error `Bad Gateway` dilempar ke error handler.
|
|
210
216
|
|
|
211
217
|
```js
|
|
212
218
|
const app = new App({ requestTimeout: 5000 });
|
|
213
219
|
```
|
|
214
220
|
|
|
215
221
|
- Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
|
|
216
|
-
- Handler yang sudah berjalan tidak dihentikan paksa.
|
|
222
|
+
- Handler yang sudah berjalan tidak dihentikan paksa. Setelah response dikirim, handler berikutnya di chain tidak dipanggil, dan error yang dilempar handler setelah timeout hanya dicatat lewat `console.error`
|
|
217
223
|
|
|
218
|
-
### `req.
|
|
224
|
+
### `req.controller`
|
|
219
225
|
|
|
220
|
-
Saat `requestTimeout` aktif, `req.signal`
|
|
226
|
+
`req.controller` adalah `AbortController` milik request (dibuat saat pertama kali diakses). Saat `requestTimeout` aktif, `req.controller.signal` di-abort ketika timeout terjadi atau ketika salah satu handler melempar error; reason-nya adalah error terkait. Teruskan signal ke operasi yang mendukungnya agar pekerjaan ikut dibatalkan:
|
|
221
227
|
|
|
222
228
|
```js
|
|
223
229
|
app.get("/proxy", async (req, res) => {
|
|
224
|
-
const upstream = await fetch("https://example.com/api", {
|
|
230
|
+
const upstream = await fetch("https://example.com/api", {
|
|
231
|
+
signal: req.controller.signal,
|
|
232
|
+
});
|
|
225
233
|
res.json(await upstream.json());
|
|
226
234
|
});
|
|
227
235
|
```
|
|
228
236
|
|
|
229
|
-
`req.signal` bernilai `undefined` jika `requestTimeout` tidak diaktifkan.
|
|
230
|
-
|
|
231
237
|
## Request
|
|
232
238
|
|
|
233
239
|
| Properti | Keterangan |
|
|
234
240
|
| --- | --- |
|
|
235
241
|
| `req.path` | Pathname tanpa query string |
|
|
236
242
|
| `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
|
|
237
|
-
| `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params
|
|
243
|
+
| `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
|
|
238
244
|
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
239
|
-
| `req.
|
|
245
|
+
| `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
|
|
240
246
|
|
|
241
247
|
```js
|
|
242
248
|
// GET /search?tag=a&tag=b&page=2
|
|
@@ -245,7 +251,7 @@ app.get("/search", (req, res) => {
|
|
|
245
251
|
});
|
|
246
252
|
```
|
|
247
253
|
|
|
248
|
-
`req.query` dan `req.params`
|
|
254
|
+
`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(...)`.
|
|
249
255
|
|
|
250
256
|
`Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
|
|
251
257
|
|
|
@@ -255,13 +261,13 @@ app.get("/search", (req, res) => {
|
|
|
255
261
|
| --- | --- |
|
|
256
262
|
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
|
|
257
263
|
| `res.send(chunk)` | Kirim response dan akhiri |
|
|
258
|
-
| `res.json(value)` | Set `Content-Type: application/json`, serialize, lalu kirim |
|
|
264
|
+
| `res.json(value)` | Set `Content-Type: application/json; charset=utf-8`, serialize, lalu kirim |
|
|
259
265
|
|
|
260
266
|
```js
|
|
261
267
|
res.status(201).json({ created: true });
|
|
262
268
|
```
|
|
263
269
|
|
|
264
|
-
`Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
|
|
270
|
+
`res.send()` dan `res.json()` tidak melakukan apa-apa jika response sudah selesai dikirim. `Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
|
|
265
271
|
|
|
266
272
|
## Middleware Bawaan
|
|
267
273
|
|
|
@@ -278,8 +284,8 @@ app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
|
|
|
278
284
|
Perilaku:
|
|
279
285
|
|
|
280
286
|
- Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
|
|
281
|
-
- Body melebihi `limit` menghasilkan `413
|
|
282
|
-
- JSON tidak valid menghasilkan `400
|
|
287
|
+
- Body melebihi `limit` menghasilkan `413` (dicek lewat `Content-Length` dan saat streaming)
|
|
288
|
+
- JSON tidak valid menghasilkan `400`, dengan error asli di `cause`
|
|
283
289
|
- Body kosong menghasilkan `req.body = null`
|
|
284
290
|
- Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
|
|
285
291
|
|
|
@@ -299,7 +305,7 @@ app.use(
|
|
|
299
305
|
|
|
300
306
|
| Opsi | Default | Keterangan |
|
|
301
307
|
| --- | --- | --- |
|
|
302
|
-
| `origin` | `"*"` | `"*"`, satu origin, array origin
|
|
308
|
+
| `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
|
|
303
309
|
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
304
310
|
| `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
|
|
305
311
|
| `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
|
|
@@ -307,11 +313,13 @@ app.use(
|
|
|
307
313
|
|
|
308
314
|
Perilaku:
|
|
309
315
|
|
|
310
|
-
-
|
|
311
|
-
- Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan
|
|
312
|
-
- `Access-Control-Allow-Headers
|
|
313
|
-
-
|
|
314
|
-
-
|
|
316
|
+
- **Preflight** adalah request `OPTIONS` dengan header `Access-Control-Request-Method`. Preflight dijawab langsung dengan `204` dan chain berhenti. `OPTIONS` biasa diteruskan ke handler berikutnya
|
|
317
|
+
- Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan dan header `Vary: Origin` ditambahkan
|
|
318
|
+
- `Access-Control-Allow-Headers`: jika `headers` berupa array, header hanya dikirim bila **semua** header yang diminta ada di daftar
|
|
319
|
+
- `Access-Control-Allow-Methods`: jika `methods` berupa array, header hanya dikirim bila method yang diminta ada di daftar
|
|
320
|
+
- Jika `headers` atau `methods` tidak diisi, nilai yang diminta di preflight dipantulkan apa adanya
|
|
321
|
+
- `"*"` untuk `headers` dan `methods` hanya dikirim sebagai `*` bila `credentials: false`. Dengan `credentials: true`, nilainya diperlakukan seperti tidak diisi (dipantulkan)
|
|
322
|
+
- Pada request non-preflight, `headers` (jika array atau `"*"`) dikirim sebagai `Access-Control-Expose-Headers`
|
|
315
323
|
|
|
316
324
|
> `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
|
|
317
325
|
|
|
@@ -323,7 +331,7 @@ const app = new App({ cacheSize: 1000, requestTimeout: 5000 });
|
|
|
323
331
|
|
|
324
332
|
| Opsi | Default | Keterangan |
|
|
325
333
|
| --- | --- | --- |
|
|
326
|
-
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. Cache dikosongkan setiap ada route baru didaftarkan |
|
|
334
|
+
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. `0` menonaktifkan cache. Cache dikosongkan setiap ada route baru didaftarkan |
|
|
327
335
|
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
328
336
|
|
|
329
337
|
`Router` hanya menerima `cacheSize`.
|
|
@@ -344,10 +352,10 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
|
|
|
344
352
|
|
|
345
353
|
### `router.find(method, path)`
|
|
346
354
|
|
|
347
|
-
Mencari route tanpa menjalankannya. Hasilnya di-cache. Berguna untuk testing.
|
|
355
|
+
Mencari route tanpa menjalankannya. Hasilnya di-cache dan `params`-nya di-freeze. Berguna untuk testing.
|
|
348
356
|
|
|
349
357
|
```js
|
|
350
|
-
const { params, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
358
|
+
const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
351
359
|
```
|
|
352
360
|
|
|
353
361
|
### `app.listen(...args)`
|
|
@@ -359,6 +367,18 @@ const server = app.listen(0); // port acak
|
|
|
359
367
|
console.log(server.address().port);
|
|
360
368
|
```
|
|
361
369
|
|
|
370
|
+
## Benchmark
|
|
371
|
+
|
|
372
|
+
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
373
|
+
|
|
374
|
+
| | Req/sec (avg) | Latency (avg) |
|
|
375
|
+
| --- | --- | --- |
|
|
376
|
+
| Fastify | 31.475 | 31,27 ms |
|
|
377
|
+
| **@ndiinginc/router** | 29.735 | 33,12 ms |
|
|
378
|
+
| Express | 20.211 | 48,93 ms |
|
|
379
|
+
|
|
380
|
+
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
381
|
+
|
|
362
382
|
## Testing
|
|
363
383
|
|
|
364
384
|
```bash
|
package/package.json
CHANGED
package/src/app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
const http = require("
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const { EMPTY_PARAMS } = require("./util");
|
|
3
|
+
const { Router } = require("./router");
|
|
4
|
+
const { Request } = require("./request");
|
|
5
|
+
const { Response } = require("./response");
|
|
6
6
|
|
|
7
7
|
/**@type {import('http').ServerOptions}*/
|
|
8
8
|
const SERVER_OPTIONS = {
|
|
@@ -11,41 +11,59 @@ const SERVER_OPTIONS = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
class App extends Router {
|
|
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.requestTimeout);
|
|
23
|
-
|
|
14
|
+
async withTimeout(req, res, handlerPromise) {
|
|
24
15
|
let handleAbort;
|
|
25
16
|
const timeoutPromise = new Promise((_, reject) => {
|
|
26
|
-
handleAbort = () => reject(signal.reason);
|
|
27
|
-
signal.addEventListener("abort", handleAbort, { once: true });
|
|
17
|
+
handleAbort = () => reject(req.controller.signal.reason);
|
|
18
|
+
req.controller.signal.addEventListener("abort", handleAbort, { once: true });
|
|
28
19
|
});
|
|
29
20
|
|
|
21
|
+
const timeout = setTimeout(() => {
|
|
22
|
+
res.statusCode = 504;
|
|
23
|
+
req.controller.abort(new Error(http.STATUS_CODES[res.statusCode]));
|
|
24
|
+
}, this.requestTimeout);
|
|
25
|
+
|
|
30
26
|
const promise = handlerPromise().catch((error) => {
|
|
31
|
-
if (!signal.aborted) {
|
|
32
|
-
controller.abort(error);
|
|
27
|
+
if (!req.controller.signal.aborted) {
|
|
28
|
+
req.controller.abort(error);
|
|
29
|
+
} else {
|
|
30
|
+
console.error(error); // swallow error?
|
|
33
31
|
}
|
|
34
32
|
});
|
|
35
33
|
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
try {
|
|
35
|
+
await Promise.race([promise, timeoutPromise]);
|
|
36
|
+
} finally {
|
|
38
37
|
clearTimeout(timeout);
|
|
39
|
-
|
|
38
|
+
req.controller.signal.removeEventListener("abort", handleAbort);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fallback(res, err) {
|
|
43
|
+
if (!res.writableEnded) {
|
|
44
|
+
if (!err) {
|
|
45
|
+
res.statusCode = 404;
|
|
46
|
+
err = new Error(http.STATUS_CODES[res.statusCode]);
|
|
47
|
+
}
|
|
48
|
+
if (res.statusCode < 400) {
|
|
49
|
+
res.statusCode = 500;
|
|
50
|
+
}
|
|
51
|
+
const error = { message: err.message };
|
|
52
|
+
if (process.env.NODE_ENV === "development") {
|
|
53
|
+
error.stack = err.stack;
|
|
54
|
+
}
|
|
55
|
+
res.json({ error });
|
|
56
|
+
}
|
|
40
57
|
}
|
|
41
58
|
|
|
42
59
|
/**
|
|
43
|
-
* @param {import('http').IncomingMessage} req
|
|
44
|
-
* @param {import('http').ServerResponse} res
|
|
60
|
+
* @param {import('http').IncomingMessage & import('./request').Request} req
|
|
61
|
+
* @param {import('http').ServerResponse & import('./response').Response} res
|
|
45
62
|
*/
|
|
46
63
|
async handle(req, res) {
|
|
47
|
-
const { params, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
48
|
-
req.params = Object.assign(Object.create(null), params);
|
|
64
|
+
const { params, paramsLength, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
65
|
+
req.params = paramsLength ? Object.assign(Object.create(null), params) : EMPTY_PARAMS;
|
|
66
|
+
|
|
49
67
|
let err;
|
|
50
68
|
try {
|
|
51
69
|
const handlerPromise = async () => {
|
|
@@ -54,8 +72,11 @@ class App extends Router {
|
|
|
54
72
|
await handler(req, res);
|
|
55
73
|
}
|
|
56
74
|
};
|
|
57
|
-
if (this.requestTimeout)
|
|
58
|
-
|
|
75
|
+
if (this.requestTimeout) {
|
|
76
|
+
await this.withTimeout(req, res, handlerPromise);
|
|
77
|
+
} else {
|
|
78
|
+
await handlerPromise();
|
|
79
|
+
}
|
|
59
80
|
} catch (error) {
|
|
60
81
|
err = error;
|
|
61
82
|
try {
|
|
@@ -67,30 +88,18 @@ class App extends Router {
|
|
|
67
88
|
err = new Error(error.message, { cause: err });
|
|
68
89
|
}
|
|
69
90
|
}
|
|
70
|
-
err = this.fallback(res, err);
|
|
71
|
-
}
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
if (!res.writableEnded) {
|
|
75
|
-
if (!err) err = ErrorResponse.status(404);
|
|
76
|
-
res.statusCode = err.statusCode || 500;
|
|
77
|
-
const error = { message: err.message };
|
|
78
|
-
if (process.env.NODE_ENV === "development") {
|
|
79
|
-
error.stack = err.stack;
|
|
80
|
-
}
|
|
81
|
-
res.json({ error });
|
|
82
|
-
}
|
|
83
|
-
return err;
|
|
92
|
+
this.fallback(res, err);
|
|
84
93
|
}
|
|
85
94
|
|
|
86
|
-
listen(...
|
|
95
|
+
listen(...args) {
|
|
87
96
|
const requestListener = (req, res) => this.handle(req, res);
|
|
88
97
|
const server = http.createServer(SERVER_OPTIONS, requestListener);
|
|
89
|
-
server.listen(...
|
|
98
|
+
server.listen(...args);
|
|
90
99
|
return server;
|
|
91
100
|
}
|
|
92
101
|
}
|
|
93
102
|
|
|
94
|
-
App.SERVER_OPTIONS
|
|
103
|
+
App.SERVER_OPTIONS=SERVER_OPTIONS
|
|
95
104
|
|
|
96
105
|
exports.App = App;
|
package/src/cache.js
CHANGED
|
@@ -13,6 +13,7 @@ class Cache {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
set(key, value) {
|
|
16
|
+
if (this.size <= 0) return; // disabled
|
|
16
17
|
if (this.map.has(key)) this.map.delete(key);
|
|
17
18
|
else if (this.map.size >= this.size) {
|
|
18
19
|
this.map.delete(this.map.keys().next().value);
|
|
@@ -24,5 +25,4 @@ class Cache {
|
|
|
24
25
|
this.map.clear();
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
|
-
|
|
28
28
|
exports.Cache = Cache;
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
require("./polyfill
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
1
|
+
require("./polyfill");
|
|
2
|
+
const { App } = require("./app");
|
|
3
|
+
const { Router } = require("./router");
|
|
4
|
+
const { json } = require("./middleware/json");
|
|
5
|
+
const { cors } = require("./middleware/cors");
|
|
6
6
|
|
|
7
7
|
App.Router = Router;
|
|
8
8
|
App.json = json;
|
package/src/middleware/cors.js
CHANGED
|
@@ -1,89 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef CORSOptions
|
|
3
|
-
* @property {String} [origin="*"]
|
|
3
|
+
* @property {String|Array|Null} [origin="*"]
|
|
4
4
|
* @property {Boolean} [credentials=false]
|
|
5
|
-
* @property {String|Array} headers
|
|
5
|
+
* @property {String|Array} [headers]
|
|
6
6
|
* @property {Number} [maxAge=0]
|
|
7
|
-
* @property {String|Array} methods
|
|
7
|
+
* @property {String|Array} [methods]
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* @param {CORSOptions} options
|
|
12
12
|
*/
|
|
13
13
|
function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
|
|
14
|
-
if (
|
|
15
|
-
headers = headers.map((
|
|
14
|
+
if (Array.isArray(headers)) {
|
|
15
|
+
headers = headers.map((h) => h.toLowerCase());
|
|
16
16
|
}
|
|
17
|
-
if (
|
|
18
|
-
methods = methods.map((
|
|
17
|
+
if (Array.isArray(methods)) {
|
|
18
|
+
methods = methods.map((m) => m.toUpperCase());
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* @param {import('http').ServerResponse} res
|
|
23
|
-
*/
|
|
24
21
|
function setAllowCredentials(res) {
|
|
25
22
|
if (credentials) {
|
|
26
23
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
27
24
|
}
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
function setAllowOrigin(req, res) {
|
|
28
|
+
const reqOrigin = req.headers["origin"];
|
|
29
|
+
let value;
|
|
30
|
+
let explicit = false;
|
|
31
|
+
|
|
32
|
+
if (origin === "*" && !credentials) {
|
|
33
|
+
value = "*";
|
|
34
|
+
} else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.includes(reqOrigin))) {
|
|
35
|
+
value = reqOrigin;
|
|
36
|
+
explicit = true;
|
|
37
|
+
}
|
|
38
|
+
// origin === null -> skip (jangan set "null", celah keamanan)
|
|
39
|
+
|
|
40
|
+
if (value) res.setHeader("Access-Control-Allow-Origin", value);
|
|
41
|
+
if (explicit) res.appendHeader("Vary", "Origin");
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
function setAllowHeaders(req, res) {
|
|
35
|
-
|
|
45
|
+
const reqHeaders = req.headers["access-control-request-headers"];
|
|
36
46
|
if (!reqHeaders) return;
|
|
37
|
-
|
|
47
|
+
|
|
48
|
+
const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
|
|
49
|
+
|
|
38
50
|
let value;
|
|
39
51
|
if (headers === "*" && !credentials) {
|
|
40
52
|
value = "*";
|
|
41
|
-
} else if (Array.isArray(headers)
|
|
42
|
-
|
|
53
|
+
} else if (Array.isArray(headers)) {
|
|
54
|
+
const allowed = requested.filter((h) => headers.includes(h));
|
|
55
|
+
if (allowed.length === requested.length) value = headers;
|
|
56
|
+
} else {
|
|
57
|
+
// headers undefined -> echo request headers (wajib per spec)
|
|
58
|
+
value = requested;
|
|
43
59
|
}
|
|
44
60
|
if (value) res.setHeader("Access-Control-Allow-Headers", value);
|
|
45
61
|
}
|
|
46
62
|
|
|
47
|
-
/**
|
|
48
|
-
* @param {import('http').IncomingMessage} req
|
|
49
|
-
* @param {import('http').ServerResponse} res
|
|
50
|
-
*/
|
|
51
63
|
function setAllowMethods(req, res) {
|
|
52
64
|
const reqMethod = req.headers["access-control-request-method"];
|
|
53
65
|
if (!reqMethod) return;
|
|
54
|
-
let value;
|
|
55
|
-
if (methods === "*" && !credentials) {
|
|
56
|
-
value = "*";
|
|
57
|
-
} else if (Array.isArray(methods) && methods.some((method) => method === reqMethod.toUpperCase())) {
|
|
58
|
-
value = methods;
|
|
59
|
-
}
|
|
60
|
-
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
61
|
-
}
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
* @param {import('http').IncomingMessage} req
|
|
65
|
-
* @param {import('http').ServerResponse} res
|
|
66
|
-
*/
|
|
67
|
-
function setAllowOrigin(req, res, preflight = true) {
|
|
68
|
-
const reqOrigin = req.headers["origin"];
|
|
67
|
+
const method = reqMethod.toUpperCase();
|
|
69
68
|
|
|
70
69
|
let value;
|
|
71
|
-
|
|
72
|
-
if (origin === "*" && !credentials) {
|
|
70
|
+
if (methods === "*" && !credentials) {
|
|
73
71
|
value = "*";
|
|
74
|
-
} else if (
|
|
75
|
-
value =
|
|
76
|
-
} else
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
} else if (Array.isArray(methods)) {
|
|
73
|
+
if (methods.includes(method)) value = methods;
|
|
74
|
+
} else {
|
|
75
|
+
// methods undefined -> echo request method
|
|
76
|
+
value = [method];
|
|
79
77
|
}
|
|
80
|
-
if (value) res.setHeader("Access-Control-Allow-
|
|
81
|
-
if (!preflight && explicit) res.appendHeader("Vary", "Origin");
|
|
78
|
+
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
82
79
|
}
|
|
83
80
|
|
|
84
|
-
/**
|
|
85
|
-
* @param {import('http').ServerResponse} res
|
|
86
|
-
*/
|
|
87
81
|
function setExposeHeaders(res) {
|
|
88
82
|
let value;
|
|
89
83
|
if (headers === "*" && !credentials) {
|
|
@@ -94,22 +88,17 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
94
88
|
if (value) res.setHeader("Access-Control-Expose-Headers", value);
|
|
95
89
|
}
|
|
96
90
|
|
|
97
|
-
/**
|
|
98
|
-
* @param {import('http').ServerResponse} res
|
|
99
|
-
*/
|
|
100
91
|
function setMaxAge(res) {
|
|
101
92
|
if (maxAge > 0) {
|
|
102
93
|
res.setHeader("Access-Control-Max-Age", maxAge);
|
|
103
94
|
}
|
|
104
95
|
}
|
|
105
96
|
|
|
106
|
-
/**
|
|
107
|
-
* @param {import('http').IncomingMessage} req
|
|
108
|
-
* @param {import('http').ServerResponse} res
|
|
109
|
-
*/
|
|
110
97
|
return (req, res) => {
|
|
111
98
|
setAllowCredentials(res);
|
|
112
|
-
|
|
99
|
+
|
|
100
|
+
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
101
|
+
// preflight
|
|
113
102
|
setAllowOrigin(req, res);
|
|
114
103
|
setAllowHeaders(req, res);
|
|
115
104
|
setAllowMethods(req, res);
|
|
@@ -118,9 +107,11 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
118
107
|
res.setHeader("Content-Length", "0");
|
|
119
108
|
res.end();
|
|
120
109
|
} else {
|
|
121
|
-
|
|
110
|
+
// normal response (termasuk OPTIONS non-preflight)
|
|
111
|
+
setAllowOrigin(req, res);
|
|
122
112
|
setExposeHeaders(res);
|
|
123
113
|
}
|
|
124
114
|
};
|
|
125
115
|
}
|
|
116
|
+
|
|
126
117
|
exports.cors = cors;
|
package/src/middleware/json.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
const http = require("
|
|
2
|
-
const { ErrorResponse } = require("../error.js");
|
|
1
|
+
const http = require("http");
|
|
3
2
|
|
|
4
3
|
function json({ limit = 100 * 1024 } = {}) {
|
|
5
4
|
const reviver = (key, value) => {
|
|
@@ -18,14 +17,16 @@ function json({ limit = 100 * 1024 } = {}) {
|
|
|
18
17
|
}
|
|
19
18
|
const contentLength = parseInt(req.headers["content-length"], 10);
|
|
20
19
|
if (contentLength && contentLength > limit) {
|
|
21
|
-
|
|
20
|
+
res.statusCode = 413;
|
|
21
|
+
throw new Error(http.STATUS_CODES[res.statusCode]);
|
|
22
22
|
}
|
|
23
23
|
let chunkLength = 0;
|
|
24
24
|
const chunks = [];
|
|
25
25
|
for await (const chunk of req) {
|
|
26
26
|
chunkLength += chunk.length;
|
|
27
27
|
if (chunkLength > limit) {
|
|
28
|
-
|
|
28
|
+
res.statusCode = 413;
|
|
29
|
+
throw new Error(http.STATUS_CODES[res.statusCode]);
|
|
29
30
|
}
|
|
30
31
|
chunks.push(chunk);
|
|
31
32
|
}
|
|
@@ -36,9 +37,9 @@ function json({ limit = 100 * 1024 } = {}) {
|
|
|
36
37
|
try {
|
|
37
38
|
req.body = JSON.parse(Buffer.concat(chunks), reviver);
|
|
38
39
|
} catch (error) {
|
|
39
|
-
|
|
40
|
+
res.statusCode = 400;
|
|
41
|
+
throw new Error(http.STATUS_CODES[res.statusCode], { cause: error });
|
|
40
42
|
}
|
|
41
43
|
};
|
|
42
44
|
}
|
|
43
|
-
|
|
44
45
|
exports.json = json;
|
package/src/node.js
CHANGED
|
@@ -1,41 +1,32 @@
|
|
|
1
1
|
class Node {
|
|
2
2
|
constructor(segment = "") {
|
|
3
3
|
this.segment = segment;
|
|
4
|
-
|
|
5
|
-
this.
|
|
6
|
-
|
|
7
|
-
this.
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
/** @type {Map<string, Node>} static children */
|
|
5
|
+
this.children = new Map();
|
|
6
|
+
/** @type {Map<string, Node>} param children, key = ':name' */
|
|
7
|
+
this.params = new Map();
|
|
8
|
+
/** @type {Node | null} wildcard child */
|
|
9
|
+
this.wildcard = null;
|
|
10
10
|
this.handlers = new Map();
|
|
11
11
|
}
|
|
12
|
-
|
|
13
|
-
* @param {String} segment
|
|
14
|
-
* @returns {Node}
|
|
15
|
-
*/
|
|
12
|
+
|
|
16
13
|
add(segment) {
|
|
14
|
+
if (segment === "*") {
|
|
15
|
+
if (!this.wildcard) this.wildcard = new Node("*");
|
|
16
|
+
return this.wildcard;
|
|
17
|
+
}
|
|
18
|
+
|
|
17
19
|
if (segment.startsWith(":")) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
this.paramChild.paramName = segment.slice(1);
|
|
21
|
-
this.paramChild.type = "param";
|
|
22
|
-
return this.paramChild;
|
|
23
|
-
} else if (segment.startsWith("*")) {
|
|
24
|
-
if (this.wildcardChild) return this.wildcardChild;
|
|
25
|
-
this.wildcardChild = new Node(segment);
|
|
26
|
-
this.wildcardChild.type = "wildcard";
|
|
27
|
-
return this.wildcardChild;
|
|
28
|
-
} else {
|
|
29
|
-
return this.staticChildren.getOrInsertComputed(segment, (segment) => {
|
|
30
|
-
const node = new Node(segment);
|
|
31
|
-
node.type = "wildcard";
|
|
32
|
-
return node;
|
|
20
|
+
return this.params.getOrInsertComputed(segment, (segment) => {
|
|
21
|
+
return new Node(segment);
|
|
33
22
|
});
|
|
34
23
|
}
|
|
24
|
+
|
|
25
|
+
return this.children.getOrInsertComputed(segment, (segment) => {
|
|
26
|
+
return new Node(segment);
|
|
27
|
+
});
|
|
35
28
|
}
|
|
36
|
-
|
|
37
|
-
* @param {Node} node
|
|
38
|
-
*/
|
|
29
|
+
|
|
39
30
|
merge(node, offset) {
|
|
40
31
|
for (const [method, entries] of node.handlers) {
|
|
41
32
|
for (const entry of entries) {
|
|
@@ -46,37 +37,41 @@ class Node {
|
|
|
46
37
|
}
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
if (node.
|
|
50
|
-
this.add(
|
|
40
|
+
if (node.wildcard) {
|
|
41
|
+
this.add("*").merge(node.wildcard, offset);
|
|
51
42
|
}
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
|
|
44
|
+
for (const [segment, child] of node.params) {
|
|
45
|
+
this.add(segment).merge(child, offset);
|
|
54
46
|
}
|
|
55
|
-
|
|
47
|
+
|
|
48
|
+
for (const [segment, child] of node.children) {
|
|
56
49
|
this.add(segment).merge(child, offset);
|
|
57
50
|
}
|
|
58
51
|
}
|
|
59
52
|
|
|
60
53
|
getMaxSeq() {
|
|
61
54
|
let max = -1;
|
|
62
|
-
|
|
55
|
+
|
|
56
|
+
for (const [, entries] of this.handlers) {
|
|
63
57
|
for (const entry of entries) {
|
|
64
58
|
max = Math.max(max, entry.seq);
|
|
65
59
|
}
|
|
66
60
|
}
|
|
67
61
|
|
|
68
|
-
if (this.
|
|
69
|
-
max = Math.max(max, this.
|
|
62
|
+
if (this.wildcard) {
|
|
63
|
+
max = Math.max(max, this.wildcard.getMaxSeq());
|
|
70
64
|
}
|
|
71
|
-
|
|
72
|
-
|
|
65
|
+
|
|
66
|
+
for (const [, child] of this.params) {
|
|
67
|
+
max = Math.max(max, child.getMaxSeq());
|
|
73
68
|
}
|
|
74
|
-
|
|
69
|
+
|
|
70
|
+
for (const [, child] of this.children) {
|
|
75
71
|
max = Math.max(max, child.getMaxSeq());
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
return max;
|
|
79
75
|
}
|
|
80
76
|
}
|
|
81
|
-
|
|
82
77
|
exports.Node = Node;
|
package/src/request.js
CHANGED
|
@@ -1,32 +1,37 @@
|
|
|
1
|
-
const http = require("
|
|
2
|
-
const { parseUrl, parseQueryString } = require("./util
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const { parseUrl, parseQueryString } = require("./util");
|
|
3
3
|
|
|
4
4
|
class Request extends http.IncomingMessage {
|
|
5
5
|
_path;
|
|
6
|
-
|
|
6
|
+
_queryString;
|
|
7
7
|
_query;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
_controller;
|
|
9
|
+
|
|
10
10
|
get path() {
|
|
11
|
-
if (this._path)
|
|
12
|
-
|
|
13
|
-
this._path =
|
|
14
|
-
|
|
11
|
+
if (!this._path) {
|
|
12
|
+
const { path, queryString } = parseUrl(this.url);
|
|
13
|
+
this._path = path;
|
|
14
|
+
this._queryString = queryString;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
|
-
this._path = this._parsed.path;
|
|
16
|
+
|
|
18
17
|
return this._path;
|
|
19
18
|
}
|
|
20
19
|
|
|
21
20
|
get query() {
|
|
22
|
-
if (this._query)
|
|
23
|
-
|
|
24
|
-
this._query = Object.create(null);
|
|
25
|
-
return this._query;
|
|
21
|
+
if (!this._query) {
|
|
22
|
+
this._query = parseQueryString(this._queryString);
|
|
26
23
|
}
|
|
27
|
-
this._query = parseQueryString(this._parsed.queryString);
|
|
28
24
|
return this._query;
|
|
29
25
|
}
|
|
30
|
-
}
|
|
31
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @returns {AbortController}
|
|
29
|
+
*/
|
|
30
|
+
get controller() {
|
|
31
|
+
if (!this._controller) {
|
|
32
|
+
this._controller = new AbortController();
|
|
33
|
+
}
|
|
34
|
+
return this._controller;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
32
37
|
exports.Request = Request;
|
package/src/response.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
|
-
const http = require("
|
|
1
|
+
const http = require("http");
|
|
2
2
|
|
|
3
3
|
class Response extends http.ServerResponse {
|
|
4
|
-
status(
|
|
5
|
-
this.statusCode =
|
|
4
|
+
status(code) {
|
|
5
|
+
this.statusCode = code;
|
|
6
6
|
return this;
|
|
7
7
|
}
|
|
8
8
|
send(chunk) {
|
|
9
9
|
if (this.writableEnded) return;
|
|
10
|
-
this.end(chunk, null, null);
|
|
10
|
+
this.end(chunk, null, null);
|
|
11
11
|
}
|
|
12
12
|
json(value) {
|
|
13
|
-
if (this.
|
|
14
|
-
|
|
15
|
-
this.end();
|
|
16
|
-
return;
|
|
13
|
+
if (!this.headersSent) {
|
|
14
|
+
this.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
17
15
|
}
|
|
18
|
-
this.
|
|
19
|
-
this.end(JSON.stringify(value), null, null); //avoid ArgumentsAdaptorTrampoline from V8
|
|
16
|
+
this.send(JSON.stringify(value));
|
|
20
17
|
}
|
|
21
18
|
}
|
|
22
19
|
exports.Response = Response;
|
package/src/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const {
|
|
2
|
-
const {
|
|
3
|
-
const {
|
|
1
|
+
const { decodeParam } = require("./util");
|
|
2
|
+
const { Node } = require("./node");
|
|
3
|
+
const { Cache } = require("./cache");
|
|
4
4
|
|
|
5
5
|
class Router {
|
|
6
6
|
constructor(options = {}) {
|
|
@@ -22,10 +22,12 @@ class Router {
|
|
|
22
22
|
handlers = [path, ...handlers];
|
|
23
23
|
path = null;
|
|
24
24
|
}
|
|
25
|
+
|
|
25
26
|
let node = this.root;
|
|
27
|
+
|
|
26
28
|
if (path) {
|
|
27
|
-
for (const
|
|
28
|
-
node = node.add(
|
|
29
|
+
for (const segment of this.split(path)) {
|
|
30
|
+
node = node.add(segment);
|
|
29
31
|
}
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -34,8 +36,8 @@ class Router {
|
|
|
34
36
|
node.merge(handler.root, this.seq);
|
|
35
37
|
this.seq += handler.root.getMaxSeq() + 1;
|
|
36
38
|
} else {
|
|
37
|
-
const
|
|
38
|
-
node.handlers.getOrInsert(
|
|
39
|
+
const key = handler.length === 3 ? "CATCH" : method;
|
|
40
|
+
node.handlers.getOrInsert(key, []).push({
|
|
39
41
|
handler,
|
|
40
42
|
seq: this.seq++,
|
|
41
43
|
});
|
|
@@ -98,96 +100,127 @@ class Router {
|
|
|
98
100
|
return this;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
collectMiddleware(node, handlers, errorHandlers) {
|
|
102
|
-
if (node.handlers.has("ALL")) {
|
|
103
|
-
for (const entry of node.handlers.get("ALL")) {
|
|
104
|
-
handlers[entry.seq] = entry.handler;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (node.handlers.has("CATCH")) {
|
|
108
|
-
for (const entry of node.handlers.get("CATCH")) {
|
|
109
|
-
errorHandlers[entry.seq] = entry.handler;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
collectHandler(node, method, handlers) {
|
|
115
|
-
if (node && node.handlers.has(method)) {
|
|
116
|
-
for (const entry of node.handlers.get(method)) {
|
|
117
|
-
handlers[entry.seq] = entry.handler;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
103
|
/**
|
|
123
104
|
* @param {Node} node
|
|
124
|
-
* @param {
|
|
125
|
-
* @param {
|
|
126
|
-
* @param {
|
|
127
|
-
* @param {
|
|
128
|
-
* @
|
|
105
|
+
* @param {string[]} segments
|
|
106
|
+
* @param {number} index
|
|
107
|
+
* @param {Node[]} path
|
|
108
|
+
* @param {Record<string, string>} params
|
|
109
|
+
* @param {{ best: { nodes: Node[], params: Record<string, string> } }} state
|
|
110
|
+
* @returns {{ nodes: Node[], params: Record<string, string>, matched: boolean }}
|
|
129
111
|
*/
|
|
130
|
-
search(node,
|
|
131
|
-
if (!node) return null;
|
|
112
|
+
search(node, segments, index, path, params, state) {
|
|
132
113
|
path.push(node);
|
|
133
|
-
if (i === parts.length) return path;
|
|
134
114
|
|
|
135
|
-
|
|
115
|
+
// habis segmen -> full match
|
|
116
|
+
if (index === segments.length) {
|
|
117
|
+
return { nodes: path.slice(), params: { ...params }, matched: true };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const segment = segments[index];
|
|
121
|
+
|
|
122
|
+
// 1. static (prioritas tertinggi)
|
|
123
|
+
const staticChild = node.children.get(segment);
|
|
124
|
+
if (staticChild) {
|
|
125
|
+
const res = this.search(staticChild, segments, index + 1, path, params, state);
|
|
126
|
+
if (res.matched) return res;
|
|
127
|
+
}
|
|
136
128
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
129
|
+
// 2. semua param child (bisa lebih dari satu)
|
|
130
|
+
for (const [key, child] of node.params) {
|
|
131
|
+
const name = key.slice(1); // buang ':'
|
|
132
|
+
const prev = params[name];
|
|
133
|
+
params[name] = decodeParam(segment);
|
|
134
|
+
const res = this.search(child, segments, index + 1, path, params, state);
|
|
135
|
+
if (res.matched) return res;
|
|
136
|
+
// backtrack param value
|
|
137
|
+
if (prev === undefined) delete params[name];
|
|
138
|
+
else params[name] = prev;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
else params[node.paramChild.paramName] = saved;
|
|
141
|
+
// 3. wildcard (prioritas terendah, menyerap sisa segmen)
|
|
142
|
+
if (node.wildcard) {
|
|
143
|
+
const rest = segments.slice(index).join("/");
|
|
144
|
+
params["wildcard"] = decodeParam(rest);
|
|
145
|
+
path.push(node.wildcard);
|
|
146
|
+
return { nodes: path.slice(), params: { ...params }, matched: true };
|
|
149
147
|
}
|
|
150
148
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
149
|
+
// tidak match di sini -> update partial terpanjang
|
|
150
|
+
// path.length > state.best.nodes.length artinya kita sudah lebih dalam
|
|
151
|
+
if (path.length > state.best.nodes.length) {
|
|
152
|
+
state.best = { nodes: path.slice(), params: { ...params } };
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
path.pop();
|
|
158
|
-
return
|
|
156
|
+
return { matched: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
collectHandler(r, method, handlers) {
|
|
160
|
+
if (r.handlers.has(method)) {
|
|
161
|
+
for (const entry of r.handlers.get(method)) {
|
|
162
|
+
handlers[entry.seq] = entry.handler;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
collectErrorHandler(r, errorHandlers) {
|
|
168
|
+
if (r.handlers.has("CATCH")) {
|
|
169
|
+
for (const entry of r.handlers.get("CATCH")) {
|
|
170
|
+
errorHandlers[entry.seq] = entry.handler;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
collectMiddleware(r, handlers, errorHandlers) {
|
|
176
|
+
this.collectHandler(r, "ALL", handlers);
|
|
177
|
+
this.collectErrorHandler(r, errorHandlers);
|
|
159
178
|
}
|
|
160
179
|
|
|
161
180
|
find(method, path) {
|
|
162
181
|
const key = `${method} ${path}`;
|
|
182
|
+
|
|
163
183
|
const cached = this.cache.get(key);
|
|
164
184
|
if (cached) return cached;
|
|
165
185
|
|
|
166
|
-
const
|
|
167
|
-
const params = {};
|
|
168
|
-
const nodePath = [];
|
|
169
|
-
const result = this.search(this.root, parts, 0, params, nodePath);
|
|
186
|
+
const segments = this.split(path);
|
|
170
187
|
|
|
188
|
+
const params = {};
|
|
171
189
|
const handlers = {};
|
|
172
190
|
const errorHandlers = {};
|
|
173
191
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
192
|
+
/** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
|
|
193
|
+
const state = { best: { nodes: [], params: {} } };
|
|
194
|
+
|
|
195
|
+
const res = this.search(this.root, segments, 0, [], params, state);
|
|
196
|
+
|
|
197
|
+
const matchedNodes = res.matched ? res.nodes : state.best.nodes;
|
|
198
|
+
const finalParams = res.matched ? res.params : state.best.params;
|
|
199
|
+
|
|
200
|
+
// middleware dari semua node di jalur (partial atau full)
|
|
201
|
+
for (const r of matchedNodes) {
|
|
202
|
+
this.collectMiddleware(r, handlers, errorHandlers);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// method handler HANYA kalau full match
|
|
206
|
+
if (res.matched) {
|
|
207
|
+
const leaf = matchedNodes[matchedNodes.length - 1];
|
|
208
|
+
this.collectHandler(leaf, method, handlers);
|
|
181
209
|
}
|
|
182
210
|
|
|
183
|
-
|
|
184
|
-
|
|
211
|
+
Object.freeze(finalParams);
|
|
212
|
+
|
|
213
|
+
const result = {
|
|
214
|
+
matched: res.matched,
|
|
215
|
+
params: finalParams,
|
|
216
|
+
paramsLength: Object.keys(finalParams).length,
|
|
185
217
|
handlers: Object.values(handlers),
|
|
186
218
|
errorHandlers: Object.values(errorHandlers),
|
|
187
219
|
};
|
|
188
|
-
this.cache.set(key, value);
|
|
189
220
|
|
|
190
|
-
|
|
221
|
+
this.cache.set(key, result);
|
|
222
|
+
|
|
223
|
+
return result;
|
|
191
224
|
}
|
|
192
225
|
}
|
|
193
226
|
exports.Router = Router;
|
package/src/util.js
CHANGED
|
@@ -1,46 +1,48 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const EMPTY_PARAMS = Object.freeze(Object.create(null));
|
|
2
|
+
exports.EMPTY_PARAMS = EMPTY_PARAMS;
|
|
3
|
+
|
|
4
|
+
function decodeParam(raw) {
|
|
3
5
|
try {
|
|
4
|
-
return decodeURIComponent(
|
|
6
|
+
return decodeURIComponent(raw);
|
|
5
7
|
} catch (error) {
|
|
6
|
-
return
|
|
8
|
+
return raw;
|
|
7
9
|
}
|
|
8
10
|
}
|
|
9
|
-
exports.
|
|
11
|
+
exports.decodeParam = decodeParam;
|
|
10
12
|
|
|
11
|
-
function
|
|
13
|
+
function decodeQuery(raw) {
|
|
14
|
+
const normalized = raw.replace(/\+/g, " ");
|
|
12
15
|
try {
|
|
13
|
-
return decodeURIComponent(
|
|
14
|
-
} catch {
|
|
15
|
-
return
|
|
16
|
+
return decodeURIComponent(normalized);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return normalized;
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
exports.decodePathParam = decodePathParam;
|
|
20
|
-
|
|
21
22
|
function parseQueryString(queryString) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const regex = /([^=&]*)?=([^&]*)?/g;
|
|
23
|
+
if (queryString === "") return Object.create(null);
|
|
24
|
+
const regex = /([^=&]*)=([^&]*)/g;
|
|
25
25
|
let match;
|
|
26
|
+
const result = Object.create(null);
|
|
26
27
|
while ((match = regex.exec(queryString))) {
|
|
27
|
-
const [, rawName
|
|
28
|
-
const name =
|
|
29
|
-
const value =
|
|
30
|
-
if (Object.hasOwn(
|
|
31
|
-
if (Array.isArray(
|
|
32
|
-
else
|
|
33
|
-
} else
|
|
34
|
-
query[name] = value;
|
|
35
|
-
}
|
|
28
|
+
const [, rawName, rawValue] = match;
|
|
29
|
+
const name = decodeQuery(rawName);
|
|
30
|
+
const value = decodeQuery(rawValue);
|
|
31
|
+
if (Object.hasOwn(result, name)) {
|
|
32
|
+
if (Array.isArray(result[name])) result[name].push(value);
|
|
33
|
+
else result[name] = [result[name], value];
|
|
34
|
+
} else result[name] = value;
|
|
36
35
|
}
|
|
37
|
-
return
|
|
36
|
+
return result;
|
|
38
37
|
}
|
|
39
38
|
exports.parseQueryString = parseQueryString;
|
|
40
39
|
|
|
41
40
|
function parseUrl(url) {
|
|
41
|
+
if (!(url.includes("?") || url.includes("#"))) {
|
|
42
|
+
return { path: url, queryString: "" };
|
|
43
|
+
}
|
|
42
44
|
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
43
45
|
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
44
|
-
return { path
|
|
46
|
+
return { path, queryString };
|
|
45
47
|
}
|
|
46
48
|
exports.parseUrl = parseUrl;
|
package/src/error.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
const http = require("node:http");
|
|
2
|
-
|
|
3
|
-
// Client error responses (400 – 499)
|
|
4
|
-
// Server error responses (500 – 599)
|
|
5
|
-
class ErrorResponse extends Error {
|
|
6
|
-
constructor({ statusCode, message } = {}, options) {
|
|
7
|
-
super(message, options);
|
|
8
|
-
this.name = this.constructor.name;
|
|
9
|
-
if (Error.captureStackTrace) {
|
|
10
|
-
Error.captureStackTrace(this, this.constructor);
|
|
11
|
-
}
|
|
12
|
-
this.statusCode = statusCode;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
static status(statusCode, options) {
|
|
16
|
-
return new ErrorResponse(
|
|
17
|
-
{
|
|
18
|
-
statusCode,
|
|
19
|
-
message: http.STATUS_CODES[statusCode],
|
|
20
|
-
},
|
|
21
|
-
options,
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
exports.ErrorResponse = ErrorResponse;
|