@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/core",
3
- "version": "0.2.1",
3
+ "version": "0.4.0-rc.1",
4
4
  "description": "Service container, router, middleware, HTTP kernel, config, and exception handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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) {
@@ -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
- if (debug) {
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
- if (debug) {
83
- return MantiqResponse.html(renderDevErrorPage(request, err), 500)
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 (request.expectsJson()) {
87
- return MantiqResponse.json(
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)
@@ -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: MantiqRequest): Promise<Response> {
176
+ private async callAction(match: RouteMatch, request: MantiqRequestContract): Promise<Response> {
176
177
  const action = match.action
177
178
 
178
179
  let result: any
@@ -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
- private static _macros = new Map<string, Function>()
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
@@ -0,0 +1,3 @@
1
+ // Optional peer dependencies
2
+ declare module 'ioredis' { const x: any; export = x; export default x; }
3
+ declare module 'memjs' { const x: any; export = x; export default x; }
@@ -1,7 +1,7 @@
1
1
  import type { MantiqRequest } from '../contracts/Request.ts'
2
2
 
3
3
  export interface WebSocketContext {
4
- userId?: string | number
4
+ userId?: string | number | undefined
5
5
  channels: Set<string>
6
6
  metadata: Record<string, any>
7
7
  }