@ndiinginc/router 4.13.1 → 4.14.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 CHANGED
@@ -1,30 +1,49 @@
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, nested router, middleware async, error handler, timeout per request, serta middleware `json`, `urlencoded`, dan `cors` bawaan.
3
+ A small, zero-dependency HTTP router for Node.js, built directly on `node:http`. Radix-trie routing, plain `(req, res)` handlers and no `next()`.
4
4
 
5
- ## Fitur
5
+ ```js
6
+ const App = require("@ndiinginc/router");
7
+
8
+ const app = new App();
9
+ app.use(App.json());
10
+
11
+ app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
12
+ app.post("/users", (req, res) => res.status(201).json(req.body));
13
+
14
+ app.listen(3000);
15
+ ```
16
+
17
+ ## Features
18
+
19
+ - Zero dependencies
20
+ - Radix-trie routing: static > `:param` > `*` wildcard, with backtracking
21
+ - Familiar API: `app.get`, `app.use`, nested routers
22
+ - Handlers and middleware are plain `(req, res)` functions. No `next()`, `async` is supported
23
+ - Error handlers with the signature `(err, req, res)`
24
+ - Per-request timeout (`504`) backed by an `AbortController`
25
+ - Built-in `json()`, `urlencoded()` and `cors()` middleware
26
+ - All HTTP methods, including `QUERY`
27
+
28
+ ## Design
6
29
 
7
- - Zero dependency, murni `node:http`
8
- - Routing berbasis tree per segmen: static, lalu `:param`, lalu wildcard `*`, dengan backtracking
9
- - Parameter dinamis `:id` dan wildcard `*` yang menangkap sisa path
10
- - Nested router dengan `use(path, router)`
11
- - Middleware global dan per-path, mendukung `async`
12
- - Error handler dengan signature `(err, req, res)`
13
- - Timeout per request, dengan `req.controller` (`AbortController`) untuk pembatalan kooperatif
14
- - `req.path`, `req.query`, `req.params`, `req.body`
15
- - Helper `res.status()`, `res.send()`, dan `res.json()`
16
- - Middleware bawaan: `json()` (dengan limit dan proteksi prototype pollution), `urlencoded()`, dan `cors()`
17
- - Semua HTTP method, termasuk `QUERY`
30
+ - **Radix trie.** Routes live in a compressed prefix tree, so a lookup follows the request path instead of walking a list of routes. Static segments are tried first, then `:param`, then `*`, and the router backtracks when a branch dead-ends.
31
+ - **Registration order.** Every handler gets a sequence number when it is registered, and the handlers that apply to a request always run in that order, wherever they sit in the tree. Middleware, route handlers and mounted routers share one ordering.
32
+ - **No `next()`.** A handler responds, throws, or simply returns, in which case the next one runs. The chain stops as soon as the response has ended.
33
+ - **Errors are plain throws.** Throwing or rejecting skips to the error handlers `(err, req, res)`, then to a default JSON error response.
34
+ - **Small surface.** `req` and `res` are Node's own classes with a few additions (`req.path`, `req.query`, `req.params`, `req.body`, `req.controller`, `res.status`, `res.send`, `res.json`). Everything else comes from middleware you add.
18
35
 
19
- ## Instalasi
36
+ It fits small HTTP services and gateways that want routing, body parsing and CORS without the weight of a full framework. If you need a large plugin ecosystem, schema validation or a lot of built-in helpers, this is not it.
37
+
38
+ ## Install
20
39
 
21
40
  ```bash
22
41
  npm install @ndiinginc/router
23
42
  ```
24
43
 
25
- Butuh Node.js 18.3 atau lebih baru.
44
+ Requires Node.js 18.3 or newer (`res.appendHeader` is used by the CORS middleware).
26
45
 
27
- ## Quick Start
46
+ ## Quick start
28
47
 
29
48
  ```js
30
49
  const App = require("@ndiinginc/router");
@@ -32,280 +51,212 @@ const App = require("@ndiinginc/router");
32
51
  const app = new App();
33
52
 
34
53
  app.use(App.json());
54
+ app.use(App.cors());
35
55
 
36
- app.get("/", (req, res) => {
37
- res.json({ hello: "world" });
38
- });
39
-
40
- app.get("/users/:id", (req, res) => {
41
- res.json({ id: req.params.id });
42
- });
56
+ app.get("/", (req, res) => res.json({ ok: true }));
57
+ app.get("/users/:id", (req, res) => res.json({ id: req.params.id }));
58
+ app.post("/users", (req, res) => res.status(201).json(req.body));
43
59
 
44
- app.post("/users", (req, res) => {
45
- res.status(201).json({ received: req.body });
46
- });
47
-
48
- app.listen(3000);
60
+ app.listen(3000, () => console.log("http://localhost:3000"));
49
61
  ```
50
62
 
51
- Yang diekspor adalah class `App`, dengan beberapa properti tambahan:
63
+ `app.listen(...)` forwards its arguments to `server.listen(...)` and returns the `http.Server`, so the usual callback works.
52
64
 
53
- | Export | Keterangan |
54
- | --- | --- |
55
- | `App` | Router + HTTP server (`listen`) |
56
- | `App.Router` | Router tanpa server, untuk nested router |
57
- | `App.json` | Middleware JSON body parser |
58
- | `App.urlencoded` | Middleware form body parser (`application/x-www-form-urlencoded`) |
59
- | `App.cors` | Middleware CORS |
65
+ ## API
60
66
 
61
- ## Routing
67
+ ### `new App(options?)`
62
68
 
63
- Semua method mengembalikan `this`, jadi bisa di-chain.
69
+ | Option | Type | Default | Description |
70
+ | --------------------- | --------- | ------- | ------------------------------------------------------------------------------------------------- |
71
+ | `requestTimeout` | `number` | `0` | Request timeout in ms. `0` disables it. Responds with `504` on timeout. |
72
+ | `ignoreTrailingSlash` | `boolean` | `false` | When `true`, `/api` and `/api/` are the same route. By default the trailing slash is significant. |
64
73
 
65
74
  ```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);
75
+ const app = new App({ requestTimeout: 5000, ignoreTrailingSlash: true });
71
76
  ```
72
77
 
73
- Method yang tersedia: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `trace`, `connect`, `query`, dan `use` (semua method).
78
+ `App` extends `Router`. Use `new App.Router()` for routers that are only mounted into another router.
74
79
 
75
- Satu route boleh punya beberapa handler:
80
+ ### Routing
76
81
 
77
82
  ```js
78
- app.get("/profile", authenticate, loadUser, (req, res) => {
79
- res.json(req.user);
80
- });
83
+ app.get(path, ...handlers)
84
+ app.post(path, ...handlers)
85
+ app.put(path, ...handlers)
86
+ app.patch(path, ...handlers)
87
+ app.delete(path, ...handlers)
88
+ app.head(path, ...handlers)
89
+ app.options(path, ...handlers)
90
+ app.trace(path, ...handlers)
91
+ app.connect(path, ...handlers)
92
+ app.query(path, ...handlers) // QUERY method (non-standard, useful for complex queries)
93
+ app.use(path?, ...handlers) // all methods
81
94
  ```
82
95
 
83
- ### Parameter dinamis
96
+ All of them return `app`, so calls can be chained. Route paths must start with `/`. Matching is case-sensitive, and `//` is not collapsed.
84
97
 
85
- Segmen yang diawali `:` adalah parameter. Nilainya otomatis di-decode dengan `decodeURIComponent`; jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
98
+ **Path syntax:**
86
99
 
87
- ```js
88
- app.get("/posts/:postId/comments/:commentId", (req, res) => {
89
- const { postId, commentId } = req.params;
90
- res.json({ postId, commentId });
91
- });
92
- ```
100
+ | Pattern | Example | Matches |
101
+ | -------- | ------------ | ---------------------------------------------------- |
102
+ | Static | `/api/users` | `/api/users` |
103
+ | Param | `/users/:id` | `/users/123` gives `req.params.id = "123"` |
104
+ | Wildcard | `/files/*` | `/files/a/b/c` gives `req.params.wildcard = "a/b/c"` |
93
105
 
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`).
106
+ - A `:param` matches one whole segment (up to the next `/`) and must not be empty. The value is URL-decoded; malformed escapes such as `%` are kept as they are.
107
+ - `*` captures the rest of the path. The value is **not** URL-decoded, and `/files/*` does not match `/files/` (nothing after the slash).
108
+ - **Priority:** static > param > wildcard. If a branch fails further down, the router backtracks and tries the next one.
109
+ - **Matching is method-aware.** If a static route has no handler for the request method, the router falls back to a param or wildcard route that has one. With `GET /users/me` and `PUT /users/:id`, a `PUT /users/me` is handled by `PUT /users/:id`.
95
110
 
96
- Beberapa parameter dengan nama berbeda boleh ada di level yang sama. Router mencoba semuanya sesuai urutan pendaftaran, dan memakai yang cocok sampai akhir path:
111
+ **Multiple params:**
97
112
 
98
113
  ```js
99
- app.get("/users/:id", handler);
100
- app.get("/users/:userId/posts", handler);
101
- // GET /users/5/posts -> req.params.userId === "5"
114
+ app.get("/users/:userId/posts/:postId", (req, res) => {
115
+ res.json(req.params); // { userId: "...", postId: "..." }
116
+ });
102
117
  ```
103
118
 
104
- ### Wildcard
119
+ Params with different names may share a level, for example `/users/:id` and `/users/:userId/posts`.
105
120
 
106
- Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
121
+ **Nested routers:**
107
122
 
108
123
  ```js
109
- app.get("/files/*", (req, res) => {
110
- res.json({ file: req.params.wildcard });
111
- });
124
+ const users = new App.Router();
125
+ users.get("/:id", (req, res) => res.json({ id: req.params.id }));
112
126
 
113
- // GET /files/images/logo.png -> { file: "images/logo.png" }
127
+ app.use("/users", users);
128
+ // GET /users/42 -> users "/:id"
114
129
  ```
115
130
 
116
- Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `/files`.
117
-
118
- ### Aturan matching
131
+ The contents of a router are copied when it is mounted. Register every route on the router **before** calling `app.use(path, router)`; routes added afterwards are not picked up. The same router can be mounted at several paths.
119
132
 
120
- - 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
- - Jika sebuah cabang gagal di tengah jalan, router mundur (backtrack) dan mencoba cabang berikutnya.
122
- - 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`.
123
- - Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
133
+ ---
124
134
 
125
- ## Middleware
135
+ ### Middleware
126
136
 
127
- Middleware didaftarkan dengan `use()`. Semua handler (middleware dan route) dijalankan **berurutan sesuai urutan pendaftaran**.
137
+ Middleware is a **2-arg** function `(req, res)`. There is **no `next()`**: handlers run in order, and the chain stops as soon as the response has ended.
128
138
 
129
139
  ```js
130
- // Global
131
140
  app.use((req, res) => {
132
- console.log(req.method, req.path);
141
+ req.startTime = Date.now();
142
+ // no next(): the chain continues automatically
133
143
  });
134
144
 
135
- // Scoped: jalan untuk /admin dan semua sub-path-nya
136
- app.use("/admin", async (req, res) => {
137
- if (req.headers.authorization !== "secret") {
138
- res.status(401).json({ error: "Unauthorized" });
139
- }
145
+ app.get("/", (req, res) => {
146
+ res.json({ elapsed: Date.now() - req.startTime });
140
147
  });
141
148
  ```
142
149
 
143
- Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
144
-
145
- > Jika tidak ada route yang cocok sepenuhnya, middleware pada bagian path yang cocok paling dalam tetap dijalankan sebelum respons 404. Contohnya, middleware `/admin` tetap jalan untuk `/admin/tidak-ada`, begitu juga middleware global.
146
-
147
- ## Nested Router
148
-
149
- Pecah aplikasi menjadi beberapa router, lalu pasang dengan `use(path, router)`. Nesting bisa berlapis.
150
+ **Order matters.** Handlers run in registration order, regardless of where they sit in the tree.
150
151
 
151
152
  ```js
152
- const { Router } = App;
153
-
154
- const users = new Router();
155
- users.get("/", (req, res) => res.json({ list: [] }));
156
- users.get("/:id", (req, res) => res.json({ id: req.params.id }));
157
-
158
- const admin = new Router();
159
- admin.use("/users", users);
160
-
161
- const app = new App();
162
- app.use("/admin", admin);
163
-
164
- // GET /admin/users -> { list: [] }
165
- // GET /admin/users/42 -> { id: "42" }
153
+ app.use(auth); // 1st
154
+ app.use("/api", logger); // 2nd
155
+ app.get("/api/car", h); // 3rd
156
+ // order: auth -> logger -> h
166
157
  ```
167
158
 
168
- > Isi sub-router disalin saat `use()` dipanggil. Daftarkan semua route ke sub-router **sebelum** di-mount; route yang ditambahkan setelahnya tidak ikut. Sub-router yang sama boleh di-mount di beberapa path.
169
-
170
- ## Error Handling
171
-
172
- Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
159
+ ### Errors
173
160
 
174
- > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
175
-
176
- ### Menentukan status code
177
-
178
- Status code diambil dari `res.statusCode`. Set sebelum melempar error:
161
+ Throw (or reject) to hand control to the error handlers. An **error handler** is a 3-arg function `(err, req, res)`:
179
162
 
180
163
  ```js
181
164
  app.get("/admin", (req, res) => {
182
165
  res.status(403);
183
166
  throw new Error("Forbidden");
184
167
  });
185
- ```
186
-
187
- Jika `res.statusCode` masih di bawah 400 saat error ditangani, hasilnya `500`. Properti `statusCode` pada objek error tidak dipakai.
188
-
189
- ### Error handler sendiri
190
168
 
191
- Di error handler, `res.statusCode` masih bernilai awal (`200`) bila error dilempar tanpa mengatur status, jadi atur sendiri:
192
-
193
- ```js
194
169
  app.use((err, req, res) => {
195
170
  const status = res.statusCode >= 400 ? res.statusCode : 500;
196
- res.status(status).json({ error: { message: err.message } });
171
+ res.status(status).json({ error: err.message });
197
172
  });
198
173
  ```
199
174
 
200
- ### Respons default
201
-
202
- Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
175
+ If no error handler responds, a default JSON response is sent:
203
176
 
204
177
  ```json
205
178
  { "error": { "message": "Not Found" } }
206
179
  ```
207
180
 
208
- - Tidak ada route cocok: `404 Not Found`
209
- - `res.statusCode` sudah `>= 400`: dipakai apa adanya
210
- - `res.statusCode` masih `< 400`: diganti `500`
211
- - Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
212
- - Field `stack` hanya disertakan saat `NODE_ENV=development`
181
+ - The status comes from `res.statusCode`. If it is still below 400 when an error is handled, it becomes `500`. A `statusCode` property on the error object is **not** used.
182
+ - A request that matches no route gets `404`.
183
+ - `stack` is only included when `NODE_ENV=development`.
184
+ - If an error handler throws as well, the default response is sent for the new error (the original is kept in `cause`).
213
185
 
214
- ## Timeout
186
+ Handlers are recognised by arity (`handler.length === 3`), so do not use default or rest parameters in handler signatures.
215
187
 
216
- 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.
188
+ ---
217
189
 
218
- ```js
219
- const app = new App({ requestTimeout: 5000 });
220
- ```
190
+ ### Request
221
191
 
222
- - Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
223
- - Handler yang sudah berjalan tidak dihentikan paksa. Setelah response dikirim, handler berikutnya di chain tidak dipanggil, dan error yang dilempar handler setelah timeout hanya dicatat lewat `console.error`
192
+ | Property | Type | Description |
193
+ | ---------------- | ----------------- | ------------------------------------------------------- |
194
+ | `req.method` | `string` | HTTP method |
195
+ | `req.url` | `string` | Raw URL (from Node) |
196
+ | `req.path` | `string` | Path only (query and hash stripped) |
197
+ | `req.query` | `object` | Parsed query string (null prototype) |
198
+ | `req.params` | `object` | Route params (null prototype) |
199
+ | `req.body` | `any` | Parsed body (from the `json` / `urlencoded` middleware) |
200
+ | `req.headers` | `object` | Request headers |
201
+ | `req.controller` | `AbortController` | Abort signal for timeouts and cancellation |
224
202
 
225
- ### `req.controller`
203
+ `req.query` is parsed lazily with `URLSearchParams`: repeated keys become arrays, `+` becomes a space, and a key without a value (`?flag`) is an empty string.
226
204
 
227
- `req.controller` adalah `AbortController` milik request (dibuat saat pertama kali diakses). Saat `requestTimeout` aktif, `req.controller.signal` di-abort ketika timeout terjadi atau ketika salah satu handler melempar error; reason-nya adalah error terkait. Teruskan signal ke operasi yang mendukungnya agar pekerjaan ikut dibatalkan:
205
+ `req.query` and `req.params` have a **null prototype**, which protects against prototype pollution:
228
206
 
229
207
  ```js
230
- app.get("/proxy", async (req, res) => {
231
- const upstream = await fetch("https://example.com/api", {
232
- signal: req.controller.signal,
233
- });
234
- res.json(await upstream.json());
235
- });
208
+ // GET /search?__proto__=x
209
+ req.query
210
+ .__proto__ // "x" (a plain own property)
211
+ ({}).__proto__; // unchanged
236
212
  ```
237
213
 
238
- ## Request
239
-
240
- | Properti | Keterangan |
241
- | --- | --- |
242
- | `req.path` | Pathname tanpa query string |
243
- | `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, `+` di-decode menjadi spasi, dan key tanpa nilai (`?flag`) bernilai string kosong |
244
- | `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
245
- | `req.body` | Hasil parse body (butuh middleware `json()` atau `urlencoded()`) |
246
- | `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
214
+ Because of that, use `Object.hasOwn(req.query, "key")` or `"key" in req.query` instead of `req.query.hasOwnProperty(...)`.
247
215
 
248
- ```js
249
- // GET /search?tag=a&tag=b&page=2
250
- app.get("/search", (req, res) => {
251
- res.json(req.query); // { tag: ["a", "b"], page: "2" }
252
- });
253
- ```
216
+ ### Response
254
217
 
255
- `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(...)`.
256
-
257
- `Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
258
-
259
- ## Response
260
-
261
- | Method | Keterangan |
262
- | --- | --- |
263
- | `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
264
- | `res.send(chunk)` | Kirim response dan akhiri |
265
- | `res.json(value)` | Set `Content-Type: application/json; charset=utf-8`, serialize, lalu kirim |
218
+ | Method | Description |
219
+ | ------------------------------- | -------------------------------------------------------------- |
220
+ | `res.status(code)` | Set the status code, chainable |
221
+ | `res.send(chunk)` | Send a raw string or buffer |
222
+ | `res.json(value)` | Send JSON with `Content-Type: application/json; charset=utf-8` |
223
+ | `res.setHeader(name, value)` | Set a header (from Node) |
224
+ | `res.appendHeader(name, value)` | Append a header (from Node 18.3) |
225
+ | `res.end()` | End the response (from Node) |
266
226
 
267
227
  ```js
268
228
  res.status(201).json({ created: true });
229
+ res.send("hello");
269
230
  ```
270
231
 
271
- `res.send()` dan `res.json()` tidak melakukan apa-apa jika response sudah selesai dikirim. `Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
232
+ `res.send()` and `res.json()` do nothing if the response has already ended.
272
233
 
273
- ## Middleware Bawaan
234
+ ---
274
235
 
275
- ### `json([options])`
276
-
277
- ```js
278
- app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
279
- ```
236
+ ### Built-in middleware
280
237
 
281
- | Opsi | Default | Keterangan |
282
- | --- | --- | --- |
283
- | `limit` | `102400` | Ukuran body maksimum dalam byte |
238
+ #### `App.json(options?)`
284
239
 
285
- Perilaku:
240
+ Parses JSON bodies. Only runs on `POST`, `PUT`, `PATCH` and `QUERY` requests with a JSON `Content-Type`.
286
241
 
287
- - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
288
- - Body melebihi `limit` menghasilkan `413` (dicek lewat `Content-Length` dan saat streaming)
289
- - JSON tidak valid menghasilkan `400`, dengan error asli di `cause`
290
- - Body kosong menghasilkan `req.body = null`
291
- - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
292
-
293
- ### `urlencoded([options])`
242
+ | Option | Type | Default | Description |
243
+ | ------- | -------- | ----------------- | ---------------------- |
244
+ | `limit` | `number` | `102400` (100 KB) | Max body size in bytes |
294
245
 
295
246
  ```js
296
- app.use(App.urlencoded({ limit: 100 * 1024 })); // default 100 KB
247
+ app.use(App.json({ limit: 1024 * 1024 })); // 1 MB
297
248
  ```
298
249
 
299
- | Opsi | Default | Keterangan |
300
- | --- | --- | --- |
301
- | `limit` | `102400` | Ukuran body maksimum dalam byte |
250
+ - **Prototype pollution guard:** `__proto__`, `constructor` and `prototype` keys are removed.
251
+ - **Size limit:** `Content-Length` is checked up front and the size is checked again while streaming. Too large gives `413`.
252
+ - **Empty body:** `req.body = null`.
253
+ - **Invalid JSON:** `400`, with the parse error in `cause`.
254
+
255
+ #### `App.urlencoded(options?)`
302
256
 
303
- Perilaku:
257
+ Parses `application/x-www-form-urlencoded` bodies. Same methods, `limit` option, `413` behaviour and empty-body handling as `App.json`.
304
258
 
305
- - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/x-www-form-urlencoded`
306
- - Body melebihi `limit` menghasilkan `413` (dicek lewat `Content-Length` dan saat streaming)
307
- - Body kosong menghasilkan `req.body = null`
308
- - Hasilnya object tanpa prototype dengan aturan yang sama seperti `req.query`: key yang berulang menjadi array dan `+` menjadi spasi
259
+ The result follows the same rules as `req.query`: a null-prototype object, repeated keys become arrays, `+` becomes a space.
309
260
 
310
261
  ```js
311
262
  app.use(App.urlencoded());
@@ -316,110 +267,147 @@ app.post("/login", (req, res) => {
316
267
  });
317
268
  ```
318
269
 
319
- ### `cors([options])`
270
+ #### `App.cors(options?)`
271
+
272
+ CORS middleware with preflight support.
273
+
274
+ | Option | Type | Default | Description |
275
+ | ------------- | ---------------------------- | ------- | -------------------------------------------------------------------- |
276
+ | `origin` | `string \| string[] \| null` | `"*"` | Allowed origin(s). `null` never sends `Access-Control-Allow-Origin`. |
277
+ | `credentials` | `boolean` | `false` | Allow credentials (cookies, auth headers) |
278
+ | `headers` | `string \| string[]` | none | Allowed request headers (preflight) and exposed response headers |
279
+ | `methods` | `string \| string[]` | none | Allowed methods |
280
+ | `maxAge` | `number` | `0` | Preflight cache time in seconds. `0` omits the header. |
320
281
 
321
282
  ```js
322
283
  app.use(
323
284
  App.cors({
324
- origin: ["https://example.com", "https://app.example.com"],
285
+ origin: ["https://app.example.com", "https://admin.example.com"],
325
286
  credentials: true,
287
+ headers: ["content-type", "authorization"],
326
288
  methods: ["GET", "POST", "PUT", "DELETE"],
327
- headers: ["Content-Type", "Authorization"],
328
- maxAge: 600,
289
+ maxAge: 3600,
329
290
  }),
330
291
  );
331
292
  ```
332
293
 
333
- | Opsi | Default | Keterangan |
334
- | --- | --- | --- |
335
- | `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
336
- | `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
337
- | `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
338
- | `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
339
- | `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
294
+ - A **preflight** is an `OPTIONS` request with an `Access-Control-Request-Method` header. It is answered with `204` and the chain stops. Other `OPTIONS` requests continue to the next handler.
295
+ - Origins are compared **exactly** (case-sensitive) with the request's `Origin` header. A match is echoed back.
296
+ - With an array, `Access-Control-Allow-Headers` is only sent if **every** requested header is allowed, and `Access-Control-Allow-Methods` only if the requested method is allowed.
297
+ - Without `headers` / `methods`, the values requested in the preflight are echoed back.
298
+ - `headers` (as an array or `"*"`) is also sent as `Access-Control-Expose-Headers` on non-preflight requests.
340
299
 
341
- Perilaku:
300
+ **Security notes:**
342
301
 
343
- - **Preflight** adalah request `OPTIONS` dengan header `Access-Control-Request-Method`. Preflight dijawab langsung dengan `204` dan chain berhenti. `OPTIONS` biasa diteruskan ke handler berikutnya
344
- - Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan dan header `Vary: Origin` ditambahkan
345
- - `Access-Control-Allow-Headers`: jika `headers` berupa array, header hanya dikirim bila **semua** header yang diminta ada di daftar
346
- - `Access-Control-Allow-Methods`: jika `methods` berupa array, header hanya dikirim bila method yang diminta ada di daftar
347
- - Jika `headers` atau `methods` tidak diisi, nilai yang diminta di preflight dipantulkan apa adanya
348
- - `"*"` untuk `headers` dan `methods` hanya dikirim sebagai `*` bila `credentials: false`. Dengan `credentials: true`, nilainya diperlakukan seperti tidak diisi (dipantulkan)
349
- - Pada request non-preflight, `headers` (jika array atau `"*"`) dikirim sebagai `Access-Control-Expose-Headers`
302
+ - `origin: "*"` together with `credentials: true` echoes whichever origin sent the request. That is what the CORS spec requires (a literal `*` is invalid with credentials), but it allows **every** site to make credentialed requests. Use it for development only and list explicit origins in production.
303
+ - `headers: "*"` or `methods: "*"` together with `credentials: true` does not send `*`; the requested values are echoed instead.
304
+ - `Vary: Origin` is added only when the origin is echoed (not for a plain `*`).
350
305
 
351
- > `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
306
+ ---
352
307
 
353
- ## Opsi
308
+ ### Timeout
354
309
 
355
310
  ```js
356
311
  const app = new App({ requestTimeout: 5000 });
357
- ```
358
312
 
359
- | Opsi | Default | Keterangan |
360
- | --- | --- | --- |
361
- | `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
362
-
363
- `Router` tidak memiliki opsi sendiri; `requestTimeout` hanya dipakai oleh `App`.
313
+ app.get("/slow", async (req, res) => {
314
+ await new Promise((resolve, reject) => {
315
+ const t = setTimeout(resolve, 10000);
316
+ req.controller.signal.addEventListener(
317
+ "abort",
318
+ () => {
319
+ clearTimeout(t);
320
+ reject(req.controller.signal.reason);
321
+ },
322
+ { once: true },
323
+ );
324
+ });
325
+ res.json({ done: true });
326
+ });
327
+ // GET /slow -> 504 Gateway Timeout after 5s
328
+ ```
364
329
 
365
- ## API
330
+ When the timeout fires, the status is set to `504` and the request fails with a `Gateway Timeout` error, which goes through your error handlers like any other error.
366
331
 
367
- ### `new App([options])` / `new Router([options])`
332
+ Handlers are **not** forcibly stopped. A handler keeps running unless it listens to `req.controller.signal` (or passes it to something that does, such as `fetch(url, { signal })`). Once the response has ended, the rest of the chain is skipped.
368
333
 
369
- Membuat instance baru. `App` adalah `Router` ditambah `listen()` dan dukungan `requestTimeout`.
334
+ `req.controller` is also aborted if a handler throws, with the error as the reason.
370
335
 
371
- ### `router.use([path], ...handlers | router)`
336
+ ---
372
337
 
373
- Mendaftarkan middleware, error handler, atau nested router untuk semua method.
338
+ ### Using your own server
374
339
 
375
- ### `router.<method>([path], ...handlers)`
340
+ `App.SERVER_OPTIONS` holds the `IncomingMessage` / `ServerResponse` classes this router needs. Pass it to your own server and call `app.handle(req, res)`:
376
341
 
377
- Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, handler dipasang di root.
342
+ ```js
343
+ const https = require("node:https");
378
344
 
379
- ### `router.find(method, path)`
345
+ const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
346
+ server.listen(443);
347
+ ```
380
348
 
381
- Mencari route tanpa menjalankannya. Berguna untuk testing.
349
+ ## Porting from Express
382
350
 
383
351
  ```js
384
- const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
352
+ // Express
353
+ const express = require("express");
354
+ const app = express();
355
+ app.use(express.json());
356
+ app.get("/", (req, res) => res.json({ ok: true }));
357
+ app.use((err, req, res, next) => res.status(500).json({}));
358
+
359
+ // This router
360
+ const App = require("@ndiinginc/router");
361
+ const app = new App();
362
+ app.use(App.json());
363
+ app.get("/", (req, res) => res.json({ ok: true }));
364
+ app.use((err, req, res) => res.status(500).json({}));
385
365
  ```
386
366
 
387
- ### `app.listen(...args)`
367
+ **Key differences:**
388
368
 
389
- Membuat `http.Server` dengan `Request` dan `Response` milik router, lalu memanggil `server.listen(...args)`. Mengembalikan instance `http.Server`.
369
+ - Middleware is **2-arg** (no `next`). Error handlers are **3-arg** (no `next`).
370
+ - `express.json()` becomes `App.json()`, `express.urlencoded()` becomes `App.urlencoded()`, `cors()` becomes `App.cors()`.
371
+ - Trailing slashes are significant unless you set `ignoreTrailingSlash`.
372
+ - Errors take their status from `res.statusCode`, not from `err.statusCode`.
373
+
374
+ **Using Express-style middleware.** Middleware that calls `next()` can be wrapped in a small adapter:
390
375
 
391
376
  ```js
392
- const server = app.listen(0); // port acak
393
- console.log(server.address().port);
377
+ const wrap = (middleware) => (req, res) =>
378
+ new Promise((resolve, reject) => {
379
+ res.once("close", resolve); // the middleware answered by itself
380
+ middleware(req, res, (err) => (err ? reject(err) : resolve()));
381
+ });
382
+
383
+ app.use(wrap(require("helmet")()));
394
384
  ```
395
385
 
396
- ### Server sendiri
386
+ This only covers the `next()` contract. Middleware that relies on Express-only extensions (`req.app`, `res.locals`, `res.redirect()` and similar) will still not work.
397
387
 
398
- `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)`:
388
+ ## Limitations
399
389
 
400
- ```js
401
- const https = require("https");
390
+ - **No `next()`.** Middleware must respond or throw (or be wrapped, see above).
391
+ - **No route removal.** Routes are registered once at startup.
392
+ - **No convenience helpers** such as `res.redirect()`, `res.cookie()`, `res.sendFile()`, `req.ip` or `req.get()`. Use the Node equivalents: `res.status(302).setHeader("Location", url).end()`, `res.setHeader("Set-Cookie", ...)`, `req.socket.remoteAddress`, `req.headers`.
393
+ - **No static file serving, compression or cookie parsing.** Use a reverse proxy (nginx, Caddy) or add your own middleware.
402
394
 
403
- const server = https.createServer(
404
- { ...App.SERVER_OPTIONS, key, cert },
405
- (req, res) => app.handle(req, res),
406
- );
407
- server.listen(443);
408
- ```
395
+ These are deliberate omissions: the core stays small and features come from middleware.
409
396
 
410
397
  ## Benchmark
411
398
 
412
- Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
399
+ Environment: Windows, one machine (the load generator and the server share the CPU), `autocannon -c 100 -p 10 -d 40`, a single route `GET /` that returns `{ "hello": "world" }`.
413
400
 
414
- | | Req/sec (avg) | Latency (avg) |
415
- | --- | --- | --- |
416
- | Fastify | 31.475 | 31,27 ms |
417
- | **@ndiinginc/router** | 29.735 | 33,12 ms |
418
- | Express | 20.211 | 48,93 ms |
401
+ | Framework | Req/s (avg) | Latency p50 | Latency p99 |
402
+ | ----------------- | ----------- | ----------- | ----------- |
403
+ | Fastify | 55,802 | 14 ms | 95 ms |
404
+ | node:http | 50,628 | 16 ms | 105 ms |
405
+ | @ndiinginc/router | 48,150 | 17 ms | 121 ms |
406
+ | Express | 28,652 | 30 ms | 195 ms |
419
407
 
420
- Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
408
+ A single-route hello world measures per-request overhead, not routing at scale, and the numbers move with hardware, Node.js version and load.
421
409
 
422
- Skrip benchmark ada di `benchmark/`. Tiap framework dijalankan di proses terpisah, dan hasilnya adalah median dari beberapa run:
410
+ To reproduce (each framework runs in its own process, results are the median of several runs, and a run is rejected if a response is not the expected one):
423
411
 
424
412
  ```bash
425
413
  npm i -D autocannon express fastify
@@ -432,6 +420,14 @@ node benchmark/run.js
432
420
  npm test
433
421
  ```
434
422
 
435
- ## Lisensi
423
+ **205 tests, 100% line coverage, 96% branch coverage.**
424
+
425
+ ## License
436
426
 
437
427
  MIT
428
+
429
+ ## Credits
430
+
431
+ Built from scratch as a learning project: the radix trie, middleware chain, sub-routers, timeout handling, CORS and body parsers are all custom.
432
+
433
+ Inspired by Express (API), Fastify (performance mindset) and `find-my-way` (radix-trie routing).
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@ndiinginc/router",
3
- "version": "4.13.1",
3
+ "version": "4.14.0",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
7
7
  "test": "nodemon --exec \"node -e \\\"require('@ndiinginc/test')(require('path').resolve('tests'),{verbose:true})\\\"\"",
8
+ "test:coverage": "node --require ./tests/setup.js --test --experimental-test-coverage",
8
9
  "dev": "nodemon src/index.js",
9
10
  "prettier": "npx prettier . --write",
10
11
  "commit": "node -e \"require('@ndiinginc/commit')()\"",
@@ -1,44 +1,44 @@
1
- const http = require("http");
2
- const { parseQueryString } = require("../util");
3
-
4
- /**
5
- * @typedef {Object} Options
6
- * @property {Number} [limit=1.024e+5]
7
- */
8
-
9
- /**
10
- * @param {Options} Options
11
- * @returns {Callback}
12
- */
13
- function urlencoded({ limit = 1.024e5 } = {}) {
14
- return async (req, res) => {
15
- if (!["POST", "PUT", "PATCH", "QUERY"].includes(req.method)) {
16
- return;
17
- }
18
- const contentType = req.headers["content-type"];
19
- if (!contentType || !contentType.includes("application/x-www-form-urlencoded")) {
20
- return;
21
- }
22
- const contentLength = parseInt(req.headers["content-length"], 10);
23
- if (contentLength && contentLength > limit) {
24
- res.statusCode = 413;
25
- throw new Error(http.STATUS_CODES[res.statusCode]);
26
- }
27
- let chunkLength = 0;
28
- const chunks = [];
29
- for await (const chunk of req) {
30
- chunkLength += chunk.length;
31
- if (chunkLength > limit) {
32
- res.statusCode = 413;
33
- throw new Error(http.STATUS_CODES[res.statusCode]);
34
- }
35
- chunks.push(chunk);
36
- }
37
- if (chunks.length === 0) {
38
- req.body = null;
39
- return;
40
- }
41
- req.body = parseQueryString(Buffer.concat(chunks).toString("utf8"));
42
- };
43
- }
44
- exports.urlencoded = urlencoded;
1
+ const http = require("http");
2
+ const { parseQueryString } = require("../util");
3
+
4
+ /**
5
+ * @typedef {Object} Options
6
+ * @property {Number} [limit=1.024e+5]
7
+ */
8
+
9
+ /**
10
+ * @param {Options} Options
11
+ * @returns {Callback}
12
+ */
13
+ function urlencoded({ limit = 1.024e5 } = {}) {
14
+ return async (req, res) => {
15
+ if (!["POST", "PUT", "PATCH", "QUERY"].includes(req.method)) {
16
+ return;
17
+ }
18
+ const contentType = req.headers["content-type"];
19
+ if (!contentType || !contentType.includes("application/x-www-form-urlencoded")) {
20
+ return;
21
+ }
22
+ const contentLength = parseInt(req.headers["content-length"], 10);
23
+ if (contentLength && contentLength > limit) {
24
+ res.statusCode = 413;
25
+ throw new Error(http.STATUS_CODES[res.statusCode]);
26
+ }
27
+ let chunkLength = 0;
28
+ const chunks = [];
29
+ for await (const chunk of req) {
30
+ chunkLength += chunk.length;
31
+ if (chunkLength > limit) {
32
+ res.statusCode = 413;
33
+ throw new Error(http.STATUS_CODES[res.statusCode]);
34
+ }
35
+ chunks.push(chunk);
36
+ }
37
+ if (chunks.length === 0) {
38
+ req.body = null;
39
+ return;
40
+ }
41
+ req.body = parseQueryString(Buffer.concat(chunks).toString("utf8"));
42
+ };
43
+ }
44
+ exports.urlencoded = urlencoded;
package/src/node.js CHANGED
@@ -40,11 +40,18 @@ class Node {
40
40
  });
41
41
  }
42
42
 
43
+ /**
44
+ * @param {String} segment
45
+ */
46
+ remove(segment) {
47
+ this.children.delete(segment);
48
+ }
49
+
43
50
  /**
44
51
  * @param {Node} node
45
52
  * @param {Number} offset
46
53
  */
47
- merge(node, offset) {
54
+ merge(node, offset = 0) {
48
55
  for (const [method, entries] of node.handlers) {
49
56
  for (const entry of entries) {
50
57
  this.handlers.getOrInsert(method, []).push({
package/src/router.js CHANGED
@@ -3,7 +3,7 @@ const { Node } = require("./node");
3
3
 
4
4
  /**
5
5
  * @typedef {Object} Options
6
- * @property {Number} requestTimeout
6
+ * @property {Number} [requestTimeout=0]
7
7
  */
8
8
 
9
9
  class Router {
@@ -12,16 +12,45 @@ class Router {
12
12
  */
13
13
  constructor(options = {}) {
14
14
  this.requestTimeout = options.requestTimeout ?? 0;
15
+
15
16
  this.root = new Node();
16
17
  this.seq = 0;
17
18
  }
18
19
 
19
20
  /**
20
- * @param {String} path
21
- * @returns {String[]}
21
+ *
22
+ * @param {*} a
23
+ * @param {*} b
24
+ * @returns
22
25
  */
23
- split(path) {
24
- return path.split("/").filter(Boolean);
26
+ lcp(a, b) {
27
+ let i = 0;
28
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
29
+ return i;
30
+ }
31
+
32
+ /**
33
+ *
34
+ * @param {*} path
35
+ * @param {*} node
36
+ * @returns
37
+ */
38
+ walk(path, node) {
39
+ for (const [segment, child] of node.children) {
40
+ const lcp = this.lcp(segment, path);
41
+ if (lcp === 0) continue;
42
+ if (lcp === segment.length) {
43
+ if (lcp === path.length) return child;
44
+ return this.walk(path.slice(lcp), child);
45
+ }
46
+ const newNode = new Node(segment.slice(0, lcp));
47
+ newNode.add(segment.slice(lcp)).merge(child);
48
+ const rest = path.slice(lcp);
49
+ if (rest.length) newNode.add(rest);
50
+ node.add(newNode.segment).merge(newNode);
51
+ node.remove(segment);
52
+ }
53
+ return node.add(path);
25
54
  }
26
55
 
27
56
  /**
@@ -34,10 +63,30 @@ class Router {
34
63
  handlers = [path, ...handlers];
35
64
  path = null;
36
65
  }
66
+
37
67
  let node = this.root;
68
+ let params = [];
38
69
  if (path) {
39
- for (const segment of this.split(path)) {
40
- node = node.add(segment);
70
+ let i = 0;
71
+ while (i < path.length) {
72
+ if (path[i] === ":") {
73
+ let j = i + 1;
74
+ while (j < path.length && path[j] !== "/") j++;
75
+ let segment = path.slice(i, j);
76
+ node = node.add(segment);
77
+ params.push(segment.slice(1));
78
+ i = j;
79
+ } else if (path[i] === "*") {
80
+ let j = i + 1;
81
+ while (j < path.length && path[j] !== "/") j++;
82
+ node = node.add(path.slice(i, j));
83
+ i = j;
84
+ } else {
85
+ let j = i + 1;
86
+ while (j < path.length && path[j] !== "/" && path[j] !== ":" && path[j] !== "*") j++;
87
+ node = this.walk(path.slice(i, j), node);
88
+ i = j;
89
+ }
41
90
  }
42
91
  }
43
92
  for (const handler of handlers) {
@@ -45,11 +94,8 @@ class Router {
45
94
  node.merge(handler.root, this.seq);
46
95
  this.seq += handler.root.getMaxSeq() + 1;
47
96
  } else {
48
- const key = handler.length === 3 ? "CATCH" : method;
49
- node.handlers.getOrInsert(key, []).push({
50
- handler,
51
- seq: this.seq++,
52
- });
97
+ const newMethod = handler.length === 3 ? "CATCH" : method;
98
+ node.handlers.getOrInsert(newMethod, []).push({ handler, seq: this.seq++ });
53
99
  }
54
100
  }
55
101
  }
@@ -171,48 +217,49 @@ class Router {
171
217
  * @param {Array} path
172
218
  * @param {Object} params
173
219
  * @param {Object} state
174
- * @param {Number} index
175
220
  * @returns {{path:Array,params:Object,matched:Boolean}}
176
221
  */
177
- search(method, segments, node, path = [], params = {}, state = { path: [], params: {} }, index = 0) {
178
- path.push(node);
179
- if (index === segments.length) {
222
+ search(method, path, node, nodes = [], params = {}, state = { nodes: [], params: {} }) {
223
+ nodes.push(node);
224
+ if (0 === path.length) {
180
225
  if (node.handlers.has(method) || node.handlers.has("ALL")) {
181
- return { path, params, matched: true };
226
+ return { nodes, params, matched: true };
182
227
  }
183
- if (path.length > state.path.length) {
184
- state.path = path.slice();
185
- state.params = { ...params };
228
+ if (nodes.length > state.nodes.length) {
229
+ state = { nodes: nodes.slice(), params: { ...params } };
186
230
  }
187
- path.pop();
188
- return { path: state.path, params: state.params, matched: false };
231
+ nodes.pop();
232
+ return { nodes: state.nodes, params: state.params, matched: false };
189
233
  }
190
- const segment = segments[index];
191
- if (node.children.has(segment)) {
192
- const res = this.search(method, segments, node.children.get(segment), path, params, state, index + 1);
193
- if (res.matched) return res;
194
- }
195
- if (node.params.size) {
196
- for (const [, child] of node.params) {
197
- const next = { ...params, [child.name]: decodeParam(segment) };
198
- const res = this.search(method, segments, child, path, next, state, index + 1);
234
+ for (const [segment, child] of node.children) {
235
+ if (path.startsWith(segment)) {
236
+ const res = this.search(method, path.slice(segment.length), child, nodes, params, state);
199
237
  if (res.matched) return res;
200
238
  }
201
239
  }
240
+ for (const [segment, child] of node.params) {
241
+ const slashIdx = path.indexOf("/");
242
+ const value = slashIdx === -1 ? path : path.slice(0, slashIdx);
243
+ const rest = slashIdx === -1 ? "" : path.slice(slashIdx);
244
+ if (value.length > 0) {
245
+ const next = { ...params, [child.name]: decodeParam(value) };
246
+ const result = this.search(method, rest, child, nodes, next, state);
247
+ if (result.matched) return result;
248
+ }
249
+ }
202
250
  if (node.wildcard && (node.wildcard.handlers.get(method) || node.wildcard.handlers.get("ALL"))) {
203
- path.push(node.wildcard);
251
+ nodes.push(node.wildcard);
204
252
  return {
205
- path,
206
- params: { ...params, wildcard: segments.slice(index).map(decodeParam).join("/") },
253
+ nodes,
254
+ params: { ...params, wildcard: path },
207
255
  matched: true,
208
256
  };
209
257
  }
210
- if (path.length > state.path.length) {
211
- state.path = path.slice();
212
- state.params = { ...params };
258
+ if (nodes.length > state.nodes.length) {
259
+ state = { nodes: nodes.slice(), params: { ...params } };
213
260
  }
214
- path.pop();
215
- return { path: state.path, params: state.params, matched: false };
261
+ nodes.pop();
262
+ return { nodes: state.nodes, params: state.params, matched: false };
216
263
  }
217
264
 
218
265
  /**
@@ -244,14 +291,19 @@ class Router {
244
291
  * @returns {{matched: boolean;params: Object;paramsLength: number;handlers: any[];errorHandlers: any[];}}
245
292
  */
246
293
  find(method, path) {
247
- const res = this.search(method, this.split(path), this.root);
294
+ let res = this.search(method, path, this.root);
295
+
296
+ if (!res.matched && path.length > 1) {
297
+ const alt = path.endsWith("/") ? path.slice(0, -1) : path + "/";
298
+ res = this.search(method, alt, this.root);
299
+ }
248
300
  const handlers = {};
249
301
  const errorHandlers = {};
250
- for (const r of res.path) {
302
+ for (const r of res.nodes) {
251
303
  this.collectMiddleware(r, handlers, errorHandlers);
252
304
  }
253
305
  if (res.matched) {
254
- this.collectHandler(res.path.at(-1), method, handlers);
306
+ this.collectHandler(res.nodes.at(-1), method, handlers);
255
307
  }
256
308
  return {
257
309
  matched: res.matched,