bro-framework 2.3.1 → 2.4.1
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 +115 -3
- package/bin/bro.js +13 -3
- package/package.json +9 -2
- package/src/index.d.ts +20 -2
- package/src/router.js +3 -1
- package/src/server.js +177 -13
package/README.md
CHANGED
|
@@ -27,8 +27,11 @@
|
|
|
27
27
|
## Table of Contents
|
|
28
28
|
|
|
29
29
|
- [Getting Started](#getting-started)
|
|
30
|
+
- [Configuration](#configuration)
|
|
30
31
|
- [The Core Experience](#the-core-experience)
|
|
31
32
|
- [Deep-Dive Features](#deep-dive-features)
|
|
33
|
+
- [Route Reference](#route-reference)
|
|
34
|
+
- [Context Reference](#context-reference)
|
|
32
35
|
- [Architecture & Request Lifecycle](#architecture--request-lifecycle)
|
|
33
36
|
- [Tech Stack Breakdown](#tech-stack-breakdown)
|
|
34
37
|
- [CLI Reference](#cli-reference)
|
|
@@ -48,6 +51,38 @@ npm run dev
|
|
|
48
51
|
|
|
49
52
|
That's it! Your zero-boilerplate backend is now running with hot-reloading enabled.
|
|
50
53
|
|
|
54
|
+
## Configuration
|
|
55
|
+
|
|
56
|
+
Configuration lives in `bro.config.js`:
|
|
57
|
+
|
|
58
|
+
```javascript
|
|
59
|
+
import { defineConfig } from 'bro-framework';
|
|
60
|
+
|
|
61
|
+
export default defineConfig({
|
|
62
|
+
server: { port: 5000, cors: true, helmet: true },
|
|
63
|
+
auth: {
|
|
64
|
+
jwtSecret: process.env.JWT_SECRET,
|
|
65
|
+
expiresIn: '7d',
|
|
66
|
+
apiKey: process.env.API_KEY
|
|
67
|
+
},
|
|
68
|
+
locale: { directory: './locale', defaultLocale: 'en' },
|
|
69
|
+
rateLimit: { windowMs: 15 * 60 * 1000, max: 100 },
|
|
70
|
+
docs: process.env.NODE_ENV !== 'production',
|
|
71
|
+
redisUrl: process.env.REDIS_URL,
|
|
72
|
+
db: async () => {
|
|
73
|
+
// Initialize database connection here
|
|
74
|
+
},
|
|
75
|
+
sockets: async (io, db) => {
|
|
76
|
+
// Setup Socket.IO event listeners here
|
|
77
|
+
},
|
|
78
|
+
onShutdown: async (db) => {
|
|
79
|
+
// Close application-owned database resources here.
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`cors: false` disables HTTP CORS middleware. Helmet is enabled by default and can be disabled with `helmet: false`. Redis is optional; when configured it powers distributed rate limiting, route caching, and Socket.IO scaling. In `NODE_ENV=test`, bro.js can inject `ioredis-mock`; install it in the consuming project's development dependencies.
|
|
85
|
+
|
|
51
86
|
---
|
|
52
87
|
|
|
53
88
|
## The Core Experience
|
|
@@ -68,6 +103,13 @@ export default defineRoute({
|
|
|
68
103
|
title: z.string().min(5),
|
|
69
104
|
content: z.string()
|
|
70
105
|
}),
|
|
106
|
+
query: z.object({
|
|
107
|
+
draft: z.coerce.boolean().default(false)
|
|
108
|
+
}),
|
|
109
|
+
response: z.object({
|
|
110
|
+
success: z.boolean(),
|
|
111
|
+
updated: z.number()
|
|
112
|
+
}),
|
|
71
113
|
handler: async ({ body, params, user, db, io }) => {
|
|
72
114
|
// 1. Data is already validated. user is already authenticated.
|
|
73
115
|
|
|
@@ -89,6 +131,8 @@ export default defineRoute({
|
|
|
89
131
|
});
|
|
90
132
|
```
|
|
91
133
|
|
|
134
|
+
Validation schemas are flat and must be declared directly as `body`, `params`, and `query`. The deprecated nested `schema: { body, params, query }` form is rejected during route loading. The optional `response` schema documents the successful JSON response in OpenAPI; it does not runtime-validate handler output.
|
|
135
|
+
|
|
92
136
|
---
|
|
93
137
|
|
|
94
138
|
## Deep-Dive Features
|
|
@@ -99,23 +143,49 @@ Create a `.js` file in the `routes/` directory, and it automatically becomes an
|
|
|
99
143
|
### Bouncer-Grade Validation
|
|
100
144
|
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
145
|
|
|
102
|
-
### Zero-Config JWTs
|
|
146
|
+
### Zero-Config Auth (JWTs, RBAC, API Keys)
|
|
103
147
|
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`.
|
|
148
|
+
You can also use Role-Based Access Control by passing an array of roles (e.g. `auth: ['admin']`) or enforce service-to-service communication with `auth: 'api-key'`. API keys are read from `auth.apiKey` or `API_KEY` and support zero-downtime rotation with an array:
|
|
149
|
+
|
|
150
|
+
```javascript
|
|
151
|
+
auth: {
|
|
152
|
+
apiKey: ['current-key', 'next-key']
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Clients send the selected key in the `x-api-key` header. API-key routes are represented as `apiKeyAuth` operations in OpenAPI.
|
|
104
157
|
|
|
105
158
|
### Context Injection
|
|
106
159
|
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.
|
|
107
160
|
|
|
161
|
+
### Redis Caching and Lifecycle
|
|
162
|
+
Set `redisUrl` to enable distributed rate limiting, route caching, and Socket.IO pub/sub scaling. Add `cache: 60` to a route to cache its JSON response for 60 seconds. Cache keys include the request URL, resolved locale, and authorization/API-key identity; do not cache responses with dimensions that are not represented in the key.
|
|
163
|
+
|
|
164
|
+
The programmatic `createServer()` API returns `shutdown()`. It stops scheduled tasks, closes Socket.IO, closes Redis clients, runs the optional `onShutdown(db)` hook, and closes the HTTP server. The CLI calls it automatically on `SIGINT` and `SIGTERM`.
|
|
165
|
+
|
|
108
166
|
### Zero-YAML Live Documentation
|
|
109
167
|
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.
|
|
110
168
|
|
|
111
169
|
### The Frontend SDK Generator
|
|
112
|
-
Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI
|
|
170
|
+
Tired of writing frontend `fetch` wrappers? Run `bro sdk`. The CLI parses your backend routes and compiles a JavaScript `bro-sdk.js` file. It includes token and locale headers, query serialization, URL-encoded dynamic parameters, and deep tree traversal (e.g., `api.users.id("123").get()`). Configure it with `setBaseURL()`, `setTokenKey()`, and `setLocale()`.
|
|
113
171
|
|
|
114
172
|
### Background Task Scheduler
|
|
115
173
|
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
174
|
|
|
117
175
|
### Zero-Boilerplate File Uploads
|
|
118
|
-
Add `upload: true` to a route.
|
|
176
|
+
Add `upload: true` to a route. bro.js uses Multer to parse `multipart/form-data` and inject files into the context. Use `single`, `array`, or `fields` for explicit field handling:
|
|
177
|
+
|
|
178
|
+
```javascript
|
|
179
|
+
export default defineRoute({
|
|
180
|
+
upload: {
|
|
181
|
+
single: 'avatar',
|
|
182
|
+
limits: { fileSize: 5 * 1024 * 1024 }
|
|
183
|
+
},
|
|
184
|
+
handler: ({ file }) => ({ name: file?.originalname })
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`ctx.file` is used by `single()`. `ctx.files` is an array for `array()` or a field-to-array object for `fields()`. Defaults include limits for file size, file count, fields, parts, and field size. Use `storage` for production disk/object-storage integration instead of retaining large buffers in memory.
|
|
119
189
|
|
|
120
190
|
### File-Based Locale
|
|
121
191
|
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:
|
|
@@ -132,6 +202,46 @@ export default defineRoute({
|
|
|
132
202
|
|
|
133
203
|
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')`.
|
|
134
204
|
|
|
205
|
+
## Route Reference
|
|
206
|
+
|
|
207
|
+
| Option | Type | Purpose |
|
|
208
|
+
| :--- | :--- | :--- |
|
|
209
|
+
| `auth` | `boolean \| string[] \| 'api-key'` | JWT, role, or API-key protection. |
|
|
210
|
+
| `body` | Zod schema | Validates JSON request bodies. |
|
|
211
|
+
| `params` | Zod schema | Validates URL parameters. |
|
|
212
|
+
| `query` | Zod schema | Validates query-string values. |
|
|
213
|
+
| `response` | Zod schema | Documents successful JSON output in OpenAPI. |
|
|
214
|
+
| `cache` | number | Redis response-cache duration in seconds. |
|
|
215
|
+
| `rateLimit` | `{ windowMs, max }` | Route-specific request limiting. |
|
|
216
|
+
| `upload` | boolean or options | Enables Multer parsing and limits. |
|
|
217
|
+
| `summary` | string | OpenAPI operation summary. |
|
|
218
|
+
|
|
219
|
+
Dynamic route files use bracket parameters such as `routes/users/[id].get.js`. Nested dynamic directories are supported. HTTP method suffixes are `get`, `post`, `put`, `delete`, `patch`, `options`, and `head`.
|
|
220
|
+
|
|
221
|
+
## Context Reference
|
|
222
|
+
|
|
223
|
+
Handlers receive:
|
|
224
|
+
|
|
225
|
+
| Property | Description |
|
|
226
|
+
| :--- | :--- |
|
|
227
|
+
| `body` | Validated body data. |
|
|
228
|
+
| `params` | Validated path parameters. |
|
|
229
|
+
| `query` | Validated query data. |
|
|
230
|
+
| `user` | Decoded JWT payload when JWT auth is used. |
|
|
231
|
+
| `db` | Value returned by `config.db`. |
|
|
232
|
+
| `io` | Socket.IO server instance. |
|
|
233
|
+
| `redis` | Redis client when Redis is enabled or test mode is active. |
|
|
234
|
+
| `file` | Single uploaded file from `upload.single()`. |
|
|
235
|
+
| `files` | Array or field map from `array()`/`fields()`. |
|
|
236
|
+
| `locale` | Resolved request locale. |
|
|
237
|
+
| `t` | Translation function, `t(key, values)`. |
|
|
238
|
+
| `jwt` | Configured JWT signing helper. |
|
|
239
|
+
| `env` | Parsed environment data when configured, otherwise `process.env`. |
|
|
240
|
+
|
|
241
|
+
## Testing
|
|
242
|
+
|
|
243
|
+
For Redis-backed integration tests without an external Redis server, install `ioredis-mock` in the consuming project and run with `NODE_ENV=test`. bro.js injects a mock Redis client and exercises cache, rate-limit, Socket.IO adapter, and shutdown paths.
|
|
244
|
+
|
|
135
245
|
---
|
|
136
246
|
|
|
137
247
|
## Architecture & Request Lifecycle
|
|
@@ -185,6 +295,8 @@ The locale is negotiated dynamically using RFC 9110 `Accept-Language` headers, s
|
|
|
185
295
|
| **API Reference** | Scalar | Auto-generated, interactive Swagger/OpenAPI documentation. |
|
|
186
296
|
| **Task Scheduler** | node-cron | Reliable internal background task orchestration. |
|
|
187
297
|
| **File Parsing** | multer | Zero-boilerplate `multipart/form-data` file extraction. |
|
|
298
|
+
| **Caching & Scaling** | Redis | Optional zero-config route caching, distributed rate-limiting, and WebSocket scaling. |
|
|
299
|
+
| **Security** | Helmet | Auto-configured industry-standard HTTP security headers. |
|
|
188
300
|
|
|
189
301
|
---
|
|
190
302
|
|
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';
|
|
@@ -29,13 +29,15 @@ export default defineConfig({
|
|
|
29
29
|
// Server Settings
|
|
30
30
|
server: {
|
|
31
31
|
port: 5000,
|
|
32
|
-
cors: true // Set to true to allow all, or pass a CORS options object
|
|
32
|
+
cors: true, // Set to true to allow all, or pass a CORS options object
|
|
33
|
+
helmet: true // Enable security headers
|
|
33
34
|
},
|
|
34
35
|
|
|
35
36
|
// Authentication Settings
|
|
36
37
|
auth: {
|
|
37
38
|
jwtSecret: 'dev_secret_please_change',
|
|
38
|
-
expiresIn: '7d'
|
|
39
|
+
expiresIn: '7d',
|
|
40
|
+
apiKey: process.env.API_KEY || ['dev_key_1', 'dev_key_2'] // Supports array for zero-downtime rotation
|
|
39
41
|
},
|
|
40
42
|
|
|
41
43
|
// Optional file-based API translations
|
|
@@ -53,6 +55,9 @@ export default defineConfig({
|
|
|
53
55
|
max: 100 // limit each IP to 100 requests per windowMs
|
|
54
56
|
},
|
|
55
57
|
|
|
58
|
+
// Redis Configuration (Auto-scales WebSockets, distributed caches & rate-limiting)
|
|
59
|
+
redisUrl: process.env.REDIS_URL, // e.g., 'redis://localhost:6379'
|
|
60
|
+
|
|
56
61
|
// WebSockets Setup
|
|
57
62
|
sockets: async (io, db) => {
|
|
58
63
|
io.on('connection', (socket) => {
|
|
@@ -79,6 +84,11 @@ export default defineConfig({
|
|
|
79
84
|
// return mongoose.connection;
|
|
80
85
|
// --------------------------
|
|
81
86
|
return null;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// Graceful Teardown Hook
|
|
90
|
+
onShutdown: async (db) => {
|
|
91
|
+
// Close application-owned database resources gracefully here
|
|
82
92
|
}
|
|
83
93
|
});
|
|
84
94
|
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bro-framework",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"description": "The No-BS Backend Framework for Node.js",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,14 +27,17 @@
|
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@scalar/express-api-reference": "^0.10.18",
|
|
30
|
+
"@socket.io/redis-adapter": "^8.3.0",
|
|
30
31
|
"chokidar": "^5.0.0",
|
|
31
32
|
"cors": "^2.8.6",
|
|
32
33
|
"dotenv": "^16.4.5",
|
|
33
34
|
"express": "^4.21.1",
|
|
34
35
|
"express-rate-limit": "^8.7.0",
|
|
36
|
+
"helmet": "^8.3.0",
|
|
35
37
|
"jsonwebtoken": "^9.0.2",
|
|
36
38
|
"multer": "^2.3.0",
|
|
37
39
|
"node-cron": "^4.6.0",
|
|
40
|
+
"redis": "^6.2.1",
|
|
38
41
|
"socket.io": "^4.8.3",
|
|
39
42
|
"tsx": "^4.23.13",
|
|
40
43
|
"zod": "^3.23.8",
|
|
@@ -43,6 +46,7 @@
|
|
|
43
46
|
"keywords": [
|
|
44
47
|
"bro",
|
|
45
48
|
"brojs",
|
|
49
|
+
"bro.js",
|
|
46
50
|
"bro-framework",
|
|
47
51
|
"framework",
|
|
48
52
|
"backend",
|
|
@@ -84,5 +88,8 @@
|
|
|
84
88
|
"ai-friendly",
|
|
85
89
|
"developer-experience",
|
|
86
90
|
"dx"
|
|
87
|
-
]
|
|
91
|
+
],
|
|
92
|
+
"devDependencies": {
|
|
93
|
+
"ioredis-mock": "^8.13.1"
|
|
94
|
+
}
|
|
88
95
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -2,6 +2,18 @@ import { z, ZodTypeAny } from 'zod';
|
|
|
2
2
|
|
|
3
3
|
type InferZod<T> = T extends ZodTypeAny ? z.infer<T> : any;
|
|
4
4
|
|
|
5
|
+
export interface UploadedFile {
|
|
6
|
+
fieldname: string;
|
|
7
|
+
originalname: string;
|
|
8
|
+
encoding: string;
|
|
9
|
+
mimetype: string;
|
|
10
|
+
size: number;
|
|
11
|
+
destination?: string;
|
|
12
|
+
filename?: string;
|
|
13
|
+
path?: string;
|
|
14
|
+
buffer?: Buffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
export interface BroContext<Body = any, Params = any, Query = any> {
|
|
6
18
|
env?: any;
|
|
7
19
|
jwt?: { sign: (payload: any, options?: any) => string };
|
|
@@ -11,19 +23,22 @@ export interface BroContext<Body = any, Params = any, Query = any> {
|
|
|
11
23
|
user?: any;
|
|
12
24
|
db?: any;
|
|
13
25
|
io?: any;
|
|
14
|
-
|
|
26
|
+
file?: UploadedFile;
|
|
27
|
+
files?: UploadedFile[] | Record<string, UploadedFile[]>;
|
|
15
28
|
locale: string;
|
|
16
29
|
t: (key: string, values?: Record<string, unknown>) => string;
|
|
17
30
|
error?: any;
|
|
31
|
+
redis?: any;
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
export interface RouteConfig<Body = any, Params = any, Query = any> {
|
|
21
|
-
auth?: boolean;
|
|
35
|
+
auth?: boolean | string[] | 'api-key';
|
|
22
36
|
upload?: boolean | { limits?: any, fields?: { name: string, maxCount?: number }[], single?: string, array?: string, fileFilter?: any, storage?: any };
|
|
23
37
|
body?: Body;
|
|
24
38
|
params?: Params;
|
|
25
39
|
query?: Query;
|
|
26
40
|
response?: ZodTypeAny;
|
|
41
|
+
cache?: number;
|
|
27
42
|
rateLimit?: {
|
|
28
43
|
windowMs: number;
|
|
29
44
|
max: number;
|
|
@@ -48,6 +63,7 @@ export interface BroConfig {
|
|
|
48
63
|
server?: {
|
|
49
64
|
port?: number;
|
|
50
65
|
cors?: boolean | object;
|
|
66
|
+
helmet?: boolean | object;
|
|
51
67
|
};
|
|
52
68
|
locale?: {
|
|
53
69
|
directory?: string;
|
|
@@ -56,6 +72,7 @@ export interface BroConfig {
|
|
|
56
72
|
auth?: {
|
|
57
73
|
jwtSecret?: string;
|
|
58
74
|
expiresIn?: string | number;
|
|
75
|
+
apiKey?: string | string[];
|
|
59
76
|
};
|
|
60
77
|
docs?: boolean | { auth?: { user: string; pass: string } };
|
|
61
78
|
rateLimit?: {
|
|
@@ -73,6 +90,7 @@ export interface BroConfig {
|
|
|
73
90
|
db?: () => Promise<any> | any;
|
|
74
91
|
sockets?: (io: any, db: any) => Promise<void> | void;
|
|
75
92
|
onShutdown?: (db: any) => Promise<void> | void;
|
|
93
|
+
redisUrl?: string;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
export function defineConfig(config: BroConfig): BroConfig;
|
package/src/router.js
CHANGED
|
@@ -158,7 +158,9 @@ export async function loadRoutes(app, routesDir, createHandler, openApiSpec) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// Auto-inject security definition if auth is true
|
|
161
|
-
if (config.auth) {
|
|
161
|
+
if (config.auth === 'api-key') {
|
|
162
|
+
operation.security = [{ apiKeyAuth: [] }];
|
|
163
|
+
} else if (config.auth) {
|
|
162
164
|
operation.security = [{ bearerAuth: [] }];
|
|
163
165
|
}
|
|
164
166
|
|
package/src/server.js
CHANGED
|
@@ -2,9 +2,13 @@ import express from 'express';
|
|
|
2
2
|
import cors from 'cors';
|
|
3
3
|
import http from 'node:http';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import { Server } from 'socket.io';
|
|
6
7
|
import rateLimit from 'express-rate-limit';
|
|
7
8
|
import multer from 'multer';
|
|
9
|
+
import helmet from 'helmet';
|
|
10
|
+
import { createClient } from 'redis';
|
|
11
|
+
import { createAdapter } from '@socket.io/redis-adapter';
|
|
8
12
|
import { apiReference } from '@scalar/express-api-reference';
|
|
9
13
|
import { verifyJwt, signJwt } from './auth.js';
|
|
10
14
|
import { loadLocale } from './locale.js';
|
|
@@ -28,6 +32,21 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
28
32
|
const localeDirectory = globalConfig.locale?.directory || path.join(process.cwd(), 'locale');
|
|
29
33
|
let locale = await loadLocale(localeDirectory, globalConfig.locale);
|
|
30
34
|
|
|
35
|
+
const helmetConfig = globalConfig.server?.helmet !== undefined ? globalConfig.server.helmet : true;
|
|
36
|
+
if (helmetConfig !== false) {
|
|
37
|
+
const userConfig = typeof helmetConfig === 'object' ? helmetConfig : {};
|
|
38
|
+
app.use(helmet({
|
|
39
|
+
...userConfig,
|
|
40
|
+
contentSecurityPolicy: userConfig.contentSecurityPolicy ?? {
|
|
41
|
+
directives: {
|
|
42
|
+
...helmet.contentSecurityPolicy.getDefaultDirectives(),
|
|
43
|
+
"script-src": ["'self'", "'unsafe-inline'"],
|
|
44
|
+
"style-src": ["'self'", "'unsafe-inline'"],
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
const corsConfig = globalConfig.server?.cors !== undefined ? globalConfig.server.cors : true;
|
|
32
51
|
|
|
33
52
|
if (corsConfig !== false) {
|
|
@@ -36,12 +55,84 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
36
55
|
|
|
37
56
|
app.use(express.json());
|
|
38
57
|
|
|
58
|
+
const safeConnect = async (client) => {
|
|
59
|
+
if (typeof client.connect !== 'function') return;
|
|
60
|
+
if (client.status && client.status !== 'wait') return;
|
|
61
|
+
try {
|
|
62
|
+
await client.connect();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (!err.message.includes('already connecting') && !err.message.includes('already connected')) {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let redisClient = null;
|
|
71
|
+
let pubClient = null;
|
|
72
|
+
let subClient = null;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
if (globalConfig.redisUrl) {
|
|
76
|
+
redisClient = createClient({ url: globalConfig.redisUrl });
|
|
77
|
+
redisClient.on('error', (err) => console.error('[bro.js] Redis Error:', err));
|
|
78
|
+
await safeConnect(redisClient);
|
|
79
|
+
} else if (process.env.NODE_ENV === 'test') {
|
|
80
|
+
try {
|
|
81
|
+
const IORedisMock = (await import('ioredis-mock')).default;
|
|
82
|
+
redisClient = new IORedisMock();
|
|
83
|
+
redisClient.connect = async () => {};
|
|
84
|
+
redisClient.setEx = redisClient.setex.bind(redisClient);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw new Error("ioredis-mock is required for test mode. Please install it as a devDependency to use NODE_ENV=test.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (redisClient) await redisClient.quit().catch(() => {});
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
39
94
|
if (globalConfig.rateLimit) {
|
|
40
|
-
|
|
95
|
+
if (redisClient) {
|
|
96
|
+
const fallbackLimiter = rateLimit(globalConfig.rateLimit);
|
|
97
|
+
app.use(async (req, res, next) => {
|
|
98
|
+
try {
|
|
99
|
+
const key = `rate_limit:global:${req.ip}`;
|
|
100
|
+
const current = await redisClient.incr(key);
|
|
101
|
+
if (current === 1) {
|
|
102
|
+
await redisClient.expire(key, Math.floor(globalConfig.rateLimit.windowMs / 1000));
|
|
103
|
+
}
|
|
104
|
+
if (current > globalConfig.rateLimit.max) {
|
|
105
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
106
|
+
}
|
|
107
|
+
next();
|
|
108
|
+
} catch (err) {
|
|
109
|
+
console.error('[bro.js] Redis Global Rate Limit Error:', err);
|
|
110
|
+
fallbackLimiter(req, res, next);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
app.use(rateLimit(globalConfig.rateLimit));
|
|
115
|
+
}
|
|
41
116
|
}
|
|
42
117
|
|
|
43
118
|
const io = new Server(server, { cors: typeof corsConfig === 'object' ? corsConfig : undefined });
|
|
44
119
|
|
|
120
|
+
try {
|
|
121
|
+
if (redisClient) {
|
|
122
|
+
pubClient = redisClient.duplicate();
|
|
123
|
+
subClient = redisClient.duplicate();
|
|
124
|
+
await Promise.all([safeConnect(pubClient), safeConnect(subClient)]);
|
|
125
|
+
io.adapter(createAdapter(pubClient, subClient));
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
await Promise.allSettled([
|
|
129
|
+
redisClient?.quit(),
|
|
130
|
+
pubClient?.quit(),
|
|
131
|
+
subClient?.quit()
|
|
132
|
+
].filter(Boolean));
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
|
|
45
136
|
if (globalConfig.sockets) {
|
|
46
137
|
await globalConfig.sockets(io, db);
|
|
47
138
|
}
|
|
@@ -55,7 +146,27 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
55
146
|
const querySchema = routeConfig.query;
|
|
56
147
|
|
|
57
148
|
if (routeConfig.rateLimit) {
|
|
58
|
-
|
|
149
|
+
if (redisClient) {
|
|
150
|
+
const fallbackLimiter = rateLimit(routeConfig.rateLimit);
|
|
151
|
+
middlewares.push(async (req, res, next) => {
|
|
152
|
+
try {
|
|
153
|
+
const key = `rate_limit:${req.ip}:${req.originalUrl}`;
|
|
154
|
+
const current = await redisClient.incr(key);
|
|
155
|
+
if (current === 1) {
|
|
156
|
+
await redisClient.expire(key, Math.floor(routeConfig.rateLimit.windowMs / 1000));
|
|
157
|
+
}
|
|
158
|
+
if (current > routeConfig.rateLimit.max) {
|
|
159
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
160
|
+
}
|
|
161
|
+
next();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('[bro.js] Redis Route Rate Limit Error:', err);
|
|
164
|
+
fallbackLimiter(req, res, next);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
middlewares.push(rateLimit(routeConfig.rateLimit));
|
|
169
|
+
}
|
|
59
170
|
}
|
|
60
171
|
|
|
61
172
|
if (routeConfig.upload) {
|
|
@@ -91,6 +202,7 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
91
202
|
env: globalConfig.envData || process.env,
|
|
92
203
|
db,
|
|
93
204
|
io,
|
|
205
|
+
redis: redisClient,
|
|
94
206
|
body: req.body,
|
|
95
207
|
params: req.params,
|
|
96
208
|
query: req.query,
|
|
@@ -106,18 +218,40 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
106
218
|
}
|
|
107
219
|
};
|
|
108
220
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
221
|
+
if (routeConfig.auth === 'api-key') {
|
|
222
|
+
const apiKey = req.headers['x-api-key'];
|
|
223
|
+
const validKey = globalConfig.auth?.apiKey || process.env.API_KEY;
|
|
224
|
+
|
|
225
|
+
let isValid = false;
|
|
226
|
+
if (Array.isArray(validKey)) {
|
|
227
|
+
isValid = validKey.includes(apiKey);
|
|
228
|
+
} else {
|
|
229
|
+
isValid = apiKey && apiKey === validKey;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isValid) {
|
|
233
|
+
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid API key' });
|
|
234
|
+
}
|
|
235
|
+
} else if (routeConfig.auth) {
|
|
236
|
+
const authHeader = req.headers.authorization;
|
|
237
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
238
|
+
return res.status(401).json({ error: 'Unauthorized', details: 'Missing or invalid Bearer token' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const token = authHeader?.split(' ')[1] ?? '';
|
|
242
|
+
const authResult = verifyJwt(token, globalConfig.jwtSecret);
|
|
243
|
+
|
|
244
|
+
if (!authResult.valid) {
|
|
245
|
+
return res.status(401).json({ error: 'Unauthorized', details: authResult.error });
|
|
246
|
+
}
|
|
247
|
+
ctx.user = authResult.payload ?? null;
|
|
248
|
+
|
|
249
|
+
if (Array.isArray(routeConfig.auth)) {
|
|
250
|
+
if (!ctx.user || !ctx.user.role || !routeConfig.auth.includes(ctx.user.role)) {
|
|
251
|
+
return res.status(403).json({ error: 'Forbidden', details: 'Insufficient role permissions' });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
119
254
|
}
|
|
120
|
-
ctx.user = authResult?.payload ?? null;
|
|
121
255
|
|
|
122
256
|
if (paramsSchema) {
|
|
123
257
|
const result = paramsSchema.safeParse(req.params);
|
|
@@ -147,9 +281,29 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
147
281
|
throw new Error('Route "handler" is missing or is not a function');
|
|
148
282
|
}
|
|
149
283
|
|
|
284
|
+
let cacheKey = null;
|
|
285
|
+
if (routeConfig.cache && redisClient) {
|
|
286
|
+
const authIdentity = crypto.createHash('sha256').update(req.headers.authorization || req.headers['x-api-key'] || 'anonymous').digest('hex');
|
|
287
|
+
cacheKey = `bro:cache:${req.method}:${req.originalUrl}:${requestLocale}:${authIdentity}`;
|
|
288
|
+
try {
|
|
289
|
+
const cached = await redisClient.get(cacheKey);
|
|
290
|
+
if (cached) {
|
|
291
|
+
const parsed = JSON.parse(cached);
|
|
292
|
+
if (!res.headersSent) res.status(200).json(parsed);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
} catch (err) {
|
|
296
|
+
console.error('[bro.js] Cache parsing failed, deleting key:', cacheKey);
|
|
297
|
+
await redisClient.del(cacheKey).catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
150
301
|
const responseData = await routeConfig.handler(ctx);
|
|
151
302
|
|
|
152
303
|
if (!res.headersSent) {
|
|
304
|
+
if (cacheKey && routeConfig.cache) {
|
|
305
|
+
await redisClient.setEx(cacheKey, routeConfig.cache, JSON.stringify(responseData));
|
|
306
|
+
}
|
|
153
307
|
res.status(200).json(responseData);
|
|
154
308
|
}
|
|
155
309
|
|
|
@@ -183,6 +337,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
183
337
|
type: 'http',
|
|
184
338
|
scheme: 'bearer',
|
|
185
339
|
bearerFormat: 'JWT'
|
|
340
|
+
},
|
|
341
|
+
apiKeyAuth: {
|
|
342
|
+
type: 'apiKey',
|
|
343
|
+
in: 'header',
|
|
344
|
+
name: 'x-api-key'
|
|
186
345
|
}
|
|
187
346
|
},
|
|
188
347
|
responses: {
|
|
@@ -256,6 +415,11 @@ export async function createServer(globalConfig, routesDir, db) {
|
|
|
256
415
|
isShuttingDown = true;
|
|
257
416
|
if (taskManager) taskManager.stopAll();
|
|
258
417
|
if (io) io.close();
|
|
418
|
+
await Promise.allSettled([
|
|
419
|
+
redisClient?.quit(),
|
|
420
|
+
pubClient?.quit(),
|
|
421
|
+
subClient?.quit()
|
|
422
|
+
].filter(Boolean));
|
|
259
423
|
|
|
260
424
|
if (typeof globalConfig.onShutdown === 'function') {
|
|
261
425
|
try {
|