vibe-gx 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENCE +21 -0
- package/README.md +170 -0
- package/package.json +46 -0
- package/utils/core/handler.js +219 -0
- package/utils/core/parser.js +242 -0
- package/utils/core/response.js +253 -0
- package/utils/core/server.js +217 -0
- package/utils/core/trie.js +218 -0
- package/utils/helpers/adapt.js +47 -0
- package/utils/helpers/colors.js +42 -0
- package/utils/helpers/mime.js +51 -0
- package/utils/scaling/cache.js +181 -0
- package/utils/scaling/cluster.js +143 -0
- package/utils/scaling/pool.js +243 -0
- package/vibe.d.ts +260 -0
- package/vibe.js +528 -0
package/LICENCE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nnamdi "Joe" Amaga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="./assets/vlogo.png" alt="Vibe Logo" width="180" />
|
|
3
|
+
<h1>Vibe</h1>
|
|
4
|
+
<p>
|
|
5
|
+
<b>A lightweight, high-performance Node.js web framework built for speed and scalability.</b>
|
|
6
|
+
</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
Vibe (part of the **GeNeSix** ecosystem) is a zero-dependency\* web framework with **Radix Trie routing**, **cluster mode**, **response caching**, and a **Fastify-style plugin system**.
|
|
12
|
+
|
|
13
|
+
> **Dependency Note:** The only dependency is `busboy` for multipart file parsing.
|
|
14
|
+
|
|
15
|
+
## ⚡ Features
|
|
16
|
+
|
|
17
|
+
| Feature | Description |
|
|
18
|
+
| :----------------------- | :-------------------------------------------- |
|
|
19
|
+
| 🚀 **Radix Trie Router** | O(log n) route matching with hybrid mode |
|
|
20
|
+
| 🔌 **Plugin System** | Fastify-style `register()` with encapsulation |
|
|
21
|
+
| 🎨 **Decorators** | Extend app, request, and response |
|
|
22
|
+
| ⚡ **Cluster Mode** | Multi-process scaling |
|
|
23
|
+
| 💾 **Response Caching** | LRU cache with ETag support |
|
|
24
|
+
| 🔗 **Connection Pool** | Generic pool for database connections |
|
|
25
|
+
| 📂 **Streaming** | Stream large files without buffering |
|
|
26
|
+
|
|
27
|
+
## 🚀 Quick Start
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
import vibe from "./vibe.js";
|
|
31
|
+
|
|
32
|
+
const app = vibe();
|
|
33
|
+
|
|
34
|
+
app.get("/", "Hello Vibe!");
|
|
35
|
+
app.get("/users/:id", (req, res) => ({ userId: req.params.id }));
|
|
36
|
+
|
|
37
|
+
app.listen(3000);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 📖 Core API
|
|
41
|
+
|
|
42
|
+
### Routes
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
app.get("/path", handler);
|
|
46
|
+
app.post("/path", { intercept: authMiddleware }, handler);
|
|
47
|
+
app.del("/path", handler); // DELETE
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Plugins (Fastify-style)
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
app.register(
|
|
54
|
+
async (app) => {
|
|
55
|
+
app.get("/status", { status: "ok" });
|
|
56
|
+
},
|
|
57
|
+
{ prefix: "/api" },
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Decorators
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
app.decorate("config", { env: "prod" });
|
|
65
|
+
app.decorateRequest("user", null);
|
|
66
|
+
app.decorateReply("sendSuccess", function (d) {
|
|
67
|
+
this.success(d);
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 🔥 Scalability Features
|
|
74
|
+
|
|
75
|
+
### Cluster Mode
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
import vibe, { clusterize } from "./vibe.js";
|
|
79
|
+
|
|
80
|
+
clusterize(
|
|
81
|
+
() => {
|
|
82
|
+
const app = vibe();
|
|
83
|
+
app.get("/", "Hello from worker!");
|
|
84
|
+
app.listen(3000);
|
|
85
|
+
},
|
|
86
|
+
{ workers: 4, restart: true },
|
|
87
|
+
);
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Response Caching
|
|
91
|
+
|
|
92
|
+
```javascript
|
|
93
|
+
import vibe, { LRUCache, cacheMiddleware } from "./vibe.js";
|
|
94
|
+
|
|
95
|
+
const app = vibe();
|
|
96
|
+
const cache = new LRUCache({ max: 1000, ttl: 60000 });
|
|
97
|
+
|
|
98
|
+
app.get("/data", { intercept: cacheMiddleware(cache) }, () => {
|
|
99
|
+
return { expensive: "computation" };
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Connection Pool
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
import vibe, { createPool } from "./vibe.js";
|
|
107
|
+
|
|
108
|
+
const dbPool = createPool({
|
|
109
|
+
create: async () => new DBConnection(),
|
|
110
|
+
destroy: async (conn) => conn.close(),
|
|
111
|
+
max: 10,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.decorate("db", dbPool);
|
|
115
|
+
|
|
116
|
+
app.get("/users", async (req, res) => {
|
|
117
|
+
return await app.decorators.db.use(async (conn) => {
|
|
118
|
+
return conn.query("SELECT * FROM users");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Streaming Uploads
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
app.post("/upload", { media: { streaming: true } }, (req, res) => {
|
|
127
|
+
req.on("file", (name, stream, info) => {
|
|
128
|
+
stream.pipe(fs.createWriteStream(`/uploads/${info.filename}`));
|
|
129
|
+
});
|
|
130
|
+
return { status: "uploading" };
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## 🛠️ API Reference
|
|
137
|
+
|
|
138
|
+
### Application
|
|
139
|
+
|
|
140
|
+
| Method | Description |
|
|
141
|
+
| :------------------------ | :---------------- |
|
|
142
|
+
| `app.listen(port)` | Start server |
|
|
143
|
+
| `app.register(fn, opts)` | Register plugin |
|
|
144
|
+
| `app.decorate(name, val)` | Add app property |
|
|
145
|
+
| `app.plugin(fn)` | Global middleware |
|
|
146
|
+
|
|
147
|
+
### Request (`req`)
|
|
148
|
+
|
|
149
|
+
| Property | Description |
|
|
150
|
+
| :----------- | :--------------- |
|
|
151
|
+
| `req.params` | Route parameters |
|
|
152
|
+
| `req.query` | Query strings |
|
|
153
|
+
| `req.body` | Parsed body |
|
|
154
|
+
| `req.files` | Uploaded files |
|
|
155
|
+
|
|
156
|
+
### Response (`res`)
|
|
157
|
+
|
|
158
|
+
| Method | Description |
|
|
159
|
+
| :------------------ | :------------ |
|
|
160
|
+
| `res.json(data)` | Send JSON |
|
|
161
|
+
| `res.send(data)` | Send response |
|
|
162
|
+
| `res.status(code)` | Set status |
|
|
163
|
+
| `res.success(data)` | 200 OK |
|
|
164
|
+
| `res.notFound()` | 404 |
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 📝 License
|
|
169
|
+
|
|
170
|
+
Part of the **GeNeSix** brand. Created by **Nnamdi "Joe" Amaga**. MIT License.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vibe-gx",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "A lightweight, regex-based Node.js web framework built for speed and simplicity.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "vibe.js",
|
|
7
|
+
"types": "vibe.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./vibe.js",
|
|
11
|
+
"types": "./vibe.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"vibe.js",
|
|
16
|
+
"vibe.d.ts",
|
|
17
|
+
"utils/"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "echo \"No tests yet\"",
|
|
21
|
+
"start": "node server.js"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"node",
|
|
25
|
+
"framework",
|
|
26
|
+
"http",
|
|
27
|
+
"router",
|
|
28
|
+
"middleware",
|
|
29
|
+
"web",
|
|
30
|
+
"backend",
|
|
31
|
+
"regex-router",
|
|
32
|
+
"express-alternative"
|
|
33
|
+
],
|
|
34
|
+
"author": "Nnamdi \"Joe\" Amaga",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"busboy": "^1.6.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"express": "^5.2.1",
|
|
44
|
+
"fastify": "^5.7.4"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { color } from "../helpers/colors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parses query string from URL into an object.
|
|
6
|
+
* @param {string} url
|
|
7
|
+
* @returns {Object}
|
|
8
|
+
*/
|
|
9
|
+
export function extractQuery(url) {
|
|
10
|
+
const query = {};
|
|
11
|
+
if (!url.includes("?")) return query;
|
|
12
|
+
for (const rq of url.split("?")[1].split("&")) {
|
|
13
|
+
const parts = rq.split("=");
|
|
14
|
+
if (parts.length === 2) {
|
|
15
|
+
query[parts[0]] = parts[1];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return query;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Extracts raw parameters from URL based on route definition.
|
|
23
|
+
* @param {string} routePath
|
|
24
|
+
* @param {string} requestPath
|
|
25
|
+
* @returns {Object}
|
|
26
|
+
*/
|
|
27
|
+
export function extractParams(routePath, requestPath) {
|
|
28
|
+
const routeSegments = routePath.split("/").filter(Boolean);
|
|
29
|
+
const requestSegments = requestPath.split("/").filter(Boolean);
|
|
30
|
+
const params = {};
|
|
31
|
+
|
|
32
|
+
routeSegments.forEach((segment, index) => {
|
|
33
|
+
if (segment.startsWith(":")) {
|
|
34
|
+
const paramName = segment.slice(1);
|
|
35
|
+
params[paramName] = requestSegments[index];
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return params;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks if the request URL matches the route Regex.
|
|
44
|
+
* @param {RegExp} pathRegex
|
|
45
|
+
* @param {string} requestPath
|
|
46
|
+
* @returns {RegExpExecArray | null}
|
|
47
|
+
*/
|
|
48
|
+
export function matchPath(pathRegex, requestPath) {
|
|
49
|
+
return pathRegex.exec(requestPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Converts a route path string (e.g., "/users/:id") into a RegExp.
|
|
54
|
+
* Captures named groups for parameters.
|
|
55
|
+
* * @param {string} path - The path to register
|
|
56
|
+
* @returns {{ pathRegex: RegExp, paramKeys: string[] }}
|
|
57
|
+
*/
|
|
58
|
+
export function PathToRegex(path) {
|
|
59
|
+
const pathSegments = path.split("/").filter(Boolean);
|
|
60
|
+
const paramKeys = [];
|
|
61
|
+
|
|
62
|
+
// Handle root path specially
|
|
63
|
+
if (pathSegments.length === 0) {
|
|
64
|
+
return { pathRegex: /^\/$/, paramKeys: [] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let pathRegex = "^";
|
|
68
|
+
for (let index = 0; index < pathSegments.length; index++) {
|
|
69
|
+
const segment = pathSegments[index];
|
|
70
|
+
if (segment.startsWith(":")) {
|
|
71
|
+
paramKeys.push(segment.slice(1));
|
|
72
|
+
pathRegex += `/(?<${segment.slice(1)}>[^/]+)`;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (segment === "*") {
|
|
77
|
+
pathRegex += "/(.*)";
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pathRegex += `/${segment}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pathRegex += "$";
|
|
85
|
+
pathRegex = new RegExp(pathRegex);
|
|
86
|
+
|
|
87
|
+
return { pathRegex, paramKeys };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Validates if data is safe to send via HTTP (string, number, boolean, object).
|
|
92
|
+
* @param {any} value
|
|
93
|
+
* @returns {boolean}
|
|
94
|
+
*/
|
|
95
|
+
export function isSendAble(value) {
|
|
96
|
+
return (
|
|
97
|
+
(value !== null && typeof value === "object") ||
|
|
98
|
+
typeof value === "string" ||
|
|
99
|
+
typeof value === "number" ||
|
|
100
|
+
typeof value === "boolean"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Executes a list of interceptor functions (middleware).
|
|
106
|
+
* Stops execution if response is ended.
|
|
107
|
+
* * @param {Function | Function[]} intercept - Single function or array of functions
|
|
108
|
+
* @param {import("../vibe.js").VibeRequest} req
|
|
109
|
+
* @param {import("../vibe.js").VibeResponse} res
|
|
110
|
+
* @param {boolean} [isRoute=true] - Context flag for error messages
|
|
111
|
+
* @returns {Promise<boolean>} - Returns false if response ended, true otherwise
|
|
112
|
+
*/
|
|
113
|
+
export async function runIntercept(intercept, req, res, isRoute = true) {
|
|
114
|
+
if (!intercept || (Array.isArray(intercept) && intercept.length === 0))
|
|
115
|
+
return true;
|
|
116
|
+
|
|
117
|
+
const funcs = Array.isArray(intercept) ? intercept : [intercept];
|
|
118
|
+
|
|
119
|
+
for (const func of funcs) {
|
|
120
|
+
if (typeof func !== "function") {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`All ${isRoute ? "Route" : "Global"} intercepts must be functions`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await func(req, res);
|
|
127
|
+
|
|
128
|
+
if (res.writableEnded) return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Centralized error handler for routes.
|
|
136
|
+
* In production, only logs error message (not full stack).
|
|
137
|
+
* @param {Error} error
|
|
138
|
+
* @param {import("../../vibe.js").VibeRequest} req
|
|
139
|
+
* @param {import("../../vibe.js").VibeResponse} res
|
|
140
|
+
*/
|
|
141
|
+
export function handleError(error, req, res) {
|
|
142
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
143
|
+
|
|
144
|
+
// Log error (full stack in dev, message only in production)
|
|
145
|
+
if (isDev) {
|
|
146
|
+
console.error("[VIBE ERROR]:", error);
|
|
147
|
+
} else {
|
|
148
|
+
console.error("[VIBE ERROR]:", error.message || "Unknown error");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!res.headersSent) {
|
|
152
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
153
|
+
|
|
154
|
+
// Only expose error details in development
|
|
155
|
+
const responseBody = isDev
|
|
156
|
+
? { error: "Internal Server Error", message: error.message }
|
|
157
|
+
: { error: "Internal Server Error" };
|
|
158
|
+
|
|
159
|
+
res.end(JSON.stringify(responseBody));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Finds the local network IP address (IPv4)
|
|
165
|
+
* @param {string} host
|
|
166
|
+
* @param {number} port
|
|
167
|
+
* @returns {void}
|
|
168
|
+
*/
|
|
169
|
+
export function getNetworkIP(host, port) {
|
|
170
|
+
const interfaces = os.networkInterfaces();
|
|
171
|
+
const addresses = [];
|
|
172
|
+
|
|
173
|
+
for (const name of Object.keys(interfaces)) {
|
|
174
|
+
addresses.push(
|
|
175
|
+
...interfaces[name]
|
|
176
|
+
.map((iface) =>
|
|
177
|
+
iface.address === "::1"
|
|
178
|
+
? { address: "[::1]", fam: iface.family }
|
|
179
|
+
: { address: iface.address, fam: iface.family },
|
|
180
|
+
)
|
|
181
|
+
.filter((addr) => !addr.address.startsWith("fe80")),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const addrs of addresses) {
|
|
186
|
+
if (host === "0.0.0.0") {
|
|
187
|
+
// => listens on all ipv4 hosts
|
|
188
|
+
if (addrs.fam === "IPv4")
|
|
189
|
+
log(`Server listening at - \x1b[4mhttp://${addrs.address}:${port}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (host === "::") {
|
|
193
|
+
// => listens on all ipv6/ipv4 hosts
|
|
194
|
+
log(`Server listening at - \x1b[4mhttp://${addrs.address}:${port}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (addrs.address === host) {
|
|
198
|
+
log(`Server listening at - \x1b[4mhttp://${addrs.address}:${port}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Logs a message with a prefix.
|
|
205
|
+
* @param {string} message
|
|
206
|
+
*/
|
|
207
|
+
export function log(message) {
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
`${color.green("[VIBE LOG]:")} ${color.bright(message)}\n`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Logs an error with a prefix.
|
|
215
|
+
* @param {string} message
|
|
216
|
+
*/
|
|
217
|
+
export function error(message) {
|
|
218
|
+
process.stderr.write(`${color.red(`[VIBE ERROR]: ${message}`)}\n`);
|
|
219
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import busboy from "busboy";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { EventEmitter } from "events";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default streaming threshold (1MB)
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_STREAM_THRESHOLD = 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parses incoming request bodies.
|
|
14
|
+
* Supports JSON and multipart/form-data (file uploads).
|
|
15
|
+
*
|
|
16
|
+
* Streaming mode: For large files, emits events instead of buffering:
|
|
17
|
+
* - req.emit("file", fieldName, stream, info) for each file
|
|
18
|
+
*
|
|
19
|
+
* @param {import("../vibe.js").VibeRequest} req - Incoming request
|
|
20
|
+
* @param {import("../vibe.js").VibeResponse} res - Response object
|
|
21
|
+
* @param {import("../vibe.js").MediaOptions} [media={}] - Route-specific file config
|
|
22
|
+
* @param {import("../vibe.js").VibeConfig} [options={}] - Global framework config
|
|
23
|
+
* @returns {Promise<void>} Resolves when parsing completes
|
|
24
|
+
*/
|
|
25
|
+
export default function bodyParser(req, res, media = {}, options = {}) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const contentType = req.headers["content-type"];
|
|
28
|
+
if (!contentType) return resolve();
|
|
29
|
+
|
|
30
|
+
req.body ||= {};
|
|
31
|
+
req.files ||= [];
|
|
32
|
+
|
|
33
|
+
/* ---------- Multipart / File Uploads ---------- */
|
|
34
|
+
if (contentType.includes("multipart/form-data")) {
|
|
35
|
+
parseMultipart(req, res, media, options, resolve, reject);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* ---------- JSON ---------- */
|
|
40
|
+
if (contentType.includes("application/json")) {
|
|
41
|
+
parseJson(req, res, media, options, resolve, reject);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Other content-types are ignored
|
|
46
|
+
resolve();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse multipart/form-data with optional streaming support
|
|
52
|
+
*/
|
|
53
|
+
function parseMultipart(req, res, media, options, resolve, reject) {
|
|
54
|
+
let bb;
|
|
55
|
+
let fileError = null;
|
|
56
|
+
const streaming = media.streaming === true;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
bb = busboy({
|
|
60
|
+
headers: req.headers,
|
|
61
|
+
limits: {
|
|
62
|
+
fileSize: media.maxSize || 10 * 1024 * 1024,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
console.error("Busboy init failed:", err);
|
|
67
|
+
return resolve();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
bb.on("field", (name, value) => {
|
|
71
|
+
req.body[name] = value;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
bb.on("file", (name, file, info) => {
|
|
75
|
+
const { filename, mimeType } = info;
|
|
76
|
+
if (!filename) return file.resume();
|
|
77
|
+
|
|
78
|
+
// File type validation
|
|
79
|
+
if (media.allowedTypes && Array.isArray(media.allowedTypes)) {
|
|
80
|
+
if (!media.allowedTypes.includes(mimeType)) {
|
|
81
|
+
fileError = new Error(
|
|
82
|
+
`File type '${mimeType}' not allowed. Allowed: ${media.allowedTypes.join(", ")}`,
|
|
83
|
+
);
|
|
84
|
+
return file.resume();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// STREAMING MODE: Emit file event, let handler deal with it
|
|
89
|
+
if (streaming) {
|
|
90
|
+
req.emit("file", name, file, { filename, mimeType });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// BUFFERING MODE: Write to disk
|
|
95
|
+
const parent = media.public ? options.publicFolder || "" : "";
|
|
96
|
+
const dest = path.resolve(
|
|
97
|
+
path.join(parent, media.dest || (media.public ? "uploads" : "private")),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Prevent path traversal
|
|
101
|
+
if (
|
|
102
|
+
media.public &&
|
|
103
|
+
!dest.startsWith(path.resolve(options.publicFolder || ""))
|
|
104
|
+
) {
|
|
105
|
+
console.warn("Attempted upload outside public folder, skipping");
|
|
106
|
+
return file.resume();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.error("Failed to create upload folder:", err);
|
|
113
|
+
return file.resume();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ext =
|
|
117
|
+
path.extname(filename) ||
|
|
118
|
+
(mimeType?.includes("/") ? "." + mimeType.split("/")[1] : "");
|
|
119
|
+
|
|
120
|
+
const safeName = `${path.basename(filename, ext)}-${crypto
|
|
121
|
+
.randomBytes(3)
|
|
122
|
+
.toString("hex")}${ext}`;
|
|
123
|
+
const filePath = path.join(dest, safeName);
|
|
124
|
+
|
|
125
|
+
const writeStream = fs.createWriteStream(filePath);
|
|
126
|
+
let size = 0;
|
|
127
|
+
let truncated = false;
|
|
128
|
+
|
|
129
|
+
file.on("data", (d) => (size += d.length));
|
|
130
|
+
|
|
131
|
+
// Handle file size limit exceeded
|
|
132
|
+
file.on("limit", () => {
|
|
133
|
+
truncated = true;
|
|
134
|
+
fileError = new Error(
|
|
135
|
+
`File '${filename}' exceeds max size of ${media.maxSize || 10 * 1024 * 1024} bytes`,
|
|
136
|
+
);
|
|
137
|
+
file.unpipe(writeStream);
|
|
138
|
+
writeStream.end();
|
|
139
|
+
// Clean up partial file
|
|
140
|
+
fs.unlink(filePath, () => {});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
file.on("error", (err) => {
|
|
144
|
+
console.error("File stream error:", err);
|
|
145
|
+
writeStream.end();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
writeStream.on("error", (err) => {
|
|
149
|
+
console.error("Write stream error:", err);
|
|
150
|
+
file.resume();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
writeStream.on("finish", () => {
|
|
154
|
+
if (!truncated) {
|
|
155
|
+
req.files.push({
|
|
156
|
+
filename: safeName,
|
|
157
|
+
originalName: filename,
|
|
158
|
+
type: mimeType,
|
|
159
|
+
filePath,
|
|
160
|
+
size,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
file.pipe(writeStream);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
bb.on("error", (err) => {
|
|
169
|
+
console.error("Busboy error:", err);
|
|
170
|
+
req.unpipe(bb);
|
|
171
|
+
reject(err);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
bb.on("finish", () => {
|
|
175
|
+
if (fileError) {
|
|
176
|
+
reject(fileError);
|
|
177
|
+
} else {
|
|
178
|
+
resolve();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
req.pipe(bb);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse JSON body with streaming support for large payloads
|
|
187
|
+
*/
|
|
188
|
+
function parseJson(req, res, media, options, resolve, reject) {
|
|
189
|
+
const limit = options.maxJsonSize || 1e6;
|
|
190
|
+
const streamThreshold = media.streamThreshold || DEFAULT_STREAM_THRESHOLD;
|
|
191
|
+
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
|
|
192
|
+
|
|
193
|
+
// STREAMING MODE: For very large JSON, let handler process incrementally
|
|
194
|
+
if (media.streaming && contentLength > streamThreshold) {
|
|
195
|
+
req.body = null; // Signal that body should be consumed via stream
|
|
196
|
+
req.emit("jsonStream", req);
|
|
197
|
+
resolve();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// BUFFERING MODE: Collect and parse
|
|
202
|
+
let body = "";
|
|
203
|
+
|
|
204
|
+
req.on("data", (chunk) => {
|
|
205
|
+
body += chunk;
|
|
206
|
+
if (body.length > limit) {
|
|
207
|
+
console.warn("JSON payload too large, destroying connection");
|
|
208
|
+
req.destroy();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
req.on("end", () => {
|
|
213
|
+
try {
|
|
214
|
+
req.body = JSON.parse(body || "{}");
|
|
215
|
+
} catch {
|
|
216
|
+
req.body = {};
|
|
217
|
+
}
|
|
218
|
+
resolve();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Stream JSON parser helper
|
|
224
|
+
* Use with streaming mode to parse large JSON incrementally
|
|
225
|
+
*
|
|
226
|
+
* @param {NodeJS.ReadableStream} stream
|
|
227
|
+
* @returns {Promise<any>}
|
|
228
|
+
*/
|
|
229
|
+
export async function parseJsonStream(stream) {
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
let body = "";
|
|
232
|
+
stream.on("data", (chunk) => (body += chunk));
|
|
233
|
+
stream.on("end", () => {
|
|
234
|
+
try {
|
|
235
|
+
resolve(JSON.parse(body));
|
|
236
|
+
} catch (err) {
|
|
237
|
+
reject(err);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
stream.on("error", reject);
|
|
241
|
+
});
|
|
242
|
+
}
|