bro-framework 2.0.1 → 2.1.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/bin/bro.js +7 -0
- package/package.json +1 -1
- package/src/auth.js +4 -12
- package/src/router.js +63 -52
- package/src/sdk.js +7 -4
- package/src/server.js +43 -11
- package/src/tasks.js +14 -4
package/bin/bro.js
CHANGED
|
@@ -182,6 +182,13 @@ async function bootstrap() {
|
|
|
182
182
|
console.error("");
|
|
183
183
|
process.exit(1);
|
|
184
184
|
}
|
|
185
|
+
globalConfig.envData = envResult.data;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (process.env.NODE_ENV === 'production' && ['dev_secret_please_change', 'bro_default_secret_key'].includes(globalConfig.jwtSecret)) {
|
|
189
|
+
console.error(`\n ✗ CRITICAL SECURITY ERROR: You are running in production with a default JWT secret!`);
|
|
190
|
+
console.error(` Please set auth.jwtSecret in bro.config.js or via JWT_SECRET environment variable.`);
|
|
191
|
+
process.exit(1);
|
|
185
192
|
}
|
|
186
193
|
|
|
187
194
|
if (!fs.existsSync(routesDir)) {
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -1,31 +1,23 @@
|
|
|
1
1
|
import jwt from 'jsonwebtoken';
|
|
2
2
|
|
|
3
|
-
let secret = 'bro_default_secret_key';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Update the secret used for signing and verifying JWTs.
|
|
7
|
-
* @param {string} newSecret
|
|
8
|
-
*/
|
|
9
|
-
export function setJwtSecret(newSecret) {
|
|
10
|
-
secret = newSecret;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
3
|
/**
|
|
14
4
|
* Signs a JWT payload.
|
|
15
5
|
* @param {Object} payload - The data to embed in the token.
|
|
6
|
+
* @param {string} secret - The JWT secret key.
|
|
16
7
|
* @param {jwt.SignOptions} [options] - jsonwebtoken sign options.
|
|
17
8
|
* @returns {string} The signed JWT token.
|
|
18
9
|
*/
|
|
19
|
-
export function signJwt(payload, options = { expiresIn: '1d' }) {
|
|
10
|
+
export function signJwt(payload, secret, options = { expiresIn: '1d' }) {
|
|
20
11
|
return jwt.sign(payload, secret, options);
|
|
21
12
|
}
|
|
22
13
|
|
|
23
14
|
/**
|
|
24
15
|
* Verifies and decodes a JWT token.
|
|
25
16
|
* @param {string} token - The JWT token to verify.
|
|
17
|
+
* @param {string} secret - The JWT secret key.
|
|
26
18
|
* @returns {{ valid: boolean, payload?: any, error?: string }}
|
|
27
19
|
*/
|
|
28
|
-
export function verifyJwt(token) {
|
|
20
|
+
export function verifyJwt(token, secret) {
|
|
29
21
|
try {
|
|
30
22
|
const payload = jwt.verify(token, secret);
|
|
31
23
|
return { valid: true, payload };
|
package/src/router.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
|
+
import crypto from 'crypto';
|
|
4
5
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -48,10 +49,11 @@ export function parseRouteFile(filePath, routesDir) {
|
|
|
48
49
|
if (routePath === '/.') routePath = '';
|
|
49
50
|
|
|
50
51
|
if (namePart !== 'index') {
|
|
51
|
-
|
|
52
|
-
routePath += `/${formattedName}`;
|
|
52
|
+
routePath += `/${namePart}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
routePath = routePath.replace(/\[(.*?)\]/g, ':$1');
|
|
56
|
+
|
|
55
57
|
if (routePath === '') routePath = '/';
|
|
56
58
|
|
|
57
59
|
return { routePath, method };
|
|
@@ -68,6 +70,7 @@ export function parseRouteFile(filePath, routesDir) {
|
|
|
68
70
|
export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
69
71
|
const files = scanDir(routesDir);
|
|
70
72
|
const loadedRoutes = [];
|
|
73
|
+
const routeModules = [];
|
|
71
74
|
|
|
72
75
|
for (const file of files) {
|
|
73
76
|
const routeInfo = parseRouteFile(file, routesDir);
|
|
@@ -77,68 +80,76 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
|
77
80
|
|
|
78
81
|
if (typeof app[method] !== 'function') continue;
|
|
79
82
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const moduleUrl = pathToFileURL(file).href + '?update=' + crypto.randomUUID();
|
|
84
|
+
const module = await import(moduleUrl);
|
|
85
|
+
const config = module.default;
|
|
86
|
+
|
|
87
|
+
if (!config) continue;
|
|
88
|
+
|
|
89
|
+
routeModules.push({ file, routePath, method, config });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const { file, routePath, method, config } of routeModules) {
|
|
93
|
+
const handler = createHandler(config);
|
|
94
|
+
app[method](routePath, handler);
|
|
95
|
+
|
|
96
|
+
if (openApiSpec) {
|
|
97
|
+
const openApiPath = routePath.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
|
|
98
|
+
if (!openApiSpec.paths[openApiPath]) openApiSpec.paths[openApiPath] = {};
|
|
86
99
|
|
|
87
|
-
const
|
|
88
|
-
|
|
100
|
+
const operation = {
|
|
101
|
+
summary: config.summary || `${method.toUpperCase()} ${routePath}`,
|
|
102
|
+
responses: { '200': { description: 'Successful response' } }
|
|
103
|
+
};
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
105
|
+
const bodySchema = config.schema?.body || config.body;
|
|
106
|
+
const paramsSchema = config.schema?.params || config.params;
|
|
107
|
+
const querySchema = config.schema?.query || config.query;
|
|
108
|
+
|
|
109
|
+
if (bodySchema) {
|
|
110
|
+
operation.requestBody = {
|
|
111
|
+
content: { 'application/json': { schema: zodToJsonSchema(bodySchema) } }
|
|
97
112
|
};
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
operation.parameters = operation.parameters || [];
|
|
107
|
-
const pSchema = zodToJsonSchema(config.params);
|
|
108
|
-
if (pSchema.properties) {
|
|
109
|
-
for (const [key, schema] of Object.entries(pSchema.properties)) {
|
|
110
|
-
operation.parameters.push({ name: key, in: 'path', required: true, schema });
|
|
111
|
-
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (paramsSchema) {
|
|
116
|
+
operation.parameters = operation.parameters || [];
|
|
117
|
+
const pSchema = zodToJsonSchema(paramsSchema);
|
|
118
|
+
if (pSchema.properties) {
|
|
119
|
+
for (const [key, schema] of Object.entries(pSchema.properties)) {
|
|
120
|
+
operation.parameters.push({ name: key, in: 'path', required: true, schema });
|
|
112
121
|
}
|
|
113
122
|
}
|
|
123
|
+
}
|
|
114
124
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
125
|
+
if (querySchema) {
|
|
126
|
+
operation.parameters = operation.parameters || [];
|
|
127
|
+
const qSchema = zodToJsonSchema(querySchema);
|
|
128
|
+
if (qSchema.properties) {
|
|
129
|
+
for (const [key, schema] of Object.entries(qSchema.properties)) {
|
|
130
|
+
operation.parameters.push({
|
|
131
|
+
name: key,
|
|
132
|
+
in: 'query',
|
|
133
|
+
required: qSchema.required?.includes(key),
|
|
134
|
+
schema
|
|
135
|
+
});
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
|
-
|
|
130
|
-
openApiSpec.paths[openApiPath][method.toLowerCase()] = operation;
|
|
131
138
|
}
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
});
|
|
140
|
+
// Auto-inject security definition if auth is true
|
|
141
|
+
if (config.auth) {
|
|
142
|
+
operation.security = [{ bearerAuth: [] }];
|
|
143
|
+
}
|
|
138
144
|
|
|
139
|
-
|
|
140
|
-
console.error(`[bro.js] Failed to load route ${file}:`, err);
|
|
145
|
+
openApiSpec.paths[openApiPath][method.toLowerCase()] = operation;
|
|
141
146
|
}
|
|
147
|
+
|
|
148
|
+
loadedRoutes.push({
|
|
149
|
+
method: method.toUpperCase(),
|
|
150
|
+
path: routePath,
|
|
151
|
+
auth: !!config.auth
|
|
152
|
+
});
|
|
142
153
|
}
|
|
143
154
|
|
|
144
155
|
return loadedRoutes;
|
package/src/sdk.js
CHANGED
|
@@ -44,7 +44,10 @@ async function request(method, path, data) {
|
|
|
44
44
|
options.body = JSON.stringify(data);
|
|
45
45
|
} else if (data && ['GET', 'DELETE'].includes(method.toUpperCase())) {
|
|
46
46
|
const params = new URLSearchParams(data);
|
|
47
|
-
|
|
47
|
+
const qs = params.toString();
|
|
48
|
+
if (qs) {
|
|
49
|
+
path += '?' + qs;
|
|
50
|
+
}
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const url = CONFIG.baseURL + path;
|
|
@@ -115,10 +118,10 @@ function generateApiObject(endpoints) {
|
|
|
115
118
|
|
|
116
119
|
for (const [key, val] of Object.entries(node)) {
|
|
117
120
|
if (val._isParam) {
|
|
118
|
-
result += `${indent}${key}: (${key}) => ({\n`;
|
|
121
|
+
result += `${indent}"${key}": (${key}) => ({\n`;
|
|
119
122
|
|
|
120
123
|
for (const [m, p] of Object.entries(val._methods)) {
|
|
121
|
-
const templatedPath = p.replace(/:([a-zA-Z0-9_]+)/g, '${$1}');
|
|
124
|
+
const templatedPath = p.replace(/:([a-zA-Z0-9_]+)/g, '${encodeURIComponent($1)}');
|
|
122
125
|
result += `${indent} ${m}: (data) => request('${m}', \`${templatedPath}\`, data),\n`;
|
|
123
126
|
}
|
|
124
127
|
|
|
@@ -129,7 +132,7 @@ function generateApiObject(endpoints) {
|
|
|
129
132
|
|
|
130
133
|
result += `${indent}}),\n`;
|
|
131
134
|
} else {
|
|
132
|
-
result += `${indent}${key}: {\n`;
|
|
135
|
+
result += `${indent}"${key}": {\n`;
|
|
133
136
|
for (const [m, p] of Object.entries(val._methods)) {
|
|
134
137
|
result += `${indent} ${m}: (data) => request('${m}', '${p}', data),\n`;
|
|
135
138
|
}
|
package/src/server.js
CHANGED
|
@@ -5,11 +5,9 @@ import { Server } from 'socket.io';
|
|
|
5
5
|
import rateLimit from 'express-rate-limit';
|
|
6
6
|
import multer from 'multer';
|
|
7
7
|
import { apiReference } from '@scalar/express-api-reference';
|
|
8
|
-
import { verifyJwt, signJwt
|
|
8
|
+
import { verifyJwt, signJwt } from './auth.js';
|
|
9
9
|
import { loadRoutes } from './router.js';
|
|
10
10
|
|
|
11
|
-
const upload = multer();
|
|
12
|
-
|
|
13
11
|
/**
|
|
14
12
|
* Creates and configures the core Express server.
|
|
15
13
|
* @param {Object} globalConfig - User's bro.config.js configurations.
|
|
@@ -23,7 +21,10 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
23
21
|
|
|
24
22
|
const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
if (corsConfig !== false) {
|
|
25
|
+
app.use(cors(typeof corsConfig === 'object' ? corsConfig : {}));
|
|
26
|
+
}
|
|
27
|
+
|
|
27
28
|
app.use(express.json());
|
|
28
29
|
|
|
29
30
|
if (globalConfig.rateLimit) {
|
|
@@ -36,10 +37,6 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
36
37
|
await globalConfig.sockets(io, db);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
if (globalConfig.jwtSecret) {
|
|
40
|
-
setJwtSecret(globalConfig.jwtSecret);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
40
|
const createHandler = (routeConfig) => {
|
|
44
41
|
const middlewares = [];
|
|
45
42
|
|
|
@@ -48,12 +45,23 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
if (routeConfig.upload) {
|
|
51
|
-
|
|
48
|
+
const routeMulterConfig = {
|
|
49
|
+
limits: globalConfig.upload?.limits || {
|
|
50
|
+
fileSize: 10 * 1024 * 1024,
|
|
51
|
+
files: 5,
|
|
52
|
+
fields: 20
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
if (typeof routeConfig.upload === 'object' && routeConfig.upload.limits) {
|
|
56
|
+
routeMulterConfig.limits = { ...routeMulterConfig.limits, ...routeConfig.upload.limits };
|
|
57
|
+
}
|
|
58
|
+
middlewares.push(multer(routeMulterConfig).any());
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
middlewares.push(async (req, res) => {
|
|
55
62
|
try {
|
|
56
63
|
const ctx = {
|
|
64
|
+
env: globalConfig.envData || process.env,
|
|
57
65
|
db,
|
|
58
66
|
io,
|
|
59
67
|
body: req.body,
|
|
@@ -61,7 +69,7 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
61
69
|
query: req.query,
|
|
62
70
|
files: req.files || req.file,
|
|
63
71
|
user: null,
|
|
64
|
-
jwt: { sign: signJwt },
|
|
72
|
+
jwt: { sign: (payload, opts) => signJwt(payload, globalConfig.jwtSecret, opts || { expiresIn: globalConfig.auth?.expiresIn || '1d' }) },
|
|
65
73
|
error: (status, message) => {
|
|
66
74
|
const err = new Error(message);
|
|
67
75
|
err.status = status;
|
|
@@ -76,7 +84,7 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
const token = authHeader.split(' ')[1];
|
|
79
|
-
const authResult = verifyJwt(token);
|
|
87
|
+
const authResult = verifyJwt(token, globalConfig.jwtSecret);
|
|
80
88
|
|
|
81
89
|
if (!authResult.valid) {
|
|
82
90
|
return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
|
|
@@ -143,6 +151,21 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
143
151
|
let openApiSpec = {
|
|
144
152
|
openapi: '3.0.0',
|
|
145
153
|
info: { title: 'bro.js API', version: '1.0.0' },
|
|
154
|
+
components: {
|
|
155
|
+
securitySchemes: {
|
|
156
|
+
bearerAuth: {
|
|
157
|
+
type: 'http',
|
|
158
|
+
scheme: 'bearer',
|
|
159
|
+
bearerFormat: 'JWT'
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
responses: {
|
|
163
|
+
BadRequest: { description: 'Bad Request', content: { 'application/json': { schema: { type: 'object', properties: { error: { type: 'string' } } } } } },
|
|
164
|
+
Unauthorized: { description: 'Unauthorized', content: { 'application/json': { schema: { type: 'object', properties: { error: { type: 'string' } } } } } },
|
|
165
|
+
NotFound: { description: 'Not Found', content: { 'application/json': { schema: { type: 'object', properties: { error: { type: 'string' } } } } } },
|
|
166
|
+
ServerError: { description: 'Internal Server Error', content: { 'application/json': { schema: { type: 'object', properties: { error: { type: 'string' } } } } } }
|
|
167
|
+
}
|
|
168
|
+
},
|
|
146
169
|
paths: {}
|
|
147
170
|
};
|
|
148
171
|
|
|
@@ -174,6 +197,15 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
174
197
|
routeStack(req, res, next);
|
|
175
198
|
});
|
|
176
199
|
|
|
200
|
+
app.use((req, res) => {
|
|
201
|
+
res.status(404).json({ error: 'Not Found' });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
app.use((err, req, res, next) => {
|
|
205
|
+
console.error(`[bro.js] Uncaught Error:`, err);
|
|
206
|
+
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
|
|
207
|
+
});
|
|
208
|
+
|
|
177
209
|
const reload = async () => {
|
|
178
210
|
const newRouter = express.Router();
|
|
179
211
|
openApiSpec.paths = {};
|
package/src/tasks.js
CHANGED
|
@@ -3,29 +3,39 @@ import path from 'path';
|
|
|
3
3
|
import { pathToFileURL } from 'url';
|
|
4
4
|
import cron from 'node-cron';
|
|
5
5
|
import { colors } from './logger.js';
|
|
6
|
+
import { scanDir } from './router.js';
|
|
7
|
+
|
|
8
|
+
let taskHandles = [];
|
|
9
|
+
|
|
10
|
+
export function stopTasks() {
|
|
11
|
+
taskHandles.forEach(t => t.stop());
|
|
12
|
+
taskHandles = [];
|
|
13
|
+
}
|
|
6
14
|
|
|
7
15
|
export async function scanTasks(ctx) {
|
|
8
16
|
const tasksDir = path.join(process.cwd(), 'tasks');
|
|
9
17
|
if (!fs.existsSync(tasksDir)) return;
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
stopTasks();
|
|
20
|
+
|
|
21
|
+
const files = scanDir(tasksDir);
|
|
12
22
|
if (files.length === 0) return;
|
|
13
23
|
|
|
14
24
|
let count = 0;
|
|
15
25
|
for (const file of files) {
|
|
16
|
-
const filePath = path.join(tasksDir, file);
|
|
17
26
|
try {
|
|
18
|
-
const moduleUrl = pathToFileURL(
|
|
27
|
+
const moduleUrl = pathToFileURL(file).href;
|
|
19
28
|
const taskModule = await import(moduleUrl);
|
|
20
29
|
|
|
21
30
|
if (taskModule.cron && typeof taskModule.handler === 'function') {
|
|
22
|
-
cron.schedule(taskModule.cron, async () => {
|
|
31
|
+
const task = cron.schedule(taskModule.cron, async () => {
|
|
23
32
|
try {
|
|
24
33
|
await taskModule.handler(ctx);
|
|
25
34
|
} catch (err) {
|
|
26
35
|
console.error(`\n ${colors.red}❌ Task Error (${file}):${colors.reset}`, err);
|
|
27
36
|
}
|
|
28
37
|
});
|
|
38
|
+
taskHandles.push(task);
|
|
29
39
|
count++;
|
|
30
40
|
}
|
|
31
41
|
} catch (err) {
|