vibe-gx 4.1.1 → 4.1.4

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.1",
3
+ "version": "4.1.4",
4
4
  "description": "A lightweight, high-performance Node.js web framework.",
5
5
  "type": "module",
6
6
  "main": "vibe.js",
@@ -1,4 +1,5 @@
1
1
  import os from "os";
2
+ import fs from "fs";
2
3
  import { color } from "../helpers/colors.js";
3
4
 
4
5
  const LOG_LEVELS = {
@@ -25,11 +26,20 @@ const LEVEL_NAMES = {
25
26
  export class Logger {
26
27
  constructor(options = {}) {
27
28
  this.level = LOG_LEVELS[options.level || "info"] || 30;
28
- this.prettyPrint = options.prettyPrint || false;
29
+ this.colors = options.colors !== undefined ? options.colors : true;
30
+ this.prettyPrint =
31
+ options.prettyPrint !== undefined ? options.prettyPrint : this.colors;
29
32
  this.lifecycle = options.lifecycle || false;
30
33
  this.stream = options.stream || process.stdout;
34
+ this.dest = options.dest || "console"; // "console", "file", "both"
35
+ this.logFile = options.logFile;
31
36
  this.bindings = options.bindings || {};
32
37
 
38
+ // Initialize file stream if needed
39
+ if (this.logFile && (this.dest === "file" || this.dest === "both")) {
40
+ this.fileStream = fs.createWriteStream(this.logFile, { flags: "a" });
41
+ }
42
+
33
43
  if (!this.bindings.pid) this.bindings.pid = process.pid;
34
44
  if (!this.bindings.hostname) this.bindings.hostname = os.hostname();
35
45
  }
@@ -42,9 +52,12 @@ export class Logger {
42
52
  level: Object.keys(LOG_LEVELS).find(
43
53
  (key) => LOG_LEVELS[key] === this.level,
44
54
  ),
55
+ colors: this.colors,
45
56
  prettyPrint: this.prettyPrint,
46
57
  lifecycle: this.lifecycle,
47
58
  stream: this.stream,
59
+ dest: this.dest,
60
+ logFile: this.logFile,
48
61
  bindings: { ...this.bindings, ...bindings },
49
62
  });
50
63
  }
@@ -107,10 +120,16 @@ export class Logger {
107
120
 
108
121
  const finalLog = { ...base, ...logData };
109
122
 
110
- if (this.prettyPrint) {
111
- this._printPretty(finalLog);
112
- } else {
113
- this.stream.write(JSON.stringify(finalLog) + "\n");
123
+ if (this.dest === "console" || this.dest === "both") {
124
+ if (this.prettyPrint) {
125
+ this._printPretty(finalLog);
126
+ } else {
127
+ this.stream.write(JSON.stringify(finalLog) + "\n");
128
+ }
129
+ }
130
+
131
+ if ((this.dest === "file" || this.dest === "both") && this.fileStream) {
132
+ this.fileStream.write(JSON.stringify(finalLog) + "\n");
114
133
  }
115
134
  }
116
135
 
@@ -90,12 +90,40 @@ 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
- req.log.info({ type: "req" }, "Incoming request");
115
+
116
+ // Determine sender IP early for logging
117
+ const sender =
118
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
119
+ req.headers["x-real-ip"] ||
120
+ req.socket.remoteAddress ||
121
+ "unknown";
122
+
123
+ req.log.info(
124
+ { type: "req", url: req.url, method: req.method, sender },
125
+ "Incoming request",
126
+ );
99
127
 
100
128
  res.on("finish", () => {
101
129
  req.log.info(
@@ -121,6 +149,12 @@ async function server(options, port, host, callback) {
121
149
 
122
150
  req.url = pathname;
123
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
+
124
158
  // Stamp response with options ref (ONLY per-request cost for response methods)
125
159
  res._vibeOptions = options;
126
160
 
@@ -207,10 +241,7 @@ async function server(options, port, host, callback) {
207
241
  if (!(await runIntercept(interceptors, req, res))) return;
208
242
  }
209
243
 
210
- // Lazy IP
211
- if (!req.ip) {
212
- req.ip = req.socket.remoteAddress || req.headers["x-forwarded-for"];
213
- }
244
+ // Lazy IP already resolved in reqListener — no-op needed here
214
245
 
215
246
  // Route matching - FAST PATH first
216
247
  const routeKey = req.method + pathname;
@@ -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
@@ -181,6 +181,12 @@ export interface LoggerConfig {
181
181
  lifecycle?: boolean;
182
182
  /** If true, formats JSON output into human-readable Vibe-styled terminal lines (like pino-pretty) */
183
183
  prettyPrint?: boolean;
184
+ /** If true, applies ANSI color formatting to terminal logs. Default: true (unless overridden) */
185
+ colors?: boolean;
186
+ /** Destination for the logs. Accepts "console", "file", or "both". Default: "console" */
187
+ dest?: "console" | "file" | "both";
188
+ /** Absolute or relative path to the target log file. Active when dest is "file" or "both" */
189
+ logFile?: string;
184
190
  /** Custom writable stream to output logs to (defaults to process.stdout) */
185
191
  stream?: NodeJS.WritableStream;
186
192
  }
@@ -640,6 +646,98 @@ export function parseJsonStream(
640
646
  onError?: (err: Error) => void,
641
647
  ): void;
642
648
 
649
+ // ==========================================
650
+ // Rate Limiting
651
+ // ==========================================
652
+
653
+ export interface RateLimitOptions {
654
+ /** Maximum number of requests allowed per window */
655
+ max: number;
656
+ /** Window duration in milliseconds. Default: 60000 (1 minute) */
657
+ window?: number;
658
+ /**
659
+ * Custom function to derive the rate limit key from the request.
660
+ * Defaults to req.ip.
661
+ * @example keyBy: (req) => req.headers["authorization"] // limit per token
662
+ */
663
+ keyBy?: (req: VibeRequest) => string;
664
+ /** Custom message sent when limit is exceeded. Default: "Too Many Requests" */
665
+ message?: string;
666
+ /** HTTP status code when limit is exceeded. Default: 429 */
667
+ statusCode?: number;
668
+ /**
669
+ * Function to skip rate limiting for certain requests.
670
+ * Return true to bypass the limiter.
671
+ * @example skip: (req) => req.ip === "127.0.0.1"
672
+ */
673
+ skip?: (req: VibeRequest) => boolean;
674
+ }
675
+
676
+ /**
677
+ * Creates a sliding window rate limiter interceptor.
678
+ *
679
+ * Works as a global plugin or a per-route interceptor.
680
+ * Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset headers.
681
+ * Returns 429 with a Retry-After header when the limit is exceeded.
682
+ *
683
+ * @example
684
+ * // Global rate limit
685
+ * import { rateLimit } from "vibe-gx";
686
+ * app.plugin(rateLimit({ max: 100, window: 60_000 }));
687
+ *
688
+ * @example
689
+ * // Per-route (tight limit on login)
690
+ * app.post("/auth/login", { intercept: rateLimit({ max: 5, window: 60_000 }) }, handler);
691
+ */
692
+ export function rateLimit(options: RateLimitOptions): Interceptor;
693
+
694
+ // ==========================================
695
+ // CORS
696
+ // ==========================================
697
+
698
+ export interface CorsOptions {
699
+ /**
700
+ * Allowed origin(s). Can be:
701
+ * - `"*"` to allow all origins
702
+ * - A single origin string e.g. `"https://myapp.com"`
703
+ * - An array of allowed origins
704
+ * - A function `(origin: string) => boolean` for dynamic allow/deny
705
+ * Default: `"*"`
706
+ */
707
+ origin?: string | string[] | ((origin: string) => boolean);
708
+ /** Allowed HTTP methods. Default: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS */
709
+ methods?: string[];
710
+ /** Headers the browser is allowed to send. Default: Content-Type, Authorization */
711
+ allowedHeaders?: string[];
712
+ /** Headers exposed to the browser in the response */
713
+ exposedHeaders?: string[];
714
+ /** Allow cookies and Authorization headers. Default: false */
715
+ credentials?: boolean;
716
+ /** Seconds to cache the preflight response. Reduces OPTIONS calls from the browser */
717
+ maxAge?: number;
718
+ }
719
+
720
+ /**
721
+ * Creates a CORS interceptor.
722
+ *
723
+ * Handles OPTIONS preflight requests automatically and sets
724
+ * Access-Control-* headers on every cross-origin response.
725
+ *
726
+ * @example
727
+ * import { cors } from "vibe-gx";
728
+ *
729
+ * // Allow all origins
730
+ * app.plugin(cors());
731
+ *
732
+ * // Specific origin with credentials
733
+ * app.plugin(cors({ origin: "https://myapp.com", credentials: true }));
734
+ *
735
+ * // Multiple origins
736
+ * app.plugin(cors({ origin: ["https://myapp.com", "https://admin.myapp.com"] }));
737
+ */
738
+ export function cors(options?: CorsOptions): Interceptor;
739
+
740
+
643
741
  // ==========================================
644
742
  // Express Middleware Adapter
645
743
  // ==========================================
package/vibe.js CHANGED
@@ -6,6 +6,7 @@ import { PathToRegex } from "./utils/core/handler.js";
6
6
  import { compileSerializer } from "./utils/core/compile-serializer.js";
7
7
  import { createLogger, Logger } from "./utils/core/logger.js";
8
8
  import { handleError } from "./utils/core/handler.js";
9
+ import { clusterize } from "./utils/scaling/cluster.js";
9
10
 
10
11
  /**
11
12
  * Helper to generate regex for a path
@@ -135,6 +136,10 @@ function pathToRegex(path) {
135
136
  * @param {Object|boolean} [config.logger] - Logger configuration
136
137
  * @returns {VibeApp}
137
138
  */
139
+ // Guard to ensure process-level listeners are only registered once
140
+ // across multiple vibe() instances (e.g. in test environments)
141
+ let _processListenersInstalled = false;
142
+
138
143
  const vibe = (config = {}) => {
139
144
  // Route trie for O(log n) matching (used when routes > threshold)
140
145
  const trie = new RouteTrie();
@@ -143,7 +148,7 @@ const vibe = (config = {}) => {
143
148
  const routes = [];
144
149
 
145
150
  // Threshold for switching between linear and trie matching
146
- const TRIE_THRESHOLD = 50;
151
+ const TRIE_THRESHOLD = 40;
147
152
 
148
153
  // Static routes Map for O(1) lookup (routes without params)
149
154
  const staticRoutes = new Map();
@@ -173,6 +178,25 @@ const vibe = (config = {}) => {
173
178
  errorHandler: handleError,
174
179
  };
175
180
 
181
+ // Register global process listeners once only (prevents listener leak
182
+ // when vibe() is called multiple times e.g. in tests)
183
+ if (!_processListenersInstalled) {
184
+ _processListenersInstalled = true;
185
+
186
+ process.on("uncaughtException", (err) => {
187
+ appLogger.fatal(err, "Uncaught Exception crashed the server");
188
+ setTimeout(() => process.exit(1), 100);
189
+ });
190
+
191
+ process.on("unhandledRejection", (reason) => {
192
+ appLogger.fatal(
193
+ { err: reason },
194
+ "Unhandled Promise Rejection crashed the server",
195
+ );
196
+ setTimeout(() => process.exit(1), 100);
197
+ });
198
+ }
199
+
176
200
  // Register default landing route
177
201
  const defaultRoute = {
178
202
  method: "GET",
@@ -411,16 +435,26 @@ const vibe = (config = {}) => {
411
435
  ...options.decorators,
412
436
  };
413
437
 
414
- // Execute plugin
438
+ // Execute plugin — invoke fn() synchronously first so all route
439
+ // registrations that happen synchronously inside the plugin use the
440
+ // correct prefix. Restore currentPrefix IMMEDIATELY after the call
441
+ // (before any await) so that concurrent un-awaited register() calls
442
+ // from the caller cannot inherit this plugin's prefix.
443
+ let result;
415
444
  try {
416
- const result = fn(scopedApp, opts);
417
- if (result && result.then) {
418
- await result;
419
- }
445
+ result = fn(scopedApp, opts);
420
446
  } finally {
421
- // Restore prefix
447
+ // Restore prefix synchronously — this is the critical fix.
448
+ // If fn() is async its routes should already be registered
449
+ // synchronously at the top of the function; the await below is
450
+ // only needed to propagate rejections.
422
451
  currentPrefix = previousPrefix;
423
452
  }
453
+
454
+ // Await async plugins for error propagation only (prefix already restored)
455
+ if (result && typeof result.then === "function") {
456
+ await result;
457
+ }
424
458
  }
425
459
 
426
460
  /**
@@ -606,3 +640,6 @@ export {
606
640
  export { LRUCache, cacheMiddleware } from "./utils/scaling/cache.js";
607
641
  export { Pool, createPool } from "./utils/scaling/pool.js";
608
642
  export { parseJsonStream } from "./utils/core/parser.js";
643
+ export { rateLimit } from "./utils/scaling/rate-limit.js";
644
+ export { cors } from "./utils/helpers/cors.js";
645
+