bro-framework 2.1.0 → 2.2.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/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  <a href="https://brojs.yessindevs.me">Documentation Website</a>
18
18
  </p>
19
19
  </p>
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."
@@ -108,7 +109,7 @@ Stop importing singleton database connections and socket instances into every fi
108
109
  If you've ever hand-written OpenAPI YAML, you know the pain. `bro.js` parses your Zod schemas and automatically serves a stunning, interactive [Scalar](https://scalar.com/) API playground at `/docs`. It's highly secure: by default, these internal docs are disabled in production mode.
109
110
 
110
111
  ### The Frontend SDK Generator
111
- Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI will parse your backend routes and compile a `bro-client.js` file for your frontend. It features built-in token management, request stringification, and type-safe deep tree traversal (e.g., `api.users.id("123").post(data)`).
112
+ Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI will parse your backend routes and compile a `bro-sdk.js` file for your frontend. It features built-in token management, request stringification, and type-safe deep tree traversal (e.g., `api.users.id("123").post(data)`).
112
113
 
113
114
  ### Background Task Scheduler
114
115
  Don't spin up a separate worker server. Drop a JavaScript file anywhere in the `tasks/` folder, export a cron string (e.g., `"0 0 * * *"`), and an async handler. `bro.js` natively schedules it as a background worker with full access to your injected database and WebSocket contexts.
package/bin/bro.js CHANGED
@@ -122,7 +122,7 @@ if (command === 'init') {
122
122
 
123
123
  if (['sdk', 'generate-client', 'client'].includes(command)) {
124
124
  generateSDK().then(() => {
125
- console.log(`\n ${colors.green} bro-client.js generated successfully!${colors.reset}\n`);
125
+ console.log(`\n ${colors.green} bro-sdk.js generated successfully!${colors.reset}\n`);
126
126
  process.exit(0);
127
127
  }).catch(err => {
128
128
  console.error(`\n ${colors.red} Error generating SDK:${colors.reset}`, err.message);
@@ -185,12 +185,6 @@ async function bootstrap() {
185
185
  globalConfig.envData = envResult.data;
186
186
  }
187
187
 
188
- if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key'].includes(globalConfig.jwtSecret)) {
189
- console.error(`\n ✗ CRITICAL SECURITY ERROR: You are running in production with a default JWT secret!`);
190
- console.error(` Please set auth.jwtSecret in bro.config.js or via JWT_SECRET environment variable.`);
191
- process.exit(1);
192
- }
193
-
194
188
  if (!fs.existsSync(routesDir)) {
195
189
  console.error(`✗ Error: 'routes' directory not found in ${cwd}`);
196
190
  console.error(` Please create a 'routes/' folder and add your first route.`);
@@ -210,7 +204,7 @@ async function bootstrap() {
210
204
  console.log(`[bro.js] Server running in production on port ${port}`);
211
205
  }
212
206
 
213
- await scanTasks({ db, io });
207
+ let taskManager = await scanTasks({ db, io });
214
208
 
215
209
  if (command === 'dev') {
216
210
  const printCurrentRoutes = (routesToPrint) => {
@@ -243,6 +237,19 @@ async function bootstrap() {
243
237
  }
244
238
  });
245
239
  }
240
+
241
+ const handleShutdown = async (signal) => {
242
+ console.log(`\n[bro.js] Received ${signal}. Shutting down gracefully...`);
243
+ if (taskManager) taskManager.stopAll();
244
+ if (io) io.close();
245
+ server.close(() => {
246
+ console.log('[bro.js] HTTP server closed.');
247
+ process.exit(0);
248
+ });
249
+ };
250
+
251
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
252
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
246
253
  });
247
254
  }
248
255
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bro-framework",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "The No-BS Backend Framework for Node.js",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.d.ts CHANGED
@@ -3,6 +3,8 @@ import { z, ZodTypeAny } from 'zod';
3
3
  type InferZod<T> = T extends ZodTypeAny ? z.infer<T> : any;
4
4
 
5
5
  export interface BroContext<Body = any, Params = any, Query = any> {
6
+ env?: any;
7
+ jwt?: { sign: (payload: any, options?: any) => string };
6
8
  body: InferZod<Body>;
7
9
  params: InferZod<Params>;
8
10
  query: InferZod<Query>;
@@ -15,7 +17,8 @@ export interface BroContext<Body = any, Params = any, Query = any> {
15
17
 
16
18
  export interface RouteConfig<Body = any, Params = any, Query = any> {
17
19
  auth?: boolean;
18
- upload?: boolean;
20
+ upload?: boolean | { limits?: any, fields?: { name: string, maxCount?: number }[], single?: string, array?: string, fileFilter?: any, storage?: any };
21
+ schema?: { body?: Body; params?: Params; query?: Query; };
19
22
  body?: Body;
20
23
  params?: Params;
21
24
  query?: Query;
@@ -46,6 +49,14 @@ export interface BroConfig {
46
49
  windowMs: number;
47
50
  max: number;
48
51
  };
52
+ upload?: {
53
+ limits?: {
54
+ fileSize?: number;
55
+ files?: number;
56
+ fields?: number;
57
+ [key: string]: any;
58
+ };
59
+ };
49
60
  db?: () => Promise<any> | any;
50
61
  sockets?: (io: any, db: any) => Promise<void> | void;
51
62
  }
package/src/logger.js CHANGED
@@ -1,3 +1,7 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
1
5
  export const colors = {
2
6
  reset: "\x1b[0m",
3
7
  bold: "\x1b[1m",
@@ -25,7 +29,16 @@ export function formatMethod(method) {
25
29
 
26
30
  export function printBanner(port, durationMs) {
27
31
  const time = durationMs.toFixed(0);
28
- const version = "2.0.1";
32
+
33
+ let version = "2.2.0";
34
+ try {
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = path.dirname(__filename);
37
+ const pkgPath = path.join(__dirname, '..', 'package.json');
38
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
39
+ version = pkg.version;
40
+ } catch(e) {}
41
+
29
42
  const innerWidth = 47;
30
43
 
31
44
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
package/src/router.js CHANGED
@@ -43,6 +43,12 @@ export function parseRouteFile(filePath, routesDir) {
43
43
  if (parts.length < 2) return null;
44
44
 
45
45
  const method = parts.pop().toLowerCase();
46
+
47
+ const allowedMethods = new Set(['get', 'post', 'put', 'delete', 'patch', 'options', 'head']);
48
+ if (!allowedMethods.has(method)) {
49
+ throw new Error(`Invalid HTTP method "${method}" in file: ${filePath}`);
50
+ }
51
+
46
52
  const namePart = parts.join('.');
47
53
 
48
54
  let routePath = '/' + path.dirname(relativePath).replace(/\\/g, '/');
@@ -52,6 +58,15 @@ export function parseRouteFile(filePath, routesDir) {
52
58
  routePath += `/${namePart}`;
53
59
  }
54
60
 
61
+ const bracketRegex = /\[(.*?)\]/g;
62
+ let match;
63
+ while ((match = bracketRegex.exec(routePath)) !== null) {
64
+ const paramName = match[1];
65
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(paramName)) {
66
+ throw new Error(`Invalid dynamic parameter "[${paramName}]" in file: ${filePath}. Must be a valid JavaScript identifier.`);
67
+ }
68
+ }
69
+
55
70
  routePath = routePath.replace(/\[(.*?)\]/g, ':$1');
56
71
 
57
72
  if (routePath === '') routePath = '/';
@@ -142,6 +157,22 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
142
157
  operation.security = [{ bearerAuth: [] }];
143
158
  }
144
159
 
160
+ if (config.upload) {
161
+ operation.requestBody = operation.requestBody || { content: {} };
162
+ operation.requestBody.content['multipart/form-data'] = {
163
+ schema: { type: 'object' }
164
+ };
165
+ }
166
+
167
+ if (bodySchema || paramsSchema || querySchema) {
168
+ operation.responses['400'] = { $ref: '#/components/responses/BadRequest' };
169
+ }
170
+ if (config.auth) {
171
+ operation.responses['401'] = { $ref: '#/components/responses/Unauthorized' };
172
+ }
173
+ operation.responses['404'] = { $ref: '#/components/responses/NotFound' };
174
+ operation.responses['500'] = { $ref: '#/components/responses/ServerError' };
175
+
145
176
  openApiSpec.paths[openApiPath][method.toLowerCase()] = operation;
146
177
  }
147
178
 
package/src/sdk.js CHANGED
@@ -98,6 +98,10 @@ function generateApiObject(endpoints) {
98
98
 
99
99
  if (!current[name]) {
100
100
  current[name] = { _isParam: isParam, _methods: {}, _children: {}, _path: pathAcc };
101
+ } else {
102
+ if (current[name]._isParam !== isParam) {
103
+ throw new Error(`SDK Collision: Route segment "${name}" conflicts between static and dynamic parameters at path "${pathAcc}"`);
104
+ }
101
105
  }
102
106
 
103
107
  if (i === parts.length - 1) {
@@ -116,9 +120,13 @@ function generateApiObject(endpoints) {
116
120
  function renderTree(node, indent = ' ') {
117
121
  let result = '';
118
122
 
123
+ const isValidIdentifier = (key) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key);
124
+
119
125
  for (const [key, val] of Object.entries(node)) {
126
+ const formattedKey = isValidIdentifier(key) ? key : `"${key}"`;
127
+
120
128
  if (val._isParam) {
121
- result += `${indent}"${key}": (${key}) => ({\n`;
129
+ result += `${indent}${formattedKey}: (${key}) => ({\n`;
122
130
 
123
131
  for (const [m, p] of Object.entries(val._methods)) {
124
132
  const templatedPath = p.replace(/:([a-zA-Z0-9_]+)/g, '${encodeURIComponent($1)}');
@@ -132,7 +140,7 @@ function generateApiObject(endpoints) {
132
140
 
133
141
  result += `${indent}}),\n`;
134
142
  } else {
135
- result += `${indent}"${key}": {\n`;
143
+ result += `${indent}${formattedKey}: {\n`;
136
144
  for (const [m, p] of Object.entries(val._methods)) {
137
145
  result += `${indent} ${m}: (data) => request('${m}', '${p}', data),\n`;
138
146
  }
package/src/server.js CHANGED
@@ -16,6 +16,10 @@ import { loadRoutes } from './router.js';
16
16
  * @returns {Promise<{ app: import('express').Application, server: http.Server, routes: Array, reload: Function, io: import('socket.io').Server }>}
17
17
  */
18
18
  export async function createServer(globalConfig, routesDir, db) {
19
+ if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key'].includes(globalConfig.jwtSecret)) {
20
+ throw new Error('CRITICAL SECURITY ERROR: You are running in production with a default JWT secret! Please set auth.jwtSecret in bro.config.js or via JWT_SECRET environment variable.');
21
+ }
22
+
19
23
  const app = express();
20
24
  const server = http.createServer(app);
21
25
 
@@ -39,6 +43,9 @@ export async function createServer(globalConfig, routesDir, db) {
39
43
 
40
44
  const createHandler = (routeConfig) => {
41
45
  const middlewares = [];
46
+ const bodySchema = routeConfig.schema?.body || routeConfig.body;
47
+ const paramsSchema = routeConfig.schema?.params || routeConfig.params;
48
+ const querySchema = routeConfig.schema?.query || routeConfig.query;
42
49
 
43
50
  if (routeConfig.rateLimit) {
44
51
  middlewares.push(rateLimit(routeConfig.rateLimit));
@@ -52,10 +59,25 @@ export async function createServer(globalConfig, routesDir, db) {
52
59
  fields: 20
53
60
  }
54
61
  };
55
- if (typeof routeConfig.upload === 'object' && routeConfig.upload.limits) {
56
- routeMulterConfig.limits = { ...routeMulterConfig.limits, ...routeConfig.upload.limits };
62
+ if (typeof routeConfig.upload === 'object') {
63
+ if (routeConfig.upload.limits) {
64
+ routeMulterConfig.limits = { ...routeMulterConfig.limits, ...routeConfig.upload.limits };
65
+ }
66
+ if (routeConfig.upload.fileFilter) routeMulterConfig.fileFilter = routeConfig.upload.fileFilter;
67
+ if (routeConfig.upload.storage) routeMulterConfig.storage = routeConfig.upload.storage;
68
+ }
69
+
70
+ const uploadParser = multer(routeMulterConfig);
71
+
72
+ if (typeof routeConfig.upload === 'object' && routeConfig.upload.fields) {
73
+ middlewares.push(uploadParser.fields(routeConfig.upload.fields));
74
+ } else if (typeof routeConfig.upload === 'object' && routeConfig.upload.single) {
75
+ middlewares.push(uploadParser.single(routeConfig.upload.single));
76
+ } else if (typeof routeConfig.upload === 'object' && routeConfig.upload.array) {
77
+ middlewares.push(uploadParser.array(routeConfig.upload.array));
78
+ } else {
79
+ middlewares.push(uploadParser.any());
57
80
  }
58
- middlewares.push(multer(routeMulterConfig).any());
59
81
  }
60
82
 
61
83
  middlewares.push(async (req, res) => {
@@ -93,24 +115,24 @@ export async function createServer(globalConfig, routesDir, db) {
93
115
  ctx.user = authResult.payload;
94
116
  }
95
117
 
96
- if (routeConfig.params) {
97
- const result = routeConfig.params.safeParse(req.params);
118
+ if (paramsSchema) {
119
+ const result = paramsSchema.safeParse(req.params);
98
120
  if (!result.success) {
99
121
  return res.status(400).json({ error: 'Invalid URL Parameters', details: result.error.flatten() });
100
122
  }
101
123
  ctx.params = result.data;
102
124
  }
103
125
 
104
- if (routeConfig.body) {
105
- const result = routeConfig.body.safeParse(req.body);
126
+ if (bodySchema) {
127
+ const result = bodySchema.safeParse(req.body);
106
128
  if (!result.success) {
107
129
  return res.status(400).json({ error: 'Invalid Request Body', details: result.error.flatten() });
108
130
  }
109
131
  ctx.body = result.data;
110
132
  }
111
133
 
112
- if (routeConfig.query) {
113
- const result = routeConfig.query.safeParse(req.query);
134
+ if (querySchema) {
135
+ const result = querySchema.safeParse(req.query);
114
136
  if (!result.success) {
115
137
  return res.status(400).json({ error: 'Invalid Query Parameters', details: result.error.flatten() });
116
138
  }
@@ -208,8 +230,9 @@ export async function createServer(globalConfig, routesDir, db) {
208
230
 
209
231
  const reload = async () => {
210
232
  const newRouter = express.Router();
211
- openApiSpec.paths = {};
212
- const routes = await loadRoutes(newRouter, routesDir, createHandler, openApiSpec);
233
+ const tempSpec = { paths: {} };
234
+ const routes = await loadRoutes(newRouter, routesDir, createHandler, tempSpec);
235
+ openApiSpec.paths = tempSpec.paths;
213
236
  routeStack = newRouter;
214
237
  return routes;
215
238
  };
package/src/tasks.js CHANGED
@@ -5,21 +5,24 @@ import cron from 'node-cron';
5
5
  import { colors } from './logger.js';
6
6
  import { scanDir } from './router.js';
7
7
 
8
- let taskHandles = [];
9
-
10
- export function stopTasks() {
11
- taskHandles.forEach(t => t.stop());
12
- taskHandles = [];
8
+ export class TaskManager {
9
+ constructor() {
10
+ this.taskHandles = [];
11
+ }
12
+
13
+ stopAll() {
14
+ this.taskHandles.forEach(t => t.stop());
15
+ this.taskHandles = [];
16
+ }
13
17
  }
14
18
 
15
19
  export async function scanTasks(ctx) {
20
+ const manager = new TaskManager();
16
21
  const tasksDir = path.join(process.cwd(), 'tasks');
17
- if (!fs.existsSync(tasksDir)) return;
22
+ if (!fs.existsSync(tasksDir)) return manager;
18
23
 
19
- stopTasks();
20
-
21
24
  const files = scanDir(tasksDir);
22
- if (files.length === 0) return;
25
+ if (files.length === 0) return manager;
23
26
 
24
27
  let count = 0;
25
28
  for (const file of files) {
@@ -35,7 +38,7 @@ export async function scanTasks(ctx) {
35
38
  console.error(`\n ${colors.red}❌ Task Error (${file}):${colors.reset}`, err);
36
39
  }
37
40
  });
38
- taskHandles.push(task);
41
+ manager.taskHandles.push(task);
39
42
  count++;
40
43
  }
41
44
  } catch (err) {
@@ -46,4 +49,6 @@ export async function scanTasks(ctx) {
46
49
  if (count > 0) {
47
50
  console.log(` ${colors.dim}├──${colors.reset} ${colors.cyan}Scheduled ${count} background task(s)${colors.reset}`);
48
51
  }
52
+
53
+ return manager;
49
54
  }