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/src/server.js CHANGED
@@ -12,7 +12,14 @@ import { createAdapter } from '@socket.io/redis-adapter';
12
12
  import { apiReference } from '@scalar/express-api-reference';
13
13
  import { verifyJwt, signJwt } from './auth.js';
14
14
  import { loadLocale } from './locale.js';
15
- import { loadRoutes } from './router.js';
15
+ import { loadRoutes } from './router.js';
16
+ import { executeRequest, resolveIdentity, RouteRegistry } from './engine.js';
17
+
18
+ export function hashIdentity(identity) { return crypto.createHash('sha256').update(String(identity)).digest('hex'); }
19
+ export function generateRateLimitKey(req, config, prefix = 'route') { const identity = resolveIdentity(req, config); return `bro:rate_limit:${prefix}:${originalUrl}:${hashIdentity(identity)}`; }
20
+ export function generateCacheKey(req, config, locale) { const identity = resolveIdentity(req, config); return `bro:cache:${method}:${originalUrl}:${locale}:${hashIdentity(identity)}`; }
21
+ import { createLogger } from './logger.js';
22
+ import { PluginManager } from './plugins.js';
16
23
  import { scanTasks } from './tasks.js';
17
24
 
18
25
  /**
@@ -22,13 +29,34 @@ import { scanTasks } from './tasks.js';
22
29
  * @param {any} db - Initialized database instance.
23
30
  * @returns {Promise<{ app: import('express').Application, server: http.Server, routes: Array, reload: Function, reloadLocale: Function, io: import('socket.io').Server, shutdown: Function }>}
24
31
  */
25
- export async function createServer(globalConfig, routesDir, db) {
26
- if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key'].includes(globalConfig.jwtSecret)) {
27
- 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.');
32
+ export async function createServer(globalConfig, routesDir, db) {
33
+ if (db && typeof db.isReady !== 'function') {
34
+ throw new Error('[bro.js] CRITICAL: In v3.0.0, the \'db\' passed to createServer MUST extend BaseDatabaseAdapter and implement isReady(), transaction(), healthCheck(), and shutdown().');
35
+ }
36
+ const globalLogger = createLogger(globalConfig.logger || { level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' });
37
+
38
+ if (process.env.NODE_ENV === 'production' && (!globalConfig.jwtSecret || globalConfig.jwtSecret.length < 32 || ['dev_secret_please_change', 'bro_default_secret_key', 'your_jwt_secret_here'].includes(globalConfig.jwtSecret))) {
39
+ throw new Error('CRITICAL SECURITY ERROR: You are running in production without a secure JWT secret! Provide a secret of at least 32 characters.');
28
40
  }
29
41
 
30
- const app = express();
31
- const server = http.createServer(app);
42
+ const routeRegistry = new RouteRegistry();
43
+ const pluginManager = new PluginManager();
44
+ if (Array.isArray(globalConfig.plugins)) {
45
+ for (const plugin of globalConfig.plugins) {
46
+ pluginManager.register(plugin);
47
+ }
48
+ }
49
+
50
+ const app = express();
51
+ await pluginManager.runOnInit(globalConfig, app);
52
+ if (db) {
53
+ if (!await db.isReady()) {
54
+ throw new Error('[bro.js] Database adapter failed readiness check during boot.');
55
+ }
56
+ }
57
+ const server = http.createServer(app);
58
+ server.requestTimeout = globalConfig.server?.timeoutMs || 30000;
59
+ server.headersTimeout = globalConfig.server?.headersTimeoutMs || 35000;
32
60
  const localeDirectory = globalConfig.locale?.directory || path.join(process.cwd(), 'locale');
33
61
  let locale = await loadLocale(localeDirectory, globalConfig.locale);
34
62
 
@@ -47,12 +75,52 @@ export async function createServer(globalConfig, routesDir, db) {
47
75
  }));
48
76
  }
49
77
 
50
- const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
51
-
52
- if (corsConfig !== false) {
53
- app.use(cors(typeof corsConfig === 'object' ? corsConfig : {}));
78
+ const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : false;
79
+ if (corsConfig === true) {
80
+ if (process.env.NODE_ENV === 'production') {
81
+ throw new Error('CRITICAL SECURITY ERROR: Permissive CORS (cors: true) is forbidden in production in v3.0.0. Provide an explicit origin allowlist.');
82
+ }
83
+ app.use(cors());
84
+ } else if (corsConfig) {
85
+ app.use(cors(corsConfig));
54
86
  }
55
87
 
88
+ app.use((req, res, next) => {
89
+ req.id = req.headers['x-request-id'] || crypto.randomUUID();
90
+ res.setHeader('X-Request-Id', req.id);
91
+ next();
92
+ });
93
+
94
+ const sendError = (res, status, message, details = null, req) => {
95
+ let finalMessage = message;
96
+ let finalDetails = details;
97
+ if (status >= 500 && process.env.NODE_ENV === 'production') {
98
+ finalMessage = 'Internal Server Error';
99
+ finalDetails = null;
100
+ }
101
+ const codeMap = { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 429: 'TOO_MANY_REQUESTS' };
102
+ const code = codeMap[status] || (status >= 500 ? 'INTERNAL_ERROR' : 'ERROR');
103
+
104
+ // v3.0.0 ALWAYS uses RFC 9457 Problem Details
105
+ const payload = {
106
+ type: `https://brojs.dev/errors/${code.toLowerCase()}`,
107
+ title: finalMessage,
108
+ status,
109
+ instance: req.originalUrl || req.url,
110
+ requestId: req.id
111
+ };
112
+ if (finalDetails) {
113
+ if (typeof finalDetails === 'string') payload.detail = finalDetails;
114
+ else payload.errors = finalDetails;
115
+ }
116
+ return res.status(status).type('application/problem+json').json(payload);
117
+ };
118
+
119
+ const formatZodError = (error) => error.issues.map(i => ({ path: i.path.join('.'), message: i.message, code: i.code }));
120
+
121
+
122
+
123
+ app.use(pluginManager.getRequestMiddleware());
56
124
  app.use(express.json());
57
125
 
58
126
  const safeConnect = async (client) => {
@@ -96,13 +164,13 @@ export async function createServer(globalConfig, routesDir, db) {
96
164
  const fallbackLimiter = rateLimit(globalConfig.rateLimit);
97
165
  app.use(async (req, res, next) => {
98
166
  try {
99
- const key = `rate_limit:global:${req.ip}`;
167
+ const key = generateRateLimitKey(req, globalConfig, 'global');
100
168
  const current = await redisClient.incr(key);
101
169
  if (current === 1) {
102
170
  await redisClient.expire(key, Math.floor(globalConfig.rateLimit.windowMs / 1000));
103
171
  }
104
172
  if (current > globalConfig.rateLimit.max) {
105
- return res.status(429).json({ error: 'Too Many Requests' });
173
+ return sendError(res, 429, 'Too Many Requests', null, req);
106
174
  }
107
175
  next();
108
176
  } catch (err) {
@@ -145,30 +213,6 @@ export async function createServer(globalConfig, routesDir, db) {
145
213
  const paramsSchema = routeConfig.params;
146
214
  const querySchema = routeConfig.query;
147
215
 
148
- if (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
- }
170
- }
171
-
172
216
  if (routeConfig.upload) {
173
217
  const defaultLimits = { fileSize: 10 * 1024 * 1024, files: 5, fields: 20, parts: 25, fieldSize: 1024 * 1024 };
174
218
  const routeMulterConfig = {
@@ -195,136 +239,55 @@ export async function createServer(globalConfig, routesDir, db) {
195
239
  }
196
240
  }
197
241
 
198
- middlewares.push(async (req, res) => {
199
- try {
200
- const requestLocale = locale.resolveLocale(req);
201
- const ctx = {
202
- env: globalConfig.envData || process.env,
203
- db,
204
- io,
205
- redis: redisClient,
206
- body: req.body,
207
- params: req.params,
208
- query: req.query,
209
- files: req.files || req.file,
210
- locale: requestLocale,
211
- t: (key, values) => locale.translate(requestLocale, key, values),
212
- user: null,
213
- jwt: { sign: (payload, opts) => signJwt(payload, globalConfig.jwtSecret, opts || { expiresIn: globalConfig.auth?.expiresIn || '1d' }) },
214
- error: (status, message) => {
215
- const err = new Error(message);
216
- err.status = status;
217
- throw err;
218
- }
219
- };
220
-
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
- }
254
- }
255
-
256
- if (paramsSchema) {
257
- const result = paramsSchema.safeParse(req.params);
258
- if (!result.success) {
259
- return res.status(400).json({ error: 'Invalid URL Parameters', details: result.error.flatten() });
260
- }
261
- ctx.params = result.data;
262
- }
263
-
264
- if (bodySchema) {
265
- const result = bodySchema.safeParse(req.body);
266
- if (!result.success) {
267
- return res.status(400).json({ error: 'Invalid Request Body', details: result.error.flatten() });
268
- }
269
- ctx.body = result.data;
270
- }
271
-
272
- if (querySchema) {
273
- const result = querySchema.safeParse(req.query);
274
- if (!result.success) {
275
- return res.status(400).json({ error: 'Invalid Query Parameters', details: result.error.flatten() });
276
- }
277
- ctx.query = result.data;
278
- }
279
-
280
- if (typeof routeConfig.handler !== 'function') {
281
- throw new Error('Route "handler" is missing or is not a function');
282
- }
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
-
301
- const responseData = await routeConfig.handler(ctx);
302
-
303
- if (!res.headersSent) {
304
- if (cacheKey && routeConfig.cache) {
305
- await redisClient.setEx(cacheKey, routeConfig.cache, JSON.stringify(responseData));
306
- }
307
- res.status(200).json(responseData);
308
- }
309
-
310
- } catch (err) {
311
- const status = err.status || 500;
312
- const message = status === 500 ? 'Internal Server Error' : err.message;
313
-
314
- if (status === 500) {
315
- console.error(`[bro.js] Execution Error in route:`);
316
- console.error(err.stack);
317
- }
318
-
319
- if (!res.headersSent) {
320
- res.status(status).json({
321
- error: message,
322
- ...(status !== 500 && err.details ? { details: err.details } : {})
323
- });
324
- }
325
- }
326
- });
327
-
242
+ middlewares.push(async (req, res) => {
243
+ const requestData = {
244
+ method: req.method,
245
+ originalUrl: req.originalUrl,
246
+ headers: req.headers,
247
+ body: req.body,
248
+ query: req.query,
249
+ params: req.params,
250
+ files: req.files || req.file,
251
+ ip: req.ip,
252
+ locale: locale.resolveLocale(req)
253
+ };
254
+
255
+ const ctxExtras = {
256
+ verifyJwt,
257
+ generateRateLimitKey,
258
+ generateCacheKey,
259
+ generateId: () => crypto.randomUUID(),
260
+ req,
261
+ res,
262
+ db,
263
+ redis: redisClient,
264
+ io,
265
+ pluginManager,
266
+ t: (key, values) => locale.translate(requestData.locale, key, values),
267
+ logger: globalLogger,
268
+ jwt: { sign: (payload, opts) => signJwt(payload, globalConfig.jwtSecret, opts || { expiresIn: globalConfig.auth?.expiresIn || '1d' }) },
269
+ error: (status, message) => {
270
+ const err = new Error(message);
271
+ err.status = status;
272
+ throw err;
273
+ },
274
+ fixtures: globalConfig.fixtures || {},
275
+ stores: globalConfig.stores || {}
276
+ };
277
+
278
+ const response = await executeRequest(routeConfig, requestData, globalConfig, ctxExtras);
279
+
280
+ if (response.headers && !res.headersSent) {
281
+ for (const [k, v] of Object.entries(response.headers)) {
282
+ res.setHeader(k, v);
283
+ }
284
+ }
285
+
286
+ if (!res.headersSent) {
287
+ res.status(response.status).json(response.body);
288
+ }
289
+ });
290
+
328
291
  return middlewares;
329
292
  };
330
293
 
@@ -376,6 +339,52 @@ export async function createServer(globalConfig, routesDir, db) {
376
339
  app.use('/docs', docsAuthMiddleware, apiReference({ spec: { url: '/docs/json' } }));
377
340
  }
378
341
 
342
+ if (globalConfig.health) {
343
+ app.get('/health/live', (req, res) => {
344
+ res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
345
+ });
346
+
347
+ app.get('/health/ready', async (req, res) => {
348
+ let isReady = true;
349
+ const checks = {};
350
+
351
+ const timeoutPromise = (ms, promise) => {
352
+ return new Promise((resolve, reject) => {
353
+ const timer = setTimeout(() => reject(new Error('timeout')), ms);
354
+ promise.then(val => { clearTimeout(timer); resolve(val); }).catch(err => { clearTimeout(timer); reject(err); });
355
+ });
356
+ };
357
+
358
+ if (redisClient) {
359
+ try {
360
+ await timeoutPromise(2000, redisClient.ping());
361
+ checks.redis = 'up';
362
+ } catch (err) {
363
+ checks.redis = 'down';
364
+ isReady = false;
365
+ }
366
+ }
367
+
368
+ if (globalConfig.health === true || typeof globalConfig.health.dbCheck !== 'function') {
369
+ if (db) checks.db = 'unknown (provide health.dbCheck)';
370
+ } else if (db) {
371
+ try {
372
+ await timeoutPromise(2000, globalConfig.health.dbCheck(db));
373
+ checks.db = 'up';
374
+ } catch (err) {
375
+ checks.db = 'down';
376
+ isReady = false;
377
+ }
378
+ }
379
+
380
+ res.status(isReady ? 200 : 503).json({
381
+ status: isReady ? 'ready' : 'unavailable',
382
+ checks,
383
+ timestamp: new Date().toISOString()
384
+ });
385
+ });
386
+ }
387
+
379
388
  let routeStack = express.Router();
380
389
 
381
390
  app.use((req, res, next) => {
@@ -383,18 +392,19 @@ export async function createServer(globalConfig, routesDir, db) {
383
392
  });
384
393
 
385
394
  app.use((req, res) => {
386
- res.status(404).json({ error: 'Not Found' });
395
+ sendError(res, 404, 'Not Found', null, req);
387
396
  });
388
397
 
398
+ app.use(pluginManager.getErrorMiddleware());
389
399
  app.use((err, req, res, next) => {
390
400
  console.error(`[bro.js] Uncaught Error:`, err);
391
- res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
401
+ sendError(res, err.status || 500, err.message || 'Internal Server Error', null, req);
392
402
  });
393
403
 
394
404
  const reload = async () => {
395
405
  const newRouter = express.Router();
396
406
  const tempSpec = { paths: {} };
397
- const routes = await loadRoutes(newRouter, routesDir, createHandler, tempSpec);
407
+ const routes = await loadRoutes(newRouter, routesDir, createHandler, tempSpec, routeRegistry);
398
408
  openApiSpec.paths = tempSpec.paths;
399
409
  routeStack = newRouter;
400
410
  return routes;
@@ -410,7 +420,8 @@ export async function createServer(globalConfig, routesDir, db) {
410
420
  let taskManager = await scanTasks({ db, io });
411
421
 
412
422
  const reloadTasks = async () => {
413
- if (taskManager) taskManager.stopAll();
423
+ if (taskManager) taskManager.stopAll();
424
+ await pluginManager.runOnShutdown();
414
425
  taskManager = await scanTasks({ db, io });
415
426
  };
416
427
 
package/src/studio.js ADDED
@@ -0,0 +1,162 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { generateSDK } from './sdk.js';
4
+ import { scanDir, parseRouteFile } from './router.js';
5
+ import { pathToFileURL } from 'url';
6
+ import { zodToJsonSchema } from 'zod-to-json-schema';
7
+
8
+ /**
9
+ * Contract Studio Engine
10
+ * Generates TS Client, OpenAPI Spec, MSW Mocks, React Query Hooks, and Error Types
11
+ */
12
+ export class ContractStudio {
13
+ constructor(routesDir, routeRegistry) {
14
+ this.routeRegistry = routeRegistry;
15
+ this.routesDir = routesDir || path.join(process.cwd(), 'routes');
16
+ this.outputDir = path.join(process.cwd(), '.bro-studio');
17
+ }
18
+
19
+ async buildAll() {
20
+ if (!fs.existsSync(this.outputDir)) {
21
+ fs.mkdirSync(this.outputDir, { recursive: true });
22
+ }
23
+
24
+ // 1. TypeScript Client (reuse existing SDK generator)
25
+ await generateSDK();
26
+
27
+ // 2. Scan endpoints for other generators
28
+ const endpoints = await this._scanEndpoints();
29
+
30
+ // 3. Generate OpenAPI
31
+ await this.generateOpenAPI(endpoints);
32
+
33
+ // 4. Generate MSW Mocks
34
+ await this.generateMswMocks(endpoints);
35
+
36
+ // 5. Generate React Query Hooks
37
+ await this.generateReactQuery(endpoints);
38
+
39
+ console.log('[bro.js] Contract Studio generation complete.');
40
+ }
41
+
42
+ async _scanEndpoints() {
43
+ const files = scanDir(this.routesDir);
44
+ const endpoints = [];
45
+ let routeCounter = 0;
46
+
47
+ for (const file of files) {
48
+ const routeInfo = parseRouteFile(file, this.routesDir);
49
+ if (routeInfo) {
50
+ const mod = await import(pathToFileURL(file).href + '?t=' + Date.now());
51
+ endpoints.push({
52
+ ...routeInfo,
53
+ file,
54
+ config: mod.default,
55
+ moduleName: `Route${routeCounter++}`
56
+ });
57
+ }
58
+ }
59
+ return endpoints;
60
+ }
61
+
62
+ async generateOpenAPI(endpoints) {
63
+ const spec = {
64
+ openapi: '3.0.0',
65
+ info: { title: 'bro.js API', version: '1.0.0' },
66
+ paths: {}
67
+ };
68
+
69
+ for (const ep of endpoints) {
70
+ const pathKey = ep.routePath.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
71
+ if (!spec.paths[pathKey]) spec.paths[pathKey] = {};
72
+
73
+
74
+ // Stable Naming
75
+ let stableName = ep.config.operationId;
76
+ if (!stableName) {
77
+ const cleanPath = ep.routePath.replace(/[^a-zA-Z0-9]/g, ' ').trim().split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('');
78
+ stableName = ep.method.toLowerCase() + cleanPath;
79
+ }
80
+
81
+ const routeOp = {
82
+ operationId: stableName,
83
+ summary: ep.config.summary || `${ep.method.toUpperCase()} ${ep.routePath}`,
84
+ responses: {
85
+ '200': { description: 'Successful response' },
86
+ '400': { description: 'Validation error' },
87
+ '401': { description: 'Unauthorized' }
88
+ }
89
+ };
90
+
91
+ if (ep.config.response) {
92
+ routeOp.responses['200'].content = {
93
+ 'application/json': {
94
+ schema: zodToJsonSchema(ep.config.response, { target: 'openApi3' })
95
+ }
96
+ };
97
+ }
98
+
99
+ spec.paths[pathKey][ep.method.toLowerCase()] = routeOp;
100
+ }
101
+
102
+ fs.writeFileSync(
103
+ path.join(this.outputDir, 'openapi.json'),
104
+ JSON.stringify(spec, null, 2)
105
+ );
106
+ }
107
+
108
+ async generateMswMocks(endpoints) {
109
+ let mswCode = `import { http, HttpResponse } from 'msw';\n\nexport const handlers = [\n`;
110
+ for (const ep of endpoints) {
111
+ const mswPath = ep.routePath.replace(/:([a-zA-Z0-9_]+)/g, ':$1');
112
+ mswCode += ` http.${ep.method.toLowerCase()}('*${mswPath}', ({ request, params, cookies }) => {\n`;
113
+
114
+ let mockData = '{ mock: true }';
115
+ if (ep.config && ep.config.response) {
116
+ const schema = zodToJsonSchema(ep.config.response);
117
+ if (schema.type === 'object' && schema.properties) {
118
+ const mockObj = {};
119
+ for (const key of Object.keys(schema.properties)) {
120
+ mockObj[key] = schema.properties[key].type === 'string' ? 'mock_string' : schema.properties[key].type === 'number' ? 123 : schema.properties[key].type === 'boolean' ? true : null;
121
+ }
122
+ mockData = JSON.stringify(mockObj);
123
+ }
124
+ }
125
+
126
+ mswCode += ` return HttpResponse.json(${mockData});\n`;
127
+ mswCode += ` }),\n`;
128
+ }
129
+ mswCode += `];\n`;
130
+ fs.writeFileSync(path.join(this.outputDir, 'msw.js'), mswCode);
131
+ }
132
+
133
+ async generateReactQuery(endpoints) {
134
+ let rqCode = `import { useQuery, useMutation } from '@tanstack/react-query';\nimport { api } from './bro-sdk';\n\n`;
135
+ for (const ep of endpoints) {
136
+ const hookName = `use${ep.moduleName}`;
137
+ const fnName = ep.moduleName.charAt(0).toLowerCase() + ep.moduleName.slice(1);
138
+ if (ep.method.toLowerCase() === 'get') {
139
+ rqCode += `export function ${hookName}(query, options) {\n return useQuery({\n queryKey: ['${ep.routePath}', query],\n queryFn: () => api.${fnName}(query),\n ...options\n });\n}\n\n`;
140
+ } else {
141
+ rqCode += `export function ${hookName}(options) {\n return useMutation({\n mutationFn: (data) => api.${fnName}(data),\n ...options\n });\n}\n\n`;
142
+ }
143
+ }
144
+ fs.writeFileSync(path.join(this.outputDir, 'react-query.js'), rqCode);
145
+ }
146
+
147
+ async watch() {
148
+ console.log('[bro.js] Contract Studio watching for route changes...');
149
+ const chokidar = await import('chokidar');
150
+ chokidar.watch(this.routesDir).on('change', async (path) => {
151
+ console.log(`[bro.js] Route ${path} changed, rebuilding contracts...`);
152
+ await this.buildAll();
153
+ });
154
+ }
155
+ }
156
+
157
+ export async function startStudio(cwd) {
158
+ console.log('[bro.js] Starting Contract Studio generation...');
159
+ const studio = new ContractStudio(path.join(cwd, 'api'));
160
+ await studio.buildAll();
161
+ console.log('[bro.js] Studio generation complete.');
162
+ }
@@ -0,0 +1,88 @@
1
+ import cron from 'node-cron';
2
+
3
+ /**
4
+ * Upgraded Task/Worker Model
5
+ * Supports concurrency, retries, leases, and dead-letter queues.
6
+ */
7
+ export class TaskManager {
8
+ constructor(config = {}) {
9
+ this.tasks = new Map();
10
+ this.redisClient = config.redisClient || null;
11
+ this.logger = config.logger || console;
12
+ }
13
+
14
+ register(name, schedule, handler, options = {}) {
15
+ const defaultOptions = { retries: 3, concurrency: 1, deadLetter: true, timeoutMs: 30000 };
16
+ const taskDef = { name, schedule, handler, options: { ...defaultOptions, ...options } };
17
+
18
+ let taskJob;
19
+ if (schedule) {
20
+ taskJob = cron.schedule(schedule, async () => {
21
+ await this.execute(name);
22
+ }, { scheduled: false });
23
+ }
24
+
25
+ this.tasks.set(name, { ...taskDef, job: taskJob });
26
+ }
27
+
28
+ startAll() {
29
+ for (const [name, task] of this.tasks.entries()) {
30
+ if (task.job) {
31
+ task.job.start();
32
+ this.logger.info(`[bro.js/tasks] Task '${name}' scheduled (${task.schedule})`);
33
+ }
34
+ }
35
+ }
36
+
37
+ stopAll() {
38
+ for (const task of this.tasks.values()) {
39
+ if (task.job) task.job.stop();
40
+ }
41
+ }
42
+
43
+ async execute(name) {
44
+ const task = this.tasks.get(name);
45
+ if (!task) throw new Error(`Task '${name}' not found`);
46
+
47
+ const leaseKey = `bro:task_lease:${name}`;
48
+ if (this.redisClient) {
49
+ const acquired = await this.redisClient.set(leaseKey, 'locked', { NX: true, PX: task.options.timeoutMs });
50
+ if (!acquired) {
51
+ this.logger.debug(`[bro.js/tasks] Task '${name}' skipped (locked)`);
52
+ return;
53
+ }
54
+ }
55
+
56
+ let attempt = 0;
57
+ while (attempt < task.options.retries) {
58
+ try {
59
+ await Promise.race([
60
+ task.handler(),
61
+ new Promise((_, rej) => setTimeout(() => rej(new Error('Task Timeout')), task.options.timeoutMs))
62
+ ]);
63
+ break; // Success
64
+ } catch (err) {
65
+ attempt++;
66
+ this.logger.error(`[bro.js/tasks] Task '${name}' attempt ${attempt} failed: ${err.message}`);
67
+ if (attempt >= task.options.retries) {
68
+ if (task.options.deadLetter && this.redisClient) {
69
+ await this.redisClient.rPush('bro:dead_letter_queue', JSON.stringify({ name, error: err.message, time: Date.now() }));
70
+ }
71
+ } else {
72
+ await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000)); // Exponential backoff
73
+ }
74
+ }
75
+ }
76
+ if (this.redisClient) await this.redisClient.del(leaseKey);
77
+ }
78
+
79
+ async _handleDeadLetter(name, error) {
80
+ if (!this.redisClient) return;
81
+ const dlqKey = `bro:dlq:${name}`;
82
+ try {
83
+ await this.redisClient.lPush(dlqKey, JSON.stringify({ error: error.message, time: new Date().toISOString() }));
84
+ } catch (e) {
85
+ this.logger.error(`[bro.js/tasks] Failed to push dead letter for '${name}':`, e);
86
+ }
87
+ }
88
+ }