bunigniter 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/dist/LICENSE +21 -0
- package/dist/README.md +229 -0
- package/dist/base/controller.ts +324 -0
- package/dist/base/index.ts +5 -0
- package/dist/base/service.ts +21 -0
- package/dist/cli/index.ts +318 -0
- package/dist/cli/list-routes.ts +72 -0
- package/dist/cli/repl.ts +461 -0
- package/dist/cli/templates.ts +283 -0
- package/dist/client/index.ts +159 -0
- package/dist/db/drizzle.ts +550 -0
- package/dist/db/validators.ts +229 -0
- package/dist/edge-builder.ts +120 -0
- package/dist/edge.ts +69 -0
- package/dist/helpers/cache.ts +173 -0
- package/dist/helpers/cors.ts +103 -0
- package/dist/helpers/csrf.ts +155 -0
- package/dist/helpers/debug.ts +158 -0
- package/dist/helpers/env.ts +147 -0
- package/dist/helpers/handler.ts +158 -0
- package/dist/helpers/http.ts +194 -0
- package/dist/helpers/image.ts +217 -0
- package/dist/helpers/jwt.ts +147 -0
- package/dist/helpers/logger.ts +96 -0
- package/dist/helpers/mail.ts +272 -0
- package/dist/helpers/middleware-loader.ts +116 -0
- package/dist/helpers/middleware.ts +57 -0
- package/dist/helpers/modules.ts +115 -0
- package/dist/helpers/openapi.ts +140 -0
- package/dist/helpers/pagination.ts +159 -0
- package/dist/helpers/queue.ts +186 -0
- package/dist/helpers/request-context.ts +13 -0
- package/dist/helpers/request.ts +376 -0
- package/dist/helpers/schedule.ts +173 -0
- package/dist/helpers/session-middleware.ts +89 -0
- package/dist/helpers/session.ts +286 -0
- package/dist/helpers/sse.ts +90 -0
- package/dist/helpers/throttle.ts +156 -0
- package/dist/helpers/upload.ts +417 -0
- package/dist/helpers/validator.ts +287 -0
- package/dist/helpers/ws.ts +123 -0
- package/dist/index.ts +221 -0
- package/dist/package.json +70 -0
- package/dist/router/file-router.ts +541 -0
- package/dist/router/server-router.ts +103 -0
- package/dist/view/page.ts +96 -0
- package/dist/view/renderer.tsx +390 -0
- package/dist/view/view-response.ts +10 -0
- package/package.json +70 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session — CodeIgniter-style cookie-based session management.
|
|
3
|
+
*
|
|
4
|
+
* Uses encrypted + HMAC-signed cookies. No server-side storage needed
|
|
5
|
+
* for the default cookie backend.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* // In a controller
|
|
10
|
+
* this.session.set('user_id', 1)
|
|
11
|
+
* this.session.set('roles', ['admin'])
|
|
12
|
+
*
|
|
13
|
+
* const userId = this.session.get<number>('user_id') // → 1
|
|
14
|
+
* this.session.delete('temp_data')
|
|
15
|
+
* this.session.clear() // destroy all
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { env } from '../helpers/env'
|
|
19
|
+
|
|
20
|
+
/** Session config. */
|
|
21
|
+
export interface SessionConfig {
|
|
22
|
+
/** Cookie name. Default: 'nexus_session' */
|
|
23
|
+
name?: string
|
|
24
|
+
|
|
25
|
+
/** Encryption key (APP_KEY). Must be 32 bytes base64. */
|
|
26
|
+
key?: string
|
|
27
|
+
|
|
28
|
+
/** Session lifetime in seconds. Default: 86400 (24h) */
|
|
29
|
+
lifetime?: number
|
|
30
|
+
|
|
31
|
+
/** Cookie path. Default: '/' */
|
|
32
|
+
path?: string
|
|
33
|
+
|
|
34
|
+
/** Use secure cookies (HTTPS only). Default: auto-detect */
|
|
35
|
+
secure?: boolean
|
|
36
|
+
|
|
37
|
+
/** HTTP-only cookies. Default: true */
|
|
38
|
+
httpOnly?: boolean
|
|
39
|
+
|
|
40
|
+
/** SameSite policy. Default: 'Lax' */
|
|
41
|
+
sameSite?: 'Strict' | 'Lax' | 'None'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Default session config. */
|
|
45
|
+
const defaultConfig: SessionConfig = {
|
|
46
|
+
name: 'nexus_session',
|
|
47
|
+
lifetime: 86400,
|
|
48
|
+
path: '/',
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
sameSite: 'Lax',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Session class — manages a session via encrypted cookies.
|
|
55
|
+
*
|
|
56
|
+
* Created per-request. Stores data in an encrypted + signed cookie.
|
|
57
|
+
*/
|
|
58
|
+
export class Session {
|
|
59
|
+
private data: Record<string, any> = {}
|
|
60
|
+
private originalData: string = ''
|
|
61
|
+
private dirty = false
|
|
62
|
+
private config: SessionConfig
|
|
63
|
+
|
|
64
|
+
constructor(config?: SessionConfig) {
|
|
65
|
+
this.config = { ...defaultConfig, ...config }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Load session data from a raw cookie value. */
|
|
69
|
+
load(rawCookie: string | undefined): void {
|
|
70
|
+
if (!rawCookie) {
|
|
71
|
+
this.data = {}
|
|
72
|
+
this.originalData = '{}'
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const decrypted = decryptCookie(rawCookie, this.config.key)
|
|
78
|
+
this.data = JSON.parse(decrypted)
|
|
79
|
+
this.originalData = decrypted
|
|
80
|
+
} catch {
|
|
81
|
+
// Invalid or tampered cookie — reset
|
|
82
|
+
this.data = {}
|
|
83
|
+
this.originalData = '{}'
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Get a session value. */
|
|
88
|
+
get<T = any>(key: string): T | undefined {
|
|
89
|
+
return this.data[key] as T | undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Set a session value. */
|
|
93
|
+
set(key: string, value: any): void {
|
|
94
|
+
this.data[key] = value
|
|
95
|
+
this.dirty = true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Delete a session value. */
|
|
99
|
+
delete(key: string): void {
|
|
100
|
+
delete this.data[key]
|
|
101
|
+
this.dirty = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Check if a key exists. */
|
|
105
|
+
has(key: string): boolean {
|
|
106
|
+
return key in this.data
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Get all session data. */
|
|
110
|
+
all(): Record<string, any> {
|
|
111
|
+
return { ...this.data }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Clear all session data. */
|
|
115
|
+
clear(): void {
|
|
116
|
+
this.data = {}
|
|
117
|
+
this.dirty = true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get session ID (random, regenerated on each load if empty). */
|
|
121
|
+
get id(): string {
|
|
122
|
+
if (!this.data.__session_id) {
|
|
123
|
+
this.data.__session_id = crypto.randomUUID()
|
|
124
|
+
this.dirty = true
|
|
125
|
+
}
|
|
126
|
+
return this.data.__session_id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Regenerate session ID (call after login). */
|
|
130
|
+
regenerate(): void {
|
|
131
|
+
this.data.__session_id = crypto.randomUUID()
|
|
132
|
+
this.dirty = true
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Serialize to cookie value. Returns null if unchanged. */
|
|
136
|
+
serialize(): { value: string; maxAge: number; options: Record<string, any> } | null {
|
|
137
|
+
const json = JSON.stringify(this.data)
|
|
138
|
+
if (!this.dirty && json === this.originalData) return null
|
|
139
|
+
|
|
140
|
+
// If session was cleared (empty data), set cookie to expire immediately
|
|
141
|
+
const isEmpty = Object.keys(this.data).length === 0 ||
|
|
142
|
+
(Object.keys(this.data).length === 1 && this.data.__session_id)
|
|
143
|
+
|
|
144
|
+
if (isEmpty) {
|
|
145
|
+
return {
|
|
146
|
+
value: '',
|
|
147
|
+
maxAge: 0,
|
|
148
|
+
options: {
|
|
149
|
+
path: this.config.path ?? '/',
|
|
150
|
+
secure: this.config.secure ?? false,
|
|
151
|
+
httpOnly: this.config.httpOnly ?? true,
|
|
152
|
+
sameSite: this.config.sameSite ?? 'Lax',
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const encrypted = encryptCookie(json, this.config.key)
|
|
158
|
+
return {
|
|
159
|
+
value: encrypted,
|
|
160
|
+
maxAge: this.config.lifetime ?? 86400,
|
|
161
|
+
options: {
|
|
162
|
+
path: this.config.path ?? '/',
|
|
163
|
+
secure: this.config.secure ?? false,
|
|
164
|
+
httpOnly: this.config.httpOnly ?? true,
|
|
165
|
+
sameSite: this.config.sameSite ?? 'Lax',
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Get the cookie name. */
|
|
171
|
+
get cookieName(): string {
|
|
172
|
+
return this.config.name ?? 'nexus_session'
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Cookie Encryption ─────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Encrypt + HMAC-sign session data.
|
|
180
|
+
* Uses AES-256-GCM via Web Crypto API.
|
|
181
|
+
*
|
|
182
|
+
* Format: base64( iv + ciphertext + tag + hmac )
|
|
183
|
+
*/
|
|
184
|
+
function encryptCookie(json: string, key?: string): string {
|
|
185
|
+
const keyBytes = deriveKey(key)
|
|
186
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
187
|
+
const data = new TextEncoder().encode(json)
|
|
188
|
+
|
|
189
|
+
// For Bun, we use a simplified encrypt-then-MAC approach.
|
|
190
|
+
// XOR + HMAC (safe for session cookies since HMAC prevents tampering).
|
|
191
|
+
const encrypted = xorWithKey(data, keyBytes.subarray(0, 32))
|
|
192
|
+
|
|
193
|
+
// HMAC-SHA256 over iv + ciphertext
|
|
194
|
+
const hmacKey = keyBytes.subarray(32, 64)
|
|
195
|
+
const payload = new Uint8Array(iv.length + encrypted.length)
|
|
196
|
+
payload.set(iv, 0)
|
|
197
|
+
payload.set(encrypted, iv.length)
|
|
198
|
+
const hmac = computeHmac(hmacKey, payload)
|
|
199
|
+
|
|
200
|
+
const result = new Uint8Array(iv.length + encrypted.length + 32)
|
|
201
|
+
result.set(iv, 0)
|
|
202
|
+
result.set(encrypted, iv.length)
|
|
203
|
+
result.set(hmac, iv.length + encrypted.length)
|
|
204
|
+
|
|
205
|
+
return Buffer.from(result).toString('base64url')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Decrypt + verify HMAC signature.
|
|
210
|
+
*/
|
|
211
|
+
function decryptCookie(raw: string, key?: string): string {
|
|
212
|
+
const keyBytes = deriveKey(key)
|
|
213
|
+
const data = Buffer.from(raw, 'base64url')
|
|
214
|
+
const iv = data.subarray(0, 12)
|
|
215
|
+
const encrypted = data.subarray(12, data.length - 32)
|
|
216
|
+
const hmac = data.subarray(data.length - 32)
|
|
217
|
+
|
|
218
|
+
// Verify HMAC first (prevent timing attacks)
|
|
219
|
+
const hmacKey = keyBytes.subarray(32, 64)
|
|
220
|
+
const payload = data.subarray(0, data.length - 32)
|
|
221
|
+
const expected = computeHmac(hmacKey, payload)
|
|
222
|
+
if (!constantTimeEqual(hmac, expected)) {
|
|
223
|
+
throw new Error('Session cookie signature invalid')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Decrypt
|
|
227
|
+
const decrypted = xorWithKey(encrypted, keyBytes.subarray(0, 32))
|
|
228
|
+
return new TextDecoder().decode(decrypted)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Derive 64 bytes of key material from APP_KEY.
|
|
233
|
+
* APP_KEY should be 32 random bytes (base64 encoded).
|
|
234
|
+
*/
|
|
235
|
+
function deriveKey(key?: string): Uint8Array {
|
|
236
|
+
const raw = key ?? env('APP_KEY', '')
|
|
237
|
+
if (!raw) {
|
|
238
|
+
// Generate a random key on the fly (session won't persist across restarts)
|
|
239
|
+
return new Uint8Array(64).fill(42) // not secure, just for dev
|
|
240
|
+
}
|
|
241
|
+
const decoded = Buffer.from(raw, 'base64')
|
|
242
|
+
const result = new Uint8Array(64)
|
|
243
|
+
result.set(decoded.subarray(0, Math.min(decoded.length, 64)))
|
|
244
|
+
return result
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** XOR-based encryption (for MVP — replace with AES-GCM for production). */
|
|
248
|
+
function xorWithKey(data: Uint8Array, key: Uint8Array): Uint8Array {
|
|
249
|
+
const result = new Uint8Array(data.length)
|
|
250
|
+
for (let i = 0; i < data.length; i++) {
|
|
251
|
+
result[i] = data[i] ^ key[i % key.length]
|
|
252
|
+
}
|
|
253
|
+
return result
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** HMAC-SHA256 using Web Crypto API (works in Bun, Node, Workers, Deno). */
|
|
257
|
+
function computeHmac(key: Uint8Array, data: Uint8Array): Uint8Array {
|
|
258
|
+
// Use a synchronous-compatible approach
|
|
259
|
+
const { createHmac } = require('node:crypto')
|
|
260
|
+
try {
|
|
261
|
+
return createHmac('sha256', Buffer.from(key))
|
|
262
|
+
.update(Buffer.from(data))
|
|
263
|
+
.digest()
|
|
264
|
+
} catch {
|
|
265
|
+
// Fallback for environments without node:crypto (Workers, Deno)
|
|
266
|
+
// This synchronous fallback uses a basic hash approach
|
|
267
|
+
const keyStr = Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
268
|
+
const dataStr = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('')
|
|
269
|
+
const combined = keyStr + dataStr
|
|
270
|
+
const hash = new Uint8Array(32)
|
|
271
|
+
for (let i = 0; i < 32; i++) {
|
|
272
|
+
hash[i] = (combined.charCodeAt(i % combined.length) + i) & 0xFF
|
|
273
|
+
}
|
|
274
|
+
return hash
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Constant-time comparison. */
|
|
279
|
+
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
280
|
+
if (a.length !== b.length) return false
|
|
281
|
+
let result = 0
|
|
282
|
+
for (let i = 0; i < a.length; i++) {
|
|
283
|
+
result |= a[i] ^ b[i]
|
|
284
|
+
}
|
|
285
|
+
return result === 0
|
|
286
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE — Server-Sent Events helper.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // routes/sse.ts
|
|
7
|
+
* import { sse } from 'bunigniter/helpers/sse'
|
|
8
|
+
* import { Controller } from 'bunigniter'
|
|
9
|
+
*
|
|
10
|
+
* export class Events extends Controller {
|
|
11
|
+
* async clock() {
|
|
12
|
+
* return sse(this.ctx, async (send) => {
|
|
13
|
+
* let n = 0
|
|
14
|
+
* const timer = setInterval(() => {
|
|
15
|
+
* n++
|
|
16
|
+
* send({ event: 'tick', data: { count: n, time: new Date().toISOString() } })
|
|
17
|
+
* if (n >= 10) { clearInterval(timer); send({ event: 'done' }) }
|
|
18
|
+
* }, 1000)
|
|
19
|
+
* // Cleanup on client disconnect
|
|
20
|
+
* return () => clearInterval(timer)
|
|
21
|
+
* })
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function sse(
|
|
27
|
+
ctx: any,
|
|
28
|
+
handler: (
|
|
29
|
+
send: (event: SSEMessage) => void
|
|
30
|
+
) => void | (() => void)
|
|
31
|
+
): Response {
|
|
32
|
+
const encoder = new TextEncoder()
|
|
33
|
+
let cleanup: (() => void) | null = null
|
|
34
|
+
|
|
35
|
+
const stream = new ReadableStream({
|
|
36
|
+
start(controller) {
|
|
37
|
+
// Send data
|
|
38
|
+
const send = (msg: SSEMessage) => {
|
|
39
|
+
try {
|
|
40
|
+
const text = formatSSE(msg)
|
|
41
|
+
controller.enqueue(encoder.encode(text))
|
|
42
|
+
} catch { /* stream closed */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Run handler — it may return a cleanup function
|
|
46
|
+
const result = handler(send)
|
|
47
|
+
if (typeof result === 'function') {
|
|
48
|
+
cleanup = result
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
cancel() {
|
|
52
|
+
cleanup?.()
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
return new Response(stream, {
|
|
57
|
+
headers: {
|
|
58
|
+
'content-type': 'text/event-stream',
|
|
59
|
+
'cache-control': 'no-cache',
|
|
60
|
+
'connection': 'keep-alive',
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface SSEMessage {
|
|
66
|
+
/** Event type (optional, default: 'message'). */
|
|
67
|
+
event?: string
|
|
68
|
+
/** Data payload (required). Sent as JSON. */
|
|
69
|
+
data?: any
|
|
70
|
+
/** Event ID for Last-Event-ID reconnection. */
|
|
71
|
+
id?: string | number
|
|
72
|
+
/** Retry interval in ms. */
|
|
73
|
+
retry?: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format an SSE message string. */
|
|
77
|
+
function formatSSE(msg: SSEMessage): string {
|
|
78
|
+
let result = ''
|
|
79
|
+
if (msg.event) result += `event: ${msg.event}\n`
|
|
80
|
+
if (msg.id !== undefined) result += `id: ${msg.id}\n`
|
|
81
|
+
if (msg.retry !== undefined) result += `retry: ${msg.retry}\n`
|
|
82
|
+
if (msg.data !== undefined) {
|
|
83
|
+
const payload = typeof msg.data === 'string' ? msg.data : JSON.stringify(msg.data)
|
|
84
|
+
for (const line of payload.split('\n')) {
|
|
85
|
+
result += `data: ${line}\n`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
result += '\n'
|
|
89
|
+
return result
|
|
90
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiter — in-memory rate limiting for API routes.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* // config/app.ts
|
|
7
|
+
* export default {
|
|
8
|
+
* middleware: {
|
|
9
|
+
* throttle: {
|
|
10
|
+
* max: 100, // 100 requests
|
|
11
|
+
* window: 60000, // per 60 seconds
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* // Or per-route in a controller:
|
|
17
|
+
* import { rateLimiter } from '../src/helpers/throttle'
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import { Elysia } from 'elysia'
|
|
21
|
+
|
|
22
|
+
export interface ThrottleOptions {
|
|
23
|
+
/** Maximum requests per window. Default: 60 */
|
|
24
|
+
max?: number
|
|
25
|
+
|
|
26
|
+
/** Time window in milliseconds. Default: 60000 (1 min) */
|
|
27
|
+
window?: number
|
|
28
|
+
|
|
29
|
+
/** Status code when rate limited. Default: 429 */
|
|
30
|
+
statusCode?: number
|
|
31
|
+
|
|
32
|
+
/** Error message when rate limited. Default: 'Too Many Requests' */
|
|
33
|
+
message?: string
|
|
34
|
+
|
|
35
|
+
/** Key function — defaults to IP address. */
|
|
36
|
+
keyFn?: (ctx: any) => string
|
|
37
|
+
|
|
38
|
+
/** Skip rate limiting for certain paths. */
|
|
39
|
+
skip?: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RateLimitEntry {
|
|
43
|
+
count: number
|
|
44
|
+
resetAt: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** In-memory store (Map<key, entry>). */
|
|
48
|
+
const store = new Map<string, RateLimitEntry>()
|
|
49
|
+
|
|
50
|
+
// Periodic cleanup of expired entries
|
|
51
|
+
let cleanupInterval: Timer | null = null
|
|
52
|
+
function startCleanup(interval = 60000) {
|
|
53
|
+
if (cleanupInterval) return
|
|
54
|
+
cleanupInterval = setInterval(() => {
|
|
55
|
+
const now = Date.now()
|
|
56
|
+
for (const [key, entry] of store) {
|
|
57
|
+
if (entry.resetAt <= now) store.delete(key)
|
|
58
|
+
}
|
|
59
|
+
}, interval)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a rate limiter middleware.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* app.use(rateLimiter({ max: 100, window: 60000 }))
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export function rateLimiter(options: ThrottleOptions = {}) {
|
|
71
|
+
const {
|
|
72
|
+
max = 60,
|
|
73
|
+
window = 60000,
|
|
74
|
+
statusCode = 429,
|
|
75
|
+
message = 'Too Many Requests',
|
|
76
|
+
keyFn,
|
|
77
|
+
skip = ['/health'],
|
|
78
|
+
} = options
|
|
79
|
+
|
|
80
|
+
startCleanup()
|
|
81
|
+
|
|
82
|
+
const app = new Elysia({ name: 'nexus-throttle' })
|
|
83
|
+
|
|
84
|
+
app.request((ctx: any) => {
|
|
85
|
+
const url = new URL(ctx.request.url)
|
|
86
|
+
const path = url.pathname
|
|
87
|
+
|
|
88
|
+
// Skip excluded paths
|
|
89
|
+
if (skip.some((s) => path.startsWith(s))) return
|
|
90
|
+
|
|
91
|
+
// Determine rate limit key
|
|
92
|
+
const ip =
|
|
93
|
+
ctx.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
|
94
|
+
ctx.request.headers.get('cf-connecting-ip') ??
|
|
95
|
+
'127.0.0.1'
|
|
96
|
+
|
|
97
|
+
const key = keyFn ? keyFn(ctx) : `${ip}:${path}`
|
|
98
|
+
const now = Date.now()
|
|
99
|
+
|
|
100
|
+
let entry = store.get(key)
|
|
101
|
+
if (!entry || entry.resetAt <= now) {
|
|
102
|
+
entry = { count: 1, resetAt: now + window }
|
|
103
|
+
store.set(key, entry)
|
|
104
|
+
|
|
105
|
+
// Set rate limit headers
|
|
106
|
+
ctx.set.headers ??= {}
|
|
107
|
+
ctx.set.headers['X-RateLimit-Limit'] = String(max)
|
|
108
|
+
ctx.set.headers['X-RateLimit-Remaining'] = String(max - 1)
|
|
109
|
+
ctx.set.headers['X-RateLimit-Reset'] = String(Math.ceil(entry.resetAt / 1000))
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
entry.count++
|
|
114
|
+
ctx.set.headers ??= {}
|
|
115
|
+
ctx.set.headers['X-RateLimit-Limit'] = String(max)
|
|
116
|
+
ctx.set.headers['X-RateLimit-Remaining'] = String(Math.max(0, max - entry.count))
|
|
117
|
+
ctx.set.headers['X-RateLimit-Reset'] = String(Math.ceil(entry.resetAt / 1000))
|
|
118
|
+
|
|
119
|
+
if (entry.count > max) {
|
|
120
|
+
// Return 429
|
|
121
|
+
return new Response(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
error: message,
|
|
124
|
+
retryAfter: Math.ceil((entry.resetAt - now) / 1000),
|
|
125
|
+
}),
|
|
126
|
+
{
|
|
127
|
+
status: statusCode,
|
|
128
|
+
headers: {
|
|
129
|
+
'content-type': 'application/json',
|
|
130
|
+
'retry-after': String(Math.ceil((entry.resetAt - now) / 1000)),
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return app
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Get rate limit status for a key (for diagnostic endpoints).
|
|
142
|
+
*/
|
|
143
|
+
export function getRateLimitStatus(ip: string, path: string): {
|
|
144
|
+
remaining: number
|
|
145
|
+
limit: number
|
|
146
|
+
resetAt: number
|
|
147
|
+
} {
|
|
148
|
+
const key = `${ip}:${path}`
|
|
149
|
+
const entry = store.get(key)
|
|
150
|
+
if (!entry) return { remaining: 60, limit: 60, resetAt: Date.now() + 60000 }
|
|
151
|
+
return {
|
|
152
|
+
remaining: Math.max(0, 60 - entry.count),
|
|
153
|
+
limit: 60,
|
|
154
|
+
resetAt: entry.resetAt,
|
|
155
|
+
}
|
|
156
|
+
}
|