@ndiinginc/router 4.10.0 → 4.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -40
- package/package.json +2 -2
- package/src/app.js +1 -12
- package/src/middleware/cors.js +0 -12
- package/src/middleware/json.js +1 -0
- package/src/node.js +3 -10
- package/src/polyfill.js +1 -3
- package/src/request.js +0 -3
- package/src/response.js +1 -1
- package/src/router.js +58 -67
- package/src/util.js +1 -9
package/README.md
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# @ndiinginc/router
|
|
2
2
|
|
|
3
|
-
Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsung di atas `node:http`, dengan routing berbasis tree,
|
|
3
|
+
Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsung di atas `node:http`, dengan routing berbasis tree, nested router, middleware async, error handler, timeout per request, serta middleware `json` dan `cors` bawaan.
|
|
4
4
|
|
|
5
5
|
## Fitur
|
|
6
6
|
|
|
7
7
|
- Zero dependency, murni `node:http`
|
|
8
8
|
- Routing berbasis tree per segmen: static, lalu `:param`, lalu wildcard `*`, dengan backtracking
|
|
9
9
|
- Parameter dinamis `:id` dan wildcard `*` yang menangkap sisa path
|
|
10
|
-
- LRU cache untuk hasil lookup route
|
|
11
10
|
- Nested router dengan `use(path, router)`
|
|
12
11
|
- Middleware global dan per-path, mendukung `async`
|
|
13
12
|
- Error handler dengan signature `(err, req, res)`
|
|
@@ -51,23 +50,19 @@ app.listen(3000);
|
|
|
51
50
|
|
|
52
51
|
Yang diekspor adalah class `App`, dengan tiga properti tambahan:
|
|
53
52
|
|
|
54
|
-
| Export
|
|
55
|
-
|
|
|
56
|
-
| `App`
|
|
53
|
+
| Export | Keterangan |
|
|
54
|
+
| ------------ | ---------------------------------------- |
|
|
55
|
+
| `App` | Router + HTTP server (`listen`) |
|
|
57
56
|
| `App.Router` | Router tanpa server, untuk nested router |
|
|
58
|
-
| `App.json`
|
|
59
|
-
| `App.cors`
|
|
57
|
+
| `App.json` | Middleware JSON body parser |
|
|
58
|
+
| `App.cors` | Middleware CORS |
|
|
60
59
|
|
|
61
60
|
## Routing
|
|
62
61
|
|
|
63
62
|
Semua method mengembalikan `this`, jadi bisa di-chain.
|
|
64
63
|
|
|
65
64
|
```js
|
|
66
|
-
app.get("/users", handler)
|
|
67
|
-
.post("/users", handler)
|
|
68
|
-
.put("/users/:id", handler)
|
|
69
|
-
.patch("/users/:id", handler)
|
|
70
|
-
.delete("/users/:id", handler);
|
|
65
|
+
app.get("/users", handler).post("/users", handler).put("/users/:id", handler).patch("/users/:id", handler).delete("/users/:id", handler);
|
|
71
66
|
```
|
|
72
67
|
|
|
73
68
|
Method yang tersedia: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `trace`, `connect`, `query`, dan `use` (semua method).
|
|
@@ -119,6 +114,7 @@ Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `
|
|
|
119
114
|
|
|
120
115
|
- 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
116
|
- Jika sebuah cabang gagal di tengah jalan, router mundur (backtrack) dan mencoba cabang berikutnya.
|
|
117
|
+
- Pemilihan cabang memperhatikan method. Jika route static tidak punya handler untuk method request, router mencoba cabang parameter atau wildcard. Contoh: dengan `GET /users/me` dan `PUT /users/:id`, request `PUT /users/me` ditangani oleh `PUT /users/:id`.
|
|
122
118
|
- Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
|
|
123
119
|
|
|
124
120
|
## Middleware
|
|
@@ -212,7 +208,7 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
212
208
|
|
|
213
209
|
## Timeout
|
|
214
210
|
|
|
215
|
-
Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, `res.statusCode` diset `
|
|
211
|
+
Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, `res.statusCode` diset `504` dan error `Gateway Timeout` dilempar ke error handler.
|
|
216
212
|
|
|
217
213
|
```js
|
|
218
214
|
const app = new App({ requestTimeout: 5000 });
|
|
@@ -236,12 +232,12 @@ app.get("/proxy", async (req, res) => {
|
|
|
236
232
|
|
|
237
233
|
## Request
|
|
238
234
|
|
|
239
|
-
| Properti
|
|
240
|
-
|
|
|
241
|
-
| `req.path`
|
|
242
|
-
| `req.query`
|
|
243
|
-
| `req.params`
|
|
244
|
-
| `req.body`
|
|
235
|
+
| Properti | Keterangan |
|
|
236
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
237
|
+
| `req.path` | Pathname tanpa query string |
|
|
238
|
+
| `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
|
|
239
|
+
| `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
|
|
240
|
+
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
245
241
|
| `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
|
|
246
242
|
|
|
247
243
|
```js
|
|
@@ -257,11 +253,11 @@ app.get("/search", (req, res) => {
|
|
|
257
253
|
|
|
258
254
|
## Response
|
|
259
255
|
|
|
260
|
-
| Method
|
|
261
|
-
|
|
|
262
|
-
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain
|
|
263
|
-
| `res.send(chunk)`
|
|
264
|
-
| `res.json(value)`
|
|
256
|
+
| Method | Keterangan |
|
|
257
|
+
| ------------------ | -------------------------------------------------------------------------- |
|
|
258
|
+
| `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
|
|
259
|
+
| `res.send(chunk)` | Kirim response dan akhiri |
|
|
260
|
+
| `res.json(value)` | Set `Content-Type: application/json; charset=utf-8`, serialize, lalu kirim |
|
|
265
261
|
|
|
266
262
|
```js
|
|
267
263
|
res.status(201).json({ created: true });
|
|
@@ -277,8 +273,8 @@ res.status(201).json({ created: true });
|
|
|
277
273
|
app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
|
|
278
274
|
```
|
|
279
275
|
|
|
280
|
-
| Opsi
|
|
281
|
-
|
|
|
276
|
+
| Opsi | Default | Keterangan |
|
|
277
|
+
| ------- | -------- | ------------------------------- |
|
|
282
278
|
| `limit` | `102400` | Ukuran body maksimum dalam byte |
|
|
283
279
|
|
|
284
280
|
Perilaku:
|
|
@@ -303,13 +299,13 @@ app.use(
|
|
|
303
299
|
);
|
|
304
300
|
```
|
|
305
301
|
|
|
306
|
-
| Opsi
|
|
307
|
-
|
|
|
308
|
-
| `origin`
|
|
309
|
-
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true`
|
|
310
|
-
| `headers`
|
|
311
|
-
| `methods`
|
|
312
|
-
| `maxAge`
|
|
302
|
+
| Opsi | Default | Keterangan |
|
|
303
|
+
| ------------- | ------- | ------------------------------------------------------------------------------------------------------------- |
|
|
304
|
+
| `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
|
|
305
|
+
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
306
|
+
| `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
|
|
307
|
+
| `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
|
|
308
|
+
| `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
|
|
313
309
|
|
|
314
310
|
Perilaku:
|
|
315
311
|
|
|
@@ -326,15 +322,14 @@ Perilaku:
|
|
|
326
322
|
## Opsi
|
|
327
323
|
|
|
328
324
|
```js
|
|
329
|
-
const app = new App({
|
|
325
|
+
const app = new App({ requestTimeout: 5000 });
|
|
330
326
|
```
|
|
331
327
|
|
|
332
|
-
| Opsi
|
|
333
|
-
|
|
|
334
|
-
| `
|
|
335
|
-
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
328
|
+
| Opsi | Default | Keterangan |
|
|
329
|
+
| ---------------- | ------- | ----------------------------------------------------------------------------- |
|
|
330
|
+
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
336
331
|
|
|
337
|
-
`Router` hanya
|
|
332
|
+
`Router` tidak memiliki opsi sendiri; `requestTimeout` hanya dipakai oleh `App`.
|
|
338
333
|
|
|
339
334
|
## API
|
|
340
335
|
|
|
@@ -352,7 +347,7 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
|
|
|
352
347
|
|
|
353
348
|
### `router.find(method, path)`
|
|
354
349
|
|
|
355
|
-
Mencari route tanpa menjalankannya.
|
|
350
|
+
Mencari route tanpa menjalankannya. Berguna untuk testing.
|
|
356
351
|
|
|
357
352
|
```js
|
|
358
353
|
const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
@@ -367,6 +362,29 @@ const server = app.listen(0); // port acak
|
|
|
367
362
|
console.log(server.address().port);
|
|
368
363
|
```
|
|
369
364
|
|
|
365
|
+
### Server sendiri
|
|
366
|
+
|
|
367
|
+
`App.SERVER_OPTIONS` berisi opsi `IncomingMessage` dan `ServerResponse` yang dipakai `listen()`. Jika ingin membuat server sendiri (misalnya HTTPS), sertakan opsi itu dan panggil `app.handle(req, res)`:
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
const https = require("https");
|
|
371
|
+
|
|
372
|
+
const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
|
|
373
|
+
server.listen(443);
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Benchmark
|
|
377
|
+
|
|
378
|
+
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
379
|
+
|
|
380
|
+
| | Req/sec (avg) | Latency (avg) |
|
|
381
|
+
| --------------------- | ------------- | ------------- |
|
|
382
|
+
| Fastify | 31.475 | 31,27 ms |
|
|
383
|
+
| **@ndiinginc/router** | 29.735 | 33,12 ms |
|
|
384
|
+
| Express | 20.211 | 48,93 ms |
|
|
385
|
+
|
|
386
|
+
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
387
|
+
|
|
370
388
|
## Testing
|
|
371
389
|
|
|
372
390
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ndiinginc/router",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -22,6 +22,6 @@
|
|
|
22
22
|
"@ndiinginc/test": "^1.17.0",
|
|
23
23
|
"@types/jest": "^30.0.0",
|
|
24
24
|
"@types/node": "^26.6.5",
|
|
25
|
-
"autocannon": "^
|
|
25
|
+
"autocannon": "^2.0.1"
|
|
26
26
|
}
|
|
27
27
|
}
|
package/src/app.js
CHANGED
|
@@ -17,20 +17,16 @@ class App extends Router {
|
|
|
17
17
|
handleAbort = () => reject(req.controller.signal.reason);
|
|
18
18
|
req.controller.signal.addEventListener("abort", handleAbort, { once: true });
|
|
19
19
|
});
|
|
20
|
-
|
|
21
20
|
const timeout = setTimeout(() => {
|
|
22
21
|
res.statusCode = 504;
|
|
23
22
|
req.controller.abort(new Error(http.STATUS_CODES[res.statusCode]));
|
|
24
23
|
}, this.requestTimeout);
|
|
25
|
-
|
|
26
24
|
const promise = handlerPromise().catch((error) => {
|
|
27
25
|
if (!req.controller.signal.aborted) {
|
|
28
26
|
req.controller.abort(error);
|
|
29
27
|
} else {
|
|
30
|
-
console.error(error); // swallow error?
|
|
31
28
|
}
|
|
32
29
|
});
|
|
33
|
-
|
|
34
30
|
try {
|
|
35
31
|
await Promise.race([promise, timeoutPromise]);
|
|
36
32
|
} finally {
|
|
@@ -45,16 +41,13 @@ class App extends Router {
|
|
|
45
41
|
res.statusCode = 404;
|
|
46
42
|
err = new Error(http.STATUS_CODES[res.statusCode]);
|
|
47
43
|
}
|
|
48
|
-
|
|
49
44
|
if (res.statusCode < 400) {
|
|
50
45
|
res.statusCode = 500;
|
|
51
46
|
}
|
|
52
|
-
|
|
53
47
|
const error = { message: err.message };
|
|
54
48
|
if (process.env.NODE_ENV === "development") {
|
|
55
49
|
error.stack = err.stack;
|
|
56
50
|
}
|
|
57
|
-
|
|
58
51
|
res.json({ error });
|
|
59
52
|
}
|
|
60
53
|
}
|
|
@@ -66,7 +59,6 @@ class App extends Router {
|
|
|
66
59
|
async handle(req, res) {
|
|
67
60
|
const { params, paramsLength, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
68
61
|
req.params = paramsLength ? Object.assign(Object.create(null), params) : EMPTY_PARAMS;
|
|
69
|
-
|
|
70
62
|
let err;
|
|
71
63
|
try {
|
|
72
64
|
const handlerPromise = async () => {
|
|
@@ -75,7 +67,6 @@ class App extends Router {
|
|
|
75
67
|
await handler(req, res);
|
|
76
68
|
}
|
|
77
69
|
};
|
|
78
|
-
|
|
79
70
|
if (this.requestTimeout) {
|
|
80
71
|
await this.withTimeout(req, res, handlerPromise);
|
|
81
72
|
} else {
|
|
@@ -83,7 +74,6 @@ class App extends Router {
|
|
|
83
74
|
}
|
|
84
75
|
} catch (error) {
|
|
85
76
|
err = error;
|
|
86
|
-
|
|
87
77
|
try {
|
|
88
78
|
for (const handler of errorHandlers) {
|
|
89
79
|
if (res.writableEnded) break;
|
|
@@ -93,7 +83,6 @@ class App extends Router {
|
|
|
93
83
|
err = new Error(error.message, { cause: err });
|
|
94
84
|
}
|
|
95
85
|
}
|
|
96
|
-
|
|
97
86
|
this.fallback(res, err);
|
|
98
87
|
}
|
|
99
88
|
|
|
@@ -105,6 +94,6 @@ class App extends Router {
|
|
|
105
94
|
}
|
|
106
95
|
}
|
|
107
96
|
|
|
108
|
-
App.SERVER_OPTIONS=SERVER_OPTIONS
|
|
97
|
+
App.SERVER_OPTIONS = SERVER_OPTIONS;
|
|
109
98
|
|
|
110
99
|
exports.App = App;
|
package/src/middleware/cors.js
CHANGED
|
@@ -28,15 +28,12 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
28
28
|
const reqOrigin = req.headers["origin"];
|
|
29
29
|
let value;
|
|
30
30
|
let explicit = false;
|
|
31
|
-
|
|
32
31
|
if (origin === "*" && !credentials) {
|
|
33
32
|
value = "*";
|
|
34
33
|
} else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.includes(reqOrigin))) {
|
|
35
34
|
value = reqOrigin;
|
|
36
35
|
explicit = true;
|
|
37
36
|
}
|
|
38
|
-
// origin === null -> skip (jangan set "null", celah keamanan)
|
|
39
|
-
|
|
40
37
|
if (value) res.setHeader("Access-Control-Allow-Origin", value);
|
|
41
38
|
if (explicit) res.appendHeader("Vary", "Origin");
|
|
42
39
|
}
|
|
@@ -44,9 +41,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
44
41
|
function setAllowHeaders(req, res) {
|
|
45
42
|
const reqHeaders = req.headers["access-control-request-headers"];
|
|
46
43
|
if (!reqHeaders) return;
|
|
47
|
-
|
|
48
44
|
const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
|
|
49
|
-
|
|
50
45
|
let value;
|
|
51
46
|
if (headers === "*" && !credentials) {
|
|
52
47
|
value = "*";
|
|
@@ -54,7 +49,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
54
49
|
const allowed = requested.filter((h) => headers.includes(h));
|
|
55
50
|
if (allowed.length === requested.length) value = headers;
|
|
56
51
|
} else {
|
|
57
|
-
// headers undefined -> echo request headers (wajib per spec)
|
|
58
52
|
value = requested;
|
|
59
53
|
}
|
|
60
54
|
if (value) res.setHeader("Access-Control-Allow-Headers", value);
|
|
@@ -63,16 +57,13 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
63
57
|
function setAllowMethods(req, res) {
|
|
64
58
|
const reqMethod = req.headers["access-control-request-method"];
|
|
65
59
|
if (!reqMethod) return;
|
|
66
|
-
|
|
67
60
|
const method = reqMethod.toUpperCase();
|
|
68
|
-
|
|
69
61
|
let value;
|
|
70
62
|
if (methods === "*" && !credentials) {
|
|
71
63
|
value = "*";
|
|
72
64
|
} else if (Array.isArray(methods)) {
|
|
73
65
|
if (methods.includes(method)) value = methods;
|
|
74
66
|
} else {
|
|
75
|
-
// methods undefined -> echo request method
|
|
76
67
|
value = [method];
|
|
77
68
|
}
|
|
78
69
|
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
@@ -96,9 +87,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
96
87
|
|
|
97
88
|
return (req, res) => {
|
|
98
89
|
setAllowCredentials(res);
|
|
99
|
-
|
|
100
90
|
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
101
|
-
// preflight
|
|
102
91
|
setAllowOrigin(req, res);
|
|
103
92
|
setAllowHeaders(req, res);
|
|
104
93
|
setAllowMethods(req, res);
|
|
@@ -107,7 +96,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
107
96
|
res.setHeader("Content-Length", "0");
|
|
108
97
|
res.end();
|
|
109
98
|
} else {
|
|
110
|
-
// normal response (termasuk OPTIONS non-preflight)
|
|
111
99
|
setAllowOrigin(req, res);
|
|
112
100
|
setExposeHeaders(res);
|
|
113
101
|
}
|
package/src/middleware/json.js
CHANGED
package/src/node.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
class Node {
|
|
2
2
|
constructor(segment = "") {
|
|
3
3
|
this.segment = segment;
|
|
4
|
+
if (segment.startsWith(":")) {
|
|
5
|
+
this.name = segment.slice(1);
|
|
6
|
+
}
|
|
4
7
|
/** @type {Map<string, Node>} static children */
|
|
5
8
|
this.children = new Map();
|
|
6
9
|
/** @type {Map<string, Node>} param children, key = ':name' */
|
|
@@ -15,13 +18,11 @@ class Node {
|
|
|
15
18
|
if (!this.wildcard) this.wildcard = new Node("*");
|
|
16
19
|
return this.wildcard;
|
|
17
20
|
}
|
|
18
|
-
|
|
19
21
|
if (segment.startsWith(":")) {
|
|
20
22
|
return this.params.getOrInsertComputed(segment, (segment) => {
|
|
21
23
|
return new Node(segment);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
|
-
|
|
25
26
|
return this.children.getOrInsertComputed(segment, (segment) => {
|
|
26
27
|
return new Node(segment);
|
|
27
28
|
});
|
|
@@ -36,15 +37,12 @@ class Node {
|
|
|
36
37
|
});
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
|
|
40
40
|
if (node.wildcard) {
|
|
41
41
|
this.add("*").merge(node.wildcard, offset);
|
|
42
42
|
}
|
|
43
|
-
|
|
44
43
|
for (const [segment, child] of node.params) {
|
|
45
44
|
this.add(segment).merge(child, offset);
|
|
46
45
|
}
|
|
47
|
-
|
|
48
46
|
for (const [segment, child] of node.children) {
|
|
49
47
|
this.add(segment).merge(child, offset);
|
|
50
48
|
}
|
|
@@ -52,25 +50,20 @@ class Node {
|
|
|
52
50
|
|
|
53
51
|
getMaxSeq() {
|
|
54
52
|
let max = -1;
|
|
55
|
-
|
|
56
53
|
for (const [, entries] of this.handlers) {
|
|
57
54
|
for (const entry of entries) {
|
|
58
55
|
max = Math.max(max, entry.seq);
|
|
59
56
|
}
|
|
60
57
|
}
|
|
61
|
-
|
|
62
58
|
if (this.wildcard) {
|
|
63
59
|
max = Math.max(max, this.wildcard.getMaxSeq());
|
|
64
60
|
}
|
|
65
|
-
|
|
66
61
|
for (const [, child] of this.params) {
|
|
67
62
|
max = Math.max(max, child.getMaxSeq());
|
|
68
63
|
}
|
|
69
|
-
|
|
70
64
|
for (const [, child] of this.children) {
|
|
71
65
|
max = Math.max(max, child.getMaxSeq());
|
|
72
66
|
}
|
|
73
|
-
|
|
74
67
|
return max;
|
|
75
68
|
}
|
|
76
69
|
}
|
package/src/polyfill.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
// Polyfill for Map.prototype.getOrInsert
|
|
3
1
|
if (!Map.prototype.getOrInsert) {
|
|
4
2
|
Map.prototype.getOrInsert = function (key, defaultValue) {
|
|
5
3
|
if (!this.has(key)) {
|
|
@@ -8,7 +6,7 @@ if (!Map.prototype.getOrInsert) {
|
|
|
8
6
|
return this.get(key);
|
|
9
7
|
};
|
|
10
8
|
}
|
|
11
|
-
|
|
9
|
+
|
|
12
10
|
if (!Map.prototype.getOrInsertComputed) {
|
|
13
11
|
Map.prototype.getOrInsertComputed = function (key, callback) {
|
|
14
12
|
if (typeof callback !== "function") {
|
package/src/request.js
CHANGED
|
@@ -13,7 +13,6 @@ class Request extends http.IncomingMessage {
|
|
|
13
13
|
this._path = path;
|
|
14
14
|
this._queryString = queryString;
|
|
15
15
|
}
|
|
16
|
-
|
|
17
16
|
return this._path;
|
|
18
17
|
}
|
|
19
18
|
|
|
@@ -21,7 +20,6 @@ class Request extends http.IncomingMessage {
|
|
|
21
20
|
if (!this._query) {
|
|
22
21
|
this._query = parseQueryString(this._queryString);
|
|
23
22
|
}
|
|
24
|
-
|
|
25
23
|
return this._query;
|
|
26
24
|
}
|
|
27
25
|
|
|
@@ -32,7 +30,6 @@ class Request extends http.IncomingMessage {
|
|
|
32
30
|
if (!this._controller) {
|
|
33
31
|
this._controller = new AbortController();
|
|
34
32
|
}
|
|
35
|
-
|
|
36
33
|
return this._controller;
|
|
37
34
|
}
|
|
38
35
|
}
|
package/src/response.js
CHANGED
package/src/router.js
CHANGED
|
@@ -13,20 +13,16 @@ class Router {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
add(method, path, ...handlers) {
|
|
16
|
-
|
|
17
16
|
if (typeof path === "function") {
|
|
18
17
|
handlers = [path, ...handlers];
|
|
19
18
|
path = null;
|
|
20
19
|
}
|
|
21
|
-
|
|
22
20
|
let node = this.root;
|
|
23
|
-
|
|
24
21
|
if (path) {
|
|
25
22
|
for (const segment of this.split(path)) {
|
|
26
23
|
node = node.add(segment);
|
|
27
24
|
}
|
|
28
25
|
}
|
|
29
|
-
|
|
30
26
|
for (const handler of handlers) {
|
|
31
27
|
if (handler.root) {
|
|
32
28
|
node.merge(handler.root, this.seq);
|
|
@@ -105,51 +101,66 @@ class Router {
|
|
|
105
101
|
* @param {{ best: { nodes: Node[], params: Record<string, string> } }} state
|
|
106
102
|
* @returns {{ nodes: Node[], params: Record<string, string>, matched: boolean }}
|
|
107
103
|
*/
|
|
108
|
-
search(
|
|
104
|
+
search(method, segments, node, path = [], params = {}, state = { path: [], params: {} }, index = 0) {
|
|
109
105
|
path.push(node);
|
|
110
|
-
|
|
111
|
-
// habis segmen -> full match
|
|
112
106
|
if (index === segments.length) {
|
|
113
|
-
|
|
107
|
+
if (node.handlers.has(method) || node.handlers.has("ALL")) {
|
|
108
|
+
return { path, params, matched: true };
|
|
109
|
+
}
|
|
110
|
+
if (path.length > state.path.length) {
|
|
111
|
+
state.path = path.slice();
|
|
112
|
+
state.params = { ...params };
|
|
113
|
+
}
|
|
114
|
+
path.pop();
|
|
115
|
+
return { path: state.path, params: state.params, matched: false };
|
|
114
116
|
}
|
|
115
|
-
|
|
116
117
|
const segment = segments[index];
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
118
|
+
if (node.children.has(segment)) {
|
|
119
|
+
const res = this.search(
|
|
120
|
+
//
|
|
121
|
+
method,
|
|
122
|
+
segments,
|
|
123
|
+
node.children.get(segment),
|
|
124
|
+
path,
|
|
125
|
+
params,
|
|
126
|
+
state,
|
|
127
|
+
index + 1,
|
|
128
|
+
);
|
|
122
129
|
if (res.matched) return res;
|
|
123
130
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
if (node.params.size) {
|
|
132
|
+
for (const [, child] of node.params) {
|
|
133
|
+
const prev = params[child.name];
|
|
134
|
+
params[child.name] = decodeParam(segment);
|
|
135
|
+
const res = this.search(
|
|
136
|
+
//
|
|
137
|
+
method,
|
|
138
|
+
segments,
|
|
139
|
+
child,
|
|
140
|
+
path,
|
|
141
|
+
params,
|
|
142
|
+
state,
|
|
143
|
+
index + 1,
|
|
144
|
+
);
|
|
145
|
+
if (res.matched) return res;
|
|
146
|
+
if (prev === undefined) {
|
|
147
|
+
const { [child.name]: _, ...rest } = params;
|
|
148
|
+
params = rest;
|
|
149
|
+
} else params[child.name] = prev;
|
|
150
|
+
}
|
|
135
151
|
}
|
|
136
|
-
|
|
137
|
-
// 3. wildcard (prioritas terendah, menyerap sisa segmen)
|
|
138
|
-
if (node.wildcard) {
|
|
152
|
+
if (node.wildcard && (node.wildcard.handlers.get(method) || node.wildcard.handlers.get("ALL"))) {
|
|
139
153
|
const rest = segments.slice(index).join("/");
|
|
140
154
|
params["wildcard"] = decodeParam(rest);
|
|
141
155
|
path.push(node.wildcard);
|
|
142
|
-
return {
|
|
156
|
+
return { path, params, matched: true };
|
|
143
157
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (path.length > state.best.nodes.length) {
|
|
148
|
-
state.best = { nodes: path.slice(), params: { ...params } };
|
|
158
|
+
if (path.length > state.path.length) {
|
|
159
|
+
state.path = path.slice();
|
|
160
|
+
state.params = { ...params };
|
|
149
161
|
}
|
|
150
|
-
|
|
151
162
|
path.pop();
|
|
152
|
-
return { matched: false };
|
|
163
|
+
return { path: state.path, params: state.params, matched: false };
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
collectHandler(r, method, handlers) {
|
|
@@ -160,50 +171,30 @@ class Router {
|
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
|
|
163
|
-
collectErrorHandler(r, errorHandlers) {
|
|
164
|
-
if (r.handlers.has("CATCH")) {
|
|
165
|
-
for (const entry of r.handlers.get("CATCH")) {
|
|
166
|
-
errorHandlers[entry.seq] = entry.handler;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
174
|
collectMiddleware(r, handlers, errorHandlers) {
|
|
172
175
|
this.collectHandler(r, "ALL", handlers);
|
|
173
|
-
this.
|
|
176
|
+
this.collectHandler(r, "CATCH", errorHandlers);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
find(method, path) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
180
|
+
const res = this.search(
|
|
181
|
+
//
|
|
182
|
+
method,
|
|
183
|
+
this.split(path),
|
|
184
|
+
this.root,
|
|
185
|
+
);
|
|
181
186
|
const handlers = {};
|
|
182
187
|
const errorHandlers = {};
|
|
183
|
-
|
|
184
|
-
/** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
|
|
185
|
-
const state = { best: { nodes: [], params: {} } };
|
|
186
|
-
|
|
187
|
-
const res = this.search(this.root, segments, 0, [], params, state);
|
|
188
|
-
|
|
189
|
-
const matchedNodes = res.matched ? res.nodes : state.best.nodes;
|
|
190
|
-
const finalParams = res.matched ? res.params : state.best.params;
|
|
191
|
-
|
|
192
|
-
// middleware dari semua node di jalur (partial atau full)
|
|
193
|
-
for (const r of matchedNodes) {
|
|
188
|
+
for (const r of res.path) {
|
|
194
189
|
this.collectMiddleware(r, handlers, errorHandlers);
|
|
195
190
|
}
|
|
196
|
-
|
|
197
|
-
// method handler HANYA kalau full match
|
|
198
191
|
if (res.matched) {
|
|
199
|
-
|
|
200
|
-
this.collectHandler(leaf, method, handlers);
|
|
192
|
+
this.collectHandler(res.path.at(-1), method, handlers);
|
|
201
193
|
}
|
|
202
|
-
|
|
203
194
|
return {
|
|
204
195
|
matched: res.matched,
|
|
205
|
-
params:
|
|
206
|
-
paramsLength: Object.keys(
|
|
196
|
+
params: res.params,
|
|
197
|
+
paramsLength: Object.keys(res.params).length,
|
|
207
198
|
handlers: Object.values(handlers),
|
|
208
199
|
errorHandlers: Object.values(errorHandlers),
|
|
209
200
|
};
|
package/src/util.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
const EMPTY_PARAMS = Object.create(null)
|
|
1
|
+
const EMPTY_PARAMS = Object.create(null);
|
|
3
2
|
exports.EMPTY_PARAMS = EMPTY_PARAMS;
|
|
4
3
|
|
|
5
4
|
function decodeParam(raw) {
|
|
@@ -13,7 +12,6 @@ exports.decodeParam = decodeParam;
|
|
|
13
12
|
|
|
14
13
|
function decodeQuery(raw) {
|
|
15
14
|
const normalized = raw.replace(/\+/g, " ");
|
|
16
|
-
|
|
17
15
|
try {
|
|
18
16
|
return decodeURIComponent(normalized);
|
|
19
17
|
} catch (error) {
|
|
@@ -23,17 +21,13 @@ function decodeQuery(raw) {
|
|
|
23
21
|
|
|
24
22
|
function parseQueryString(queryString) {
|
|
25
23
|
if (queryString === "") return Object.create(null);
|
|
26
|
-
|
|
27
24
|
const regex = /([^=&]*)=([^&]*)/g;
|
|
28
25
|
const result = Object.create(null);
|
|
29
|
-
|
|
30
26
|
let match;
|
|
31
27
|
while ((match = regex.exec(queryString))) {
|
|
32
28
|
const [, rawName, rawValue] = match;
|
|
33
|
-
|
|
34
29
|
const name = decodeQuery(rawName);
|
|
35
30
|
const value = decodeQuery(rawValue);
|
|
36
|
-
|
|
37
31
|
if (Object.hasOwn(result, name)) {
|
|
38
32
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
39
33
|
else result[name] = [result[name], value];
|
|
@@ -48,10 +42,8 @@ function parseUrl(url) {
|
|
|
48
42
|
if (!(url.includes("?") || url.includes("#"))) {
|
|
49
43
|
return { path: url, queryString: "" };
|
|
50
44
|
}
|
|
51
|
-
|
|
52
45
|
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
53
46
|
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
54
|
-
|
|
55
47
|
return { path, queryString };
|
|
56
48
|
}
|
|
57
49
|
exports.parseUrl = parseUrl;
|