@ndiinginc/router 4.4.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,18 +1,20 @@
1
1
  # @ndiinginc/router
2
2
 
3
- Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsung di atas `node:http`, dengan routing berbasis tree, nested router, middleware async, error handler, dan JSON body parser bawaan.
3
+ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsung di atas `node:http`, dengan routing berbasis tree, LRU cache untuk lookup route, nested router, middleware async, error handler, timeout per request, serta middleware `json` dan `cors` bawaan.
4
4
 
5
5
  ## Fitur
6
6
 
7
7
  - Zero dependency, murni `node:http`
8
- - Routing berbasis tree per segment (static match diprioritaskan, lalu dynamic)
8
+ - Routing berbasis tree per segmen (static match diprioritaskan, lalu dynamic)
9
9
  - Parameter dinamis `:id` dan wildcard `*`
10
+ - LRU cache untuk hasil lookup route
10
11
  - Nested router dengan `use(path, router)`
11
12
  - Middleware global dan per-path, mendukung `async`
12
13
  - Error handler dengan signature `(err, req, res)`
14
+ - Timeout per request (`req.timeout`) yang menghasilkan `504`
13
15
  - `req.path`, `req.query`, `req.params`, `req.body`
14
16
  - Helper `res.send()` dan `res.json()`
15
- - JSON body parser dengan limit ukuran dan proteksi prototype pollution
17
+ - Middleware bawaan: `json()` (dengan limit dan proteksi prototype pollution) dan `cors()`
16
18
  - Semua HTTP method, termasuk `QUERY`
17
19
 
18
20
  ## Instalasi
@@ -21,14 +23,16 @@ Router HTTP kecil, cepat, dan **zero-dependency** untuk Node.js. Dibangun langsu
21
23
  npm install @ndiinginc/router
22
24
  ```
23
25
 
24
- Butuh **Node.js 22.1+** (memakai `URL.parse`).
26
+ Butuh Node.js 18.3 atau lebih baru.
25
27
 
26
28
  ## Quick Start
27
29
 
28
30
  ```js
29
- const Router = require("@ndiinginc/router");
31
+ const App = require("@ndiinginc/router");
30
32
 
31
- const app = new Router();
33
+ const app = new App();
34
+
35
+ app.use(App.json());
32
36
 
33
37
  app.get("/", (req, res) => {
34
38
  res.json({ hello: "world" });
@@ -38,19 +42,44 @@ app.get("/users/:id", (req, res) => {
38
42
  res.json({ id: req.params.id });
39
43
  });
40
44
 
45
+ app.post("/users", (req, res) => {
46
+ res.json({ received: req.body });
47
+ });
48
+
41
49
  app.listen(3000);
42
50
  ```
43
51
 
52
+ Yang diekspor adalah class `App`, dengan tiga properti tambahan:
53
+
54
+ | Export | Keterangan |
55
+ | --- | --- |
56
+ | `App` | Router + HTTP server (`listen`) |
57
+ | `App.Router` | Router tanpa server, untuk nested router |
58
+ | `App.json` | Middleware JSON body parser |
59
+ | `App.cors` | Middleware CORS |
60
+
44
61
  ## Routing
45
62
 
46
63
  Semua method mengembalikan `this`, jadi bisa di-chain.
47
64
 
48
65
  ```js
49
- app.get("/users", handler).post("/users", handler).put("/users/:id", handler).patch("/users/:id", handler).delete("/users/:id", handler);
66
+ app.get("/users", handler)
67
+ .post("/users", handler)
68
+ .put("/users/:id", handler)
69
+ .patch("/users/:id", handler)
70
+ .delete("/users/:id", handler);
50
71
  ```
51
72
 
52
73
  Method yang tersedia: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`, `trace`, `connect`, `query`, dan `use` (semua method).
53
74
 
75
+ Satu route boleh punya beberapa handler:
76
+
77
+ ```js
78
+ app.get("/profile", authenticate, loadUser, (req, res) => {
79
+ res.json(req.user);
80
+ });
81
+ ```
82
+
54
83
  ### Parameter dinamis
55
84
 
56
85
  ```js
@@ -62,7 +91,7 @@ app.get("/posts/:postId/comments/:commentId", (req, res) => {
62
91
 
63
92
  ### Wildcard
64
93
 
65
- `*` cocok dengan karakter apa pun di dalam satu segmen.
94
+ `*` cocok dengan karakter apa pun **di dalam satu segmen**. Wildcard tidak melintasi `/`.
66
95
 
67
96
  ```js
68
97
  app.get("/files/report-*", (req, res) => {
@@ -70,13 +99,15 @@ app.get("/files/report-*", (req, res) => {
70
99
  });
71
100
  ```
72
101
 
73
- ### Prioritas match
102
+ ### Aturan matching
74
103
 
75
- Untuk setiap segmen, router mencoba **static match dulu**, baru pattern dinamis. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
104
+ - Untuk setiap segmen, router mencoba **static match dulu**, baru pattern dinamis. Jadi `/users/me` menang atas `/users/:id` bila keduanya terdaftar.
105
+ - Jika ada beberapa pattern dinamis di level yang sama, yang **terdaftar lebih dulu** yang dipakai (tanpa backtracking).
106
+ - Trailing slash dan slash ganda diabaikan: `/users`, `/users/`, dan `//users` dianggap sama.
76
107
 
77
108
  ## Middleware
78
109
 
79
- Middleware didaftarkan dengan `use()` dan dijalankan **berurutan sesuai urutan pendaftaran**.
110
+ Middleware didaftarkan dengan `use()`. Semua handler (middleware dan route) dijalankan **berurutan sesuai urutan pendaftaran**.
80
111
 
81
112
  ```js
82
113
  // Global
@@ -93,15 +124,17 @@ app.use("/admin", async (req, res) => {
93
124
  });
94
125
  ```
95
126
 
96
- Chain berhenti otomatis begitu response dikirim (`res.send()` / `res.json()` menandai `res.done = true`), jadi handler berikutnya tidak dipanggil.
127
+ Chain berhenti otomatis begitu response selesai dikirim (`res.writableEnded`), jadi handler berikutnya tidak dipanggil.
97
128
 
98
- > Middleware global tetap dijalankan walaupun tidak ada route yang cocok (sebelum 404 dikirim).
129
+ > Middleware tetap dijalankan walaupun tidak ada route yang cocok, sebelum respons 404 dikirim.
99
130
 
100
131
  ## Nested Router
101
132
 
102
- Pecah aplikasi jadi beberapa router lalu pasang dengan `use(path, router)`. Nesting bisa berlapis.
133
+ Pecah aplikasi menjadi beberapa router, lalu pasang dengan `use(path, router)`. Nesting bisa berlapis.
103
134
 
104
135
  ```js
136
+ const { Router } = App;
137
+
105
138
  const users = new Router();
106
139
  users.get("/", (req, res) => res.json({ list: [] }));
107
140
  users.get("/:id", (req, res) => res.json({ id: req.params.id }));
@@ -109,16 +142,18 @@ users.get("/:id", (req, res) => res.json({ id: req.params.id }));
109
142
  const admin = new Router();
110
143
  admin.use("/users", users);
111
144
 
112
- const app = new Router();
145
+ const app = new App();
113
146
  app.use("/admin", admin);
114
147
 
115
- // GET /admin/users -> list
148
+ // GET /admin/users -> { list: [] }
116
149
  // GET /admin/users/42 -> { id: "42" }
117
150
  ```
118
151
 
152
+ > Tree sub-router digabungkan saat `use()` dipanggil. Daftarkan semua route ke sub-router **sebelum** di-mount; route yang ditambahkan setelahnya tidak ikut. Satu instance sub-router sebaiknya hanya di-mount satu kali.
153
+
119
154
  ## Error Handling
120
155
 
121
- Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Error handler dipanggil saat handler atau middleware melempar error (sync maupun async).
156
+ Handler dengan **tiga argumen** `(err, req, res)` dikenali sebagai error handler. Dipanggil saat handler atau middleware melempar error (sync maupun async).
122
157
 
123
158
  ```js
124
159
  app.get("/boom", () => {
@@ -127,7 +162,28 @@ app.get("/boom", () => {
127
162
 
128
163
  app.use((err, req, res) => {
129
164
  res.statusCode = err.statusCode || 500;
130
- res.json({ error: err.message });
165
+ res.json({ error: { message: err.message } });
166
+ });
167
+ ```
168
+
169
+ > Deteksi error handler memakai `handler.length === 3`. Jangan memakai default parameter atau rest parameter di signature handler, karena mengubah `length`.
170
+
171
+ ### Menentukan status code
172
+
173
+ Ada dua cara:
174
+
175
+ ```js
176
+ // 1. Properti statusCode pada error
177
+ app.get("/admin", () => {
178
+ const err = new Error("Forbidden");
179
+ err.statusCode = 403;
180
+ throw err;
181
+ });
182
+
183
+ // 2. Set res.statusCode sebelum throw
184
+ app.get("/limit", (req, res) => {
185
+ res.statusCode = 429;
186
+ throw new Error("Too Many Requests");
131
187
  });
132
188
  ```
133
189
 
@@ -136,21 +192,39 @@ app.use((err, req, res) => {
136
192
  Jika tidak ada handler yang mengirim response, router mengirim JSON otomatis:
137
193
 
138
194
  ```json
139
- { "error": { "code": undefined, "message": "Not Found" } }
195
+ { "error": { "message": "Not Found" } }
140
196
  ```
141
197
 
142
- - Tidak ada route cocok: status `404`
143
- - Error dengan status 2xx: dinaikkan menjadi `500`
144
- - Saat `NODE_ENV=development`, field `stack` ikut disertakan
198
+ - Tidak ada route cocok: `404 Not Found`
199
+ - Error tanpa status code: `500`
200
+ - Jika `res.statusCode` sudah `>= 400`, nilainya dipertahankan
201
+ - Jika error handler sendiri melempar error, error baru dibuat dengan `cause` menunjuk error asli
202
+
203
+ - Field `stack` hanya disertakan saat `NODE_ENV=development`
204
+
205
+ ## Timeout
206
+
207
+ Set `req.timeout` (milidetik) untuk membatasi waktu eksekusi handler. Jika terlampaui, error `504 Gateway Timeout` dilempar dan diteruskan ke error handler.
208
+
209
+ ```js
210
+ app.use((req) => {
211
+ req.timeout = 5000;
212
+ });
213
+ ```
214
+
215
+ - Nilai default `0` (tanpa timeout)
216
+ - Harus diset **secara sinkron di middleware pertama** pada chain, karena dibaca sesaat setelah chain mulai berjalan
217
+ - Timeout tidak membatalkan handler yang sedang berjalan; hanya menghentikan penantian dan mengirim error
145
218
 
146
219
  ## Request
147
220
 
148
- | Properti | Keterangan |
149
- | ------------ | ---------------------------------------------------- |
150
- | `req.path` | Pathname tanpa query string |
151
- | `req.query` | Object query string. Key yang berulang menjadi array |
152
- | `req.params` | Parameter dari route dinamis |
153
- | `req.body` | Hasil parse body (butuh middleware `json()`) |
221
+ | Properti | Keterangan |
222
+ | --- | --- |
223
+ | `req.path` | Pathname tanpa query string |
224
+ | `req.query` | Object query string, di-parse secara lazy. Key yang berulang menjadi array |
225
+ | `req.params` | Parameter dari route dinamis (sudah di-decode dengan `decodeURIComponent`) |
226
+ | `req.body` | Hasil parse body (butuh middleware `json()`) |
227
+ | `req.timeout` | Batas waktu handler dalam ms (default `0`) |
154
228
 
155
229
  ```js
156
230
  // GET /search?tag=a&tag=b&page=2
@@ -159,61 +233,99 @@ app.get("/search", (req, res) => {
159
233
  });
160
234
  ```
161
235
 
236
+ `Request` adalah turunan `http.IncomingMessage`, jadi `req.headers`, `req.method`, dan lainnya tetap tersedia.
237
+
162
238
  ## Response
163
239
 
164
- | Method | Keterangan |
165
- | ------------------ | ----------------------------------------------------------- |
166
- | `res.send(string)` | Kirim response dan akhiri chain |
167
- | `res.json(object)` | Set `content-type: application/json`, serialize, lalu kirim |
240
+ | Method | Keterangan |
241
+ | --- | --- |
242
+ | `res.send(chunk)` | Kirim response dan akhiri |
243
+ | `res.json(value)` | Set `Content-Type: application/json`, serialize, lalu kirim |
168
244
 
169
- Keduanya juga turunan dari `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
245
+ `Response` adalah turunan `http.ServerResponse`, jadi `res.statusCode`, `res.setHeader()`, dan lainnya tetap tersedia.
170
246
 
171
- ## JSON Body Parser
247
+ ## Middleware Bawaan
248
+
249
+ ### `json([options])`
172
250
 
173
251
  ```js
174
- const Router = require("@ndiinginc/router");
175
- const { json } = require("@ndiinginc/router/middleware");
252
+ app.use(App.json({ limit: 100 * 1024 })); // default 100 KB
253
+ ```
254
+
255
+ | Opsi | Default | Keterangan |
256
+ | --- | --- | --- |
257
+ | `limit` | `102400` | Ukuran body maksimum dalam byte |
176
258
 
177
- const app = new Router();
259
+ Perilaku:
178
260
 
179
- app.use(json({ limit: 100 * 1024 })); // default 100 KB
261
+ - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `Content-Type: application/json`
262
+ - Body melebihi `limit` menghasilkan `413 Payload Too Large` (dicek lewat `Content-Length` dan saat streaming)
263
+ - JSON tidak valid menghasilkan `400` dengan pesan `Invalid JSON body`
264
+ - Body kosong menghasilkan `req.body = null`
265
+ - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
180
266
 
181
- app.post("/users", (req, res) => {
182
- res.json({ received: req.body });
183
- });
267
+ ### `cors([options])`
268
+
269
+ ```js
270
+ app.use(
271
+ App.cors({
272
+ origin: "https://example.com",
273
+ credentials: true,
274
+ methods: ["GET", "POST", "PUT", "DELETE"],
275
+ headers: ["Content-Type", "Authorization"],
276
+ maxAge: 600,
277
+ }),
278
+ );
184
279
  ```
185
280
 
281
+ | Opsi | Default | Keterangan |
282
+ | --- | --- | --- |
283
+ | `origin` | `"*"` | `"*"`, string origin, atau `null` |
284
+ | `credentials` | `false` | Mengirim `Access-Control-Allow-Credentials: true` |
285
+ | `headers` | - | `"*"` atau array nama header |
286
+ | `methods` | - | `"*"` atau array nama method |
287
+ | `maxAge` | `0` | Lama cache preflight (detik). `0` berarti header tidak dikirim |
288
+
186
289
  Perilaku:
187
290
 
188
- - Hanya memproses method `POST`, `PUT`, `PATCH`, dan `QUERY` dengan `content-type: application/json`
189
- - Body melebihi `limit` memicu error `413 Payload Too Large` (dicek lewat `content-length` dan saat streaming)
190
- - JSON tidak valid memicu error `400 Invalid JSON body`
191
- - Body kosong menghasilkan `req.body = null`
192
- - Key `__proto__`, `constructor`, dan `prototype` dibuang untuk mencegah prototype pollution
291
+ - Request `OPTIONS` dijawab langsung dengan `204` dan chain berhenti
292
+ - Dengan `origin: "*"` dan `credentials: true`, origin dari request dipantulkan dan header `Vary: Origin` ditambahkan
293
+ - Nilai `"*"` untuk `headers` dan `methods` diabaikan saat `credentials: true`, sesuai spesifikasi CORS
294
+ - Pada request non-preflight, `headers` (jika array) dikirim sebagai `Access-Control-Expose-Headers`
295
+
296
+ ## Opsi Router
297
+
298
+ ```js
299
+ const app = new App({ size: 1000 });
300
+ ```
301
+
302
+ | Opsi | Default | Keterangan |
303
+ | --- | --- | --- |
304
+ | `size` | `1000` | Jumlah maksimum entri LRU cache untuk hasil lookup `METHOD path` |
193
305
 
194
306
  ## API
195
307
 
196
- ### `new Router()`
308
+ ### `new App([options])` / `new Router([options])`
197
309
 
198
- Membuat instance router baru.
310
+ Membuat instance baru. `App` adalah `Router` ditambah `listen()`.
199
311
 
200
- ### `router.use([path], handler | router)`
312
+ ### `router.use([path], ...handlers | router)`
201
313
 
202
314
  Mendaftarkan middleware, error handler, atau nested router untuk semua method.
203
315
 
204
- ### `router.<method>([path], handler)`
316
+ ### `router.<method>([path], ...handlers)`
205
317
 
206
- Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, handler dipasang di root router.
318
+ Mendaftarkan handler untuk method tertentu. `path` opsional; jika dihilangkan, handler dipasang di root.
207
319
 
208
320
  ### `router.find(method, path)`
209
321
 
210
- Mencari route tanpa menjalankannya. Berguna untuk testing.
322
+ Mencari route tanpa menjalankannya. Hasilnya di-cache. Berguna untuk testing.
211
323
 
212
324
  ```js
213
325
  const { params, handlers, errorHandlers } = app.find("GET", "/users/1");
214
326
  ```
215
327
 
216
- ### `router.listen(...args)`
328
+ ### `app.listen(...args)`
217
329
 
218
330
  Membuat `http.Server` dengan `Request` dan `Response` milik router, lalu memanggil `server.listen(...args)`. Mengembalikan instance `http.Server`.
219
331
 
@@ -222,6 +334,18 @@ const server = app.listen(0); // port acak
222
334
  console.log(server.address().port);
223
335
  ```
224
336
 
337
+ ## Benchmark
338
+
339
+ Hello world dengan satu route `GET /`, diuji memakai `autocannon -c 100 -d 40 -p 10` di mesin lokal.
340
+
341
+ | | Req/sec (avg) | Latency (avg) |
342
+ | --- | --- | --- |
343
+ | Fastify | 31.475 | 31,27 ms |
344
+ | **@ndiinginc/router** | 29.735 | 33,12 ms |
345
+ | Express | 20.211 | 48,93 ms |
346
+
347
+ Hasil bervariasi tergantung mesin, versi Node.js, dan jumlah route. Benchmark ini hanya menggambarkan overhead dasar, bukan performa aplikasi nyata.
348
+
225
349
  ## Testing
226
350
 
227
351
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ndiinginc/router",
3
- "version": "4.4.0",
3
+ "version": "4.4.1",
4
4
  "description": "",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
package/src/app.js CHANGED
@@ -59,7 +59,11 @@ class App extends Router {
59
59
  res.statusCode = err.statusCode || 500;
60
60
  }
61
61
 
62
- res.json({ error: { message: err.message, stack: err.stack } });
62
+ const error = { message: err.message };
63
+ if (process.env.NODE_ENV === "development") {
64
+ error.stack = err.stack;
65
+ }
66
+ res.json({ error });
63
67
  }
64
68
  }
65
69
 
package/src/router.js CHANGED
@@ -107,7 +107,7 @@ class Router {
107
107
 
108
108
  let node = this.root;
109
109
 
110
- let params = {};
110
+ let params = Object.create(null);
111
111
  const handlers = {};
112
112
  const errorHandlers = {};
113
113
 
@@ -120,7 +120,14 @@ class Router {
120
120
  const match = child.pattern.exec(input);
121
121
  if (match) {
122
122
  matched = child;
123
- Object.assign(params, match.groups);
123
+ for (const name in match.groups) {
124
+ const raw = match.groups[name];
125
+ try {
126
+ params[name] = decodeURIComponent(raw);
127
+ } catch {
128
+ params[name] = raw;
129
+ }
130
+ }
124
131
  break;
125
132
  }
126
133
  }
@@ -128,7 +135,7 @@ class Router {
128
135
 
129
136
  if (!matched) {
130
137
  node = null;
131
- params = {};
138
+ params = Object.create(null);
132
139
  break;
133
140
  }
134
141
 
package/src/util.js CHANGED
@@ -2,13 +2,25 @@ const URL_REGEX = /^([^?#]*)(\?([^#]*))?(#.*)?$/;
2
2
  const QUERY_STRING_REGEX = /([^=&]*)?=([^&]*)?/g;
3
3
 
4
4
  function parseQueryString(queryString) {
5
- const result = {};
5
+ const result = Object.create(null);
6
6
 
7
7
  let match;
8
8
  while ((match = QUERY_STRING_REGEX.exec(queryString)) !== null) {
9
- const [, name = "", raw = ""] = match;
9
+ const [, rawName = "", rawValue = ""] = match;
10
10
 
11
- const value = decodeURIComponent(raw);
11
+ let name
12
+ try {
13
+ name = decodeURIComponent(rawName)
14
+ } catch (error) {
15
+ name=rawName
16
+ }
17
+
18
+ let value
19
+ try {
20
+ value = decodeURIComponent(rawValue)
21
+ } catch (error) {
22
+ value=rawValue
23
+ }
12
24
 
13
25
  if (result[name]) {
14
26
  if (Array.isArray(result[name])) result[name].push(value);