@ndiinginc/router 4.7.0 → 4.9.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
@@ -5,13 +5,13 @@ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsu
5
5
  ## Fitur
6
6
 
7
7
  - Zero dependency, murni `node:http`
8
- - Routing berbasis tree per segmen (static match diprioritaskan, lalu dynamic)
9
- - Parameter dinamis `:id` dan wildcard `*`
8
+ - Routing berbasis tree per segmen: static, lalu `:param`, lalu wildcard `*`, dengan backtracking
9
+ - Parameter dinamis `:id` dan wildcard `*` yang menangkap sisa path
10
10
  - LRU cache untuk hasil lookup route
11
11
  - Nested router dengan `use(path, router)`
12
12
  - Middleware global dan per-path, mendukung `async`
13
13
  - Error handler dengan signature `(err, req, res)`
14
- - Timeout per request yang menghasilkan `504`, dengan `req.signal` untuk pembatalan kooperatif
14
+ - Timeout per request, dengan `req.controller` (`AbortController`) untuk pembatalan kooperatif
15
15
  - `req.path`, `req.query`, `req.params`, `req.body`
16
16
  - Helper `res.status()`, `res.send()`, dan `res.json()`
17
17
  - Middleware bawaan: `json()` (dengan limit dan proteksi prototype pollution) dan `cors()`
@@ -82,7 +82,7 @@ app.get("/profile", authenticate, loadUser, (req, res) => {
82
82
 
83
83
  ### Parameter dinamis
84
84
 
85
- Nilai parameter otomatis di-decode dengan `decodeURIComponent`. Jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
85
+ Segmen yang diawali `:` adalah parameter. Nilainya otomatis di-decode dengan `decodeURIComponent`; jika formatnya tidak valid (misalnya `%`), nilai mentah dipakai apa adanya.
86
86
 
87
87
  ```js
88
88
  app.get("/posts/:postId/comments/:commentId", (req, res) => {
@@ -91,20 +91,34 @@ app.get("/posts/:postId/comments/:commentId", (req, res) => {
91
91
  });
92
92
  ```
93
93
 
94
+ Satu segmen adalah satu parameter utuh; nama parameter tidak bisa digabung dengan teks lain dalam segmen yang sama (`:id.json` akan dianggap parameter bernama `id.json`).
95
+
96
+ Beberapa parameter dengan nama berbeda boleh ada di level yang sama. Router mencoba semuanya sesuai urutan pendaftaran, dan memakai yang cocok sampai akhir path:
97
+
98
+ ```js
99
+ app.get("/users/:id", handler);
100
+ app.get("/users/:userId/posts", handler);
101
+ // GET /users/5/posts -> req.params.userId === "5"
102
+ ```
103
+
94
104
  ### Wildcard
95
105
 
96
- `*` cocok dengan karakter apa pun **di dalam satu segmen**. Wildcard tidak melintasi `/`.
106
+ Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
97
107
 
98
108
  ```js
99
- app.get("/files/report-*", (req, res) => {
100
- res.json({ path: req.path });
109
+ app.get("/files/*", (req, res) => {
110
+ res.json({ file: req.params.wildcard });
101
111
  });
112
+
113
+ // GET /files/images/logo.png -> { file: "images/logo.png" }
102
114
  ```
103
115
 
116
+ Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `/files`.
117
+
104
118
  ### Aturan matching
105
119
 
106
- - Untuk setiap segmen, router mencoba **static match dulu**, baru pattern dinamis. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
107
- - Jika ada beberapa pattern dinamis di level yang sama, yang **terdaftar lebih dulu** yang dipakai (tanpa backtracking).
120
+ - Di setiap level, router mencoba **static dulu, lalu parameter (sesuai urutan pendaftaran), lalu wildcard**. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
121
+ - Jika sebuah cabang gagal di tengah jalan, router mundur (backtrack) dan mencoba cabang berikutnya.
108
122
  - Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
109
123
 
110
124
  ## Middleware
@@ -127,7 +141,7 @@ app.use("/admin", async (req, res) => {
127
141
 
128
142
  Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
129
143
 
130
- > Middleware tetap dijalankan walaupun tidak ada route yang cocok, sebelum respons 404 dikirim.
144
+ > 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.
131
145
 
132
146
  ## Nested Router
133
147
 
@@ -156,32 +170,32 @@ app.use("/admin", admin);
156
170
 
157
171
  Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
158
172
 
159
- ```js
160
- app.get("/boom", () => {
161
- throw new Error("Something broke");
162
- });
173
+ > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
163
174
 
164
- app.use((err, req, res) => {
165
- res.status(err.statusCode || 500).json({ error: { message: err.message } });
175
+ ### Menentukan status code
176
+
177
+ Status code diambil dari `res.statusCode`. Set sebelum melempar error:
178
+
179
+ ```js
180
+ app.get("/admin", (req, res) => {
181
+ res.status(403);
182
+ throw new Error("Forbidden");
166
183
  });
167
184
  ```
168
185
 
169
- > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
186
+ Jika `res.statusCode` masih di bawah 400 saat error ditangani, hasilnya `500`. Properti `statusCode` pada objek error tidak dipakai.
170
187
 
171
- ### Menentukan status code
188
+ ### Error handler sendiri
172
189
 
173
- Status code diambil dari properti `statusCode` pada error. Tanpa properti itu, hasilnya `500`.
190
+ Di error handler, `res.statusCode` masih bernilai awal (`200`) bila error dilempar tanpa mengatur status, jadi atur sendiri:
174
191
 
175
192
  ```js
176
- app.get("/admin", () => {
177
- const err = new Error("Forbidden");
178
- err.statusCode = 403;
179
- throw err;
193
+ app.use((err, req, res) => {
194
+ const status = res.statusCode >= 400 ? res.statusCode : 500;
195
+ res.status(status).json({ error: { message: err.message } });
180
196
  });
181
197
  ```
182
198
 
183
- > `res.statusCode` atau `res.status()` yang diset **sebelum** error dilempar tidak dipakai oleh respons default. Gunakan `err.statusCode`, atau tangani sendiri di error handler.
184
-
185
199
  ### Respons default
186
200
 
187
201
  Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
@@ -191,44 +205,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
191
205
  ```
192
206
 
193
207
  - Tidak ada route cocok: `404 Not Found`
194
- - Error dengan `statusCode`: status tersebut dipakai
195
- - Error tanpa `statusCode`: `500`
208
+ - `res.statusCode` sudah `>= 400`: dipakai apa adanya
209
+ - `res.statusCode` masih `< 400`: diganti `500`
196
210
  - Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
197
211
  - Field `stack` hanya disertakan saat `NODE_ENV=development`
198
212
 
199
213
  ## Timeout
200
214
 
201
- Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, error `504 Gateway Timeout` dilempar dan diteruskan ke error handler.
215
+ Aktifkan lewat opsi `requestTimeout` (milidetik) pada `App`. Jika seluruh chain handler melebihi waktu tersebut, `res.statusCode` diset `502` dan error `Bad Gateway` dilempar ke error handler.
202
216
 
203
217
  ```js
204
218
  const app = new App({ requestTimeout: 5000 });
205
219
  ```
206
220
 
207
221
  - Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
208
- - Handler yang sudah berjalan tidak dihentikan paksa. Tetapi setelah response dikirim, `res.json()` dan `res.send()` mengabaikan penulisan berikutnya, dan handler selanjutnya di chain tidak dipanggil
222
+ - 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`
209
223
 
210
- ### `req.signal`
224
+ ### `req.controller`
211
225
 
212
- Saat `requestTimeout` aktif, `req.signal` adalah `AbortSignal` yang di-abort ketika timeout terjadi (reason-nya error 504) atau ketika salah satu handler melempar error (reason-nya error tersebut). Teruskan ke operasi yang mendukungnya agar pekerjaan ikut dibatalkan:
226
+ `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:
213
227
 
214
228
  ```js
215
229
  app.get("/proxy", async (req, res) => {
216
- const upstream = await fetch("https://example.com/api", { signal: req.signal });
230
+ const upstream = await fetch("https://example.com/api", {
231
+ signal: req.controller.signal,
232
+ });
217
233
  res.json(await upstream.json());
218
234
  });
219
235
  ```
220
236
 
221
- `req.signal` bernilai `undefined` jika `requestTimeout` tidak diaktifkan.
222
-
223
237
  ## Request
224
238
 
225
239
  | Properti | Keterangan |
226
240
  | --- | --- |
227
241
  | `req.path` | Pathname tanpa query string |
228
242
  | `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
229
- | `req.params` | Parameter dari route dinamis (sudah di-decode) |
243
+ | `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
230
244
  | `req.body` | Hasil parse body (butuh middleware `json()`) |
231
- | `req.signal` | `AbortSignal` yang di-abort saat timeout atau error handler (hanya jika `requestTimeout` aktif) |
245
+ | `req.controller` | `AbortController` untuk request ini. Di-abort saat timeout atau error handler (jika `requestTimeout` aktif) |
232
246
 
233
247
  ```js
234
248
  // GET /search?tag=a&tag=b&page=2
@@ -247,13 +261,13 @@ app.get("/search", (req, res) => {
247
261
  | --- | --- |
248
262
  | `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
249
263
  | `res.send(chunk)` | Kirim response dan akhiri |
250
- | `res.json(value)` | Set `Content-Type: application/json`, serialize, lalu kirim |
264
+ | `res.json(value)` | Set `Content-Type: application/json; charset=utf-8`, serialize, lalu kirim |
251
265
 
252
266
  ```js
253
267
  res.status(201).json({ created: true });
254
268
  ```
255
269
 
256
- `Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
270
+ `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.
257
271
 
258
272
  ## Middleware Bawaan
259
273
 
@@ -270,8 +284,8 @@ app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
270
284
  Perilaku:
271
285
 
272
286
  - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
273
- - Body melebihi `limit` menghasilkan `413 Payload Too Large` (dicek lewat `Content-Length` dan saat streaming)
274
- - JSON tidak valid menghasilkan `400` dengan pesan `Invalid JSON body`
287
+ - Body melebihi `limit` menghasilkan `413` (dicek lewat `Content-Length` dan saat streaming)
288
+ - JSON tidak valid menghasilkan `400`, dengan error asli di `cause`
275
289
  - Body kosong menghasilkan `req.body = null`
276
290
  - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
277
291
 
@@ -280,7 +294,7 @@ Perilaku:
280
294
  ```js
281
295
  app.use(
282
296
  App.cors({
283
- origin: "https://example.com",
297
+ origin: ["https://example.com", "https://app.example.com"],
284
298
  credentials: true,
285
299
  methods: ["GET", "POST", "PUT", "DELETE"],
286
300
  headers: ["Content-Type", "Authorization"],
@@ -291,19 +305,21 @@ app.use(
291
305
 
292
306
  | Opsi | Default | Keterangan |
293
307
  | --- | --- | --- |
294
- | `origin` | `"*"` | `"*"`, satu origin, daftar dipisah koma, pola dengan `*` (misalnya `https://*.example.com`), atau `null` |
308
+ | `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
295
309
  | `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
296
- | `headers` | - | `"*"` atau array nama header |
297
- | `methods` | - | `"*"` atau array nama method |
310
+ | `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
311
+ | `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
298
312
  | `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
299
313
 
300
314
  Perilaku:
301
315
 
302
- - Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
303
- - `Access-Control-Allow-Headers` dan `Access-Control-Allow-Methods` hanya dikirim pada preflight, jika nilai yang diminta ada di daftar
304
- - Jika `origin` berupa daftar atau pola (atau `"*"` dengan `credentials: true`), origin dari request dipantulkan bila cocok. Pencocokan dilakukan pada seluruh string (tanpa memperhatikan huruf besar/kecil), dan header `Vary: Origin` ditambahkan pada request non-preflight
305
- - Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
306
- - Pada request non-preflight, `headers` (jika array) dikirim sebagai `Access-Control-Expose-Headers`
316
+ - **Preflight** adalah request `OPTIONS` dengan header `Access-Control-Request-Method`. Preflight dijawab langsung dengan `204` dan chain berhenti. `OPTIONS` biasa diteruskan ke handler berikutnya
317
+ - Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan dan header `Vary: Origin` ditambahkan
318
+ - `Access-Control-Allow-Headers`: jika `headers` berupa array, header hanya dikirim bila **semua** header yang diminta ada di daftar
319
+ - `Access-Control-Allow-Methods`: jika `methods` berupa array, header hanya dikirim bila method yang diminta ada di daftar
320
+ - Jika `headers` atau `methods` tidak diisi, nilai yang diminta di preflight dipantulkan apa adanya
321
+ - `"*"` untuk `headers` dan `methods` hanya dikirim sebagai `*` bila `credentials: false`. Dengan `credentials: true`, nilainya diperlakukan seperti tidak diisi (dipantulkan)
322
+ - Pada request non-preflight, `headers` (jika array atau `"*"`) dikirim sebagai `Access-Control-Expose-Headers`
307
323
 
308
324
  > `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
309
325
 
@@ -315,8 +331,8 @@ const app = new App({ cacheSize: 1000, requestTimeout: 5000 });
315
331
 
316
332
  | Opsi | Default | Keterangan |
317
333
  | --- | --- | --- |
318
- | `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path` |
319
- | `requestTimeout` | - | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#requestTimeout) |
334
+ | `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. `0` menonaktifkan cache. Cache dikosongkan setiap ada route baru didaftarkan |
335
+ | `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
320
336
 
321
337
  `Router` hanya menerima `cacheSize`.
322
338
 
@@ -336,10 +352,10 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
336
352
 
337
353
  ### `router.find(method, path)`
338
354
 
339
- Mencari route tanpa menjalankannya. Hasilnya di-cache. Berguna untuk testing.
355
+ Mencari route tanpa menjalankannya. Hasilnya di-cache dan `params`-nya di-freeze. Berguna untuk testing.
340
356
 
341
357
  ```js
342
- const { params, handlers, errorHandlers } = app.find("GET", "/users/1");
358
+ const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
343
359
  ```
344
360
 
345
361
  ### `app.listen(...args)`
@@ -355,12 +371,11 @@ console.log(server.address().port);
355
371
 
356
372
  Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
357
373
 
358
- | package | Latency | Req/Sec | Bytes/Sec | Result |
359
- | ----------------- | -------- | -------- | --------- | ------------------------------------- |
360
- | fastify | 25.3 ms | 38,738.4 | 7.24 MB | 1550k requests in 40.06s, 290 MB read |
361
- | @ndiinginc/router | 28.45 ms | 34,541.4 | 5.91 MB | 1383k requests in 40.06s, 236 MB read |
362
- | express | 41 ms | 24,095.6 | 6.05 MB | 965k requests in 40.06s, 242 MB read |
363
-
374
+ | | Req/sec (avg) | Latency (avg) |
375
+ | --- | --- | --- |
376
+ | Fastify | 31.475 | 31,27 ms |
377
+ | **@ndiinginc/router** | 29.735 | 33,12 ms |
378
+ | Express | 20.211 | 48,93 ms |
364
379
 
365
380
  Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
366
381
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ndiinginc/router",
3
- "version": "4.7.0",
3
+ "version": "4.9.0",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -19,7 +19,7 @@
19
19
  "type": "commonjs",
20
20
  "devDependencies": {
21
21
  "@ndiinginc/commit": "^1.0.1",
22
- "@ndiinginc/test": "^1.7.1",
22
+ "@ndiinginc/test": "^1.17.0",
23
23
  "@types/jest": "^30.0.0",
24
24
  "@types/node": "^26.6.5"
25
25
  }
package/src/app.js CHANGED
@@ -1,8 +1,8 @@
1
- const http = require("node:http");
2
- const { Router } = require("./router.js");
3
- const { Request } = require("./request.js");
4
- const { Response } = require("./response.js");
5
- const { ErrorResponse } = require("./error.js");
1
+ const http = require("http");
2
+ const { EMPTY_PARAMS } = require("./util");
3
+ const { Router } = require("./router");
4
+ const { Request } = require("./request");
5
+ const { Response } = require("./response");
6
6
 
7
7
  /**@type {import('http').ServerOptions}*/
8
8
  const SERVER_OPTIONS = {
@@ -11,41 +11,59 @@ const SERVER_OPTIONS = {
11
11
  };
12
12
 
13
13
  class App extends Router {
14
- async withTimeout(req, handlerPromise) {
15
- const controller = new AbortController();
16
- const { signal } = controller;
17
-
18
- req.signal = signal;
19
-
20
- const timeout = setTimeout(() => {
21
- controller.abort(ErrorResponse.status(504));
22
- }, this.requestTimeout);
23
-
14
+ async withTimeout(req, res, handlerPromise) {
24
15
  let handleAbort;
25
16
  const timeoutPromise = new Promise((_, reject) => {
26
- handleAbort = () => reject(signal.reason);
27
- signal.addEventListener("abort", handleAbort, { once: true });
17
+ handleAbort = () => reject(req.controller.signal.reason);
18
+ req.controller.signal.addEventListener("abort", handleAbort, { once: true });
28
19
  });
29
20
 
21
+ const timeout = setTimeout(() => {
22
+ res.statusCode = 504;
23
+ req.controller.abort(new Error(http.STATUS_CODES[res.statusCode]));
24
+ }, this.requestTimeout);
25
+
30
26
  const promise = handlerPromise().catch((error) => {
31
- if (!signal.aborted) {
32
- controller.abort(error);
27
+ if (!req.controller.signal.aborted) {
28
+ req.controller.abort(error);
29
+ } else {
30
+ console.error(error); // swallow error?
33
31
  }
34
32
  });
35
33
 
36
- await Promise.race([promise, timeoutPromise]).finally(() => {
37
- signal.removeEventListener("abort", handleAbort);
34
+ try {
35
+ await Promise.race([promise, timeoutPromise]);
36
+ } finally {
38
37
  clearTimeout(timeout);
39
- });
38
+ req.controller.signal.removeEventListener("abort", handleAbort);
39
+ }
40
+ }
41
+
42
+ fallback(res, err) {
43
+ if (!res.writableEnded) {
44
+ if (!err) {
45
+ res.statusCode = 404;
46
+ err = new Error(http.STATUS_CODES[res.statusCode]);
47
+ }
48
+ if (res.statusCode < 400) {
49
+ res.statusCode = 500;
50
+ }
51
+ const error = { message: err.message };
52
+ if (process.env.NODE_ENV === "development") {
53
+ error.stack = err.stack;
54
+ }
55
+ res.json({ error });
56
+ }
40
57
  }
41
58
 
42
59
  /**
43
- * @param {import('http').IncomingMessage} req
44
- * @param {import('http').ServerResponse} res
60
+ * @param {import('http').IncomingMessage & import('./request').Request} req
61
+ * @param {import('http').ServerResponse & import('./response').Response} res
45
62
  */
46
63
  async handle(req, res) {
47
- const { params, handlers, errorHandlers } = this.find(req.method, req.path);
48
- req.params = Object.assign(Object.create(null), params);
64
+ const { params, paramsLength, handlers, errorHandlers } = this.find(req.method, req.path);
65
+ req.params = paramsLength ? Object.assign(Object.create(null), params) : EMPTY_PARAMS;
66
+
49
67
  let err;
50
68
  try {
51
69
  const handlerPromise = async () => {
@@ -54,8 +72,11 @@ class App extends Router {
54
72
  await handler(req, res);
55
73
  }
56
74
  };
57
- if (this.requestTimeout) await this.withTimeout(req, handlerPromise);
58
- else await handlerPromise();
75
+ if (this.requestTimeout) {
76
+ await this.withTimeout(req, res, handlerPromise);
77
+ } else {
78
+ await handlerPromise();
79
+ }
59
80
  } catch (error) {
60
81
  err = error;
61
82
  try {
@@ -67,25 +88,18 @@ class App extends Router {
67
88
  err = new Error(error.message, { cause: err });
68
89
  }
69
90
  }
70
- if (!res.writableEnded) {
71
- if (!err) err = ErrorResponse.status(404);
72
- res.statusCode = err.statusCode || 500;
73
- const error = { message: err.message };
74
- if (process.env.NODE_ENV === "development") {
75
- error.stack = err.stack;
76
- }
77
- res.json({ error });
78
- }
91
+
92
+ this.fallback(res, err);
79
93
  }
80
94
 
81
- listen(...params) {
95
+ listen(...args) {
82
96
  const requestListener = (req, res) => this.handle(req, res);
83
97
  const server = http.createServer(SERVER_OPTIONS, requestListener);
84
- server.listen(...params);
98
+ server.listen(...args);
85
99
  return server;
86
100
  }
87
101
  }
88
102
 
89
- App.SERVER_OPTIONS = SERVER_OPTIONS;
103
+ App.SERVER_OPTIONS=SERVER_OPTIONS
90
104
 
91
105
  exports.App = App;
package/src/cache.js CHANGED
@@ -1,5 +1,5 @@
1
1
  class Cache {
2
- constructor(size) {
2
+ constructor(size = 0) {
3
3
  this.size = size;
4
4
  this.map = new Map();
5
5
  }
@@ -13,12 +13,16 @@ class Cache {
13
13
  }
14
14
 
15
15
  set(key, value) {
16
+ if (this.size <= 0) return; // disabled
16
17
  if (this.map.has(key)) this.map.delete(key);
17
18
  else if (this.map.size >= this.size) {
18
19
  this.map.delete(this.map.keys().next().value);
19
20
  }
20
21
  this.map.set(key, value);
21
22
  }
22
- }
23
23
 
24
+ clear() {
25
+ this.map.clear();
26
+ }
27
+ }
24
28
  exports.Cache = Cache;
package/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
- require("./polyfill.js");
2
- const { json } = require("./middleware/json.js");
3
- const { cors } = require("./middleware/cors.js");
4
- const { App } = require("./app.js");
5
- const { Router } = require("./router.js");
1
+ require("./polyfill");
2
+ const { App } = require("./app");
3
+ const { Router } = require("./router");
4
+ const { json } = require("./middleware/json");
5
+ const { cors } = require("./middleware/cors");
6
6
 
7
7
  App.Router = Router;
8
8
  App.json = json;
@@ -1,96 +1,83 @@
1
- function pattern(string) {
2
- return new RegExp(
3
- `^(${String(string)
4
- .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
5
- .replace(/ /g, "")
6
- .replace(/,/g, "|")
7
- .replace(/\\\*/g, ".*")})$`,
8
- "i",
9
- );
10
- }
11
-
12
1
  /**
13
2
  * @typedef CORSOptions
14
- * @property {String} [origin="*"]
3
+ * @property {String|Array|Null} [origin="*"]
15
4
  * @property {Boolean} [credentials=false]
16
- * @property {String|Array} headers
5
+ * @property {String|Array} [headers]
17
6
  * @property {Number} [maxAge=0]
18
- * @property {String|Array} methods
7
+ * @property {String|Array} [methods]
19
8
  */
20
9
 
21
10
  /**
22
11
  * @param {CORSOptions} options
23
12
  */
24
13
  function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
25
- const HEADERS_PATTERN = headers && pattern(headers);
26
- const METHODS_PATTERN = methods && pattern(methods);
27
- const ORIGIN_PATTERN = pattern(origin);
14
+ if (Array.isArray(headers)) {
15
+ headers = headers.map((h) => h.toLowerCase());
16
+ }
17
+ if (Array.isArray(methods)) {
18
+ methods = methods.map((m) => m.toUpperCase());
19
+ }
28
20
 
29
- /**
30
- * @param {import('http').ServerResponse} res
31
- */
32
21
  function setAllowCredentials(res) {
33
22
  if (credentials) {
34
23
  res.setHeader("Access-Control-Allow-Credentials", "true");
35
24
  }
36
25
  }
37
26
 
38
- /**
39
- * @param {import('http').IncomingMessage} req
40
- * @param {import('http').ServerResponse} res
41
- */
27
+ function setAllowOrigin(req, res) {
28
+ const reqOrigin = req.headers["origin"];
29
+ let value;
30
+ let explicit = false;
31
+
32
+ if (origin === "*" && !credentials) {
33
+ value = "*";
34
+ } else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.includes(reqOrigin))) {
35
+ value = reqOrigin;
36
+ explicit = true;
37
+ }
38
+ // origin === null -> skip (jangan set "null", celah keamanan)
39
+
40
+ if (value) res.setHeader("Access-Control-Allow-Origin", value);
41
+ if (explicit) res.appendHeader("Vary", "Origin");
42
+ }
43
+
42
44
  function setAllowHeaders(req, res) {
43
45
  const reqHeaders = req.headers["access-control-request-headers"];
44
46
  if (!reqHeaders) return;
47
+
48
+ const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
49
+
45
50
  let value;
46
51
  if (headers === "*" && !credentials) {
47
52
  value = "*";
48
- } else if (Array.isArray(headers) && HEADERS_PATTERN && HEADERS_PATTERN.test(reqHeaders)) {
49
- value = headers;
53
+ } else if (Array.isArray(headers)) {
54
+ const allowed = requested.filter((h) => headers.includes(h));
55
+ if (allowed.length === requested.length) value = headers;
56
+ } else {
57
+ // headers undefined -> echo request headers (wajib per spec)
58
+ value = requested;
50
59
  }
51
60
  if (value) res.setHeader("Access-Control-Allow-Headers", value);
52
61
  }
53
62
 
54
- /**
55
- * @param {import('http').IncomingMessage} req
56
- * @param {import('http').ServerResponse} res
57
- */
58
63
  function setAllowMethods(req, res) {
59
64
  const reqMethod = req.headers["access-control-request-method"];
60
65
  if (!reqMethod) return;
61
- let value;
62
- if (methods === "*" && !credentials) {
63
- value = "*";
64
- } else if (Array.isArray(methods) && METHODS_PATTERN && METHODS_PATTERN.test(reqMethod)) {
65
- value = methods;
66
- }
67
- if (value) res.setHeader("Access-Control-Allow-Methods", value);
68
- }
69
66
 
70
- /**
71
- * @param {import('http').IncomingMessage} req
72
- * @param {import('http').ServerResponse} res
73
- */
74
- function setAllowOrigin(req, res, preflight = true) {
75
- const reqOrigin = req.headers["origin"];
67
+ const method = reqMethod.toUpperCase();
76
68
 
77
69
  let value;
78
- let explicit;
79
- if (origin === "*" && !credentials) {
70
+ if (methods === "*" && !credentials) {
80
71
  value = "*";
81
- } else if (origin === null) {
82
- value = "null";
83
- } else if (ORIGIN_PATTERN.test(reqOrigin)) {
84
- value = reqOrigin;
85
- explicit = true;
72
+ } else if (Array.isArray(methods)) {
73
+ if (methods.includes(method)) value = methods;
74
+ } else {
75
+ // methods undefined -> echo request method
76
+ value = [method];
86
77
  }
87
- if (value) res.setHeader("Access-Control-Allow-Origin", value);
88
- if (!preflight && explicit) res.appendHeader("Vary", "Origin");
78
+ if (value) res.setHeader("Access-Control-Allow-Methods", value);
89
79
  }
90
80
 
91
- /**
92
- * @param {import('http').ServerResponse} res
93
- */
94
81
  function setExposeHeaders(res) {
95
82
  let value;
96
83
  if (headers === "*" && !credentials) {
@@ -101,22 +88,17 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
101
88
  if (value) res.setHeader("Access-Control-Expose-Headers", value);
102
89
  }
103
90
 
104
- /**
105
- * @param {import('http').ServerResponse} res
106
- */
107
91
  function setMaxAge(res) {
108
92
  if (maxAge > 0) {
109
93
  res.setHeader("Access-Control-Max-Age", maxAge);
110
94
  }
111
95
  }
112
96
 
113
- /**
114
- * @param {import('http').IncomingMessage} req
115
- * @param {import('http').ServerResponse} res
116
- */
117
97
  return (req, res) => {
118
98
  setAllowCredentials(res);
119
- if (req.method === "OPTIONS") {
99
+
100
+ if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
101
+ // preflight
120
102
  setAllowOrigin(req, res);
121
103
  setAllowHeaders(req, res);
122
104
  setAllowMethods(req, res);
@@ -125,9 +107,11 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
125
107
  res.setHeader("Content-Length", "0");
126
108
  res.end();
127
109
  } else {
128
- setAllowOrigin(req, res, false);
110
+ // normal response (termasuk OPTIONS non-preflight)
111
+ setAllowOrigin(req, res);
129
112
  setExposeHeaders(res);
130
113
  }
131
114
  };
132
115
  }
116
+
133
117
  exports.cors = cors;
@@ -1,5 +1,4 @@
1
- const http = require("node:http");
2
- const { ErrorResponse } = require("../error.js");
1
+ const http = require("http");
3
2
 
4
3
  function json({ limit = 100 * 1024 } = {}) {
5
4
  const reviver = (key, value) => {
@@ -18,14 +17,16 @@ function json({ limit = 100 * 1024 } = {}) {
18
17
  }
19
18
  const contentLength = parseInt(req.headers["content-length"], 10);
20
19
  if (contentLength && contentLength > limit) {
21
- throw ErrorResponse.status(413);
20
+ res.statusCode = 413;
21
+ throw new Error(http.STATUS_CODES[res.statusCode]);
22
22
  }
23
23
  let chunkLength = 0;
24
24
  const chunks = [];
25
25
  for await (const chunk of req) {
26
26
  chunkLength += chunk.length;
27
27
  if (chunkLength > limit) {
28
- throw ErrorResponse.status(413);
28
+ res.statusCode = 413;
29
+ throw new Error(http.STATUS_CODES[res.statusCode]);
29
30
  }
30
31
  chunks.push(chunk);
31
32
  }
@@ -36,9 +37,9 @@ function json({ limit = 100 * 1024 } = {}) {
36
37
  try {
37
38
  req.body = JSON.parse(Buffer.concat(chunks), reviver);
38
39
  } catch (error) {
39
- throw new ErrorResponse({ statusCode: 400, message: "Invalid JSON body" }, { cause: error });
40
+ res.statusCode = 400;
41
+ throw new Error(http.STATUS_CODES[res.statusCode], { cause: error });
40
42
  }
41
43
  };
42
44
  }
43
-
44
45
  exports.json = json;
package/src/node.js CHANGED
@@ -1,48 +1,77 @@
1
- const { Pattern } = require("./pattern.js");
2
-
3
1
  class Node {
4
2
  constructor(segment = "") {
5
- this.hasRegExpChildren = false;
6
- this.pattern = /[:*]/.test(segment) && new Pattern(segment);
7
- /**@type {Map<string,Node>}*/
3
+ this.segment = segment;
4
+ /** @type {Map<string, Node>} static children */
8
5
  this.children = new Map();
6
+ /** @type {Map<string, Node>} param children, key = ':name' */
7
+ this.params = new Map();
8
+ /** @type {Node | null} wildcard child */
9
+ this.wildcard = null;
9
10
  this.handlers = new Map();
10
11
  }
11
12
 
12
13
  add(segment) {
13
- return this.children.getOrInsertComputed(segment, () => {
14
- const node = new Node(segment);
15
- if (node.pattern) this.hasRegExpChildren = true;
16
- return node;
14
+ if (segment === "*") {
15
+ if (!this.wildcard) this.wildcard = new Node("*");
16
+ return this.wildcard;
17
+ }
18
+
19
+ if (segment.startsWith(":")) {
20
+ return this.params.getOrInsertComputed(segment, (segment) => {
21
+ return new Node(segment);
22
+ });
23
+ }
24
+
25
+ return this.children.getOrInsertComputed(segment, (segment) => {
26
+ return new Node(segment);
17
27
  });
18
28
  }
19
29
 
20
30
  merge(node, offset) {
21
31
  for (const [method, entries] of node.handlers) {
22
- const target = this.handlers.getOrInsert(method, []);
23
32
  for (const entry of entries) {
24
- target.push({ ...entry, seq: entry.seq + offset });
33
+ this.handlers.getOrInsert(method, []).push({
34
+ ...entry,
35
+ seq: entry.seq + offset,
36
+ });
25
37
  }
26
38
  }
39
+
40
+ if (node.wildcard) {
41
+ this.add("*").merge(node.wildcard, offset);
42
+ }
43
+
44
+ for (const [segment, child] of node.params) {
45
+ this.add(segment).merge(child, offset);
46
+ }
47
+
27
48
  for (const [segment, child] of node.children) {
28
- const target = this.children.getOrInsertComputed(segment, () => new Node(segment));
29
- if (target.pattern) this.hasRegExpChildren = true;
30
- target.merge(child, offset);
49
+ this.add(segment).merge(child, offset);
31
50
  }
32
51
  }
33
52
 
34
53
  getMaxSeq() {
35
54
  let max = -1;
36
- for (const [, handlers] of this.handlers) {
37
- for (const entry of handlers) {
55
+
56
+ for (const [, entries] of this.handlers) {
57
+ for (const entry of entries) {
38
58
  max = Math.max(max, entry.seq);
39
59
  }
40
60
  }
61
+
62
+ if (this.wildcard) {
63
+ max = Math.max(max, this.wildcard.getMaxSeq());
64
+ }
65
+
66
+ for (const [, child] of this.params) {
67
+ max = Math.max(max, child.getMaxSeq());
68
+ }
69
+
41
70
  for (const [, child] of this.children) {
42
71
  max = Math.max(max, child.getMaxSeq());
43
72
  }
73
+
44
74
  return max;
45
75
  }
46
76
  }
47
-
48
77
  exports.Node = Node;
package/src/request.js CHANGED
@@ -1,32 +1,37 @@
1
- const http = require("node:http");
2
- const { parseUrl, parseQueryString } = require("./util.js");
1
+ const http = require("http");
2
+ const { parseUrl, parseQueryString } = require("./util");
3
3
 
4
4
  class Request extends http.IncomingMessage {
5
5
  _path;
6
- _parsed;
6
+ _queryString;
7
7
  _query;
8
- params = {};
9
- signal;
8
+ _controller;
9
+
10
10
  get path() {
11
- if (this._path) return this._path;
12
- if (!/[?#]/.test(this.url)) {
13
- this._path = this.url;
14
- return this._path;
11
+ if (!this._path) {
12
+ const { path, queryString } = parseUrl(this.url);
13
+ this._path = path;
14
+ this._queryString = queryString;
15
15
  }
16
- this._parsed = parseUrl(this.url);
17
- this._path = this._parsed.path;
16
+
18
17
  return this._path;
19
18
  }
20
19
 
21
20
  get query() {
22
- if (this._query) return this._query;
23
- if (!this._parsed) {
24
- this._query = Object.create(null);
25
- return this._query;
21
+ if (!this._query) {
22
+ this._query = parseQueryString(this._queryString);
26
23
  }
27
- this._query = parseQueryString(this._parsed.queryString);
28
24
  return this._query;
29
25
  }
30
- }
31
26
 
27
+ /**
28
+ * @returns {AbortController}
29
+ */
30
+ get controller() {
31
+ if (!this._controller) {
32
+ this._controller = new AbortController();
33
+ }
34
+ return this._controller;
35
+ }
36
+ }
32
37
  exports.Request = Request;
package/src/response.js CHANGED
@@ -1,22 +1,19 @@
1
- const http = require("node:http");
1
+ const http = require("http");
2
2
 
3
3
  class Response extends http.ServerResponse {
4
- status(statusCode) {
5
- this.statusCode = statusCode;
4
+ status(code) {
5
+ this.statusCode = code;
6
6
  return this;
7
7
  }
8
8
  send(chunk) {
9
9
  if (this.writableEnded) return;
10
- this.end(chunk,null,null);//avoid ArgumentsAdaptorTrampoline from V8
10
+ this.end(chunk, null, null);
11
11
  }
12
12
  json(value) {
13
- if (this.writableEnded) return;
14
- if (this.headersSent) {
15
- this.end();
16
- return;
13
+ if (!this.headersSent) {
14
+ this.setHeader("Content-Type", "application/json; charset=utf-8");
17
15
  }
18
- this.setHeader("Content-Type", "application/json");
19
- this.end(JSON.stringify(value),null,null);//avoid ArgumentsAdaptorTrampoline from V8
16
+ this.send(JSON.stringify(value));
20
17
  }
21
18
  }
22
19
  exports.Response = Response;
package/src/router.js CHANGED
@@ -1,6 +1,6 @@
1
- const { Node } = require("./node.js");
2
- const { Cache } = require("./cache.js");
3
- const { decodePathParam } = require("./util.js");
1
+ const { decodeParam } = require("./util");
2
+ const { Node } = require("./node");
3
+ const { Cache } = require("./cache");
4
4
 
5
5
  class Router {
6
6
  constructor(options = {}) {
@@ -16,12 +16,16 @@ class Router {
16
16
  }
17
17
 
18
18
  add(method, path, ...handlers) {
19
+ this.cache.clear();
20
+
19
21
  if (typeof path === "function") {
20
22
  handlers = [path, ...handlers];
21
23
  path = null;
22
24
  }
25
+
23
26
  let node = this.root;
24
- if (path !== null) {
27
+
28
+ if (path) {
25
29
  for (const segment of this.split(path)) {
26
30
  node = node.add(segment);
27
31
  }
@@ -32,10 +36,10 @@ class Router {
32
36
  node.merge(handler.root, this.seq);
33
37
  this.seq += handler.root.getMaxSeq() + 1;
34
38
  } else {
35
- node.handlers.getOrInsert(method, []).push({
39
+ const key = handler.length === 3 ? "CATCH" : method;
40
+ node.handlers.getOrInsert(key, []).push({
36
41
  handler,
37
42
  seq: this.seq++,
38
- isErrorHandler: handler.length === 3,
39
43
  });
40
44
  }
41
45
  }
@@ -96,70 +100,126 @@ class Router {
96
100
  return this;
97
101
  }
98
102
 
99
- matchRegExp(node, input, params) {
100
- let matched;
101
- for (const [, child] of node.children) {
102
- if (!child.pattern) continue;
103
- const match = child.pattern.exec(input);
104
- if (match) {
105
- matched = child;
106
- for (const name in match.groups) {
107
- params[name] = decodePathParam(match.groups[name]);
108
- }
109
- break;
103
+ /**
104
+ * @param {Node} node
105
+ * @param {string[]} segments
106
+ * @param {number} index
107
+ * @param {Node[]} path
108
+ * @param {Record<string, string>} params
109
+ * @param {{ best: { nodes: Node[], params: Record<string, string> } }} state
110
+ * @returns {{ nodes: Node[], params: Record<string, string>, matched: boolean }}
111
+ */
112
+ search(node, segments, index, path, params, state) {
113
+ path.push(node);
114
+
115
+ // habis segmen -> full match
116
+ if (index === segments.length) {
117
+ return { nodes: path.slice(), params: { ...params }, matched: true };
118
+ }
119
+
120
+ const segment = segments[index];
121
+
122
+ // 1. static (prioritas tertinggi)
123
+ const staticChild = node.children.get(segment);
124
+ if (staticChild) {
125
+ const res = this.search(staticChild, segments, index + 1, path, params, state);
126
+ if (res.matched) return res;
127
+ }
128
+
129
+ // 2. semua param child (bisa lebih dari satu)
130
+ for (const [key, child] of node.params) {
131
+ const name = key.slice(1); // buang ':'
132
+ const prev = params[name];
133
+ params[name] = decodeParam(segment);
134
+ const res = this.search(child, segments, index + 1, path, params, state);
135
+ if (res.matched) return res;
136
+ // backtrack param value
137
+ if (prev === undefined) delete params[name];
138
+ else params[name] = prev;
139
+ }
140
+
141
+ // 3. wildcard (prioritas terendah, menyerap sisa segmen)
142
+ if (node.wildcard) {
143
+ const rest = segments.slice(index).join("/");
144
+ params["wildcard"] = decodeParam(rest);
145
+ path.push(node.wildcard);
146
+ return { nodes: path.slice(), params: { ...params }, matched: true };
147
+ }
148
+
149
+ // tidak match di sini -> update partial terpanjang
150
+ // path.length > state.best.nodes.length artinya kita sudah lebih dalam
151
+ if (path.length > state.best.nodes.length) {
152
+ state.best = { nodes: path.slice(), params: { ...params } };
153
+ }
154
+
155
+ path.pop();
156
+ return { matched: false };
157
+ }
158
+
159
+ collectHandler(r, method, handlers) {
160
+ if (r.handlers.has(method)) {
161
+ for (const entry of r.handlers.get(method)) {
162
+ handlers[entry.seq] = entry.handler;
110
163
  }
111
164
  }
112
- return matched;
113
165
  }
114
166
 
115
- /**
116
- * @param {'GET'|'QUERY'|'HEAD'|'OPTIONS'|'TRACE'|'PUT'|'DELETE'|'POST'|'PATCH'|'CONNECT'} method
117
- * @param {String} path
118
- * @returns {{params: {};handlers: any[];errorHandlers: any[];}}
119
- */
167
+ collectErrorHandler(r, errorHandlers) {
168
+ if (r.handlers.has("CATCH")) {
169
+ for (const entry of r.handlers.get("CATCH")) {
170
+ errorHandlers[entry.seq] = entry.handler;
171
+ }
172
+ }
173
+ }
174
+
175
+ collectMiddleware(r, handlers, errorHandlers) {
176
+ this.collectHandler(r, "ALL", handlers);
177
+ this.collectErrorHandler(r, errorHandlers);
178
+ }
179
+
120
180
  find(method, path) {
121
181
  const key = `${method} ${path}`;
182
+
122
183
  const cached = this.cache.get(key);
123
184
  if (cached) return cached;
124
- let node = this.root;
125
- let params = Object.create(null);
185
+
186
+ const segments = this.split(path);
187
+
188
+ const params = {};
126
189
  const handlers = {};
127
190
  const errorHandlers = {};
128
- for (const input of this.split(path)) {
129
- let matched = node.children.get(input);
130
- if (!matched && node.hasRegExpChildren) {
131
- matched = this.matchRegExp(node, input, params);
132
- }
133
- if (!matched) {
134
- node = null;
135
- params = Object.create(null);
136
- break;
137
- }
138
- node = matched;
139
- if (node.handlers.has("ALL")) {
140
- for (const entry of node.handlers.get("ALL")) {
141
- if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
142
- else handlers[entry.seq] = entry.handler;
143
- }
144
- }
145
- }
146
- if (this.root.handlers.has("ALL")) {
147
- for (const entry of this.root.handlers.get("ALL")) {
148
- if (entry.isErrorHandler) errorHandlers[entry.seq] = entry.handler;
149
- else handlers[entry.seq] = entry.handler;
150
- }
191
+
192
+ /** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
193
+ const state = { best: { nodes: [], params: {} } };
194
+
195
+ const res = this.search(this.root, segments, 0, [], params, state);
196
+
197
+ const matchedNodes = res.matched ? res.nodes : state.best.nodes;
198
+ const finalParams = res.matched ? res.params : state.best.params;
199
+
200
+ // middleware dari semua node di jalur (partial atau full)
201
+ for (const r of matchedNodes) {
202
+ this.collectMiddleware(r, handlers, errorHandlers);
151
203
  }
152
- if (node && node.handlers.has(method)) {
153
- for (const entry of node.handlers.get(method)) {
154
- handlers[entry.seq] = entry.handler;
155
- }
204
+
205
+ // method handler HANYA kalau full match
206
+ if (res.matched) {
207
+ const leaf = matchedNodes[matchedNodes.length - 1];
208
+ this.collectHandler(leaf, method, handlers);
156
209
  }
210
+
211
+ Object.freeze(finalParams);
212
+
157
213
  const result = {
158
- params,
214
+ matched: res.matched,
215
+ params: finalParams,
216
+ paramsLength: Object.keys(finalParams).length,
159
217
  handlers: Object.values(handlers),
160
218
  errorHandlers: Object.values(errorHandlers),
161
219
  };
220
+
162
221
  this.cache.set(key, result);
222
+
163
223
  return result;
164
224
  }
165
225
  }
package/src/util.js CHANGED
@@ -1,31 +1,33 @@
1
- const URL_REGEX = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
2
- const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
1
+ const EMPTY_PARAMS = Object.freeze(Object.create(null));
2
+ exports.EMPTY_PARAMS = EMPTY_PARAMS;
3
3
 
4
- function decodeQueryParam(encoded) {
4
+ function decodeParam(raw) {
5
5
  try {
6
- return decodeURIComponent(encoded.replace(/\+/g, " "));
6
+ return decodeURIComponent(raw);
7
7
  } catch (error) {
8
- return encoded;
8
+ return raw;
9
9
  }
10
10
  }
11
- exports.decodeQueryParam = decodeQueryParam;
11
+ exports.decodeParam = decodeParam;
12
12
 
13
- function decodePathParam(encoded) {
13
+ function decodeQuery(raw) {
14
+ const normalized = raw.replace(/\+/g, " ");
14
15
  try {
15
- return decodeURIComponent(encoded);
16
- } catch {
17
- return encoded;
16
+ return decodeURIComponent(normalized);
17
+ } catch (error) {
18
+ return normalized;
18
19
  }
19
20
  }
20
- exports.decodePathParam=decodePathParam
21
21
 
22
22
  function parseQueryString(queryString) {
23
- const result = Object.create(null);
23
+ if (queryString === "") return Object.create(null);
24
+ const regex = /([^=&]*)=([^&]*)/g;
24
25
  let match;
25
- while ((match = QUERY_STRING_REGEX.exec(queryString)) !== null) {
26
- const [, rawName = "", rawValue = ""] = match;
27
- const name = decodeQueryParam(rawName);
28
- const value = decodeQueryParam(rawValue);
26
+ const result = Object.create(null);
27
+ while ((match = regex.exec(queryString))) {
28
+ const [, rawName, rawValue] = match;
29
+ const name = decodeQuery(rawName);
30
+ const value = decodeQuery(rawValue);
29
31
  if (Object.hasOwn(result, name)) {
30
32
  if (Array.isArray(result[name])) result[name].push(value);
31
33
  else result[name] = [result[name], value];
@@ -36,7 +38,11 @@ function parseQueryString(queryString) {
36
38
  exports.parseQueryString = parseQueryString;
37
39
 
38
40
  function parseUrl(url) {
39
- const [, rawPath = "", , queryString = ""] = URL_REGEX.exec(url);
40
- return { path: rawPath || "/", queryString };
41
+ if (!(url.includes("?") || url.includes("#"))) {
42
+ return { path: url, queryString: "" };
43
+ }
44
+ const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
45
+ const [, path = "/", , queryString = ""] = regex.exec(url);
46
+ return { path, queryString };
41
47
  }
42
48
  exports.parseUrl = parseUrl;
package/src/error.js DELETED
@@ -1,26 +0,0 @@
1
- const http = require("node:http");
2
-
3
- // Client error responses (400 – 499)
4
- // Server error responses (500 – 599)
5
- class ErrorResponse extends Error {
6
- constructor({ statusCode, message } = {}, options) {
7
- super(message, options);
8
- this.name = this.constructor.name;
9
- if (Error.captureStackTrace) {
10
- Error.captureStackTrace(this, this.constructor);
11
- }
12
- this.statusCode = statusCode;
13
- }
14
-
15
- static status(statusCode, options) {
16
- return new ErrorResponse(
17
- {
18
- statusCode,
19
- message: http.STATUS_CODES[statusCode],
20
- },
21
- options,
22
- );
23
- }
24
- }
25
-
26
- exports.ErrorResponse = ErrorResponse;
package/src/pattern.js DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * @typedef PatternExecResult
3
- * @property {number} index
4
- * @property {string} input
5
- * @property {Record<string, string>} [groups]
6
- * @property {number} length
7
- */
8
- class Pattern {
9
- constructor(input) {
10
- const pattern = `^${input
11
- .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
12
- .replace(/:(\w+)/g, "(?<$1>[^/]+)")
13
- .replace(/\\\*/g, ".*")}$`;
14
- Object.defineProperty(this, "regex", {
15
- value: new RegExp(pattern),
16
- configurable: true,
17
- enumerable: false,
18
- });
19
- }
20
-
21
- /**
22
- * @param {string} input
23
- * @returns {PatternExecResult | null}
24
- */
25
- exec(input) {
26
- return this.regex.exec(input);
27
- }
28
-
29
- test(input) {
30
- return this.regex.test(input);
31
- }
32
- }
33
-
34
- exports.Pattern = Pattern;