@ndiinginc/router 4.9.0 → 4.11.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 +20 -7
- package/package.json +3 -2
- package/src/app.js +5 -0
- package/src/polyfill.js +1 -0
- package/src/request.js +2 -0
- package/src/response.js +2 -0
- package/src/router.js +17 -22
- package/src/util.js +11 -2
- package/src/cache.js +0 -28
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)`
|
|
@@ -119,6 +118,7 @@ Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `
|
|
|
119
118
|
|
|
120
119
|
- 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
120
|
- Jika sebuah cabang gagal di tengah jalan, router mundur (backtrack) dan mencoba cabang berikutnya.
|
|
121
|
+
- 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
122
|
- Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
|
|
123
123
|
|
|
124
124
|
## Middleware
|
|
@@ -212,7 +212,7 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
|
|
|
212
212
|
|
|
213
213
|
## Timeout
|
|
214
214
|
|
|
215
|
-
Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, `res.statusCode` diset `
|
|
215
|
+
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
216
|
|
|
217
217
|
```js
|
|
218
218
|
const app = new App({ requestTimeout: 5000 });
|
|
@@ -326,15 +326,14 @@ Perilaku:
|
|
|
326
326
|
## Opsi
|
|
327
327
|
|
|
328
328
|
```js
|
|
329
|
-
const app = new App({
|
|
329
|
+
const app = new App({ requestTimeout: 5000 });
|
|
330
330
|
```
|
|
331
331
|
|
|
332
332
|
| Opsi | Default | Keterangan |
|
|
333
333
|
| --- | --- | --- |
|
|
334
|
-
| `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. `0` menonaktifkan cache. Cache dikosongkan setiap ada route baru didaftarkan |
|
|
335
334
|
| `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
|
|
336
335
|
|
|
337
|
-
`Router` hanya
|
|
336
|
+
`Router` tidak memiliki opsi sendiri; `requestTimeout` hanya dipakai oleh `App`.
|
|
338
337
|
|
|
339
338
|
## API
|
|
340
339
|
|
|
@@ -352,7 +351,7 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
|
|
|
352
351
|
|
|
353
352
|
### `router.find(method, path)`
|
|
354
353
|
|
|
355
|
-
Mencari route tanpa menjalankannya.
|
|
354
|
+
Mencari route tanpa menjalankannya. Berguna untuk testing.
|
|
356
355
|
|
|
357
356
|
```js
|
|
358
357
|
const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
|
|
@@ -367,6 +366,20 @@ const server = app.listen(0); // port acak
|
|
|
367
366
|
console.log(server.address().port);
|
|
368
367
|
```
|
|
369
368
|
|
|
369
|
+
### Server sendiri
|
|
370
|
+
|
|
371
|
+
`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)`:
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
const https = require("https");
|
|
375
|
+
|
|
376
|
+
const server = https.createServer(
|
|
377
|
+
{ ...App.SERVER_OPTIONS, key, cert },
|
|
378
|
+
(req, res) => app.handle(req, res),
|
|
379
|
+
);
|
|
380
|
+
server.listen(443);
|
|
381
|
+
```
|
|
382
|
+
|
|
370
383
|
## Benchmark
|
|
371
384
|
|
|
372
385
|
Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ndiinginc/router",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.11.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@ndiinginc/commit": "^1.0.1",
|
|
22
22
|
"@ndiinginc/test": "^1.17.0",
|
|
23
23
|
"@types/jest": "^30.0.0",
|
|
24
|
-
"@types/node": "^26.6.5"
|
|
24
|
+
"@types/node": "^26.6.5",
|
|
25
|
+
"autocannon": "^8.0.0"
|
|
25
26
|
}
|
|
26
27
|
}
|
package/src/app.js
CHANGED
|
@@ -45,13 +45,16 @@ class App extends Router {
|
|
|
45
45
|
res.statusCode = 404;
|
|
46
46
|
err = new Error(http.STATUS_CODES[res.statusCode]);
|
|
47
47
|
}
|
|
48
|
+
|
|
48
49
|
if (res.statusCode < 400) {
|
|
49
50
|
res.statusCode = 500;
|
|
50
51
|
}
|
|
52
|
+
|
|
51
53
|
const error = { message: err.message };
|
|
52
54
|
if (process.env.NODE_ENV === "development") {
|
|
53
55
|
error.stack = err.stack;
|
|
54
56
|
}
|
|
57
|
+
|
|
55
58
|
res.json({ error });
|
|
56
59
|
}
|
|
57
60
|
}
|
|
@@ -72,6 +75,7 @@ class App extends Router {
|
|
|
72
75
|
await handler(req, res);
|
|
73
76
|
}
|
|
74
77
|
};
|
|
78
|
+
|
|
75
79
|
if (this.requestTimeout) {
|
|
76
80
|
await this.withTimeout(req, res, handlerPromise);
|
|
77
81
|
} else {
|
|
@@ -79,6 +83,7 @@ class App extends Router {
|
|
|
79
83
|
}
|
|
80
84
|
} catch (error) {
|
|
81
85
|
err = error;
|
|
86
|
+
|
|
82
87
|
try {
|
|
83
88
|
for (const handler of errorHandlers) {
|
|
84
89
|
if (res.writableEnded) break;
|
package/src/polyfill.js
CHANGED
package/src/request.js
CHANGED
|
@@ -21,6 +21,7 @@ class Request extends http.IncomingMessage {
|
|
|
21
21
|
if (!this._query) {
|
|
22
22
|
this._query = parseQueryString(this._queryString);
|
|
23
23
|
}
|
|
24
|
+
|
|
24
25
|
return this._query;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -31,6 +32,7 @@ class Request extends http.IncomingMessage {
|
|
|
31
32
|
if (!this._controller) {
|
|
32
33
|
this._controller = new AbortController();
|
|
33
34
|
}
|
|
35
|
+
|
|
34
36
|
return this._controller;
|
|
35
37
|
}
|
|
36
38
|
}
|
package/src/response.js
CHANGED
|
@@ -5,10 +5,12 @@ class Response extends http.ServerResponse {
|
|
|
5
5
|
this.statusCode = code;
|
|
6
6
|
return this;
|
|
7
7
|
}
|
|
8
|
+
|
|
8
9
|
send(chunk) {
|
|
9
10
|
if (this.writableEnded) return;
|
|
10
11
|
this.end(chunk, null, null);
|
|
11
12
|
}
|
|
13
|
+
|
|
12
14
|
json(value) {
|
|
13
15
|
if (!this.headersSent) {
|
|
14
16
|
this.setHeader("Content-Type", "application/json; charset=utf-8");
|
package/src/router.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
const { decodeParam } = require("./util");
|
|
2
2
|
const { Node } = require("./node");
|
|
3
|
-
const { Cache } = require("./cache");
|
|
4
3
|
|
|
5
4
|
class Router {
|
|
6
5
|
constructor(options = {}) {
|
|
7
6
|
this.requestTimeout = options.requestTimeout ?? 0;
|
|
8
|
-
this.cacheSize = options.cacheSize ?? 1000;
|
|
9
7
|
this.root = new Node();
|
|
10
8
|
this.seq = 0;
|
|
11
|
-
this.cache = new Cache(this.cacheSize);
|
|
12
9
|
}
|
|
13
10
|
|
|
14
11
|
split(path) {
|
|
@@ -16,8 +13,6 @@ class Router {
|
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
add(method, path, ...handlers) {
|
|
19
|
-
this.cache.clear();
|
|
20
|
-
|
|
21
16
|
if (typeof path === "function") {
|
|
22
17
|
handlers = [path, ...handlers];
|
|
23
18
|
path = null;
|
|
@@ -109,12 +104,23 @@ class Router {
|
|
|
109
104
|
* @param {{ best: { nodes: Node[], params: Record<string, string> } }} state
|
|
110
105
|
* @returns {{ nodes: Node[], params: Record<string, string>, matched: boolean }}
|
|
111
106
|
*/
|
|
112
|
-
search(node, segments, index, path, params, state) {
|
|
107
|
+
search(node, segments, index, path, params, state,method) {
|
|
113
108
|
path.push(node);
|
|
114
109
|
|
|
115
110
|
// habis segmen -> full match
|
|
111
|
+
// if (index === segments.length) {
|
|
112
|
+
// return { nodes: path.slice(), params: { ...params }, matched: true };
|
|
113
|
+
// }
|
|
116
114
|
if (index === segments.length) {
|
|
117
|
-
|
|
115
|
+
if (node.handlers.has(method)) {
|
|
116
|
+
return { nodes: path.slice(), params: { ...params }, matched: true };
|
|
117
|
+
}
|
|
118
|
+
// node ada tapi tidak punya handler method ini: catat sebagai partial, lalu coba cabang lain
|
|
119
|
+
if (path.length > state.best.nodes.length) {
|
|
120
|
+
state.best = { nodes: path.slice(), params: { ...params } };
|
|
121
|
+
}
|
|
122
|
+
path.pop();
|
|
123
|
+
return { matched: false };
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
const segment = segments[index];
|
|
@@ -122,7 +128,7 @@ class Router {
|
|
|
122
128
|
// 1. static (prioritas tertinggi)
|
|
123
129
|
const staticChild = node.children.get(segment);
|
|
124
130
|
if (staticChild) {
|
|
125
|
-
const res = this.search(staticChild, segments, index + 1, path, params, state);
|
|
131
|
+
const res = this.search(staticChild, segments, index + 1, path, params, state,method);
|
|
126
132
|
if (res.matched) return res;
|
|
127
133
|
}
|
|
128
134
|
|
|
@@ -131,7 +137,7 @@ class Router {
|
|
|
131
137
|
const name = key.slice(1); // buang ':'
|
|
132
138
|
const prev = params[name];
|
|
133
139
|
params[name] = decodeParam(segment);
|
|
134
|
-
const res = this.search(child, segments, index + 1, path, params, state);
|
|
140
|
+
const res = this.search(child, segments, index + 1, path, params, state,method);
|
|
135
141
|
if (res.matched) return res;
|
|
136
142
|
// backtrack param value
|
|
137
143
|
if (prev === undefined) delete params[name];
|
|
@@ -178,11 +184,6 @@ class Router {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
find(method, path) {
|
|
181
|
-
const key = `${method} ${path}`;
|
|
182
|
-
|
|
183
|
-
const cached = this.cache.get(key);
|
|
184
|
-
if (cached) return cached;
|
|
185
|
-
|
|
186
187
|
const segments = this.split(path);
|
|
187
188
|
|
|
188
189
|
const params = {};
|
|
@@ -192,7 +193,7 @@ class Router {
|
|
|
192
193
|
/** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
|
|
193
194
|
const state = { best: { nodes: [], params: {} } };
|
|
194
195
|
|
|
195
|
-
const res = this.search(this.root, segments, 0, [], params, state);
|
|
196
|
+
const res = this.search(this.root, segments, 0, [], params, state,method);
|
|
196
197
|
|
|
197
198
|
const matchedNodes = res.matched ? res.nodes : state.best.nodes;
|
|
198
199
|
const finalParams = res.matched ? res.params : state.best.params;
|
|
@@ -208,19 +209,13 @@ class Router {
|
|
|
208
209
|
this.collectHandler(leaf, method, handlers);
|
|
209
210
|
}
|
|
210
211
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const result = {
|
|
212
|
+
return {
|
|
214
213
|
matched: res.matched,
|
|
215
214
|
params: finalParams,
|
|
216
215
|
paramsLength: Object.keys(finalParams).length,
|
|
217
216
|
handlers: Object.values(handlers),
|
|
218
217
|
errorHandlers: Object.values(errorHandlers),
|
|
219
218
|
};
|
|
220
|
-
|
|
221
|
-
this.cache.set(key, result);
|
|
222
|
-
|
|
223
|
-
return result;
|
|
224
219
|
}
|
|
225
220
|
}
|
|
226
221
|
exports.Router = Router;
|
package/src/util.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const EMPTY_PARAMS = Object.freeze(Object.create(null));
|
|
1
|
+
// const EMPTY_PARAMS = Object.freeze(Object.create(null));
|
|
2
|
+
const EMPTY_PARAMS = Object.create(null)
|
|
2
3
|
exports.EMPTY_PARAMS = EMPTY_PARAMS;
|
|
3
4
|
|
|
4
5
|
function decodeParam(raw) {
|
|
@@ -12,6 +13,7 @@ exports.decodeParam = decodeParam;
|
|
|
12
13
|
|
|
13
14
|
function decodeQuery(raw) {
|
|
14
15
|
const normalized = raw.replace(/\+/g, " ");
|
|
16
|
+
|
|
15
17
|
try {
|
|
16
18
|
return decodeURIComponent(normalized);
|
|
17
19
|
} catch (error) {
|
|
@@ -21,18 +23,23 @@ function decodeQuery(raw) {
|
|
|
21
23
|
|
|
22
24
|
function parseQueryString(queryString) {
|
|
23
25
|
if (queryString === "") return Object.create(null);
|
|
26
|
+
|
|
24
27
|
const regex = /([^=&]*)=([^&]*)/g;
|
|
25
|
-
let match;
|
|
26
28
|
const result = Object.create(null);
|
|
29
|
+
|
|
30
|
+
let match;
|
|
27
31
|
while ((match = regex.exec(queryString))) {
|
|
28
32
|
const [, rawName, rawValue] = match;
|
|
33
|
+
|
|
29
34
|
const name = decodeQuery(rawName);
|
|
30
35
|
const value = decodeQuery(rawValue);
|
|
36
|
+
|
|
31
37
|
if (Object.hasOwn(result, name)) {
|
|
32
38
|
if (Array.isArray(result[name])) result[name].push(value);
|
|
33
39
|
else result[name] = [result[name], value];
|
|
34
40
|
} else result[name] = value;
|
|
35
41
|
}
|
|
42
|
+
|
|
36
43
|
return result;
|
|
37
44
|
}
|
|
38
45
|
exports.parseQueryString = parseQueryString;
|
|
@@ -41,8 +48,10 @@ function parseUrl(url) {
|
|
|
41
48
|
if (!(url.includes("?") || url.includes("#"))) {
|
|
42
49
|
return { path: url, queryString: "" };
|
|
43
50
|
}
|
|
51
|
+
|
|
44
52
|
const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
|
|
45
53
|
const [, path = "/", , queryString = ""] = regex.exec(url);
|
|
54
|
+
|
|
46
55
|
return { path, queryString };
|
|
47
56
|
}
|
|
48
57
|
exports.parseUrl = parseUrl;
|
package/src/cache.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
class Cache {
|
|
2
|
-
constructor(size = 0) {
|
|
3
|
-
this.size = size;
|
|
4
|
-
this.map = new Map();
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
get(key) {
|
|
8
|
-
if (!this.map.has(key)) return undefined;
|
|
9
|
-
const value = this.map.get(key);
|
|
10
|
-
this.map.delete(key);
|
|
11
|
-
this.map.set(key, value);
|
|
12
|
-
return value;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
set(key, value) {
|
|
16
|
-
if (this.size <= 0) return; // disabled
|
|
17
|
-
if (this.map.has(key)) this.map.delete(key);
|
|
18
|
-
else if (this.map.size >= this.size) {
|
|
19
|
-
this.map.delete(this.map.keys().next().value);
|
|
20
|
-
}
|
|
21
|
-
this.map.set(key, value);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
clear() {
|
|
25
|
-
this.map.clear();
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
exports.Cache = Cache;
|