@ndiinginc/router 4.7.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 +74 -59
- package/package.json +2 -2
- package/src/app.js +54 -40
- package/src/cache.js +6 -2
- package/src/index.js +5 -5
- package/src/middleware/cors.js +49 -65
- package/src/middleware/json.js +7 -6
- package/src/node.js +46 -17
- package/src/request.js +22 -17
- package/src/response.js +7 -10
- package/src/router.js +113 -53
- package/src/util.js +24 -18
- package/src/error.js +0 -26
- package/src/pattern.js +0 -34
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsu
|
|
|
5
5
|
## Fitur
|
|
6
6
|
|
|
7
7
|
- Zero dependency, murni `node:http`
|
|
8
|
-
- Routing berbasis tree per segmen
|
|
9
|
-
- Parameter dinamis `:id` dan wildcard `*`
|
|
8
|
+
- Routing berbasis tree per segmen: static, lalu `:param`, lalu wildcard `*`, dengan backtracking
|
|
9
|
+
- Parameter dinamis `:id` dan wildcard `*` yang menangkap sisa path
|
|
10
10
|
- LRU cache untuk hasil lookup route
|
|
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()`
|
|
@@ -82,7 +82,7 @@ app.get("/profile", authenticate, loadUser, (req, res) => {
|
|
|
82
82
|
|
|
83
83
|
### Parameter dinamis
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
Segmen yang diawali `:` adalah parameter. Nilainya otomatis di-decode dengan `decodeURIComponent`; jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
|
|
86
86
|
|
|
87
87
|
```js
|
|
88
88
|
app.get("/posts/:postId/comments/:commentId", (req, res) => {
|
|
@@ -91,20 +91,34 @@ app.get("/posts/:postId/comments/:commentId", (req, res) => {
|
|
|
91
91
|
});
|
|
92
92
|
```
|
|
93
93
|
|
|
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
|
+
|
|
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
|
+
```
|
|
103
|
+
|
|
94
104
|
### Wildcard
|
|
95
105
|
|
|
96
|
-
`*`
|
|
106
|
+
Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
|
|
97
107
|
|
|
98
108
|
```js
|
|
99
|
-
app.get("/files
|
|
100
|
-
res.json({
|
|
109
|
+
app.get("/files/*", (req, res) => {
|
|
110
|
+
res.json({ file: req.params.wildcard });
|
|
101
111
|
});
|
|
112
|
+
|
|
113
|
+
// GET /files/images/logo.png -> { file: "images/logo.png" }
|
|
102
114
|
```
|
|
103
115
|
|
|
116
|
+
Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `/files`.
|
|
117
|
+
|
|
104
118
|
### Aturan matching
|
|
105
119
|
|
|
106
|
-
-
|
|
107
|
-
- Jika
|
|
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.
|
|
108
122
|
- Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
|
|
109
123
|
|
|
110
124
|
## Middleware
|
|
@@ -127,7 +141,7 @@ app.use("/admin", async (req, res) => {
|
|
|
127
141
|
|
|
128
142
|
Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
|
|
129
143
|
|
|
130
|
-
>
|
|
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.
|
|
131
145
|
|
|
132
146
|
## Nested Router
|
|
133
147
|
|
|
@@ -156,32 +170,32 @@ app.use("/admin", admin);
|
|
|
156
170
|
|
|
157
171
|
Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
|
|
158
172
|
|
|
159
|
-
|
|
160
|
-
app.get("/boom", () => {
|
|
161
|
-
throw new Error("Something broke");
|
|
162
|
-
});
|
|
173
|
+
> Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
|
|
163
174
|
|
|
164
|
-
|
|
165
|
-
|
|
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");
|
|
166
183
|
});
|
|
167
184
|
```
|
|
168
185
|
|
|
169
|
-
|
|
186
|
+
Jika `res.statusCode` masih di bawah 400 saat error ditangani, hasilnya `500`. Properti `statusCode` pada objek error tidak dipakai.
|
|
170
187
|
|
|
171
|
-
###
|
|
188
|
+
### Error handler sendiri
|
|
172
189
|
|
|
173
|
-
|
|
190
|
+
Di error handler, `res.statusCode` masih bernilai awal (`200`) bila error dilempar tanpa mengatur status, jadi atur sendiri:
|
|
174
191
|
|
|
175
192
|
```js
|
|
176
|
-
app.
|
|
177
|
-
const
|
|
178
|
-
err.
|
|
179
|
-
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 } });
|
|
180
196
|
});
|
|
181
197
|
```
|
|
182
198
|
|
|
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
|
-
|
|
185
199
|
### Respons default
|
|
186
200
|
|
|
187
201
|
Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
@@ -191,44 +205,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
191
205
|
```
|
|
192
206
|
|
|
193
207
|
- Tidak ada route cocok: `404 Not Found`
|
|
194
|
-
-
|
|
195
|
-
-
|
|
208
|
+
- `res.statusCode` sudah `>= 400`: dipakai apa adanya
|
|
209
|
+
- `res.statusCode` masih `< 400`: diganti `500`
|
|
196
210
|
- Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
|
|
197
211
|
- Field `stack` hanya disertakan saat `NODE_ENV=development`
|
|
198
212
|
|
|
199
213
|
## Timeout
|
|
200
214
|
|
|
201
|
-
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.
|
|
202
216
|
|
|
203
217
|
```js
|
|
204
218
|
const app = new App({ requestTimeout: 5000 });
|
|
205
219
|
```
|
|
206
220
|
|
|
207
221
|
- Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
|
|
208
|
-
- 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`
|
|
209
223
|
|
|
210
|
-
### `req.
|
|
224
|
+
### `req.controller`
|
|
211
225
|
|
|
212
|
-
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:
|
|
213
227
|
|
|
214
228
|
```js
|
|
215
229
|
app.get("/proxy", async (req, res) => {
|
|
216
|
-
const upstream = await fetch("https://example.com/api", {
|
|
230
|
+
const upstream = await fetch("https://example.com/api", {
|
|
231
|
+
signal: req.controller.signal,
|
|
232
|
+
});
|
|
217
233
|
res.json(await upstream.json());
|
|
218
234
|
});
|
|
219
235
|
```
|
|
220
236
|
|
|
221
|
-
`req.signal` bernilai `undefined` jika `requestTimeout` tidak diaktifkan.
|
|
222
|
-
|
|
223
237
|
## Request
|
|
224
238
|
|
|
225
239
|
| Properti | Keterangan |
|
|
226
240
|
| --- | --- |
|
|
227
241
|
| `req.path` | Pathname tanpa query string |
|
|
228
242
|
| `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) |
|
|
243
|
+
| `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
|
|
230
244
|
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
231
|
-
| `req.
|
|
245
|
+
| `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
|
|
232
246
|
|
|
233
247
|
```js
|
|
234
248
|
// GET /search?tag=a&tag=b&page=2
|
|
@@ -247,13 +261,13 @@ app.get("/search", (req, res) => {
|
|
|
247
261
|
| --- | --- |
|
|
248
262
|
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
|
|
249
263
|
| `res.send(chunk)` | Kirim response dan akhiri |
|
|
250
|
-
| `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 |
|
|
251
265
|
|
|
252
266
|
```js
|
|
253
267
|
res.status(201).json({ created: true });
|
|
254
268
|
```
|
|
255
269
|
|
|
256
|
-
`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.
|
|
257
271
|
|
|
258
272
|
## Middleware Bawaan
|
|
259
273
|
|
|
@@ -270,8 +284,8 @@ app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
|
|
|
270
284
|
Perilaku:
|
|
271
285
|
|
|
272
286
|
- Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
|
|
273
|
-
- Body melebihi `limit` menghasilkan `413
|
|
274
|
-
- 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`
|
|
275
289
|
- Body kosong menghasilkan `req.body = null`
|
|
276
290
|
- Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
|
|
277
291
|
|
|
@@ -280,7 +294,7 @@ Perilaku:
|
|
|
280
294
|
```js
|
|
281
295
|
app.use(
|
|
282
296
|
App.cors({
|
|
283
|
-
origin: "https://example.com",
|
|
297
|
+
origin: ["https://example.com", "https://app.example.com"],
|
|
284
298
|
credentials: true,
|
|
285
299
|
methods: ["GET", "POST", "PUT", "DELETE"],
|
|
286
300
|
headers: ["Content-Type", "Authorization"],
|
|
@@ -291,19 +305,21 @@ app.use(
|
|
|
291
305
|
|
|
292
306
|
| Opsi | Default | Keterangan |
|
|
293
307
|
| --- | --- | --- |
|
|
294
|
-
| `origin` | `"*"` | `"*"`, satu origin,
|
|
308
|
+
| `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
|
|
295
309
|
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
296
|
-
| `headers` | - | `"*"` atau array nama header |
|
|
297
|
-
| `methods` | - | `"*"` atau array nama method |
|
|
310
|
+
| `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
|
|
311
|
+
| `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
|
|
298
312
|
| `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
|
|
299
313
|
|
|
300
314
|
Perilaku:
|
|
301
315
|
|
|
302
|
-
-
|
|
303
|
-
-
|
|
304
|
-
-
|
|
305
|
-
-
|
|
306
|
-
-
|
|
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`
|
|
307
323
|
|
|
308
324
|
> `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
|
|
309
325
|
|
|
@@ -315,8 +331,8 @@ const app = new App({ cacheSize: 1000, requestTimeout: 5000 });
|
|
|
315
331
|
|
|
316
332
|
| Opsi | Default | Keterangan |
|
|
317
333
|
| --- | --- | --- |
|
|
318
|
-
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path` |
|
|
319
|
-
| `requestTimeout` |
|
|
334
|
+
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. `0` menonaktifkan cache. Cache dikosongkan setiap ada route baru didaftarkan |
|
|
335
|
+
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
320
336
|
|
|
321
337
|
`Router` hanya menerima `cacheSize`.
|
|
322
338
|
|
|
@@ -336,10 +352,10 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
|
|
|
336
352
|
|
|
337
353
|
### `router.find(method, path)`
|
|
338
354
|
|
|
339
|
-
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.
|
|
340
356
|
|
|
341
357
|
```js
|
|
342
|
-
const { params, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
358
|
+
const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
343
359
|
```
|
|
344
360
|
|
|
345
361
|
### `app.listen(...args)`
|
|
@@ -355,12 +371,11 @@ console.log(server.address().port);
|
|
|
355
371
|
|
|
356
372
|
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
357
373
|
|
|
358
|
-
|
|
|
359
|
-
|
|
|
360
|
-
|
|
|
361
|
-
|
|
|
362
|
-
|
|
|
363
|
-
|
|
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 |
|
|
364
379
|
|
|
365
380
|
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
366
381
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ndiinginc/router",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"type": "commonjs",
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@ndiinginc/commit": "^1.0.1",
|
|
22
|
-
"@ndiinginc/test": "^1.
|
|
22
|
+
"@ndiinginc/test": "^1.17.0",
|
|
23
23
|
"@types/jest": "^30.0.0",
|
|
24
24
|
"@types/node": "^26.6.5"
|
|
25
25
|
}
|
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,25 +88,18 @@ class App extends Router {
|
|
|
67
88
|
err = new Error(error.message, { cause: err });
|
|
68
89
|
}
|
|
69
90
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
res.statusCode = err.statusCode || 500;
|
|
73
|
-
const error = { message: err.message };
|
|
74
|
-
if (process.env.NODE_ENV === "development") {
|
|
75
|
-
error.stack = err.stack;
|
|
76
|
-
}
|
|
77
|
-
res.json({ error });
|
|
78
|
-
}
|
|
91
|
+
|
|
92
|
+
this.fallback(res, err);
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
listen(...
|
|
95
|
+
listen(...args) {
|
|
82
96
|
const requestListener = (req, res) => this.handle(req, res);
|
|
83
97
|
const server = http.createServer(SERVER_OPTIONS, requestListener);
|
|
84
|
-
server.listen(...
|
|
98
|
+
server.listen(...args);
|
|
85
99
|
return server;
|
|
86
100
|
}
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
App.SERVER_OPTIONS
|
|
103
|
+
App.SERVER_OPTIONS=SERVER_OPTIONS
|
|
90
104
|
|
|
91
105
|
exports.App = App;
|
package/src/cache.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
class Cache {
|
|
2
|
-
constructor(size) {
|
|
2
|
+
constructor(size = 0) {
|
|
3
3
|
this.size = size;
|
|
4
4
|
this.map = new Map();
|
|
5
5
|
}
|
|
@@ -13,12 +13,16 @@ 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);
|
|
19
20
|
}
|
|
20
21
|
this.map.set(key, value);
|
|
21
22
|
}
|
|
22
|
-
}
|
|
23
23
|
|
|
24
|
+
clear() {
|
|
25
|
+
this.map.clear();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
24
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,96 +1,83 @@
|
|
|
1
|
-
function pattern(string) {
|
|
2
|
-
return new RegExp(
|
|
3
|
-
`^(${String(string)
|
|
4
|
-
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
5
|
-
.replace(/ /g, "")
|
|
6
|
-
.replace(/,/g, "|")
|
|
7
|
-
.replace(/\\\*/g, ".*")})$`,
|
|
8
|
-
"i",
|
|
9
|
-
);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
1
|
/**
|
|
13
2
|
* @typedef CORSOptions
|
|
14
|
-
* @property {String} [origin="*"]
|
|
3
|
+
* @property {String|Array|Null} [origin="*"]
|
|
15
4
|
* @property {Boolean} [credentials=false]
|
|
16
|
-
* @property {String|Array} headers
|
|
5
|
+
* @property {String|Array} [headers]
|
|
17
6
|
* @property {Number} [maxAge=0]
|
|
18
|
-
* @property {String|Array} methods
|
|
7
|
+
* @property {String|Array} [methods]
|
|
19
8
|
*/
|
|
20
9
|
|
|
21
10
|
/**
|
|
22
11
|
* @param {CORSOptions} options
|
|
23
12
|
*/
|
|
24
13
|
function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if (Array.isArray(headers)) {
|
|
15
|
+
headers = headers.map((h) => h.toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(methods)) {
|
|
18
|
+
methods = methods.map((m) => m.toUpperCase());
|
|
19
|
+
}
|
|
28
20
|
|
|
29
|
-
/**
|
|
30
|
-
* @param {import('http').ServerResponse} res
|
|
31
|
-
*/
|
|
32
21
|
function setAllowCredentials(res) {
|
|
33
22
|
if (credentials) {
|
|
34
23
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
35
24
|
}
|
|
36
25
|
}
|
|
37
26
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
|
|
42
44
|
function setAllowHeaders(req, res) {
|
|
43
45
|
const reqHeaders = req.headers["access-control-request-headers"];
|
|
44
46
|
if (!reqHeaders) return;
|
|
47
|
+
|
|
48
|
+
const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
|
|
49
|
+
|
|
45
50
|
let value;
|
|
46
51
|
if (headers === "*" && !credentials) {
|
|
47
52
|
value = "*";
|
|
48
|
-
} else if (Array.isArray(headers)
|
|
49
|
-
|
|
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;
|
|
50
59
|
}
|
|
51
60
|
if (value) res.setHeader("Access-Control-Allow-Headers", value);
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
/**
|
|
55
|
-
* @param {import('http').IncomingMessage} req
|
|
56
|
-
* @param {import('http').ServerResponse} res
|
|
57
|
-
*/
|
|
58
63
|
function setAllowMethods(req, res) {
|
|
59
64
|
const reqMethod = req.headers["access-control-request-method"];
|
|
60
65
|
if (!reqMethod) return;
|
|
61
|
-
let value;
|
|
62
|
-
if (methods === "*" && !credentials) {
|
|
63
|
-
value = "*";
|
|
64
|
-
} else if (Array.isArray(methods) && METHODS_PATTERN && METHODS_PATTERN.test(reqMethod)) {
|
|
65
|
-
value = methods;
|
|
66
|
-
}
|
|
67
|
-
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
68
|
-
}
|
|
69
66
|
|
|
70
|
-
|
|
71
|
-
* @param {import('http').IncomingMessage} req
|
|
72
|
-
* @param {import('http').ServerResponse} res
|
|
73
|
-
*/
|
|
74
|
-
function setAllowOrigin(req, res, preflight = true) {
|
|
75
|
-
const reqOrigin = req.headers["origin"];
|
|
67
|
+
const method = reqMethod.toUpperCase();
|
|
76
68
|
|
|
77
69
|
let value;
|
|
78
|
-
|
|
79
|
-
if (origin === "*" && !credentials) {
|
|
70
|
+
if (methods === "*" && !credentials) {
|
|
80
71
|
value = "*";
|
|
81
|
-
} else if (
|
|
82
|
-
value =
|
|
83
|
-
} else
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
} else if (Array.isArray(methods)) {
|
|
73
|
+
if (methods.includes(method)) value = methods;
|
|
74
|
+
} else {
|
|
75
|
+
// methods undefined -> echo request method
|
|
76
|
+
value = [method];
|
|
86
77
|
}
|
|
87
|
-
if (value) res.setHeader("Access-Control-Allow-
|
|
88
|
-
if (!preflight && explicit) res.appendHeader("Vary", "Origin");
|
|
78
|
+
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
89
79
|
}
|
|
90
80
|
|
|
91
|
-
/**
|
|
92
|
-
* @param {import('http').ServerResponse} res
|
|
93
|
-
*/
|
|
94
81
|
function setExposeHeaders(res) {
|
|
95
82
|
let value;
|
|
96
83
|
if (headers === "*" && !credentials) {
|
|
@@ -101,22 +88,17 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
101
88
|
if (value) res.setHeader("Access-Control-Expose-Headers", value);
|
|
102
89
|
}
|
|
103
90
|
|
|
104
|
-
/**
|
|
105
|
-
* @param {import('http').ServerResponse} res
|
|
106
|
-
*/
|
|
107
91
|
function setMaxAge(res) {
|
|
108
92
|
if (maxAge > 0) {
|
|
109
93
|
res.setHeader("Access-Control-Max-Age", maxAge);
|
|
110
94
|
}
|
|
111
95
|
}
|
|
112
96
|
|
|
113
|
-
/**
|
|
114
|
-
* @param {import('http').IncomingMessage} req
|
|
115
|
-
* @param {import('http').ServerResponse} res
|
|
116
|
-
*/
|
|
117
97
|
return (req, res) => {
|
|
118
98
|
setAllowCredentials(res);
|
|
119
|
-
|
|
99
|
+
|
|
100
|
+
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
101
|
+
// preflight
|
|
120
102
|
setAllowOrigin(req, res);
|
|
121
103
|
setAllowHeaders(req, res);
|
|
122
104
|
setAllowMethods(req, res);
|
|
@@ -125,9 +107,11 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
125
107
|
res.setHeader("Content-Length", "0");
|
|
126
108
|
res.end();
|
|
127
109
|
} else {
|
|
128
|
-
|
|
110
|
+
// normal response (termasuk OPTIONS non-preflight)
|
|
111
|
+
setAllowOrigin(req, res);
|
|
129
112
|
setExposeHeaders(res);
|
|
130
113
|
}
|
|
131
114
|
};
|
|
132
115
|
}
|
|
116
|
+
|
|
133
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,48 +1,77 @@
|
|
|
1
|
-
const { Pattern } = require("./pattern.js");
|
|
2
|
-
|
|
3
1
|
class Node {
|
|
4
2
|
constructor(segment = "") {
|
|
5
|
-
this.
|
|
6
|
-
|
|
7
|
-
/**@type {Map<string,Node>}*/
|
|
3
|
+
this.segment = segment;
|
|
4
|
+
/** @type {Map<string, Node>} static children */
|
|
8
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;
|
|
9
10
|
this.handlers = new Map();
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
add(segment) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
if (segment === "*") {
|
|
15
|
+
if (!this.wildcard) this.wildcard = new Node("*");
|
|
16
|
+
return this.wildcard;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (segment.startsWith(":")) {
|
|
20
|
+
return this.params.getOrInsertComputed(segment, (segment) => {
|
|
21
|
+
return new Node(segment);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return this.children.getOrInsertComputed(segment, (segment) => {
|
|
26
|
+
return new Node(segment);
|
|
17
27
|
});
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
merge(node, offset) {
|
|
21
31
|
for (const [method, entries] of node.handlers) {
|
|
22
|
-
const target = this.handlers.getOrInsert(method, []);
|
|
23
32
|
for (const entry of entries) {
|
|
24
|
-
|
|
33
|
+
this.handlers.getOrInsert(method, []).push({
|
|
34
|
+
...entry,
|
|
35
|
+
seq: entry.seq + offset,
|
|
36
|
+
});
|
|
25
37
|
}
|
|
26
38
|
}
|
|
39
|
+
|
|
40
|
+
if (node.wildcard) {
|
|
41
|
+
this.add("*").merge(node.wildcard, offset);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const [segment, child] of node.params) {
|
|
45
|
+
this.add(segment).merge(child, offset);
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
for (const [segment, child] of node.children) {
|
|
28
|
-
|
|
29
|
-
if (target.pattern) this.hasRegExpChildren = true;
|
|
30
|
-
target.merge(child, offset);
|
|
49
|
+
this.add(segment).merge(child, offset);
|
|
31
50
|
}
|
|
32
51
|
}
|
|
33
52
|
|
|
34
53
|
getMaxSeq() {
|
|
35
54
|
let max = -1;
|
|
36
|
-
|
|
37
|
-
|
|
55
|
+
|
|
56
|
+
for (const [, entries] of this.handlers) {
|
|
57
|
+
for (const entry of entries) {
|
|
38
58
|
max = Math.max(max, entry.seq);
|
|
39
59
|
}
|
|
40
60
|
}
|
|
61
|
+
|
|
62
|
+
if (this.wildcard) {
|
|
63
|
+
max = Math.max(max, this.wildcard.getMaxSeq());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const [, child] of this.params) {
|
|
67
|
+
max = Math.max(max, child.getMaxSeq());
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
for (const [, child] of this.children) {
|
|
42
71
|
max = Math.max(max, child.getMaxSeq());
|
|
43
72
|
}
|
|
73
|
+
|
|
44
74
|
return max;
|
|
45
75
|
}
|
|
46
76
|
}
|
|
47
|
-
|
|
48
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 = {}) {
|
|
@@ -16,12 +16,16 @@ class Router {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
add(method, path, ...handlers) {
|
|
19
|
+
this.cache.clear();
|
|
20
|
+
|
|
19
21
|
if (typeof path === "function") {
|
|
20
22
|
handlers = [path, ...handlers];
|
|
21
23
|
path = null;
|
|
22
24
|
}
|
|
25
|
+
|
|
23
26
|
let node = this.root;
|
|
24
|
-
|
|
27
|
+
|
|
28
|
+
if (path) {
|
|
25
29
|
for (const segment of this.split(path)) {
|
|
26
30
|
node = node.add(segment);
|
|
27
31
|
}
|
|
@@ -32,10 +36,10 @@ class Router {
|
|
|
32
36
|
node.merge(handler.root, this.seq);
|
|
33
37
|
this.seq += handler.root.getMaxSeq() + 1;
|
|
34
38
|
} else {
|
|
35
|
-
|
|
39
|
+
const key = handler.length === 3 ? "CATCH" : method;
|
|
40
|
+
node.handlers.getOrInsert(key, []).push({
|
|
36
41
|
handler,
|
|
37
42
|
seq: this.seq++,
|
|
38
|
-
isErrorHandler: handler.length === 3,
|
|
39
43
|
});
|
|
40
44
|
}
|
|
41
45
|
}
|
|
@@ -96,70 +100,126 @@ class Router {
|
|
|
96
100
|
return this;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
103
|
+
/**
|
|
104
|
+
* @param {Node} node
|
|
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 }}
|
|
111
|
+
*/
|
|
112
|
+
search(node, segments, index, path, params, state) {
|
|
113
|
+
path.push(node);
|
|
114
|
+
|
|
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
|
+
}
|
|
128
|
+
|
|
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;
|
|
139
|
+
}
|
|
140
|
+
|
|
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 };
|
|
147
|
+
}
|
|
148
|
+
|
|
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 } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
path.pop();
|
|
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;
|
|
110
163
|
}
|
|
111
164
|
}
|
|
112
|
-
return matched;
|
|
113
165
|
}
|
|
114
166
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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);
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
find(method, path) {
|
|
121
181
|
const key = `${method} ${path}`;
|
|
182
|
+
|
|
122
183
|
const cached = this.cache.get(key);
|
|
123
184
|
if (cached) return cached;
|
|
124
|
-
|
|
125
|
-
|
|
185
|
+
|
|
186
|
+
const segments = this.split(path);
|
|
187
|
+
|
|
188
|
+
const params = {};
|
|
126
189
|
const handlers = {};
|
|
127
190
|
const errorHandlers = {};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
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
|
-
}
|
|
191
|
+
|
|
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);
|
|
151
203
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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);
|
|
156
209
|
}
|
|
210
|
+
|
|
211
|
+
Object.freeze(finalParams);
|
|
212
|
+
|
|
157
213
|
const result = {
|
|
158
|
-
|
|
214
|
+
matched: res.matched,
|
|
215
|
+
params: finalParams,
|
|
216
|
+
paramsLength: Object.keys(finalParams).length,
|
|
159
217
|
handlers: Object.values(handlers),
|
|
160
218
|
errorHandlers: Object.values(errorHandlers),
|
|
161
219
|
};
|
|
220
|
+
|
|
162
221
|
this.cache.set(key, result);
|
|
222
|
+
|
|
163
223
|
return result;
|
|
164
224
|
}
|
|
165
225
|
}
|
package/src/util.js
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
1
|
+
const EMPTY_PARAMS = Object.freeze(Object.create(null));
|
|
2
|
+
exports.EMPTY_PARAMS = EMPTY_PARAMS;
|
|
3
3
|
|
|
4
|
-
function
|
|
4
|
+
function decodeParam(raw) {
|
|
5
5
|
try {
|
|
6
|
-
return decodeURIComponent(
|
|
6
|
+
return decodeURIComponent(raw);
|
|
7
7
|
} catch (error) {
|
|
8
|
-
return
|
|
8
|
+
return raw;
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
-
exports.
|
|
11
|
+
exports.decodeParam = decodeParam;
|
|
12
12
|
|
|
13
|
-
function
|
|
13
|
+
function decodeQuery(raw) {
|
|
14
|
+
const normalized = raw.replace(/\+/g, " ");
|
|
14
15
|
try {
|
|
15
|
-
return decodeURIComponent(
|
|
16
|
-
} catch {
|
|
17
|
-
return
|
|
16
|
+
return decodeURIComponent(normalized);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return normalized;
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
|
-
exports.decodePathParam=decodePathParam
|
|
21
21
|
|
|
22
22
|
function parseQueryString(queryString) {
|
|
23
|
-
|
|
23
|
+
if (queryString === "") return Object.create(null);
|
|
24
|
+
const regex = /([^=&]*)=([^&]*)/g;
|
|
24
25
|
let match;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
26
|
+
const result = Object.create(null);
|
|
27
|
+
while ((match = regex.exec(queryString))) {
|
|
28
|
+
const [, rawName, rawValue] = match;
|
|
29
|
+
const name = decodeQuery(rawName);
|
|
30
|
+
const value = decodeQuery(rawValue);
|
|
29
31
|
if (Object.hasOwn(result, name)) {
|
|
30
32
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
31
33
|
else result[name] = [result[name], value];
|
|
@@ -36,7 +38,11 @@ function parseQueryString(queryString) {
|
|
|
36
38
|
exports.parseQueryString = parseQueryString;
|
|
37
39
|
|
|
38
40
|
function parseUrl(url) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
if (!(url.includes("?") || url.includes("#"))) {
|
|
42
|
+
return { path: url, queryString: "" };
|
|
43
|
+
}
|
|
44
|
+
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
45
|
+
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
46
|
+
return { path, queryString };
|
|
41
47
|
}
|
|
42
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;
|
package/src/pattern.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @typedef PatternExecResult
|
|
3
|
-
* @property {number} index
|
|
4
|
-
* @property {string} input
|
|
5
|
-
* @property {Record<string, string>} [groups]
|
|
6
|
-
* @property {number} length
|
|
7
|
-
*/
|
|
8
|
-
class Pattern {
|
|
9
|
-
constructor(input) {
|
|
10
|
-
const pattern = `^${input
|
|
11
|
-
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
12
|
-
.replace(/:(\w+)/g, "(?<$1>[^/]+)")
|
|
13
|
-
.replace(/\\\*/g, ".*")}$`;
|
|
14
|
-
Object.defineProperty(this, "regex", {
|
|
15
|
-
value: new RegExp(pattern),
|
|
16
|
-
configurable: true,
|
|
17
|
-
enumerable: false,
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* @param {string} input
|
|
23
|
-
* @returns {PatternExecResult | null}
|
|
24
|
-
*/
|
|
25
|
-
exec(input) {
|
|
26
|
-
return this.regex.exec(input);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
test(input) {
|
|
30
|
-
return this.regex.test(input);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
exports.Pattern = Pattern;
|