bxo 0.0.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/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/README.md +15 -0
- package/example.ts +137 -0
- package/index.ts +409 -0
- package/package.json +20 -0
- package/plugins/auth.ts +116 -0
- package/plugins/cors.ts +79 -0
- package/plugins/index.ts +13 -0
- package/plugins/logger.ts +104 -0
- package/plugins/ratelimit.ts +136 -0
- package/tsconfig.json +29 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
---
|
2
|
+
description: Use Bun instead of Node.js, npm, pnpm, or vite.
|
3
|
+
globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
|
4
|
+
alwaysApply: false
|
5
|
+
---
|
6
|
+
|
7
|
+
Default to using Bun instead of Node.js.
|
8
|
+
|
9
|
+
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
10
|
+
- Use `bun test` instead of `jest` or `vitest`
|
11
|
+
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
12
|
+
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
13
|
+
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
14
|
+
- Bun automatically loads .env, so don't use dotenv.
|
15
|
+
|
16
|
+
## APIs
|
17
|
+
|
18
|
+
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
19
|
+
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
20
|
+
- `Bun.redis` for Redis. Don't use `ioredis`.
|
21
|
+
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
22
|
+
- `WebSocket` is built-in. Don't use `ws`.
|
23
|
+
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
24
|
+
- Bun.$`ls` instead of execa.
|
25
|
+
|
26
|
+
## Testing
|
27
|
+
|
28
|
+
Use `bun test` to run tests.
|
29
|
+
|
30
|
+
```ts#index.test.ts
|
31
|
+
import { test, expect } from "bun:test";
|
32
|
+
|
33
|
+
test("hello world", () => {
|
34
|
+
expect(1).toBe(1);
|
35
|
+
});
|
36
|
+
```
|
37
|
+
|
38
|
+
## Frontend
|
39
|
+
|
40
|
+
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
41
|
+
|
42
|
+
Server:
|
43
|
+
|
44
|
+
```ts#index.ts
|
45
|
+
import index from "./index.html"
|
46
|
+
|
47
|
+
Bun.serve({
|
48
|
+
routes: {
|
49
|
+
"/": index,
|
50
|
+
"/api/users/:id": {
|
51
|
+
GET: (req) => {
|
52
|
+
return new Response(JSON.stringify({ id: req.params.id }));
|
53
|
+
},
|
54
|
+
},
|
55
|
+
},
|
56
|
+
// optional websocket support
|
57
|
+
websocket: {
|
58
|
+
open: (ws) => {
|
59
|
+
ws.send("Hello, world!");
|
60
|
+
},
|
61
|
+
message: (ws, message) => {
|
62
|
+
ws.send(message);
|
63
|
+
},
|
64
|
+
close: (ws) => {
|
65
|
+
// handle close
|
66
|
+
}
|
67
|
+
},
|
68
|
+
development: {
|
69
|
+
hmr: true,
|
70
|
+
console: true,
|
71
|
+
}
|
72
|
+
})
|
73
|
+
```
|
74
|
+
|
75
|
+
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
76
|
+
|
77
|
+
```html#index.html
|
78
|
+
<html>
|
79
|
+
<body>
|
80
|
+
<h1>Hello, world!</h1>
|
81
|
+
<script type="module" src="./frontend.tsx"></script>
|
82
|
+
</body>
|
83
|
+
</html>
|
84
|
+
```
|
85
|
+
|
86
|
+
With the following `frontend.tsx`:
|
87
|
+
|
88
|
+
```tsx#frontend.tsx
|
89
|
+
import React from "react";
|
90
|
+
|
91
|
+
// import .css files directly and it works
|
92
|
+
import './index.css';
|
93
|
+
|
94
|
+
import { createRoot } from "react-dom/client";
|
95
|
+
|
96
|
+
const root = createRoot(document.body);
|
97
|
+
|
98
|
+
export default function Frontend() {
|
99
|
+
return <h1>Hello, world!</h1>;
|
100
|
+
}
|
101
|
+
|
102
|
+
root.render(<Frontend />);
|
103
|
+
```
|
104
|
+
|
105
|
+
Then, run index.ts
|
106
|
+
|
107
|
+
```sh
|
108
|
+
bun --hot ./index.ts
|
109
|
+
```
|
110
|
+
|
111
|
+
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
|
package/README.md
ADDED
package/example.ts
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
import BXO, { z } from './index';
|
2
|
+
import { cors, logger, auth, rateLimit, createJWT } from './plugins';
|
3
|
+
|
4
|
+
// Create the app instance
|
5
|
+
const app = new BXO();
|
6
|
+
|
7
|
+
// Add plugins
|
8
|
+
app
|
9
|
+
.use(logger({ format: 'simple' }))
|
10
|
+
.use(cors({
|
11
|
+
origin: ['http://localhost:3000', 'https://example.com'],
|
12
|
+
credentials: true
|
13
|
+
}))
|
14
|
+
.use(rateLimit({
|
15
|
+
max: 100,
|
16
|
+
window: 60, // 1 minute
|
17
|
+
exclude: ['/health']
|
18
|
+
}))
|
19
|
+
.use(auth({
|
20
|
+
type: 'jwt',
|
21
|
+
secret: 'your-secret-key',
|
22
|
+
exclude: ['/', '/login', '/health']
|
23
|
+
}));
|
24
|
+
|
25
|
+
// Add lifecycle hooks
|
26
|
+
app
|
27
|
+
.onStart(() => {
|
28
|
+
console.log('🚀 Server starting up...');
|
29
|
+
})
|
30
|
+
.onStop(() => {
|
31
|
+
console.log('🛑 Server shutting down...');
|
32
|
+
})
|
33
|
+
.onRequest((ctx) => {
|
34
|
+
console.log(`📨 Processing ${ctx.request.method} ${ctx.request.url}`);
|
35
|
+
})
|
36
|
+
.onResponse((ctx, response) => {
|
37
|
+
console.log(`📤 Response sent for ${ctx.request.method} ${ctx.request.url}`);
|
38
|
+
return response;
|
39
|
+
})
|
40
|
+
.onError((ctx, error) => {
|
41
|
+
console.error(`💥 Error in ${ctx.request.method} ${ctx.request.url}:`, error.message);
|
42
|
+
return { error: 'Something went wrong', timestamp: new Date().toISOString() };
|
43
|
+
});
|
44
|
+
|
45
|
+
// Routes exactly like your example
|
46
|
+
app
|
47
|
+
// Two arguments: path, handler
|
48
|
+
.get('/simple', async (ctx) => {
|
49
|
+
return { message: 'Hello World' };
|
50
|
+
})
|
51
|
+
|
52
|
+
// Three arguments: path, handler, config
|
53
|
+
.get('/users/:id', async (ctx) => {
|
54
|
+
// ctx.params.id is fully typed as string (UUID)
|
55
|
+
// ctx.query.include is typed as string | undefined
|
56
|
+
return { user: { id: ctx.params.id, include: ctx.query.include } };
|
57
|
+
}, {
|
58
|
+
params: z.object({ id: z.string().uuid() }),
|
59
|
+
query: z.object({ include: z.string().optional() })
|
60
|
+
})
|
61
|
+
|
62
|
+
.post('/users', async (ctx) => {
|
63
|
+
// ctx.body is fully typed with name: string, email: string
|
64
|
+
return { created: ctx.body };
|
65
|
+
}, {
|
66
|
+
body: z.object({
|
67
|
+
name: z.string(),
|
68
|
+
email: z.string().email()
|
69
|
+
})
|
70
|
+
})
|
71
|
+
|
72
|
+
// Additional examples
|
73
|
+
.get('/health', async (ctx) => {
|
74
|
+
return { status: 'ok', timestamp: new Date().toISOString() };
|
75
|
+
})
|
76
|
+
|
77
|
+
.post('/login', async (ctx) => {
|
78
|
+
const { username, password } = ctx.body;
|
79
|
+
|
80
|
+
// Simple auth check (in production, verify against database)
|
81
|
+
if (username === 'admin' && password === 'password') {
|
82
|
+
const token = createJWT({ username, role: 'admin' }, 'your-secret-key', 3600);
|
83
|
+
return { token, user: { username, role: 'admin' } };
|
84
|
+
}
|
85
|
+
|
86
|
+
ctx.set.status = 401;
|
87
|
+
return { error: 'Invalid credentials' };
|
88
|
+
}, {
|
89
|
+
body: z.object({
|
90
|
+
username: z.string(),
|
91
|
+
password: z.string()
|
92
|
+
})
|
93
|
+
})
|
94
|
+
|
95
|
+
.get('/protected', async (ctx) => {
|
96
|
+
// ctx.user is available here because of auth plugin
|
97
|
+
return { message: 'This is protected', user: ctx.user };
|
98
|
+
})
|
99
|
+
|
100
|
+
.put('/users/:id', async (ctx) => {
|
101
|
+
return {
|
102
|
+
updated: ctx.body,
|
103
|
+
id: ctx.params.id,
|
104
|
+
version: ctx.headers['if-match']
|
105
|
+
};
|
106
|
+
}, {
|
107
|
+
params: z.object({ id: z.string().uuid() }),
|
108
|
+
body: z.object({
|
109
|
+
name: z.string().optional(),
|
110
|
+
email: z.string().email().optional()
|
111
|
+
}),
|
112
|
+
headers: z.object({
|
113
|
+
'if-match': z.string()
|
114
|
+
})
|
115
|
+
})
|
116
|
+
|
117
|
+
.delete('/users/:id', async (ctx) => {
|
118
|
+
ctx.set.status = 204;
|
119
|
+
return null;
|
120
|
+
}, {
|
121
|
+
params: z.object({ id: z.string().uuid() })
|
122
|
+
});
|
123
|
+
|
124
|
+
// Start the server
|
125
|
+
app.listen(3000, 'localhost');
|
126
|
+
|
127
|
+
console.log(`
|
128
|
+
🦊 BXO Framework Example
|
129
|
+
|
130
|
+
Try these endpoints:
|
131
|
+
- GET /simple
|
132
|
+
- GET /users/123e4567-e89b-12d3-a456-426614174000?include=profile
|
133
|
+
- POST /users (with JSON body: {"name": "John", "email": "john@example.com"})
|
134
|
+
- GET /health
|
135
|
+
- POST /login (with JSON body: {"username": "admin", "password": "password"})
|
136
|
+
- GET /protected (requires Bearer token from /login)
|
137
|
+
`);
|
package/index.ts
ADDED
@@ -0,0 +1,409 @@
|
|
1
|
+
import { z } from 'zod';
|
2
|
+
|
3
|
+
// Type utilities for extracting types from Zod schemas
|
4
|
+
type InferZodType<T> = T extends z.ZodType<infer U> ? U : never;
|
5
|
+
|
6
|
+
// Configuration interface for route handlers
|
7
|
+
interface RouteConfig {
|
8
|
+
params?: z.ZodSchema<any>;
|
9
|
+
query?: z.ZodSchema<any>;
|
10
|
+
body?: z.ZodSchema<any>;
|
11
|
+
headers?: z.ZodSchema<any>;
|
12
|
+
}
|
13
|
+
|
14
|
+
// Context type that's fully typed based on the route configuration
|
15
|
+
export type Context<TConfig extends RouteConfig = {}> = {
|
16
|
+
params: TConfig['params'] extends z.ZodSchema<any> ? InferZodType<TConfig['params']> : Record<string, string>;
|
17
|
+
query: TConfig['query'] extends z.ZodSchema<any> ? InferZodType<TConfig['query']> : Record<string, string | undefined>;
|
18
|
+
body: TConfig['body'] extends z.ZodSchema<any> ? InferZodType<TConfig['body']> : unknown;
|
19
|
+
headers: TConfig['headers'] extends z.ZodSchema<any> ? InferZodType<TConfig['headers']> : Record<string, string>;
|
20
|
+
request: Request;
|
21
|
+
set: {
|
22
|
+
status?: number;
|
23
|
+
headers?: Record<string, string>;
|
24
|
+
};
|
25
|
+
// Extended properties that can be added by plugins
|
26
|
+
user?: any;
|
27
|
+
[key: string]: any;
|
28
|
+
};
|
29
|
+
|
30
|
+
// Handler function type
|
31
|
+
type Handler<TConfig extends RouteConfig = {}> = (ctx: Context<TConfig>) => Promise<any> | any;
|
32
|
+
|
33
|
+
// Plugin interface (also exported from plugins/index.ts)
|
34
|
+
interface Plugin {
|
35
|
+
name?: string;
|
36
|
+
onRequest?: (ctx: Context) => Promise<void> | void;
|
37
|
+
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
38
|
+
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
39
|
+
}
|
40
|
+
|
41
|
+
|
42
|
+
|
43
|
+
// Route definition
|
44
|
+
interface Route {
|
45
|
+
method: string;
|
46
|
+
path: string;
|
47
|
+
handler: Handler<any>;
|
48
|
+
config?: RouteConfig;
|
49
|
+
}
|
50
|
+
|
51
|
+
// Lifecycle hooks
|
52
|
+
interface LifecycleHooks {
|
53
|
+
onStart?: () => Promise<void> | void;
|
54
|
+
onStop?: () => Promise<void> | void;
|
55
|
+
onRequest?: (ctx: Context) => Promise<void> | void;
|
56
|
+
onResponse?: (ctx: Context, response: any) => Promise<any> | any;
|
57
|
+
onError?: (ctx: Context, error: Error) => Promise<any> | any;
|
58
|
+
}
|
59
|
+
|
60
|
+
export default class BXO {
|
61
|
+
private routes: Route[] = [];
|
62
|
+
private plugins: Plugin[] = [];
|
63
|
+
private hooks: LifecycleHooks = {};
|
64
|
+
|
65
|
+
constructor() {}
|
66
|
+
|
67
|
+
// Lifecycle hook methods
|
68
|
+
onStart(handler: () => Promise<void> | void): this {
|
69
|
+
this.hooks.onStart = handler;
|
70
|
+
return this;
|
71
|
+
}
|
72
|
+
|
73
|
+
onStop(handler: () => Promise<void> | void): this {
|
74
|
+
this.hooks.onStop = handler;
|
75
|
+
return this;
|
76
|
+
}
|
77
|
+
|
78
|
+
onRequest(handler: (ctx: Context) => Promise<void> | void): this {
|
79
|
+
this.hooks.onRequest = handler;
|
80
|
+
return this;
|
81
|
+
}
|
82
|
+
|
83
|
+
onResponse(handler: (ctx: Context, response: any) => Promise<any> | any): this {
|
84
|
+
this.hooks.onResponse = handler;
|
85
|
+
return this;
|
86
|
+
}
|
87
|
+
|
88
|
+
onError(handler: (ctx: Context, error: Error) => Promise<any> | any): this {
|
89
|
+
this.hooks.onError = handler;
|
90
|
+
return this;
|
91
|
+
}
|
92
|
+
|
93
|
+
// Plugin system
|
94
|
+
use(plugin: Plugin): this {
|
95
|
+
this.plugins.push(plugin);
|
96
|
+
return this;
|
97
|
+
}
|
98
|
+
|
99
|
+
// HTTP method handlers with overloads for type safety
|
100
|
+
get<TConfig extends RouteConfig = {}>(
|
101
|
+
path: string,
|
102
|
+
handler: Handler<TConfig>
|
103
|
+
): this;
|
104
|
+
get<TConfig extends RouteConfig = {}>(
|
105
|
+
path: string,
|
106
|
+
handler: Handler<TConfig>,
|
107
|
+
config: TConfig
|
108
|
+
): this;
|
109
|
+
get<TConfig extends RouteConfig = {}>(
|
110
|
+
path: string,
|
111
|
+
handler: Handler<TConfig>,
|
112
|
+
config?: TConfig
|
113
|
+
): this {
|
114
|
+
this.routes.push({ method: 'GET', path, handler, config });
|
115
|
+
return this;
|
116
|
+
}
|
117
|
+
|
118
|
+
post<TConfig extends RouteConfig = {}>(
|
119
|
+
path: string,
|
120
|
+
handler: Handler<TConfig>
|
121
|
+
): this;
|
122
|
+
post<TConfig extends RouteConfig = {}>(
|
123
|
+
path: string,
|
124
|
+
handler: Handler<TConfig>,
|
125
|
+
config: TConfig
|
126
|
+
): this;
|
127
|
+
post<TConfig extends RouteConfig = {}>(
|
128
|
+
path: string,
|
129
|
+
handler: Handler<TConfig>,
|
130
|
+
config?: TConfig
|
131
|
+
): this {
|
132
|
+
this.routes.push({ method: 'POST', path, handler, config });
|
133
|
+
return this;
|
134
|
+
}
|
135
|
+
|
136
|
+
put<TConfig extends RouteConfig = {}>(
|
137
|
+
path: string,
|
138
|
+
handler: Handler<TConfig>
|
139
|
+
): this;
|
140
|
+
put<TConfig extends RouteConfig = {}>(
|
141
|
+
path: string,
|
142
|
+
handler: Handler<TConfig>,
|
143
|
+
config: TConfig
|
144
|
+
): this;
|
145
|
+
put<TConfig extends RouteConfig = {}>(
|
146
|
+
path: string,
|
147
|
+
handler: Handler<TConfig>,
|
148
|
+
config?: TConfig
|
149
|
+
): this {
|
150
|
+
this.routes.push({ method: 'PUT', path, handler, config });
|
151
|
+
return this;
|
152
|
+
}
|
153
|
+
|
154
|
+
delete<TConfig extends RouteConfig = {}>(
|
155
|
+
path: string,
|
156
|
+
handler: Handler<TConfig>
|
157
|
+
): this;
|
158
|
+
delete<TConfig extends RouteConfig = {}>(
|
159
|
+
path: string,
|
160
|
+
handler: Handler<TConfig>,
|
161
|
+
config: TConfig
|
162
|
+
): this;
|
163
|
+
delete<TConfig extends RouteConfig = {}>(
|
164
|
+
path: string,
|
165
|
+
handler: Handler<TConfig>,
|
166
|
+
config?: TConfig
|
167
|
+
): this {
|
168
|
+
this.routes.push({ method: 'DELETE', path, handler, config });
|
169
|
+
return this;
|
170
|
+
}
|
171
|
+
|
172
|
+
patch<TConfig extends RouteConfig = {}>(
|
173
|
+
path: string,
|
174
|
+
handler: Handler<TConfig>
|
175
|
+
): this;
|
176
|
+
patch<TConfig extends RouteConfig = {}>(
|
177
|
+
path: string,
|
178
|
+
handler: Handler<TConfig>,
|
179
|
+
config: TConfig
|
180
|
+
): this;
|
181
|
+
patch<TConfig extends RouteConfig = {}>(
|
182
|
+
path: string,
|
183
|
+
handler: Handler<TConfig>,
|
184
|
+
config?: TConfig
|
185
|
+
): this {
|
186
|
+
this.routes.push({ method: 'PATCH', path, handler, config });
|
187
|
+
return this;
|
188
|
+
}
|
189
|
+
|
190
|
+
// Route matching utility
|
191
|
+
private matchRoute(method: string, pathname: string): { route: Route; params: Record<string, string> } | null {
|
192
|
+
for (const route of this.routes) {
|
193
|
+
if (route.method !== method) continue;
|
194
|
+
|
195
|
+
const routeSegments = route.path.split('/').filter(Boolean);
|
196
|
+
const pathSegments = pathname.split('/').filter(Boolean);
|
197
|
+
|
198
|
+
if (routeSegments.length !== pathSegments.length) continue;
|
199
|
+
|
200
|
+
const params: Record<string, string> = {};
|
201
|
+
let isMatch = true;
|
202
|
+
|
203
|
+
for (let i = 0; i < routeSegments.length; i++) {
|
204
|
+
const routeSegment = routeSegments[i];
|
205
|
+
const pathSegment = pathSegments[i];
|
206
|
+
|
207
|
+
if (!routeSegment || !pathSegment) {
|
208
|
+
isMatch = false;
|
209
|
+
break;
|
210
|
+
}
|
211
|
+
|
212
|
+
if (routeSegment.startsWith(':')) {
|
213
|
+
const paramName = routeSegment.slice(1);
|
214
|
+
params[paramName] = decodeURIComponent(pathSegment);
|
215
|
+
} else if (routeSegment !== pathSegment) {
|
216
|
+
isMatch = false;
|
217
|
+
break;
|
218
|
+
}
|
219
|
+
}
|
220
|
+
|
221
|
+
if (isMatch) {
|
222
|
+
return { route, params };
|
223
|
+
}
|
224
|
+
}
|
225
|
+
|
226
|
+
return null;
|
227
|
+
}
|
228
|
+
|
229
|
+
// Parse query string
|
230
|
+
private parseQuery(searchParams: URLSearchParams): Record<string, string | undefined> {
|
231
|
+
const query: Record<string, string | undefined> = {};
|
232
|
+
for (const [key, value] of searchParams.entries()) {
|
233
|
+
query[key] = value;
|
234
|
+
}
|
235
|
+
return query;
|
236
|
+
}
|
237
|
+
|
238
|
+
// Parse headers
|
239
|
+
private parseHeaders(headers: Headers): Record<string, string> {
|
240
|
+
const headerObj: Record<string, string> = {};
|
241
|
+
for (const [key, value] of headers.entries()) {
|
242
|
+
headerObj[key] = value;
|
243
|
+
}
|
244
|
+
return headerObj;
|
245
|
+
}
|
246
|
+
|
247
|
+
// Validate data against Zod schema
|
248
|
+
private validateData<T>(schema: z.ZodSchema<T> | undefined, data: any): T {
|
249
|
+
if (!schema) return data;
|
250
|
+
return schema.parse(data);
|
251
|
+
}
|
252
|
+
|
253
|
+
// Main request handler
|
254
|
+
private async handleRequest(request: Request): Promise<Response> {
|
255
|
+
const url = new URL(request.url);
|
256
|
+
const method = request.method;
|
257
|
+
const pathname = url.pathname;
|
258
|
+
|
259
|
+
const matchResult = this.matchRoute(method, pathname);
|
260
|
+
if (!matchResult) {
|
261
|
+
return new Response('Not Found', { status: 404 });
|
262
|
+
}
|
263
|
+
|
264
|
+
const { route, params } = matchResult;
|
265
|
+
const query = this.parseQuery(url.searchParams);
|
266
|
+
const headers = this.parseHeaders(request.headers);
|
267
|
+
|
268
|
+
let body: any;
|
269
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
270
|
+
const contentType = request.headers.get('content-type');
|
271
|
+
if (contentType?.includes('application/json')) {
|
272
|
+
try {
|
273
|
+
body = await request.json();
|
274
|
+
} catch {
|
275
|
+
body = {};
|
276
|
+
}
|
277
|
+
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
278
|
+
const formData = await request.formData();
|
279
|
+
body = Object.fromEntries(formData.entries());
|
280
|
+
} else {
|
281
|
+
body = await request.text();
|
282
|
+
}
|
283
|
+
}
|
284
|
+
|
285
|
+
// Create context
|
286
|
+
const ctx: Context = {
|
287
|
+
params: route.config?.params ? this.validateData(route.config.params, params) : params,
|
288
|
+
query: route.config?.query ? this.validateData(route.config.query, query) : query,
|
289
|
+
body: route.config?.body ? this.validateData(route.config.body, body) : body,
|
290
|
+
headers: route.config?.headers ? this.validateData(route.config.headers, headers) : headers,
|
291
|
+
request,
|
292
|
+
set: {}
|
293
|
+
};
|
294
|
+
|
295
|
+
try {
|
296
|
+
// Run global onRequest hook
|
297
|
+
if (this.hooks.onRequest) {
|
298
|
+
await this.hooks.onRequest(ctx);
|
299
|
+
}
|
300
|
+
|
301
|
+
// Run plugin onRequest hooks
|
302
|
+
for (const plugin of this.plugins) {
|
303
|
+
if (plugin.onRequest) {
|
304
|
+
await plugin.onRequest(ctx);
|
305
|
+
}
|
306
|
+
}
|
307
|
+
|
308
|
+
// Execute route handler
|
309
|
+
let response = await route.handler(ctx);
|
310
|
+
|
311
|
+
// Run global onResponse hook
|
312
|
+
if (this.hooks.onResponse) {
|
313
|
+
response = await this.hooks.onResponse(ctx, response) || response;
|
314
|
+
}
|
315
|
+
|
316
|
+
// Run plugin onResponse hooks
|
317
|
+
for (const plugin of this.plugins) {
|
318
|
+
if (plugin.onResponse) {
|
319
|
+
response = await plugin.onResponse(ctx, response) || response;
|
320
|
+
}
|
321
|
+
}
|
322
|
+
|
323
|
+
// Convert response to Response object
|
324
|
+
if (response instanceof Response) {
|
325
|
+
return response;
|
326
|
+
}
|
327
|
+
|
328
|
+
const responseInit: ResponseInit = {
|
329
|
+
status: ctx.set.status || 200,
|
330
|
+
headers: ctx.set.headers || {}
|
331
|
+
};
|
332
|
+
|
333
|
+
if (typeof response === 'string') {
|
334
|
+
return new Response(response, responseInit);
|
335
|
+
}
|
336
|
+
|
337
|
+
return new Response(JSON.stringify(response), {
|
338
|
+
...responseInit,
|
339
|
+
headers: {
|
340
|
+
'Content-Type': 'application/json',
|
341
|
+
...responseInit.headers
|
342
|
+
}
|
343
|
+
});
|
344
|
+
|
345
|
+
} catch (error) {
|
346
|
+
// Run error hooks
|
347
|
+
let errorResponse: any;
|
348
|
+
|
349
|
+
if (this.hooks.onError) {
|
350
|
+
errorResponse = await this.hooks.onError(ctx, error as Error);
|
351
|
+
}
|
352
|
+
|
353
|
+
for (const plugin of this.plugins) {
|
354
|
+
if (plugin.onError) {
|
355
|
+
errorResponse = await plugin.onError(ctx, error as Error) || errorResponse;
|
356
|
+
}
|
357
|
+
}
|
358
|
+
|
359
|
+
if (errorResponse) {
|
360
|
+
if (errorResponse instanceof Response) {
|
361
|
+
return errorResponse;
|
362
|
+
}
|
363
|
+
return new Response(JSON.stringify(errorResponse), {
|
364
|
+
status: 500,
|
365
|
+
headers: { 'Content-Type': 'application/json' }
|
366
|
+
});
|
367
|
+
}
|
368
|
+
|
369
|
+
// Default error response
|
370
|
+
const errorMessage = error instanceof Error ? error.message : 'Internal Server Error';
|
371
|
+
return new Response(JSON.stringify({ error: errorMessage }), {
|
372
|
+
status: 500,
|
373
|
+
headers: { 'Content-Type': 'application/json' }
|
374
|
+
});
|
375
|
+
}
|
376
|
+
}
|
377
|
+
|
378
|
+
// Start the server
|
379
|
+
async listen(port: number = 3000, hostname: string = 'localhost'): Promise<void> {
|
380
|
+
if (this.hooks.onStart) {
|
381
|
+
await this.hooks.onStart();
|
382
|
+
}
|
383
|
+
|
384
|
+
const server = Bun.serve({
|
385
|
+
port,
|
386
|
+
hostname,
|
387
|
+
fetch: (request) => this.handleRequest(request),
|
388
|
+
});
|
389
|
+
|
390
|
+
console.log(`🦊 BXO server running at http://${hostname}:${port}`);
|
391
|
+
|
392
|
+
// Handle graceful shutdown
|
393
|
+
process.on('SIGINT', async () => {
|
394
|
+
if (this.hooks.onStop) {
|
395
|
+
await this.hooks.onStop();
|
396
|
+
}
|
397
|
+
server.stop();
|
398
|
+
process.exit(0);
|
399
|
+
});
|
400
|
+
}
|
401
|
+
}
|
402
|
+
|
403
|
+
// Export Zod for convenience
|
404
|
+
export { z };
|
405
|
+
|
406
|
+
export type { Plugin } from './plugins';
|
407
|
+
|
408
|
+
// Export types for external use
|
409
|
+
export type { RouteConfig };
|
package/package.json
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
{
|
2
|
+
"name": "bxo",
|
3
|
+
"module": "index.ts",
|
4
|
+
"version": "0.0.1",
|
5
|
+
"description": "A simple and lightweight web framework for Bun",
|
6
|
+
"type": "module",
|
7
|
+
"devDependencies": {
|
8
|
+
"@types/bun": "latest"
|
9
|
+
},
|
10
|
+
"exports": {
|
11
|
+
".": "./index.ts",
|
12
|
+
"./plugins": "./plugins/index.ts"
|
13
|
+
},
|
14
|
+
"peerDependencies": {
|
15
|
+
"typescript": "^5"
|
16
|
+
},
|
17
|
+
"dependencies": {
|
18
|
+
"zod": "^4.0.5"
|
19
|
+
}
|
20
|
+
}
|
package/plugins/auth.ts
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
interface AuthOptions {
|
2
|
+
type: 'jwt' | 'bearer' | 'apikey';
|
3
|
+
secret?: string;
|
4
|
+
header?: string;
|
5
|
+
verify?: (token: string, ctx: any) => Promise<any> | any;
|
6
|
+
exclude?: string[];
|
7
|
+
}
|
8
|
+
|
9
|
+
export function auth(options: AuthOptions) {
|
10
|
+
const {
|
11
|
+
type,
|
12
|
+
secret,
|
13
|
+
header = 'authorization',
|
14
|
+
verify,
|
15
|
+
exclude = []
|
16
|
+
} = options;
|
17
|
+
|
18
|
+
return {
|
19
|
+
name: 'auth',
|
20
|
+
onRequest: async (ctx: any) => {
|
21
|
+
const url = new URL(ctx.request.url);
|
22
|
+
const pathname = url.pathname;
|
23
|
+
|
24
|
+
// Skip auth for excluded paths
|
25
|
+
if (exclude.some(path => {
|
26
|
+
if (path.includes('*')) {
|
27
|
+
const regex = new RegExp(path.replace(/\*/g, '.*'));
|
28
|
+
return regex.test(pathname);
|
29
|
+
}
|
30
|
+
return pathname === path || pathname.startsWith(path);
|
31
|
+
})) {
|
32
|
+
return;
|
33
|
+
}
|
34
|
+
|
35
|
+
const authHeader = ctx.request.headers.get(header.toLowerCase());
|
36
|
+
|
37
|
+
if (!authHeader) {
|
38
|
+
throw new Response(JSON.stringify({ error: 'Authorization header required' }), {
|
39
|
+
status: 401,
|
40
|
+
headers: { 'Content-Type': 'application/json' }
|
41
|
+
});
|
42
|
+
}
|
43
|
+
|
44
|
+
let token: string;
|
45
|
+
|
46
|
+
if (type === 'jwt' || type === 'bearer') {
|
47
|
+
if (!authHeader.startsWith('Bearer ')) {
|
48
|
+
throw new Response(JSON.stringify({ error: 'Invalid authorization format. Use Bearer <token>' }), {
|
49
|
+
status: 401,
|
50
|
+
headers: { 'Content-Type': 'application/json' }
|
51
|
+
});
|
52
|
+
}
|
53
|
+
token = authHeader.slice(7);
|
54
|
+
} else if (type === 'apikey') {
|
55
|
+
token = authHeader;
|
56
|
+
} else {
|
57
|
+
token = authHeader;
|
58
|
+
}
|
59
|
+
|
60
|
+
try {
|
61
|
+
let user: any;
|
62
|
+
|
63
|
+
if (verify) {
|
64
|
+
user = await verify(token, ctx);
|
65
|
+
} else if (type === 'jwt' && secret) {
|
66
|
+
// Simple JWT verification (in production, use a proper JWT library)
|
67
|
+
const [headerB64, payloadB64, signature] = token.split('.');
|
68
|
+
if (!headerB64 || !payloadB64 || !signature) {
|
69
|
+
throw new Error('Invalid JWT format');
|
70
|
+
}
|
71
|
+
|
72
|
+
const payload = JSON.parse(atob(payloadB64));
|
73
|
+
|
74
|
+
// Check expiration
|
75
|
+
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
76
|
+
throw new Error('Token expired');
|
77
|
+
}
|
78
|
+
|
79
|
+
user = payload;
|
80
|
+
} else {
|
81
|
+
user = { token };
|
82
|
+
}
|
83
|
+
|
84
|
+
// Attach user to context
|
85
|
+
ctx.user = user;
|
86
|
+
|
87
|
+
} catch (error) {
|
88
|
+
const message = error instanceof Error ? error.message : 'Invalid token';
|
89
|
+
throw new Response(JSON.stringify({ error: message }), {
|
90
|
+
status: 401,
|
91
|
+
headers: { 'Content-Type': 'application/json' }
|
92
|
+
});
|
93
|
+
}
|
94
|
+
}
|
95
|
+
};
|
96
|
+
}
|
97
|
+
|
98
|
+
// Helper function for creating JWT tokens (simple implementation)
|
99
|
+
export function createJWT(payload: any, secret: string, expiresIn: number = 3600): string {
|
100
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
101
|
+
const now = Math.floor(Date.now() / 1000);
|
102
|
+
|
103
|
+
const jwtPayload = {
|
104
|
+
...payload,
|
105
|
+
iat: now,
|
106
|
+
exp: now + expiresIn
|
107
|
+
};
|
108
|
+
|
109
|
+
const headerB64 = btoa(JSON.stringify(header));
|
110
|
+
const payloadB64 = btoa(JSON.stringify(jwtPayload));
|
111
|
+
|
112
|
+
// Simple signature (in production, use proper HMAC-SHA256)
|
113
|
+
const signature = btoa(`${headerB64}.${payloadB64}.${secret}`);
|
114
|
+
|
115
|
+
return `${headerB64}.${payloadB64}.${signature}`;
|
116
|
+
}
|
package/plugins/cors.ts
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
interface CORSOptions {
|
2
|
+
origin?: string | string[] | boolean;
|
3
|
+
methods?: string[];
|
4
|
+
allowedHeaders?: string[];
|
5
|
+
credentials?: boolean;
|
6
|
+
maxAge?: number;
|
7
|
+
}
|
8
|
+
|
9
|
+
export function cors(options: CORSOptions = {}) {
|
10
|
+
const {
|
11
|
+
origin = '*',
|
12
|
+
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
13
|
+
allowedHeaders = ['Content-Type', 'Authorization'],
|
14
|
+
credentials = false,
|
15
|
+
maxAge = 86400
|
16
|
+
} = options;
|
17
|
+
|
18
|
+
return {
|
19
|
+
name: 'cors',
|
20
|
+
onRequest: async (ctx: any) => {
|
21
|
+
// Handle preflight OPTIONS request
|
22
|
+
if (ctx.request.method === 'OPTIONS') {
|
23
|
+
const headers: Record<string, string> = {};
|
24
|
+
|
25
|
+
// Handle origin
|
26
|
+
if (typeof origin === 'boolean') {
|
27
|
+
if (origin) {
|
28
|
+
headers['Access-Control-Allow-Origin'] = ctx.request.headers.get('origin') || '*';
|
29
|
+
}
|
30
|
+
} else if (typeof origin === 'string') {
|
31
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
32
|
+
} else if (Array.isArray(origin)) {
|
33
|
+
const requestOrigin = ctx.request.headers.get('origin');
|
34
|
+
if (requestOrigin && origin.includes(requestOrigin)) {
|
35
|
+
headers['Access-Control-Allow-Origin'] = requestOrigin;
|
36
|
+
}
|
37
|
+
}
|
38
|
+
|
39
|
+
headers['Access-Control-Allow-Methods'] = methods.join(', ');
|
40
|
+
headers['Access-Control-Allow-Headers'] = allowedHeaders.join(', ');
|
41
|
+
|
42
|
+
if (credentials) {
|
43
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
44
|
+
}
|
45
|
+
|
46
|
+
headers['Access-Control-Max-Age'] = maxAge.toString();
|
47
|
+
|
48
|
+
ctx.set.status = 204;
|
49
|
+
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
50
|
+
|
51
|
+
throw new Response(null, { status: 204, headers });
|
52
|
+
}
|
53
|
+
},
|
54
|
+
onResponse: async (ctx: any, response: any) => {
|
55
|
+
const headers: Record<string, string> = {};
|
56
|
+
|
57
|
+
// Handle origin for actual requests
|
58
|
+
if (typeof origin === 'boolean') {
|
59
|
+
if (origin) {
|
60
|
+
headers['Access-Control-Allow-Origin'] = ctx.request.headers.get('origin') || '*';
|
61
|
+
}
|
62
|
+
} else if (typeof origin === 'string') {
|
63
|
+
headers['Access-Control-Allow-Origin'] = origin;
|
64
|
+
} else if (Array.isArray(origin)) {
|
65
|
+
const requestOrigin = ctx.request.headers.get('origin');
|
66
|
+
if (requestOrigin && origin.includes(requestOrigin)) {
|
67
|
+
headers['Access-Control-Allow-Origin'] = requestOrigin;
|
68
|
+
}
|
69
|
+
}
|
70
|
+
|
71
|
+
if (credentials) {
|
72
|
+
headers['Access-Control-Allow-Credentials'] = 'true';
|
73
|
+
}
|
74
|
+
|
75
|
+
ctx.set.headers = { ...ctx.set.headers, ...headers };
|
76
|
+
return response;
|
77
|
+
}
|
78
|
+
};
|
79
|
+
}
|
package/plugins/index.ts
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
// Export all plugins
|
2
|
+
export { cors } from './cors';
|
3
|
+
export { logger } from './logger';
|
4
|
+
export { auth, createJWT } from './auth';
|
5
|
+
export { rateLimit } from './ratelimit';
|
6
|
+
|
7
|
+
// Plugin types for convenience
|
8
|
+
export interface Plugin {
|
9
|
+
name?: string;
|
10
|
+
onRequest?: (ctx: any) => Promise<void> | void;
|
11
|
+
onResponse?: (ctx: any, response: any) => Promise<any> | any;
|
12
|
+
onError?: (ctx: any, error: Error) => Promise<any> | any;
|
13
|
+
}
|
@@ -0,0 +1,104 @@
|
|
1
|
+
interface LoggerOptions {
|
2
|
+
format?: 'simple' | 'detailed' | 'json';
|
3
|
+
includeBody?: boolean;
|
4
|
+
includeHeaders?: boolean;
|
5
|
+
}
|
6
|
+
|
7
|
+
export function logger(options: LoggerOptions = {}) {
|
8
|
+
const {
|
9
|
+
format = 'simple',
|
10
|
+
includeBody = false,
|
11
|
+
includeHeaders = false
|
12
|
+
} = options;
|
13
|
+
|
14
|
+
return {
|
15
|
+
name: 'logger',
|
16
|
+
onRequest: async (ctx: any) => {
|
17
|
+
ctx._startTime = Date.now();
|
18
|
+
|
19
|
+
if (format === 'json') {
|
20
|
+
const logData: any = {
|
21
|
+
timestamp: new Date().toISOString(),
|
22
|
+
method: ctx.request.method,
|
23
|
+
url: ctx.request.url,
|
24
|
+
type: 'request'
|
25
|
+
};
|
26
|
+
|
27
|
+
if (includeHeaders) {
|
28
|
+
logData.headers = Object.fromEntries(ctx.request.headers.entries());
|
29
|
+
}
|
30
|
+
|
31
|
+
if (includeBody && ctx.body) {
|
32
|
+
logData.body = ctx.body;
|
33
|
+
}
|
34
|
+
|
35
|
+
console.log(JSON.stringify(logData));
|
36
|
+
} else if (format === 'detailed') {
|
37
|
+
console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
|
38
|
+
if (includeHeaders) {
|
39
|
+
console.log(' Headers:', Object.fromEntries(ctx.request.headers.entries()));
|
40
|
+
}
|
41
|
+
if (includeBody && ctx.body) {
|
42
|
+
console.log(' Body:', ctx.body);
|
43
|
+
}
|
44
|
+
} else {
|
45
|
+
console.log(`→ ${ctx.request.method} ${ctx.request.url}`);
|
46
|
+
}
|
47
|
+
},
|
48
|
+
onResponse: async (ctx: any, response: any) => {
|
49
|
+
const duration = Date.now() - (ctx._startTime || 0);
|
50
|
+
const status = ctx.set.status || 200;
|
51
|
+
|
52
|
+
if (format === 'json') {
|
53
|
+
const logData: any = {
|
54
|
+
timestamp: new Date().toISOString(),
|
55
|
+
method: ctx.request.method,
|
56
|
+
url: ctx.request.url,
|
57
|
+
status,
|
58
|
+
duration: `${duration}ms`,
|
59
|
+
type: 'response'
|
60
|
+
};
|
61
|
+
|
62
|
+
if (includeHeaders && ctx.set.headers) {
|
63
|
+
logData.responseHeaders = ctx.set.headers;
|
64
|
+
}
|
65
|
+
|
66
|
+
if (includeBody && response) {
|
67
|
+
logData.response = response;
|
68
|
+
}
|
69
|
+
|
70
|
+
console.log(JSON.stringify(logData));
|
71
|
+
} else if (format === 'detailed') {
|
72
|
+
console.log(`← ${ctx.request.method} ${ctx.request.url} ${status} ${duration}ms`);
|
73
|
+
if (includeHeaders && ctx.set.headers) {
|
74
|
+
console.log(' Response Headers:', ctx.set.headers);
|
75
|
+
}
|
76
|
+
if (includeBody && response) {
|
77
|
+
console.log(' Response:', response);
|
78
|
+
}
|
79
|
+
} else {
|
80
|
+
const statusColor = status >= 400 ? '\x1b[31m' : status >= 300 ? '\x1b[33m' : '\x1b[32m';
|
81
|
+
const resetColor = '\x1b[0m';
|
82
|
+
console.log(`← ${ctx.request.method} ${ctx.request.url} ${statusColor}${status}${resetColor} ${duration}ms`);
|
83
|
+
}
|
84
|
+
|
85
|
+
return response;
|
86
|
+
},
|
87
|
+
onError: async (ctx: any, error: Error) => {
|
88
|
+
const duration = Date.now() - (ctx._startTime || 0);
|
89
|
+
|
90
|
+
if (format === 'json') {
|
91
|
+
console.log(JSON.stringify({
|
92
|
+
timestamp: new Date().toISOString(),
|
93
|
+
method: ctx.request.method,
|
94
|
+
url: ctx.request.url,
|
95
|
+
error: error.message,
|
96
|
+
duration: `${duration}ms`,
|
97
|
+
type: 'error'
|
98
|
+
}));
|
99
|
+
} else {
|
100
|
+
console.log(`✗ ${ctx.request.method} ${ctx.request.url} \x1b[31mERROR\x1b[0m ${duration}ms: ${error.message}`);
|
101
|
+
}
|
102
|
+
}
|
103
|
+
};
|
104
|
+
}
|
@@ -0,0 +1,136 @@
|
|
1
|
+
interface RateLimitOptions {
|
2
|
+
max: number;
|
3
|
+
window: number; // in seconds
|
4
|
+
keyGenerator?: (ctx: any) => string;
|
5
|
+
skipSuccessful?: boolean;
|
6
|
+
skipFailed?: boolean;
|
7
|
+
exclude?: string[];
|
8
|
+
message?: string;
|
9
|
+
statusCode?: number;
|
10
|
+
}
|
11
|
+
|
12
|
+
class RateLimitStore {
|
13
|
+
private store = new Map<string, { count: number; resetTime: number }>();
|
14
|
+
|
15
|
+
get(key: string): { count: number; resetTime: number } | undefined {
|
16
|
+
const entry = this.store.get(key);
|
17
|
+
if (entry && Date.now() > entry.resetTime) {
|
18
|
+
this.store.delete(key);
|
19
|
+
return undefined;
|
20
|
+
}
|
21
|
+
return entry;
|
22
|
+
}
|
23
|
+
|
24
|
+
set(key: string, count: number, resetTime: number): void {
|
25
|
+
this.store.set(key, { count, resetTime });
|
26
|
+
}
|
27
|
+
|
28
|
+
increment(key: string, window: number): { count: number; resetTime: number } {
|
29
|
+
const now = Date.now();
|
30
|
+
const entry = this.get(key);
|
31
|
+
|
32
|
+
if (!entry) {
|
33
|
+
const resetTime = now + (window * 1000);
|
34
|
+
this.set(key, 1, resetTime);
|
35
|
+
return { count: 1, resetTime };
|
36
|
+
}
|
37
|
+
|
38
|
+
entry.count++;
|
39
|
+
this.set(key, entry.count, entry.resetTime);
|
40
|
+
return entry;
|
41
|
+
}
|
42
|
+
|
43
|
+
cleanup(): void {
|
44
|
+
const now = Date.now();
|
45
|
+
for (const [key, entry] of this.store.entries()) {
|
46
|
+
if (now > entry.resetTime) {
|
47
|
+
this.store.delete(key);
|
48
|
+
}
|
49
|
+
}
|
50
|
+
}
|
51
|
+
}
|
52
|
+
|
53
|
+
export function rateLimit(options: RateLimitOptions) {
|
54
|
+
const {
|
55
|
+
max,
|
56
|
+
window,
|
57
|
+
keyGenerator = (ctx) => {
|
58
|
+
// Default: use IP address
|
59
|
+
return ctx.request.headers.get('x-forwarded-for') ||
|
60
|
+
ctx.request.headers.get('x-real-ip') ||
|
61
|
+
'unknown';
|
62
|
+
},
|
63
|
+
skipSuccessful = false,
|
64
|
+
skipFailed = false,
|
65
|
+
exclude = [],
|
66
|
+
message = 'Too many requests',
|
67
|
+
statusCode = 429
|
68
|
+
} = options;
|
69
|
+
|
70
|
+
const store = new RateLimitStore();
|
71
|
+
|
72
|
+
// Cleanup expired entries every 5 minutes
|
73
|
+
setInterval(() => store.cleanup(), 5 * 60 * 1000);
|
74
|
+
|
75
|
+
return {
|
76
|
+
name: 'rateLimit',
|
77
|
+
onRequest: async (ctx: any) => {
|
78
|
+
const url = new URL(ctx.request.url);
|
79
|
+
const pathname = url.pathname;
|
80
|
+
|
81
|
+
// Skip rate limiting for excluded paths
|
82
|
+
if (exclude.some(path => {
|
83
|
+
if (path.includes('*')) {
|
84
|
+
const regex = new RegExp(path.replace(/\*/g, '.*'));
|
85
|
+
return regex.test(pathname);
|
86
|
+
}
|
87
|
+
return pathname === path || pathname.startsWith(path);
|
88
|
+
})) {
|
89
|
+
return;
|
90
|
+
}
|
91
|
+
|
92
|
+
const key = keyGenerator(ctx);
|
93
|
+
const entry = store.increment(key, window);
|
94
|
+
|
95
|
+
if (entry.count > max) {
|
96
|
+
const resetTime = Math.ceil(entry.resetTime / 1000);
|
97
|
+
throw new Response(JSON.stringify({
|
98
|
+
error: message,
|
99
|
+
retryAfter: resetTime - Math.floor(Date.now() / 1000)
|
100
|
+
}), {
|
101
|
+
status: statusCode,
|
102
|
+
headers: {
|
103
|
+
'Content-Type': 'application/json',
|
104
|
+
'X-RateLimit-Limit': max.toString(),
|
105
|
+
'X-RateLimit-Remaining': '0',
|
106
|
+
'X-RateLimit-Reset': resetTime.toString(),
|
107
|
+
'Retry-After': (resetTime - Math.floor(Date.now() / 1000)).toString()
|
108
|
+
}
|
109
|
+
});
|
110
|
+
}
|
111
|
+
|
112
|
+
// Add rate limit headers
|
113
|
+
ctx.set.headers = {
|
114
|
+
...ctx.set.headers,
|
115
|
+
'X-RateLimit-Limit': max.toString(),
|
116
|
+
'X-RateLimit-Remaining': Math.max(0, max - entry.count).toString(),
|
117
|
+
'X-RateLimit-Reset': Math.ceil(entry.resetTime / 1000).toString()
|
118
|
+
};
|
119
|
+
},
|
120
|
+
onResponse: async (ctx: any, response: any) => {
|
121
|
+
const status = ctx.set.status || 200;
|
122
|
+
const key = keyGenerator(ctx);
|
123
|
+
|
124
|
+
// Optionally skip counting successful or failed requests
|
125
|
+
if ((skipSuccessful && status < 400) || (skipFailed && status >= 400)) {
|
126
|
+
// Decrement the counter since we don't want to count this request
|
127
|
+
const entry = store.get(key);
|
128
|
+
if (entry && entry.count > 0) {
|
129
|
+
store.set(key, entry.count - 1, entry.resetTime);
|
130
|
+
}
|
131
|
+
}
|
132
|
+
|
133
|
+
return response;
|
134
|
+
}
|
135
|
+
};
|
136
|
+
}
|
package/tsconfig.json
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
{
|
2
|
+
"compilerOptions": {
|
3
|
+
// Environment setup & latest features
|
4
|
+
"lib": ["ESNext"],
|
5
|
+
"target": "ESNext",
|
6
|
+
"module": "Preserve",
|
7
|
+
"moduleDetection": "force",
|
8
|
+
"jsx": "react-jsx",
|
9
|
+
"allowJs": true,
|
10
|
+
|
11
|
+
// Bundler mode
|
12
|
+
"moduleResolution": "bundler",
|
13
|
+
"allowImportingTsExtensions": true,
|
14
|
+
"verbatimModuleSyntax": true,
|
15
|
+
"noEmit": true,
|
16
|
+
|
17
|
+
// Best practices
|
18
|
+
"strict": true,
|
19
|
+
"skipLibCheck": true,
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
21
|
+
"noUncheckedIndexedAccess": true,
|
22
|
+
"noImplicitOverride": true,
|
23
|
+
|
24
|
+
// Some stricter flags (disabled by default)
|
25
|
+
"noUnusedLocals": false,
|
26
|
+
"noUnusedParameters": false,
|
27
|
+
"noPropertyAccessFromIndexSignature": false
|
28
|
+
}
|
29
|
+
}
|