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 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.1",
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
- files?: any[];
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
- app.use(rateLimit(globalConfig.rateLimit));
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
- middlewares.push(rateLimit(routeConfig.rateLimit));
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
- const authHeader = req.headers.authorization;
110
- if ((!authHeader || !authHeader.startsWith('Bearer ')) && routeConfig.auth) {
111
- return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid Bearer token' });
112
- }
113
-
114
- const token = authHeader?.split(' ')[1] ?? '';
115
- const authResult = verifyJwt(token, globalConfig.jwtSecret);
116
-
117
- if (!authResult.valid && routeConfig.auth) {
118
- return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
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 {