vibe-gx 4.1.2 → 4.2.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
@@ -50,6 +50,8 @@ npm install vibe-gx
50
50
  | 🎨 **Decorators** | Extend app, request, and response |
51
51
  | ⚡ **Cluster Mode** | Built-in multi-process scaling |
52
52
  | 💾 **LRU Cache** | Built-in response caching with ETag |
53
+ | 🛡️ **Rate Limiting** | Built-in sliding window rate limiter — no dependencies |
54
+ | 🌐 **CORS** | Built-in CORS with preflight handling — no dependencies |
53
55
  | 🔗 **Connection Pool** | Generic pool for databases |
54
56
  | 📂 **File Uploads** | Multipart uploads with size/type validation |
55
57
  | 🌊 **Streaming** | Large file uploads without buffering |
@@ -474,15 +476,61 @@ Use any Express middleware with the adapter:
474
476
 
475
477
  ```javascript
476
478
  import vibe, { adapt } from "vibe-gx";
477
- import cors from "cors";
478
479
  import helmet from "helmet";
479
480
  import compression from "compression";
480
481
 
481
- app.plugin(adapt(cors()));
482
482
  app.plugin(adapt(helmet()));
483
483
  app.plugin(adapt(compression()));
484
484
  ```
485
485
 
486
+ > **Note:** Vibe ships with a native `cors()` helper. No need to adapt the `cors` npm package.
487
+
488
+ ---
489
+
490
+ ## 🛡️ Rate Limiting
491
+
492
+ Built-in sliding window rate limiter — no external dependencies:
493
+
494
+ ```javascript
495
+ import vibe, { rateLimit } from "vibe-gx";
496
+
497
+ const app = vibe();
498
+
499
+ // Global limit: 100 requests per minute per IP
500
+ app.plugin(rateLimit({ max: 100, window: 60_000 }));
501
+
502
+ // Per-route: tight limit on login (brute force protection)
503
+ app.post(
504
+ "/auth/login",
505
+ { intercept: rateLimit({ max: 5, window: 60_000 }) },
506
+ handler,
507
+ );
508
+ ```
509
+
510
+ Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` headers automatically.
511
+
512
+ ---
513
+
514
+ ## 🌐 CORS
515
+
516
+ Built-in CORS with automatic preflight handling — no external dependencies:
517
+
518
+ ```javascript
519
+ import vibe, { cors } from "vibe-gx";
520
+
521
+ const app = vibe();
522
+
523
+ app.plugin(
524
+ cors({
525
+ origin: "https://myapp.com",
526
+ credentials: true,
527
+ maxAge: 86_400, // cache preflight for 24 hours
528
+ }),
529
+ );
530
+ ```
531
+
532
+ Supports wildcard, single origin, array of origins, and dynamic origin functions.
533
+
486
534
  ---
487
535
 
488
536
  ## 🔒 Security
@@ -602,18 +650,18 @@ app.post(
602
650
 
603
651
  ### Request (`req`)
604
652
 
605
- | Property | Description |
606
- | :------------ | :------------------------- |
607
- | `req.id` | Auto-generated UUID logic |
608
- | `req.log` | Context-bound logger API |
609
- | `req.params` | Route parameters (`:id`) |
610
- | `req.query` | Query string (`?page=1`) |
611
- | `req.body` | Parsed JSON/form body |
612
- | `req.files` | Uploaded files (multipart) |
613
- | `req.ip` | Client IP address |
614
- | `req.method` | HTTP method |
615
- | `req.url` | Request URL |
616
- | `req.headers` | Request headers |
653
+ | Property | Description |
654
+ | :------------ | :------------------------------------------------------- |
655
+ | `req.id` | Lazy UUID — generated only on first access |
656
+ | `req.log` | Lazy context-bound logger — created only on first access |
657
+ | `req.params` | Route parameters (`:id`) |
658
+ | `req.query` | Query string (`?page=1`) |
659
+ | `req.body` | Parsed JSON/form body |
660
+ | `req.files` | Uploaded files (multipart) |
661
+ | `req.ip` | Real client IP — proxy-aware (`x-forwarded-for` first) |
662
+ | `req.method` | HTTP method |
663
+ | `req.url` | Request URL (pathname only, query stripped) |
664
+ | `req.headers` | Request headers |
617
665
 
618
666
  ### Response (`res`)
619
667
 
@@ -665,6 +713,18 @@ app.post(
665
713
  | `pool.close()` | Close pool |
666
714
  | `pool.stats` | Get pool statistics |
667
715
 
716
+ ### Rate Limit Utilities
717
+
718
+ | Function | Description |
719
+ | :---------------- | :----------------------------------- |
720
+ | `rateLimit(opts)` | Create a sliding window rate limiter |
721
+
722
+ ### CORS Utilities
723
+
724
+ | Function | Description |
725
+ | :------------ | :------------------------ |
726
+ | `cors(opts?)` | Create a CORS interceptor |
727
+
668
728
  ---
669
729
 
670
730
  ## 📊 Benchmarks
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibe-gx",
3
- "version": "4.1.2",
3
+ "version": "4.2.0",
4
4
  "description": "A lightweight, high-performance Node.js web framework.",
5
5
  "type": "module",
6
6
  "main": "vibe.js",
@@ -9,6 +9,7 @@ const LOG_LEVELS = {
9
9
  warn: 40,
10
10
  error: 50,
11
11
  fatal: 60,
12
+ silent: 100, // Higher than all levels — suppresses all output (logger: false)
12
13
  };
13
14
 
14
15
  const LEVEL_NAMES = {
@@ -136,45 +137,59 @@ export class Logger {
136
137
  _printPretty(log) {
137
138
  const time = new Date(log.time).toLocaleTimeString();
138
139
  const lvlName = LEVEL_NAMES[log.level] || "INFO";
139
- let prefixC = color.cyan;
140
- if (log.level >= 50) prefixC = color.red;
141
- else if (log.level === 40) prefixC = color.yellow;
142
- else if (log.level <= 20) prefixC = color.dim;
143
-
144
- const prefix = prefixC(`[VIBE ${lvlName} ${time}]`);
145
- let context = "";
146
- if (log.reqId) {
147
- context = `\x1b[90m[${log.reqId}]\x1b[0m `;
148
- }
149
140
 
150
- let content = log.msg || "";
151
- if (log.color && color[log.color]) {
152
- content = color[log.color](content);
153
- }
141
+ const isError = log.level >= 50;
142
+ const isWarn = log.level === 40;
143
+ const isDebug = log.level <= 20;
144
+
145
+ // Build context tag (reqId)
146
+ const context = log.reqId ? `[${log.reqId}] ` : "";
154
147
 
148
+ // Build message content
149
+ let content = log.msg || "";
155
150
  if (log.err && log.err.stack) {
156
- content += "\n" + prefixC(log.err.stack);
151
+ content += "\n" + log.err.stack;
157
152
  }
158
153
 
159
- // Attempt to print remaining metadata if it's not standard
154
+ // Build metadata string (skip standard keys)
160
155
  const skipKeys = [
161
- "level",
162
- "time",
163
- "pid",
164
- "hostname",
165
- "reqId",
166
- "msg",
167
- "err",
168
- "color",
156
+ "level", "time", "pid", "hostname", "reqId", "msg", "err", "color",
169
157
  ];
170
158
  let metaStr = "";
171
159
  for (const key of Object.keys(log)) {
172
160
  if (!skipKeys.includes(key)) {
173
- metaStr += ` \x1b[90m${key}=${JSON.stringify(log[key])}\x1b[0m`;
161
+ metaStr += ` ${key}=${JSON.stringify(log[key])}`;
174
162
  }
175
163
  }
176
164
 
177
- this.stream.write(`${prefix} ${context}${content}${metaStr}\n`);
165
+ const rawPrefix = `[VIBE ${lvlName} ${time}]`;
166
+
167
+ if (isError) {
168
+ // Entire line is red — prefix, context, message, stack, metadata
169
+ const fullLine = `${rawPrefix} ${context}${content}${metaStr}`;
170
+ this.stream.write(color.red(fullLine) + "\n");
171
+ } else if (isWarn) {
172
+ // Yellow prefix, bright content
173
+ const coloredContent = log.color && color[log.color]
174
+ ? color[log.color](content)
175
+ : color.bright(content);
176
+ this.stream.write(
177
+ color.yellow(rawPrefix) + " " + context + coloredContent +
178
+ (metaStr ? color.dim(metaStr) : "") + "\n",
179
+ );
180
+ } else if (isDebug) {
181
+ // Dim entire line for trace/debug
182
+ this.stream.write(color.dim(`${rawPrefix} ${context}${content}${metaStr}`) + "\n");
183
+ } else {
184
+ // Info — green prefix + bright content (matches [VIBE LOG] style)
185
+ const coloredContent = log.color && color[log.color]
186
+ ? color[log.color](content)
187
+ : color.bright(content);
188
+ this.stream.write(
189
+ color.green(rawPrefix) + " " + context + coloredContent +
190
+ (metaStr ? color.dim(metaStr) : "") + "\n",
191
+ );
192
+ }
178
193
  }
179
194
  }
180
195
 
@@ -103,7 +103,7 @@ function parseMultipart(req, res, media, options, resolve, reject) {
103
103
  },
104
104
  });
105
105
  } catch (err) {
106
- console.error("Busboy init failed:", err);
106
+ options.logger?.error(err, "[VIBE] Busboy init failed");
107
107
  return resolve();
108
108
  }
109
109
 
@@ -152,7 +152,10 @@ function parseMultipart(req, res, media, options, resolve, reject) {
152
152
  media.public &&
153
153
  !dest.startsWith(path.resolve(options.publicFolder || ""))
154
154
  ) {
155
- console.warn("Attempted upload outside public folder, skipping");
155
+ options.logger?.warn(
156
+ { dest, publicFolder: options.publicFolder },
157
+ "[VIBE] Attempted upload outside public folder, skipping",
158
+ );
156
159
  pendingWrites--;
157
160
  checkComplete();
158
161
  return file.resume();
@@ -161,7 +164,7 @@ function parseMultipart(req, res, media, options, resolve, reject) {
161
164
  try {
162
165
  if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
163
166
  } catch (err) {
164
- console.error("Failed to create upload folder:", err);
167
+ options.logger?.error(err, "[VIBE] Failed to create upload folder");
165
168
  pendingWrites--;
166
169
  checkComplete();
167
170
  return file.resume();
@@ -200,14 +203,14 @@ function parseMultipart(req, res, media, options, resolve, reject) {
200
203
  });
201
204
 
202
205
  file.on("error", (err) => {
203
- console.error("File stream error:", err);
206
+ options.logger?.error(err, "[VIBE] File stream error");
204
207
  writeStream.end();
205
208
  pendingWrites--;
206
209
  checkComplete();
207
210
  });
208
211
 
209
212
  writeStream.on("error", (err) => {
210
- console.error("Write stream error:", err);
213
+ options.logger?.error(err, "[VIBE] Write stream error");
211
214
  file.resume();
212
215
  pendingWrites--;
213
216
  checkComplete();
@@ -231,7 +234,7 @@ function parseMultipart(req, res, media, options, resolve, reject) {
231
234
  });
232
235
 
233
236
  bb.on("error", (err) => {
234
- console.error("Busboy error:", err);
237
+ options.logger?.error(err, "[VIBE] Busboy error");
235
238
  req.unpipe(bb);
236
239
  reject(err);
237
240
  });
@@ -266,7 +269,10 @@ function parseJson(req, res, media, options, resolve, reject) {
266
269
  req.on("data", (chunk) => {
267
270
  body += chunk;
268
271
  if (body.length > limit) {
269
- console.warn("JSON payload too large, destroying connection");
272
+ options.logger?.warn(
273
+ { limit, received: body.length },
274
+ "[VIBE] JSON payload too large, destroying connection",
275
+ );
270
276
  req.destroy();
271
277
  }
272
278
  });
@@ -243,7 +243,12 @@ const vibeResponseMethods = {
243
243
  * @param {Error} error
244
244
  */
245
245
  serverError(error) {
246
- console.error(error);
246
+ const logger = this._vibeOptions?.logger;
247
+ if (logger) {
248
+ logger.error(error, "[VIBE] Internal server error");
249
+ } else {
250
+ console.error(error);
251
+ }
247
252
  this.writeHead(500, JSON_CT);
248
253
  this.end(RESPONSES.serverError);
249
254
  },
@@ -90,15 +90,35 @@ async function server(options, port, host, callback) {
90
90
 
91
91
  // Main request handler - ULTRA OPTIMIZED
92
92
  function reqListener(req, res) {
93
- req.id = crypto.randomUUID();
94
- req.log = options.logger.child({ reqId: req.id });
93
+ // Lazy req.id and req.log — UUID and child logger are only created
94
+ // on first access. Routes that don't log or need an ID pay zero cost.
95
+ let _reqId = null;
96
+ let _reqLog = null;
97
+ Object.defineProperty(req, "id", {
98
+ get() {
99
+ if (_reqId === null) _reqId = crypto.randomUUID();
100
+ return _reqId;
101
+ },
102
+ configurable: true,
103
+ });
104
+ Object.defineProperty(req, "log", {
105
+ get() {
106
+ if (_reqLog === null)
107
+ _reqLog = options.logger.child({ reqId: req.id });
108
+ return _reqLog;
109
+ },
110
+ configurable: true,
111
+ });
95
112
 
96
113
  if (options.loggerConfig && options.loggerConfig.lifecycle) {
97
114
  req.startTime = Date.now();
98
115
 
99
116
  // Determine sender IP early for logging
100
117
  const sender =
101
- req.socket.remoteAddress || req.headers["x-forwarded-for"] || "unknown";
118
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
119
+ req.headers["x-real-ip"] ||
120
+ req.socket.remoteAddress ||
121
+ "unknown";
102
122
 
103
123
  req.log.info(
104
124
  { type: "req", url: req.url, method: req.method, sender },
@@ -129,6 +149,12 @@ async function server(options, port, host, callback) {
129
149
 
130
150
  req.url = pathname;
131
151
 
152
+ // Resolve real client IP once — proxy-aware, available on ALL paths
153
+ req.ip =
154
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
155
+ req.headers["x-real-ip"] ||
156
+ req.socket.remoteAddress;
157
+
132
158
  // Stamp response with options ref (ONLY per-request cost for response methods)
133
159
  res._vibeOptions = options;
134
160
 
@@ -215,10 +241,7 @@ async function server(options, port, host, callback) {
215
241
  if (!(await runIntercept(interceptors, req, res))) return;
216
242
  }
217
243
 
218
- // Lazy IP
219
- if (!req.ip) {
220
- req.ip = req.socket.remoteAddress || req.headers["x-forwarded-for"];
221
- }
244
+ // Lazy IP already resolved in reqListener — no-op needed here
222
245
 
223
246
  // Route matching - FAST PATH first
224
247
  const routeKey = req.method + pathname;
@@ -296,8 +319,14 @@ async function server(options, port, host, callback) {
296
319
  getNetworkIP(mainHost, port);
297
320
 
298
321
  const strategy = useTrieMatching ? "Trie (O(log n))" : "Linear (O(n))";
299
- console.log(
300
- `[VIBE] Route matching: ${strategy} (${options.routeCount} routes, ${staticRoutes.size} static, threshold: ${options.trieThreshold})`,
322
+ options.logger.info(
323
+ {
324
+ strategy,
325
+ routeCount: options.routeCount,
326
+ staticRoutes: staticRoutes.size,
327
+ trieThreshold: options.trieThreshold,
328
+ },
329
+ "[VIBE] Route matching strategy initialized",
301
330
  );
302
331
 
303
332
  if (callback) callback();
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Built-in CORS (Cross-Origin Resource Sharing) helper for Vibe.
3
+ *
4
+ * Returns an interceptor that handles preflight OPTIONS requests and sets
5
+ * the appropriate Access-Control-* headers on every response.
6
+ *
7
+ * @example
8
+ * import vibe, { cors } from "vibe-gx";
9
+ * const app = vibe();
10
+ *
11
+ * app.plugin(cors({ origin: "https://myapp.com", credentials: true }));
12
+ */
13
+
14
+ // Pre-allocated headers for preflight responses
15
+ const PREFLIGHT_HEADERS = { "content-length": "0" };
16
+
17
+ /**
18
+ * @typedef {Object} CorsOptions
19
+ * @property {string | string[] | ((origin: string) => boolean)} [origin="*"]
20
+ * Allowed origin(s). Can be a string, array of strings, or a function
21
+ * that returns true/false for a given origin.
22
+ * @property {string[]} [methods] Allowed HTTP methods. Default: common methods
23
+ * @property {string[]} [allowedHeaders] Allowed request headers
24
+ * @property {string[]} [exposedHeaders] Headers exposed to the browser
25
+ * @property {boolean} [credentials=false] Allow cookies / auth headers
26
+ * @property {number} [maxAge] Preflight cache duration in seconds
27
+ */
28
+
29
+ /**
30
+ * Creates a CORS interceptor.
31
+ *
32
+ * - Handles OPTIONS preflight requests automatically (responds 204 and stops).
33
+ * - Sets Access-Control-* headers on every request.
34
+ * - Supports wildcard, single origin, array of origins, or dynamic function.
35
+ *
36
+ * @param {CorsOptions} [opts={}]
37
+ * @returns {import("../vibe.js").Interceptor}
38
+ */
39
+ export function cors(opts = {}) {
40
+ const {
41
+ origin = "*",
42
+ methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
43
+ allowedHeaders = ["Content-Type", "Authorization"],
44
+ exposedHeaders = [],
45
+ credentials = false,
46
+ maxAge,
47
+ } = opts;
48
+
49
+ // Pre-compute static header values where possible
50
+ const methodsStr = methods.join(", ");
51
+ const allowedHeadersStr = allowedHeaders.join(", ");
52
+ const exposedHeadersStr = exposedHeaders.length
53
+ ? exposedHeaders.join(", ")
54
+ : null;
55
+ const maxAgeStr = maxAge != null ? String(maxAge) : null;
56
+
57
+ /**
58
+ * Resolve the Access-Control-Allow-Origin value for a given request origin.
59
+ * @param {string} requestOrigin
60
+ * @returns {string | null} The value to set, or null to omit the header
61
+ */
62
+ function resolveOrigin(requestOrigin) {
63
+ if (origin === "*") return "*";
64
+
65
+ if (typeof origin === "function") {
66
+ return origin(requestOrigin) ? requestOrigin : null;
67
+ }
68
+
69
+ if (Array.isArray(origin)) {
70
+ return origin.includes(requestOrigin) ? requestOrigin : null;
71
+ }
72
+
73
+ // Single string
74
+ return origin === requestOrigin ? requestOrigin : null;
75
+ }
76
+
77
+ /**
78
+ * The interceptor returned to app.plugin().
79
+ * @param {import("../vibe.js").VibeRequest} req
80
+ * @param {import("../vibe.js").VibeResponse} res
81
+ * @returns {boolean}
82
+ */
83
+ return function corsInterceptor(req, res) {
84
+ const requestOrigin = req.headers["origin"];
85
+
86
+ // No Origin header — not a cross-origin request, skip CORS headers
87
+ if (!requestOrigin) return true;
88
+
89
+ const allowedOrigin = resolveOrigin(requestOrigin);
90
+
91
+ if (allowedOrigin) {
92
+ res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
93
+
94
+ // If not wildcard, tell proxies/CDNs the response varies by Origin
95
+ if (allowedOrigin !== "*") {
96
+ res.setHeader("Vary", "Origin");
97
+ }
98
+ }
99
+
100
+ if (credentials) {
101
+ res.setHeader("Access-Control-Allow-Credentials", "true");
102
+ }
103
+
104
+ if (exposedHeadersStr) {
105
+ res.setHeader("Access-Control-Expose-Headers", exposedHeadersStr);
106
+ }
107
+
108
+ // Handle preflight (OPTIONS) — respond immediately and stop
109
+ if (req.method === "OPTIONS") {
110
+ res.setHeader("Access-Control-Allow-Methods", methodsStr);
111
+ res.setHeader("Access-Control-Allow-Headers", allowedHeadersStr);
112
+
113
+ if (maxAgeStr) {
114
+ res.setHeader("Access-Control-Max-Age", maxAgeStr);
115
+ }
116
+
117
+ res.writeHead(204, PREFLIGHT_HEADERS);
118
+ res.end();
119
+ return false; // Stop further processing
120
+ }
121
+
122
+ return true;
123
+ };
124
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Built-in sliding window rate limiter for Vibe.
3
+ *
4
+ * Works as both a global plugin and a per-route interceptor.
5
+ * Uses an in-memory Map with automatic TTL-based cleanup.
6
+ * No external dependencies.
7
+ *
8
+ * @example
9
+ * // Global — all routes
10
+ * import { rateLimit } from "vibe-gx";
11
+ * app.plugin(rateLimit({ max: 100, window: 60_000 }));
12
+ *
13
+ * @example
14
+ * // Per-route — tight limit on login
15
+ * app.post("/auth/login", { intercept: rateLimit({ max: 5, window: 60_000 }) }, handler);
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} RateLimitOptions
20
+ * @property {number} max - Maximum number of requests allowed per window
21
+ * @property {number} [window=60000] - Window duration in milliseconds. Default: 60s
22
+ * @property {(req: import("../vibe.js").VibeRequest) => string} [keyBy] - Custom key function. Default: req.ip
23
+ * @property {string} [message] - Custom error message. Default: "Too Many Requests"
24
+ * @property {number} [statusCode=429] - HTTP status code when limit exceeded. Default: 429
25
+ * @property {(req: import("../vibe.js").VibeRequest) => boolean} [skip] - Return true to bypass the limiter for a request
26
+ */
27
+
28
+ // Pre-allocated 429 response headers
29
+ const RATE_LIMIT_HEADERS = { "content-type": "text/plain" };
30
+
31
+ /**
32
+ * Creates a rate limiter interceptor using a sliding window algorithm.
33
+ *
34
+ * Each unique key (default: IP address) gets a counter and a window start time.
35
+ * When the window expires the counter resets. When the counter exceeds `max`
36
+ * the request is rejected with 429 and a `Retry-After` header.
37
+ *
38
+ * @param {RateLimitOptions} opts
39
+ * @returns {import("../vibe.js").Interceptor}
40
+ */
41
+ export function rateLimit(opts = {}) {
42
+ const max = opts.max;
43
+
44
+ if (!max || typeof max !== "number" || max < 1) {
45
+ throw new Error("[vibe] rateLimit: `max` must be a positive number");
46
+ }
47
+
48
+ const windowMs = opts.window ?? 60_000;
49
+ const message = opts.message ?? "Too Many Requests";
50
+ const statusCode = opts.statusCode ?? 429;
51
+ const keyBy = opts.keyBy ?? ((req) => req.ip ?? "unknown");
52
+ const skip = opts.skip ?? null;
53
+
54
+ // In-memory store: key → { count, windowStart }
55
+ // Entries are cleaned up automatically when the window expires
56
+ const store = new Map();
57
+
58
+ // Periodic cleanup to prevent unbounded memory growth.
59
+ // Runs every `windowMs` and removes all expired entries.
60
+ const cleanupInterval = setInterval(() => {
61
+ const now = Date.now();
62
+ for (const [key, entry] of store) {
63
+ if (now - entry.windowStart >= windowMs) {
64
+ store.delete(key);
65
+ }
66
+ }
67
+ }, windowMs);
68
+
69
+ // Don't keep the process alive just for cleanup
70
+ if (cleanupInterval.unref) cleanupInterval.unref();
71
+
72
+ /**
73
+ * The interceptor function returned to app.plugin() or route intercept.
74
+ * @param {import("../vibe.js").VibeRequest} req
75
+ * @param {import("../vibe.js").VibeResponse} res
76
+ * @returns {boolean}
77
+ */
78
+ return function rateLimitInterceptor(req, res) {
79
+ // Allow bypassing for certain requests (e.g. internal health checks)
80
+ if (skip && skip(req)) return true;
81
+
82
+ const key = keyBy(req);
83
+ const now = Date.now();
84
+
85
+ let entry = store.get(key);
86
+
87
+ // No entry yet or window has expired — start a fresh window
88
+ if (!entry || now - entry.windowStart >= windowMs) {
89
+ entry = { count: 1, windowStart: now };
90
+ store.set(key, entry);
91
+ } else {
92
+ entry.count++;
93
+ }
94
+
95
+ const remaining = Math.max(0, max - entry.count);
96
+ const resetInMs = windowMs - (now - entry.windowStart);
97
+ const resetInSeconds = Math.ceil(resetInMs / 1000);
98
+
99
+ // Always set informational headers
100
+ res.setHeader("X-RateLimit-Limit", max);
101
+ res.setHeader("X-RateLimit-Remaining", remaining);
102
+ res.setHeader("X-RateLimit-Reset", Math.ceil((entry.windowStart + windowMs) / 1000));
103
+
104
+ if (entry.count > max) {
105
+ res.setHeader("Retry-After", resetInSeconds);
106
+ res.writeHead(statusCode, RATE_LIMIT_HEADERS);
107
+ res.end(message);
108
+ return false; // Stop request processing
109
+ }
110
+
111
+ return true;
112
+ };
113
+ }
package/vibe.d.ts CHANGED
@@ -219,8 +219,6 @@ export interface LoggerAPI {
219
219
  export interface VibeConfig {
220
220
  /** Configuration for the native Vibe terminal logger */
221
221
  logger?: LoggerConfig | boolean;
222
- /** Enable automatic process restarting on crash. Spawns an internal cluster manager. Default: false */
223
- autoRestart?: boolean;
224
222
  }
225
223
 
226
224
  // ==========================================
@@ -239,10 +237,8 @@ export interface VibeRequest extends IncomingMessage {
239
237
  body: Record<string, any>;
240
238
  /** Uploaded files array (if multipart/form-data) */
241
239
  files?: UploadedFile[];
242
- /** Client IP address */
240
+ /** Real client IP — first entry from x-forwarded-for, x-real-ip, or socket address */
243
241
  ip?: string;
244
- /** Detailed client IP info */
245
- fullIp?: string;
246
242
  /** Automatically generated UUID for the request lifecycle */
247
243
  id: string;
248
244
  /** Context-bound logger automatically stamped with the req.id constraint */
@@ -304,9 +300,58 @@ export type Interceptor = (
304
300
  res: VibeResponse,
305
301
  ) => boolean | void | Promise<boolean | void>;
306
302
 
303
+ /**
304
+ * Scoped app interface passed to register() plugin callbacks.
305
+ * A subset of VibeApp — excludes server-level methods that make no sense
306
+ * inside an encapsulated plugin (listen, logRoutes, setPublicFolder, include).
307
+ */
308
+ export interface ScopedVibeApp {
309
+ get: RouteRegistrar;
310
+ post: RouteRegistrar;
311
+ put: RouteRegistrar;
312
+ del: RouteRegistrar;
313
+ patch: RouteRegistrar;
314
+ head: RouteRegistrar;
315
+
316
+ /** Register a global interceptor within this plugin scope */
317
+ plugin: (interceptor: Interceptor) => void;
318
+
319
+ /** Register a nested plugin */
320
+ register: (fn: PluginCallback, opts?: RegisterOptions) => Promise<void>;
321
+
322
+ /** Decorate the app with a custom property */
323
+ decorate: (name: string, value: any) => void;
324
+
325
+ /** Decorate request objects with a custom property */
326
+ decorateRequest: (name: string, value: any) => void;
327
+
328
+ /** Decorate response objects with a custom property */
329
+ decorateReply: (name: string, value: any) => void;
330
+
331
+ /** Override the error handler for this plugin scope */
332
+ setErrorHandler: (
333
+ fn: (error: Error, req: VibeRequest, res: VibeResponse) => void,
334
+ ) => void;
335
+
336
+ /** Structured logger — app.log.info(), .warn(), .error() etc. */
337
+ log: LoggerAPI;
338
+
339
+ /** Alias for log */
340
+ logger: LoggerAPI;
341
+
342
+ /** Legacy colorized string logger */
343
+ logLegacy: (
344
+ value: any,
345
+ typeOrColor?: ColorName | "info" | "error" | "warn" | "req",
346
+ ) => void;
347
+
348
+ /** Any decorators registered via decorate() are available as direct properties */
349
+ [key: string]: any;
350
+ }
351
+
307
352
  /** Plugin callback function (Fastify-style) */
308
353
  export type PluginCallback = (
309
- app: VibeApp,
354
+ app: ScopedVibeApp,
310
355
  opts: RegisterOptions,
311
356
  ) => void | Promise<void>;
312
357
 
@@ -393,11 +438,23 @@ export interface RouterAPI {
393
438
  head: RouteRegistrar;
394
439
 
395
440
  /**
396
- * Log helper supporting native colors and Vibe-stylized log levels
397
- * @param value The message or object to log
398
- * @param typeOrColor Optional color name (e.g. 'green') or level ('info', 'warn', 'error', 'req')
441
+ * Pino/Fastify-compatible structured logger.
442
+ * Available inside both `app.register()` plugins and `app.include()` sub-routers.
443
+ * @example
444
+ * api.log.info("Route registered");
445
+ * api.log.warn({ userId: 1 }, "Slow query");
446
+ */
447
+ log: LoggerAPI;
448
+
449
+ /** Alias for `log` — consistent with `app.logger` */
450
+ logger: LoggerAPI;
451
+
452
+ /**
453
+ * Legacy colorized string logger (plain terminal output).
454
+ * @param value The message to log
455
+ * @param typeOrColor Optional color name (e.g. 'green', 'red')
399
456
  */
400
- log: (
457
+ logLegacy: (
401
458
  value: any,
402
459
  typeOrColor?: ColorName | "info" | "error" | "warn" | "req",
403
460
  ) => void;
@@ -489,6 +546,17 @@ export interface VibeApp extends RouterAPI {
489
546
 
490
547
  /** Alias for `app.log` */
491
548
  logger: LoggerAPI;
549
+
550
+ /**
551
+ * Legacy colorized string logger (plain terminal output).
552
+ * Bypasses the Pino JSON interface — for simple dev-time messages.
553
+ * @param value The message to log
554
+ * @param typeOrColor Optional color name (e.g. 'green', 'red')
555
+ */
556
+ logLegacy: (
557
+ value: any,
558
+ typeOrColor?: ColorName | "info" | "error" | "warn" | "req",
559
+ ) => void;
492
560
  }
493
561
 
494
562
  /**
@@ -648,6 +716,98 @@ export function parseJsonStream(
648
716
  onError?: (err: Error) => void,
649
717
  ): void;
650
718
 
719
+ // ==========================================
720
+ // Rate Limiting
721
+ // ==========================================
722
+
723
+ export interface RateLimitOptions {
724
+ /** Maximum number of requests allowed per window */
725
+ max: number;
726
+ /** Window duration in milliseconds. Default: 60000 (1 minute) */
727
+ window?: number;
728
+ /**
729
+ * Custom function to derive the rate limit key from the request.
730
+ * Defaults to req.ip.
731
+ * @example keyBy: (req) => req.headers["authorization"] // limit per token
732
+ */
733
+ keyBy?: (req: VibeRequest) => string;
734
+ /** Custom message sent when limit is exceeded. Default: "Too Many Requests" */
735
+ message?: string;
736
+ /** HTTP status code when limit is exceeded. Default: 429 */
737
+ statusCode?: number;
738
+ /**
739
+ * Function to skip rate limiting for certain requests.
740
+ * Return true to bypass the limiter.
741
+ * @example skip: (req) => req.ip === "127.0.0.1"
742
+ */
743
+ skip?: (req: VibeRequest) => boolean;
744
+ }
745
+
746
+ /**
747
+ * Creates a sliding window rate limiter interceptor.
748
+ *
749
+ * Works as a global plugin or a per-route interceptor.
750
+ * Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers.
751
+ * Returns 429 with a Retry-After header when the limit is exceeded.
752
+ *
753
+ * @example
754
+ * // Global rate limit
755
+ * import { rateLimit } from "vibe-gx";
756
+ * app.plugin(rateLimit({ max: 100, window: 60_000 }));
757
+ *
758
+ * @example
759
+ * // Per-route (tight limit on login)
760
+ * app.post("/auth/login", { intercept: rateLimit({ max: 5, window: 60_000 }) }, handler);
761
+ */
762
+ export function rateLimit(options: RateLimitOptions): Interceptor;
763
+
764
+ // ==========================================
765
+ // CORS
766
+ // ==========================================
767
+
768
+ export interface CorsOptions {
769
+ /**
770
+ * Allowed origin(s). Can be:
771
+ * - `"*"` to allow all origins
772
+ * - A single origin string e.g. `"https://myapp.com"`
773
+ * - An array of allowed origins
774
+ * - A function `(origin: string) => boolean` for dynamic allow/deny
775
+ * Default: `"*"`
776
+ */
777
+ origin?: string | string[] | ((origin: string) => boolean);
778
+ /** Allowed HTTP methods. Default: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS */
779
+ methods?: string[];
780
+ /** Headers the browser is allowed to send. Default: Content-Type, Authorization */
781
+ allowedHeaders?: string[];
782
+ /** Headers exposed to the browser in the response */
783
+ exposedHeaders?: string[];
784
+ /** Allow cookies and Authorization headers. Default: false */
785
+ credentials?: boolean;
786
+ /** Seconds to cache the preflight response. Reduces OPTIONS calls from the browser */
787
+ maxAge?: number;
788
+ }
789
+
790
+ /**
791
+ * Creates a CORS interceptor.
792
+ *
793
+ * Handles OPTIONS preflight requests automatically and sets
794
+ * Access-Control-* headers on every cross-origin response.
795
+ *
796
+ * @example
797
+ * import { cors } from "vibe-gx";
798
+ *
799
+ * // Allow all origins
800
+ * app.plugin(cors());
801
+ *
802
+ * // Specific origin with credentials
803
+ * app.plugin(cors({ origin: "https://myapp.com", credentials: true }));
804
+ *
805
+ * // Multiple origins
806
+ * app.plugin(cors({ origin: ["https://myapp.com", "https://admin.myapp.com"] }));
807
+ */
808
+ export function cors(options?: CorsOptions): Interceptor;
809
+
810
+
651
811
  // ==========================================
652
812
  // Express Middleware Adapter
653
813
  // ==========================================
@@ -669,11 +829,3 @@ export function adapt(
669
829
  mw: (req: any, res: any, next: (err?: any) => void) => void,
670
830
  ): Interceptor;
671
831
 
672
- /**
673
- * Adapt multiple Express middlewares at once.
674
- * @param middlewares - Express middleware functions
675
- * @returns Array of Vibe-compatible interceptors
676
- */
677
- export function adaptAll(
678
- ...middlewares: Array<(req: any, res: any, next: (err?: any) => void) => void>
679
- ): Interceptor[];
package/vibe.js CHANGED
@@ -37,7 +37,6 @@ function pathToRegex(path) {
37
37
  * size: number
38
38
  * }>,
39
39
  * ip?: string,
40
- * fullIp?: string
41
40
  * }} VibeRequest
42
41
  */
43
42
 
@@ -134,9 +133,12 @@ function pathToRegex(path) {
134
133
  * Initializes a Vibe application instance.
135
134
  * @param {Object} [config={}]
136
135
  * @param {Object|boolean} [config.logger] - Logger configuration
137
- * @param {boolean} [config.autoRestart] - Restart server automatically on crash
138
136
  * @returns {VibeApp}
139
137
  */
138
+ // Guard to ensure process-level listeners are only registered once
139
+ // across multiple vibe() instances (e.g. in test environments)
140
+ let _processListenersInstalled = false;
141
+
140
142
  const vibe = (config = {}) => {
141
143
  // Route trie for O(log n) matching (used when routes > threshold)
142
144
  const trie = new RouteTrie();
@@ -145,7 +147,7 @@ const vibe = (config = {}) => {
145
147
  const routes = [];
146
148
 
147
149
  // Threshold for switching between linear and trie matching
148
- const TRIE_THRESHOLD = 50;
150
+ const TRIE_THRESHOLD = 40;
149
151
 
150
152
  // Static routes Map for O(1) lookup (routes without params)
151
153
  const staticRoutes = new Map();
@@ -175,21 +177,24 @@ const vibe = (config = {}) => {
175
177
  errorHandler: handleError,
176
178
  };
177
179
 
178
- // Add global uncaught exception handler to prevent silent deaths and log cleanly
179
- process.on("uncaughtException", (err) => {
180
- appLogger.fatal(err, "Uncaught Exception crashed the server");
181
- // Only exit here if we're not exiting gracefully anyway,
182
- // cluster manager will restart it.
183
- setTimeout(() => process.exit(1), 100);
184
- });
180
+ // Register global process listeners once only (prevents listener leak
181
+ // when vibe() is called multiple times e.g. in tests)
182
+ if (!_processListenersInstalled) {
183
+ _processListenersInstalled = true;
185
184
 
186
- process.on("unhandledRejection", (reason, promise) => {
187
- appLogger.fatal(
188
- { err: reason },
189
- "Unhandled Promise Rejection crashed the server",
190
- );
191
- setTimeout(() => process.exit(1), 100);
192
- });
185
+ process.on("uncaughtException", (err) => {
186
+ appLogger.fatal(err, "Uncaught Exception crashed the server");
187
+ setTimeout(() => process.exit(1), 100);
188
+ });
189
+
190
+ process.on("unhandledRejection", (reason) => {
191
+ appLogger.fatal(
192
+ { err: reason },
193
+ "Unhandled Promise Rejection crashed the server",
194
+ );
195
+ setTimeout(() => process.exit(1), 100);
196
+ });
197
+ }
193
198
 
194
199
  // Register default landing route
195
200
  const defaultRoute = {
@@ -395,13 +400,7 @@ const vibe = (config = {}) => {
395
400
  host = undefined;
396
401
  }
397
402
 
398
- const startServer = () => server(options, Number(port), host, callback);
399
-
400
- if (config.autoRestart) {
401
- clusterize(startServer, { workers: 1, restart: true });
402
- } else {
403
- startServer();
404
- }
403
+ server(options, Number(port), host, callback);
405
404
  }
406
405
 
407
406
  /**
@@ -430,21 +429,34 @@ const vibe = (config = {}) => {
430
429
  decorateRequest,
431
430
  decorateReply,
432
431
  register,
433
- log,
432
+ log: appLogger, // Structured logger (api.log.info / warn / error etc.)
433
+ logger: appLogger, // Alias — consistent with root app.logger
434
+ logLegacy: log, // Legacy colorized string logger (api.logLegacy(msg, color))
435
+ setErrorHandler: (fn) => { options.errorHandler = fn; },
434
436
  // Expose decorators
435
437
  ...options.decorators,
436
438
  };
437
439
 
438
- // Execute plugin
440
+ // Execute plugin — invoke fn() synchronously first so all route
441
+ // registrations that happen synchronously inside the plugin use the
442
+ // correct prefix. Restore currentPrefix IMMEDIATELY after the call
443
+ // (before any await) so that concurrent un-awaited register() calls
444
+ // from the caller cannot inherit this plugin's prefix.
445
+ let result;
439
446
  try {
440
- const result = fn(scopedApp, opts);
441
- if (result && result.then) {
442
- await result;
443
- }
447
+ result = fn(scopedApp, opts);
444
448
  } finally {
445
- // Restore prefix
449
+ // Restore prefix synchronously — this is the critical fix.
450
+ // If fn() is async its routes should already be registered
451
+ // synchronously at the top of the function; the await below is
452
+ // only needed to propagate rejections.
446
453
  currentPrefix = previousPrefix;
447
454
  }
455
+
456
+ // Await async plugins for error propagation only (prefix already restored)
457
+ if (result && typeof result.then === "function") {
458
+ await result;
459
+ }
448
460
  }
449
461
 
450
462
  /**
@@ -476,7 +488,9 @@ const vibe = (config = {}) => {
476
488
  del: wrap("DELETE"),
477
489
  patch: wrap("PATCH"),
478
490
  head: wrap("HEAD"),
479
- log,
491
+ log: appLogger, // Structured logger — consistent with app.log
492
+ logger: appLogger, // Alias
493
+ logLegacy: log, // Legacy colorized string logger
480
494
  plugin,
481
495
  };
482
496
  }
@@ -630,3 +644,6 @@ export {
630
644
  export { LRUCache, cacheMiddleware } from "./utils/scaling/cache.js";
631
645
  export { Pool, createPool } from "./utils/scaling/pool.js";
632
646
  export { parseJsonStream } from "./utils/core/parser.js";
647
+ export { rateLimit } from "./utils/scaling/rate-limit.js";
648
+ export { cors } from "./utils/helpers/cors.js";
649
+