@mantiq/core 0.2.1 → 0.3.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/package.json +1 -1
- package/src/exceptions/Handler.ts +18 -11
- package/src/http/Request.ts +2 -0
- package/src/index.ts +3 -0
- package/src/rateLimit/RateLimiter.ts +146 -0
- package/src/rateLimit/ThrottleRequests.ts +135 -0
package/package.json
CHANGED
|
@@ -54,10 +54,7 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
54
54
|
return MantiqResponse.redirect((err as any).redirectTo)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
return MantiqResponse.html(renderDevErrorPage(request, err), err.statusCode)
|
|
59
|
-
}
|
|
60
|
-
|
|
57
|
+
// API routes always get JSON — even in debug mode
|
|
61
58
|
if (request.expectsJson()) {
|
|
62
59
|
const body: Record<string, any> = {
|
|
63
60
|
error: { message: err.message, status: err.statusCode },
|
|
@@ -65,9 +62,16 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
65
62
|
if (err instanceof ValidationError) {
|
|
66
63
|
body['error']['errors'] = err.errors
|
|
67
64
|
}
|
|
65
|
+
if (debug && err.stack) {
|
|
66
|
+
body['error']['trace'] = err.stack.split('\n').map((l: string) => l.trim())
|
|
67
|
+
}
|
|
68
68
|
return MantiqResponse.json(body, err.statusCode, err.headers)
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (debug) {
|
|
72
|
+
return MantiqResponse.html(renderDevErrorPage(request, err), err.statusCode)
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
return MantiqResponse.html(
|
|
72
76
|
this.genericHtmlPage(err.statusCode, err.message),
|
|
73
77
|
err.statusCode,
|
|
@@ -79,15 +83,18 @@ export class DefaultExceptionHandler implements ExceptionHandler {
|
|
|
79
83
|
err: Error,
|
|
80
84
|
debug: boolean,
|
|
81
85
|
): Response {
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
// API routes always get JSON — even in debug mode
|
|
87
|
+
if (request.expectsJson()) {
|
|
88
|
+
const body: Record<string, any> = { error: { message: 'Internal Server Error', status: 500 } }
|
|
89
|
+
if (debug && err.stack) {
|
|
90
|
+
body['error']['message'] = err.message
|
|
91
|
+
body['error']['trace'] = err.stack.split('\n').map((l: string) => l.trim())
|
|
92
|
+
}
|
|
93
|
+
return MantiqResponse.json(body, 500)
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
if (
|
|
87
|
-
return MantiqResponse.
|
|
88
|
-
{ error: { message: 'Internal Server Error', status: 500 } },
|
|
89
|
-
500,
|
|
90
|
-
)
|
|
96
|
+
if (debug) {
|
|
97
|
+
return MantiqResponse.html(renderDevErrorPage(request, err), 500)
|
|
91
98
|
}
|
|
92
99
|
|
|
93
100
|
return MantiqResponse.html(this.genericHtmlPage(500, 'Internal Server Error'), 500)
|
package/src/http/Request.ts
CHANGED
|
@@ -133,6 +133,8 @@ export class MantiqRequest implements MantiqRequestContract {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
expectsJson(): boolean {
|
|
136
|
+
// Routes under /api/ always expect JSON responses
|
|
137
|
+
if (this.path().startsWith('/api/') || this.path() === '/api') return true
|
|
136
138
|
const accept = this.header('accept') ?? ''
|
|
137
139
|
return accept.includes('application/json') || accept.includes('text/json')
|
|
138
140
|
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,9 @@ export { TrimStringsMiddleware } from './middleware/TrimStrings.ts'
|
|
|
53
53
|
export { StartSession } from './middleware/StartSession.ts'
|
|
54
54
|
export { EncryptCookies } from './middleware/EncryptCookies.ts'
|
|
55
55
|
export { VerifyCsrfToken } from './middleware/VerifyCsrfToken.ts'
|
|
56
|
+
export { RateLimiter, MemoryStore } from './rateLimit/RateLimiter.ts'
|
|
57
|
+
export type { RateLimitConfig, RateLimitStore, LimiterResolver } from './rateLimit/RateLimiter.ts'
|
|
58
|
+
export { ThrottleRequests } from './rateLimit/ThrottleRequests.ts'
|
|
56
59
|
export { WebSocketKernel } from './websocket/WebSocketKernel.ts'
|
|
57
60
|
export { DefaultExceptionHandler } from './exceptions/Handler.ts'
|
|
58
61
|
export { CoreServiceProvider } from './providers/CoreServiceProvider.ts'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiter — tracks request counts per key within time windows.
|
|
3
|
+
*
|
|
4
|
+
* Supports named limiters with custom resolvers, and pluggable stores
|
|
5
|
+
* (memory by default, cache/Redis when available).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const limiter = new RateLimiter()
|
|
9
|
+
*
|
|
10
|
+
* // Define a named limiter
|
|
11
|
+
* limiter.for('api', (request) => ({
|
|
12
|
+
* key: request.ip(),
|
|
13
|
+
* maxAttempts: 60,
|
|
14
|
+
* decayMinutes: 1,
|
|
15
|
+
* }))
|
|
16
|
+
*
|
|
17
|
+
* // Or with multiple limits
|
|
18
|
+
* limiter.for('uploads', (request) => [
|
|
19
|
+
* { key: request.ip(), maxAttempts: 10, decayMinutes: 1 },
|
|
20
|
+
* { key: request.user()?.id ?? request.ip(), maxAttempts: 100, decayMinutes: 60 },
|
|
21
|
+
* ])
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export interface RateLimitConfig {
|
|
25
|
+
key: string
|
|
26
|
+
maxAttempts: number
|
|
27
|
+
decayMinutes: number
|
|
28
|
+
responseCallback?: (request: any, headers: Record<string, string>) => Response | void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RateLimitStore {
|
|
32
|
+
/** Get current hit count for key. */
|
|
33
|
+
get(key: string): Promise<number>
|
|
34
|
+
/** Increment hit count. Returns new count. */
|
|
35
|
+
increment(key: string, decaySeconds: number): Promise<number>
|
|
36
|
+
/** Get remaining seconds until the key resets. */
|
|
37
|
+
availableIn(key: string): Promise<number>
|
|
38
|
+
/** Reset a key. */
|
|
39
|
+
clear(key: string): Promise<void>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type LimiterResolver = (request: any) => RateLimitConfig | RateLimitConfig[]
|
|
43
|
+
|
|
44
|
+
export class RateLimiter {
|
|
45
|
+
private limiters = new Map<string, LimiterResolver>()
|
|
46
|
+
private store: RateLimitStore
|
|
47
|
+
|
|
48
|
+
constructor(store?: RateLimitStore) {
|
|
49
|
+
this.store = store ?? new MemoryStore()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Define a named rate limiter. */
|
|
53
|
+
for(name: string, resolver: LimiterResolver): this {
|
|
54
|
+
this.limiters.set(name, resolver)
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get a named limiter resolver. */
|
|
59
|
+
limiter(name: string): LimiterResolver | undefined {
|
|
60
|
+
return this.limiters.get(name)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check if a key has too many attempts. */
|
|
64
|
+
async tooManyAttempts(key: string, maxAttempts: number): Promise<boolean> {
|
|
65
|
+
const attempts = await this.store.get(key)
|
|
66
|
+
return attempts >= maxAttempts
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record a hit for a key. Returns the new count. */
|
|
70
|
+
async hit(key: string, decaySeconds: number): Promise<number> {
|
|
71
|
+
return this.store.increment(key, decaySeconds)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get current attempt count. */
|
|
75
|
+
async attempts(key: string): Promise<number> {
|
|
76
|
+
return this.store.get(key)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Get remaining attempts. */
|
|
80
|
+
async remaining(key: string, maxAttempts: number): Promise<number> {
|
|
81
|
+
const current = await this.store.get(key)
|
|
82
|
+
return Math.max(0, maxAttempts - current)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get seconds until the rate limit resets. */
|
|
86
|
+
async availableIn(key: string): Promise<number> {
|
|
87
|
+
return this.store.availableIn(key)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Reset a key's attempt count. */
|
|
91
|
+
async clear(key: string): Promise<void> {
|
|
92
|
+
return this.store.clear(key)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Set the backing store (memory, cache, redis). */
|
|
96
|
+
setStore(store: RateLimitStore): void {
|
|
97
|
+
this.store = store
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Memory Store (default) ───────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
interface MemoryEntry {
|
|
104
|
+
count: number
|
|
105
|
+
expiresAt: number // unix ms
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class MemoryStore implements RateLimitStore {
|
|
109
|
+
private entries = new Map<string, MemoryEntry>()
|
|
110
|
+
|
|
111
|
+
async get(key: string): Promise<number> {
|
|
112
|
+
const entry = this.entries.get(key)
|
|
113
|
+
if (!entry) return 0
|
|
114
|
+
if (Date.now() > entry.expiresAt) {
|
|
115
|
+
this.entries.delete(key)
|
|
116
|
+
return 0
|
|
117
|
+
}
|
|
118
|
+
return entry.count
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async increment(key: string, decaySeconds: number): Promise<number> {
|
|
122
|
+
const existing = this.entries.get(key)
|
|
123
|
+
const now = Date.now()
|
|
124
|
+
|
|
125
|
+
if (existing && now <= existing.expiresAt) {
|
|
126
|
+
existing.count++
|
|
127
|
+
return existing.count
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// New window
|
|
131
|
+
const entry: MemoryEntry = { count: 1, expiresAt: now + decaySeconds * 1000 }
|
|
132
|
+
this.entries.set(key, entry)
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async availableIn(key: string): Promise<number> {
|
|
137
|
+
const entry = this.entries.get(key)
|
|
138
|
+
if (!entry) return 0
|
|
139
|
+
const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1000)
|
|
140
|
+
return Math.max(0, remaining)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async clear(key: string): Promise<void> {
|
|
144
|
+
this.entries.delete(key)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
2
|
+
import { HttpError } from '../errors/HttpError.ts'
|
|
3
|
+
import type { RateLimiter, RateLimitConfig } from './RateLimiter.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Middleware that throttles requests using the RateLimiter.
|
|
7
|
+
*
|
|
8
|
+
* Usage with named limiter:
|
|
9
|
+
* router.get('/api/data', handler).middleware('throttle:api')
|
|
10
|
+
*
|
|
11
|
+
* Usage with inline limits:
|
|
12
|
+
* router.get('/api/data', handler).middleware('throttle:60,1')
|
|
13
|
+
* // 60 requests per 1 minute, keyed by IP
|
|
14
|
+
*
|
|
15
|
+
* Response headers:
|
|
16
|
+
* X-RateLimit-Limit: 60
|
|
17
|
+
* X-RateLimit-Remaining: 45
|
|
18
|
+
* Retry-After: 30 (only when rate limited)
|
|
19
|
+
*/
|
|
20
|
+
export class ThrottleRequests {
|
|
21
|
+
private params: string[] = []
|
|
22
|
+
|
|
23
|
+
constructor(private readonly rateLimiter: RateLimiter) {}
|
|
24
|
+
|
|
25
|
+
setParameters(...params: string[]): void {
|
|
26
|
+
this.params = params
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
|
|
30
|
+
const configs = this.resolveConfigs(request)
|
|
31
|
+
|
|
32
|
+
// Check all limits before proceeding
|
|
33
|
+
for (const config of configs) {
|
|
34
|
+
const fullKey = `rate_limit:${config.key}`
|
|
35
|
+
|
|
36
|
+
if (await this.rateLimiter.tooManyAttempts(fullKey, config.maxAttempts)) {
|
|
37
|
+
const retryAfter = await this.rateLimiter.availableIn(fullKey)
|
|
38
|
+
return this.buildTooManyResponse(request, config, retryAfter)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Record hits
|
|
43
|
+
for (const config of configs) {
|
|
44
|
+
const fullKey = `rate_limit:${config.key}`
|
|
45
|
+
await this.rateLimiter.hit(fullKey, config.decayMinutes * 60)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Process request
|
|
49
|
+
const response = await next()
|
|
50
|
+
|
|
51
|
+
// Add rate limit headers (use the most restrictive limit)
|
|
52
|
+
return this.addHeaders(response, configs)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private resolveConfigs(request: MantiqRequest): RateLimitConfig[] {
|
|
56
|
+
if (this.params.length === 0) {
|
|
57
|
+
// Default: 60 per minute by IP
|
|
58
|
+
return [{ key: request.ip(), maxAttempts: 60, decayMinutes: 1 }]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const first = this.params[0]!
|
|
62
|
+
|
|
63
|
+
// Check if it's a named limiter
|
|
64
|
+
const resolver = this.rateLimiter.limiter(first)
|
|
65
|
+
if (resolver) {
|
|
66
|
+
const result = resolver(request)
|
|
67
|
+
return Array.isArray(result) ? result : [result]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Inline: throttle:maxAttempts,decayMinutes
|
|
71
|
+
const maxAttempts = parseInt(first, 10) || 60
|
|
72
|
+
const decayMinutes = parseInt(this.params[1] ?? '1', 10) || 1
|
|
73
|
+
|
|
74
|
+
return [{
|
|
75
|
+
key: request.ip(),
|
|
76
|
+
maxAttempts,
|
|
77
|
+
decayMinutes,
|
|
78
|
+
}]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private buildTooManyResponse(
|
|
82
|
+
request: MantiqRequest,
|
|
83
|
+
config: RateLimitConfig,
|
|
84
|
+
retryAfter: number,
|
|
85
|
+
): Response {
|
|
86
|
+
if (config.responseCallback) {
|
|
87
|
+
const headers: Record<string, string> = {
|
|
88
|
+
'Retry-After': String(retryAfter),
|
|
89
|
+
'X-RateLimit-Limit': String(config.maxAttempts),
|
|
90
|
+
'X-RateLimit-Remaining': '0',
|
|
91
|
+
}
|
|
92
|
+
const custom = config.responseCallback(request, headers)
|
|
93
|
+
if (custom) return custom
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const body = request.expectsJson()
|
|
97
|
+
? JSON.stringify({ message: 'Too Many Requests', retry_after: retryAfter })
|
|
98
|
+
: 'Too Many Requests'
|
|
99
|
+
|
|
100
|
+
return new Response(body, {
|
|
101
|
+
status: 429,
|
|
102
|
+
headers: {
|
|
103
|
+
'Content-Type': request.expectsJson() ? 'application/json' : 'text/plain',
|
|
104
|
+
'Retry-After': String(retryAfter),
|
|
105
|
+
'X-RateLimit-Limit': String(config.maxAttempts),
|
|
106
|
+
'X-RateLimit-Remaining': '0',
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async addHeaders(response: Response, configs: RateLimitConfig[]): Promise<Response> {
|
|
112
|
+
// Use the most restrictive limit for headers
|
|
113
|
+
let minRemaining = Infinity
|
|
114
|
+
let limit = 0
|
|
115
|
+
|
|
116
|
+
for (const config of configs) {
|
|
117
|
+
const fullKey = `rate_limit:${config.key}`
|
|
118
|
+
const remaining = await this.rateLimiter.remaining(fullKey, config.maxAttempts)
|
|
119
|
+
if (remaining < minRemaining) {
|
|
120
|
+
minRemaining = remaining
|
|
121
|
+
limit = config.maxAttempts
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const headers = new Headers(response.headers)
|
|
126
|
+
headers.set('X-RateLimit-Limit', String(limit))
|
|
127
|
+
headers.set('X-RateLimit-Remaining', String(minRemaining))
|
|
128
|
+
|
|
129
|
+
return new Response(response.body, {
|
|
130
|
+
status: response.status,
|
|
131
|
+
statusText: response.statusText,
|
|
132
|
+
headers,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|