bro-framework 2.4.3 → 2.4.4

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.4",
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,6 +3,8 @@ 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;
@@ -17,17 +19,15 @@ export function createBro(globalConfig = {}) {
17
19
 
18
20
  initPromise = (async () => {
19
21
  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
- };
22
+ if (globalConfig.env) {
23
+ try {
24
+ globalConfig.env.parse(process.env);
25
+ } catch (err) {
26
+ console.error('[bro.js/next] Environment Validation Error:', err);
27
+ throw err;
28
+ }
28
29
  }
29
30
 
30
- // DB
31
31
  if (typeof globalConfig.db === 'function') {
32
32
  globalDb = await globalConfig.db();
33
33
  } else if (globalConfig.db && typeof globalConfig.db.init === 'function') {
@@ -37,7 +37,6 @@ export function createBro(globalConfig = {}) {
37
37
  if (globalDb instanceof Promise) globalDb = await globalDb;
38
38
  }
39
39
 
40
- // Redis
41
40
  if (globalConfig.redisUrl) {
42
41
  globalRedis = createClient({ url: globalConfig.redisUrl });
43
42
  globalRedis.on('error', (err) => console.error('[bro.js/next] Redis Error:', err));
@@ -60,6 +59,45 @@ export function createBro(globalConfig = {}) {
60
59
  return initPromise;
61
60
  }
62
61
 
62
+ const resolveLocale = (headers) => {
63
+ const acceptLanguage = headers['accept-language'] || '';
64
+ const preferredLanguages = acceptLanguage
65
+ .split(',')
66
+ .map(lang => lang.split(';')[0].trim().toLowerCase())
67
+ .filter(lang => lang);
68
+
69
+ const configuredLocales = Object.keys(globalConfig.locales || {});
70
+
71
+ for (const lang of preferredLanguages) {
72
+ if (configuredLocales.includes(lang)) {
73
+ return lang;
74
+ }
75
+ const baseLang = lang.split('-')[0];
76
+ if (configuredLocales.includes(baseLang)) {
77
+ return baseLang;
78
+ }
79
+ }
80
+
81
+ return globalConfig.defaultLocale || 'en';
82
+ };
83
+
84
+ const translate = (locale, key, values = {}) => {
85
+ let messages = globalConfig.locales?.[locale] || globalConfig.locales?.[globalConfig.defaultLocale || 'en'];
86
+ if (!messages) return key;
87
+
88
+ // Handle Webpack / ES module JSON interop where the object is under .default
89
+ if (messages.default && typeof messages.default === 'object') {
90
+ messages = messages.default;
91
+ }
92
+
93
+ const message = key.split('.').reduce((acc, part) => acc && acc[part], messages);
94
+ if (!message || typeof message !== 'string') return key;
95
+
96
+ return message.replace(/\{(\w+)\}/g, (_, name) => {
97
+ return values[name] !== undefined ? String(values[name]) : `{${name}}`;
98
+ });
99
+ };
100
+
63
101
  const errorHelper = (status, message) => {
64
102
  const err = new Error(message);
65
103
  err.status = status;
@@ -71,54 +109,11 @@ export function createBro(globalConfig = {}) {
71
109
  try {
72
110
  await ensureInitialized();
73
111
 
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
- }
112
+ const resolvedLocale = resolveLocale(Object.fromEntries(req.headers.entries()));
120
113
 
114
+ // Auth extraction early for Identity caching
121
115
  let user = null;
116
+ let apiKeyUsed = null;
122
117
  if (config.auth) {
123
118
  if (config.auth === 'api-key') {
124
119
  const apiKey = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace('Bearer ', '');
@@ -133,6 +128,7 @@ export function createBro(globalConfig = {}) {
133
128
  if (!isValid) {
134
129
  return NextResponse.json({ error: 'Unauthorized', details: 'Missing or invalid API key' }, { status: 401 });
135
130
  }
131
+ apiKeyUsed = apiKey;
136
132
  } else {
137
133
  const authHeader = req.headers.get('authorization');
138
134
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
@@ -161,8 +157,85 @@ export function createBro(globalConfig = {}) {
161
157
  }
162
158
  }
163
159
 
164
- const resolvedLocale = globalLocale.resolveLocale({ headers: Object.fromEntries(req.headers.entries()) });
160
+ // Rate Limiting
161
+ const activeRateLimit = config.rateLimit === false ? null : (config.rateLimit || globalConfig.rateLimit);
162
+ if (activeRateLimit && globalRedis) {
163
+ const ip = req.headers.get('x-forwarded-for') || 'ip';
164
+ const urlObj = new URL(req.url);
165
+ const rlKey = `rate-limit:${urlObj.pathname}:${ip}`;
166
+ const currentCount = await globalRedis.incr(rlKey);
167
+ if (currentCount === 1) {
168
+ await globalRedis.expire(rlKey, Math.ceil(activeRateLimit.windowMs / 1000));
169
+ }
170
+ if (currentCount > activeRateLimit.max) {
171
+ return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
172
+ }
173
+ }
174
+
175
+ // Caching
176
+ let cacheKey = null;
177
+ if (config.cache && globalRedis && req.method === 'GET') {
178
+ const urlObj = new URL(req.url);
179
+ const identity = user ? (user.id || user.role || 'user') : (apiKeyUsed || 'anon');
180
+ cacheKey = `cache:${urlObj.pathname}${urlObj.search}:${resolvedLocale}:${identity}`;
181
+
182
+ const cachedData = await globalRedis.get(cacheKey);
183
+ if (cachedData) {
184
+ return NextResponse.json(JSON.parse(cachedData), { status: 200 });
185
+ }
186
+ }
187
+
188
+ const rawParams = context?.params ? await context.params : {};
189
+ const url = new URL(req.url);
190
+ const rawQuery = Object.fromEntries(url.searchParams.entries());
165
191
 
192
+ let rawBody = {};
193
+ const parsedFiles = {};
194
+ let totalFiles = 0;
195
+
196
+ if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
197
+ const contentType = req.headers.get('content-type') || '';
198
+
199
+ if (contentType.includes('multipart/form-data')) {
200
+ try {
201
+ const formData = await req.formData();
202
+ for (const [key, value] of formData.entries()) {
203
+ if (value instanceof File || value instanceof Blob) {
204
+ if (!parsedFiles[key]) parsedFiles[key] = [];
205
+ parsedFiles[key].push(value);
206
+ totalFiles++;
207
+ } else {
208
+ rawBody[key] = value;
209
+ }
210
+ }
211
+ } catch (err) {}
212
+ } else {
213
+ try {
214
+ rawBody = await req.json();
215
+ } catch (err) {}
216
+ }
217
+ }
218
+
219
+ let body, params, query;
220
+ try {
221
+ if (config.body) body = await config.body.parseAsync(rawBody);
222
+ } catch (err) {
223
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Request Body', details: err.issues }, { status: 400 });
224
+ throw err;
225
+ }
226
+ try {
227
+ if (config.params) params = await config.params.parseAsync(rawParams);
228
+ } catch (err) {
229
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid URL Parameters', details: err.issues }, { status: 400 });
230
+ throw err;
231
+ }
232
+ try {
233
+ if (config.query) query = await config.query.parseAsync(rawQuery);
234
+ } catch (err) {
235
+ if (err instanceof z.ZodError) return NextResponse.json({ error: 'Invalid Query Parameters', details: err.issues }, { status: 400 });
236
+ throw err;
237
+ }
238
+
166
239
  let ctxFile = undefined;
167
240
  let ctxFiles = undefined;
168
241
  if (totalFiles === 1) {
@@ -185,13 +258,20 @@ export function createBro(globalConfig = {}) {
185
258
  file: ctxFile,
186
259
  files: ctxFiles,
187
260
  locale: resolvedLocale,
188
- t: (key, values) => globalLocale.translate(resolvedLocale, key, values),
261
+ t: (key, values) => translate(resolvedLocale, key, values),
189
262
  user,
190
- jwt: { sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, opts) },
263
+ jwt: {
264
+ sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, Object.assign({ expiresIn: globalConfig?.auth?.expiresIn || '1d' }, opts || {}))
265
+ },
191
266
  error: errorHelper
192
267
  };
193
268
 
194
269
  const result = await config.handler(ctx);
270
+
271
+ if (cacheKey && globalRedis) {
272
+ await globalRedis.set(cacheKey, JSON.stringify(result), { EX: config.cache });
273
+ }
274
+
195
275
  return NextResponse.json(result, { status: 200 });
196
276
 
197
277
  } 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
  }