@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 +269 -232
- package/package.json +2 -1
- package/src/middleware/urlencoded.js +44 -44
- package/src/node.js +8 -1
- package/src/router.js +97 -47
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) 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,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
|
-
|
|
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.cors` | Middleware CORS |
|
|
65
|
+
## API
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
### `new App(options?)`
|
|
61
68
|
|
|
62
|
-
|
|
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
|
|
75
|
+
const app = new App({ requestTimeout: 5000, ignoreTrailingSlash: true });
|
|
66
76
|
```
|
|
67
77
|
|
|
68
|
-
|
|
78
|
+
`App` extends `Router`. Use `new App.Router()` for routers that are only mounted into another router.
|
|
69
79
|
|
|
70
|
-
|
|
80
|
+
### Routing
|
|
71
81
|
|
|
72
82
|
```js
|
|
73
|
-
app.get(
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
**Path syntax:**
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
**Multiple params:**
|
|
92
112
|
|
|
93
113
|
```js
|
|
94
|
-
app.get("/users/:
|
|
95
|
-
|
|
96
|
-
|
|
114
|
+
app.get("/users/:userId/posts/:postId", (req, res) => {
|
|
115
|
+
res.json(req.params); // { userId: "...", postId: "..." }
|
|
116
|
+
});
|
|
97
117
|
```
|
|
98
118
|
|
|
99
|
-
|
|
119
|
+
Params with different names may share a level, for example `/users/:id` and `/users/:userId/posts`.
|
|
100
120
|
|
|
101
|
-
|
|
121
|
+
**Nested routers:**
|
|
102
122
|
|
|
103
123
|
```js
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
124
|
+
const users = new App.Router();
|
|
125
|
+
users.get("/:id", (req, res) => res.json({ id: req.params.id }));
|
|
107
126
|
|
|
108
|
-
|
|
127
|
+
app.use("/users", users);
|
|
128
|
+
// GET /users/42 -> users "/:id"
|
|
109
129
|
```
|
|
110
130
|
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
+
### Middleware
|
|
121
136
|
|
|
122
|
-
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.
|
|
123
138
|
|
|
124
139
|
```js
|
|
125
|
-
// Global
|
|
126
140
|
app.use((req, res) => {
|
|
127
|
-
|
|
141
|
+
req.startTime = Date.now();
|
|
142
|
+
// no next(): the chain continues automatically
|
|
128
143
|
});
|
|
129
144
|
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
171
|
+
res.status(status).json({ error: err.message });
|
|
192
172
|
});
|
|
193
173
|
```
|
|
194
174
|
|
|
195
|
-
|
|
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
|
-
-
|
|
204
|
-
-
|
|
205
|
-
- `
|
|
206
|
-
-
|
|
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
|
-
|
|
186
|
+
Handlers are recognised by arity (`handler.length === 3`), so do not use default or rest parameters in handler signatures.
|
|
210
187
|
|
|
211
|
-
|
|
188
|
+
---
|
|
212
189
|
|
|
213
|
-
|
|
214
|
-
const app = new App({ requestTimeout: 5000 });
|
|
215
|
-
```
|
|
190
|
+
### Request
|
|
216
191
|
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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.
|
|
205
|
+
`req.query` and `req.params` have a **null prototype**, which protects against prototype pollution:
|
|
223
206
|
|
|
224
207
|
```js
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
236
|
-
|
|
|
237
|
-
| `
|
|
238
|
-
| `
|
|
239
|
-
| `
|
|
240
|
-
| `
|
|
241
|
-
| `
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
`
|
|
232
|
+
`res.send()` and `res.json()` do nothing if the response has already ended.
|
|
233
|
+
|
|
234
|
+
---
|
|
251
235
|
|
|
252
|
-
|
|
236
|
+
### Built-in middleware
|
|
253
237
|
|
|
254
|
-
|
|
238
|
+
#### `App.json(options?)`
|
|
255
239
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
|
259
|
-
|
|
|
260
|
-
| `
|
|
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
|
-
|
|
247
|
+
app.use(App.json({ limit: 1024 * 1024 })); // 1 MB
|
|
264
248
|
```
|
|
265
249
|
|
|
266
|
-
|
|
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
|
-
|
|
257
|
+
Parses `application/x-www-form-urlencoded` bodies. Same methods, `limit` option, `413` behaviour and empty-body handling as `App.json`.
|
|
269
258
|
|
|
270
|
-
|
|
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.
|
|
274
|
-
```
|
|
262
|
+
app.use(App.urlencoded());
|
|
275
263
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
270
|
+
#### `App.cors(options?)`
|
|
281
271
|
|
|
282
|
-
|
|
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
|
-
|
|
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://
|
|
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
|
-
|
|
297
|
-
maxAge: 600,
|
|
289
|
+
maxAge: 3600,
|
|
298
290
|
}),
|
|
299
291
|
);
|
|
300
292
|
```
|
|
301
293
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
300
|
+
**Security notes:**
|
|
311
301
|
|
|
312
|
-
-
|
|
313
|
-
-
|
|
314
|
-
- `
|
|
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
|
-
|
|
306
|
+
---
|
|
321
307
|
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
+
`req.controller` is also aborted if a handler throws, with the error as the reason.
|
|
339
335
|
|
|
340
|
-
|
|
336
|
+
---
|
|
341
337
|
|
|
342
|
-
|
|
338
|
+
### Using your own server
|
|
343
339
|
|
|
344
|
-
|
|
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
|
-
|
|
342
|
+
```js
|
|
343
|
+
const https = require("node:https");
|
|
347
344
|
|
|
348
|
-
|
|
345
|
+
const server = https.createServer({ ...App.SERVER_OPTIONS, key, cert }, (req, res) => app.handle(req, res));
|
|
346
|
+
server.listen(443);
|
|
347
|
+
```
|
|
349
348
|
|
|
350
|
-
|
|
349
|
+
## Porting from Express
|
|
351
350
|
|
|
352
351
|
```js
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
+
**Using Express-style middleware.** Middleware that calls `next()` can be wrapped in a small adapter:
|
|
359
375
|
|
|
360
376
|
```js
|
|
361
|
-
const
|
|
362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
+
## Limitations
|
|
368
389
|
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
381
|
-
|
|
|
382
|
-
| Fastify
|
|
383
|
-
|
|
|
384
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
21
|
-
* @
|
|
21
|
+
*
|
|
22
|
+
* @param {*} a
|
|
23
|
+
* @param {*} b
|
|
24
|
+
* @returns
|
|
22
25
|
*/
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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,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,
|
|
178
|
-
|
|
179
|
-
if (
|
|
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 {
|
|
226
|
+
return { nodes, params, matched: true };
|
|
182
227
|
}
|
|
183
|
-
if (
|
|
184
|
-
state
|
|
185
|
-
state.params = { ...params };
|
|
228
|
+
if (nodes.length > state.nodes.length) {
|
|
229
|
+
state = { nodes: nodes.slice(), params: { ...params } };
|
|
186
230
|
}
|
|
187
|
-
|
|
188
|
-
return {
|
|
231
|
+
nodes.pop();
|
|
232
|
+
return { nodes: state.nodes, params: state.params, matched: false };
|
|
189
233
|
}
|
|
190
|
-
const segment
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
251
|
+
nodes.push(node.wildcard);
|
|
252
|
+
return {
|
|
253
|
+
nodes,
|
|
254
|
+
params: { ...params, wildcard: path },
|
|
255
|
+
matched: true,
|
|
256
|
+
};
|
|
211
257
|
}
|
|
212
|
-
if (
|
|
213
|
-
state
|
|
214
|
-
state.params = { ...params };
|
|
258
|
+
if (nodes.length > state.nodes.length) {
|
|
259
|
+
state = { nodes: nodes.slice(), params: { ...params } };
|
|
215
260
|
}
|
|
216
|
-
|
|
217
|
-
return {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
306
|
+
this.collectHandler(res.nodes.at(-1), method, handlers);
|
|
257
307
|
}
|
|
258
308
|
return {
|
|
259
309
|
matched: res.matched,
|