@ndiinginc/router 4.13.0 → 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` 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) 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,358 +51,368 @@ 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 tiga 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.cors` | Middleware CORS |
65
+ ## API
59
66
 
60
- ## Routing
67
+ ### `new App(options?)`
61
68
 
62
- 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. |
63
73
 
64
74
  ```js
65
- app.get("/users", handler).post("/users", handler).put("/users/:id", handler).patch("/users/:id", handler).delete("/users/:id", handler);
75
+ const app = new App({ requestTimeout: 5000, ignoreTrailingSlash: true });
66
76
  ```
67
77
 
68
- 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.
69
79
 
70
- Satu route boleh punya beberapa handler:
80
+ ### Routing
71
81
 
72
82
  ```js
73
- app.get("/profile", authenticate, loadUser, (req, res) => {
74
- res.json(req.user);
75
- });
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
76
94
  ```
77
95
 
78
- ### 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.
79
97
 
80
- Segmen yang diawali `:` adalah parameter. Nilainya otomatis di-decode dengan `decodeURIComponent`; jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
98
+ **Path syntax:**
81
99
 
82
- ```js
83
- app.get("/posts/:postId/comments/:commentId", (req, res) => {
84
- const { postId, commentId } = req.params;
85
- res.json({ postId, commentId });
86
- });
87
- ```
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"` |
88
105
 
89
- 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`.
90
110
 
91
- 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:**
92
112
 
93
113
  ```js
94
- app.get("/users/:id", handler);
95
- app.get("/users/:userId/posts", handler);
96
- // 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
+ });
97
117
  ```
98
118
 
99
- ### Wildcard
119
+ Params with different names may share a level, for example `/users/:id` and `/users/:userId/posts`.
100
120
 
101
- Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
121
+ **Nested routers:**
102
122
 
103
123
  ```js
104
- app.get("/files/*", (req, res) => {
105
- res.json({ file: req.params.wildcard });
106
- });
124
+ const users = new App.Router();
125
+ users.get("/:id", (req, res) => res.json({ id: req.params.id }));
107
126
 
108
- // GET /files/images/logo.png -> { file: "images/logo.png" }
127
+ app.use("/users", users);
128
+ // GET /users/42 -> users "/:id"
109
129
  ```
110
130
 
111
- Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `/files`.
112
-
113
- ### 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.
114
132
 
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.
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`.
118
- - Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
133
+ ---
119
134
 
120
- ## Middleware
135
+ ### Middleware
121
136
 
122
- 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.
123
138
 
124
139
  ```js
125
- // Global
126
140
  app.use((req, res) => {
127
- console.log(req.method, req.path);
141
+ req.startTime = Date.now();
142
+ // no next(): the chain continues automatically
128
143
  });
129
144
 
130
- // Scoped: jalan untuk /admin dan semua sub-path-nya
131
- app.use("/admin", async (req, res) => {
132
- if (req.headers.authorization !== "secret") {
133
- res.status(401).json({ error: "Unauthorized" });
134
- }
145
+ app.get("/", (req, res) => {
146
+ res.json({ elapsed: Date.now() - req.startTime });
135
147
  });
136
148
  ```
137
149
 
138
- Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
139
-
140
- > 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.
141
-
142
- ## Nested Router
143
-
144
- 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.
145
151
 
146
152
  ```js
147
- const { Router } = App;
148
-
149
- const users = new Router();
150
- users.get("/", (req, res) => res.json({ list: [] }));
151
- users.get("/:id", (req, res) => res.json({ id: req.params.id }));
152
-
153
- const admin = new Router();
154
- admin.use("/users", users);
155
-
156
- const app = new App();
157
- app.use("/admin", admin);
158
-
159
- // GET /admin/users -> { list: [] }
160
- // 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
161
157
  ```
162
158
 
163
- > 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.
164
-
165
- ## Error Handling
166
-
167
- Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
159
+ ### Errors
168
160
 
169
- > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
170
-
171
- ### Menentukan status code
172
-
173
- 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)`:
174
162
 
175
163
  ```js
176
164
  app.get("/admin", (req, res) => {
177
165
  res.status(403);
178
166
  throw new Error("Forbidden");
179
167
  });
180
- ```
181
-
182
- Jika `res.statusCode` masih di bawah 400 saat error ditangani, hasilnya `500`. Properti `statusCode` pada objek error tidak dipakai.
183
-
184
- ### Error handler sendiri
185
168
 
186
- Di error handler, `res.statusCode` masih bernilai awal (`200`) bila error dilempar tanpa mengatur status, jadi atur sendiri:
187
-
188
- ```js
189
169
  app.use((err, req, res) => {
190
170
  const status = res.statusCode >= 400 ? res.statusCode : 500;
191
- res.status(status).json({ error: { message: err.message } });
171
+ res.status(status).json({ error: err.message });
192
172
  });
193
173
  ```
194
174
 
195
- ### Respons default
196
-
197
- Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
175
+ If no error handler responds, a default JSON response is sent:
198
176
 
199
177
  ```json
200
178
  { "error": { "message": "Not Found" } }
201
179
  ```
202
180
 
203
- - Tidak ada route cocok: `404 Not Found`
204
- - `res.statusCode` sudah `>= 400`: dipakai apa adanya
205
- - `res.statusCode` masih `< 400`: diganti `500`
206
- - Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
207
- - 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`).
208
185
 
209
- ## Timeout
186
+ Handlers are recognised by arity (`handler.length === 3`), so do not use default or rest parameters in handler signatures.
210
187
 
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.
188
+ ---
212
189
 
213
- ```js
214
- const app = new App({ requestTimeout: 5000 });
215
- ```
190
+ ### Request
216
191
 
217
- - Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
218
- - 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 |
219
202
 
220
- ### `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.
221
204
 
222
- `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:
223
206
 
224
207
  ```js
225
- app.get("/proxy", async (req, res) => {
226
- const upstream = await fetch("https://example.com/api", {
227
- signal: req.controller.signal,
228
- });
229
- res.json(await upstream.json());
230
- });
208
+ // GET /search?__proto__=x
209
+ req.query
210
+ .__proto__ // "x" (a plain own property)
211
+ ({}).__proto__; // unchanged
231
212
  ```
232
213
 
233
- ## Request
214
+ Because of that, use `Object.hasOwn(req.query, "key")` or `"key" in req.query` instead of `req.query.hasOwnProperty(...)`.
215
+
216
+ ### Response
234
217
 
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()`) |
241
- | `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
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) |
242
226
 
243
227
  ```js
244
- // GET /search?tag=a&tag=b&page=2
245
- app.get("/search", (req, res) => {
246
- res.json(req.query); // { tag: ["a", "b"], page: "2" }
247
- });
228
+ res.status(201).json({ created: true });
229
+ res.send("hello");
248
230
  ```
249
231
 
250
- `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(...)`.
232
+ `res.send()` and `res.json()` do nothing if the response has already ended.
233
+
234
+ ---
251
235
 
252
- `Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
236
+ ### Built-in middleware
253
237
 
254
- ## Response
238
+ #### `App.json(options?)`
255
239
 
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 |
240
+ Parses JSON bodies. Only runs on `POST`, `PUT`, `PATCH` and `QUERY` requests with a JSON `Content-Type`.
241
+
242
+ | Option | Type | Default | Description |
243
+ | ------- | -------- | ----------------- | ---------------------- |
244
+ | `limit` | `number` | `102400` (100 KB) | Max body size in bytes |
261
245
 
262
246
  ```js
263
- res.status(201).json({ created: true });
247
+ app.use(App.json({ limit: 1024 * 1024 })); // 1 MB
264
248
  ```
265
249
 
266
- `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.
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?)`
267
256
 
268
- ## Middleware Bawaan
257
+ Parses `application/x-www-form-urlencoded` bodies. Same methods, `limit` option, `413` behaviour and empty-body handling as `App.json`.
269
258
 
270
- ### `json([options])`
259
+ The result follows the same rules as `req.query`: a null-prototype object, repeated keys become arrays, `+` becomes a space.
271
260
 
272
261
  ```js
273
- app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
274
- ```
262
+ app.use(App.urlencoded());
275
263
 
276
- | Opsi | Default | Keterangan |
277
- | ------- | -------- | ------------------------------- |
278
- | `limit` | `102400` | Ukuran body maksimum dalam byte |
264
+ app.post("/login", (req, res) => {
265
+ // body: user=ndiing&tag=a&tag=b
266
+ res.json(req.body); // { user: "ndiing", tag: ["a", "b"] }
267
+ });
268
+ ```
279
269
 
280
- Perilaku:
270
+ #### `App.cors(options?)`
281
271
 
282
- - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
283
- - Body melebihi `limit` menghasilkan `413` (dicek lewat `Content-Length` dan saat streaming)
284
- - JSON tidak valid menghasilkan `400`, dengan error asli di `cause`
285
- - Body kosong menghasilkan `req.body = null`
286
- - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
272
+ CORS middleware with preflight support.
287
273
 
288
- ### `cors([options])`
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. |
289
281
 
290
282
  ```js
291
283
  app.use(
292
284
  App.cors({
293
- origin: ["https://example.com", "https://app.example.com"],
285
+ origin: ["https://app.example.com", "https://admin.example.com"],
294
286
  credentials: true,
287
+ headers: ["content-type", "authorization"],
295
288
  methods: ["GET", "POST", "PUT", "DELETE"],
296
- headers: ["Content-Type", "Authorization"],
297
- maxAge: 600,
289
+ maxAge: 3600,
298
290
  }),
299
291
  );
300
292
  ```
301
293
 
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 |
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.
309
299
 
310
- Perilaku:
300
+ **Security notes:**
311
301
 
312
- - **Preflight** adalah request `OPTIONS` dengan header `Access-Control-Request-Method`. Preflight dijawab langsung dengan `204` dan chain berhenti. `OPTIONS` biasa diteruskan ke handler berikutnya
313
- - Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan dan header `Vary: Origin` ditambahkan
314
- - `Access-Control-Allow-Headers`: jika `headers` berupa array, header hanya dikirim bila **semua** header yang diminta ada di daftar
315
- - `Access-Control-Allow-Methods`: jika `methods` berupa array, header hanya dikirim bila method yang diminta ada di daftar
316
- - Jika `headers` atau `methods` tidak diisi, nilai yang diminta di preflight dipantulkan apa adanya
317
- - `"*"` untuk `headers` dan `methods` hanya dikirim sebagai `*` bila `credentials: false`. Dengan `credentials: true`, nilainya diperlakukan seperti tidak diisi (dipantulkan)
318
- - 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 `*`).
319
305
 
320
- > `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
306
+ ---
321
307
 
322
- ## Opsi
308
+ ### Timeout
323
309
 
324
310
  ```js
325
311
  const app = new App({ requestTimeout: 5000 });
326
- ```
327
-
328
- | Opsi | Default | Keterangan |
329
- | ---------------- | ------- | ----------------------------------------------------------------------------- |
330
- | `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
331
312
 
332
- `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
+ ```
333
329
 
334
- ## 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.
335
331
 
336
- ### `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.
337
333
 
338
- 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.
339
335
 
340
- ### `router.use([path], ...handlers | router)`
336
+ ---
341
337
 
342
- Mendaftarkan middleware, error handler, atau nested router untuk semua method.
338
+ ### Using your own server
343
339
 
344
- ### `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)`:
345
341
 
346
- Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, handler dipasang di root.
342
+ ```js
343
+ const https = require("node:https");
347
344
 
348
- ### `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
+ ```
349
348
 
350
- Mencari route tanpa menjalankannya. Berguna untuk testing.
349
+ ## Porting from Express
351
350
 
352
351
  ```js
353
- 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({}));
354
365
  ```
355
366
 
356
- ### `app.listen(...args)`
367
+ **Key differences:**
368
+
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`.
357
373
 
358
- Membuat `http.Server` dengan `Request` dan `Response` milik router, lalu memanggil `server.listen(...args)`. Mengembalikan instance `http.Server`.
374
+ **Using Express-style middleware.** Middleware that calls `next()` can be wrapped in a small adapter:
359
375
 
360
376
  ```js
361
- const server = app.listen(0); // port acak
362
- 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")()));
363
384
  ```
364
385
 
365
- ### 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.
366
387
 
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)`:
388
+ ## Limitations
368
389
 
369
- ```js
370
- 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.
371
394
 
372
- const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
373
- server.listen(443);
374
- ```
395
+ These are deliberate omissions: the core stays small and features come from middleware.
375
396
 
376
397
  ## Benchmark
377
398
 
378
- 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" }`.
379
400
 
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 |
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 |
385
407
 
386
- 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.
409
+
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):
411
+
412
+ ```bash
413
+ npm i -D autocannon express fastify
414
+ node benchmark/run.js
415
+ ```
387
416
 
388
417
  ## Testing
389
418
 
@@ -391,6 +420,14 @@ Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark in
391
420
  npm test
392
421
  ```
393
422
 
394
- ## Lisensi
423
+ **205 tests, 100% line coverage, 96% branch coverage.**
424
+
425
+ ## License
395
426
 
396
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.0",
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,50 +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 prev = params[child.name];
198
- params[child.name] = decodeParam(segment);
199
- const res = this.search(method, segments, child, path, params, 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);
200
237
  if (res.matched) return res;
201
- if (prev === undefined) {
202
- const { [child.name]: _, ...rest } = params;
203
- params = rest;
204
- } else params[child.name] = prev;
238
+ }
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;
205
248
  }
206
249
  }
207
250
  if (node.wildcard && (node.wildcard.handlers.get(method) || node.wildcard.handlers.get("ALL"))) {
208
- params["wildcard"] = segments.slice(index).map(decodeParam).join("/");
209
- path.push(node.wildcard);
210
- return { path, params, matched: true };
251
+ nodes.push(node.wildcard);
252
+ return {
253
+ nodes,
254
+ params: { ...params, wildcard: path },
255
+ matched: true,
256
+ };
211
257
  }
212
- if (path.length > state.path.length) {
213
- state.path = path.slice();
214
- state.params = { ...params };
258
+ if (nodes.length > state.nodes.length) {
259
+ state = { nodes: nodes.slice(), params: { ...params } };
215
260
  }
216
- path.pop();
217
- return { path: state.path, params: state.params, matched: false };
261
+ nodes.pop();
262
+ return { nodes: state.nodes, params: state.params, matched: false };
218
263
  }
219
264
 
220
265
  /**
@@ -246,14 +291,19 @@ class Router {
246
291
  * @returns {{matched: boolean;params: Object;paramsLength: number;handlers: any[];errorHandlers: any[];}}
247
292
  */
248
293
  find(method, path) {
249
- 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
+ }
250
300
  const handlers = {};
251
301
  const errorHandlers = {};
252
- for (const r of res.path) {
302
+ for (const r of res.nodes) {
253
303
  this.collectMiddleware(r, handlers, errorHandlers);
254
304
  }
255
305
  if (res.matched) {
256
- this.collectHandler(res.path.at(-1), method, handlers);
306
+ this.collectHandler(res.nodes.at(-1), method, handlers);
257
307
  }
258
308
  return {
259
309
  matched: res.matched,