@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 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, LRU cache untuk lookup route, nested router, middleware async, error handler, timeout per request, serta middleware `json` dan `cors` bawaan.
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 | Keterangan |
55
- | --- | --- |
56
- | `App` | Router + HTTP server (`listen`) |
53
+ | Export | Keterangan |
54
+ | ------------ | ---------------------------------------- |
55
+ | `App` | Router + HTTP server (`listen`) |
57
56
  | `App.Router` | Router tanpa server, untuk nested router |
58
- | `App.json` | Middleware JSON body parser |
59
- | `App.cors` | Middleware 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 `502` dan error `Bad Gateway` dilempar ke error handler.
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 | Keterangan |
240
- | --- | --- |
241
- | `req.path` | Pathname tanpa query string |
242
- | `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
243
- | `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
244
- | `req.body` | Hasil parse body (butuh middleware `json()`) |
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 | Keterangan |
261
- | --- | --- |
262
- | `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
263
- | `res.send(chunk)` | Kirim response dan akhiri |
264
- | `res.json(value)` | Set `Content-Type: application/json; charset=utf-8`, serialize, lalu kirim |
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 | Default | Keterangan |
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 | Default | Keterangan |
307
- | --- | --- | --- |
308
- | `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
309
- | `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
310
- | `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
311
- | `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
312
- | `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
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({ cacheSize: 1000, requestTimeout: 5000 });
325
+ const app = new App({ requestTimeout: 5000 });
330
326
  ```
331
327
 
332
- | Opsi | Default | Keterangan |
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
- | `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 menerima `cacheSize`.
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. Hasilnya di-cache dan `params`-nya di-freeze. Berguna untuk testing.
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.10.0",
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": "^8.0.0"
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;
@@ -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
  }
@@ -7,6 +7,7 @@ function json({ limit = 100 * 1024 } = {}) {
7
7
  }
8
8
  return value;
9
9
  };
10
+
10
11
  return async (req, res) => {
11
12
  if (!["POST", "PUT", "PATCH", "QUERY"].includes(req.method)) {
12
13
  return;
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
- // Polyfill for Map.prototype.getOrInsertComputed
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
@@ -10,7 +10,7 @@ class Response extends http.ServerResponse {
10
10
  if (this.writableEnded) return;
11
11
  this.end(chunk, null, null);
12
12
  }
13
-
13
+
14
14
  json(value) {
15
15
  if (!this.headersSent) {
16
16
  this.setHeader("Content-Type", "application/json; charset=utf-8");
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(node, segments, index, path, params, state) {
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
- return { nodes: path.slice(), params: { ...params }, matched: true };
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
- // 1. static (prioritas tertinggi)
119
- const staticChild = node.children.get(segment);
120
- if (staticChild) {
121
- const res = this.search(staticChild, segments, index + 1, path, params, state);
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
- // 2. semua param child (bisa lebih dari satu)
126
- for (const [key, child] of node.params) {
127
- const name = key.slice(1); // buang ':'
128
- const prev = params[name];
129
- params[name] = decodeParam(segment);
130
- const res = this.search(child, segments, index + 1, path, params, state);
131
- if (res.matched) return res;
132
- // backtrack param value
133
- if (prev === undefined) delete params[name];
134
- else params[name] = prev;
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 { nodes: path.slice(), params: { ...params }, matched: true };
156
+ return { path, params, matched: true };
143
157
  }
144
-
145
- // tidak match di sini -> update partial terpanjang
146
- // path.length > state.best.nodes.length artinya kita sudah lebih dalam
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.collectErrorHandler(r, errorHandlers);
176
+ this.collectHandler(r, "CATCH", errorHandlers);
174
177
  }
175
178
 
176
179
  find(method, path) {
177
-
178
- const segments = this.split(path);
179
-
180
- const params = {};
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
- const leaf = matchedNodes[matchedNodes.length - 1];
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: finalParams,
206
- paramsLength: Object.keys(finalParams).length,
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
- // const EMPTY_PARAMS = Object.freeze(Object.create(null));
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;