bro-framework 2.4.4 → 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 +44 -0
- package/bin/bro.js +194 -292
- package/package.json +138 -103
- package/src/dashboard.js +80 -0
- package/src/database.js +105 -0
- package/src/edge.js +244 -0
- package/src/engine.js +245 -0
- package/src/index.d.ts +31 -11
- package/src/index.js +10 -0
- package/src/logger.js +47 -0
- package/src/next.d.ts +9 -9
- package/src/next.js +91 -129
- package/src/observability.js +81 -0
- package/src/plugins.js +135 -0
- package/src/policy-auth.js +88 -0
- package/src/router.js +1 -1
- package/src/sdk.js +229 -169
- package/src/server.js +181 -170
- package/src/studio.js +162 -0
- package/src/task-engine.js +88 -0
- package/src/testing.js +142 -0
- package/src/uploads.js +129 -0
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 (
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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 :
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|