@ndiinginc/router 4.8.0 → 4.10.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
@@ -11,7 +11,7 @@ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsu
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()`
@@ -93,15 +93,21 @@ app.get("/posts/:postId/comments/:commentId", (req, res) => {
93
93
 
94
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
95
 
96
- > Di setiap level hanya ada satu parameter. Jika `/users/:id` dan `/users/:userId/posts` didaftarkan bersamaan, keduanya memakai nama yang pertama didaftarkan (`id`). Pakai nama yang sama untuk level yang sama.
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
+ ```
97
103
 
98
104
  ### Wildcard
99
105
 
100
- Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params[0]`.
106
+ Segmen `*` menangkap **seluruh sisa path** (termasuk `/`) dan nilainya tersedia di `req.params.wildcard`.
101
107
 
102
108
  ```js
103
109
  app.get("/files/*", (req, res) => {
104
- res.json({ file: req.params[0] });
110
+ res.json({ file: req.params.wildcard });
105
111
  });
106
112
 
107
113
  // GET /files/images/logo.png -> { file: "images/logo.png" }
@@ -111,8 +117,8 @@ Wildcard butuh minimal satu segmen tersisa, jadi `/files/*` tidak cocok dengan `
111
117
 
112
118
  ### Aturan matching
113
119
 
114
- - Di setiap level, router mencoba **static dulu, lalu parameter, lalu wildcard**. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
115
- - Jika cabang static gagal di tengah jalan, router mundur (backtrack) dan mencoba parameter, lalu wildcard.
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.
116
122
  - Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
117
123
 
118
124
  ## Middleware
@@ -135,7 +141,7 @@ app.use("/admin", async (req, res) => {
135
141
 
136
142
  Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
137
143
 
138
- > Jika path tidak cocok dengan route mana pun, hanya middleware global (tanpa path) yang dijalankan 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.
139
145
 
140
146
  ## Nested Router
141
147
 
@@ -164,32 +170,32 @@ app.use("/admin", admin);
164
170
 
165
171
  Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
166
172
 
167
- ```js
168
- app.get("/boom", () => {
169
- throw new Error("Something broke");
170
- });
173
+ > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
171
174
 
172
- app.use((err, req, res) => {
173
- 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");
174
183
  });
175
184
  ```
176
185
 
177
- > 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.
178
187
 
179
- ### Menentukan status code
188
+ ### Error handler sendiri
180
189
 
181
- 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:
182
191
 
183
192
  ```js
184
- app.get("/admin", () => {
185
- const err = new Error("Forbidden");
186
- err.statusCode = 403;
187
- 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 } });
188
196
  });
189
197
  ```
190
198
 
191
- > `res.statusCode` atau `res.status()` yang diset **sebelum** error dilempar tidak dipakai oleh respons default. Gunakan `err.statusCode`, atau tangani sendiri di error handler.
192
-
193
199
  ### Respons default
194
200
 
195
201
  Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
@@ -199,44 +205,44 @@ Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
199
205
  ```
200
206
 
201
207
  - Tidak ada route cocok: `404 Not Found`
202
- - Error dengan `statusCode`: status tersebut dipakai
203
- - Error tanpa `statusCode`: `500`
208
+ - `res.statusCode` sudah `>= 400`: dipakai apa adanya
209
+ - `res.statusCode` masih `< 400`: diganti `500`
204
210
  - Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
205
211
  - Field `stack` hanya disertakan saat `NODE_ENV=development`
206
212
 
207
213
  ## Timeout
208
214
 
209
- 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.
210
216
 
211
217
  ```js
212
218
  const app = new App({ requestTimeout: 5000 });
213
219
  ```
214
220
 
215
221
  - Tanpa opsi `requestTimeout` (atau bernilai `0`), tidak ada batas waktu
216
- - 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`
217
223
 
218
- ### `req.signal`
224
+ ### `req.controller`
219
225
 
220
- 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:
221
227
 
222
228
  ```js
223
229
  app.get("/proxy", async (req, res) => {
224
- 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
+ });
225
233
  res.json(await upstream.json());
226
234
  });
227
235
  ```
228
236
 
229
- `req.signal` bernilai `undefined` jika `requestTimeout` tidak diaktifkan.
230
-
231
237
  ## Request
232
238
 
233
239
  | Properti | Keterangan |
234
240
  | --- | --- |
235
241
  | `req.path` | Pathname tanpa query string |
236
242
  | `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array, dan `+` di-decode menjadi spasi |
237
- | `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params[0]` |
243
+ | `req.params` | Parameter dari route dinamis (sudah di-decode). Wildcard `*` ada di `req.params.wildcard` |
238
244
  | `req.body` | Hasil parse body (butuh middleware `json()`) |
239
- | `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) |
240
246
 
241
247
  ```js
242
248
  // GET /search?tag=a&tag=b&page=2
@@ -245,7 +251,7 @@ app.get("/search", (req, res) => {
245
251
  });
246
252
  ```
247
253
 
248
- `req.query` dan `req.params` yang sampai ke handler memakai object tanpa prototype, jadi gunakan `Object.hasOwn(req.query, "key")` atau `"key" in req.query`, bukan `req.query.hasOwnProperty(...)`.
254
+ `req.query` dan `req.params` memakai object tanpa prototype, jadi gunakan `Object.hasOwn(req.query, "key")` atau `"key" in req.query`, bukan `req.query.hasOwnProperty(...)`.
249
255
 
250
256
  `Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
251
257
 
@@ -255,13 +261,13 @@ app.get("/search", (req, res) => {
255
261
  | --- | --- |
256
262
  | `res.status(code)` | Set status code, mengembalikan `res` sehingga bisa di-chain |
257
263
  | `res.send(chunk)` | Kirim response dan akhiri |
258
- | `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 |
259
265
 
260
266
  ```js
261
267
  res.status(201).json({ created: true });
262
268
  ```
263
269
 
264
- `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.
265
271
 
266
272
  ## Middleware Bawaan
267
273
 
@@ -278,8 +284,8 @@ app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
278
284
  Perilaku:
279
285
 
280
286
  - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
281
- - Body melebihi `limit` menghasilkan `413 Payload Too Large` (dicek lewat `Content-Length` dan saat streaming)
282
- - 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`
283
289
  - Body kosong menghasilkan `req.body = null`
284
290
  - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
285
291
 
@@ -299,7 +305,7 @@ app.use(
299
305
 
300
306
  | Opsi | Default | Keterangan |
301
307
  | --- | --- | --- |
302
- | `origin` | `"*"` | `"*"`, satu origin, array origin, atau `null` |
308
+ | `origin` | `"*"` | `"*"`, satu origin, atau array origin. `null` tidak mengirim header `Access-Control-Allow-Origin` sama sekali |
303
309
  | `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
304
310
  | `headers` | - | `"*"` atau array nama header (tidak membedakan huruf besar/kecil) |
305
311
  | `methods` | - | `"*"` atau array nama method (tidak membedakan huruf besar/kecil) |
@@ -307,11 +313,13 @@ app.use(
307
313
 
308
314
  Perilaku:
309
315
 
310
- - Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
311
- - Origin dicocokkan **persis sama** (case-sensitive) dengan header `Origin` dari request. Jika cocok, origin tersebut dipantulkan, dan header `Vary: Origin` ditambahkan pada request non-preflight
312
- - `Access-Control-Allow-Headers` dan `Access-Control-Allow-Methods` hanya dikirim pada preflight, dan hanya jika **semua** header atau method yang diminta ada di daftar
313
- - Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
314
- - 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`
315
323
 
316
324
  > `origin: "*"` bersama `credentials: true` memantulkan **semua** origin dengan credentials. Pakai hanya untuk development; di production sebutkan origin secara eksplisit.
317
325
 
@@ -323,7 +331,7 @@ const app = new App({ cacheSize: 1000, requestTimeout: 5000 });
323
331
 
324
332
  | Opsi | Default | Keterangan |
325
333
  | --- | --- | --- |
326
- | `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. Cache dikosongkan setiap ada route baru didaftarkan |
334
+ | `cacheSize` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path`. `0` menonaktifkan cache. Cache dikosongkan setiap ada route baru didaftarkan |
327
335
  | `requestTimeout` | `0` | Batas waktu handler dalam ms (khusus `App`). Lihat bagian [Timeout](#timeout) |
328
336
 
329
337
  `Router` hanya menerima `cacheSize`.
@@ -344,10 +352,10 @@ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, h
344
352
 
345
353
  ### `router.find(method, path)`
346
354
 
347
- 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.
348
356
 
349
357
  ```js
350
- const { params, handlers, errorHandlers } = app.find("GET", "/users/1");
358
+ const { matched, params, paramsLength, handlers, errorHandlers } = app.find("GET", "/users/1");
351
359
  ```
352
360
 
353
361
  ### `app.listen(...args)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ndiinginc/router",
3
- "version": "4.8.0",
3
+ "version": "4.10.0",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
@@ -21,6 +21,7 @@
21
21
  "@ndiinginc/commit": "^1.0.1",
22
22
  "@ndiinginc/test": "^1.17.0",
23
23
  "@types/jest": "^30.0.0",
24
- "@types/node": "^26.6.5"
24
+ "@types/node": "^26.6.5",
25
+ "autocannon": "^8.0.0"
25
26
  }
26
27
  }
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,62 @@ 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
+
49
+ if (res.statusCode < 400) {
50
+ res.statusCode = 500;
51
+ }
52
+
53
+ const error = { message: err.message };
54
+ if (process.env.NODE_ENV === "development") {
55
+ error.stack = err.stack;
56
+ }
57
+
58
+ res.json({ error });
59
+ }
40
60
  }
41
61
 
42
62
  /**
43
- * @param {import('http').IncomingMessage} req
44
- * @param {import('http').ServerResponse} res
63
+ * @param {import('http').IncomingMessage & import('./request').Request} req
64
+ * @param {import('http').ServerResponse & import('./response').Response} res
45
65
  */
46
66
  async handle(req, res) {
47
- const { params, handlers, errorHandlers } = this.find(req.method, req.path);
48
- req.params = Object.assign(Object.create(null), params);
67
+ const { params, paramsLength, handlers, errorHandlers } = this.find(req.method, req.path);
68
+ req.params = paramsLength ? Object.assign(Object.create(null), params) : EMPTY_PARAMS;
69
+
49
70
  let err;
50
71
  try {
51
72
  const handlerPromise = async () => {
@@ -54,10 +75,15 @@ class App extends Router {
54
75
  await handler(req, res);
55
76
  }
56
77
  };
57
- if (this.requestTimeout) await this.withTimeout(req, handlerPromise);
58
- else await handlerPromise();
78
+
79
+ if (this.requestTimeout) {
80
+ await this.withTimeout(req, res, handlerPromise);
81
+ } else {
82
+ await handlerPromise();
83
+ }
59
84
  } catch (error) {
60
85
  err = error;
86
+
61
87
  try {
62
88
  for (const handler of errorHandlers) {
63
89
  if (res.writableEnded) break;
@@ -67,30 +93,18 @@ class App extends Router {
67
93
  err = new Error(error.message, { cause: err });
68
94
  }
69
95
  }
70
- err = this.fallback(res, err);
71
- }
72
96
 
73
- fallback(res, err) {
74
- if (!res.writableEnded) {
75
- if (!err) err = ErrorResponse.status(404);
76
- res.statusCode = err.statusCode || 500;
77
- const error = { message: err.message };
78
- if (process.env.NODE_ENV === "development") {
79
- error.stack = err.stack;
80
- }
81
- res.json({ error });
82
- }
83
- return err;
97
+ this.fallback(res, err);
84
98
  }
85
99
 
86
- listen(...params) {
100
+ listen(...args) {
87
101
  const requestListener = (req, res) => this.handle(req, res);
88
102
  const server = http.createServer(SERVER_OPTIONS, requestListener);
89
- server.listen(...params);
103
+ server.listen(...args);
90
104
  return server;
91
105
  }
92
106
  }
93
107
 
94
- App.SERVER_OPTIONS = SERVER_OPTIONS;
108
+ App.SERVER_OPTIONS=SERVER_OPTIONS
95
109
 
96
110
  exports.App = App;
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,89 +1,83 @@
1
1
  /**
2
2
  * @typedef CORSOptions
3
- * @property {String} [origin="*"]
3
+ * @property {String|Array|Null} [origin="*"]
4
4
  * @property {Boolean} [credentials=false]
5
- * @property {String|Array} headers
5
+ * @property {String|Array} [headers]
6
6
  * @property {Number} [maxAge=0]
7
- * @property {String|Array} methods
7
+ * @property {String|Array} [methods]
8
8
  */
9
9
 
10
10
  /**
11
11
  * @param {CORSOptions} options
12
12
  */
13
13
  function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods } = {}) {
14
- if (headers && Array.isArray(headers)) {
15
- headers = headers.map((header) => header.toLowerCase());
14
+ if (Array.isArray(headers)) {
15
+ headers = headers.map((h) => h.toLowerCase());
16
16
  }
17
- if (methods && Array.isArray(methods)) {
18
- methods = methods.map((method) => method.toUpperCase());
17
+ if (Array.isArray(methods)) {
18
+ methods = methods.map((m) => m.toUpperCase());
19
19
  }
20
20
 
21
- /**
22
- * @param {import('http').ServerResponse} res
23
- */
24
21
  function setAllowCredentials(res) {
25
22
  if (credentials) {
26
23
  res.setHeader("Access-Control-Allow-Credentials", "true");
27
24
  }
28
25
  }
29
26
 
30
- /**
31
- * @param {import('http').IncomingMessage} req
32
- * @param {import('http').ServerResponse} res
33
- */
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
+
34
44
  function setAllowHeaders(req, res) {
35
- let reqHeaders = req.headers["access-control-request-headers"];
45
+ const reqHeaders = req.headers["access-control-request-headers"];
36
46
  if (!reqHeaders) return;
37
- reqHeaders = reqHeaders.split(",").map((header) => header.trim().toLowerCase());
47
+
48
+ const requested = reqHeaders.split(",").map((h) => h.trim().toLowerCase());
49
+
38
50
  let value;
39
51
  if (headers === "*" && !credentials) {
40
52
  value = "*";
41
- } else if (Array.isArray(headers) && reqHeaders.every((reqHeader) => headers.some((header) => header === reqHeader))) {
42
- 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;
43
59
  }
44
60
  if (value) res.setHeader("Access-Control-Allow-Headers", value);
45
61
  }
46
62
 
47
- /**
48
- * @param {import('http').IncomingMessage} req
49
- * @param {import('http').ServerResponse} res
50
- */
51
63
  function setAllowMethods(req, res) {
52
64
  const reqMethod = req.headers["access-control-request-method"];
53
65
  if (!reqMethod) return;
54
- let value;
55
- if (methods === "*" && !credentials) {
56
- value = "*";
57
- } else if (Array.isArray(methods) && methods.some((method) => method === reqMethod.toUpperCase())) {
58
- value = methods;
59
- }
60
- if (value) res.setHeader("Access-Control-Allow-Methods", value);
61
- }
62
66
 
63
- /**
64
- * @param {import('http').IncomingMessage} req
65
- * @param {import('http').ServerResponse} res
66
- */
67
- function setAllowOrigin(req, res, preflight = true) {
68
- const reqOrigin = req.headers["origin"];
67
+ const method = reqMethod.toUpperCase();
69
68
 
70
69
  let value;
71
- let explicit;
72
- if (origin === "*" && !credentials) {
70
+ if (methods === "*" && !credentials) {
73
71
  value = "*";
74
- } else if (origin === null) {
75
- value = "null";
76
- } else if (origin === "*" || origin === reqOrigin || (Array.isArray(origin) && origin.some((origin) => origin === reqOrigin))) {
77
- value = reqOrigin;
78
- 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];
79
77
  }
80
- if (value) res.setHeader("Access-Control-Allow-Origin", value);
81
- if (!preflight && explicit) res.appendHeader("Vary", "Origin");
78
+ if (value) res.setHeader("Access-Control-Allow-Methods", value);
82
79
  }
83
80
 
84
- /**
85
- * @param {import('http').ServerResponse} res
86
- */
87
81
  function setExposeHeaders(res) {
88
82
  let value;
89
83
  if (headers === "*" && !credentials) {
@@ -94,22 +88,17 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
94
88
  if (value) res.setHeader("Access-Control-Expose-Headers", value);
95
89
  }
96
90
 
97
- /**
98
- * @param {import('http').ServerResponse} res
99
- */
100
91
  function setMaxAge(res) {
101
92
  if (maxAge > 0) {
102
93
  res.setHeader("Access-Control-Max-Age", maxAge);
103
94
  }
104
95
  }
105
96
 
106
- /**
107
- * @param {import('http').IncomingMessage} req
108
- * @param {import('http').ServerResponse} res
109
- */
110
97
  return (req, res) => {
111
98
  setAllowCredentials(res);
112
- if (req.method === "OPTIONS") {
99
+
100
+ if (req.method === "OPTIONS" && req.headers["access-control-request-method"]) {
101
+ // preflight
113
102
  setAllowOrigin(req, res);
114
103
  setAllowHeaders(req, res);
115
104
  setAllowMethods(req, res);
@@ -118,9 +107,11 @@ function cors({ origin = "*", credentials = false, headers, maxAge = 0, methods
118
107
  res.setHeader("Content-Length", "0");
119
108
  res.end();
120
109
  } else {
121
- setAllowOrigin(req, res, false);
110
+ // normal response (termasuk OPTIONS non-preflight)
111
+ setAllowOrigin(req, res);
122
112
  setExposeHeaders(res);
123
113
  }
124
114
  };
125
115
  }
116
+
126
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,41 +1,32 @@
1
1
  class Node {
2
2
  constructor(segment = "") {
3
3
  this.segment = segment;
4
- /**@type {Map<string, Node>}*/
5
- this.staticChildren = new Map();
6
- this.paramChild = null;
7
- this.paramName = null;
8
- this.wildcardChild = null;
9
- /**@type {Map<string, Function[]>}*/
4
+ /** @type {Map<string, Node>} static children */
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;
10
10
  this.handlers = new Map();
11
11
  }
12
- /**
13
- * @param {String} segment
14
- * @returns {Node}
15
- */
12
+
16
13
  add(segment) {
14
+ if (segment === "*") {
15
+ if (!this.wildcard) this.wildcard = new Node("*");
16
+ return this.wildcard;
17
+ }
18
+
17
19
  if (segment.startsWith(":")) {
18
- if (this.paramChild) return this.paramChild;
19
- this.paramChild = new Node(segment);
20
- this.paramChild.paramName = segment.slice(1);
21
- this.paramChild.type = "param";
22
- return this.paramChild;
23
- } else if (segment.startsWith("*")) {
24
- if (this.wildcardChild) return this.wildcardChild;
25
- this.wildcardChild = new Node(segment);
26
- this.wildcardChild.type = "wildcard";
27
- return this.wildcardChild;
28
- } else {
29
- return this.staticChildren.getOrInsertComputed(segment, (segment) => {
30
- const node = new Node(segment);
31
- node.type = "wildcard";
32
- return node;
20
+ return this.params.getOrInsertComputed(segment, (segment) => {
21
+ return new Node(segment);
33
22
  });
34
23
  }
24
+
25
+ return this.children.getOrInsertComputed(segment, (segment) => {
26
+ return new Node(segment);
27
+ });
35
28
  }
36
- /**
37
- * @param {Node} node
38
- */
29
+
39
30
  merge(node, offset) {
40
31
  for (const [method, entries] of node.handlers) {
41
32
  for (const entry of entries) {
@@ -46,37 +37,41 @@ class Node {
46
37
  }
47
38
  }
48
39
 
49
- if (node.paramChild) {
50
- this.add(node.paramChild.segment).merge(node.paramChild, offset);
40
+ if (node.wildcard) {
41
+ this.add("*").merge(node.wildcard, offset);
51
42
  }
52
- if (node.wildcardChild) {
53
- this.add(node.wildcardChild.segment).merge(node.wildcardChild, offset);
43
+
44
+ for (const [segment, child] of node.params) {
45
+ this.add(segment).merge(child, offset);
54
46
  }
55
- for (const [segment, child] of node.staticChildren) {
47
+
48
+ for (const [segment, child] of node.children) {
56
49
  this.add(segment).merge(child, offset);
57
50
  }
58
51
  }
59
52
 
60
53
  getMaxSeq() {
61
54
  let max = -1;
62
- for (const [method, entries] of this.handlers) {
55
+
56
+ for (const [, entries] of this.handlers) {
63
57
  for (const entry of entries) {
64
58
  max = Math.max(max, entry.seq);
65
59
  }
66
60
  }
67
61
 
68
- if (this.paramChild) {
69
- max = Math.max(max, this.paramChild.getMaxSeq());
62
+ if (this.wildcard) {
63
+ max = Math.max(max, this.wildcard.getMaxSeq());
70
64
  }
71
- if (this.wildcardChild) {
72
- max = Math.max(max, this.wildcardChild.getMaxSeq());
65
+
66
+ for (const [, child] of this.params) {
67
+ max = Math.max(max, child.getMaxSeq());
73
68
  }
74
- for (const [path, child] of this.staticChildren) {
69
+
70
+ for (const [, child] of this.children) {
75
71
  max = Math.max(max, child.getMaxSeq());
76
72
  }
77
73
 
78
74
  return max;
79
75
  }
80
76
  }
81
-
82
77
  exports.Node = Node;
package/src/polyfill.js CHANGED
@@ -1,3 +1,4 @@
1
+
1
2
  // Polyfill for Map.prototype.getOrInsert
2
3
  if (!Map.prototype.getOrInsert) {
3
4
  Map.prototype.getOrInsert = function (key, defaultValue) {
package/src/request.js CHANGED
@@ -1,32 +1,39 @@
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);
24
+
28
25
  return this._query;
29
26
  }
30
- }
31
27
 
28
+ /**
29
+ * @returns {AbortController}
30
+ */
31
+ get controller() {
32
+ if (!this._controller) {
33
+ this._controller = new AbortController();
34
+ }
35
+
36
+ return this._controller;
37
+ }
38
+ }
32
39
  exports.Request = Request;
package/src/response.js CHANGED
@@ -1,22 +1,21 @@
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
9
  send(chunk) {
9
10
  if (this.writableEnded) return;
10
- this.end(chunk, null, null); //avoid ArgumentsAdaptorTrampoline from V8
11
+ this.end(chunk, null, null);
11
12
  }
13
+
12
14
  json(value) {
13
- if (this.writableEnded) return;
14
- if (this.headersSent) {
15
- this.end();
16
- return;
15
+ if (!this.headersSent) {
16
+ this.setHeader("Content-Type", "application/json; charset=utf-8");
17
17
  }
18
- this.setHeader("Content-Type", "application/json");
19
- this.end(JSON.stringify(value), null, null); //avoid ArgumentsAdaptorTrampoline from V8
18
+ this.send(JSON.stringify(value));
20
19
  }
21
20
  }
22
21
  exports.Response = Response;
package/src/router.js CHANGED
@@ -1,14 +1,11 @@
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");
4
3
 
5
4
  class Router {
6
5
  constructor(options = {}) {
7
6
  this.requestTimeout = options.requestTimeout ?? 0;
8
- this.cacheSize = options.cacheSize ?? 1000;
9
7
  this.root = new Node();
10
8
  this.seq = 0;
11
- this.cache = new Cache(this.cacheSize);
12
9
  }
13
10
 
14
11
  split(path) {
@@ -16,16 +13,17 @@ class Router {
16
13
  }
17
14
 
18
15
  add(method, path, ...handlers) {
19
- this.cache.clear();
20
16
 
21
17
  if (typeof path === "function") {
22
18
  handlers = [path, ...handlers];
23
19
  path = null;
24
20
  }
21
+
25
22
  let node = this.root;
23
+
26
24
  if (path) {
27
- for (const part of this.split(path)) {
28
- node = node.add(part);
25
+ for (const segment of this.split(path)) {
26
+ node = node.add(segment);
29
27
  }
30
28
  }
31
29
 
@@ -34,8 +32,8 @@ class Router {
34
32
  node.merge(handler.root, this.seq);
35
33
  this.seq += handler.root.getMaxSeq() + 1;
36
34
  } else {
37
- const newMethod = handler.length === 3 ? "CATCH" : method;
38
- node.handlers.getOrInsert(newMethod, []).push({
35
+ const key = handler.length === 3 ? "CATCH" : method;
36
+ node.handlers.getOrInsert(key, []).push({
39
37
  handler,
40
38
  seq: this.seq++,
41
39
  });
@@ -98,96 +96,117 @@ class Router {
98
96
  return this;
99
97
  }
100
98
 
101
- collectMiddleware(node, handlers, errorHandlers) {
102
- if (node.handlers.has("ALL")) {
103
- for (const entry of node.handlers.get("ALL")) {
104
- handlers[entry.seq] = entry.handler;
105
- }
106
- }
107
- if (node.handlers.has("CATCH")) {
108
- for (const entry of node.handlers.get("CATCH")) {
109
- errorHandlers[entry.seq] = entry.handler;
110
- }
111
- }
112
- }
113
-
114
- collectHandler(node, method, handlers) {
115
- if (node && node.handlers.has(method)) {
116
- for (const entry of node.handlers.get(method)) {
117
- handlers[entry.seq] = entry.handler;
118
- }
119
- }
120
- }
121
-
122
99
  /**
123
100
  * @param {Node} node
124
- * @param {*} parts
125
- * @param {*} i
126
- * @param {*} params
127
- * @param {*} path
128
- * @returns
101
+ * @param {string[]} segments
102
+ * @param {number} index
103
+ * @param {Node[]} path
104
+ * @param {Record<string, string>} params
105
+ * @param {{ best: { nodes: Node[], params: Record<string, string> } }} state
106
+ * @returns {{ nodes: Node[], params: Record<string, string>, matched: boolean }}
129
107
  */
130
- search(node, parts, i, params, path) {
131
- if (!node) return null;
108
+ search(node, segments, index, path, params, state) {
132
109
  path.push(node);
133
- if (i === parts.length) return path;
134
110
 
135
- const part = parts[i];
111
+ // habis segmen -> full match
112
+ if (index === segments.length) {
113
+ return { nodes: path.slice(), params: { ...params }, matched: true };
114
+ }
115
+
116
+ const segment = segments[index];
136
117
 
137
- if (node.staticChildren.has(part)) {
138
- const r = this.search(node.staticChildren.get(part), parts, i + 1, params, path);
139
- if (r) return r;
118
+ // 1. static (prioritas tertinggi)
119
+ const staticChild = node.children.get(segment);
120
+ if (staticChild) {
121
+ const res = this.search(staticChild, segments, index + 1, path, params, state);
122
+ if (res.matched) return res;
140
123
  }
141
124
 
142
- if (node.paramChild) {
143
- const saved = params[node.paramChild.paramName];
144
- params[node.paramChild.paramName] = decodePathParam(part);
145
- const r = this.search(node.paramChild, parts, i + 1, params, path);
146
- if (r) return r;
147
- if (saved === undefined) delete params[node.paramChild.paramName];
148
- else params[node.paramChild.paramName] = saved;
125
+ // 2. semua param child (bisa lebih dari satu)
126
+ for (const [key, child] of node.params) {
127
+ const name = key.slice(1); // buang ':'
128
+ const prev = params[name];
129
+ params[name] = decodeParam(segment);
130
+ const res = this.search(child, segments, index + 1, path, params, state);
131
+ if (res.matched) return res;
132
+ // backtrack param value
133
+ if (prev === undefined) delete params[name];
134
+ else params[name] = prev;
149
135
  }
150
136
 
151
- if (node.wildcardChild) {
152
- params[0] = decodePathParam(parts.slice(i).join("/"));
153
- path.push(node.wildcardChild);
154
- return path;
137
+ // 3. wildcard (prioritas terendah, menyerap sisa segmen)
138
+ if (node.wildcard) {
139
+ const rest = segments.slice(index).join("/");
140
+ params["wildcard"] = decodeParam(rest);
141
+ path.push(node.wildcard);
142
+ return { nodes: path.slice(), params: { ...params }, matched: true };
143
+ }
144
+
145
+ // tidak match di sini -> update partial terpanjang
146
+ // path.length > state.best.nodes.length artinya kita sudah lebih dalam
147
+ if (path.length > state.best.nodes.length) {
148
+ state.best = { nodes: path.slice(), params: { ...params } };
155
149
  }
156
150
 
157
151
  path.pop();
158
- return null;
152
+ return { matched: false };
153
+ }
154
+
155
+ collectHandler(r, method, handlers) {
156
+ if (r.handlers.has(method)) {
157
+ for (const entry of r.handlers.get(method)) {
158
+ handlers[entry.seq] = entry.handler;
159
+ }
160
+ }
161
+ }
162
+
163
+ collectErrorHandler(r, errorHandlers) {
164
+ if (r.handlers.has("CATCH")) {
165
+ for (const entry of r.handlers.get("CATCH")) {
166
+ errorHandlers[entry.seq] = entry.handler;
167
+ }
168
+ }
169
+ }
170
+
171
+ collectMiddleware(r, handlers, errorHandlers) {
172
+ this.collectHandler(r, "ALL", handlers);
173
+ this.collectErrorHandler(r, errorHandlers);
159
174
  }
160
175
 
161
176
  find(method, path) {
162
- const key = `${method} ${path}`;
163
- const cached = this.cache.get(key);
164
- if (cached) return cached;
165
177
 
166
- const parts = this.split(path);
167
- const params = {};
168
- const nodePath = [];
169
- const result = this.search(this.root, parts, 0, params, nodePath);
178
+ const segments = this.split(path);
170
179
 
180
+ const params = {};
171
181
  const handlers = {};
172
182
  const errorHandlers = {};
173
183
 
174
- if (result) {
175
- for (const n of result) {
176
- this.collectMiddleware(n, handlers, errorHandlers);
177
- }
178
- this.collectHandler(result[result.length - 1], method, handlers);
179
- } else {
180
- this.collectMiddleware(this.root, handlers, errorHandlers);
184
+ /** @type {{ best: { nodes: Node[], params: Record<string, string> } }} */
185
+ const state = { best: { nodes: [], params: {} } };
186
+
187
+ const res = this.search(this.root, segments, 0, [], params, state);
188
+
189
+ const matchedNodes = res.matched ? res.nodes : state.best.nodes;
190
+ const finalParams = res.matched ? res.params : state.best.params;
191
+
192
+ // middleware dari semua node di jalur (partial atau full)
193
+ for (const r of matchedNodes) {
194
+ this.collectMiddleware(r, handlers, errorHandlers);
181
195
  }
182
196
 
183
- const value = {
184
- params,
197
+ // method handler HANYA kalau full match
198
+ if (res.matched) {
199
+ const leaf = matchedNodes[matchedNodes.length - 1];
200
+ this.collectHandler(leaf, method, handlers);
201
+ }
202
+
203
+ return {
204
+ matched: res.matched,
205
+ params: finalParams,
206
+ paramsLength: Object.keys(finalParams).length,
185
207
  handlers: Object.values(handlers),
186
208
  errorHandlers: Object.values(errorHandlers),
187
209
  };
188
- this.cache.set(key, value);
189
-
190
- return value;
191
210
  }
192
211
  }
193
212
  exports.Router = Router;
package/src/util.js CHANGED
@@ -1,46 +1,57 @@
1
- function decodeQueryParam(encoded) {
2
- const normalize = encoded.replace(/\+/g, " ");
1
+ // const EMPTY_PARAMS = Object.freeze(Object.create(null));
2
+ const EMPTY_PARAMS = Object.create(null)
3
+ exports.EMPTY_PARAMS = EMPTY_PARAMS;
4
+
5
+ function decodeParam(raw) {
3
6
  try {
4
- return decodeURIComponent(normalize);
7
+ return decodeURIComponent(raw);
5
8
  } catch (error) {
6
- return normalize;
9
+ return raw;
7
10
  }
8
11
  }
9
- exports.decodeQueryParam = decodeQueryParam;
12
+ exports.decodeParam = decodeParam;
13
+
14
+ function decodeQuery(raw) {
15
+ const normalized = raw.replace(/\+/g, " ");
10
16
 
11
- function decodePathParam(encoded) {
12
17
  try {
13
- return decodeURIComponent(encoded);
14
- } catch {
15
- return encoded;
18
+ return decodeURIComponent(normalized);
19
+ } catch (error) {
20
+ return normalized;
16
21
  }
17
22
  }
18
23
 
19
- exports.decodePathParam = decodePathParam;
20
-
21
24
  function parseQueryString(queryString) {
22
- const query = Object.create(null);
23
- if (!queryString) return query;
24
- const regex = /([^=&]*)?=([^&]*)?/g;
25
+ if (queryString === "") return Object.create(null);
26
+
27
+ const regex = /([^=&]*)=([^&]*)/g;
28
+ const result = Object.create(null);
29
+
25
30
  let match;
26
31
  while ((match = regex.exec(queryString))) {
27
- const [, rawName = "", rawValue = ""] = match;
28
- const name = decodeQueryParam(rawName);
29
- const value = decodeQueryParam(rawValue);
30
- if (Object.hasOwn(query, name)) {
31
- if (Array.isArray(query[name])) query[name].push(value);
32
- else query[name] = [query[name], value];
33
- } else {
34
- query[name] = value;
35
- }
32
+ const [, rawName, rawValue] = match;
33
+
34
+ const name = decodeQuery(rawName);
35
+ const value = decodeQuery(rawValue);
36
+
37
+ if (Object.hasOwn(result, name)) {
38
+ if (Array.isArray(result[name])) result[name].push(value);
39
+ else result[name] = [result[name], value];
40
+ } else result[name] = value;
36
41
  }
37
- return query;
42
+
43
+ return result;
38
44
  }
39
45
  exports.parseQueryString = parseQueryString;
40
46
 
41
47
  function parseUrl(url) {
48
+ if (!(url.includes("?") || url.includes("#"))) {
49
+ return { path: url, queryString: "" };
50
+ }
51
+
42
52
  const regex = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
43
53
  const [, path = "/", , queryString = ""] = regex.exec(url);
44
- return { path: path || "/", queryString };
54
+
55
+ return { path, queryString };
45
56
  }
46
57
  exports.parseUrl = parseUrl;
package/src/cache.js DELETED
@@ -1,28 +0,0 @@
1
- class Cache {
2
- constructor(size = 0) {
3
- this.size = size;
4
- this.map = new Map();
5
- }
6
-
7
- get(key) {
8
- if (!this.map.has(key)) return undefined;
9
- const value = this.map.get(key);
10
- this.map.delete(key);
11
- this.map.set(key, value);
12
- return value;
13
- }
14
-
15
- set(key, value) {
16
- if (this.map.has(key)) this.map.delete(key);
17
- else if (this.map.size >= this.size) {
18
- this.map.delete(this.map.keys().next().value);
19
- }
20
- this.map.set(key, value);
21
- }
22
-
23
- clear() {
24
- this.map.clear();
25
- }
26
- }
27
-
28
- exports.Cache = Cache;
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;