bro-framework 2.4.5 → 3.0.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
@@ -84,6 +84,8 @@ export default defineConfig({
84
84
 
85
85
  `cors: false` disables HTTP CORS middleware. Helmet is enabled by default and can be disabled with `helmet: false`. Redis is optional; when configured it powers distributed rate limiting, route caching, and Socket.IO scaling. In `NODE_ENV=test`, bro.js can inject `ioredis-mock`; install it in the consuming project's development dependencies.
86
86
 
87
+ > **Production Security Note**: When deploying behind a reverse proxy (Nginx, AWS ALB, Vercel, Render), ensure your load balancer properly sets `X-Forwarded-For`. Rate limiting and trusted IP functionality relies on this proxy configuration.
88
+
87
89
  ---
88
90
 
89
91
  ## The Core Experience
@@ -243,6 +245,40 @@ Handlers receive:
243
245
 
244
246
  For Redis-backed integration tests without an external Redis server, install `ioredis-mock` in the consuming project and run with `NODE_ENV=test`. bro.js injects a mock Redis client and exercises cache, rate-limit, Socket.IO adapter, and shutdown paths.
245
247
 
248
+ ### Supertest + Vitest Recipe
249
+
250
+ You can programmatically bootstrap `bro.js` using `createServer` for blazing fast integration tests. Here's a complete `vitest` recipe:
251
+
252
+ ```javascript
253
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
254
+ import request from 'supertest';
255
+ import path from 'path';
256
+ import { createServer } from 'bro-framework';
257
+ import config from '../bro.config.js';
258
+
259
+ describe('API Tests', () => {
260
+ let app, shutdown;
261
+
262
+ beforeAll(async () => {
263
+ // 1. Initialize the server programmatically
264
+ const instance = await createServer(config, path.resolve('./routes'), null);
265
+ app = instance.app;
266
+ shutdown = instance.shutdown;
267
+ });
268
+
269
+ afterAll(async () => {
270
+ // 2. Cleanly teardown tasks, redis, and sockets
271
+ if (shutdown) await shutdown();
272
+ });
273
+
274
+ it('should return a 200 from the healthcheck', async () => {
275
+ const res = await request(app).get('/health/live');
276
+ expect(res.status).toBe(200);
277
+ expect(res.body.status).toBe('ok');
278
+ });
279
+ });
280
+ ```
281
+
246
282
  ---
247
283
 
248
284
  ## Architecture & Request Lifecycle
@@ -339,6 +375,14 @@ export const POST = defineRoute({
339
375
 
340
376
  ---
341
377
 
378
+ ## Compatibility Table
379
+
380
+ | bro.js Version | Node.js | Next.js App Router | Express | Zod |
381
+ | :------------- | :-------- | :----------------- | :------ | :------ |
382
+ | `>= 2.0.0` | `>= 18.x` | `>= 13.4.x` | `4.x` | `3.x` |
383
+
384
+ ---
385
+
342
386
  ## Author & License
343
387
 
344
388
  - **Author**: Yass1n (@medyass1ne)
package/bin/bro.js CHANGED
@@ -1,293 +1,195 @@
1
1
  #!/usr/bin/env node
2
-
3
- import path from 'path';
4
- import fs from 'fs';
5
- import { pathToFileURL } from 'url';
6
- import { register } from 'tsx/esm/api';
7
-
8
- register();
9
-
10
- import { createServer } from '../src/server.js';
11
- import { colors, printBanner, printRoute, printHotReload } from '../src/logger.js';
12
- import { generateSDK } from '../src/sdk.js';
13
- import dotenv from 'dotenv';
14
- import chokidar from 'chokidar';
15
-
16
- dotenv.config();
17
-
18
- const command = process.argv[2] || 'dev';
19
-
20
- if (command === 'dev') {
21
- process.env.NODE_ENV = process.env.NODE_ENV || 'development';
22
- } else if (command === 'start') {
23
- process.env.NODE_ENV = 'production';
24
- }
25
-
26
- const CONFIG_TEMPLATE = `import { defineConfig } from 'bro-framework';
27
-
28
- export default defineConfig({
29
- // Server Settings
30
- server: {
31
- port: 5000,
32
- cors: true, // Set to true to allow all, or pass a CORS options object
33
- helmet: true // Enable security headers
34
- },
35
-
36
- // Authentication Settings
37
- auth: {
38
- jwtSecret: 'dev_secret_please_change',
39
- expiresIn: '7d',
40
- apiKey: process.env.API_KEY || ['dev_key_1', 'dev_key_2'] // Supports array for zero-downtime rotation
41
- },
42
-
43
- // Optional file-based API translations
44
- // Add locale/en.js, locale/fr.js, etc.
45
- locale: {
46
- defaultLocale: 'en'
47
- },
48
-
49
- // API Documentation (Scalar UI)
50
- docs: process.env.NODE_ENV !== 'production', // Set to false to disable completely, or true to force in prod
51
-
52
- // Rate Limiting
53
- rateLimit: {
54
- windowMs: 15 * 60 * 1000, // 15 minutes
55
- max: 100 // limit each IP to 100 requests per windowMs
56
- },
57
-
58
- // Redis Configuration (Auto-scales WebSockets, distributed caches & rate-limiting)
59
- redisUrl: process.env.REDIS_URL, // e.g., 'redis://localhost:6379'
60
-
61
- // WebSockets Setup
62
- sockets: async (io, db) => {
63
- io.on('connection', (socket) => {
64
- console.log('Client connected:', socket.id);
65
- });
66
- },
67
-
68
- // Database Context Injection
69
- // This instance will be injected into every route's ctx.db (if defined)
70
- db: async () => {
71
- // If you use a database, set up your connection here
72
- // and return the connection instance or an object of your models.
73
- // Could be MongoDB, MySQL, etc. (your choice)
74
- // --- MONGOOSE EXAMPLE ---
75
- // import mongoose from 'mongoose';
76
-
77
- // await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/bro_database');
78
- // console.log("Connected to MongoDB");
79
-
80
- // You can return mongoose itself, or an object of your models
81
- // to access them instantly in your routes without importing them!
82
- // Example: return { User, Post };
83
-
84
- // return mongoose.connection;
85
- // --------------------------
86
- return null;
87
- },
88
-
89
- // Graceful Teardown Hook
90
- onShutdown: async (db) => {
91
- // Close application-owned database resources gracefully here
92
- }
93
- });
94
- `;
95
-
96
- function scaffoldConfig() {
97
- const configPath = path.join(process.cwd(), 'bro.config.js');
98
- if (!fs.existsSync(configPath)) {
99
- fs.writeFileSync(configPath, CONFIG_TEMPLATE, 'utf-8');
100
- console.log(`\n ${colors.green} Created default bro.config.js${colors.reset}\n`);
101
- }
102
- }
103
-
104
- function ensureTypeModule() {
105
- const pkgPath = path.join(process.cwd(), 'package.json');
106
-
107
- if (fs.existsSync(pkgPath)) {
108
- try {
109
- const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
110
- const pkg = JSON.parse(pkgRaw);
111
-
112
- if (pkg.type !== 'module') {
113
- pkg.type = 'module';
114
- fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
115
- console.log(`\n ${colors.green} Auto-configured package.json for ES Modules${colors.reset}`);
116
- }
117
- } catch (err) {
118
- console.error(`\n ${colors.red} Failed to parse package.json for ES Modules setup${colors.reset}`, err);
119
- }
120
- } else {
121
- const defaultPkg = {
122
- name: "bro-app",
123
- version: "1.0.0",
124
- type: "module",
125
- private: true
126
- };
127
- fs.writeFileSync(pkgPath, JSON.stringify(defaultPkg, null, 2), 'utf-8');
128
- console.log(`\n ${colors.green} Created package.json with ES Modules enabled${colors.reset}`);
129
- }
130
- }
131
-
132
- if (command === 'init') {
133
- ensureTypeModule();
134
- scaffoldConfig();
135
- process.exit(0);
136
- }
137
-
138
- if (['sdk', 'generate-client', 'client'].includes(command)) {
139
- generateSDK().then(() => {
140
- console.log(`\n ${colors.green} bro-sdk.js generated successfully!${colors.reset}\n`);
141
- process.exit(0);
142
- }).catch(err => {
143
- console.error(`\n ${colors.red} Error generating SDK:${colors.reset}`, err.message);
144
- process.exit(1);
145
- });
146
- }
147
-
148
- async function bootstrap() {
149
- if (command === 'dev') {
150
- ensureTypeModule();
151
- scaffoldConfig();
152
- }
153
-
154
- const startTime = performance.now();
155
-
156
- const cwd = process.cwd();
157
- const configPath = path.join(cwd, 'bro.config.js');
158
- const routesDir = path.join(cwd, 'routes');
159
-
160
- let globalConfig = {
161
- port: process.env.PORT || 5000,
162
- jwtSecret: process.env.JWT_SECRET || 'dev_secret_please_change'
163
- };
164
-
165
- let db = null;
166
-
167
- if (fs.existsSync(configPath)) {
168
- try {
169
- const configModule = await import(pathToFileURL(configPath).href);
170
- const userConfig = configModule.default || configModule.config || {};
171
-
172
- if (userConfig.server?.port) globalConfig.port = userConfig.server.port;
173
- if (userConfig.auth?.jwtSecret) globalConfig.jwtSecret = userConfig.auth.jwtSecret;
174
-
175
- globalConfig = { ...globalConfig, ...userConfig };
176
-
177
- if (typeof globalConfig.db === 'function') {
178
- db = await globalConfig.db();
179
- } else if (globalConfig.db && typeof globalConfig.db.init === 'function') {
180
- db = await globalConfig.db.init();
181
- } else if (globalConfig.db) {
182
- db = globalConfig.db;
183
- if (db instanceof Promise) db = await db;
184
- }
185
- } catch (err) {
186
- console.error('✗ Failed to load bro.config.js:', err);
187
- }
188
- }
189
-
190
- if (globalConfig.env) {
191
- const envResult = globalConfig.env.safeParse(process.env);
192
- if (!envResult.success) {
193
- console.error(`\n ${colors.red} Environment Validation Failed${colors.reset}`);
194
- envResult.error.errors.forEach(err => {
195
- console.error(` ${colors.dim}-${colors.reset} ${colors.bold}${err.path.join('.')}${colors.reset}: ${err.message}`);
196
- });
197
- console.error("");
198
- process.exit(1);
199
- }
200
- globalConfig.envData = envResult.data;
201
- }
202
-
203
- if (!fs.existsSync(routesDir)) {
204
- console.error(`✗ Error: 'routes' directory not found in ${cwd}`);
205
- console.error(` Please create a 'routes/' folder and add your first route.`);
206
- process.exit(1);
207
- }
208
-
209
- const localeDir = globalConfig.locale?.directory || path.join(cwd, 'locale');
210
- const tasksDir = path.join(cwd, 'tasks');
211
- const { app, server, routes: initialRoutes, reload, reloadLocale, reloadTasks, io, shutdown } = await createServer(globalConfig, routesDir, db);
212
- const port = globalConfig.port;
213
-
214
- let currentRoutes = initialRoutes;
215
-
216
- server.listen(port, async () => {
217
- if (command === 'dev') {
218
- console.clear();
219
- printBanner(port, performance.now() - startTime);
220
- } else if (command === 'start') {
221
- console.log(`[bro.js] Server running in production on port ${port}`);
222
- }
223
-
224
- if (command === 'dev') {
225
- const printCurrentRoutes = (routesToPrint) => {
226
- if (routesToPrint.length > 0) {
227
- routesToPrint.forEach((r, i) => {
228
- printRoute(r.method, r.path, r.auth, i === routesToPrint.length - 1);
229
- });
230
- console.log("");
231
- } else {
232
- console.log(" No routes found.\n");
233
- }
234
- };
235
-
236
- printCurrentRoutes(currentRoutes);
237
-
238
- const localeGlob = localeDir.replace(/\\/g, '/') + '/*.{js,mjs,ts,json}';
239
- const watcher = chokidar.watch([routesDir, localeGlob, tasksDir], { ignoreInitial: true });
240
-
241
- watcher.on('all', async (event, filepath) => {
242
- const isValidFile = filepath.match(/\.(js|ts|mjs|json)$/);
243
- if (!isValidFile) return;
244
- const relLocale = path.relative(path.resolve(localeDir), filepath);
245
- const isLocaleFile = !relLocale.startsWith('..') && !path.isAbsolute(relLocale);
246
-
247
- const relTask = path.relative(path.resolve(tasksDir), filepath);
248
- const isTaskFile = !relTask.startsWith('..') && !path.isAbsolute(relTask);
249
-
250
- try {
251
- const reloadStartTime = performance.now();
252
- if (isLocaleFile) {
253
- await reloadLocale();
254
- } else if (isTaskFile) {
255
- await reloadTasks();
256
- } else {
257
- currentRoutes = await reload();
258
- }
259
- const reloadTimeMs = performance.now() - reloadStartTime;
260
-
261
- const fileType = isTaskFile ? 'Task' : (isLocaleFile ? 'Locale' : 'Route');
262
- printHotReload(path.basename(filepath), event, reloadTimeMs, fileType);
263
- printCurrentRoutes(currentRoutes);
264
- } catch (err) {
265
- const fileType = isTaskFile ? 'tasks' : (isLocaleFile ? 'locale' : 'routes');
266
- console.error(`\n ✗ Error hot-reloading ${fileType}:`, err);
267
- }
268
- });
269
- }
270
-
271
- const handleShutdown = async (signal) => {
272
- console.log(`\n[bro.js] Received ${signal}. Shutting down gracefully...`);
273
- await shutdown();
274
- console.log('[bro.js] HTTP server closed.');
275
- process.exit(0);
276
- };
277
-
278
- process.on('SIGINT', () => handleShutdown('SIGINT'));
279
- process.on('SIGTERM', () => handleShutdown('SIGTERM'));
280
- });
281
- }
282
-
283
- if (command === 'dev' || command === 'start') {
284
- bootstrap();
285
- } else if (!['sdk', 'generate-client', 'client', 'init'].includes(command)) {
286
- console.log(`\n ${colors.bold}${colors.green}bro.js CLI${colors.reset}\n`);
287
- console.log(` ${colors.bold}Usage:${colors.reset} bro <command>\n`);
288
- console.log(` ${colors.bold}Commands:${colors.reset}`);
289
- console.log(` ${colors.cyan}dev${colors.reset} Start the development server with hot-reload`);
290
- console.log(` ${colors.cyan}start${colors.reset} Start the production server gracefully`);
291
- console.log(` ${colors.cyan}init${colors.reset} Scaffold a new bro.config.js workspace`);
292
- console.log(` ${colors.cyan}sdk${colors.reset} Generate a typed frontend client\n`);
293
- }
2
+
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import { pathToFileURL } from 'url';
6
+ import { register } from 'tsx/esm/api';
7
+
8
+ register();
9
+
10
+ import { createServer } from '../src/server.js';
11
+ import { colors, printBanner, printRoute, printHotReload } from '../src/logger.js';
12
+ import { generateSDK } from '../src/sdk.js';
13
+ import dotenv from 'dotenv';
14
+ import chokidar from 'chokidar';
15
+
16
+ dotenv.config();
17
+
18
+ const command = process.argv[2] || 'dev';
19
+
20
+ async function bootstrap() {
21
+ const startTime = performance.now();
22
+ const configPath = path.resolve(process.cwd(), 'bro.config.js');
23
+ let globalConfig = {};
24
+
25
+ if (fs.existsSync(configPath)) {
26
+ try {
27
+ const configModule = await import(pathToFileURL(configPath).href);
28
+ globalConfig = configModule.default || configModule;
29
+ } catch (err) {
30
+ console.error('[bro.js] Error loading bro.config.js:', err);
31
+ process.exit(1);
32
+ }
33
+ }
34
+
35
+ const routesDir = globalConfig.routesDir || path.resolve(process.cwd(), 'api');
36
+ const localeDir = globalConfig.locale?.directory || path.resolve(process.cwd(), 'locale');
37
+ const tasksDir = globalConfig.tasksDir || path.resolve(process.cwd(), 'tasks');
38
+ const port = globalConfig.server?.port || process.env.PORT || 3000;
39
+
40
+ let db = null;
41
+ // Initialize db from globalConfig if provided (mocked here for CLI boot)
42
+ if (globalConfig.db && typeof globalConfig.db === 'function') {
43
+ db = await globalConfig.db();
44
+ } else {
45
+ db = globalConfig.db;
46
+ }
47
+
48
+ const { app, server, routes, reload, reloadLocale, shutdown } = await createServer(globalConfig, routesDir, db);
49
+ let currentRoutes = routes;
50
+
51
+ server.listen(port, () => {
52
+ if (command === 'dev') {
53
+ console.clear();
54
+ printBanner(port, performance.now() - startTime);
55
+
56
+ if (process.argv.includes('--ui')) {
57
+ import('../src/dashboard.js').then(({ startDashboard }) => {
58
+ startDashboard(globalConfig, currentRoutes);
59
+ }).catch(err => console.error('[bro.js] Error starting dashboard:', err));
60
+ }
61
+ } else if (command === 'start') {
62
+ console.log(`[bro.js] Server running in production on port ${port}`);
63
+ }
64
+
65
+ if (command === 'dev') {
66
+ const printCurrentRoutes = (routesToPrint) => {
67
+ if (routesToPrint.length > 0) {
68
+ routesToPrint.forEach((r, i) => {
69
+ printRoute(r.method, r.path, r.auth, i === routesToPrint.length - 1);
70
+ });
71
+ console.log("");
72
+ } else {
73
+ console.log(" No routes found.\n");
74
+ }
75
+ };
76
+
77
+ printCurrentRoutes(currentRoutes);
78
+
79
+ const localeGlob = localeDir.replace(/\\/g, '/') + '/*.{js,mjs,ts,json}';
80
+ const watcher = chokidar.watch([routesDir, localeGlob, tasksDir], { ignoreInitial: true });
81
+
82
+ watcher.on('all', async (event, filepath) => {
83
+ const isValidFile = filepath.match(/\.(js|ts|mjs|json)$/);
84
+ if (!isValidFile) return;
85
+ const relLocale = path.relative(path.resolve(localeDir), filepath);
86
+ const isLocaleFile = !relLocale.startsWith('..') && !path.isAbsolute(relLocale);
87
+
88
+ const relTask = path.relative(path.resolve(tasksDir), filepath);
89
+ const isTaskFile = !relTask.startsWith('..') && !path.isAbsolute(relTask);
90
+
91
+ try {
92
+ const reloadStartTime = performance.now();
93
+ if (isLocaleFile) {
94
+ await reloadLocale();
95
+ } else if (isTaskFile) {
96
+ // Tasks reload
97
+ } else {
98
+ currentRoutes = await reload();
99
+ }
100
+ const reloadTimeMs = performance.now() - reloadStartTime;
101
+
102
+ const fileType = isTaskFile ? 'Task' : (isLocaleFile ? 'Locale' : 'Route');
103
+ printHotReload(path.basename(filepath), event, reloadTimeMs, fileType);
104
+ printCurrentRoutes(currentRoutes);
105
+ } catch (err) {
106
+ const fileType = isTaskFile ? 'tasks' : (isLocaleFile ? 'locale' : 'routes');
107
+ console.error(`\n ✗ Error hot-reloading ${fileType}:`, err);
108
+ }
109
+ });
110
+ }
111
+
112
+ const handleShutdown = async (signal) => {
113
+ console.log(`\n[bro.js] Received ${signal}. Shutting down gracefully...`);
114
+ if (shutdown) await shutdown();
115
+ console.log('[bro.js] HTTP server closed.');
116
+ process.exit(0);
117
+ };
118
+
119
+ process.on('SIGINT', () => handleShutdown('SIGINT'));
120
+ process.on('SIGTERM', () => handleShutdown('SIGTERM'));
121
+ });
122
+ }
123
+
124
+
125
+ if (command === 'test') {
126
+ const hasVitest = fs.existsSync(path.resolve(process.cwd(), 'node_modules', 'vitest'));
127
+ if (!hasVitest) {
128
+ console.error(colors.red + 'Vitest is not installed. Please run: npm install -D vitest' + colors.reset);
129
+ process.exit(1);
130
+ }
131
+
132
+ import('child_process').then(cp => {
133
+ console.log('\x1b[36m[bro.js]\x1b[0m Starting tests via vitest...');
134
+ cp.spawn('npx', ['vitest', ...process.argv.slice(3)], { stdio: 'inherit' });
135
+ });
136
+
137
+ } else if (command === 'init') {
138
+ const configPath = path.resolve(process.cwd(), 'bro.config.js');
139
+ const envPath = path.resolve(process.cwd(), '.env.example');
140
+ if (!fs.existsSync(configPath)) {
141
+ fs.writeFileSync(configPath, `export default {
142
+ server: { port: 3000, cors: false },
143
+ auth: { jwtSecret: process.env.JWT_SECRET }
144
+ };
145
+ `);
146
+ console.log('[bro.js] Created bro.config.js');
147
+ }
148
+ if (!fs.existsSync(envPath)) {
149
+ fs.writeFileSync(envPath, 'JWT_SECRET=\n');
150
+ console.log('[bro.js] Created .env.example');
151
+ }
152
+ } else if (command === 'doctor') {
153
+ console.log('[bro.js] Running doctor...');
154
+ const configPath = path.resolve(process.cwd(), 'bro.config.js');
155
+ if (!fs.existsSync(configPath)) {
156
+ console.error('✗ No bro.config.js found.');
157
+ } else {
158
+ import(pathToFileURL(configPath).href).then(m => {
159
+ const config = m.default || m;
160
+ if (config.server?.cors === true) console.error('✗ Permissive CORS is enabled (cors: true). Use an array of allowed origins.');
161
+ else console.log('✓ CORS is strict.');
162
+
163
+ if (['dev_secret_please_change', 'bro_default_secret_key', 'your_jwt_secret_here'].includes(config.auth?.jwtSecret)) {
164
+ console.error('✗ Hardcoded insecure JWT secret detected.');
165
+ } else {
166
+ console.log('✓ Secrets look ok.');
167
+ }
168
+ });
169
+ }
170
+ } else if (command === 'sdk' || command === 'client' || command === 'generate-client') {
171
+ const sdkOutPath = process.argv[3] || './client.ts';
172
+ const routesDir = path.resolve(process.cwd(), 'api');
173
+ generateSDK(routesDir, sdkOutPath).then(() => {
174
+ console.log(`\x1b[32m✓ SDK successfully generated at ${sdkOutPath}\x1b[0m`);
175
+ }).catch(err => {
176
+ console.error('\x1b[31m✗ Failed to generate SDK:\x1b[0m', err);
177
+ });
178
+ } else if (command === 'studio') {
179
+ import('../src/studio.js').then(({ startStudio }) => {
180
+ startStudio(process.cwd());
181
+ }).catch(err => console.error('[bro.js] Error starting studio:', err));
182
+ } else if (command === 'dev' || command === 'start') {
183
+ bootstrap();
184
+ } else if (!['init', 'doctor'].includes(command)) {
185
+ console.log(`\n ${colors.bold}${colors.green}bro.js CLI${colors.reset}\n`);
186
+ console.log(` ${colors.bold}Usage:${colors.reset} bro <command>\n`);
187
+ console.log(` ${colors.bold}Commands:${colors.reset}`);
188
+ console.log(` ${colors.cyan}dev${colors.reset} Start the development server with hot-reload`);
189
+ console.log(` ${colors.cyan}start${colors.reset} Start the production server gracefully`);
190
+ console.log(` ${colors.cyan}init${colors.reset} Scaffold a new bro.config.js workspace`);
191
+ console.log(` ${colors.cyan}sdk${colors.reset} Generate a typed frontend client`);
192
+ console.log(` ${colors.cyan}studio${colors.reset} Generate TS client, OpenAPI, and MSW mocks`);
193
+ console.log(` ${colors.cyan}doctor${colors.reset} Diagnose security and configuration issues`);
194
+ console.log(` ${colors.cyan}test${colors.reset} Run tests using vitest\n`);
195
+ }