bro-framework 1.2.3
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/LICENSE +21 -0
- package/README.md +173 -0
- package/bin/bro.js +242 -0
- package/package.json +31 -0
- package/src/auth.js +41 -0
- package/src/index.d.ts +51 -0
- package/src/index.js +28 -0
- package/src/logger.js +54 -0
- package/src/router.js +145 -0
- package/src/sdk.js +149 -0
- package/src/server.js +188 -0
- package/src/tasks.js +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Med Yassine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<h1 align="center">bro.js</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>The zero-boilerplate Node.js framework that actually has your back.</strong>
|
|
5
|
+
</p>
|
|
6
|
+
<p align="center">
|
|
7
|
+
<img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT">
|
|
8
|
+
<img src="https://img.shields.io/badge/Node.js-%3E%3D%2018-green.svg?style=flat-square" alt="Node.js: >= 18">
|
|
9
|
+
<img src="https://img.shields.io/badge/Architecture-Pure%20ESM-orange.svg?style=flat-square" alt="Architecture: Pure ESM">
|
|
10
|
+
<img src="https://img.shields.io/badge/Validation-Zod-3068b7.svg?style=flat-square" alt="Validation: Zod">
|
|
11
|
+
<img src="https://img.shields.io/badge/Realtime-Socket.io-black.svg?style=flat-square" alt="Realtime: Socket.io">
|
|
12
|
+
<img src="https://img.shields.io/badge/Engine-Express-eeeeee.svg?style=flat-square" alt="Engine: Express">
|
|
13
|
+
</p>
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://brojs.yessindevs.me">Documentation Website</a>
|
|
16
|
+
</p>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
> "NestJS wants four decorators, three modules, and an existential crisis just to handle a GET request. Express makes you write the same 40 lines of CORS, JSON parsing, and auth middleware for every project. bro.js gives you file routing, auto-validation, JWT auth, WebSockets, and live docs out of the box. Be honest: you just want to return an object."
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Table of Contents
|
|
26
|
+
|
|
27
|
+
- [The Core Experience](#the-core-experience)
|
|
28
|
+
- [Deep-Dive Features](#deep-dive-features)
|
|
29
|
+
- [Architecture & Request Lifecycle](#architecture--request-lifecycle)
|
|
30
|
+
- [Tech Stack Breakdown](#tech-stack-breakdown)
|
|
31
|
+
- [CLI Reference](#cli-reference)
|
|
32
|
+
- [Author & License](#author--license)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## The Core Experience
|
|
37
|
+
|
|
38
|
+
In `bro.js`, everything you need is handed to you instantly. No setup, no middleware wrangling, no manual `req/res` handling. You define your route, set your validation, and return an object.
|
|
39
|
+
|
|
40
|
+
`routes/posts/[id].post.js`:
|
|
41
|
+
|
|
42
|
+
```javascript
|
|
43
|
+
import { defineRoute, z } from 'bro-framework';
|
|
44
|
+
|
|
45
|
+
export default defineRoute({
|
|
46
|
+
auth: true,
|
|
47
|
+
params: z.object({
|
|
48
|
+
id: z.string().uuid()
|
|
49
|
+
}),
|
|
50
|
+
body: z.object({
|
|
51
|
+
title: z.string().min(5),
|
|
52
|
+
content: z.string()
|
|
53
|
+
}),
|
|
54
|
+
handler: async ({ body, params, user, db, io }) => {
|
|
55
|
+
// 1. Data is already validated. user is already authenticated.
|
|
56
|
+
|
|
57
|
+
// 2. Perform database operation using the injected Mongoose context
|
|
58
|
+
const post = await db.collection('posts').updateOne(
|
|
59
|
+
{ _id: params.id },
|
|
60
|
+
{ $set: { ...body, authorId: user.id } }
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// 3. Broadcast to all clients instantly using injected Socket.io
|
|
64
|
+
io.emit('post_updated', { postId: params.id, title: body.title });
|
|
65
|
+
|
|
66
|
+
// 4. Return an object. bro.js handles the 200 JSON response.
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
updated: post.modifiedCount
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Deep-Dive Features
|
|
78
|
+
|
|
79
|
+
### File-Based Routing
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
### Bouncer-Grade Validation
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
### Zero-Config JWTs
|
|
86
|
+
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`.
|
|
87
|
+
|
|
88
|
+
### Context Injection
|
|
89
|
+
Stop importing singleton database connections and socket instances into every file. Define your `db` and `sockets` setup once in `bro.config.js`. `bro.js` orchestrates the initialization and injects both instances directly into the `ctx` object for every request handler.
|
|
90
|
+
|
|
91
|
+
### Zero-YAML Live Documentation
|
|
92
|
+
If you've ever hand-written OpenAPI YAML, you know the pain. `bro.js` parses your Zod schemas and automatically serves a stunning, interactive [Scalar](https://scalar.com/) API playground at `/docs`. It's highly secure: by default, these internal docs are disabled in production mode.
|
|
93
|
+
|
|
94
|
+
### The Frontend SDK Generator
|
|
95
|
+
Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI will parse your backend routes and compile a `bro-client.js` file for your frontend. It features built-in token management, request stringification, and type-safe deep tree traversal (e.g., `api.users.id("123").post(data)`).
|
|
96
|
+
|
|
97
|
+
### Background Task Scheduler
|
|
98
|
+
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.
|
|
99
|
+
|
|
100
|
+
### Zero-Boilerplate File Uploads
|
|
101
|
+
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`.
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## Architecture & Request Lifecycle
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
[ Incoming HTTP Request ]
|
|
109
|
+
│
|
|
110
|
+
▼
|
|
111
|
+
( Express Engine )
|
|
112
|
+
│
|
|
113
|
+
▼
|
|
114
|
+
[ CORS / JSON Pre-flight ]
|
|
115
|
+
│
|
|
116
|
+
▼
|
|
117
|
+
( Dev Logger )
|
|
118
|
+
│
|
|
119
|
+
▼
|
|
120
|
+
[ Auth Guard (JWT Check) ] ──(Fail)──> 401 Unauthorized
|
|
121
|
+
│
|
|
122
|
+
▼
|
|
123
|
+
[ Zod Bouncer Validation ] ───(Fail)──> 400 Bad Request
|
|
124
|
+
│
|
|
125
|
+
▼
|
|
126
|
+
( Route Handler )
|
|
127
|
+
╭─────────────────────╮
|
|
128
|
+
│ Injects: │
|
|
129
|
+
│ - ctx.body / params │
|
|
130
|
+
│ - ctx.user │
|
|
131
|
+
│ - ctx.db │
|
|
132
|
+
│ - ctx.io │
|
|
133
|
+
│ - ctx.files │
|
|
134
|
+
╰─────────────────────╯
|
|
135
|
+
│
|
|
136
|
+
▼
|
|
137
|
+
[ Auto JSON Formatter ] ─────(Fail)──> 500 Internal Error
|
|
138
|
+
│
|
|
139
|
+
▼
|
|
140
|
+
[ Client JSON Response ]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Tech Stack Breakdown
|
|
146
|
+
|
|
147
|
+
| Layer | Technology | Purpose |
|
|
148
|
+
| :--- | :--- | :--- |
|
|
149
|
+
| **Engine** | Node.js (Express) | High-performance, battle-tested HTTP abstraction layer. |
|
|
150
|
+
| **Validation** | Zod | Bouncer-grade, strictly typed schema validation for payloads. |
|
|
151
|
+
| **Authentication** | jsonwebtoken | Stateless, scalable security for protecting endpoints. |
|
|
152
|
+
| **Realtime** | Socket.io | Bi-directional, event-driven WebSocket communication. |
|
|
153
|
+
| **API Reference** | Scalar | Auto-generated, interactive Swagger/OpenAPI documentation. |
|
|
154
|
+
| **Task Scheduler** | node-cron | Reliable internal background task orchestration. |
|
|
155
|
+
| **File Parsing** | multer | Zero-boilerplate `multipart/form-data` file extraction. |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## CLI Reference
|
|
160
|
+
|
|
161
|
+
| Command | Description |
|
|
162
|
+
| :--- | :--- |
|
|
163
|
+
| `bro dev` | Development server featuring instant boot, visual CLI banner, and `chokidar`-powered hot module remapping. |
|
|
164
|
+
| `bro start` | Production runner locked down for security. Zero watcher overhead, suppressed internal logs, and isolated API docs. |
|
|
165
|
+
| `bro init` | Automated workspace scaffolder. Generates configuration files and forcefully ensures your `package.json` respects `"type": "module"`. |
|
|
166
|
+
| `bro sdk` | Route parser and browser client compiler. Generates your frontend SDK in one hit. |
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Author & License
|
|
171
|
+
|
|
172
|
+
- **Author**: Yessin (@medyass1ne)
|
|
173
|
+
- **License**: MIT
|
package/bin/bro.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import { pathToFileURL } from 'url';
|
|
6
|
+
import { createServer } from '../src/server.js';
|
|
7
|
+
import { colors, printBanner, printRoute, printHotReload } from '../src/logger.js';
|
|
8
|
+
import { scanTasks } from '../src/tasks.js';
|
|
9
|
+
import { generateSDK } from '../src/sdk.js';
|
|
10
|
+
import dotenv from 'dotenv';
|
|
11
|
+
import chokidar from 'chokidar';
|
|
12
|
+
|
|
13
|
+
dotenv.config();
|
|
14
|
+
|
|
15
|
+
const command = process.argv[2] || 'dev';
|
|
16
|
+
|
|
17
|
+
if (command === 'dev') {
|
|
18
|
+
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
|
19
|
+
} else if (command === 'start') {
|
|
20
|
+
process.env.NODE_ENV = 'production';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CONFIG_TEMPLATE = `import { defineConfig } from 'bro.js';
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
// Server Settings
|
|
27
|
+
server: {
|
|
28
|
+
port: 5000,
|
|
29
|
+
cors: true // Set to true to allow all, or pass a CORS options object
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Authentication Settings
|
|
33
|
+
auth: {
|
|
34
|
+
jwtSecret: 'dev_secret_please_change',
|
|
35
|
+
expiresIn: '7d'
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// API Documentation (Scalar UI)
|
|
39
|
+
docs: process.env.NODE_ENV !== 'production', // Set to false to disable completely, or true to force in prod
|
|
40
|
+
|
|
41
|
+
// Rate Limiting
|
|
42
|
+
rateLimit: {
|
|
43
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
44
|
+
max: 100 // limit each IP to 100 requests per windowMs
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
// Database Context Injection
|
|
48
|
+
// This instance will be injected into every route's ctx.db (if defined)
|
|
49
|
+
db: async () => {
|
|
50
|
+
// If you use a database, set up your connection here
|
|
51
|
+
// and return the connection instance or an object of your models.
|
|
52
|
+
// Could be MongoDB, MySQL, etc. (your choice)
|
|
53
|
+
// --- MONGOOSE EXAMPLE ---
|
|
54
|
+
// import mongoose from 'mongoose';
|
|
55
|
+
|
|
56
|
+
// await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/bro_database');
|
|
57
|
+
// console.log("Connected to MongoDB");
|
|
58
|
+
|
|
59
|
+
// You can return mongoose itself, or an object of your models
|
|
60
|
+
// to access them instantly in your routes without importing them!
|
|
61
|
+
// Example: return { User, Post };
|
|
62
|
+
|
|
63
|
+
// return mongoose.connection;
|
|
64
|
+
// --------------------------
|
|
65
|
+
return null;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// WebSockets Setup
|
|
69
|
+
sockets: async (io, db) => {
|
|
70
|
+
io.on('connection', (socket) => {
|
|
71
|
+
console.log('Client connected:', socket.id);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
function scaffoldConfig() {
|
|
78
|
+
const configPath = path.join(process.cwd(), 'bro.config.js');
|
|
79
|
+
if (!fs.existsSync(configPath)) {
|
|
80
|
+
fs.writeFileSync(configPath, CONFIG_TEMPLATE, 'utf-8');
|
|
81
|
+
console.log(`\n ${colors.green} Created default bro.config.js${colors.reset}\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function ensureTypeModule() {
|
|
86
|
+
const pkgPath = path.join(process.cwd(), 'package.json');
|
|
87
|
+
|
|
88
|
+
if (fs.existsSync(pkgPath)) {
|
|
89
|
+
try {
|
|
90
|
+
const pkgRaw = fs.readFileSync(pkgPath, 'utf-8');
|
|
91
|
+
const pkg = JSON.parse(pkgRaw);
|
|
92
|
+
|
|
93
|
+
if (pkg.type !== 'module') {
|
|
94
|
+
pkg.type = 'module';
|
|
95
|
+
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), 'utf-8');
|
|
96
|
+
console.log(`\n ${colors.green} Auto-configured package.json for ES Modules${colors.reset}`);
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error(`\n ${colors.red} Failed to parse package.json for ES Modules setup${colors.reset}`, err);
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
const defaultPkg = {
|
|
103
|
+
name: "bro-app",
|
|
104
|
+
version: "1.0.0",
|
|
105
|
+
type: "module",
|
|
106
|
+
private: true
|
|
107
|
+
};
|
|
108
|
+
fs.writeFileSync(pkgPath, JSON.stringify(defaultPkg, null, 2), 'utf-8');
|
|
109
|
+
console.log(`\n ${colors.green} Created package.json with ES Modules enabled${colors.reset}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (command === 'init') {
|
|
114
|
+
ensureTypeModule();
|
|
115
|
+
scaffoldConfig();
|
|
116
|
+
process.exit(0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (['sdk', 'generate-client', 'client'].includes(command)) {
|
|
120
|
+
generateSDK().then(() => {
|
|
121
|
+
console.log(`\n ${colors.green}✨ bro-client.js generated successfully!${colors.reset}\n`);
|
|
122
|
+
process.exit(0);
|
|
123
|
+
}).catch(err => {
|
|
124
|
+
console.error(`\n ${colors.red}❌ Error generating SDK:${colors.reset}`, err.message);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function bootstrap() {
|
|
130
|
+
if (command === 'dev') {
|
|
131
|
+
ensureTypeModule();
|
|
132
|
+
scaffoldConfig();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const startTime = performance.now();
|
|
136
|
+
|
|
137
|
+
const cwd = process.cwd();
|
|
138
|
+
const configPath = path.join(cwd, 'bro.config.js');
|
|
139
|
+
const routesDir = path.join(cwd, 'routes');
|
|
140
|
+
|
|
141
|
+
let globalConfig = {
|
|
142
|
+
port: process.env.PORT || 5000,
|
|
143
|
+
jwtSecret: process.env.JWT_SECRET || 'dev_secret_please_change'
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
let db = null;
|
|
147
|
+
|
|
148
|
+
if (fs.existsSync(configPath)) {
|
|
149
|
+
try {
|
|
150
|
+
const configModule = await import(pathToFileURL(configPath).href);
|
|
151
|
+
const userConfig = configModule.default || configModule.config || {};
|
|
152
|
+
|
|
153
|
+
if (userConfig.server?.port) globalConfig.port = userConfig.server.port;
|
|
154
|
+
if (userConfig.auth?.jwtSecret) globalConfig.jwtSecret = userConfig.auth.jwtSecret;
|
|
155
|
+
|
|
156
|
+
globalConfig = { ...globalConfig, ...userConfig };
|
|
157
|
+
|
|
158
|
+
if (typeof globalConfig.db === 'function') {
|
|
159
|
+
db = await globalConfig.db();
|
|
160
|
+
} else if (globalConfig.db && typeof globalConfig.db.init === 'function') {
|
|
161
|
+
db = await globalConfig.db.init();
|
|
162
|
+
} else if (globalConfig.db) {
|
|
163
|
+
db = globalConfig.db;
|
|
164
|
+
if (db instanceof Promise) db = await db;
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('✗ Failed to load bro.config.js:', err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (globalConfig.env) {
|
|
172
|
+
const envResult = globalConfig.env.safeParse(process.env);
|
|
173
|
+
if (!envResult.success) {
|
|
174
|
+
console.error(`\n ${colors.red}❌ Environment Validation Failed${colors.reset}`);
|
|
175
|
+
envResult.error.errors.forEach(err => {
|
|
176
|
+
console.error(` ${colors.dim}-${colors.reset} ${colors.bold}${err.path.join('.')}${colors.reset}: ${err.message}`);
|
|
177
|
+
});
|
|
178
|
+
console.error("");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!fs.existsSync(routesDir)) {
|
|
184
|
+
console.error(`✗ Error: 'routes' directory not found in ${cwd}`);
|
|
185
|
+
console.error(` Please create a 'routes/' folder and add your first route.`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { app, server, routes: initialRoutes, reload, io } = await createServer(globalConfig, routesDir, db);
|
|
190
|
+
const port = globalConfig.port;
|
|
191
|
+
|
|
192
|
+
let currentRoutes = initialRoutes;
|
|
193
|
+
|
|
194
|
+
server.listen(port, async () => {
|
|
195
|
+
if (command === 'dev') {
|
|
196
|
+
console.clear();
|
|
197
|
+
printBanner(port, performance.now() - startTime);
|
|
198
|
+
} else if (command === 'start') {
|
|
199
|
+
console.log(`[bro.js] Server running in production on port ${port}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await scanTasks({ db, io });
|
|
203
|
+
|
|
204
|
+
if (command === 'dev') {
|
|
205
|
+
const printCurrentRoutes = (routesToPrint) => {
|
|
206
|
+
if (routesToPrint.length > 0) {
|
|
207
|
+
routesToPrint.forEach((r, i) => {
|
|
208
|
+
printRoute(r.method, r.path, r.auth, i === routesToPrint.length - 1);
|
|
209
|
+
});
|
|
210
|
+
console.log("");
|
|
211
|
+
} else {
|
|
212
|
+
console.log(" No routes found.\n");
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
printCurrentRoutes(currentRoutes);
|
|
217
|
+
|
|
218
|
+
const watcher = chokidar.watch(routesDir, { ignoreInitial: true });
|
|
219
|
+
|
|
220
|
+
watcher.on('all', async (event, filepath) => {
|
|
221
|
+
if (!filepath.endsWith('.js')) return;
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const reloadStartTime = performance.now();
|
|
225
|
+
currentRoutes = await reload();
|
|
226
|
+
const reloadTimeMs = performance.now() - reloadStartTime;
|
|
227
|
+
|
|
228
|
+
printHotReload(path.basename(filepath), event, reloadTimeMs);
|
|
229
|
+
printCurrentRoutes(currentRoutes);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error(`\n ✗ Error hot-reloading routes:`, err);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (command === 'dev' || command === 'start') {
|
|
239
|
+
bootstrap();
|
|
240
|
+
} else {
|
|
241
|
+
console.log(`Usage: bro dev | bro start | bro init`);
|
|
242
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bro-framework",
|
|
3
|
+
"version": "1.2.3",
|
|
4
|
+
"description": "The No-BS Backend Framework for Node.js",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"bro": "./bin/bro.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"bin"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@scalar/express-api-reference": "^0.10.18",
|
|
19
|
+
"chokidar": "^5.0.0",
|
|
20
|
+
"cors": "^2.8.6",
|
|
21
|
+
"dotenv": "^16.4.5",
|
|
22
|
+
"express": "^4.21.1",
|
|
23
|
+
"express-rate-limit": "^8.7.0",
|
|
24
|
+
"jsonwebtoken": "^9.0.2",
|
|
25
|
+
"multer": "^2.3.0",
|
|
26
|
+
"node-cron": "^4.6.0",
|
|
27
|
+
"socket.io": "^4.8.3",
|
|
28
|
+
"zod": "^3.23.8",
|
|
29
|
+
"zod-to-json-schema": "^3.25.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import jwt from 'jsonwebtoken';
|
|
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
|
+
/**
|
|
14
|
+
* Signs a JWT payload.
|
|
15
|
+
* @param {Object} payload - The data to embed in the token.
|
|
16
|
+
* @param {jwt.SignOptions} [options] - jsonwebtoken sign options.
|
|
17
|
+
* @returns {string} The signed JWT token.
|
|
18
|
+
*/
|
|
19
|
+
export function signJwt(payload, options = { expiresIn: '1d' }) {
|
|
20
|
+
return jwt.sign(payload, secret, options);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verifies and decodes a JWT token.
|
|
25
|
+
* @param {string} token - The JWT token to verify.
|
|
26
|
+
* @returns {{ valid: boolean, payload?: any, error?: string }}
|
|
27
|
+
*/
|
|
28
|
+
export function verifyJwt(token) {
|
|
29
|
+
try {
|
|
30
|
+
const payload = jwt.verify(token, secret);
|
|
31
|
+
return { valid: true, payload };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
let message = 'Invalid token';
|
|
34
|
+
if (error.name === 'TokenExpiredError') {
|
|
35
|
+
message = 'Token has expired';
|
|
36
|
+
} else if (error.name === 'JsonWebTokenError') {
|
|
37
|
+
message = 'Signature verification failed';
|
|
38
|
+
}
|
|
39
|
+
return { valid: false, error: message };
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ZodType } from 'zod';
|
|
2
|
+
|
|
3
|
+
export interface BroContext {
|
|
4
|
+
body?: any;
|
|
5
|
+
params?: any;
|
|
6
|
+
query?: any;
|
|
7
|
+
user?: any;
|
|
8
|
+
db?: any;
|
|
9
|
+
io?: any;
|
|
10
|
+
files?: any[];
|
|
11
|
+
error?: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RouteConfig {
|
|
15
|
+
auth?: boolean;
|
|
16
|
+
upload?: boolean;
|
|
17
|
+
body?: ZodType<any, any, any>;
|
|
18
|
+
params?: ZodType<any, any, any>;
|
|
19
|
+
query?: ZodType<any, any, any>;
|
|
20
|
+
rateLimit?: {
|
|
21
|
+
windowMs: number;
|
|
22
|
+
max: number;
|
|
23
|
+
};
|
|
24
|
+
summary?: string;
|
|
25
|
+
handler: (ctx: BroContext) => Promise<any> | any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function defineRoute(config: RouteConfig): RouteConfig;
|
|
29
|
+
|
|
30
|
+
export interface BroConfig {
|
|
31
|
+
env?: ZodType<any, any, any>;
|
|
32
|
+
server?: {
|
|
33
|
+
port?: number;
|
|
34
|
+
cors?: boolean | object;
|
|
35
|
+
};
|
|
36
|
+
auth?: {
|
|
37
|
+
jwtSecret?: string;
|
|
38
|
+
expiresIn?: string | number;
|
|
39
|
+
};
|
|
40
|
+
docs?: boolean | { auth?: { user: string; pass: string } };
|
|
41
|
+
rateLimit?: {
|
|
42
|
+
windowMs: number;
|
|
43
|
+
max: number;
|
|
44
|
+
};
|
|
45
|
+
db?: () => Promise<any> | any;
|
|
46
|
+
sockets?: (io: any, db: any) => Promise<void> | void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function defineConfig(config: BroConfig): BroConfig;
|
|
50
|
+
|
|
51
|
+
export { z } from 'zod';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defines a route configuration for bro.js.
|
|
5
|
+
* @param {Object} config - The route configuration.
|
|
6
|
+
* @param {Function} config.handler - The route handler function receiving ctx.
|
|
7
|
+
* @param {boolean} [config.auth] - Whether the route requires authentication.
|
|
8
|
+
* @param {import('zod').ZodType} [config.params] - Zod schema for route parameters.
|
|
9
|
+
* @param {import('zod').ZodType} [config.body] - Zod schema for request body.
|
|
10
|
+
* @param {import('zod').ZodType} [config.query] - Zod schema for query parameters.
|
|
11
|
+
* @returns {Object} The unchanged config object.
|
|
12
|
+
*/
|
|
13
|
+
export function defineRoute(config) {
|
|
14
|
+
return config;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Defines the global framework configuration.
|
|
19
|
+
* @param {Object} config - The global configuration.
|
|
20
|
+
* @param {string} [config.jwtSecret] - Secret key for JWT signing/verification.
|
|
21
|
+
* @param {number} [config.port] - Server port to listen on.
|
|
22
|
+
* @returns {Object} The unchanged config object.
|
|
23
|
+
*/
|
|
24
|
+
export function defineConfig(config) {
|
|
25
|
+
return config;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { z };
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const colors = {
|
|
2
|
+
reset: "\x1b[0m",
|
|
3
|
+
bold: "\x1b[1m",
|
|
4
|
+
dim: "\x1b[2m",
|
|
5
|
+
red: "\x1b[31m",
|
|
6
|
+
green: "\x1b[32m",
|
|
7
|
+
yellow: "\x1b[33m",
|
|
8
|
+
blue: "\x1b[34m",
|
|
9
|
+
magenta: "\x1b[35m",
|
|
10
|
+
cyan: "\x1b[36m",
|
|
11
|
+
white: "\x1b[37m"
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function formatMethod(method) {
|
|
15
|
+
const m = method.toUpperCase().padEnd(6);
|
|
16
|
+
switch(method.toUpperCase()) {
|
|
17
|
+
case 'GET': return `${colors.green}${colors.bold}${m}${colors.reset}`;
|
|
18
|
+
case 'POST': return `${colors.blue}${colors.bold}${m}${colors.reset}`;
|
|
19
|
+
case 'PUT': return `${colors.yellow}${colors.bold}${m}${colors.reset}`;
|
|
20
|
+
case 'DELETE': return `${colors.red}${colors.bold}${m}${colors.reset}`;
|
|
21
|
+
case 'PATCH': return `${colors.magenta}${colors.bold}${m}${colors.reset}`;
|
|
22
|
+
default: return `${colors.white}${colors.bold}${m}${colors.reset}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function printBanner(port, durationMs) {
|
|
27
|
+
const time = durationMs.toFixed(0);
|
|
28
|
+
const version = "1.0.0";
|
|
29
|
+
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log(`${colors.green}╭───────────────────────────────────────────────╮${colors.reset}`);
|
|
32
|
+
console.log(`${colors.green}│${colors.reset} ${colors.green}│${colors.reset}`);
|
|
33
|
+
console.log(`${colors.green}│${colors.reset} ${colors.bold}bro.js${colors.reset} v${version} ${colors.green}│${colors.reset}`);
|
|
34
|
+
console.log(`${colors.green}│${colors.reset} ${colors.green}│${colors.reset}`);
|
|
35
|
+
console.log(`${colors.green}│${colors.reset} ➜ ${colors.bold}Local:${colors.reset} ${colors.cyan}http://localhost:${port}${colors.reset} ${colors.green}│${colors.reset}`);
|
|
36
|
+
console.log(`${colors.green}│${colors.reset} ➜ ${colors.bold}Ready in:${colors.reset} ${colors.yellow}${time}ms${colors.reset} ${colors.green}│${colors.reset}`);
|
|
37
|
+
console.log(`${colors.green}│${colors.reset} ${colors.green}│${colors.reset}`);
|
|
38
|
+
console.log(`${colors.green}╰───────────────────────────────────────────────╯${colors.reset}`);
|
|
39
|
+
console.log("");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function printRoute(method, routePath, hasAuth, isLast = false) {
|
|
43
|
+
const branch = isLast ? "╰──" : "├──";
|
|
44
|
+
const coloredMethod = formatMethod(method);
|
|
45
|
+
const authIcon = hasAuth ? " 🔒" : "";
|
|
46
|
+
|
|
47
|
+
console.log(` ${colors.dim}${branch}${colors.reset} ${coloredMethod} ${routePath}${authIcon}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function printHotReload(fileName, event, reloadTimeMs) {
|
|
51
|
+
const time = typeof reloadTimeMs === 'number' ? reloadTimeMs.toFixed(0) : reloadTimeMs;
|
|
52
|
+
console.log(`\n ${colors.cyan}Route updated:${colors.reset} ${colors.bold}${fileName}${colors.reset} ${colors.dim}(${event})${colors.reset}`);
|
|
53
|
+
console.log(` ${colors.dim}Remapped in ${time}ms${colors.reset}\n`);
|
|
54
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathToFileURL } from 'url';
|
|
4
|
+
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Recursively scans a directory for .js files.
|
|
8
|
+
* @param {string} dir - The base directory to scan.
|
|
9
|
+
* @param {string[]} [fileList] - Internal accumulator for recursion.
|
|
10
|
+
* @returns {string[]} Array of absolute file paths.
|
|
11
|
+
*/
|
|
12
|
+
export function scanDir(dir, fileList = []) {
|
|
13
|
+
if (!fs.existsSync(dir)) return fileList;
|
|
14
|
+
|
|
15
|
+
const files = fs.readdirSync(dir);
|
|
16
|
+
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
const filePath = path.join(dir, file);
|
|
19
|
+
if (fs.statSync(filePath).isDirectory()) {
|
|
20
|
+
scanDir(filePath, fileList);
|
|
21
|
+
} else if (filePath.endsWith('.js')) {
|
|
22
|
+
fileList.push(filePath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return fileList;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Converts a file path to an Express route path.
|
|
31
|
+
* Example: routes/users/[id].get.js -> { routePath: '/users/:id', method: 'get' }
|
|
32
|
+
* @param {string} filePath - Absolute path to the route file.
|
|
33
|
+
* @param {string} routesDir - The root routes directory.
|
|
34
|
+
* @returns {{ routePath: string, method: string } | null}
|
|
35
|
+
*/
|
|
36
|
+
export function parseRouteFile(filePath, routesDir) {
|
|
37
|
+
const relativePath = path.relative(routesDir, filePath);
|
|
38
|
+
|
|
39
|
+
const parsed = path.parse(relativePath);
|
|
40
|
+
const parts = parsed.name.split('.');
|
|
41
|
+
|
|
42
|
+
if (parts.length < 2) return null;
|
|
43
|
+
|
|
44
|
+
const method = parts.pop().toLowerCase();
|
|
45
|
+
const namePart = parts.join('.');
|
|
46
|
+
|
|
47
|
+
let routePath = '/' + path.dirname(relativePath).replace(/\\/g, '/');
|
|
48
|
+
if (routePath === '/.') routePath = '';
|
|
49
|
+
|
|
50
|
+
if (namePart !== 'index') {
|
|
51
|
+
const formattedName = namePart.replace(/\[(.*?)\]/g, ':$1');
|
|
52
|
+
routePath += `/${formattedName}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (routePath === '') routePath = '/';
|
|
56
|
+
|
|
57
|
+
return { routePath, method };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loads and maps all route files into the Express application.
|
|
62
|
+
* @param {import('express').Application} app - The Express app instance.
|
|
63
|
+
* @param {string} routesDir - Path to the user's routes folder.
|
|
64
|
+
* @param {Function} createHandler - Core wrapper function for route logic.
|
|
65
|
+
* @param {Object} [openApiSpec] - Optional OpenAPI Spec object to build.
|
|
66
|
+
* @returns {Promise<Array>} Array of loaded route objects.
|
|
67
|
+
*/
|
|
68
|
+
export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
69
|
+
const files = scanDir(routesDir);
|
|
70
|
+
const loadedRoutes = [];
|
|
71
|
+
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const routeInfo = parseRouteFile(file, routesDir);
|
|
74
|
+
if (!routeInfo) continue;
|
|
75
|
+
|
|
76
|
+
const { routePath, method } = routeInfo;
|
|
77
|
+
|
|
78
|
+
if (typeof app[method] !== 'function') continue;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const moduleUrl = pathToFileURL(file).href + '?update=' + Date.now();
|
|
82
|
+
const module = await import(moduleUrl);
|
|
83
|
+
const config = module.default;
|
|
84
|
+
|
|
85
|
+
if (!config) continue;
|
|
86
|
+
|
|
87
|
+
const handler = createHandler(config);
|
|
88
|
+
app[method](routePath, handler);
|
|
89
|
+
|
|
90
|
+
if (openApiSpec) {
|
|
91
|
+
const openApiPath = routePath.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
|
|
92
|
+
if (!openApiSpec.paths[openApiPath]) openApiSpec.paths[openApiPath] = {};
|
|
93
|
+
|
|
94
|
+
const operation = {
|
|
95
|
+
summary: config.summary || `${method.toUpperCase()} ${routePath}`,
|
|
96
|
+
responses: { '200': { description: 'Successful response' } }
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (config.body) {
|
|
100
|
+
operation.requestBody = {
|
|
101
|
+
content: { 'application/json': { schema: zodToJsonSchema(config.body) } }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (config.params) {
|
|
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (config.query) {
|
|
116
|
+
operation.parameters = operation.parameters || [];
|
|
117
|
+
const qSchema = zodToJsonSchema(config.query);
|
|
118
|
+
if (qSchema.properties) {
|
|
119
|
+
for (const [key, schema] of Object.entries(qSchema.properties)) {
|
|
120
|
+
operation.parameters.push({
|
|
121
|
+
name: key,
|
|
122
|
+
in: 'query',
|
|
123
|
+
required: qSchema.required?.includes(key),
|
|
124
|
+
schema
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
openApiSpec.paths[openApiPath][method.toLowerCase()] = operation;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
loadedRoutes.push({
|
|
134
|
+
method: method.toUpperCase(),
|
|
135
|
+
path: routePath,
|
|
136
|
+
auth: !!config.auth
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error(`[bro.js] Failed to load route ${file}:`, err);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return loadedRoutes;
|
|
145
|
+
}
|
package/src/sdk.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { parseRouteFile, scanDir } from './router.js';
|
|
4
|
+
|
|
5
|
+
export async function generateSDK() {
|
|
6
|
+
const routesDir = path.join(process.cwd(), 'routes');
|
|
7
|
+
if (!fs.existsSync(routesDir)) {
|
|
8
|
+
throw new Error(`Routes directory not found at ${routesDir}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const files = scanDir(routesDir);
|
|
12
|
+
const endpoints = [];
|
|
13
|
+
|
|
14
|
+
for (const file of files) {
|
|
15
|
+
const routeInfo = parseRouteFile(file, routesDir);
|
|
16
|
+
if (routeInfo) {
|
|
17
|
+
endpoints.push(routeInfo);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const code = `// Auto-generated by bro.js
|
|
22
|
+
const CONFIG = {
|
|
23
|
+
baseURL: 'http://localhost:5000',
|
|
24
|
+
tokenKey: 'bro_token'
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function request(method, path, data) {
|
|
28
|
+
const headers = {};
|
|
29
|
+
|
|
30
|
+
if (typeof localStorage !== 'undefined') {
|
|
31
|
+
const token = localStorage.getItem(CONFIG.tokenKey);
|
|
32
|
+
if (token) {
|
|
33
|
+
headers['Authorization'] = \`Bearer \${token}\`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const options = {
|
|
38
|
+
method: method.toUpperCase(),
|
|
39
|
+
headers
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (data && ['POST', 'PUT', 'PATCH'].includes(method.toUpperCase())) {
|
|
43
|
+
headers['Content-Type'] = 'application/json';
|
|
44
|
+
options.body = JSON.stringify(data);
|
|
45
|
+
} else if (data && ['GET', 'DELETE'].includes(method.toUpperCase())) {
|
|
46
|
+
const params = new URLSearchParams(data);
|
|
47
|
+
path += '?' + params.toString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const url = CONFIG.baseURL + path;
|
|
51
|
+
const response = await fetch(url, options);
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
let errMessage = response.statusText;
|
|
55
|
+
try {
|
|
56
|
+
const errData = await response.json();
|
|
57
|
+
errMessage = errData.error || errData.message || errMessage;
|
|
58
|
+
} catch (e) {}
|
|
59
|
+
throw new Error(\`HTTP \${response.status}: \${errMessage}\`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return response.json();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const api = {
|
|
66
|
+
${generateApiObject(endpoints)}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function setBaseURL(url) {
|
|
70
|
+
CONFIG.baseURL = url;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function setTokenKey(key) {
|
|
74
|
+
CONFIG.tokenKey = key;
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
fs.writeFileSync(path.join(process.cwd(), 'bro-sdk.js'), code, 'utf-8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function generateApiObject(endpoints) {
|
|
82
|
+
const tree = {};
|
|
83
|
+
|
|
84
|
+
for (const { routePath, method } of endpoints) {
|
|
85
|
+
const parts = routePath.split('/').filter(Boolean);
|
|
86
|
+
|
|
87
|
+
let current = tree;
|
|
88
|
+
let pathAcc = '';
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < parts.length; i++) {
|
|
91
|
+
const part = parts[i];
|
|
92
|
+
const isParam = part.startsWith(':');
|
|
93
|
+
const name = isParam ? part.slice(1) : part;
|
|
94
|
+
pathAcc += '/' + part;
|
|
95
|
+
|
|
96
|
+
if (!current[name]) {
|
|
97
|
+
current[name] = { _isParam: isParam, _methods: {}, _children: {}, _path: pathAcc };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (i === parts.length - 1) {
|
|
101
|
+
current[name]._methods[method.toLowerCase()] = pathAcc;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
current = current[name]._children;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (parts.length === 0) {
|
|
108
|
+
if (!tree['root']) tree['root'] = { _isParam: false, _methods: {}, _children: {}, _path: '/' };
|
|
109
|
+
tree['root']._methods[method.toLowerCase()] = '/';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderTree(node, indent = ' ') {
|
|
114
|
+
let result = '';
|
|
115
|
+
|
|
116
|
+
for (const [key, val] of Object.entries(node)) {
|
|
117
|
+
if (val._isParam) {
|
|
118
|
+
result += `${indent}${key}: (${key}) => ({\n`;
|
|
119
|
+
|
|
120
|
+
for (const [m, p] of Object.entries(val._methods)) {
|
|
121
|
+
const templatedPath = p.replace(/:([a-zA-Z0-9_]+)/g, '${$1}');
|
|
122
|
+
result += `${indent} ${m}: (data) => request('${m}', \`${templatedPath}\`, data),\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const childrenStr = renderTree(val._children, indent + ' ');
|
|
126
|
+
if (childrenStr) {
|
|
127
|
+
result += childrenStr;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
result += `${indent}}),\n`;
|
|
131
|
+
} else {
|
|
132
|
+
result += `${indent}${key}: {\n`;
|
|
133
|
+
for (const [m, p] of Object.entries(val._methods)) {
|
|
134
|
+
result += `${indent} ${m}: (data) => request('${m}', '${p}', data),\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const childrenStr = renderTree(val._children, indent + ' ');
|
|
138
|
+
if (childrenStr) {
|
|
139
|
+
result += childrenStr;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
result += `${indent}},\n`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return renderTree(tree);
|
|
149
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { Server } from 'socket.io';
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import multer from 'multer';
|
|
7
|
+
import { apiReference } from '@scalar/express-api-reference';
|
|
8
|
+
import { verifyJwt, signJwt, setJwtSecret } from './auth.js';
|
|
9
|
+
import { loadRoutes } from './router.js';
|
|
10
|
+
|
|
11
|
+
const upload = multer();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates and configures the core Express server.
|
|
15
|
+
* @param {Object} globalConfig - User's bro.config.js configurations.
|
|
16
|
+
* @param {string} routesDir - Path to the target routes directory.
|
|
17
|
+
* @param {any} db - Initialized database instance.
|
|
18
|
+
* @returns {Promise<{ app: import('express').Application, server: http.Server, routes: Array, reload: Function, io: import('socket.io').Server }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function createServer(globalConfig, routesDir, db) {
|
|
21
|
+
const app = express();
|
|
22
|
+
const server = http.createServer(app);
|
|
23
|
+
|
|
24
|
+
const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
|
|
25
|
+
|
|
26
|
+
app.use(cors(typeof corsConfig === 'object' ? corsConfig : {}));
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
|
|
29
|
+
if (globalConfig.rateLimit) {
|
|
30
|
+
app.use(rateLimit(globalConfig.rateLimit));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const io = new Server(server, { cors: typeof corsConfig === 'object' ? corsConfig : undefined });
|
|
34
|
+
|
|
35
|
+
if (globalConfig.sockets) {
|
|
36
|
+
await globalConfig.sockets(io, db);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (globalConfig.jwtSecret) {
|
|
40
|
+
setJwtSecret(globalConfig.jwtSecret);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const createHandler = (routeConfig) => {
|
|
44
|
+
const middlewares = [];
|
|
45
|
+
|
|
46
|
+
if (routeConfig.rateLimit) {
|
|
47
|
+
middlewares.push(rateLimit(routeConfig.rateLimit));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (routeConfig.upload) {
|
|
51
|
+
middlewares.push(upload.any());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
middlewares.push(async (req, res) => {
|
|
55
|
+
try {
|
|
56
|
+
const ctx = {
|
|
57
|
+
db,
|
|
58
|
+
io,
|
|
59
|
+
body: req.body,
|
|
60
|
+
params: req.params,
|
|
61
|
+
query: req.query,
|
|
62
|
+
files: req.files || req.file,
|
|
63
|
+
user: null,
|
|
64
|
+
jwt: { sign: signJwt },
|
|
65
|
+
error: (status, message) => {
|
|
66
|
+
const err = new Error(message);
|
|
67
|
+
err.status = status;
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
if (routeConfig.auth) {
|
|
73
|
+
const authHeader = req.headers.authorization;
|
|
74
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
75
|
+
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid Bearer token' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const token = authHeader.split(' ')[1];
|
|
79
|
+
const authResult = verifyJwt(token);
|
|
80
|
+
|
|
81
|
+
if (!authResult.valid) {
|
|
82
|
+
return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ctx.user = authResult.payload;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (routeConfig.params) {
|
|
89
|
+
const result = routeConfig.params.safeParse(req.params);
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
return res.status(400).json({ error: 'Invalid URL Parameters', details: result.error.flatten() });
|
|
92
|
+
}
|
|
93
|
+
ctx.params = result.data;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (routeConfig.body) {
|
|
97
|
+
const result = routeConfig.body.safeParse(req.body);
|
|
98
|
+
if (!result.success) {
|
|
99
|
+
return res.status(400).json({ error: 'Invalid Request Body', details: result.error.flatten() });
|
|
100
|
+
}
|
|
101
|
+
ctx.body = result.data;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (routeConfig.query) {
|
|
105
|
+
const result = routeConfig.query.safeParse(req.query);
|
|
106
|
+
if (!result.success) {
|
|
107
|
+
return res.status(400).json({ error: 'Invalid Query Parameters', details: result.error.flatten() });
|
|
108
|
+
}
|
|
109
|
+
ctx.query = result.data;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (typeof routeConfig.handler !== 'function') {
|
|
113
|
+
throw new Error('Route "handler" is missing or is not a function');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const responseData = await routeConfig.handler(ctx);
|
|
117
|
+
|
|
118
|
+
if (!res.headersSent) {
|
|
119
|
+
res.status(200).json(responseData);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const status = err.status || 500;
|
|
124
|
+
const message = status === 500 ? 'Internal Server Error' : err.message;
|
|
125
|
+
|
|
126
|
+
if (status === 500) {
|
|
127
|
+
console.error(`[bro.js] Execution Error in route:`);
|
|
128
|
+
console.error(err.stack);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!res.headersSent) {
|
|
132
|
+
res.status(status).json({
|
|
133
|
+
error: message,
|
|
134
|
+
...(status !== 500 && err.details ? { details: err.details } : {})
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return middlewares;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
let openApiSpec = {
|
|
144
|
+
openapi: '3.0.0',
|
|
145
|
+
info: { title: 'bro.js API', version: '1.0.0' },
|
|
146
|
+
paths: {}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const shouldMountDocs = globalConfig.docs !== false && (globalConfig.docs === true || typeof globalConfig.docs === 'object' || process.env.NODE_ENV !== 'production');
|
|
150
|
+
|
|
151
|
+
if (shouldMountDocs) {
|
|
152
|
+
const docsAuthMiddleware = (req, res, next) => {
|
|
153
|
+
if (typeof globalConfig.docs === 'object' && globalConfig.docs.auth) {
|
|
154
|
+
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
|
|
155
|
+
const [user, pass] = Buffer.from(b64auth, 'base64').toString().split(':');
|
|
156
|
+
|
|
157
|
+
if (user === globalConfig.docs.auth.user && pass === globalConfig.docs.auth.pass) {
|
|
158
|
+
return next();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
res.set('WWW-Authenticate', 'Basic realm="bro.js API Docs"');
|
|
162
|
+
return res.status(401).send('Authentication required.');
|
|
163
|
+
}
|
|
164
|
+
next();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
app.get('/docs/json', docsAuthMiddleware, (req, res) => res.json(openApiSpec));
|
|
168
|
+
app.use('/docs', docsAuthMiddleware, apiReference({ spec: { url: '/docs/json' } }));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let routeStack = express.Router();
|
|
172
|
+
|
|
173
|
+
app.use((req, res, next) => {
|
|
174
|
+
routeStack(req, res, next);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const reload = async () => {
|
|
178
|
+
const newRouter = express.Router();
|
|
179
|
+
openApiSpec.paths = {};
|
|
180
|
+
const routes = await loadRoutes(newRouter, routesDir, createHandler, openApiSpec);
|
|
181
|
+
routeStack = newRouter;
|
|
182
|
+
return routes;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const initialRoutes = await reload();
|
|
186
|
+
|
|
187
|
+
return { app, server, routes: initialRoutes, reload, io };
|
|
188
|
+
}
|
package/src/tasks.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathToFileURL } from 'url';
|
|
4
|
+
import cron from 'node-cron';
|
|
5
|
+
import { colors } from './logger.js';
|
|
6
|
+
|
|
7
|
+
export async function scanTasks(ctx) {
|
|
8
|
+
const tasksDir = path.join(process.cwd(), 'tasks');
|
|
9
|
+
if (!fs.existsSync(tasksDir)) return;
|
|
10
|
+
|
|
11
|
+
const files = fs.readdirSync(tasksDir).filter(f => f.endsWith('.js'));
|
|
12
|
+
if (files.length === 0) return;
|
|
13
|
+
|
|
14
|
+
let count = 0;
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const filePath = path.join(tasksDir, file);
|
|
17
|
+
try {
|
|
18
|
+
const moduleUrl = pathToFileURL(filePath).href;
|
|
19
|
+
const taskModule = await import(moduleUrl);
|
|
20
|
+
|
|
21
|
+
if (taskModule.cron && typeof taskModule.handler === 'function') {
|
|
22
|
+
cron.schedule(taskModule.cron, async () => {
|
|
23
|
+
try {
|
|
24
|
+
await taskModule.handler(ctx);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.error(`\n ${colors.red}❌ Task Error (${file}):${colors.reset}`, err);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
count++;
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error(`\n ${colors.red}❌ Failed to load task ${file}:${colors.reset}`, err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (count > 0) {
|
|
37
|
+
console.log(` ${colors.dim}├──${colors.reset} ${colors.cyan}Scheduled ${count} background task(s)${colors.reset}`);
|
|
38
|
+
}
|
|
39
|
+
}
|