bro-framework 2.4.3 → 2.4.5

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/bin/bro.js CHANGED
@@ -207,7 +207,8 @@ async function bootstrap() {
207
207
  }
208
208
 
209
209
  const localeDir = globalConfig.locale?.directory || path.join(cwd, 'locale');
210
- const { app, server, routes: initialRoutes, reload, reloadLocale, io, shutdown } = await createServer(globalConfig, routesDir, db);
210
+ const tasksDir = path.join(cwd, 'tasks');
211
+ const { app, server, routes: initialRoutes, reload, reloadLocale, reloadTasks, io, shutdown } = await createServer(globalConfig, routesDir, db);
211
212
  const port = globalConfig.port;
212
213
 
213
214
  let currentRoutes = initialRoutes;
@@ -234,28 +235,35 @@ async function bootstrap() {
234
235
 
235
236
  printCurrentRoutes(currentRoutes);
236
237
 
237
- const localeGlob = localeDir.replace(/\\/g, '/') + '/*.{js,mjs,ts}';
238
- const watcher = chokidar.watch([routesDir, localeGlob], { ignoreInitial: true });
238
+ const localeGlob = localeDir.replace(/\\/g, '/') + '/*.{js,mjs,ts,json}';
239
+ const watcher = chokidar.watch([routesDir, localeGlob, tasksDir], { ignoreInitial: true });
239
240
 
240
241
  watcher.on('all', async (event, filepath) => {
241
- const isJavaScriptFile = filepath.endsWith('.js') || filepath.endsWith('.ts') || filepath.endsWith('.mjs');
242
- if (!isJavaScriptFile) return;
242
+ const isValidFile = filepath.match(/\.(js|ts|mjs|json)$/);
243
+ if (!isValidFile) return;
243
244
  const relLocale = path.relative(path.resolve(localeDir), filepath);
244
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);
245
249
 
246
250
  try {
247
251
  const reloadStartTime = performance.now();
248
252
  if (isLocaleFile) {
249
253
  await reloadLocale();
254
+ } else if (isTaskFile) {
255
+ await reloadTasks();
250
256
  } else {
251
257
  currentRoutes = await reload();
252
258
  }
253
259
  const reloadTimeMs = performance.now() - reloadStartTime;
254
260
 
255
- printHotReload(path.basename(filepath), event, reloadTimeMs, isLocaleFile ? 'Locale' : 'Route');
261
+ const fileType = isTaskFile ? 'Task' : (isLocaleFile ? 'Locale' : 'Route');
262
+ printHotReload(path.basename(filepath), event, reloadTimeMs, fileType);
256
263
  printCurrentRoutes(currentRoutes);
257
264
  } catch (err) {
258
- console.error(`\n ✗ Error hot-reloading ${isLocaleFile ? 'locale' : 'routes'}:`, err);
265
+ const fileType = isTaskFile ? 'tasks' : (isLocaleFile ? 'locale' : 'routes');
266
+ console.error(`\n ✗ Error hot-reloading ${fileType}:`, err);
259
267
  }
260
268
  });
261
269
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bro-framework",
3
- "version": "2.4.3",
3
+ "version": "2.4.5",
4
4
  "description": "The No-BS Backend Framework for Node.js",
5
5
  "repository": {
6
6
  "type": "git",
package/src/locale.js CHANGED
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { pathToFileURL } from 'url';
4
4
  import crypto from 'node:crypto';
5
5
 
6
- const LOCALE_EXTENSIONS = new Set(['.js', '.mjs', '.ts']);
6
+ const LOCALE_EXTENSIONS = new Set(['.js', '.mjs', '.ts', '.json']);
7
7
 
8
8
  function localeFromFilename(fileName) {
9
9
  return path.basename(fileName, path.extname(fileName));
package/src/next.d.ts CHANGED
@@ -12,11 +12,15 @@ export interface UploadedFile {
12
12
  }
13
13
 
14
14
  export interface NextBroGlobalConfig<TDb = any> {
15
- locale?: any;
15
+ env?: ZodTypeAny;
16
+ locales?: Record<string, any>;
17
+ defaultLocale?: string;
16
18
  redisUrl?: string;
19
+ rateLimit?: { windowMs: number; max: number; };
17
20
  auth?: {
18
21
  jwtSecret?: string;
19
22
  apiKey?: string | string[];
23
+ expiresIn?: string | number;
20
24
  };
21
25
  db?: TDb | Promise<TDb> | (() => TDb | Promise<TDb>) | { init: () => TDb | Promise<TDb> };
22
26
  }
@@ -44,6 +48,11 @@ export interface NextRouteConfig<TBody = any, TQuery = any, TParams = any, TDb =
44
48
  body?: TBody;
45
49
  query?: TQuery;
46
50
  params?: TParams;
51
+ cache?: number;
52
+ rateLimit?: { windowMs: number; max: number; } | false;
53
+ response?: ZodTypeAny;
54
+ summary?: string;
55
+ upload?: any;
47
56
  handler: (ctx: NextRouteContext<TBody, TQuery, TParams, TDb>) => Promise<any> | any;
48
57
  }
49
58
 
package/src/next.js CHANGED
@@ -3,13 +3,19 @@ import { z } from 'zod';
3
3
  import { verifyJwt, signJwt } from './auth.js';
4
4
  import { createClient } from 'redis';
5
5
 
6
+ export { z };
7
+
6
8
  export function createBro(globalConfig = {}) {
7
9
  let isInitialized = false;
8
10
  let initPromise = null;
9
11
 
10
12
  let globalDb = null;
11
- let globalRedis = null;
12
13
  let globalLocale = null;
14
+
15
+ const globalStore = globalThis;
16
+ globalStore.__broRedis = globalStore.__broRedis || null;
17
+ globalStore.__broMemoryCache = globalStore.__broMemoryCache || new Map();
18
+ globalStore.__broMemoryRateLimit = globalStore.__broMemoryRateLimit || new Map();
13
19
 
14
20
  async function ensureInitialized() {
15
21
  if (isInitialized) return;
@@ -17,17 +23,15 @@ export function createBro(globalConfig = {}) {
17
23
 
18
24
  initPromise = (async () => {
19
25
  try {
20
- // Locale Dummy or Real
21
- if (globalConfig.locale && typeof globalConfig.locale.resolveLocale === 'function') {
22
- globalLocale = globalConfig.locale;
23
- } else {
24
- globalLocale = {
25
- resolveLocale: () => 'en',
26
- translate: (locale, key) => key
27
- };
26
+ if (globalConfig.env) {
27
+ try {
28
+ globalConfig.env.parse(process.env);
29
+ } catch (err) {
30
+ console.error('[bro.js/next] Environment Validation Error:', err);
31
+ throw err;
32
+ }
28
33
  }
29
34
 
30
- // DB
31
35
  if (typeof globalConfig.db === 'function') {
32
36
  globalDb = await globalConfig.db();
33
37
  } else if (globalConfig.db && typeof globalConfig.db.init === 'function') {
@@ -37,12 +41,11 @@ export function createBro(globalConfig = {}) {
37
41
  if (globalDb instanceof Promise) globalDb = await globalDb;
38
42
  }
39
43
 
40
- // Redis
41
44
  if (globalConfig.redisUrl) {
42
- globalRedis = createClient({ url: globalConfig.redisUrl });
43
- globalRedis.on('error', (err) => console.error('[bro.js/next] Redis Error:', err));
44
- if (globalRedis.status === 'wait' || !globalRedis.status) {
45
- await globalRedis.connect().catch(err => {
45
+ globalStore.__broRedis = createClient({ url: globalConfig.redisUrl });
46
+ globalStore.__broRedis.on('error', (err) => console.error('[bro.js/next] Redis Error:', err));
47
+ if (!globalStore.__broRedis.isOpen) {
48
+ await globalStore.__broRedis.connect().catch(err => {
46
49
  if (!err.message.includes('already connecting') && !err.message.includes('already connected')) {
47
50
  throw err;
48
51
  }
@@ -60,6 +63,45 @@ export function createBro(globalConfig = {}) {
60
63
  return initPromise;
61
64
  }
62
65
 
66
+ const resolveLocale = (headers) => {
67
+ const acceptLanguage = headers['accept-language'] || '';
68
+ const preferredLanguages = acceptLanguage
69
+ .split(',')
70
+ .map(lang => lang.split(';')[0].trim().toLowerCase())
71
+ .filter(lang => lang);
72
+
73
+ const configuredLocales = Object.keys(globalConfig.locales || {});
74
+
75
+ for (const lang of preferredLanguages) {
76
+ if (configuredLocales.includes(lang)) {
77
+ return lang;
78
+ }
79
+ const baseLang = lang.split('-')[0];
80
+ if (configuredLocales.includes(baseLang)) {
81
+ return baseLang;
82
+ }
83
+ }
84
+
85
+ return globalConfig.defaultLocale || 'en';
86
+ };
87
+
88
+ const translate = (locale, key, values = {}) => {
89
+ let messages = globalConfig.locales?.[locale] || globalConfig.locales?.[globalConfig.defaultLocale || 'en'];
90
+ if (!messages) return key;
91
+
92
+ // Handle Webpack / ES module JSON interop where the object is under .default
93
+ if (messages.default && typeof messages.default === 'object') {
94
+ messages = messages.default;
95
+ }
96
+
97
+ const message = key.split('.').reduce((acc, part) => acc && acc[part], messages);
98
+ if (!message || typeof message !== 'string') return key;
99
+
100
+ return message.replace(/\{(\w+)\}/g, (_, name) => {
101
+ return values[name] !== undefined ? String(values[name]) : `{${name}}`;
102
+ });
103
+ };
104
+
63
105
  const errorHelper = (status, message) => {
64
106
  const err = new Error(message);
65
107
  err.status = status;
@@ -71,54 +113,11 @@ export function createBro(globalConfig = {}) {
71
113
  try {
72
114
  await ensureInitialized();
73
115
 
74
- const rawParams = context?.params ? await context.params : {};
75
-
76
- const url = new URL(req.url);
77
- const rawQuery = Object.fromEntries(url.searchParams.entries());
78
-
79
- let rawBody = {};
80
- const parsedFiles = {};
81
- let totalFiles = 0;
82
-
83
- if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
84
- const contentType = req.headers.get('content-type') || '';
85
-
86
- if (contentType.includes('multipart/form-data')) {
87
- try {
88
- const formData = await req.formData();
89
- for (const [key, value] of formData.entries()) {
90
- if (value instanceof File || value instanceof Blob) {
91
- if (!parsedFiles[key]) parsedFiles[key] = [];
92
- parsedFiles[key].push(value);
93
- totalFiles++;
94
- } else {
95
- rawBody[key] = value;
96
- }
97
- }
98
- } catch (err) {}
99
- } else {
100
- try {
101
- rawBody = await req.json();
102
- } catch (err) {}
103
- }
104
- }
105
-
106
- let body, params, query;
107
- try {
108
- if (config.body) body = await config.body.parseAsync(rawBody);
109
- if (config.params) params = await config.params.parseAsync(rawParams);
110
- if (config.query) query = await config.query.parseAsync(rawQuery);
111
- } catch (err) {
112
- if (err instanceof z.ZodError) {
113
- return NextResponse.json(
114
- { error: 'Validation Error', issues: err.issues },
115
- { status: 400 }
116
- );
117
- }
118
- throw err;
119
- }
116
+ const resolvedLocale = resolveLocale(Object.fromEntries(req.headers.entries()));
120
117
 
118
+ // Auth extraction early for Identity caching
121
119
  let user = null;
120
+ let apiKeyUsed = null;
122
121
  if (config.auth) {
123
122
  if (config.auth === 'api-key') {
124
123
  const apiKey = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace('Bearer ', '');
@@ -133,6 +132,7 @@ export function createBro(globalConfig = {}) {
133
132
  if (!isValid) {
134
133
  return NextResponse.json({ error: 'Unauthorized', details: 'Missing or invalid API key' }, { status: 401 });
135
134
  }
135
+ apiKeyUsed = apiKey;
136
136
  } else {
137
137
  const authHeader = req.headers.get('authorization');
138
138
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -161,8 +161,113 @@ export function createBro(globalConfig = {}) {
161
161
  }
162
162
  }
163
163
 
164
- const resolvedLocale = globalLocale.resolveLocale({ headers: Object.fromEntries(req.headers.entries()) });
164
+ // Rate Limiting
165
+ const activeRateLimit = config.rateLimit === false ? null : (config.rateLimit || globalConfig.rateLimit);
166
+ if (activeRateLimit) {
167
+ const ip = req.headers.get('x-forwarded-for') || 'ip';
168
+ const urlObj = new URL(req.url);
169
+ const rlKey = `rate-limit:${urlObj.pathname}:${ip}`;
170
+
171
+ if (globalStore.__broRedis) {
172
+ const currentCount = await globalStore.__broRedis.incr(rlKey);
173
+ if (currentCount === 1) {
174
+ await globalStore.__broRedis.expire(rlKey, Math.ceil(activeRateLimit.windowMs / 1000));
175
+ }
176
+ if (currentCount > activeRateLimit.max) {
177
+ return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
178
+ }
179
+ } else {
180
+ const now = Date.now();
181
+ let record = globalStore.__broMemoryRateLimit.get(rlKey);
182
+
183
+ if (!record || now > record.expires) {
184
+ record = { count: 0, expires: now + activeRateLimit.windowMs };
185
+ }
186
+
187
+ record.count++;
188
+ globalStore.__broMemoryRateLimit.set(rlKey, record);
189
+
190
+ if (record.count > activeRateLimit.max) {
191
+ return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
192
+ }
193
+ }
194
+ }
195
+
196
+ // Caching
197
+ let cacheKey = null;
198
+ if (config.cache && req.method === 'GET') {
199
+ const urlObj = new URL(req.url);
200
+ const identity = user ? (user.id || user.role || 'user') : (apiKeyUsed || 'anon');
201
+ cacheKey = `cache:${urlObj.pathname}${urlObj.search}:${resolvedLocale}:${identity}`;
202
+
203
+ if (globalStore.__broRedis) {
204
+ const cachedData = await globalStore.__broRedis.get(cacheKey);
205
+ if (cachedData) {
206
+ return NextResponse.json(JSON.parse(cachedData), { status: 200 });
207
+ }
208
+ } else {
209
+ const cached = globalStore.__broMemoryCache.get(cacheKey);
210
+ if (cached) {
211
+ if (Date.now() < cached.expires) {
212
+ return NextResponse.json(cached.data, { status: 200 });
213
+ } else {
214
+ globalStore.__broMemoryCache.delete(cacheKey);
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ const rawParams = context?.params ? await context.params : {};
221
+ const url = new URL(req.url);
222
+ const rawQuery = Object.fromEntries(url.searchParams.entries());
165
223
 
224
+ let rawBody = {};
225
+ const parsedFiles = {};
226
+ let totalFiles = 0;
227
+
228
+ if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
229
+ const contentType = req.headers.get('content-type') || '';
230
+
231
+ if (contentType.includes('multipart/form-data')) {
232
+ try {
233
+ const formData = await req.formData();
234
+ for (const [key, value] of formData.entries()) {
235
+ if (value instanceof File || value instanceof Blob) {
236
+ if (!parsedFiles[key]) parsedFiles[key] = [];
237
+ parsedFiles[key].push(value);
238
+ totalFiles++;
239
+ } else {
240
+ rawBody[key] = value;
241
+ }
242
+ }
243
+ } catch (err) {}
244
+ } else {
245
+ try {
246
+ rawBody = await req.json();
247
+ } catch (err) {}
248
+ }
249
+ }
250
+
251
+ let body, params, query;
252
+ try {
253
+ if (config.body) body = await config.body.parseAsync(rawBody);
254
+ } catch (err) {
255
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Request Body', details: err.issues }, { status: 400 });
256
+ throw err;
257
+ }
258
+ try {
259
+ if (config.params) params = await config.params.parseAsync(rawParams);
260
+ } catch (err) {
261
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid URL Parameters', details: err.issues }, { status: 400 });
262
+ throw err;
263
+ }
264
+ try {
265
+ if (config.query) query = await config.query.parseAsync(rawQuery);
266
+ } catch (err) {
267
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Query Parameters', details: err.issues }, { status: 400 });
268
+ throw err;
269
+ }
270
+
166
271
  let ctxFile = undefined;
167
272
  let ctxFiles = undefined;
168
273
  if (totalFiles === 1) {
@@ -177,7 +282,7 @@ export function createBro(globalConfig = {}) {
177
282
  req,
178
283
  env: process.env,
179
284
  db: globalDb,
180
- redis: globalRedis,
285
+ redis: globalStore.__broRedis,
181
286
  io: { emit: () => console.warn('[bro.js/next] WebSockets require standard bro.js server.') },
182
287
  body,
183
288
  params,
@@ -185,13 +290,24 @@ export function createBro(globalConfig = {}) {
185
290
  file: ctxFile,
186
291
  files: ctxFiles,
187
292
  locale: resolvedLocale,
188
- t: (key, values) => globalLocale.translate(resolvedLocale, key, values),
293
+ t: (key, values) => translate(resolvedLocale, key, values),
189
294
  user,
190
- jwt: { sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, opts) },
295
+ jwt: {
296
+ sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, Object.assign({ expiresIn: globalConfig?.auth?.expiresIn || '1d' }, opts || {}))
297
+ },
191
298
  error: errorHelper
192
299
  };
193
300
 
194
301
  const result = await config.handler(ctx);
302
+
303
+ if (cacheKey) {
304
+ if (globalStore.__broRedis) {
305
+ await globalStore.__broRedis.set(cacheKey, JSON.stringify(result), { EX: config.cache });
306
+ } else {
307
+ globalStore.__broMemoryCache.set(cacheKey, { data: result, expires: Date.now() + (config.cache * 1000) });
308
+ }
309
+ }
310
+
195
311
  return NextResponse.json(result, { status: 200 });
196
312
 
197
313
  } catch (error) {
package/src/router.js CHANGED
@@ -157,7 +157,6 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
157
157
  }
158
158
  }
159
159
 
160
- // Auto-inject security definition if auth is true
161
160
  if (config.auth === 'api-key') {
162
161
  operation.security = [{ apiKeyAuth: [] }];
163
162
  } else if (config.auth) {
package/src/server.js CHANGED
@@ -407,7 +407,12 @@ export async function createServer(globalConfig, routesDir, db) {
407
407
 
408
408
  const initialRoutes = await reload();
409
409
 
410
- const taskManager = await scanTasks({ db, io });
410
+ let taskManager = await scanTasks({ db, io });
411
+
412
+ const reloadTasks = async () => {
413
+ if (taskManager) taskManager.stopAll();
414
+ taskManager = await scanTasks({ db, io });
415
+ };
411
416
 
412
417
  let isShuttingDown = false;
413
418
  const shutdown = async () => {
@@ -434,5 +439,5 @@ export async function createServer(globalConfig, routesDir, db) {
434
439
  });
435
440
  };
436
441
 
437
- return { app, server, routes: initialRoutes, reload, reloadLocale, io, shutdown };
442
+ return { app, server, routes: initialRoutes, reload, reloadLocale, reloadTasks, io, shutdown };
438
443
  }