bro-framework 2.4.1 → 2.4.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/README.md +29 -1
- package/bin/bro.js +9 -3
- package/package.json +9 -1
- package/src/next.d.ts +61 -0
- package/src/next.js +211 -0
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
- [Architecture & Request Lifecycle](#architecture--request-lifecycle)
|
|
36
36
|
- [Tech Stack Breakdown](#tech-stack-breakdown)
|
|
37
37
|
- [CLI Reference](#cli-reference)
|
|
38
|
+
- [Next.js App Router Integration](#nextjs-app-router-integration)
|
|
38
39
|
- [Author & License](#author--license)
|
|
39
40
|
|
|
40
41
|
---
|
|
@@ -309,9 +310,36 @@ For Redis-backed integration tests without an external Redis server, install `io
|
|
|
309
310
|
| `bro init` | Automated workspace scaffolder. Generates configuration files and forcefully ensures your `package.json` respects `"type": "module"`. |
|
|
310
311
|
| `bro sdk` | Route parser and browser client compiler. Generates your typed `bro-sdk.js` frontend SDK in one hit. |
|
|
311
312
|
|
|
313
|
+
## Next.js App Router Integration
|
|
314
|
+
|
|
315
|
+
You can natively use `bro.js` syntax, Zod validation, and Authentication inside your Next.js API routes (`app/api/.../route.ts`)!
|
|
316
|
+
|
|
317
|
+
First, create your factory instance (e.g., `lib/bro.ts`):
|
|
318
|
+
```typescript
|
|
319
|
+
import { createBro } from 'bro-framework/next';
|
|
320
|
+
|
|
321
|
+
export const { defineRoute, z } = createBro({
|
|
322
|
+
auth: { apiKey: process.env.API_KEY }
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Then use it seamlessly in your route files:
|
|
327
|
+
```typescript
|
|
328
|
+
import { defineRoute, z } from '@/lib/bro';
|
|
329
|
+
|
|
330
|
+
export const POST = defineRoute({
|
|
331
|
+
body: z.object({ name: z.string() }),
|
|
332
|
+
handler: async ({ body }) => {
|
|
333
|
+
return { success: true, hello: body.name };
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
> **Capabilities & Limitations**: Because Next.js API routes are "Serverless" (meaning they sleep when not actively processing a request), features that require a constantly running server such as **WebSockets**, **Background Tasks (Cron)**, **Rate Limiting**, and **Auto-generated Docs** are strictly limited to the standalone `bro.js` framework and are not available in the Next.js adapter.
|
|
339
|
+
|
|
312
340
|
---
|
|
313
341
|
|
|
314
342
|
## Author & License
|
|
315
343
|
|
|
316
|
-
- **Author**:
|
|
344
|
+
- **Author**: Yass1n (@medyass1ne)
|
|
317
345
|
- **License**: MIT
|
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';
|
|
@@ -274,6 +274,12 @@ async function bootstrap() {
|
|
|
274
274
|
|
|
275
275
|
if (command === 'dev' || command === 'start') {
|
|
276
276
|
bootstrap();
|
|
277
|
-
} else {
|
|
278
|
-
console.log(
|
|
277
|
+
} else if (!['sdk', 'generate-client', 'client', 'init'].includes(command)) {
|
|
278
|
+
console.log(`\n ${colors.bold}${colors.green}bro.js CLI${colors.reset}\n`);
|
|
279
|
+
console.log(` ${colors.bold}Usage:${colors.reset} bro <command>\n`);
|
|
280
|
+
console.log(` ${colors.bold}Commands:${colors.reset}`);
|
|
281
|
+
console.log(` ${colors.cyan}dev${colors.reset} Start the development server with hot-reload`);
|
|
282
|
+
console.log(` ${colors.cyan}start${colors.reset} Start the production server gracefully`);
|
|
283
|
+
console.log(` ${colors.cyan}init${colors.reset} Scaffold a new bro.config.js workspace`);
|
|
284
|
+
console.log(` ${colors.cyan}sdk${colors.reset} Generate a typed frontend client\n`);
|
|
279
285
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bro-framework",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.3",
|
|
4
4
|
"description": "The No-BS Backend Framework for Node.js",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": {
|
|
17
17
|
"types": "./src/index.d.ts",
|
|
18
|
+
"import": "./src/index.js",
|
|
19
|
+
"require": "./src/index.js",
|
|
18
20
|
"default": "./src/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./next": {
|
|
23
|
+
"types": "./src/next.d.ts",
|
|
24
|
+
"import": "./src/next.js",
|
|
25
|
+
"require": "./src/next.js",
|
|
26
|
+
"default": "./src/next.js"
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
"bin": {
|
package/src/next.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z, ZodTypeAny } from 'zod';
|
|
2
|
+
|
|
3
|
+
export { z };
|
|
4
|
+
|
|
5
|
+
export interface UploadedFile {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
size: number;
|
|
9
|
+
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
10
|
+
stream: () => ReadableStream;
|
|
11
|
+
text: () => Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface NextBroGlobalConfig<TDb = any> {
|
|
15
|
+
locale?: any;
|
|
16
|
+
redisUrl?: string;
|
|
17
|
+
auth?: {
|
|
18
|
+
jwtSecret?: string;
|
|
19
|
+
apiKey?: string | string[];
|
|
20
|
+
};
|
|
21
|
+
db?: TDb | Promise<TDb> | (() => TDb | Promise<TDb>) | { init: () => TDb | Promise<TDb> };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NextRouteContext<TBody = any, TQuery = any, TParams = any, TDb = any> {
|
|
25
|
+
req: Request;
|
|
26
|
+
env: Record<string, string | undefined>;
|
|
27
|
+
db: TDb;
|
|
28
|
+
redis: any;
|
|
29
|
+
io: { emit: (event: string, data: any) => void };
|
|
30
|
+
body: TBody extends ZodTypeAny ? z.infer<TBody> : any;
|
|
31
|
+
query: TQuery extends ZodTypeAny ? z.infer<TQuery> : any;
|
|
32
|
+
params: TParams extends ZodTypeAny ? z.infer<TParams> : any;
|
|
33
|
+
file?: UploadedFile;
|
|
34
|
+
files?: Record<string, UploadedFile[]>;
|
|
35
|
+
locale: string;
|
|
36
|
+
t: (key: string, values?: any) => string;
|
|
37
|
+
user?: any;
|
|
38
|
+
jwt: { sign: (payload: any, opts?: any) => string };
|
|
39
|
+
error: (status: number, message: string) => never;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface NextRouteConfig<TBody = any, TQuery = any, TParams = any, TDb = any> {
|
|
43
|
+
auth?: boolean | string[] | 'api-key' | string;
|
|
44
|
+
body?: TBody;
|
|
45
|
+
query?: TQuery;
|
|
46
|
+
params?: TParams;
|
|
47
|
+
handler: (ctx: NextRouteContext<TBody, TQuery, TParams, TDb>) => Promise<any> | any;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BroNextInstance<TDb = any> {
|
|
51
|
+
z: typeof z;
|
|
52
|
+
defineRoute: <
|
|
53
|
+
TBody extends ZodTypeAny = any,
|
|
54
|
+
TQuery extends ZodTypeAny = any,
|
|
55
|
+
TParams extends ZodTypeAny = any
|
|
56
|
+
>(
|
|
57
|
+
config: NextRouteConfig<TBody, TQuery, TParams, TDb>
|
|
58
|
+
) => (req: Request | any, context: any) => Promise<any>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export declare function createBro<TDb = any>(config?: NextBroGlobalConfig<TDb>): BroNextInstance<TDb>;
|
package/src/next.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { verifyJwt, signJwt } from './auth.js';
|
|
4
|
+
import { createClient } from 'redis';
|
|
5
|
+
|
|
6
|
+
export function createBro(globalConfig = {}) {
|
|
7
|
+
let isInitialized = false;
|
|
8
|
+
let initPromise = null;
|
|
9
|
+
|
|
10
|
+
let globalDb = null;
|
|
11
|
+
let globalRedis = null;
|
|
12
|
+
let globalLocale = null;
|
|
13
|
+
|
|
14
|
+
async function ensureInitialized() {
|
|
15
|
+
if (isInitialized) return;
|
|
16
|
+
if (initPromise) return initPromise;
|
|
17
|
+
|
|
18
|
+
initPromise = (async () => {
|
|
19
|
+
try {
|
|
20
|
+
// Locale Dummy or Real
|
|
21
|
+
if (globalConfig.locale && typeof globalConfig.locale.resolveLocale === 'function') {
|
|
22
|
+
globalLocale = globalConfig.locale;
|
|
23
|
+
} else {
|
|
24
|
+
globalLocale = {
|
|
25
|
+
resolveLocale: () => 'en',
|
|
26
|
+
translate: (locale, key) => key
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// DB
|
|
31
|
+
if (typeof globalConfig.db === 'function') {
|
|
32
|
+
globalDb = await globalConfig.db();
|
|
33
|
+
} else if (globalConfig.db && typeof globalConfig.db.init === 'function') {
|
|
34
|
+
globalDb = await globalConfig.db.init();
|
|
35
|
+
} else if (globalConfig.db) {
|
|
36
|
+
globalDb = globalConfig.db;
|
|
37
|
+
if (globalDb instanceof Promise) globalDb = await globalDb;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Redis
|
|
41
|
+
if (globalConfig.redisUrl) {
|
|
42
|
+
globalRedis = createClient({ url: globalConfig.redisUrl });
|
|
43
|
+
globalRedis.on('error', (err) => console.error('[bro.js/next] Redis Error:', err));
|
|
44
|
+
if (globalRedis.status === 'wait' || !globalRedis.status) {
|
|
45
|
+
await globalRedis.connect().catch(err => {
|
|
46
|
+
if (!err.message.includes('already connecting') && !err.message.includes('already connected')) {
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
isInitialized = true;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('[bro.js/next] Initialization Error:', err);
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
return initPromise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const errorHelper = (status, message) => {
|
|
64
|
+
const err = new Error(message);
|
|
65
|
+
err.status = status;
|
|
66
|
+
throw err;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function defineRoute(config) {
|
|
70
|
+
return async function (req, context) {
|
|
71
|
+
try {
|
|
72
|
+
await ensureInitialized();
|
|
73
|
+
|
|
74
|
+
const rawParams = context?.params ? await context.params : {};
|
|
75
|
+
|
|
76
|
+
const url = new URL(req.url);
|
|
77
|
+
const rawQuery = Object.fromEntries(url.searchParams.entries());
|
|
78
|
+
|
|
79
|
+
let rawBody = {};
|
|
80
|
+
const parsedFiles = {};
|
|
81
|
+
let totalFiles = 0;
|
|
82
|
+
|
|
83
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
|
84
|
+
const contentType = req.headers.get('content-type') || '';
|
|
85
|
+
|
|
86
|
+
if (contentType.includes('multipart/form-data')) {
|
|
87
|
+
try {
|
|
88
|
+
const formData = await req.formData();
|
|
89
|
+
for (const [key, value] of formData.entries()) {
|
|
90
|
+
if (value instanceof File || value instanceof Blob) {
|
|
91
|
+
if (!parsedFiles[key]) parsedFiles[key] = [];
|
|
92
|
+
parsedFiles[key].push(value);
|
|
93
|
+
totalFiles++;
|
|
94
|
+
} else {
|
|
95
|
+
rawBody[key] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {}
|
|
99
|
+
} else {
|
|
100
|
+
try {
|
|
101
|
+
rawBody = await req.json();
|
|
102
|
+
} catch (err) {}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let body, params, query;
|
|
107
|
+
try {
|
|
108
|
+
if (config.body) body = await config.body.parseAsync(rawBody);
|
|
109
|
+
if (config.params) params = await config.params.parseAsync(rawParams);
|
|
110
|
+
if (config.query) query = await config.query.parseAsync(rawQuery);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err instanceof z.ZodError) {
|
|
113
|
+
return NextResponse.json(
|
|
114
|
+
{ error: 'Validation Error', issues: err.issues },
|
|
115
|
+
{ status: 400 }
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let user = null;
|
|
122
|
+
if (config.auth) {
|
|
123
|
+
if (config.auth === 'api-key') {
|
|
124
|
+
const apiKey = req.headers.get('x-api-key') || req.headers.get('authorization')?.replace('Bearer ', '');
|
|
125
|
+
const configuredKey = globalConfig?.auth?.apiKey || process.env.API_KEY;
|
|
126
|
+
|
|
127
|
+
let isValid = false;
|
|
128
|
+
if (configuredKey) {
|
|
129
|
+
const keys = (Array.isArray(configuredKey) ? configuredKey : configuredKey.split(',')).map(k => String(k).trim());
|
|
130
|
+
isValid = keys.includes(apiKey);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!isValid) {
|
|
134
|
+
return NextResponse.json({ error: 'Unauthorized', details: 'Missing or invalid API key' }, { status: 401 });
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
const authHeader = req.headers.get('authorization');
|
|
138
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
139
|
+
return NextResponse.json({ error: 'Unauthorized', details: 'Missing Bearer token' }, { status: 401 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const token = authHeader.split(' ')[1];
|
|
143
|
+
const secret = globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET;
|
|
144
|
+
|
|
145
|
+
if (!secret) {
|
|
146
|
+
return NextResponse.json({ error: 'Internal Server Error', details: 'JWT_SECRET is not configured' }, { status: 500 });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const decoded = verifyJwt(token, secret);
|
|
150
|
+
if (!decoded.valid) {
|
|
151
|
+
return NextResponse.json({ error: 'Unauthorized', details: decoded.error }, { status: 401 });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
user = decoded.payload;
|
|
155
|
+
|
|
156
|
+
if (Array.isArray(config.auth) && config.auth.length > 0) {
|
|
157
|
+
if (!user.role || !config.auth.includes(user.role)) {
|
|
158
|
+
return NextResponse.json({ error: 'Forbidden', details: 'Insufficient permissions' }, { status: 403 });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const resolvedLocale = globalLocale.resolveLocale({ headers: Object.fromEntries(req.headers.entries()) });
|
|
165
|
+
|
|
166
|
+
let ctxFile = undefined;
|
|
167
|
+
let ctxFiles = undefined;
|
|
168
|
+
if (totalFiles === 1) {
|
|
169
|
+
const keys = Object.keys(parsedFiles);
|
|
170
|
+
ctxFile = parsedFiles[keys[0]][0];
|
|
171
|
+
ctxFiles = parsedFiles;
|
|
172
|
+
} else if (totalFiles > 1) {
|
|
173
|
+
ctxFiles = parsedFiles;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ctx = {
|
|
177
|
+
req,
|
|
178
|
+
env: process.env,
|
|
179
|
+
db: globalDb,
|
|
180
|
+
redis: globalRedis,
|
|
181
|
+
io: { emit: () => console.warn('[bro.js/next] WebSockets require standard bro.js server.') },
|
|
182
|
+
body,
|
|
183
|
+
params,
|
|
184
|
+
query,
|
|
185
|
+
file: ctxFile,
|
|
186
|
+
files: ctxFiles,
|
|
187
|
+
locale: resolvedLocale,
|
|
188
|
+
t: (key, values) => globalLocale.translate(resolvedLocale, key, values),
|
|
189
|
+
user,
|
|
190
|
+
jwt: { sign: (payload, opts) => signJwt(payload, globalConfig?.auth?.jwtSecret || process.env.JWT_SECRET, opts) },
|
|
191
|
+
error: errorHelper
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const result = await config.handler(ctx);
|
|
195
|
+
return NextResponse.json(result, { status: 200 });
|
|
196
|
+
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error.status) {
|
|
199
|
+
return NextResponse.json({ error: error.message }, { status: error.status });
|
|
200
|
+
}
|
|
201
|
+
console.error('[bro.js/next] Unhandled Error:', error);
|
|
202
|
+
return NextResponse.json(
|
|
203
|
+
{ error: 'Internal Server Error', message: error.message },
|
|
204
|
+
{ status: 500 }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { defineRoute, z };
|
|
211
|
+
}
|