bro-framework 2.2.2 → 2.3.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
@@ -97,7 +97,7 @@ export default defineRoute({
97
97
  Create a `.js` file in the `routes/` directory, and it automatically becomes an endpoint. We use Next.js-style bracket syntax for dynamic parameters. A file named `routes/users/[id].get.js` translates natively to a `GET /users/:id` Express route under the hood.
98
98
 
99
99
  ### Bouncer-Grade Validation
100
- Powered by Zod. Attach a schema to `body`, `query`, or `params` in your route definition. If the client sends malformed data, `bro.js` automatically rejects the request with a structured `400 Bad Request` JSON payload *before* your handler ever executes. You never have to manually validate inputs again.
100
+ Powered by Zod. Attach a schema to `body`, `query`, or `params` directly in your route definition. If the client sends malformed data, `bro.js` automatically rejects the request with a structured `400 Bad Request` JSON payload *before* your handler ever executes. You never have to manually validate inputs again. You can also define a `response` schema to strongly type your OpenAPI documentation (strictly opt-in; arbitrary 200s work out of the box).
101
101
 
102
102
  ### Zero-Config JWTs
103
103
  Add `auth: true` to your route config. `bro.js` will intercept the request, extract the `Authorization: Bearer <token>` header, verify the signature using your `jwtSecret`, and inject the decoded payload directly into `ctx.user`.
@@ -115,7 +115,22 @@ Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI will parse yo
115
115
  Don't spin up a separate worker server. Drop a JavaScript file anywhere in the `tasks/` folder, export a cron string (e.g., `"0 0 * * *"`), and an async handler. `bro.js` natively schedules it as a background worker with full access to your injected database and WebSocket contexts.
116
116
 
117
117
  ### Zero-Boilerplate File Uploads
118
- Add `upload: true` to a route. `bro.js` automatically hooks into `multer`, parses the `multipart/form-data` payload in memory, and injects the files directly into `ctx.files`.
118
+ Add `upload: true` to a route. `bro.js` automatically hooks into `multer`, parses the `multipart/form-data` payload in memory, and injects the files directly into `ctx.files`. It also natively supports granular file limits, restricting max sizes, parts, and fielding counts instantly to protect your RAM. (Note: Use the `storage` configuration for heavy production disk writing to prevent memory exhaustion).
119
+
120
+ ### File-Based Locale
121
+ Create a `locale/` folder with one translation file per locale, such as `locale/en.js` and `locale/fr.js`. Export a plain object from each file, then use `t()` in any route:
122
+
123
+ ```javascript
124
+ // locale/fr.js
125
+ export default { welcome: 'Bienvenue, {name} !' };
126
+
127
+ // routes/welcome.get.js
128
+ export default defineRoute({
129
+ handler: async ({ t }) => ({ message: t('welcome', { name: 'Sam' }) })
130
+ });
131
+ ```
132
+
133
+ The locale is negotiated dynamically using RFC 9110 `Accept-Language` headers, supporting full region fallback and custom defaults, and the generated SDK can securely set it via `setLocale('fr')`.
119
134
 
120
135
  ---
121
136
 
@@ -178,9 +193,9 @@ Add `upload: true` to a route. `bro.js` automatically hooks into `multer`, parse
178
193
  | Command | Description |
179
194
  | :--- | :--- |
180
195
  | `bro dev` | Development server featuring instant boot, visual CLI banner, and `chokidar`-powered hot module remapping. |
181
- | `bro start` &nbsp; | Production runner locked down for security. Zero watcher overhead, suppressed internal logs, and isolated API docs. |
196
+ | `bro start` &nbsp; | Production runner locked down for security. Features Graceful Shutdown APIs (with `onShutdown` DB teardown), suppressed internal logs, and isolated API docs. |
182
197
  | `bro init` | Automated workspace scaffolder. Generates configuration files and forcefully ensures your `package.json` respects `"type": "module"`. |
183
- | `bro sdk` | Route parser and browser client compiler. Generates your frontend SDK in one hit. |
198
+ | `bro sdk` | Route parser and browser client compiler. Generates your typed `bro-sdk.js` frontend SDK in one hit. |
184
199
 
185
200
  ---
186
201
 
package/bin/bro.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import path from 'path';
4
4
  import fs from 'fs';
@@ -37,6 +37,12 @@ export default defineConfig({
37
37
  jwtSecret: 'dev_secret_please_change',
38
38
  expiresIn: '7d'
39
39
  },
40
+
41
+ // Optional file-based API translations
42
+ // Add locale/en.js, locale/fr.js, etc.
43
+ locale: {
44
+ defaultLocale: 'en'
45
+ },
40
46
 
41
47
  // API Documentation (Scalar UI)
42
48
  docs: process.env.NODE_ENV !== 'production', // Set to false to disable completely, or true to force in prod
@@ -190,7 +196,8 @@ async function bootstrap() {
190
196
  process.exit(1);
191
197
  }
192
198
 
193
- const { app, server, routes: initialRoutes, reload, io, shutdown } = await createServer(globalConfig, routesDir, db);
199
+ const localeDir = globalConfig.locale?.directory || path.join(cwd, 'locale');
200
+ const { app, server, routes: initialRoutes, reload, reloadLocale, io, shutdown } = await createServer(globalConfig, routesDir, db);
194
201
  const port = globalConfig.port;
195
202
 
196
203
  let currentRoutes = initialRoutes;
@@ -217,20 +224,28 @@ async function bootstrap() {
217
224
 
218
225
  printCurrentRoutes(currentRoutes);
219
226
 
220
- const watcher = chokidar.watch(routesDir, { ignoreInitial: true });
227
+ const localeGlob = localeDir.replace(/\\/g, '/') + '/*.{js,mjs,ts}';
228
+ const watcher = chokidar.watch([routesDir, localeGlob], { ignoreInitial: true });
221
229
 
222
230
  watcher.on('all', async (event, filepath) => {
223
- if (!filepath.endsWith('.js') && !filepath.endsWith('.ts')) return;
224
-
231
+ const isJavaScriptFile = filepath.endsWith('.js') || filepath.endsWith('.ts') || filepath.endsWith('.mjs');
232
+ if (!isJavaScriptFile) return;
233
+ const relLocale = path.relative(path.resolve(localeDir), filepath);
234
+ const isLocaleFile = !relLocale.startsWith('..') && !path.isAbsolute(relLocale);
235
+
225
236
  try {
226
237
  const reloadStartTime = performance.now();
227
- currentRoutes = await reload();
238
+ if (isLocaleFile) {
239
+ await reloadLocale();
240
+ } else {
241
+ currentRoutes = await reload();
242
+ }
228
243
  const reloadTimeMs = performance.now() - reloadStartTime;
229
244
 
230
- printHotReload(path.basename(filepath), event, reloadTimeMs);
245
+ printHotReload(path.basename(filepath), event, reloadTimeMs, isLocaleFile ? 'Locale' : 'Route');
231
246
  printCurrentRoutes(currentRoutes);
232
247
  } catch (err) {
233
- console.error(`\n ✗ Error hot-reloading routes:`, err);
248
+ console.error(`\n ✗ Error hot-reloading ${isLocaleFile ? 'locale' : 'routes'}:`, err);
234
249
  }
235
250
  });
236
251
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bro-framework",
3
- "version": "2.2.2",
3
+ "version": "2.3.0",
4
4
  "description": "The No-BS Backend Framework for Node.js",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.d.ts CHANGED
@@ -12,16 +12,18 @@ export interface BroContext<Body = any, Params = any, Query = any> {
12
12
  db?: any;
13
13
  io?: any;
14
14
  files?: any[];
15
+ locale: string;
16
+ t: (key: string, values?: Record<string, unknown>) => string;
15
17
  error?: any;
16
18
  }
17
19
 
18
20
  export interface RouteConfig<Body = any, Params = any, Query = any> {
19
21
  auth?: boolean;
20
22
  upload?: boolean | { limits?: any, fields?: { name: string, maxCount?: number }[], single?: string, array?: string, fileFilter?: any, storage?: any };
21
- schema?: { body?: Body; params?: Params; query?: Query; };
22
23
  body?: Body;
23
24
  params?: Params;
24
25
  query?: Query;
26
+ response?: ZodTypeAny;
25
27
  rateLimit?: {
26
28
  windowMs: number;
27
29
  max: number;
@@ -34,12 +36,23 @@ export function defineRoute<Body = any, Params = any, Query = any>(
34
36
  config: RouteConfig<Body, Params, Query>
35
37
  ): RouteConfig<Body, Params, Query>;
36
38
 
39
+ export function loadLocale(directory: string, options?: { defaultLocale?: string }): Promise<{
40
+ locales: string[];
41
+ defaultLocale: string;
42
+ resolveLocale: (request: any) => string;
43
+ translate: (locale: string, key: string, values?: Record<string, unknown>) => string;
44
+ }>;
45
+
37
46
  export interface BroConfig {
38
47
  env?: ZodTypeAny;
39
48
  server?: {
40
49
  port?: number;
41
50
  cors?: boolean | object;
42
51
  };
52
+ locale?: {
53
+ directory?: string;
54
+ defaultLocale?: string;
55
+ };
43
56
  auth?: {
44
57
  jwtSecret?: string;
45
58
  expiresIn?: string | number;
@@ -59,6 +72,7 @@ export interface BroConfig {
59
72
  };
60
73
  db?: () => Promise<any> | any;
61
74
  sockets?: (io: any, db: any) => Promise<void> | void;
75
+ onShutdown?: (db: any) => Promise<void> | void;
62
76
  }
63
77
 
64
78
  export function defineConfig(config: BroConfig): BroConfig;
package/src/index.js CHANGED
@@ -26,3 +26,4 @@ export function defineConfig(config) {
26
26
  }
27
27
 
28
28
  export { z };
29
+ export { loadLocale } from './locale.js';
package/src/locale.js ADDED
@@ -0,0 +1,118 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { pathToFileURL } from 'url';
4
+ import crypto from 'node:crypto';
5
+
6
+ const LOCALE_EXTENSIONS = new Set(['.js', '.mjs', '.ts']);
7
+
8
+ function localeFromFilename(fileName) {
9
+ return path.basename(fileName, path.extname(fileName));
10
+ }
11
+
12
+ function normalizeLocale(locale) {
13
+ const normalized = String(locale || '').trim().replaceAll('_', '-').toLowerCase();
14
+ const aliases = {
15
+ english: 'en',
16
+ arabic: 'ar',
17
+ french: 'fr',
18
+ spanish: 'es',
19
+ german: 'de',
20
+ portuguese: 'pt',
21
+ italian: 'it',
22
+ japanese: 'ja',
23
+ korean: 'ko',
24
+ chinese: 'zh'
25
+ };
26
+ return aliases[normalized] || normalized;
27
+ }
28
+
29
+ function findLocale(locales, requestedLocale, fallbackLocale) {
30
+ const requested = normalizeLocale(requestedLocale);
31
+ const exact = locales.find(locale => normalizeLocale(locale) === requested);
32
+ if (exact) return exact;
33
+
34
+ const language = requested.split('-')[0];
35
+ const languageMatch = locales.find(locale => normalizeLocale(locale).split('-')[0] === language);
36
+ if (languageMatch) return languageMatch;
37
+
38
+ return fallbackLocale;
39
+ }
40
+
41
+ function readMessage(messages, key) {
42
+ return key.split('.').reduce((value, part) => value?.[part], messages);
43
+ }
44
+
45
+ function interpolate(message, values) {
46
+ return message.replace(/\{(\w+)\}/g, (_, name) => {
47
+ return values[name] === undefined ? `{${name}}` : String(values[name]);
48
+ });
49
+ }
50
+
51
+ function parseAcceptLanguage(header) {
52
+ return String(header || '')
53
+ .split(',')
54
+ .map(value => {
55
+ const [tag, qVal] = value.split(';').map(s => s.trim());
56
+ let q = 1;
57
+ if (qVal && qVal.startsWith('q=')) {
58
+ const parsedQ = parseFloat(qVal.slice(2));
59
+ if (!isNaN(parsedQ)) q = parsedQ;
60
+ }
61
+ return { tag, q };
62
+ })
63
+ .filter(item => item.tag && item.q > 0)
64
+ .sort((a, b) => b.q - a.q)
65
+ .map(item => item.tag);
66
+ }
67
+
68
+ /**
69
+ * Loads translation files from the application's locale directory.
70
+ * Each file name becomes a locale, for example locale/en-US.js.
71
+ * @param {string} directory
72
+ * @param {{ defaultLocale?: string }} [options]
73
+ */
74
+ export async function loadLocale(directory, options = {}) {
75
+ const files = fs.existsSync(directory)
76
+ ? fs.readdirSync(directory).filter(file => {
77
+ const fullPath = path.join(directory, file);
78
+ return fs.statSync(fullPath).isFile() && LOCALE_EXTENSIONS.has(path.extname(file));
79
+ })
80
+ : [];
81
+
82
+ const messages = {};
83
+ for (const file of files) {
84
+ const module = await import(`${pathToFileURL(path.join(directory, file)).href}?update=${crypto.randomUUID()}`);
85
+ const catalog = module.default || module.messages || module;
86
+ if (catalog && typeof catalog === 'object') {
87
+ messages[localeFromFilename(file)] = catalog;
88
+ }
89
+ }
90
+
91
+ const locales = Object.keys(messages);
92
+ const configuredDefault = options.defaultLocale;
93
+ const defaultLocale = findLocale(locales, configuredDefault, null)
94
+ || locales.find(locale => ['en', 'en-us'].includes(normalizeLocale(locale)))
95
+ || locales[0]
96
+ || 'en';
97
+
98
+ function resolveLocale(request) {
99
+ const requestedLocales = parseAcceptLanguage(request?.headers?.['accept-language']);
100
+ for (const requested of requestedLocales) {
101
+ const match = findLocale(locales, requested, null);
102
+ if (match) return match;
103
+ }
104
+ return defaultLocale;
105
+ }
106
+
107
+ function translate(locale, key, values = {}) {
108
+ const selectedLocale = findLocale(locales, locale, defaultLocale);
109
+ const message = readMessage(messages[selectedLocale], key)
110
+ ?? readMessage(messages[defaultLocale], key);
111
+
112
+ if (message === undefined) return key;
113
+ if (typeof message !== 'string') return message;
114
+ return interpolate(message, values);
115
+ }
116
+
117
+ return { locales, defaultLocale, resolveLocale, translate };
118
+ }
package/src/logger.js CHANGED
@@ -72,8 +72,8 @@ export function printRoute(method, routePath, hasAuth, isLast = false) {
72
72
  console.log(` ${colors.dim}${branch}${colors.reset} ${coloredMethod} ${routePath}${authIcon}`);
73
73
  }
74
74
 
75
- export function printHotReload(fileName, event, reloadTimeMs) {
75
+ export function printHotReload(fileName, event, reloadTimeMs, resourceType = 'Route') {
76
76
  const time = typeof reloadTimeMs === 'number' ? reloadTimeMs.toFixed(0) : reloadTimeMs;
77
- console.log(`\n ${colors.cyan}Route updated:${colors.reset} ${colors.bold}${fileName}${colors.reset} ${colors.dim}(${event})${colors.reset}`);
77
+ console.log(`\n ${colors.cyan}${resourceType} updated:${colors.reset} ${colors.bold}${fileName}${colors.reset} ${colors.dim}(${event})${colors.reset}`);
78
78
  console.log(` ${colors.dim}Remapped in ${time}ms${colors.reset}\n`);
79
79
  }
package/src/router.js CHANGED
@@ -116,10 +116,15 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
116
116
  summary: config.summary || `${method.toUpperCase()} ${routePath}`,
117
117
  responses: { '200': { description: 'Successful response' } }
118
118
  };
119
-
120
- const bodySchema = config.schema?.body || config.body;
121
- const paramsSchema = config.schema?.params || config.params;
122
- const querySchema = config.schema?.query || config.query;
119
+
120
+ if (config.response) {
121
+ operation.responses['200'].content = {
122
+ 'application/json': { schema: zodToJsonSchema(config.response) }
123
+ };
124
+ }
125
+ const bodySchema = config.body;
126
+ const paramsSchema = config.params;
127
+ const querySchema = config.query;
123
128
 
124
129
  if (bodySchema) {
125
130
  operation.requestBody = {
package/src/sdk.js CHANGED
@@ -21,11 +21,16 @@ export async function generateSDK() {
21
21
  const code = `// Auto-generated by bro.js
22
22
  const CONFIG = {
23
23
  baseURL: 'http://localhost:5000',
24
- tokenKey: 'bro_token'
24
+ tokenKey: 'bro_token',
25
+ locale: undefined
25
26
  };
26
27
 
27
28
  async function request(method, path, data) {
28
29
  const headers = {};
30
+
31
+ if (CONFIG.locale) {
32
+ headers['Accept-Language'] = CONFIG.locale;
33
+ }
29
34
 
30
35
  if (typeof localStorage !== 'undefined') {
31
36
  const token = localStorage.getItem(CONFIG.tokenKey);
@@ -76,6 +81,10 @@ export function setBaseURL(url) {
76
81
  export function setTokenKey(key) {
77
82
  CONFIG.tokenKey = key;
78
83
  }
84
+
85
+ export function setLocale(locale) {
86
+ CONFIG.locale = locale;
87
+ }
79
88
  `;
80
89
 
81
90
  fs.writeFileSync(path.join(process.cwd(), 'bro-sdk.js'), code, 'utf-8');
package/src/server.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import express from 'express';
2
2
  import cors from 'cors';
3
3
  import http from 'node:http';
4
+ import path from 'node:path';
4
5
  import { Server } from 'socket.io';
5
6
  import rateLimit from 'express-rate-limit';
6
7
  import multer from 'multer';
7
8
  import { apiReference } from '@scalar/express-api-reference';
8
9
  import { verifyJwt, signJwt } from './auth.js';
10
+ import { loadLocale } from './locale.js';
9
11
  import { loadRoutes } from './router.js';
10
12
  import { scanTasks } from './tasks.js';
11
13
 
@@ -14,7 +16,7 @@ import { scanTasks } from './tasks.js';
14
16
  * @param {Object} globalConfig - User's bro.config.js configurations.
15
17
  * @param {string} routesDir - Path to the target routes directory.
16
18
  * @param {any} db - Initialized database instance.
17
- * @returns {Promise<{ app: import('express').Application, server: http.Server, routes: Array, reload: Function, io: import('socket.io').Server }>}
19
+ * @returns {Promise<{ app: import('express').Application, server: http.Server, routes: Array, reload: Function, reloadLocale: Function, io: import('socket.io').Server, shutdown: Function }>}
18
20
  */
19
21
  export async function createServer(globalConfig, routesDir, db) {
20
22
  if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key'].includes(globalConfig.jwtSecret)) {
@@ -23,6 +25,8 @@ export async function createServer(globalConfig, routesDir, db) {
23
25
 
24
26
  const app = express();
25
27
  const server = http.createServer(app);
28
+ const localeDirectory = globalConfig.locale?.directory || path.join(process.cwd(), 'locale');
29
+ let locale = await loadLocale(localeDirectory, globalConfig.locale);
26
30
 
27
31
  const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
28
32
 
@@ -41,12 +45,14 @@ export async function createServer(globalConfig, routesDir, db) {
41
45
  if (globalConfig.sockets) {
42
46
  await globalConfig.sockets(io, db);
43
47
  }
44
-
45
48
  const createHandler = (routeConfig) => {
46
49
  const middlewares = [];
47
- const bodySchema = routeConfig.schema?.body || routeConfig.body;
48
- const paramsSchema = routeConfig.schema?.params || routeConfig.params;
49
- const querySchema = routeConfig.schema?.query || routeConfig.query;
50
+ if (routeConfig.schema) {
51
+ throw new Error("Nested 'schema' object is no longer supported in bro.js v2.3.0+. Please use flat, top-level properties (body, query, params) instead.");
52
+ }
53
+ const bodySchema = routeConfig.body;
54
+ const paramsSchema = routeConfig.params;
55
+ const querySchema = routeConfig.query;
50
56
 
51
57
  if (routeConfig.rateLimit) {
52
58
  middlewares.push(rateLimit(routeConfig.rateLimit));
@@ -80,6 +86,7 @@ export async function createServer(globalConfig, routesDir, db) {
80
86
 
81
87
  middlewares.push(async (req, res) => {
82
88
  try {
89
+ const requestLocale = locale.resolveLocale(req);
83
90
  const ctx = {
84
91
  env: globalConfig.envData || process.env,
85
92
  db,
@@ -88,6 +95,8 @@ export async function createServer(globalConfig, routesDir, db) {
88
95
  params: req.params,
89
96
  query: req.query,
90
97
  files: req.files || req.file,
98
+ locale: requestLocale,
99
+ t: (key, values) => locale.translate(requestLocale, key, values),
91
100
  user: null,
92
101
  jwt: { sign: (payload, opts) => signJwt(payload, globalConfig.jwtSecret, opts || { expiresIn: globalConfig.auth?.expiresIn || '1d' }) },
93
102
  error: (status, message) => {
@@ -235,17 +244,34 @@ export async function createServer(globalConfig, routesDir, db) {
235
244
  return routes;
236
245
  };
237
246
 
247
+ const reloadLocale = async () => {
248
+ locale = await loadLocale(localeDirectory, globalConfig.locale);
249
+ return locale;
250
+ };
251
+
238
252
  const initialRoutes = await reload();
239
253
 
240
254
  const taskManager = await scanTasks({ db, io });
241
255
 
256
+ let isShuttingDown = false;
242
257
  const shutdown = async () => {
258
+ if (isShuttingDown) return;
259
+ isShuttingDown = true;
243
260
  if (taskManager) taskManager.stopAll();
244
261
  if (io) io.close();
262
+
263
+ if (typeof globalConfig.onShutdown === 'function') {
264
+ try {
265
+ await globalConfig.onShutdown(db);
266
+ } catch (err) {
267
+ console.error('[bro.js] Error during database teardown hook:', err);
268
+ }
269
+ }
270
+
245
271
  return new Promise((resolve) => {
246
272
  server.close(() => resolve());
247
273
  });
248
274
  };
249
275
 
250
- return { app, server, routes: initialRoutes, reload, io, shutdown };
276
+ return { app, server, routes: initialRoutes, reload, reloadLocale, io, shutdown };
251
277
  }