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 +2 -1
- package/bin/bro.js +15 -8
- package/package.json +1 -1
- package/src/index.d.ts +12 -1
- package/src/logger.js +14 -1
- package/src/router.js +31 -0
- package/src/sdk.js +10 -2
- package/src/server.js +34 -11
- package/src/tasks.js +15 -10
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-
|
|
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-
|
|
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
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
|
-
|
|
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}
|
|
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}
|
|
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'
|
|
56
|
-
|
|
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 (
|
|
97
|
-
const result =
|
|
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 (
|
|
105
|
-
const result =
|
|
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 (
|
|
113
|
-
const result =
|
|
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
|
-
|
|
212
|
-
const routes = await loadRoutes(newRouter, routesDir, createHandler,
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|