bro-framework 2.3.0 → 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 +5 -2
- package/package.json +9 -2
- package/src/index.d.ts +20 -2
- package/src/router.js +3 -1
- package/src/server.js +166 -5
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
23
|
-
>
|
|
23
|
+
> Skip the boilerplate. bro.js gives you file-based routing, automatic Zod validation, JWT auth, and WebSockets right out of the box. Just write your business logic, return an object, and let the framework handle the rest.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -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,20 +218,39 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
106
218
|
}
|
|
107
219
|
};
|
|
108
220
|
|
|
109
|
-
if (routeConfig.auth) {
|
|
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) {
|
|
110
236
|
const authHeader = req.headers.authorization;
|
|
111
237
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
112
238
|
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid Bearer token' });
|
|
113
239
|
}
|
|
114
240
|
|
|
115
|
-
const token = authHeader
|
|
241
|
+
const token = authHeader?.split(' ')[1] ?? '';
|
|
116
242
|
const authResult = verifyJwt(token, globalConfig.jwtSecret);
|
|
117
243
|
|
|
118
244
|
if (!authResult.valid) {
|
|
119
245
|
return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
|
|
120
246
|
}
|
|
247
|
+
ctx.user = authResult.payload ?? null;
|
|
121
248
|
|
|
122
|
-
|
|
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
|
+
}
|
|
123
254
|
}
|
|
124
255
|
|
|
125
256
|
if (paramsSchema) {
|
|
@@ -150,9 +281,29 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
150
281
|
throw new Error('Route "handler" is missing or is not a function');
|
|
151
282
|
}
|
|
152
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
|
+
|
|
153
301
|
const responseData = await routeConfig.handler(ctx);
|
|
154
302
|
|
|
155
303
|
if (!res.headersSent) {
|
|
304
|
+
if (cacheKey && routeConfig.cache) {
|
|
305
|
+
await redisClient.setEx(cacheKey, routeConfig.cache, JSON.stringify(responseData));
|
|
306
|
+
}
|
|
156
307
|
res.status(200).json(responseData);
|
|
157
308
|
}
|
|
158
309
|
|
|
@@ -186,6 +337,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
186
337
|
type: 'http',
|
|
187
338
|
scheme: 'bearer',
|
|
188
339
|
bearerFormat: 'JWT'
|
|
340
|
+
},
|
|
341
|
+
apiKeyAuth: {
|
|
342
|
+
type: 'apiKey',
|
|
343
|
+
in: 'header',
|
|
344
|
+
name: 'x-api-key'
|
|
189
345
|
}
|
|
190
346
|
},
|
|
191
347
|
responses: {
|
|
@@ -259,6 +415,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
259
415
|
isShuttingDown = true;
|
|
260
416
|
if (taskManager) taskManager.stopAll();
|
|
261
417
|
if (io) io.close();
|
|
418
|
+
await Promise.allSettled([
|
|
419
|
+
redisClient?.quit(),
|
|
420
|
+
pubClient?.quit(),
|
|
421
|
+
subClient?.quit()
|
|
422
|
+
].filter(Boolean));
|
|
262
423
|
|
|
263
424
|
if (typeof globalConfig.onShutdown === 'function') {
|
|
264
425
|
try {
|