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 +15 -7
- package/package.json +1 -1
- package/src/locale.js +1 -1
- package/src/next.d.ts +10 -1
- package/src/next.js +139 -59
- package/src/router.js +0 -1
- package/src/server.js +7 -2
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
|
|
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
|
|
242
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
261
|
+
t: (key, values) => translate(resolvedLocale, key, values),
|
|
189
262
|
user,
|
|
190
|
-
jwt: {
|
|
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
|
-
|
|
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
|
}
|