@ndiinginc/router 4.13.1 → 4.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -259
- package/package.json +2 -1
- package/src/middleware/urlencoded.js +44 -44
- package/src/node.js +8 -1
- package/src/router.js +97 -42
package/README.md
CHANGED
|
@@ -1,30 +1,49 @@
|
|
|
1
1
|
# @ndiinginc/router
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
+
Requires Node.js 18.3 or newer (`res.appendHeader` is used by the CORS middleware).
|
|
26
45
|
|
|
27
|
-
## Quick
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
63
|
+
`app.listen(...)` forwards its arguments to `server.listen(...)` and returns the `http.Server`, so the usual callback works.
|
|
52
64
|
|
|
53
|
-
|
|
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
|
-
|
|
67
|
+
### `new App(options?)`
|
|
62
68
|
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
78
|
+
`App` extends `Router`. Use `new App.Router()` for routers that are only mounted into another router.
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
### Routing
|
|
76
81
|
|
|
77
82
|
```js
|
|
78
|
-
app.get(
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
**Path syntax:**
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
**Multiple params:**
|
|
97
112
|
|
|
98
113
|
```js
|
|
99
|
-
app.get("/users/:
|
|
100
|
-
|
|
101
|
-
|
|
114
|
+
app.get("/users/:userId/posts/:postId", (req, res) => {
|
|
115
|
+
res.json(req.params); // { userId: "...", postId: "..." }
|
|
116
|
+
});
|
|
102
117
|
```
|
|
103
118
|
|
|
104
|
-
|
|
119
|
+
Params with different names may share a level, for example `/users/:id` and `/users/:userId/posts`.
|
|
105
120
|
|
|
106
|
-
|
|
121
|
+
**Nested routers:**
|
|
107
122
|
|
|
108
123
|
```js
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
});
|
|
124
|
+
const users = new App.Router();
|
|
125
|
+
users.get("/:id", (req, res) => res.json({ id: req.params.id }));
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
app.use("/users", users);
|
|
128
|
+
// GET /users/42 -> users "/:id"
|
|
114
129
|
```
|
|
115
130
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
### Middleware
|
|
126
136
|
|
|
127
|
-
Middleware
|
|
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
|
-
|
|
141
|
+
req.startTime = Date.now();
|
|
142
|
+
// no next(): the chain continues automatically
|
|
133
143
|
});
|
|
134
144
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
171
|
+
res.status(status).json({ error: err.message });
|
|
197
172
|
});
|
|
198
173
|
```
|
|
199
174
|
|
|
200
|
-
|
|
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
|
-
-
|
|
209
|
-
-
|
|
210
|
-
- `
|
|
211
|
-
-
|
|
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
|
-
|
|
186
|
+
Handlers are recognised by arity (`handler.length === 3`), so do not use default or rest parameters in handler signatures.
|
|
215
187
|
|
|
216
|
-
|
|
188
|
+
---
|
|
217
189
|
|
|
218
|
-
|
|
219
|
-
const app = new App({ requestTimeout: 5000 });
|
|
220
|
-
```
|
|
190
|
+
### Request
|
|
221
191
|
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
|
205
|
+
`req.query` and `req.params` have a **null prototype**, which protects against prototype pollution:
|
|
228
206
|
|
|
229
207
|
```js
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
|
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()`
|
|
232
|
+
`res.send()` and `res.json()` do nothing if the response has already ended.
|
|
272
233
|
|
|
273
|
-
|
|
234
|
+
---
|
|
274
235
|
|
|
275
|
-
###
|
|
276
|
-
|
|
277
|
-
```js
|
|
278
|
-
app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
|
|
279
|
-
```
|
|
236
|
+
### Built-in middleware
|
|
280
237
|
|
|
281
|
-
|
|
282
|
-
| --- | --- | --- |
|
|
283
|
-
| `limit` | `102400` | Ukuran body maksimum dalam byte |
|
|
238
|
+
#### `App.json(options?)`
|
|
284
239
|
|
|
285
|
-
|
|
240
|
+
Parses JSON bodies. Only runs on `POST`, `PUT`, `PATCH` and `QUERY` requests with a JSON `Content-Type`.
|
|
286
241
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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.
|
|
247
|
+
app.use(App.json({ limit: 1024 * 1024 })); // 1 MB
|
|
297
248
|
```
|
|
298
249
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
257
|
+
Parses `application/x-www-form-urlencoded` bodies. Same methods, `limit` option, `413` behaviour and empty-body handling as `App.json`.
|
|
304
258
|
|
|
305
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
328
|
-
maxAge: 600,
|
|
289
|
+
maxAge: 3600,
|
|
329
290
|
}),
|
|
330
291
|
);
|
|
331
292
|
```
|
|
332
293
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
300
|
+
**Security notes:**
|
|
342
301
|
|
|
343
|
-
-
|
|
344
|
-
-
|
|
345
|
-
- `
|
|
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
|
-
|
|
306
|
+
---
|
|
352
307
|
|
|
353
|
-
|
|
308
|
+
### Timeout
|
|
354
309
|
|
|
355
310
|
```js
|
|
356
311
|
const app = new App({ requestTimeout: 5000 });
|
|
357
|
-
```
|
|
358
312
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
`req.controller` is also aborted if a handler throws, with the error as the reason.
|
|
370
335
|
|
|
371
|
-
|
|
336
|
+
---
|
|
372
337
|
|
|
373
|
-
|
|
338
|
+
### Using your own server
|
|
374
339
|
|
|
375
|
-
|
|
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
|
-
|
|
342
|
+
```js
|
|
343
|
+
const https = require("node:https");
|
|
378
344
|
|
|
379
|
-
|
|
345
|
+
const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
|
|
346
|
+
server.listen(443);
|
|
347
|
+
```
|
|
380
348
|
|
|
381
|
-
|
|
349
|
+
## Porting from Express
|
|
382
350
|
|
|
383
351
|
```js
|
|
384
|
-
|
|
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
|
-
|
|
367
|
+
**Key differences:**
|
|
388
368
|
|
|
389
|
-
|
|
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
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
## Limitations
|
|
399
389
|
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
415
|
-
|
|
|
416
|
-
| Fastify |
|
|
417
|
-
|
|
|
418
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "4.14.1",
|
|
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
|
-
*
|
|
21
|
-
* @
|
|
21
|
+
*
|
|
22
|
+
* @param {*} a
|
|
23
|
+
* @param {*} b
|
|
24
|
+
* @returns
|
|
25
|
+
*/
|
|
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
|
|
22
37
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
49
|
-
node.handlers.getOrInsert(
|
|
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,51 @@ 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,
|
|
178
|
-
|
|
179
|
-
if (
|
|
222
|
+
search(method, path, node = this.root, 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 {
|
|
226
|
+
return { nodes, params, matched: true };
|
|
182
227
|
}
|
|
183
|
-
if (
|
|
184
|
-
state.
|
|
185
|
-
|
|
228
|
+
if (nodes.length > state.nodes.length) {
|
|
229
|
+
state.nodes=nodes.slice()
|
|
230
|
+
params={...params}
|
|
186
231
|
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
}
|
|
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;
|
|
232
|
+
nodes.pop();
|
|
233
|
+
return { nodes: state.nodes, params: state.params, matched: false };
|
|
194
234
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const res = this.search(method, segments, child, path, next, state, index + 1);
|
|
235
|
+
for (const [segment, child] of node.children) {
|
|
236
|
+
if (path.startsWith(segment)) {
|
|
237
|
+
const res = this.search(method, path.slice(segment.length), child, nodes, params, state);
|
|
199
238
|
if (res.matched) return res;
|
|
200
239
|
}
|
|
201
240
|
}
|
|
241
|
+
for (const [segment, child] of node.params) {
|
|
242
|
+
const slashIdx = path.indexOf("/");
|
|
243
|
+
const value = slashIdx === -1 ? path : path.slice(0, slashIdx);
|
|
244
|
+
const rest = slashIdx === -1 ? "" : path.slice(slashIdx);
|
|
245
|
+
if (value.length > 0) {
|
|
246
|
+
const next = { ...params, [child.name]: decodeParam(value) };
|
|
247
|
+
const result = this.search(method, rest, child, nodes, next, state);
|
|
248
|
+
if (result.matched) return result;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
202
251
|
if (node.wildcard && (node.wildcard.handlers.get(method) || node.wildcard.handlers.get("ALL"))) {
|
|
203
|
-
|
|
252
|
+
nodes.push(node.wildcard);
|
|
204
253
|
return {
|
|
205
|
-
|
|
206
|
-
params: { ...params, wildcard:
|
|
254
|
+
nodes,
|
|
255
|
+
params: { ...params, wildcard: path },
|
|
207
256
|
matched: true,
|
|
208
257
|
};
|
|
209
258
|
}
|
|
210
|
-
if (
|
|
211
|
-
state.
|
|
212
|
-
|
|
259
|
+
if (nodes.length > state.nodes.length) {
|
|
260
|
+
state.nodes=nodes.slice()
|
|
261
|
+
params={...params}
|
|
213
262
|
}
|
|
214
|
-
|
|
215
|
-
return {
|
|
263
|
+
nodes.pop();
|
|
264
|
+
return { nodes: state.nodes, params: state.params, matched: false };
|
|
216
265
|
}
|
|
217
266
|
|
|
218
267
|
/**
|
|
@@ -244,14 +293,19 @@ class Router {
|
|
|
244
293
|
* @returns {{matched: boolean;params: Object;paramsLength: number;handlers: any[];errorHandlers: any[];}}
|
|
245
294
|
*/
|
|
246
295
|
find(method, path) {
|
|
247
|
-
|
|
296
|
+
let res = this.search(method, path);
|
|
297
|
+
|
|
298
|
+
if (!res.matched && path.length > 1) {
|
|
299
|
+
const alt = path.endsWith("/") ? path.slice(0, -1) : path + "/";
|
|
300
|
+
res = this.search(method, alt);
|
|
301
|
+
}
|
|
248
302
|
const handlers = {};
|
|
249
303
|
const errorHandlers = {};
|
|
250
|
-
for (const r of res.
|
|
304
|
+
for (const r of res.nodes) {
|
|
251
305
|
this.collectMiddleware(r, handlers, errorHandlers);
|
|
252
306
|
}
|
|
253
307
|
if (res.matched) {
|
|
254
|
-
this.collectHandler(res.
|
|
308
|
+
this.collectHandler(res.nodes.at(-1), method, handlers);
|
|
255
309
|
}
|
|
256
310
|
return {
|
|
257
311
|
matched: res.matched,
|
|
@@ -261,5 +315,6 @@ class Router {
|
|
|
261
315
|
errorHandlers: Object.values(errorHandlers),
|
|
262
316
|
};
|
|
263
317
|
}
|
|
318
|
+
|
|
264
319
|
}
|
|
265
320
|
exports.Router = Router;
|