@ndiinginc/router 4.7.0 → 4.8.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 +27 -32
- package/package.json +2 -2
- package/src/app.js +5 -0
- package/src/cache.js +5 -1
- package/src/middleware/cors.js +11 -18
- package/src/node.js +56 -22
- package/src/response.js +2 -2
- package/src/router.js +80 -53
- package/src/util.js +19 -15
- package/src/pattern.js +0 -34
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@ 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`
|
|
@@ -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,28 @@ 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
|
+
> Di setiap level hanya ada satu parameter. Jika `/users/:id` dan `/users/:userId/posts` didaftarkan bersamaan, keduanya memakai nama yang pertama didaftarkan (`id`). Pakai nama yang sama untuk level yang sama.
|
|
97
|
+
|
|
94
98
|
### Wildcard
|
|
95
99
|
|
|
96
|
-
`*`
|
|
100
|
+
Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params[0]`.
|
|
97
101
|
|
|
98
102
|
```js
|
|
99
|
-
app.get("/files
|
|
100
|
-
res.json({
|
|
103
|
+
app.get("/files/*", (req, res) => {
|
|
104
|
+
res.json({ file: req.params[0] });
|
|
101
105
|
});
|
|
106
|
+
|
|
107
|
+
// GET /files/images/logo.png -> { file: "images/logo.png" }
|
|
102
108
|
```
|
|
103
109
|
|
|
110
|
+
Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `/files`.
|
|
111
|
+
|
|
104
112
|
### Aturan matching
|
|
105
113
|
|
|
106
|
-
-
|
|
107
|
-
- Jika
|
|
114
|
+
- Di setiap level, router mencoba **static dulu, lalu parameter, lalu wildcard**. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
|
|
115
|
+
- Jika cabang static gagal di tengah jalan, router mundur (backtrack) dan mencoba parameter, lalu wildcard.
|
|
108
116
|
- Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
|
|
109
117
|
|
|
110
118
|
## Middleware
|
|
@@ -127,7 +135,7 @@ app.use("/admin", async (req, res) => {
|
|
|
127
135
|
|
|
128
136
|
Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
|
|
129
137
|
|
|
130
|
-
>
|
|
138
|
+
> Jika path tidak cocok dengan route mana pun, hanya middleware global (tanpa path) yang dijalankan sebelum respons 404 dikirim.
|
|
131
139
|
|
|
132
140
|
## Nested Router
|
|
133
141
|
|
|
@@ -226,7 +234,7 @@ app.get("/proxy", async (req, res) => {
|
|
|
226
234
|
| --- | --- |
|
|
227
235
|
| `req.path` | Pathname tanpa query string |
|
|
228
236
|
| `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) |
|
|
237
|
+
| `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params[0]` |
|
|
230
238
|
| `req.body` | Hasil parse body (butuh middleware `json()`) |
|
|
231
239
|
| `req.signal` | `AbortSignal` yang di-abort saat timeout atau error handler (hanya jika `requestTimeout` aktif) |
|
|
232
240
|
|
|
@@ -237,7 +245,7 @@ app.get("/search", (req, res) => {
|
|
|
237
245
|
});
|
|
238
246
|
```
|
|
239
247
|
|
|
240
|
-
`req.query` dan `req.params` memakai object tanpa prototype, jadi gunakan `Object.hasOwn(req.query, "key")` atau `"key" in req.query`, bukan `req.query.hasOwnProperty(...)`.
|
|
248
|
+
`req.query` dan `req.params` yang sampai ke handler memakai object tanpa prototype, jadi gunakan `Object.hasOwn(req.query, "key")` atau `"key" in req.query`, bukan `req.query.hasOwnProperty(...)`.
|
|
241
249
|
|
|
242
250
|
`Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
|
|
243
251
|
|
|
@@ -280,7 +288,7 @@ Perilaku:
|
|
|
280
288
|
```js
|
|
281
289
|
app.use(
|
|
282
290
|
App.cors({
|
|
283
|
-
origin: "https://example.com",
|
|
291
|
+
origin: ["https://example.com", "https://app.example.com"],
|
|
284
292
|
credentials: true,
|
|
285
293
|
methods: ["GET", "POST", "PUT", "DELETE"],
|
|
286
294
|
headers: ["Content-Type", "Authorization"],
|
|
@@ -291,17 +299,17 @@ app.use(
|
|
|
291
299
|
|
|
292
300
|
| Opsi | Default | Keterangan |
|
|
293
301
|
| --- | --- | --- |
|
|
294
|
-
| `origin` | `"*"` | `"*"`, satu origin,
|
|
302
|
+
| `origin` | `"*"` | `"*"`, satu origin, array origin, atau `null` |
|
|
295
303
|
| `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
|
|
296
|
-
| `headers` | - | `"*"` atau array nama header |
|
|
297
|
-
| `methods` | - | `"*"` atau array nama method |
|
|
304
|
+
| `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
|
|
305
|
+
| `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
|
|
298
306
|
| `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
|
|
299
307
|
|
|
300
308
|
Perilaku:
|
|
301
309
|
|
|
302
310
|
- Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
|
|
303
|
-
-
|
|
304
|
-
-
|
|
311
|
+
- Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan, dan header `Vary: Origin` ditambahkan pada request non-preflight
|
|
312
|
+
- `Access-Control-Allow-Headers` dan `Access-Control-Allow-Methods` hanya dikirim pada preflight, dan hanya jika **semua** header atau method yang diminta ada di daftar
|
|
305
313
|
- Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
|
|
306
314
|
- Pada request non-preflight, `headers` (jika array) dikirim sebagai `Access-Control-Expose-Headers`
|
|
307
315
|
|
|
@@ -315,8 +323,8 @@ const app = new App({ cacheSize: 1000, requestTimeout: 5000 });
|
|
|
315
323
|
|
|
316
324
|
| Opsi | Default | Keterangan |
|
|
317
325
|
| --- | --- | --- |
|
|
318
|
-
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path
|
|
319
|
-
| `requestTimeout` |
|
|
326
|
+
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. Cache dikosongkan setiap ada route baru didaftarkan |
|
|
327
|
+
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
320
328
|
|
|
321
329
|
`Router` hanya menerima `cacheSize`.
|
|
322
330
|
|
|
@@ -351,19 +359,6 @@ const server = app.listen(0); // port acak
|
|
|
351
359
|
console.log(server.address().port);
|
|
352
360
|
```
|
|
353
361
|
|
|
354
|
-
## Benchmark
|
|
355
|
-
|
|
356
|
-
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
|
357
|
-
|
|
358
|
-
| package | Latency | Req/Sec | Bytes/Sec | Result |
|
|
359
|
-
| ----------------- | -------- | -------- | --------- | ------------------------------------- |
|
|
360
|
-
| fastify | 25.3 ms | 38,738.4 | 7.24 MB | 1550k requests in 40.06s, 290 MB read |
|
|
361
|
-
| @ndiinginc/router | 28.45 ms | 34,541.4 | 5.91 MB | 1383k requests in 40.06s, 236 MB read |
|
|
362
|
-
| express | 41 ms | 24,095.6 | 6.05 MB | 965k requests in 40.06s, 242 MB read |
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
|
|
366
|
-
|
|
367
362
|
## Testing
|
|
368
363
|
|
|
369
364
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ndiinginc/router",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.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
|
@@ -67,6 +67,10 @@ class App extends Router {
|
|
|
67
67
|
err = new Error(error.message, { cause: err });
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
err = this.fallback(res, err);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
fallback(res, err) {
|
|
70
74
|
if (!res.writableEnded) {
|
|
71
75
|
if (!err) err = ErrorResponse.status(404);
|
|
72
76
|
res.statusCode = err.statusCode || 500;
|
|
@@ -76,6 +80,7 @@ class App extends Router {
|
|
|
76
80
|
}
|
|
77
81
|
res.json({ error });
|
|
78
82
|
}
|
|
83
|
+
return err;
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
listen(...params) {
|
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
|
}
|
|
@@ -19,6 +19,10 @@ class Cache {
|
|
|
19
19
|
}
|
|
20
20
|
this.map.set(key, value);
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
clear() {
|
|
24
|
+
this.map.clear();
|
|
25
|
+
}
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
exports.Cache = Cache;
|
package/src/middleware/cors.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
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
3
|
* @property {String} [origin="*"]
|
|
@@ -22,9 +11,12 @@ function pattern(string) {
|
|
|
22
11
|
* @param {CORSOptions} options
|
|
23
12
|
*/
|
|
24
13
|
function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if (headers && Array.isArray(headers)) {
|
|
15
|
+
headers = headers.map((header) => header.toLowerCase());
|
|
16
|
+
}
|
|
17
|
+
if (methods && Array.isArray(methods)) {
|
|
18
|
+
methods = methods.map((method) => method.toUpperCase());
|
|
19
|
+
}
|
|
28
20
|
|
|
29
21
|
/**
|
|
30
22
|
* @param {import('http').ServerResponse} res
|
|
@@ -40,12 +32,13 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
40
32
|
* @param {import('http').ServerResponse} res
|
|
41
33
|
*/
|
|
42
34
|
function setAllowHeaders(req, res) {
|
|
43
|
-
|
|
35
|
+
let reqHeaders = req.headers["access-control-request-headers"];
|
|
44
36
|
if (!reqHeaders) return;
|
|
37
|
+
reqHeaders = reqHeaders.split(",").map((header) => header.trim().toLowerCase());
|
|
45
38
|
let value;
|
|
46
39
|
if (headers === "*" && !credentials) {
|
|
47
40
|
value = "*";
|
|
48
|
-
} else if (Array.isArray(headers) &&
|
|
41
|
+
} else if (Array.isArray(headers) && reqHeaders.every((reqHeader) => headers.some((header) => header === reqHeader))) {
|
|
49
42
|
value = headers;
|
|
50
43
|
}
|
|
51
44
|
if (value) res.setHeader("Access-Control-Allow-Headers", value);
|
|
@@ -61,7 +54,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
61
54
|
let value;
|
|
62
55
|
if (methods === "*" && !credentials) {
|
|
63
56
|
value = "*";
|
|
64
|
-
} else if (Array.isArray(methods) &&
|
|
57
|
+
} else if (Array.isArray(methods) && methods.some((method) => method === reqMethod.toUpperCase())) {
|
|
65
58
|
value = methods;
|
|
66
59
|
}
|
|
67
60
|
if (value) res.setHeader("Access-Control-Allow-Methods", value);
|
|
@@ -80,7 +73,7 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
|
|
|
80
73
|
value = "*";
|
|
81
74
|
} else if (origin === null) {
|
|
82
75
|
value = "null";
|
|
83
|
-
} else if (
|
|
76
|
+
} else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.some((origin) => origin === reqOrigin))) {
|
|
84
77
|
value = reqOrigin;
|
|
85
78
|
explicit = true;
|
|
86
79
|
}
|
package/src/node.js
CHANGED
|
@@ -1,46 +1,80 @@
|
|
|
1
|
-
const { Pattern } = require("./pattern.js");
|
|
2
|
-
|
|
3
1
|
class Node {
|
|
4
2
|
constructor(segment = "") {
|
|
5
|
-
this.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
this.
|
|
3
|
+
this.segment = segment;
|
|
4
|
+
/**@type {Map<string, Node>}*/
|
|
5
|
+
this.staticChildren = new Map();
|
|
6
|
+
this.paramChild = null;
|
|
7
|
+
this.paramName = null;
|
|
8
|
+
this.wildcardChild = null;
|
|
9
|
+
/**@type {Map<string, Function[]>}*/
|
|
9
10
|
this.handlers = new Map();
|
|
10
11
|
}
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* @param {String} segment
|
|
14
|
+
* @returns {Node}
|
|
15
|
+
*/
|
|
12
16
|
add(segment) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
if (segment.startsWith(":")) {
|
|
18
|
+
if (this.paramChild) return this.paramChild;
|
|
19
|
+
this.paramChild = new Node(segment);
|
|
20
|
+
this.paramChild.paramName = segment.slice(1);
|
|
21
|
+
this.paramChild.type = "param";
|
|
22
|
+
return this.paramChild;
|
|
23
|
+
} else if (segment.startsWith("*")) {
|
|
24
|
+
if (this.wildcardChild) return this.wildcardChild;
|
|
25
|
+
this.wildcardChild = new Node(segment);
|
|
26
|
+
this.wildcardChild.type = "wildcard";
|
|
27
|
+
return this.wildcardChild;
|
|
28
|
+
} else {
|
|
29
|
+
return this.staticChildren.getOrInsertComputed(segment, (segment) => {
|
|
30
|
+
const node = new Node(segment);
|
|
31
|
+
node.type = "wildcard";
|
|
32
|
+
return node;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
18
35
|
}
|
|
19
|
-
|
|
36
|
+
/**
|
|
37
|
+
* @param {Node} node
|
|
38
|
+
*/
|
|
20
39
|
merge(node, offset) {
|
|
21
40
|
for (const [method, entries] of node.handlers) {
|
|
22
|
-
const target = this.handlers.getOrInsert(method, []);
|
|
23
41
|
for (const entry of entries) {
|
|
24
|
-
|
|
42
|
+
this.handlers.getOrInsert(method, []).push({
|
|
43
|
+
...entry,
|
|
44
|
+
seq: entry.seq + offset,
|
|
45
|
+
});
|
|
25
46
|
}
|
|
26
47
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
|
|
49
|
+
if (node.paramChild) {
|
|
50
|
+
this.add(node.paramChild.segment).merge(node.paramChild, offset);
|
|
51
|
+
}
|
|
52
|
+
if (node.wildcardChild) {
|
|
53
|
+
this.add(node.wildcardChild.segment).merge(node.wildcardChild, offset);
|
|
54
|
+
}
|
|
55
|
+
for (const [segment, child] of node.staticChildren) {
|
|
56
|
+
this.add(segment).merge(child, offset);
|
|
31
57
|
}
|
|
32
58
|
}
|
|
33
59
|
|
|
34
60
|
getMaxSeq() {
|
|
35
61
|
let max = -1;
|
|
36
|
-
for (const [,
|
|
37
|
-
for (const entry of
|
|
62
|
+
for (const [method, entries] of this.handlers) {
|
|
63
|
+
for (const entry of entries) {
|
|
38
64
|
max = Math.max(max, entry.seq);
|
|
39
65
|
}
|
|
40
66
|
}
|
|
41
|
-
|
|
67
|
+
|
|
68
|
+
if (this.paramChild) {
|
|
69
|
+
max = Math.max(max, this.paramChild.getMaxSeq());
|
|
70
|
+
}
|
|
71
|
+
if (this.wildcardChild) {
|
|
72
|
+
max = Math.max(max, this.wildcardChild.getMaxSeq());
|
|
73
|
+
}
|
|
74
|
+
for (const [path, child] of this.staticChildren) {
|
|
42
75
|
max = Math.max(max, child.getMaxSeq());
|
|
43
76
|
}
|
|
77
|
+
|
|
44
78
|
return max;
|
|
45
79
|
}
|
|
46
80
|
}
|
package/src/response.js
CHANGED
|
@@ -7,7 +7,7 @@ class Response extends http.ServerResponse {
|
|
|
7
7
|
}
|
|
8
8
|
send(chunk) {
|
|
9
9
|
if (this.writableEnded) return;
|
|
10
|
-
this.end(chunk,null,null)
|
|
10
|
+
this.end(chunk, null, null); //avoid ArgumentsAdaptorTrampoline from V8
|
|
11
11
|
}
|
|
12
12
|
json(value) {
|
|
13
13
|
if (this.writableEnded) return;
|
|
@@ -16,7 +16,7 @@ class Response extends http.ServerResponse {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
this.setHeader("Content-Type", "application/json");
|
|
19
|
-
this.end(JSON.stringify(value),null,null)
|
|
19
|
+
this.end(JSON.stringify(value), null, null); //avoid ArgumentsAdaptorTrampoline from V8
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
exports.Response = Response;
|
package/src/router.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Node } = require("./node.js");
|
|
2
2
|
const { Cache } = require("./cache.js");
|
|
3
|
-
const {
|
|
3
|
+
const { decodePathParam } = require("./util.js");
|
|
4
4
|
|
|
5
5
|
class Router {
|
|
6
6
|
constructor(options = {}) {
|
|
@@ -16,14 +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
|
}
|
|
23
25
|
let node = this.root;
|
|
24
|
-
if (path
|
|
25
|
-
for (const
|
|
26
|
-
node = node.add(
|
|
26
|
+
if (path) {
|
|
27
|
+
for (const part of this.split(path)) {
|
|
28
|
+
node = node.add(part);
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -32,10 +34,10 @@ class Router {
|
|
|
32
34
|
node.merge(handler.root, this.seq);
|
|
33
35
|
this.seq += handler.root.getMaxSeq() + 1;
|
|
34
36
|
} else {
|
|
35
|
-
|
|
37
|
+
const newMethod = handler.length === 3 ? "CATCH" : method;
|
|
38
|
+
node.handlers.getOrInsert(newMethod, []).push({
|
|
36
39
|
handler,
|
|
37
40
|
seq: this.seq++,
|
|
38
|
-
isErrorHandler: handler.length === 3,
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
}
|
|
@@ -96,71 +98,96 @@ class Router {
|
|
|
96
98
|
return this;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
101
|
+
collectMiddleware(node, handlers, errorHandlers) {
|
|
102
|
+
if (node.handlers.has("ALL")) {
|
|
103
|
+
for (const entry of node.handlers.get("ALL")) {
|
|
104
|
+
handlers[entry.seq] = entry.handler;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (node.handlers.has("CATCH")) {
|
|
108
|
+
for (const entry of node.handlers.get("CATCH")) {
|
|
109
|
+
errorHandlers[entry.seq] = entry.handler;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
collectHandler(node, method, handlers) {
|
|
115
|
+
if (node && node.handlers.has(method)) {
|
|
116
|
+
for (const entry of node.handlers.get(method)) {
|
|
117
|
+
handlers[entry.seq] = entry.handler;
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
|
-
return matched;
|
|
113
120
|
}
|
|
114
121
|
|
|
115
122
|
/**
|
|
116
|
-
* @param {
|
|
117
|
-
* @param {
|
|
118
|
-
* @
|
|
123
|
+
* @param {Node} node
|
|
124
|
+
* @param {*} parts
|
|
125
|
+
* @param {*} i
|
|
126
|
+
* @param {*} params
|
|
127
|
+
* @param {*} path
|
|
128
|
+
* @returns
|
|
119
129
|
*/
|
|
130
|
+
search(node, parts, i, params, path) {
|
|
131
|
+
if (!node) return null;
|
|
132
|
+
path.push(node);
|
|
133
|
+
if (i === parts.length) return path;
|
|
134
|
+
|
|
135
|
+
const part = parts[i];
|
|
136
|
+
|
|
137
|
+
if (node.staticChildren.has(part)) {
|
|
138
|
+
const r = this.search(node.staticChildren.get(part), parts, i + 1, params, path);
|
|
139
|
+
if (r) return r;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (node.paramChild) {
|
|
143
|
+
const saved = params[node.paramChild.paramName];
|
|
144
|
+
params[node.paramChild.paramName] = decodePathParam(part);
|
|
145
|
+
const r = this.search(node.paramChild, parts, i + 1, params, path);
|
|
146
|
+
if (r) return r;
|
|
147
|
+
if (saved === undefined) delete params[node.paramChild.paramName];
|
|
148
|
+
else params[node.paramChild.paramName] = saved;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (node.wildcardChild) {
|
|
152
|
+
params[0] = decodePathParam(parts.slice(i).join("/"));
|
|
153
|
+
path.push(node.wildcardChild);
|
|
154
|
+
return path;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
path.pop();
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
120
161
|
find(method, path) {
|
|
121
162
|
const key = `${method} ${path}`;
|
|
122
163
|
const cached = this.cache.get(key);
|
|
123
164
|
if (cached) return cached;
|
|
124
|
-
|
|
125
|
-
|
|
165
|
+
|
|
166
|
+
const parts = this.split(path);
|
|
167
|
+
const params = {};
|
|
168
|
+
const nodePath = [];
|
|
169
|
+
const result = this.search(this.root, parts, 0, params, nodePath);
|
|
170
|
+
|
|
126
171
|
const handlers = {};
|
|
127
172
|
const errorHandlers = {};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
if (!matched) {
|
|
134
|
-
node = null;
|
|
135
|
-
params = Object.create(null);
|
|
136
|
-
break;
|
|
137
|
-
}
|
|
138
|
-
node = matched;
|
|
139
|
-
if (node.handlers.has("ALL")) {
|
|
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
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (node && node.handlers.has(method)) {
|
|
153
|
-
for (const entry of node.handlers.get(method)) {
|
|
154
|
-
handlers[entry.seq] = entry.handler;
|
|
173
|
+
|
|
174
|
+
if (result) {
|
|
175
|
+
for (const n of result) {
|
|
176
|
+
this.collectMiddleware(n, handlers, errorHandlers);
|
|
155
177
|
}
|
|
178
|
+
this.collectHandler(result[result.length - 1], method, handlers);
|
|
179
|
+
} else {
|
|
180
|
+
this.collectMiddleware(this.root, handlers, errorHandlers);
|
|
156
181
|
}
|
|
157
|
-
|
|
182
|
+
|
|
183
|
+
const value = {
|
|
158
184
|
params,
|
|
159
185
|
handlers: Object.values(handlers),
|
|
160
186
|
errorHandlers: Object.values(errorHandlers),
|
|
161
187
|
};
|
|
162
|
-
this.cache.set(key,
|
|
163
|
-
|
|
188
|
+
this.cache.set(key, value);
|
|
189
|
+
|
|
190
|
+
return value;
|
|
164
191
|
}
|
|
165
192
|
}
|
|
166
193
|
exports.Router = Router;
|
package/src/util.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
-
const URL_REGEX = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
2
|
-
const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
|
|
3
|
-
|
|
4
1
|
function decodeQueryParam(encoded) {
|
|
2
|
+
const normalize = encoded.replace(/\+/g, " ");
|
|
5
3
|
try {
|
|
6
|
-
return decodeURIComponent(
|
|
4
|
+
return decodeURIComponent(normalize);
|
|
7
5
|
} catch (error) {
|
|
8
|
-
return
|
|
6
|
+
return normalize;
|
|
9
7
|
}
|
|
10
8
|
}
|
|
11
9
|
exports.decodeQueryParam = decodeQueryParam;
|
|
@@ -17,26 +15,32 @@ function decodePathParam(encoded) {
|
|
|
17
15
|
return encoded;
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
|
-
|
|
18
|
+
|
|
19
|
+
exports.decodePathParam = decodePathParam;
|
|
21
20
|
|
|
22
21
|
function parseQueryString(queryString) {
|
|
23
|
-
const
|
|
22
|
+
const query = Object.create(null);
|
|
23
|
+
if (!queryString) return query;
|
|
24
|
+
const regex = /([^=&]*)?=([^&]*)?/g;
|
|
24
25
|
let match;
|
|
25
|
-
while ((match =
|
|
26
|
+
while ((match = regex.exec(queryString))) {
|
|
26
27
|
const [, rawName = "", rawValue = ""] = match;
|
|
27
28
|
const name = decodeQueryParam(rawName);
|
|
28
29
|
const value = decodeQueryParam(rawValue);
|
|
29
|
-
if (Object.hasOwn(
|
|
30
|
-
if (Array.isArray(
|
|
31
|
-
else
|
|
32
|
-
} else
|
|
30
|
+
if (Object.hasOwn(query, name)) {
|
|
31
|
+
if (Array.isArray(query[name])) query[name].push(value);
|
|
32
|
+
else query[name] = [query[name], value];
|
|
33
|
+
} else {
|
|
34
|
+
query[name] = value;
|
|
35
|
+
}
|
|
33
36
|
}
|
|
34
|
-
return
|
|
37
|
+
return query;
|
|
35
38
|
}
|
|
36
39
|
exports.parseQueryString = parseQueryString;
|
|
37
40
|
|
|
38
41
|
function parseUrl(url) {
|
|
39
|
-
const
|
|
40
|
-
|
|
42
|
+
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
43
|
+
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
44
|
+
return { path: path || "/", queryString };
|
|
41
45
|
}
|
|
42
46
|
exports.parseUrl = parseUrl;
|
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;
|