bxo 0.0.5-dev.37 → 0.0.5-dev.39
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 +16 -1
- package/index.ts +51 -5
- package/package.json +1 -1
- package/plugins/cors.ts +53 -41
- package/plugins/ratelimit.ts +54 -58
package/README.md
CHANGED
|
@@ -203,7 +203,7 @@ app.post('/api/users/:id', async (ctx) => {
|
|
|
203
203
|
|
|
204
204
|
## 🔌 Plugin System
|
|
205
205
|
|
|
206
|
-
BXO has a powerful plugin system with lifecycle hooks. Plugins are separate modules imported from `./plugins
|
|
206
|
+
BXO has a powerful plugin system with lifecycle hooks. Plugins can be either middleware-style plugins or full BXO instances. Middleware plugins are separate modules imported from `./plugins` and are executed in the order they are added.
|
|
207
207
|
|
|
208
208
|
### Available Plugins
|
|
209
209
|
|
|
@@ -275,6 +275,8 @@ app.use(rateLimit({
|
|
|
275
275
|
|
|
276
276
|
### Creating Custom Plugins
|
|
277
277
|
|
|
278
|
+
#### Middleware-Style Plugins (Recommended)
|
|
279
|
+
|
|
278
280
|
```typescript
|
|
279
281
|
const customPlugin = {
|
|
280
282
|
name: 'custom',
|
|
@@ -295,6 +297,19 @@ const customPlugin = {
|
|
|
295
297
|
app.use(customPlugin);
|
|
296
298
|
```
|
|
297
299
|
|
|
300
|
+
#### Full BXO Instance Plugins
|
|
301
|
+
|
|
302
|
+
You can also use full BXO instances as plugins, which will merge their routes and hooks:
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
const pluginApp = new BXO();
|
|
306
|
+
pluginApp.get('/plugin-route', async (ctx) => {
|
|
307
|
+
return { message: 'From plugin' };
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
app.use(pluginApp);
|
|
311
|
+
```
|
|
312
|
+
|
|
298
313
|
## 🎣 Lifecycle Hooks
|
|
299
314
|
|
|
300
315
|
BXO provides comprehensive lifecycle hooks with a consistent before/after pattern for both server and request lifecycle:
|
package/index.ts
CHANGED
|
@@ -133,11 +133,30 @@ interface BXOOptions {
|
|
|
133
133
|
enableValidation?: boolean;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Plugin interface for middleware-style plugins
|
|
137
|
+
interface Plugin {
|
|
138
|
+
name?: string;
|
|
139
|
+
onRequest?: (ctx: Context) => Promise<void> | void;
|
|
140
|
+
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
|
141
|
+
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
|
142
|
+
}
|
|
143
|
+
|
|
136
144
|
export default class BXO {
|
|
137
145
|
private _routes: Route[] = [];
|
|
138
146
|
private _wsRoutes: WSRoute[] = [];
|
|
139
147
|
private plugins: BXO[] = [];
|
|
140
|
-
private
|
|
148
|
+
private middleware: Plugin[] = []; // New middleware array
|
|
149
|
+
private hooks: {
|
|
150
|
+
onBeforeStart?: (instance: BXO) => Promise<void> | void;
|
|
151
|
+
onAfterStart?: (instance: BXO) => Promise<void> | void;
|
|
152
|
+
onBeforeRestart?: (instance: BXO) => Promise<void> | void;
|
|
153
|
+
onAfterRestart?: (instance: BXO) => Promise<void> | void;
|
|
154
|
+
onBeforeStop?: (instance: BXO) => Promise<void> | void;
|
|
155
|
+
onAfterStop?: (instance: BXO) => Promise<void> | void;
|
|
156
|
+
onRequest?: (ctx: Context, instance: BXO) => Promise<void> | void;
|
|
157
|
+
onResponse?: (ctx: Context, response: any, instance: BXO) => Promise<any> | any;
|
|
158
|
+
onError?: (ctx: Context, error: Error, instance: BXO) => Promise<any> | any;
|
|
159
|
+
} = {};
|
|
141
160
|
private server?: any;
|
|
142
161
|
private isRunning: boolean = false;
|
|
143
162
|
private serverPort?: number;
|
|
@@ -186,9 +205,15 @@ export default class BXO {
|
|
|
186
205
|
return this;
|
|
187
206
|
}
|
|
188
207
|
|
|
189
|
-
// Plugin system - now accepts
|
|
190
|
-
use(
|
|
191
|
-
|
|
208
|
+
// Plugin system - now accepts both BXO instances and middleware plugins
|
|
209
|
+
use(plugin: BXO | Plugin): this {
|
|
210
|
+
if ('_routes' in plugin) {
|
|
211
|
+
// It's a BXO instance
|
|
212
|
+
this.plugins.push(plugin);
|
|
213
|
+
} else {
|
|
214
|
+
// It's a middleware plugin
|
|
215
|
+
this.middleware.push(plugin);
|
|
216
|
+
}
|
|
192
217
|
return this;
|
|
193
218
|
}
|
|
194
219
|
|
|
@@ -654,7 +679,7 @@ export default class BXO {
|
|
|
654
679
|
const fallbackStatuses = [200, 201, 400, 500];
|
|
655
680
|
for (const fallbackStatus of fallbackStatuses) {
|
|
656
681
|
if (responseConfig[fallbackStatus]) {
|
|
657
|
-
return responseConfig[fallbackStatus]
|
|
682
|
+
return responseConfig[fallbackStatus]?.parse(data);
|
|
658
683
|
}
|
|
659
684
|
}
|
|
660
685
|
|
|
@@ -835,6 +860,13 @@ export default class BXO {
|
|
|
835
860
|
}
|
|
836
861
|
|
|
837
862
|
try {
|
|
863
|
+
// Run middleware onRequest hooks
|
|
864
|
+
for (const plugin of this.middleware) {
|
|
865
|
+
if (plugin.onRequest) {
|
|
866
|
+
await plugin.onRequest(ctx);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
838
870
|
// Run global onRequest hook
|
|
839
871
|
if (this.hooks.onRequest) {
|
|
840
872
|
await this.hooks.onRequest(ctx, this);
|
|
@@ -850,6 +882,13 @@ export default class BXO {
|
|
|
850
882
|
// Execute route handler
|
|
851
883
|
let response = await route.handler(ctx);
|
|
852
884
|
|
|
885
|
+
// Run middleware onResponse hooks
|
|
886
|
+
for (const plugin of this.middleware) {
|
|
887
|
+
if (plugin.onResponse) {
|
|
888
|
+
response = await plugin.onResponse(ctx, response) || response;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
853
892
|
// Run global onResponse hook
|
|
854
893
|
if (this.hooks.onResponse) {
|
|
855
894
|
response = await this.hooks.onResponse(ctx, response, this) || response;
|
|
@@ -1016,6 +1055,13 @@ export default class BXO {
|
|
|
1016
1055
|
// Run error hooks
|
|
1017
1056
|
let errorResponse: any;
|
|
1018
1057
|
|
|
1058
|
+
// Run middleware onError hooks
|
|
1059
|
+
for (const plugin of this.middleware) {
|
|
1060
|
+
if (plugin.onError) {
|
|
1061
|
+
errorResponse = await plugin.onError(ctx, error as Error) || errorResponse;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1019
1065
|
if (this.hooks.onError) {
|
|
1020
1066
|
errorResponse = await this.hooks.onError(ctx, error as Error, this);
|
|
1021
1067
|
}
|
package/package.json
CHANGED
package/plugins/cors.ts
CHANGED
|
@@ -8,7 +8,7 @@ interface CORSOptions {
|
|
|
8
8
|
maxAge?: number;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function cors(options: CORSOptions = {}):
|
|
11
|
+
export function cors(options: CORSOptions = {}): any {
|
|
12
12
|
const {
|
|
13
13
|
origin = '*',
|
|
14
14
|
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
@@ -17,14 +17,46 @@ export function cors(options: CORSOptions = {}): BXO {
|
|
|
17
17
|
maxAge = 86400
|
|
18
18
|
} = options;
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
return {
|
|
21
|
+
name: 'cors',
|
|
22
|
+
onRequest: async (ctx: any) => {
|
|
23
|
+
// Handle preflight OPTIONS request
|
|
24
|
+
if (ctx.request.method === 'OPTIONS') {
|
|
25
|
+
const headers: Record<string, string> = {};
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
// Handle origin
|
|
28
|
+
if (typeof origin === 'boolean') {
|
|
29
|
+
if (origin) {
|
|
30
|
+
headers['Access-Control-Allow-Origin'] = ctx.request.headers.get('origin') || '*';
|
|
31
|
+
}
|
|
32
|
+
} else if (typeof origin === 'string') {
|
|
33
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
|
34
|
+
} else if (Array.isArray(origin)) {
|
|
35
|
+
const requestOrigin = ctx.request.headers.get('origin');
|
|
36
|
+
if (requestOrigin && origin.includes(requestOrigin)) {
|
|
37
|
+
headers['Access-Control-Allow-Origin'] = requestOrigin;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
headers['Access-Control-Allow-Methods'] = methods.join(', ');
|
|
42
|
+
headers['Access-Control-Allow-Headers'] = allowedHeaders.join(', ');
|
|
43
|
+
|
|
44
|
+
if (credentials) {
|
|
45
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
headers['Access-Control-Max-Age'] = maxAge.toString();
|
|
49
|
+
|
|
50
|
+
ctx.set.status = 204;
|
|
51
|
+
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
|
52
|
+
|
|
53
|
+
throw new Response(null, { status: 204, headers });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
onResponse: async (ctx: any, response: any) => {
|
|
25
57
|
const headers: Record<string, string> = {};
|
|
26
58
|
|
|
27
|
-
// Handle origin
|
|
59
|
+
// Handle origin for actual requests
|
|
28
60
|
if (typeof origin === 'boolean') {
|
|
29
61
|
if (origin) {
|
|
30
62
|
headers['Access-Control-Allow-Origin'] = ctx.request.headers.get('origin') || '*';
|
|
@@ -38,46 +70,26 @@ export function cors(options: CORSOptions = {}): BXO {
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
|
|
41
|
-
headers['Access-Control-Allow-Methods'] = methods.join(', ');
|
|
42
|
-
headers['Access-Control-Allow-Headers'] = allowedHeaders.join(', ');
|
|
43
|
-
|
|
44
73
|
if (credentials) {
|
|
45
74
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
|
46
75
|
}
|
|
47
|
-
|
|
48
|
-
headers['Access-Control-Max-Age'] = maxAge.toString();
|
|
49
|
-
|
|
50
|
-
ctx.set.status = 204;
|
|
51
|
-
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
|
52
|
-
|
|
53
|
-
throw new Response(null, { status: 204, headers });
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
corsInstance.onResponse(async (ctx: any, response: any) => {
|
|
58
|
-
const headers: Record<string, string> = {};
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
// If response is a Response object, add headers to it
|
|
78
|
+
if (response instanceof Response) {
|
|
79
|
+
const newHeaders = new Headers(response.headers);
|
|
80
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
81
|
+
newHeaders.set(key, value);
|
|
82
|
+
});
|
|
83
|
+
return new Response(response.body, {
|
|
84
|
+
status: response.status,
|
|
85
|
+
statusText: response.statusText,
|
|
86
|
+
headers: newHeaders
|
|
87
|
+
});
|
|
64
88
|
}
|
|
65
|
-
} else if (typeof origin === 'string') {
|
|
66
|
-
headers['Access-Control-Allow-Origin'] = origin;
|
|
67
|
-
} else if (Array.isArray(origin)) {
|
|
68
|
-
const requestOrigin = ctx.request.headers.get('origin');
|
|
69
|
-
if (requestOrigin && origin.includes(requestOrigin)) {
|
|
70
|
-
headers['Access-Control-Allow-Origin'] = requestOrigin;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
89
|
|
|
74
|
-
|
|
75
|
-
headers
|
|
90
|
+
// Otherwise, set headers in context for the framework to handle
|
|
91
|
+
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
|
92
|
+
return response;
|
|
76
93
|
}
|
|
77
|
-
|
|
78
|
-
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
|
79
|
-
return response;
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
return corsInstance;
|
|
94
|
+
};
|
|
83
95
|
}
|
package/plugins/ratelimit.ts
CHANGED
|
@@ -52,7 +52,7 @@ class RateLimitStore {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function rateLimit(options: RateLimitOptions):
|
|
55
|
+
export function rateLimit(options: RateLimitOptions): any {
|
|
56
56
|
const {
|
|
57
57
|
max,
|
|
58
58
|
window,
|
|
@@ -74,67 +74,63 @@ export function rateLimit(options: RateLimitOptions): BXO {
|
|
|
74
74
|
// Cleanup expired entries every 5 minutes
|
|
75
75
|
setInterval(() => store.cleanup(), 5 * 60 * 1000);
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
return {
|
|
78
|
+
name: 'rateLimit',
|
|
79
|
+
onRequest: async (ctx: any) => {
|
|
80
|
+
const url = new URL(ctx.request.url);
|
|
81
|
+
const pathname = url.pathname;
|
|
82
|
+
|
|
83
|
+
// Skip rate limiting for excluded paths
|
|
84
|
+
if (exclude.some(path => {
|
|
85
|
+
if (path.includes('*')) {
|
|
86
|
+
const regex = new RegExp(path.replace(/\*/g, '.*'));
|
|
87
|
+
return regex.test(pathname);
|
|
88
|
+
}
|
|
89
|
+
return pathname === path || pathname.startsWith(path);
|
|
90
|
+
})) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
const key = keyGenerator(ctx);
|
|
95
|
+
const entry = store.increment(key, window);
|
|
96
|
+
|
|
97
|
+
if (entry.count > max) {
|
|
98
|
+
const resetTime = Math.ceil(entry.resetTime / 1000);
|
|
99
|
+
throw new Response(JSON.stringify({
|
|
100
|
+
error: message,
|
|
101
|
+
retryAfter: resetTime
|
|
102
|
+
}), {
|
|
103
|
+
status: statusCode,
|
|
104
|
+
headers: {
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
'X-RateLimit-Limit': max.toString(),
|
|
107
|
+
'X-RateLimit-Remaining': '0',
|
|
108
|
+
'X-RateLimit-Reset': resetTime.toString(),
|
|
109
|
+
'Retry-After': resetTime.toString()
|
|
110
|
+
}
|
|
111
|
+
});
|
|
88
112
|
}
|
|
89
|
-
return pathname === path || pathname.startsWith(path);
|
|
90
|
-
})) {
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
113
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
'X-RateLimit-Remaining': '0',
|
|
108
|
-
'X-RateLimit-Reset': resetTime.toString(),
|
|
109
|
-
'Retry-After': (resetTime - Math.floor(Date.now() / 1000)).toString()
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Add rate limit headers
|
|
115
|
-
ctx.set.headers = {
|
|
116
|
-
...ctx.set.headers,
|
|
117
|
-
'X-RateLimit-Limit': max.toString(),
|
|
118
|
-
'X-RateLimit-Remaining': Math.max(0, max - entry.count).toString(),
|
|
119
|
-
'X-RateLimit-Reset': Math.ceil(entry.resetTime / 1000).toString()
|
|
120
|
-
};
|
|
121
|
-
});
|
|
114
|
+
// Add rate limit headers to context
|
|
115
|
+
ctx.set.headers = {
|
|
116
|
+
...ctx.set.headers,
|
|
117
|
+
'X-RateLimit-Limit': max.toString(),
|
|
118
|
+
'X-RateLimit-Remaining': Math.max(0, max - entry.count).toString(),
|
|
119
|
+
'X-RateLimit-Reset': Math.ceil(entry.resetTime / 1000).toString()
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
onResponse: async (ctx: any, response: any) => {
|
|
123
|
+
// Skip successful requests if configured
|
|
124
|
+
if (skipSuccessful && response instanceof Response && response.status < 400) {
|
|
125
|
+
return response;
|
|
126
|
+
}
|
|
122
127
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// Optionally skip counting successful or failed requests
|
|
128
|
-
if ((skipSuccessful && status < 400) || (skipFailed && status >= 400)) {
|
|
129
|
-
// Decrement the counter since we don't want to count this request
|
|
130
|
-
const entry = store.get(key);
|
|
131
|
-
if (entry && entry.count > 0) {
|
|
132
|
-
store.set(key, entry.count - 1, entry.resetTime);
|
|
128
|
+
// Skip failed requests if configured
|
|
129
|
+
if (skipFailed && response instanceof Response && response.status >= 400) {
|
|
130
|
+
return response;
|
|
133
131
|
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return response;
|
|
137
|
-
});
|
|
138
132
|
|
|
139
|
-
|
|
133
|
+
return response;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
140
136
|
}
|