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 CHANGED
@@ -20,7 +20,7 @@
20
20
 
21
21
  ---
22
22
 
23
- > "NestJS wants four decorators, three modules, and an existential crisis just to handle a GET request. Express makes you write the same 40 lines of CORS, JSON parsing, and auth middleware for every project. bro.js gives you file routing, auto-validation, JWT auth, WebSockets, and live docs out of the box. Be honest: you just want to return an object."
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.0",
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,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.split(' ')[1];
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
- ctx.user = authResult.payload;
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 {