@ndiinginc/router 4.11.0 → 4.11.2
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 +35 -42
- package/package.json +2 -2
- package/src/app.js +16 -14
- package/src/middleware/cors.js +14 -19
- package/src/middleware/json.js +16 -1
- package/src/node.js +25 -13
- package/src/polyfill.js +1 -3
- package/src/request.js +0 -6
- package/src/response.js +11 -1
- package/src/router.js +150 -80
- package/src/util.js +17 -9
package/README.md
CHANGED
|
@@ -50,23 +50,19 @@ app.listen(3000);
|
|
|
50
50
|
|
|
51
51
|
Yang diekspor adalah class `App`, dengan tiga properti tambahan:
|
|
52
52
|
|
|
53
|
-
| Export
|
|
54
|
-
|
|
|
55
|
-
| `App`
|
|
53
|
+
| Export | Keterangan |
|
|
54
|
+
| ------------ | ---------------------------------------- |
|
|
55
|
+
| `App` | Router + HTTP server (`listen`) |
|
|
56
56
|
| `App.Router` | Router tanpa server, untuk nested router |
|
|
57
|
-
| `App.json`
|
|
58
|
-
| `App.cors`
|
|
57
|
+
| `App.json` | Middleware JSON body parser |
|
|
58
|
+
| `App.cors` | Middleware CORS |
|
|
59
59
|
|
|
60
60
|
## Routing
|
|
61
61
|
|
|
62
62
|
Semua method mengembalikan `this`, jadi bisa di-chain.
|
|
63
63
|
|
|
64
64
|
```js
|
|
65
|
-
app.get("/users", handler)
|
|
66
|
-
.post("/users", handler)
|
|
67
|
-
.put("/users/:id", handler)
|
|
68
|
-
.patch("/users/:id", handler)
|
|
69
|
-
.delete("/users/:id", handler);
|
|
65
|
+
app.get("/users", handler).post("/users", handler).put("/users/:id", handler).patch("/users/:id", handler).delete("/users/:id", handler);
|
|
70
66
|
```
|
|
71
67
|
|
|
72
68
|
Method yang tersedia: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `trace`, `connect`, `query`, dan `use` (semua method).
|
|
@@ -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
|
|
|
@@ -329,9 +325,9 @@ Perilaku:
|
|
|
329
325
|
const app = new App({ requestTimeout: 5000 });
|
|
330
326
|
```
|
|
331
327
|
|
|
332
|
-
| Opsi
|
|
333
|
-
|
|
|
334
|
-
| `requestTimeout` | `0`
|
|
328
|
+
| Opsi | Default | Keterangan |
|
|
329
|
+
| ---------------- | ------- | ----------------------------------------------------------------------------- |
|
|
330
|
+
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
335
331
|
|
|
336
332
|
`Router` tidak memiliki opsi sendiri; `requestTimeout` hanya dipakai oleh `App`.
|
|
337
333
|
|
|
@@ -373,10 +369,7 @@ console.log(server.address().port);
|
|
|
373
369
|
```js
|
|
374
370
|
const https = require("https");
|
|
375
371
|
|
|
376
|
-
const server = https.createServer(
|
|
377
|
-
{ ...App.SERVER_OPTIONS, key, cert },
|
|
378
|
-
(req, res) => app.handle(req, res),
|
|
379
|
-
);
|
|
372
|
+
const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
|
|
380
373
|
server.listen(443);
|
|
381
374
|
```
|
|
382
375
|
|
|
@@ -384,11 +377,11 @@ server.listen(443);
|
|
|
384
377
|
|
|
385
378
|
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
386
379
|
|
|
387
|
-
|
|
|
388
|
-
|
|
|
389
|
-
| Fastify
|
|
390
|
-
| **@ndiinginc/router** | 29.735
|
|
391
|
-
| Express
|
|
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 |
|
|
392
385
|
|
|
393
386
|
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
394
387
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ndiinginc/router",
|
|
3
|
-
"version": "4.11.
|
|
3
|
+
"version": "4.11.2",
|
|
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
|
@@ -11,26 +11,27 @@ const SERVER_OPTIONS = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
class App extends Router {
|
|
14
|
+
/**
|
|
15
|
+
* @param {Request} req
|
|
16
|
+
* @param {Response} res
|
|
17
|
+
* @param {Promise} handlerPromise
|
|
18
|
+
*/
|
|
14
19
|
async withTimeout(req, res, handlerPromise) {
|
|
15
20
|
let handleAbort;
|
|
16
21
|
const timeoutPromise = new Promise((_, reject) => {
|
|
17
22
|
handleAbort = () => reject(req.controller.signal.reason);
|
|
18
23
|
req.controller.signal.addEventListener("abort", handleAbort, { once: true });
|
|
19
24
|
});
|
|
20
|
-
|
|
21
25
|
const timeout = setTimeout(() => {
|
|
22
26
|
res.statusCode = 504;
|
|
23
27
|
req.controller.abort(new Error(http.STATUS_CODES[res.statusCode]));
|
|
24
28
|
}, this.requestTimeout);
|
|
25
|
-
|
|
26
29
|
const promise = handlerPromise().catch((error) => {
|
|
27
30
|
if (!req.controller.signal.aborted) {
|
|
28
31
|
req.controller.abort(error);
|
|
29
32
|
} else {
|
|
30
|
-
console.error(error); // swallow error?
|
|
31
33
|
}
|
|
32
34
|
});
|
|
33
|
-
|
|
34
35
|
try {
|
|
35
36
|
await Promise.race([promise, timeoutPromise]);
|
|
36
37
|
} finally {
|
|
@@ -39,34 +40,34 @@ class App extends Router {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {Response} res
|
|
45
|
+
* @param {Error} err
|
|
46
|
+
*/
|
|
42
47
|
fallback(res, err) {
|
|
43
48
|
if (!res.writableEnded) {
|
|
44
49
|
if (!err) {
|
|
45
50
|
res.statusCode = 404;
|
|
46
51
|
err = new Error(http.STATUS_CODES[res.statusCode]);
|
|
47
52
|
}
|
|
48
|
-
|
|
49
53
|
if (res.statusCode < 400) {
|
|
50
54
|
res.statusCode = 500;
|
|
51
55
|
}
|
|
52
|
-
|
|
53
56
|
const error = { message: err.message };
|
|
54
57
|
if (process.env.NODE_ENV === "development") {
|
|
55
58
|
error.stack = err.stack;
|
|
56
59
|
}
|
|
57
|
-
|
|
58
60
|
res.json({ error });
|
|
59
61
|
}
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
/**
|
|
63
|
-
* @param {
|
|
64
|
-
* @param {
|
|
65
|
+
* @param {Request} req
|
|
66
|
+
* @param {Response} res
|
|
65
67
|
*/
|
|
66
68
|
async handle(req, res) {
|
|
67
69
|
const { params, paramsLength, handlers, errorHandlers } = this.find(req.method, req.path);
|
|
68
70
|
req.params = paramsLength ? Object.assign(Object.create(null), params) : EMPTY_PARAMS;
|
|
69
|
-
|
|
70
71
|
let err;
|
|
71
72
|
try {
|
|
72
73
|
const handlerPromise = async () => {
|
|
@@ -75,7 +76,6 @@ class App extends Router {
|
|
|
75
76
|
await handler(req, res);
|
|
76
77
|
}
|
|
77
78
|
};
|
|
78
|
-
|
|
79
79
|
if (this.requestTimeout) {
|
|
80
80
|
await this.withTimeout(req, res, handlerPromise);
|
|
81
81
|
} else {
|
|
@@ -83,7 +83,6 @@ class App extends Router {
|
|
|
83
83
|
}
|
|
84
84
|
} catch (error) {
|
|
85
85
|
err = error;
|
|
86
|
-
|
|
87
86
|
try {
|
|
88
87
|
for (const handler of errorHandlers) {
|
|
89
88
|
if (res.writableEnded) break;
|
|
@@ -93,10 +92,13 @@ class App extends Router {
|
|
|
93
92
|
err = new Error(error.message, { cause: err });
|
|
94
93
|
}
|
|
95
94
|
}
|
|
96
|
-
|
|
97
95
|
this.fallback(res, err);
|
|
98
96
|
}
|
|
99
97
|
|
|
98
|
+
/**
|
|
99
|
+
* @param {import('net').ListenOptions} args
|
|
100
|
+
* @returns {import('http').Server}
|
|
101
|
+
*/
|
|
100
102
|
listen(...args) {
|
|
101
103
|
const requestListener = (req, res) => this.handle(req, res);
|
|
102
104
|
const server = http.createServer(SERVER_OPTIONS, requestListener);
|
|
@@ -105,6 +107,6 @@ class App extends Router {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
App.SERVER_OPTIONS=SERVER_OPTIONS
|
|
110
|
+
App.SERVER_OPTIONS = SERVER_OPTIONS;
|
|
109
111
|
|
|
110
112
|
exports.App = App;
|
package/src/middleware/cors.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @typedef
|
|
3
|
-
* @property {
|
|
4
|
-
* @property {
|
|
5
|
-
* @property {
|
|
6
|
-
* @property {Number}
|
|
7
|
-
* @property {
|
|
2
|
+
* @typedef {Object} Options
|
|
3
|
+
* @property {string|array} origin
|
|
4
|
+
* @property {boolean} credentials
|
|
5
|
+
* @property {string|array} headers
|
|
6
|
+
* @property {Number} maxAge
|
|
7
|
+
* @property {string|array} methods
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* @
|
|
11
|
+
* @callback Callback
|
|
12
|
+
* @param {import('../request')} req
|
|
13
|
+
* @param {import('../response')} res
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Options} Options
|
|
18
|
+
* @returns {Callback}
|
|
12
19
|
*/
|
|
13
20
|
function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
|
|
14
21
|
if (Array.isArray(headers)) {
|
|
@@ -28,15 +35,12 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
28
35
|
const reqOrigin = req.headers["origin"];
|
|
29
36
|
let value;
|
|
30
37
|
let explicit = false;
|
|
31
|
-
|
|
32
38
|
if (origin === "*" && !credentials) {
|
|
33
39
|
value = "*";
|
|
34
40
|
} else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.includes(reqOrigin))) {
|
|
35
41
|
value = reqOrigin;
|
|
36
42
|
explicit = true;
|
|
37
43
|
}
|
|
38
|
-
// origin === null -> skip (jangan set "null", celah keamanan)
|
|
39
|
-
|
|
40
44
|
if (value) res.setHeader("Access-Control-Allow-Origin", value);
|
|
41
45
|
if (explicit) res.appendHeader("Vary", "Origin");
|
|
42
46
|
}
|
|
@@ -44,9 +48,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
44
48
|
function setAllowHeaders(req, res) {
|
|
45
49
|
const reqHeaders = req.headers["access-control-request-headers"];
|
|
46
50
|
if (!reqHeaders) return;
|
|
47
|
-
|
|
48
51
|
const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
|
|
49
|
-
|
|
50
52
|
let value;
|
|
51
53
|
if (headers === "*" && !credentials) {
|
|
52
54
|
value = "*";
|
|
@@ -54,7 +56,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
54
56
|
const allowed = requested.filter((h) => headers.includes(h));
|
|
55
57
|
if (allowed.length === requested.length) value = headers;
|
|
56
58
|
} else {
|
|
57
|
-
// headers undefined -> echo request headers (wajib per spec)
|
|
58
59
|
value = requested;
|
|
59
60
|
}
|
|
60
61
|
if (value) res.setHeader("Access-Control-Allow-Headers", value);
|
|
@@ -63,16 +64,13 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
63
64
|
function setAllowMethods(req, res) {
|
|
64
65
|
const reqMethod = req.headers["access-control-request-method"];
|
|
65
66
|
if (!reqMethod) return;
|
|
66
|
-
|
|
67
67
|
const method = reqMethod.toUpperCase();
|
|
68
|
-
|
|
69
68
|
let value;
|
|
70
69
|
if (methods === "*" && !credentials) {
|
|
71
70
|
value = "*";
|
|
72
71
|
} else if (Array.isArray(methods)) {
|
|
73
72
|
if (methods.includes(method)) value = methods;
|
|
74
73
|
} else {
|
|
75
|
-
// methods undefined -> echo request method
|
|
76
74
|
value = [method];
|
|
77
75
|
}
|
|
78
76
|
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
@@ -96,9 +94,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
96
94
|
|
|
97
95
|
return (req, res) => {
|
|
98
96
|
setAllowCredentials(res);
|
|
99
|
-
|
|
100
97
|
if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
|
|
101
|
-
// preflight
|
|
102
98
|
setAllowOrigin(req, res);
|
|
103
99
|
setAllowHeaders(req, res);
|
|
104
100
|
setAllowMethods(req, res);
|
|
@@ -107,7 +103,6 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
107
103
|
res.setHeader("Content-Length", "0");
|
|
108
104
|
res.end();
|
|
109
105
|
} else {
|
|
110
|
-
// normal response (termasuk OPTIONS non-preflight)
|
|
111
106
|
setAllowOrigin(req, res);
|
|
112
107
|
setExposeHeaders(res);
|
|
113
108
|
}
|
package/src/middleware/json.js
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
|
+
/**
|
|
3
|
+
* @typedef {Object} Options
|
|
4
|
+
* @property {Number} [limit=1.024e+5]
|
|
5
|
+
*/
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
/**
|
|
8
|
+
* @callback Callback
|
|
9
|
+
* @param {import('../request')} req
|
|
10
|
+
* @param {import('../response')} res
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Options} Options
|
|
15
|
+
* @returns {Callback}
|
|
16
|
+
*/
|
|
17
|
+
function json({ limit = 1.024e5 } = {}) {
|
|
4
18
|
const reviver = (key, value) => {
|
|
5
19
|
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
6
20
|
return undefined;
|
|
7
21
|
}
|
|
8
22
|
return value;
|
|
9
23
|
};
|
|
24
|
+
|
|
10
25
|
return async (req, res) => {
|
|
11
26
|
if (!["POST", "PUT", "PATCH", "QUERY"].includes(req.method)) {
|
|
12
27
|
return;
|
package/src/node.js
CHANGED
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
class Node {
|
|
2
|
+
/**
|
|
3
|
+
* @param {String} segment
|
|
4
|
+
*/
|
|
2
5
|
constructor(segment = "") {
|
|
3
6
|
this.segment = segment;
|
|
4
|
-
|
|
7
|
+
if (segment.startsWith(":")) {
|
|
8
|
+
/**@type {String}*/
|
|
9
|
+
this.name = segment.slice(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**@type {Map<string,Node>}*/
|
|
5
13
|
this.children = new Map();
|
|
6
|
-
|
|
14
|
+
|
|
15
|
+
/**@type {Map<string,Node>}*/
|
|
7
16
|
this.params = new Map();
|
|
8
|
-
|
|
17
|
+
|
|
18
|
+
/**@type {Node}*/
|
|
9
19
|
this.wildcard = null;
|
|
20
|
+
/**@type {Map<string,Function[]>}*/
|
|
10
21
|
this.handlers = new Map();
|
|
11
22
|
}
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* @param {String} segment
|
|
26
|
+
* @returns {Node}
|
|
27
|
+
*/
|
|
13
28
|
add(segment) {
|
|
14
29
|
if (segment === "*") {
|
|
15
30
|
if (!this.wildcard) this.wildcard = new Node("*");
|
|
16
31
|
return this.wildcard;
|
|
17
32
|
}
|
|
18
|
-
|
|
19
33
|
if (segment.startsWith(":")) {
|
|
20
34
|
return this.params.getOrInsertComputed(segment, (segment) => {
|
|
21
35
|
return new Node(segment);
|
|
22
36
|
});
|
|
23
37
|
}
|
|
24
|
-
|
|
25
38
|
return this.children.getOrInsertComputed(segment, (segment) => {
|
|
26
39
|
return new Node(segment);
|
|
27
40
|
});
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/**
|
|
44
|
+
* @param {Node} node
|
|
45
|
+
* @param {Number} offset
|
|
46
|
+
*/
|
|
30
47
|
merge(node, offset) {
|
|
31
48
|
for (const [method, entries] of node.handlers) {
|
|
32
49
|
for (const entry of entries) {
|
|
@@ -36,41 +53,36 @@ class Node {
|
|
|
36
53
|
});
|
|
37
54
|
}
|
|
38
55
|
}
|
|
39
|
-
|
|
40
56
|
if (node.wildcard) {
|
|
41
57
|
this.add("*").merge(node.wildcard, offset);
|
|
42
58
|
}
|
|
43
|
-
|
|
44
59
|
for (const [segment, child] of node.params) {
|
|
45
60
|
this.add(segment).merge(child, offset);
|
|
46
61
|
}
|
|
47
|
-
|
|
48
62
|
for (const [segment, child] of node.children) {
|
|
49
63
|
this.add(segment).merge(child, offset);
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @returns {Number}
|
|
69
|
+
*/
|
|
53
70
|
getMaxSeq() {
|
|
54
71
|
let max = -1;
|
|
55
|
-
|
|
56
72
|
for (const [, entries] of this.handlers) {
|
|
57
73
|
for (const entry of entries) {
|
|
58
74
|
max = Math.max(max, entry.seq);
|
|
59
75
|
}
|
|
60
76
|
}
|
|
61
|
-
|
|
62
77
|
if (this.wildcard) {
|
|
63
78
|
max = Math.max(max, this.wildcard.getMaxSeq());
|
|
64
79
|
}
|
|
65
|
-
|
|
66
80
|
for (const [, child] of this.params) {
|
|
67
81
|
max = Math.max(max, child.getMaxSeq());
|
|
68
82
|
}
|
|
69
|
-
|
|
70
83
|
for (const [, child] of this.children) {
|
|
71
84
|
max = Math.max(max, child.getMaxSeq());
|
|
72
85
|
}
|
|
73
|
-
|
|
74
86
|
return max;
|
|
75
87
|
}
|
|
76
88
|
}
|
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,18 +20,13 @@ 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
|
|
|
28
|
-
/**
|
|
29
|
-
* @returns {AbortController}
|
|
30
|
-
*/
|
|
31
26
|
get controller() {
|
|
32
27
|
if (!this._controller) {
|
|
33
28
|
this._controller = new AbortController();
|
|
34
29
|
}
|
|
35
|
-
|
|
36
30
|
return this._controller;
|
|
37
31
|
}
|
|
38
32
|
}
|
package/src/response.js
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
2
|
|
|
3
3
|
class Response extends http.ServerResponse {
|
|
4
|
+
/**
|
|
5
|
+
* @param {Number} code
|
|
6
|
+
* @returns {Response}
|
|
7
|
+
*/
|
|
4
8
|
status(code) {
|
|
5
9
|
this.statusCode = code;
|
|
6
10
|
return this;
|
|
7
11
|
}
|
|
8
12
|
|
|
13
|
+
/**
|
|
14
|
+
* @param {String} chunk
|
|
15
|
+
*/
|
|
9
16
|
send(chunk) {
|
|
10
17
|
if (this.writableEnded) return;
|
|
11
18
|
this.end(chunk, null, null);
|
|
12
19
|
}
|
|
13
|
-
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} value
|
|
23
|
+
*/
|
|
14
24
|
json(value) {
|
|
15
25
|
if (!this.headersSent) {
|
|
16
26
|
this.setHeader("Content-Type", "application/json; charset=utf-8");
|
package/src/router.js
CHANGED
|
@@ -1,31 +1,45 @@
|
|
|
1
1
|
const { decodeParam } = require("./util");
|
|
2
2
|
const { Node } = require("./node");
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {Object} Options
|
|
6
|
+
* @property {Number} requestTimeout
|
|
7
|
+
*/
|
|
8
|
+
|
|
4
9
|
class Router {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Options} options
|
|
12
|
+
*/
|
|
5
13
|
constructor(options = {}) {
|
|
6
14
|
this.requestTimeout = options.requestTimeout ?? 0;
|
|
7
15
|
this.root = new Node();
|
|
8
16
|
this.seq = 0;
|
|
9
17
|
}
|
|
10
18
|
|
|
19
|
+
/**
|
|
20
|
+
* @param {String} path
|
|
21
|
+
* @returns {String[]}
|
|
22
|
+
*/
|
|
11
23
|
split(path) {
|
|
12
24
|
return path.split("/").filter(Boolean);
|
|
13
25
|
}
|
|
14
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @param {String} method
|
|
29
|
+
* @param {String|Function} path
|
|
30
|
+
* @param {Function[]} handlers
|
|
31
|
+
*/
|
|
15
32
|
add(method, path, ...handlers) {
|
|
16
33
|
if (typeof path === "function") {
|
|
17
34
|
handlers = [path, ...handlers];
|
|
18
35
|
path = null;
|
|
19
36
|
}
|
|
20
|
-
|
|
21
37
|
let node = this.root;
|
|
22
|
-
|
|
23
38
|
if (path) {
|
|
24
39
|
for (const segment of this.split(path)) {
|
|
25
40
|
node = node.add(segment);
|
|
26
41
|
}
|
|
27
42
|
}
|
|
28
|
-
|
|
29
43
|
for (const handler of handlers) {
|
|
30
44
|
if (handler.root) {
|
|
31
45
|
node.merge(handler.root, this.seq);
|
|
@@ -40,128 +54,193 @@ class Router {
|
|
|
40
54
|
}
|
|
41
55
|
}
|
|
42
56
|
|
|
57
|
+
/**
|
|
58
|
+
* @param {String|Function} path
|
|
59
|
+
* @param {Function[]} handlers
|
|
60
|
+
* @returns {this}
|
|
61
|
+
*/
|
|
43
62
|
use(path, ...handlers) {
|
|
44
63
|
this.add("ALL", path, ...handlers);
|
|
45
64
|
return this;
|
|
46
65
|
}
|
|
47
66
|
|
|
67
|
+
/**
|
|
68
|
+
* @param {String|Function} path
|
|
69
|
+
* @param {Function[]} handlers
|
|
70
|
+
* @returns {this}
|
|
71
|
+
*/
|
|
48
72
|
get(path, ...handlers) {
|
|
49
73
|
this.add("GET", path, ...handlers);
|
|
50
74
|
return this;
|
|
51
75
|
}
|
|
52
76
|
|
|
77
|
+
/**
|
|
78
|
+
* @param {String|Function} path
|
|
79
|
+
* @param {Function[]} handlers
|
|
80
|
+
* @returns {this}
|
|
81
|
+
*/
|
|
53
82
|
query(path, ...handlers) {
|
|
54
83
|
this.add("QUERY", path, ...handlers);
|
|
55
84
|
return this;
|
|
56
85
|
}
|
|
57
86
|
|
|
87
|
+
/**
|
|
88
|
+
* @param {String|Function} path
|
|
89
|
+
* @param {Function[]} handlers
|
|
90
|
+
* @returns {this}
|
|
91
|
+
*/
|
|
58
92
|
head(path, ...handlers) {
|
|
59
93
|
this.add("HEAD", path, ...handlers);
|
|
60
94
|
return this;
|
|
61
95
|
}
|
|
62
96
|
|
|
97
|
+
/**
|
|
98
|
+
* @param {String|Function} path
|
|
99
|
+
* @param {Function[]} handlers
|
|
100
|
+
* @returns {this}
|
|
101
|
+
*/
|
|
63
102
|
options(path, ...handlers) {
|
|
64
103
|
this.add("OPTIONS", path, ...handlers);
|
|
65
104
|
return this;
|
|
66
105
|
}
|
|
67
106
|
|
|
107
|
+
/**
|
|
108
|
+
* @param {String|Function} path
|
|
109
|
+
* @param {Function[]} handlers
|
|
110
|
+
* @returns {this}
|
|
111
|
+
*/
|
|
68
112
|
trace(path, ...handlers) {
|
|
69
113
|
this.add("TRACE", path, ...handlers);
|
|
70
114
|
return this;
|
|
71
115
|
}
|
|
72
116
|
|
|
117
|
+
/**
|
|
118
|
+
* @param {String|Function} path
|
|
119
|
+
* @param {Function[]} handlers
|
|
120
|
+
* @returns {this}
|
|
121
|
+
*/
|
|
73
122
|
put(path, ...handlers) {
|
|
74
123
|
this.add("PUT", path, ...handlers);
|
|
75
124
|
return this;
|
|
76
125
|
}
|
|
77
126
|
|
|
127
|
+
/**
|
|
128
|
+
* @param {String|Function} path
|
|
129
|
+
* @param {Function[]} handlers
|
|
130
|
+
* @returns {this}
|
|
131
|
+
*/
|
|
78
132
|
delete(path, ...handlers) {
|
|
79
133
|
this.add("DELETE", path, ...handlers);
|
|
80
134
|
return this;
|
|
81
135
|
}
|
|
82
136
|
|
|
137
|
+
/**
|
|
138
|
+
* @param {String|Function} path
|
|
139
|
+
* @param {Function[]} handlers
|
|
140
|
+
* @returns {this}
|
|
141
|
+
*/
|
|
83
142
|
post(path, ...handlers) {
|
|
84
143
|
this.add("POST", path, ...handlers);
|
|
85
144
|
return this;
|
|
86
145
|
}
|
|
87
146
|
|
|
147
|
+
/**
|
|
148
|
+
* @param {String|Function} path
|
|
149
|
+
* @param {Function[]} handlers
|
|
150
|
+
* @returns {this}
|
|
151
|
+
*/
|
|
88
152
|
patch(path, ...handlers) {
|
|
89
153
|
this.add("PATCH", path, ...handlers);
|
|
90
154
|
return this;
|
|
91
155
|
}
|
|
92
156
|
|
|
157
|
+
/**
|
|
158
|
+
* @param {String|Function} path
|
|
159
|
+
* @param {Function[]} handlers
|
|
160
|
+
* @returns {this}
|
|
161
|
+
*/
|
|
93
162
|
connect(path, ...handlers) {
|
|
94
163
|
this.add("CONNECT", path, ...handlers);
|
|
95
164
|
return this;
|
|
96
165
|
}
|
|
97
166
|
|
|
98
167
|
/**
|
|
168
|
+
* @param {String} method
|
|
169
|
+
* @param {String[]} segments
|
|
99
170
|
* @param {Node} node
|
|
100
|
-
* @param {
|
|
101
|
-
* @param {
|
|
102
|
-
* @param {
|
|
103
|
-
* @param {
|
|
104
|
-
* @
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
search(node, segments, index, path, params, state,method) {
|
|
171
|
+
* @param {Array} path
|
|
172
|
+
* @param {Object} params
|
|
173
|
+
* @param {Object} state
|
|
174
|
+
* @param {Number} index
|
|
175
|
+
* @returns {{path:Array,params:Object,matched:Boolean}}
|
|
176
|
+
*/
|
|
177
|
+
search(method, segments, node, path = [], params = {}, state = { path: [], params: {} }, index = 0) {
|
|
108
178
|
path.push(node);
|
|
109
|
-
|
|
110
|
-
// habis segmen -> full match
|
|
111
|
-
// if (index === segments.length) {
|
|
112
|
-
// return { nodes: path.slice(), params: { ...params }, matched: true };
|
|
113
|
-
// }
|
|
114
179
|
if (index === segments.length) {
|
|
115
|
-
if (node.handlers.has(method)) {
|
|
116
|
-
return {
|
|
180
|
+
if (node.handlers.has(method) || node.handlers.has("ALL")) {
|
|
181
|
+
return { path, params, matched: true };
|
|
117
182
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
state.
|
|
183
|
+
if (path.length > state.path.length) {
|
|
184
|
+
state.path = path.slice();
|
|
185
|
+
state.params = { ...params };
|
|
121
186
|
}
|
|
122
187
|
path.pop();
|
|
123
|
-
return { matched: false };
|
|
188
|
+
return { path: state.path, params: state.params, matched: false };
|
|
124
189
|
}
|
|
125
|
-
|
|
126
190
|
const segment = segments[index];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
191
|
+
if (node.children.has(segment)) {
|
|
192
|
+
const res = this.search(
|
|
193
|
+
//
|
|
194
|
+
method,
|
|
195
|
+
segments,
|
|
196
|
+
node.children.get(segment),
|
|
197
|
+
path,
|
|
198
|
+
params,
|
|
199
|
+
state,
|
|
200
|
+
index + 1,
|
|
201
|
+
);
|
|
132
202
|
if (res.matched) return res;
|
|
133
203
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
204
|
+
if (node.params.size) {
|
|
205
|
+
for (const [, child] of node.params) {
|
|
206
|
+
const prev = params[child.name];
|
|
207
|
+
params[child.name] = decodeParam(segment);
|
|
208
|
+
const res = this.search(
|
|
209
|
+
//
|
|
210
|
+
method,
|
|
211
|
+
segments,
|
|
212
|
+
child,
|
|
213
|
+
path,
|
|
214
|
+
params,
|
|
215
|
+
state,
|
|
216
|
+
index + 1,
|
|
217
|
+
);
|
|
218
|
+
if (res.matched) return res;
|
|
219
|
+
if (prev === undefined) {
|
|
220
|
+
const { [child.name]: _, ...rest } = params;
|
|
221
|
+
params = rest;
|
|
222
|
+
} else params[child.name] = prev;
|
|
223
|
+
}
|
|
145
224
|
}
|
|
146
|
-
|
|
147
|
-
// 3. wildcard (prioritas terendah, menyerap sisa segmen)
|
|
148
|
-
if (node.wildcard) {
|
|
225
|
+
if (node.wildcard && (node.wildcard.handlers.get(method) || node.wildcard.handlers.get("ALL"))) {
|
|
149
226
|
const rest = segments.slice(index).join("/");
|
|
150
227
|
params["wildcard"] = decodeParam(rest);
|
|
151
228
|
path.push(node.wildcard);
|
|
152
|
-
return {
|
|
229
|
+
return { path, params, matched: true };
|
|
153
230
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (path.length > state.best.nodes.length) {
|
|
158
|
-
state.best = { nodes: path.slice(), params: { ...params } };
|
|
231
|
+
if (path.length > state.path.length) {
|
|
232
|
+
state.path = path.slice();
|
|
233
|
+
state.params = { ...params };
|
|
159
234
|
}
|
|
160
|
-
|
|
161
235
|
path.pop();
|
|
162
|
-
return { matched: false };
|
|
236
|
+
return { path: state.path, params: state.params, matched: false };
|
|
163
237
|
}
|
|
164
238
|
|
|
239
|
+
/**
|
|
240
|
+
* @param {Node} r
|
|
241
|
+
* @param {String} method
|
|
242
|
+
* @param {Object} handlers
|
|
243
|
+
*/
|
|
165
244
|
collectHandler(r, method, handlers) {
|
|
166
245
|
if (r.handlers.has(method)) {
|
|
167
246
|
for (const entry of r.handlers.get(method)) {
|
|
@@ -170,49 +249,40 @@ class Router {
|
|
|
170
249
|
}
|
|
171
250
|
}
|
|
172
251
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
252
|
+
/**
|
|
253
|
+
* @param {Node} r
|
|
254
|
+
* @param {Object} handlers
|
|
255
|
+
* @param {Object} errorHandlers
|
|
256
|
+
*/
|
|
181
257
|
collectMiddleware(r, handlers, errorHandlers) {
|
|
182
258
|
this.collectHandler(r, "ALL", handlers);
|
|
183
|
-
this.
|
|
259
|
+
this.collectHandler(r, "CATCH", errorHandlers);
|
|
184
260
|
}
|
|
185
261
|
|
|
262
|
+
/**
|
|
263
|
+
* @param {String} method
|
|
264
|
+
* @param {String} path
|
|
265
|
+
* @returns {{matched: boolean;params: Object;paramsLength: number;handlers: any[];errorHandlers: any[];}}
|
|
266
|
+
*/
|
|
186
267
|
find(method, path) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
268
|
+
const res = this.search(
|
|
269
|
+
//
|
|
270
|
+
method,
|
|
271
|
+
this.split(path),
|
|
272
|
+
this.root,
|
|
273
|
+
);
|
|
190
274
|
const handlers = {};
|
|
191
275
|
const errorHandlers = {};
|
|
192
|
-
|
|
193
|
-
/** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
|
|
194
|
-
const state = { best: { nodes: [], params: {} } };
|
|
195
|
-
|
|
196
|
-
const res = this.search(this.root, segments, 0, [], params, state,method);
|
|
197
|
-
|
|
198
|
-
const matchedNodes = res.matched ? res.nodes : state.best.nodes;
|
|
199
|
-
const finalParams = res.matched ? res.params : state.best.params;
|
|
200
|
-
|
|
201
|
-
// middleware dari semua node di jalur (partial atau full)
|
|
202
|
-
for (const r of matchedNodes) {
|
|
276
|
+
for (const r of res.path) {
|
|
203
277
|
this.collectMiddleware(r, handlers, errorHandlers);
|
|
204
278
|
}
|
|
205
|
-
|
|
206
|
-
// method handler HANYA kalau full match
|
|
207
279
|
if (res.matched) {
|
|
208
|
-
|
|
209
|
-
this.collectHandler(leaf, method, handlers);
|
|
280
|
+
this.collectHandler(res.path.at(-1), method, handlers);
|
|
210
281
|
}
|
|
211
|
-
|
|
212
282
|
return {
|
|
213
283
|
matched: res.matched,
|
|
214
|
-
params:
|
|
215
|
-
paramsLength: Object.keys(
|
|
284
|
+
params: res.params,
|
|
285
|
+
paramsLength: Object.keys(res.params).length,
|
|
216
286
|
handlers: Object.values(handlers),
|
|
217
287
|
errorHandlers: Object.values(errorHandlers),
|
|
218
288
|
};
|
package/src/util.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
const EMPTY_PARAMS = Object.create(null)
|
|
1
|
+
const EMPTY_PARAMS = Object.create(null);
|
|
3
2
|
exports.EMPTY_PARAMS = EMPTY_PARAMS;
|
|
4
3
|
|
|
4
|
+
/**
|
|
5
|
+
* @param {String} raw
|
|
6
|
+
* @returns {String}
|
|
7
|
+
*/
|
|
5
8
|
function decodeParam(raw) {
|
|
6
9
|
try {
|
|
7
10
|
return decodeURIComponent(raw);
|
|
@@ -11,9 +14,12 @@ function decodeParam(raw) {
|
|
|
11
14
|
}
|
|
12
15
|
exports.decodeParam = decodeParam;
|
|
13
16
|
|
|
17
|
+
/**
|
|
18
|
+
* @param {String} raw
|
|
19
|
+
* @returns {String}
|
|
20
|
+
*/
|
|
14
21
|
function decodeQuery(raw) {
|
|
15
22
|
const normalized = raw.replace(/\+/g, " ");
|
|
16
|
-
|
|
17
23
|
try {
|
|
18
24
|
return decodeURIComponent(normalized);
|
|
19
25
|
} catch (error) {
|
|
@@ -21,19 +27,19 @@ function decodeQuery(raw) {
|
|
|
21
27
|
}
|
|
22
28
|
}
|
|
23
29
|
|
|
30
|
+
/**
|
|
31
|
+
* @param {String} queryString
|
|
32
|
+
* @returns {Object}
|
|
33
|
+
*/
|
|
24
34
|
function parseQueryString(queryString) {
|
|
25
35
|
if (queryString === "") return Object.create(null);
|
|
26
|
-
|
|
27
36
|
const regex = /([^=&]*)=([^&]*)/g;
|
|
28
37
|
const result = Object.create(null);
|
|
29
|
-
|
|
30
38
|
let match;
|
|
31
39
|
while ((match = regex.exec(queryString))) {
|
|
32
40
|
const [, rawName, rawValue] = match;
|
|
33
|
-
|
|
34
41
|
const name = decodeQuery(rawName);
|
|
35
42
|
const value = decodeQuery(rawValue);
|
|
36
|
-
|
|
37
43
|
if (Object.hasOwn(result, name)) {
|
|
38
44
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
39
45
|
else result[name] = [result[name], value];
|
|
@@ -44,14 +50,16 @@ function parseQueryString(queryString) {
|
|
|
44
50
|
}
|
|
45
51
|
exports.parseQueryString = parseQueryString;
|
|
46
52
|
|
|
53
|
+
/**
|
|
54
|
+
* @param {String} url
|
|
55
|
+
* @returns {{path: any;queryString: any;}}
|
|
56
|
+
*/
|
|
47
57
|
function parseUrl(url) {
|
|
48
58
|
if (!(url.includes("?") || url.includes("#"))) {
|
|
49
59
|
return { path: url, queryString: "" };
|
|
50
60
|
}
|
|
51
|
-
|
|
52
61
|
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
53
62
|
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
54
|
-
|
|
55
63
|
return { path, queryString };
|
|
56
64
|
}
|
|
57
65
|
exports.parseUrl = parseUrl;
|