bro-framework 2.3.1 → 2.4.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 +4 -1
- package/package.json +9 -2
- package/src/index.d.ts +20 -2
- package/src/router.js +3 -1
- package/src/server.js +177 -13
package/README.md
CHANGED
|
@@ -99,8 +99,9 @@ Create a `.js` file in the `routes/` directory, and it automatically becomes an
|
|
|
99
99
|
### Bouncer-Grade Validation
|
|
100
100
|
Powered by Zod. Attach a schema to `body`, `query`, or `params` directly in your route definition. If the client sends malformed data, `bro.js` automatically rejects the request with a structured `400 Bad Request` JSON payload *before* your handler ever executes. You never have to manually validate inputs again. You can also define a `response` schema to strongly type your OpenAPI documentation (strictly opt-in; arbitrary 200s work out of the box).
|
|
101
101
|
|
|
102
|
-
### Zero-Config JWTs
|
|
102
|
+
### Zero-Config Auth (JWTs, RBAC, API Keys)
|
|
103
103
|
Add `auth: true` to your route config. `bro.js` will intercept the request, extract the `Authorization: Bearer <token>` header, verify the signature using your `jwtSecret`, and inject the decoded payload directly into `ctx.user`.
|
|
104
|
+
You can also use Role-Based Access Control by passing an array of roles (e.g. `auth: ['admin']`) or enforce strict service-to-service communication by using `auth: 'api-key'`. API Keys fully support zero-downtime rotation by accepting an array of valid keys in `bro.config.js`.
|
|
104
105
|
|
|
105
106
|
### Context Injection
|
|
106
107
|
Stop importing singleton database connections and socket instances into every file. Define your `db` and `sockets` setup once in `bro.config.js`. `bro.js` orchestrates the initialization and injects both instances directly into the `ctx` object for every request handler.
|
|
@@ -185,6 +186,8 @@ The locale is negotiated dynamically using RFC 9110 `Accept-Language` headers, s
|
|
|
185
186
|
| **API Reference** | Scalar | Auto-generated, interactive Swagger/OpenAPI documentation. |
|
|
186
187
|
| **Task Scheduler** | node-cron | Reliable internal background task orchestration. |
|
|
187
188
|
| **File Parsing** | multer | Zero-boilerplate `multipart/form-data` file extraction. |
|
|
189
|
+
| **Caching & Scaling** | Redis | Optional zero-config route caching, distributed rate-limiting, and WebSocket scaling. |
|
|
190
|
+
| **Security** | Helmet | Auto-configured industry-standard HTTP security headers. |
|
|
188
191
|
|
|
189
192
|
---
|
|
190
193
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bro-framework",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "The No-BS Backend Framework for Node.js",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,14 +27,17 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@scalar/express-api-reference": "^0.10.18",
|
|
30
|
+
"@socket.io/redis-adapter": "^8.3.0",
|
|
30
31
|
"chokidar": "^5.0.0",
|
|
31
32
|
"cors": "^2.8.6",
|
|
32
33
|
"dotenv": "^16.4.5",
|
|
33
34
|
"express": "^4.21.1",
|
|
34
35
|
"express-rate-limit": "^8.7.0",
|
|
36
|
+
"helmet": "^8.3.0",
|
|
35
37
|
"jsonwebtoken": "^9.0.2",
|
|
36
38
|
"multer": "^2.3.0",
|
|
37
39
|
"node-cron": "^4.6.0",
|
|
40
|
+
"redis": "^6.2.1",
|
|
38
41
|
"socket.io": "^4.8.3",
|
|
39
42
|
"tsx": "^4.23.13",
|
|
40
43
|
"zod": "^3.23.8",
|
|
@@ -43,6 +46,7 @@
|
|
|
43
46
|
"keywords": [
|
|
44
47
|
"bro",
|
|
45
48
|
"brojs",
|
|
49
|
+
"bro.js",
|
|
46
50
|
"bro-framework",
|
|
47
51
|
"framework",
|
|
48
52
|
"backend",
|
|
@@ -84,5 +88,8 @@
|
|
|
84
88
|
"ai-friendly",
|
|
85
89
|
"developer-experience",
|
|
86
90
|
"dx"
|
|
87
|
-
]
|
|
91
|
+
],
|
|
92
|
+
"devDependencies": {
|
|
93
|
+
"ioredis-mock": "^8.13.1"
|
|
94
|
+
}
|
|
88
95
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -2,6 +2,18 @@ import { z, ZodTypeAny } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
type InferZod<T> = T extends ZodTypeAny ? z.infer<T> : any;
|
|
4
4
|
|
|
5
|
+
export interface UploadedFile {
|
|
6
|
+
fieldname: string;
|
|
7
|
+
originalname: string;
|
|
8
|
+
encoding: string;
|
|
9
|
+
mimetype: string;
|
|
10
|
+
size: number;
|
|
11
|
+
destination?: string;
|
|
12
|
+
filename?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
buffer?: Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
export interface BroContext<Body = any, Params = any, Query = any> {
|
|
6
18
|
env?: any;
|
|
7
19
|
jwt?: { sign: (payload: any, options?: any) => string };
|
|
@@ -11,19 +23,22 @@ export interface BroContext<Body = any, Params = any, Query = any> {
|
|
|
11
23
|
user?: any;
|
|
12
24
|
db?: any;
|
|
13
25
|
io?: any;
|
|
14
|
-
|
|
26
|
+
file?: UploadedFile;
|
|
27
|
+
files?: UploadedFile[] | Record<string, UploadedFile[]>;
|
|
15
28
|
locale: string;
|
|
16
29
|
t: (key: string, values?: Record<string, unknown>) => string;
|
|
17
30
|
error?: any;
|
|
31
|
+
redis?: any;
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
export interface RouteConfig<Body = any, Params = any, Query = any> {
|
|
21
|
-
auth?: boolean;
|
|
35
|
+
auth?: boolean | string[] | 'api-key';
|
|
22
36
|
upload?: boolean | { limits?: any, fields?: { name: string, maxCount?: number }[], single?: string, array?: string, fileFilter?: any, storage?: any };
|
|
23
37
|
body?: Body;
|
|
24
38
|
params?: Params;
|
|
25
39
|
query?: Query;
|
|
26
40
|
response?: ZodTypeAny;
|
|
41
|
+
cache?: number;
|
|
27
42
|
rateLimit?: {
|
|
28
43
|
windowMs: number;
|
|
29
44
|
max: number;
|
|
@@ -48,6 +63,7 @@ export interface BroConfig {
|
|
|
48
63
|
server?: {
|
|
49
64
|
port?: number;
|
|
50
65
|
cors?: boolean | object;
|
|
66
|
+
helmet?: boolean | object;
|
|
51
67
|
};
|
|
52
68
|
locale?: {
|
|
53
69
|
directory?: string;
|
|
@@ -56,6 +72,7 @@ export interface BroConfig {
|
|
|
56
72
|
auth?: {
|
|
57
73
|
jwtSecret?: string;
|
|
58
74
|
expiresIn?: string | number;
|
|
75
|
+
apiKey?: string | string[];
|
|
59
76
|
};
|
|
60
77
|
docs?: boolean | { auth?: { user: string; pass: string } };
|
|
61
78
|
rateLimit?: {
|
|
@@ -73,6 +90,7 @@ export interface BroConfig {
|
|
|
73
90
|
db?: () => Promise<any> | any;
|
|
74
91
|
sockets?: (io: any, db: any) => Promise<void> | void;
|
|
75
92
|
onShutdown?: (db: any) => Promise<void> | void;
|
|
93
|
+
redisUrl?: string;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
export function defineConfig(config: BroConfig): BroConfig;
|
package/src/router.js
CHANGED
|
@@ -158,7 +158,9 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// Auto-inject security definition if auth is true
|
|
161
|
-
if (config.auth) {
|
|
161
|
+
if (config.auth === 'api-key') {
|
|
162
|
+
operation.security = [{ apiKeyAuth: [] }];
|
|
163
|
+
} else if (config.auth) {
|
|
162
164
|
operation.security = [{ bearerAuth: [] }];
|
|
163
165
|
}
|
|
164
166
|
|
package/src/server.js
CHANGED
|
@@ -2,9 +2,13 @@ import express from 'express';
|
|
|
2
2
|
import cors from 'cors';
|
|
3
3
|
import http from 'node:http';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import { Server } from 'socket.io';
|
|
6
7
|
import rateLimit from 'express-rate-limit';
|
|
7
8
|
import multer from 'multer';
|
|
9
|
+
import helmet from 'helmet';
|
|
10
|
+
import { createClient } from 'redis';
|
|
11
|
+
import { createAdapter } from '@socket.io/redis-adapter';
|
|
8
12
|
import { apiReference } from '@scalar/express-api-reference';
|
|
9
13
|
import { verifyJwt, signJwt } from './auth.js';
|
|
10
14
|
import { loadLocale } from './locale.js';
|
|
@@ -28,6 +32,21 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
28
32
|
const localeDirectory = globalConfig.locale?.directory || path.join(process.cwd(), 'locale');
|
|
29
33
|
let locale = await loadLocale(localeDirectory, globalConfig.locale);
|
|
30
34
|
|
|
35
|
+
const helmetConfig = globalConfig.server?.helmet !== undefined ? globalConfig.server.helmet : true;
|
|
36
|
+
if (helmetConfig !== false) {
|
|
37
|
+
const userConfig = typeof helmetConfig === 'object' ? helmetConfig : {};
|
|
38
|
+
app.use(helmet({
|
|
39
|
+
...userConfig,
|
|
40
|
+
contentSecurityPolicy: userConfig.contentSecurityPolicy ?? {
|
|
41
|
+
directives: {
|
|
42
|
+
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
|
43
|
+
"script-src": ["'self'", "'unsafe-inline'"],
|
|
44
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
|
|
32
51
|
|
|
33
52
|
if (corsConfig !== false) {
|
|
@@ -36,12 +55,84 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
36
55
|
|
|
37
56
|
app.use(express.json());
|
|
38
57
|
|
|
58
|
+
const safeConnect = async (client) => {
|
|
59
|
+
if (typeof client.connect !== 'function') return;
|
|
60
|
+
if (client.status && client.status !== 'wait') return;
|
|
61
|
+
try {
|
|
62
|
+
await client.connect();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (!err.message.includes('already connecting') && !err.message.includes('already connected')) {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let redisClient = null;
|
|
71
|
+
let pubClient = null;
|
|
72
|
+
let subClient = null;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (globalConfig.redisUrl) {
|
|
76
|
+
redisClient = createClient({ url: globalConfig.redisUrl });
|
|
77
|
+
redisClient.on('error', (err) => console.error('[bro.js] Redis Error:', err));
|
|
78
|
+
await safeConnect(redisClient);
|
|
79
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
80
|
+
try {
|
|
81
|
+
const IORedisMock = (await import('ioredis-mock')).default;
|
|
82
|
+
redisClient = new IORedisMock();
|
|
83
|
+
redisClient.connect = async () => {};
|
|
84
|
+
redisClient.setEx = redisClient.setex.bind(redisClient);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error("ioredis-mock is required for test mode. Please install it as a devDependency to use NODE_ENV=test.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (redisClient) await redisClient.quit().catch(() => {});
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
39
94
|
if (globalConfig.rateLimit) {
|
|
40
|
-
|
|
95
|
+
if (redisClient) {
|
|
96
|
+
const fallbackLimiter = rateLimit(globalConfig.rateLimit);
|
|
97
|
+
app.use(async (req, res, next) => {
|
|
98
|
+
try {
|
|
99
|
+
const key = `rate_limit:global:${req.ip}`;
|
|
100
|
+
const current = await redisClient.incr(key);
|
|
101
|
+
if (current === 1) {
|
|
102
|
+
await redisClient.expire(key, Math.floor(globalConfig.rateLimit.windowMs / 1000));
|
|
103
|
+
}
|
|
104
|
+
if (current > globalConfig.rateLimit.max) {
|
|
105
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
106
|
+
}
|
|
107
|
+
next();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('[bro.js] Redis Global Rate Limit Error:', err);
|
|
110
|
+
fallbackLimiter(req, res, next);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
app.use(rateLimit(globalConfig.rateLimit));
|
|
115
|
+
}
|
|
41
116
|
}
|
|
42
117
|
|
|
43
118
|
const io = new Server(server, { cors: typeof corsConfig === 'object' ? corsConfig : undefined });
|
|
44
119
|
|
|
120
|
+
try {
|
|
121
|
+
if (redisClient) {
|
|
122
|
+
pubClient = redisClient.duplicate();
|
|
123
|
+
subClient = redisClient.duplicate();
|
|
124
|
+
await Promise.all([safeConnect(pubClient), safeConnect(subClient)]);
|
|
125
|
+
io.adapter(createAdapter(pubClient, subClient));
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
await Promise.allSettled([
|
|
129
|
+
redisClient?.quit(),
|
|
130
|
+
pubClient?.quit(),
|
|
131
|
+
subClient?.quit()
|
|
132
|
+
].filter(Boolean));
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
|
|
45
136
|
if (globalConfig.sockets) {
|
|
46
137
|
await globalConfig.sockets(io, db);
|
|
47
138
|
}
|
|
@@ -55,7 +146,27 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
55
146
|
const querySchema = routeConfig.query;
|
|
56
147
|
|
|
57
148
|
if (routeConfig.rateLimit) {
|
|
58
|
-
|
|
149
|
+
if (redisClient) {
|
|
150
|
+
const fallbackLimiter = rateLimit(routeConfig.rateLimit);
|
|
151
|
+
middlewares.push(async (req, res, next) => {
|
|
152
|
+
try {
|
|
153
|
+
const key = `rate_limit:${req.ip}:${req.originalUrl}`;
|
|
154
|
+
const current = await redisClient.incr(key);
|
|
155
|
+
if (current === 1) {
|
|
156
|
+
await redisClient.expire(key, Math.floor(routeConfig.rateLimit.windowMs / 1000));
|
|
157
|
+
}
|
|
158
|
+
if (current > routeConfig.rateLimit.max) {
|
|
159
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
160
|
+
}
|
|
161
|
+
next();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[bro.js] Redis Route Rate Limit Error:', err);
|
|
164
|
+
fallbackLimiter(req, res, next);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
middlewares.push(rateLimit(routeConfig.rateLimit));
|
|
169
|
+
}
|
|
59
170
|
}
|
|
60
171
|
|
|
61
172
|
if (routeConfig.upload) {
|
|
@@ -91,6 +202,7 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
91
202
|
env: globalConfig.envData || process.env,
|
|
92
203
|
db,
|
|
93
204
|
io,
|
|
205
|
+
redis: redisClient,
|
|
94
206
|
body: req.body,
|
|
95
207
|
params: req.params,
|
|
96
208
|
query: req.query,
|
|
@@ -106,18 +218,40 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
106
218
|
}
|
|
107
219
|
};
|
|
108
220
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
221
|
+
if (routeConfig.auth === 'api-key') {
|
|
222
|
+
const apiKey = req.headers['x-api-key'];
|
|
223
|
+
const validKey = globalConfig.auth?.apiKey || process.env.API_KEY;
|
|
224
|
+
|
|
225
|
+
let isValid = false;
|
|
226
|
+
if (Array.isArray(validKey)) {
|
|
227
|
+
isValid = validKey.includes(apiKey);
|
|
228
|
+
} else {
|
|
229
|
+
isValid = apiKey && apiKey === validKey;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isValid) {
|
|
233
|
+
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid API key' });
|
|
234
|
+
}
|
|
235
|
+
} else if (routeConfig.auth) {
|
|
236
|
+
const authHeader = req.headers.authorization;
|
|
237
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
238
|
+
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid Bearer token' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const token = authHeader?.split(' ')[1] ?? '';
|
|
242
|
+
const authResult = verifyJwt(token, globalConfig.jwtSecret);
|
|
243
|
+
|
|
244
|
+
if (!authResult.valid) {
|
|
245
|
+
return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
|
|
246
|
+
}
|
|
247
|
+
ctx.user = authResult.payload ?? null;
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(routeConfig.auth)) {
|
|
250
|
+
if (!ctx.user || !ctx.user.role || !routeConfig.auth.includes(ctx.user.role)) {
|
|
251
|
+
return res.status(403).json({ error: 'Forbidden', details: 'Insufficient role permissions' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
119
254
|
}
|
|
120
|
-
ctx.user = authResult?.payload ?? null;
|
|
121
255
|
|
|
122
256
|
if (paramsSchema) {
|
|
123
257
|
const result = paramsSchema.safeParse(req.params);
|
|
@@ -147,9 +281,29 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
147
281
|
throw new Error('Route "handler" is missing or is not a function');
|
|
148
282
|
}
|
|
149
283
|
|
|
284
|
+
let cacheKey = null;
|
|
285
|
+
if (routeConfig.cache && redisClient) {
|
|
286
|
+
const authIdentity = crypto.createHash('sha256').update(req.headers.authorization || req.headers['x-api-key'] || 'anonymous').digest('hex');
|
|
287
|
+
cacheKey = `bro:cache:${req.method}:${req.originalUrl}:${requestLocale}:${authIdentity}`;
|
|
288
|
+
try {
|
|
289
|
+
const cached = await redisClient.get(cacheKey);
|
|
290
|
+
if (cached) {
|
|
291
|
+
const parsed = JSON.parse(cached);
|
|
292
|
+
if (!res.headersSent) res.status(200).json(parsed);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error('[bro.js] Cache parsing failed, deleting key:', cacheKey);
|
|
297
|
+
await redisClient.del(cacheKey).catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
150
301
|
const responseData = await routeConfig.handler(ctx);
|
|
151
302
|
|
|
152
303
|
if (!res.headersSent) {
|
|
304
|
+
if (cacheKey && routeConfig.cache) {
|
|
305
|
+
await redisClient.setEx(cacheKey, routeConfig.cache, JSON.stringify(responseData));
|
|
306
|
+
}
|
|
153
307
|
res.status(200).json(responseData);
|
|
154
308
|
}
|
|
155
309
|
|
|
@@ -183,6 +337,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
183
337
|
type: 'http',
|
|
184
338
|
scheme: 'bearer',
|
|
185
339
|
bearerFormat: 'JWT'
|
|
340
|
+
},
|
|
341
|
+
apiKeyAuth: {
|
|
342
|
+
type: 'apiKey',
|
|
343
|
+
in: 'header',
|
|
344
|
+
name: 'x-api-key'
|
|
186
345
|
}
|
|
187
346
|
},
|
|
188
347
|
responses: {
|
|
@@ -256,6 +415,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
256
415
|
isShuttingDown = true;
|
|
257
416
|
if (taskManager) taskManager.stopAll();
|
|
258
417
|
if (io) io.close();
|
|
418
|
+
await Promise.allSettled([
|
|
419
|
+
redisClient?.quit(),
|
|
420
|
+
pubClient?.quit(),
|
|
421
|
+
subClient?.quit()
|
|
422
|
+
].filter(Boolean));
|
|
259
423
|
|
|
260
424
|
if (typeof globalConfig.onShutdown === 'function') {
|
|
261
425
|
try {
|