@mantiq/core 0.2.1 → 0.4.0-rc.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/package.json +1 -1
- package/src/application/Application.ts +2 -2
- package/src/contracts/Router.ts +2 -2
- package/src/discovery/Discoverer.ts +200 -0
- package/src/exceptions/Handler.ts +18 -11
- package/src/http/Kernel.ts +4 -3
- package/src/http/Request.ts +3 -1
- package/src/index.ts +5 -0
- package/src/macroable/Macroable.ts +1 -1
- package/src/providers/CoreServiceProvider.ts +8 -0
- package/src/rateLimit/RateLimiter.ts +146 -0
- package/src/rateLimit/ThrottleRequests.ts +154 -0
- package/src/types.d.ts +3 -0
- package/src/websocket/WebSocketContext.ts +1 -1
package/package.json
CHANGED
|
@@ -30,7 +30,7 @@ export class Application extends ContainerImpl {
|
|
|
30
30
|
private constructor(private readonly basePath: string = process.cwd()) {
|
|
31
31
|
super()
|
|
32
32
|
// Register the application itself so it can be resolved from the container
|
|
33
|
-
this.instance(Application, this)
|
|
33
|
+
this.instance(Application as any, this)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// ── Singleton access ──────────────────────────────────────────────────────
|
|
@@ -191,7 +191,7 @@ export class Application extends ContainerImpl {
|
|
|
191
191
|
* Override make() to handle deferred provider loading.
|
|
192
192
|
* If a binding isn't found in the container, check deferred providers.
|
|
193
193
|
*/
|
|
194
|
-
make<T>(abstract: Bindable<T>): T {
|
|
194
|
+
override make<T>(abstract: Bindable<T>): T {
|
|
195
195
|
try {
|
|
196
196
|
return super.make(abstract)
|
|
197
197
|
} catch (err) {
|
package/src/contracts/Router.ts
CHANGED
|
@@ -19,14 +19,14 @@ export interface RouteMatch {
|
|
|
19
19
|
action: RouteAction
|
|
20
20
|
params: Record<string, any>
|
|
21
21
|
middleware: string[]
|
|
22
|
-
routeName?: string
|
|
22
|
+
routeName?: string | undefined
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface RouteDefinition {
|
|
26
26
|
method: HttpMethod | HttpMethod[]
|
|
27
27
|
path: string
|
|
28
28
|
action: RouteAction
|
|
29
|
-
name?: string
|
|
29
|
+
name?: string | undefined
|
|
30
30
|
middleware: string[]
|
|
31
31
|
wheres: Record<string, RegExp>
|
|
32
32
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join, relative } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Auto-discovers application classes by scanning conventional directories.
|
|
6
|
+
*
|
|
7
|
+
* In development: scans the filesystem and rebuilds the manifest.
|
|
8
|
+
* In production: reads a cached manifest from bootstrap/manifest.json.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const discoverer = new Discoverer(app.basePath)
|
|
12
|
+
* const manifest = await discoverer.build() // scan + cache
|
|
13
|
+
* const manifest = discoverer.cached() // read cache only
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface DiscoveryManifest {
|
|
17
|
+
providers: string[]
|
|
18
|
+
commands: string[]
|
|
19
|
+
routes: string[]
|
|
20
|
+
models: string[]
|
|
21
|
+
policies: string[]
|
|
22
|
+
middleware: string[]
|
|
23
|
+
observers: string[]
|
|
24
|
+
listeners: string[]
|
|
25
|
+
jobs: string[]
|
|
26
|
+
timestamp: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EMPTY_MANIFEST: DiscoveryManifest = {
|
|
30
|
+
providers: [],
|
|
31
|
+
commands: [],
|
|
32
|
+
routes: [],
|
|
33
|
+
models: [],
|
|
34
|
+
policies: [],
|
|
35
|
+
middleware: [],
|
|
36
|
+
observers: [],
|
|
37
|
+
listeners: [],
|
|
38
|
+
jobs: [],
|
|
39
|
+
timestamp: 0,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Directories to scan, relative to basePath. */
|
|
43
|
+
const DISCOVERY_MAP: Array<{ key: keyof DiscoveryManifest; dir: string; pattern: string }> = [
|
|
44
|
+
{ key: 'providers', dir: 'app/Providers', pattern: '*ServiceProvider.ts' },
|
|
45
|
+
{ key: 'commands', dir: 'app/Console/Commands', pattern: '*Command.ts' },
|
|
46
|
+
{ key: 'routes', dir: 'routes', pattern: '*.ts' },
|
|
47
|
+
{ key: 'models', dir: 'app/Models', pattern: '*.ts' },
|
|
48
|
+
{ key: 'policies', dir: 'app/Policies', pattern: '*Policy.ts' },
|
|
49
|
+
{ key: 'middleware', dir: 'app/Http/Middleware', pattern: '*.ts' },
|
|
50
|
+
{ key: 'observers', dir: 'app/Observers', pattern: '*Observer.ts' },
|
|
51
|
+
{ key: 'listeners', dir: 'app/Listeners', pattern: '*Listener.ts' },
|
|
52
|
+
{ key: 'jobs', dir: 'app/Jobs', pattern: '*.ts' },
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
export class Discoverer {
|
|
56
|
+
private manifestPath: string
|
|
57
|
+
|
|
58
|
+
constructor(private basePath: string) {
|
|
59
|
+
this.manifestPath = join(basePath, 'bootstrap', 'manifest.json')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scan all directories and build a fresh manifest.
|
|
64
|
+
* Writes to bootstrap/manifest.json for caching.
|
|
65
|
+
*/
|
|
66
|
+
async build(): Promise<DiscoveryManifest> {
|
|
67
|
+
const manifest: DiscoveryManifest = { ...EMPTY_MANIFEST, timestamp: Date.now() }
|
|
68
|
+
|
|
69
|
+
for (const { key, dir, pattern } of DISCOVERY_MAP) {
|
|
70
|
+
if (key === 'timestamp') continue
|
|
71
|
+
const fullDir = join(this.basePath, dir)
|
|
72
|
+
const files = await this.scanDirectory(fullDir, pattern)
|
|
73
|
+
;(manifest[key] as string[]) = files.map((f) => join(dir, f))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Write cache
|
|
77
|
+
this.writeManifest(manifest)
|
|
78
|
+
return manifest
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read the cached manifest. Returns null if no cache exists.
|
|
83
|
+
*/
|
|
84
|
+
cached(): DiscoveryManifest | null {
|
|
85
|
+
if (!existsSync(this.manifestPath)) return null
|
|
86
|
+
try {
|
|
87
|
+
const raw = readFileSync(this.manifestPath, 'utf-8')
|
|
88
|
+
return JSON.parse(raw) as DiscoveryManifest
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get the manifest — cached in production, fresh in development.
|
|
96
|
+
*/
|
|
97
|
+
async resolve(isDev = true): Promise<DiscoveryManifest> {
|
|
98
|
+
if (!isDev) {
|
|
99
|
+
const cached = this.cached()
|
|
100
|
+
if (cached) return cached
|
|
101
|
+
}
|
|
102
|
+
return this.build()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Load and instantiate all discovered service providers.
|
|
107
|
+
* Returns provider instances ready for registration.
|
|
108
|
+
*/
|
|
109
|
+
async loadProviders(manifest: DiscoveryManifest): Promise<any[]> {
|
|
110
|
+
const providers: any[] = []
|
|
111
|
+
for (const file of manifest.providers) {
|
|
112
|
+
const fullPath = join(this.basePath, file)
|
|
113
|
+
try {
|
|
114
|
+
const mod = await import(fullPath)
|
|
115
|
+
const ProviderClass = this.findExport(mod, (v) =>
|
|
116
|
+
typeof v === 'function' && v.prototype?.register && v.prototype?.boot
|
|
117
|
+
)
|
|
118
|
+
if (ProviderClass) providers.push(ProviderClass)
|
|
119
|
+
} catch {
|
|
120
|
+
// Skip unloadable providers
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return providers
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Load and register all discovered route files.
|
|
128
|
+
* Each route file should export a default function: (router) => void
|
|
129
|
+
*/
|
|
130
|
+
async loadRoutes(manifest: DiscoveryManifest, router: any): Promise<void> {
|
|
131
|
+
for (const file of manifest.routes) {
|
|
132
|
+
const fullPath = join(this.basePath, file)
|
|
133
|
+
try {
|
|
134
|
+
const mod = await import(fullPath)
|
|
135
|
+
if (typeof mod.default === 'function') {
|
|
136
|
+
mod.default(router)
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// Skip unloadable routes
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Load all discovered command classes for CLI kernel.
|
|
146
|
+
*/
|
|
147
|
+
async loadCommands(manifest: DiscoveryManifest): Promise<any[]> {
|
|
148
|
+
const commands: any[] = []
|
|
149
|
+
for (const file of manifest.commands) {
|
|
150
|
+
const fullPath = join(this.basePath, file)
|
|
151
|
+
try {
|
|
152
|
+
const mod = await import(fullPath)
|
|
153
|
+
for (const exported of Object.values(mod)) {
|
|
154
|
+
if (typeof exported !== 'function') continue
|
|
155
|
+
try {
|
|
156
|
+
const instance = new (exported as any)()
|
|
157
|
+
if (instance.name && typeof instance.handle === 'function') {
|
|
158
|
+
commands.push(instance)
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// Not a command
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Skip
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return commands
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
private async scanDirectory(dir: string, pattern: string): Promise<string[]> {
|
|
174
|
+
const files: string[] = []
|
|
175
|
+
try {
|
|
176
|
+
const glob = new Bun.Glob(pattern)
|
|
177
|
+
for await (const file of glob.scan({ cwd: dir, absolute: false })) {
|
|
178
|
+
// Skip dotfiles and test files
|
|
179
|
+
if (file.startsWith('.') || file.includes('.test.') || file.includes('.spec.')) continue
|
|
180
|
+
files.push(file)
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
// Directory doesn't exist
|
|
184
|
+
}
|
|
185
|
+
return files.sort()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private writeManifest(manifest: DiscoveryManifest): void {
|
|
189
|
+
const dir = dirname(this.manifestPath)
|
|
190
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
191
|
+
writeFileSync(this.manifestPath, JSON.stringify(manifest, null, 2))
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private findExport(mod: any, predicate: (v: any) => boolean): any {
|
|
195
|
+
for (const exported of Object.values(mod)) {
|
|
196
|
+
if (predicate(exported)) return exported
|
|
197
|
+
}
|
|
198
|
+
return null
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -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/Kernel.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Container, Constructor } from '../contracts/Container.ts'
|
|
|
2
2
|
import type { ExceptionHandler } from '../contracts/ExceptionHandler.ts'
|
|
3
3
|
import type { Middleware } from '../contracts/Middleware.ts'
|
|
4
4
|
import type { Router, RouteMatch } from '../contracts/Router.ts'
|
|
5
|
+
import type { MantiqRequest as MantiqRequestContract } from '../contracts/Request.ts'
|
|
5
6
|
import { MantiqRequest } from './Request.ts'
|
|
6
7
|
import { MantiqResponse } from './Response.ts'
|
|
7
8
|
import { Pipeline } from '../middleware/Pipeline.ts'
|
|
@@ -90,7 +91,7 @@ export class HttpKernel {
|
|
|
90
91
|
/**
|
|
91
92
|
* Main entry point. Passed to Bun.serve() as the fetch handler.
|
|
92
93
|
*/
|
|
93
|
-
async handle(bunRequest: Request, server: Server): Promise<Response> {
|
|
94
|
+
async handle(bunRequest: Request, server: Bun.Server<any>): Promise<Response> {
|
|
94
95
|
// WebSocket upgrade
|
|
95
96
|
if (bunRequest.headers.get('upgrade')?.toLowerCase() === 'websocket') {
|
|
96
97
|
return this.wsKernel.handleUpgrade(bunRequest, server)
|
|
@@ -145,7 +146,7 @@ export class HttpKernel {
|
|
|
145
146
|
port,
|
|
146
147
|
hostname,
|
|
147
148
|
fetch: (req, server) => this.handle(req, server),
|
|
148
|
-
websocket: this.wsKernel.getBunHandlers()
|
|
149
|
+
websocket: this.wsKernel.getBunHandlers() as Bun.WebSocketHandler<any>,
|
|
149
150
|
})
|
|
150
151
|
|
|
151
152
|
const display = hostname === '0.0.0.0' ? 'localhost' : hostname
|
|
@@ -172,7 +173,7 @@ export class HttpKernel {
|
|
|
172
173
|
* Call the route action (controller method or closure).
|
|
173
174
|
* Converts the return value to a Response.
|
|
174
175
|
*/
|
|
175
|
-
private async callAction(match: RouteMatch, request:
|
|
176
|
+
private async callAction(match: RouteMatch, request: MantiqRequestContract): Promise<Response> {
|
|
176
177
|
const action = match.action
|
|
177
178
|
|
|
178
179
|
let result: any
|
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
|
}
|
|
@@ -226,7 +228,7 @@ export class MantiqRequest implements MantiqRequestContract {
|
|
|
226
228
|
|
|
227
229
|
try {
|
|
228
230
|
if (contentType.includes('application/json')) {
|
|
229
|
-
this.parsedBody = await this.bunRequest.clone().json()
|
|
231
|
+
this.parsedBody = await this.bunRequest.clone().json() as Record<string, any>
|
|
230
232
|
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
231
233
|
const text = await this.bunRequest.clone().text()
|
|
232
234
|
this.parsedBody = Object.fromEntries(new URLSearchParams(text).entries())
|
package/src/index.ts
CHANGED
|
@@ -53,9 +53,14 @@ 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, getDefaultRateLimiter, setDefaultRateLimiter } 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'
|
|
62
|
+
export { Discoverer } from './discovery/Discoverer.ts'
|
|
63
|
+
export type { DiscoveryManifest } from './discovery/Discoverer.ts'
|
|
59
64
|
|
|
60
65
|
// ── Encryption ────────────────────────────────────────────────────────────────
|
|
61
66
|
export { AesEncrypter } from './encryption/Encrypter.ts'
|
|
@@ -48,7 +48,7 @@ export interface MacroableInstance {
|
|
|
48
48
|
*/
|
|
49
49
|
export function Macroable<T extends Constructor>(Base: T): T & MacroableStatic {
|
|
50
50
|
class MacroableClass extends (Base as Constructor) {
|
|
51
|
-
|
|
51
|
+
static _macros = new Map<string, Function>()
|
|
52
52
|
|
|
53
53
|
static macro(name: string, fn: Function): void {
|
|
54
54
|
this._ensureOwnMacros()
|
|
@@ -9,6 +9,7 @@ import { TrimStringsMiddleware } from '../middleware/TrimStrings.ts'
|
|
|
9
9
|
import { StartSession } from '../middleware/StartSession.ts'
|
|
10
10
|
import { EncryptCookies } from '../middleware/EncryptCookies.ts'
|
|
11
11
|
import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
|
|
12
|
+
import { ThrottleRequests } from '../rateLimit/ThrottleRequests.ts'
|
|
12
13
|
import { ROUTER } from '../helpers/route.ts'
|
|
13
14
|
import { ENCRYPTER } from '../helpers/encrypt.ts'
|
|
14
15
|
import { AesEncrypter } from '../encryption/Encrypter.ts'
|
|
@@ -77,6 +78,9 @@ export class CoreServiceProvider extends ServiceProvider {
|
|
|
77
78
|
this.app.bind(EncryptCookies, (c) => new EncryptCookies(c.make<AesEncrypter>(ENCRYPTER)))
|
|
78
79
|
this.app.bind(VerifyCsrfToken, (c) => new VerifyCsrfToken(c.make<AesEncrypter>(ENCRYPTER)))
|
|
79
80
|
|
|
81
|
+
// Rate limiting — zero-config, uses shared in-memory store
|
|
82
|
+
this.app.singleton(ThrottleRequests, () => new ThrottleRequests())
|
|
83
|
+
|
|
80
84
|
// HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
|
|
81
85
|
this.app.singleton(HttpKernel, (c) => {
|
|
82
86
|
const router = c.make(RouterImpl)
|
|
@@ -93,5 +97,9 @@ export class CoreServiceProvider extends ServiceProvider {
|
|
|
93
97
|
const encrypter = await AesEncrypter.fromAppKey(appKey)
|
|
94
98
|
this.app.instance(ENCRYPTER, encrypter)
|
|
95
99
|
}
|
|
100
|
+
|
|
101
|
+
// ── Auto-register middleware aliases on HttpKernel ─────────────────────
|
|
102
|
+
const kernel = this.app.make(HttpKernel)
|
|
103
|
+
kernel.registerMiddleware('throttle', ThrottleRequests)
|
|
96
104
|
}
|
|
97
105
|
}
|
|
@@ -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,154 @@
|
|
|
1
|
+
import type { MantiqRequest } from '../contracts/Request.ts'
|
|
2
|
+
import type { Middleware } from '../contracts/Middleware.ts'
|
|
3
|
+
import { HttpError } from '../errors/HttpError.ts'
|
|
4
|
+
import { RateLimiter } from './RateLimiter.ts'
|
|
5
|
+
import type { RateLimitConfig } from './RateLimiter.ts'
|
|
6
|
+
|
|
7
|
+
/** Shared default RateLimiter instance. */
|
|
8
|
+
let _defaultLimiter: RateLimiter | null = null
|
|
9
|
+
export function getDefaultRateLimiter(): RateLimiter {
|
|
10
|
+
if (!_defaultLimiter) _defaultLimiter = new RateLimiter()
|
|
11
|
+
return _defaultLimiter
|
|
12
|
+
}
|
|
13
|
+
export function setDefaultRateLimiter(limiter: RateLimiter): void {
|
|
14
|
+
_defaultLimiter = limiter
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Middleware that throttles requests using the RateLimiter.
|
|
19
|
+
*
|
|
20
|
+
* Usage with named limiter:
|
|
21
|
+
* router.get('/api/data', handler).middleware('throttle:api')
|
|
22
|
+
*
|
|
23
|
+
* Usage with inline limits:
|
|
24
|
+
* router.get('/api/data', handler).middleware('throttle:60,1')
|
|
25
|
+
* // 60 requests per 1 minute, keyed by IP
|
|
26
|
+
*
|
|
27
|
+
* Response headers:
|
|
28
|
+
* X-RateLimit-Limit: 60
|
|
29
|
+
* X-RateLimit-Remaining: 45
|
|
30
|
+
* Retry-After: 30 (only when rate limited)
|
|
31
|
+
*/
|
|
32
|
+
export class ThrottleRequests implements Middleware {
|
|
33
|
+
private params: string[] = []
|
|
34
|
+
private rateLimiter: RateLimiter = getDefaultRateLimiter()
|
|
35
|
+
|
|
36
|
+
constructor() {}
|
|
37
|
+
|
|
38
|
+
/** Use a custom RateLimiter instead of the default shared instance. */
|
|
39
|
+
useRateLimiter(limiter: RateLimiter): this {
|
|
40
|
+
this.rateLimiter = limiter
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setParameters(params: string[]): void {
|
|
45
|
+
this.params = params
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async handle(request: MantiqRequest, next: () => Promise<Response>): Promise<Response> {
|
|
49
|
+
const configs = this.resolveConfigs(request)
|
|
50
|
+
|
|
51
|
+
// Check all limits before proceeding
|
|
52
|
+
for (const config of configs) {
|
|
53
|
+
const fullKey = `rate_limit:${config.key}`
|
|
54
|
+
|
|
55
|
+
if (await this.rateLimiter.tooManyAttempts(fullKey, config.maxAttempts)) {
|
|
56
|
+
const retryAfter = await this.rateLimiter.availableIn(fullKey)
|
|
57
|
+
return this.buildTooManyResponse(request, config, retryAfter)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Record hits
|
|
62
|
+
for (const config of configs) {
|
|
63
|
+
const fullKey = `rate_limit:${config.key}`
|
|
64
|
+
await this.rateLimiter.hit(fullKey, config.decayMinutes * 60)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Process request
|
|
68
|
+
const response = await next()
|
|
69
|
+
|
|
70
|
+
// Add rate limit headers (use the most restrictive limit)
|
|
71
|
+
return this.addHeaders(response, configs)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private resolveConfigs(request: MantiqRequest): RateLimitConfig[] {
|
|
75
|
+
if (this.params.length === 0) {
|
|
76
|
+
// Default: 60 per minute by IP
|
|
77
|
+
return [{ key: request.ip(), maxAttempts: 60, decayMinutes: 1 }]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const first = this.params[0]!
|
|
81
|
+
|
|
82
|
+
// Check if it's a named limiter
|
|
83
|
+
const resolver = this.rateLimiter.limiter(first)
|
|
84
|
+
if (resolver) {
|
|
85
|
+
const result = resolver(request)
|
|
86
|
+
return Array.isArray(result) ? result : [result]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Inline: throttle:maxAttempts,decayMinutes
|
|
90
|
+
const maxAttempts = parseInt(first, 10) || 60
|
|
91
|
+
const decayMinutes = parseInt(this.params[1] ?? '1', 10) || 1
|
|
92
|
+
|
|
93
|
+
return [{
|
|
94
|
+
key: request.ip(),
|
|
95
|
+
maxAttempts,
|
|
96
|
+
decayMinutes,
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private buildTooManyResponse(
|
|
101
|
+
request: MantiqRequest,
|
|
102
|
+
config: RateLimitConfig,
|
|
103
|
+
retryAfter: number,
|
|
104
|
+
): Response {
|
|
105
|
+
if (config.responseCallback) {
|
|
106
|
+
const headers: Record<string, string> = {
|
|
107
|
+
'Retry-After': String(retryAfter),
|
|
108
|
+
'X-RateLimit-Limit': String(config.maxAttempts),
|
|
109
|
+
'X-RateLimit-Remaining': '0',
|
|
110
|
+
}
|
|
111
|
+
const custom = config.responseCallback(request, headers)
|
|
112
|
+
if (custom) return custom
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const body = request.expectsJson()
|
|
116
|
+
? JSON.stringify({ message: 'Too Many Requests', retry_after: retryAfter })
|
|
117
|
+
: 'Too Many Requests'
|
|
118
|
+
|
|
119
|
+
return new Response(body, {
|
|
120
|
+
status: 429,
|
|
121
|
+
headers: {
|
|
122
|
+
'Content-Type': request.expectsJson() ? 'application/json' : 'text/plain',
|
|
123
|
+
'Retry-After': String(retryAfter),
|
|
124
|
+
'X-RateLimit-Limit': String(config.maxAttempts),
|
|
125
|
+
'X-RateLimit-Remaining': '0',
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async addHeaders(response: Response, configs: RateLimitConfig[]): Promise<Response> {
|
|
131
|
+
// Use the most restrictive limit for headers
|
|
132
|
+
let minRemaining = Infinity
|
|
133
|
+
let limit = 0
|
|
134
|
+
|
|
135
|
+
for (const config of configs) {
|
|
136
|
+
const fullKey = `rate_limit:${config.key}`
|
|
137
|
+
const remaining = await this.rateLimiter.remaining(fullKey, config.maxAttempts)
|
|
138
|
+
if (remaining < minRemaining) {
|
|
139
|
+
minRemaining = remaining
|
|
140
|
+
limit = config.maxAttempts
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const headers = new Headers(response.headers)
|
|
145
|
+
headers.set('X-RateLimit-Limit', String(limit))
|
|
146
|
+
headers.set('X-RateLimit-Remaining', String(minRemaining))
|
|
147
|
+
|
|
148
|
+
return new Response(response.body, {
|
|
149
|
+
status: response.status,
|
|
150
|
+
statusText: response.statusText,
|
|
151
|
+
headers,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/types.d.ts
ADDED