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 +74 -14
- package/package.json +1 -1
- package/utils/core/logger.js +24 -5
- package/utils/core/server.js +38 -7
- package/utils/helpers/cors.js +124 -0
- package/utils/scaling/rate-limit.js +113 -0
- package/vibe.d.ts +98 -0
- package/vibe.js +44 -7
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` |
|
|
608
|
-
| `req.log` |
|
|
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` |
|
|
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
package/utils/core/logger.js
CHANGED
|
@@ -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.
|
|
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.
|
|
111
|
-
this.
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
package/utils/core/server.js
CHANGED
|
@@ -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
|
|
94
|
-
|
|
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
|
+
|
|
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 =
|
|
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
|
-
|
|
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
|
+
|