@stacksjs/bun-router 0.0.15 → 0.0.17

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.
Files changed (83) hide show
  1. package/dist/auth.d.ts +22 -8
  2. package/dist/chunk-1ahs68ys.js +18 -0
  3. package/dist/{chunk-j0e7z7hd.js → chunk-cgptvjdf.js} +153 -75
  4. package/dist/chunk-g3ybefhg.js +1923 -0
  5. package/dist/cli.js +13 -11
  6. package/dist/container/container.d.ts +1 -0
  7. package/dist/container/contextual-binding.d.ts +0 -1
  8. package/dist/container/decorators.d.ts +2 -5
  9. package/dist/container/index.d.ts +16 -0
  10. package/dist/container/index.js +322 -0
  11. package/dist/file-serving/static-files.d.ts +16 -0
  12. package/dist/index.d.ts +17 -1
  13. package/dist/index.js +1086 -1270
  14. package/dist/middleware/cors.d.ts +9 -0
  15. package/dist/middleware/csrf.d.ts +7 -0
  16. package/dist/middleware/ddos_protection.d.ts +16 -1
  17. package/dist/middleware/rate_limit.d.ts +7 -1
  18. package/dist/middleware/request_tracer.d.ts +15 -9
  19. package/dist/middleware/response_cache.d.ts +2 -1
  20. package/dist/request/context.d.ts +12 -1
  21. package/dist/request/macros.d.ts +40 -2
  22. package/dist/response/macros.d.ts +3 -3
  23. package/dist/router/fluent-routing.d.ts +50 -13
  24. package/dist/router/handler-resolver.d.ts +8 -0
  25. package/dist/router/index.d.ts +2 -0
  26. package/dist/router/route-compiler.d.ts +8 -10
  27. package/dist/router/route-trie.d.ts +7 -1
  28. package/dist/router/router.d.ts +13 -2
  29. package/dist/router/validation-integration.d.ts +10 -4
  30. package/dist/types/middleware-types.d.ts +1 -1
  31. package/dist/types/route-inference.d.ts +9 -2
  32. package/dist/types.d.ts +62 -9
  33. package/dist/utils.d.ts +1 -1
  34. package/package.json +11 -1
  35. package/src/auth.ts +32 -12
  36. package/src/container/container.ts +58 -12
  37. package/src/container/contextual-binding.ts +4 -1
  38. package/src/container/decorators.ts +16 -7
  39. package/src/container/index.ts +49 -0
  40. package/src/errors/circuit-breaker.ts +3 -2
  41. package/src/errors/graceful-degradation.ts +2 -0
  42. package/src/file-serving/static-files.ts +144 -7
  43. package/src/index.ts +53 -1
  44. package/src/middleware/cors.ts +76 -8
  45. package/src/middleware/csrf.ts +80 -22
  46. package/src/middleware/ddos_protection.ts +27 -3
  47. package/src/middleware/input_validation.ts +10 -6
  48. package/src/middleware/performance_alerting.ts +2 -0
  49. package/src/middleware/performance_monitor.ts +2 -0
  50. package/src/middleware/rate_limit.ts +7 -1
  51. package/src/middleware/request_signing.ts +2 -0
  52. package/src/middleware/request_tracer.ts +34 -17
  53. package/src/middleware/response_cache.ts +48 -13
  54. package/src/middleware/session.ts +3 -3
  55. package/src/observability/metrics.ts +1 -1
  56. package/src/request/context.ts +50 -3
  57. package/src/request/enhanced-request.ts +20 -1
  58. package/src/request/macros.ts +183 -49
  59. package/src/response/macros.ts +3 -3
  60. package/src/router/file-based-routing.ts +17 -10
  61. package/src/router/fluent-routing.ts +117 -48
  62. package/src/router/group-organization.ts +17 -1
  63. package/src/router/handler-resolver.ts +36 -0
  64. package/src/router/http-methods.ts +33 -3
  65. package/src/router/index.ts +6 -0
  66. package/src/router/middleware.ts +3 -0
  67. package/src/router/optimized-route-matching.ts +58 -9
  68. package/src/router/route-compiler.ts +62 -73
  69. package/src/router/route-matching.ts +19 -0
  70. package/src/router/route-trie.ts +34 -48
  71. package/src/router/router.ts +72 -60
  72. package/src/router/server.ts +361 -139
  73. package/src/router/validation-integration.ts +11 -5
  74. package/src/testing/auth-testing.ts +6 -4
  75. package/src/types/middleware-types.ts +7 -4
  76. package/src/types/route-inference.ts +29 -14
  77. package/src/types.ts +63 -7
  78. package/src/url.ts +7 -2
  79. package/src/utils.ts +107 -39
  80. package/src/validation/validator.ts +7 -4
  81. package/src/websocket/clustering.ts +8 -1
  82. package/dist/router/fluent-router.d.ts +0 -315
  83. package/src/router/fluent-router.ts +0 -927
@@ -18,6 +18,12 @@ export interface StaticFileConfig {
18
18
  compressionThreshold?: number
19
19
  cacheControl?: string
20
20
  dotfiles?: 'allow' | 'deny' | 'ignore'
21
+ /**
22
+ * Files larger than this (bytes) are streamed straight from disk via
23
+ * `Bun.file()` instead of being buffered into the in-memory cache.
24
+ * Streamed responses support HTTP Range requests (206). Default: 5 MiB.
25
+ */
26
+ maxCacheFileSize?: number
21
27
  }
22
28
 
23
29
  export interface FileCache {
@@ -62,6 +68,7 @@ export class StaticFileServer {
62
68
  compressionThreshold: config.compressionThreshold ?? 1024,
63
69
  cacheControl: config.cacheControl ?? '',
64
70
  dotfiles: config.dotfiles ?? 'ignore',
71
+ maxCacheFileSize: config.maxCacheFileSize ?? 5 * 1024 * 1024, // 5 MiB
65
72
  ...config,
66
73
  }
67
74
  }
@@ -71,10 +78,19 @@ export class StaticFileServer {
71
78
  */
72
79
  async serve(request: Request): Promise<Response | null> {
73
80
  const url = new URL(request.url)
74
- const pathname = decodeURIComponent(url.pathname)
75
81
 
76
- // Security: prevent directory traversal
77
- if (pathname.includes('..') || pathname.includes('\0')) {
82
+ // Malformed percent-encoding must not crash the server with a 500
83
+ let pathname: string
84
+ try {
85
+ pathname = decodeURIComponent(url.pathname)
86
+ }
87
+ catch {
88
+ return new Response('Bad Request', { status: 400 })
89
+ }
90
+
91
+ // Security: prevent directory traversal (the check runs after
92
+ // decoding, so encoded forms like %2e%2e are covered too)
93
+ if (pathname.includes('..') || pathname.includes('\0') || pathname.includes('\\')) {
78
94
  return new Response('Forbidden', { status: 403 })
79
95
  }
80
96
 
@@ -122,6 +138,12 @@ export class StaticFileServer {
122
138
  return this.serveCachedFile(request, cached, requestPath, startTime)
123
139
  }
124
140
 
141
+ // Large files are streamed straight from disk (with Range support)
142
+ // instead of buffered into the in-memory cache
143
+ if (size > this.config.maxCacheFileSize) {
144
+ return this.serveStreamedFile(request, filePath, requestPath, lastModified, startTime)
145
+ }
146
+
125
147
  // Read and cache file
126
148
  const content = await file.arrayBuffer()
127
149
  const mimeType = file.type || this.getMimeType(filePath)
@@ -226,6 +248,75 @@ export class StaticFileServer {
226
248
  return new Response(content, { headers })
227
249
  }
228
250
 
251
+ /**
252
+ * Stream a file from disk without buffering it into memory.
253
+ * Supports single-range HTTP Range requests (RFC 9110 §14).
254
+ */
255
+ private serveStreamedFile(
256
+ request: Request,
257
+ filePath: string,
258
+ requestPath: string,
259
+ lastModified: string,
260
+ startTime: number,
261
+ ): Response {
262
+ const file = Bun.file(filePath)
263
+ const size = file.size
264
+ const mimeType = file.type || this.getMimeType(filePath)
265
+
266
+ const headers = new Headers()
267
+ headers.set('Content-Type', mimeType)
268
+ headers.set('Accept-Ranges', 'bytes')
269
+
270
+ if (this.config.lastModified) {
271
+ headers.set('Last-Modified', lastModified)
272
+ if (request.headers.get('if-modified-since') === lastModified) {
273
+ this.updateStats(requestPath, 0, startTime, true)
274
+ return new Response(null, { status: 304, headers })
275
+ }
276
+ }
277
+
278
+ if (this.config.cacheControl) {
279
+ headers.set('Cache-Control', this.config.cacheControl)
280
+ }
281
+ else {
282
+ headers.set('Cache-Control', this.config.immutable
283
+ ? `public, max-age=${this.config.maxAge}, immutable`
284
+ : `public, max-age=${this.config.maxAge}`)
285
+ }
286
+
287
+ // Single-range requests: "bytes=start-end", "bytes=start-", "bytes=-suffix"
288
+ const rangeHeader = request.headers.get('range')
289
+ const rangeMatch = rangeHeader?.match(/^bytes=(\d*)-(\d*)$/)
290
+ if (rangeMatch && (rangeMatch[1] !== '' || rangeMatch[2] !== '')) {
291
+ let start: number
292
+ let end: number
293
+ if (rangeMatch[1] === '') {
294
+ // Suffix range: last N bytes
295
+ const suffixLength = Number(rangeMatch[2])
296
+ start = Math.max(0, size - suffixLength)
297
+ end = size - 1
298
+ }
299
+ else {
300
+ start = Number(rangeMatch[1])
301
+ end = rangeMatch[2] === '' ? size - 1 : Math.min(Number(rangeMatch[2]), size - 1)
302
+ }
303
+
304
+ if (start > end || start >= size) {
305
+ headers.set('Content-Range', `bytes */${size}`)
306
+ return new Response(null, { status: 416, headers })
307
+ }
308
+
309
+ headers.set('Content-Range', `bytes ${start}-${end}/${size}`)
310
+ headers.set('Content-Length', String(end - start + 1))
311
+ this.updateStats(requestPath, end - start + 1, startTime, false)
312
+ return new Response(file.slice(start, end + 1), { status: 206, headers })
313
+ }
314
+
315
+ headers.set('Content-Length', String(size))
316
+ this.updateStats(requestPath, size, startTime, false)
317
+ return new Response(file, { headers })
318
+ }
319
+
229
320
  /**
230
321
  * Resolve file path, handling index files and extensions
231
322
  */
@@ -380,6 +471,15 @@ export class StaticFileServer {
380
471
  this.compressionCache.clear()
381
472
  }
382
473
 
474
+ /**
475
+ * Invalidate a single cached file (e.g. from a file watcher), leaving
476
+ * the rest of the cache warm.
477
+ */
478
+ invalidateFile(filePath: string): void {
479
+ this.cache.delete(filePath)
480
+ this.compressionCache.delete(filePath)
481
+ }
482
+
383
483
  /**
384
484
  * Get cache size
385
485
  */
@@ -567,15 +667,52 @@ export const FileServingUtils = {
567
667
  */
568
668
  createFileWatcher: async (server: StaticFileServer, watchPath: string): Promise<{ close: () => void }> => {
569
669
  const fs = await import('node:fs')
570
- const watcher = fs.watch(watchPath, { recursive: true }, (eventType: string, filename: string | null) => {
571
- if (eventType === 'change' || eventType === 'rename') {
670
+ const path = await import('node:path')
671
+
672
+ // Changes are debounced and invalidated per-file: a burst of writes
673
+ // (build output, git checkout) used to flush the entire cache once
674
+ // per event. Events without a filename still fall back to a full clear.
675
+ const pendingFiles = new Set<string>()
676
+ let fullClear = false
677
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
678
+
679
+ const flush = () => {
680
+ debounceTimer = null
681
+ if (fullClear) {
572
682
  server.clearCache()
573
- console.warn(`File changed: ${filename || 'unknown'}, cache cleared`)
574
683
  }
684
+ else {
685
+ for (const file of pendingFiles) {
686
+ server.invalidateFile(file)
687
+ }
688
+ }
689
+ pendingFiles.clear()
690
+ fullClear = false
691
+ }
692
+
693
+ const watcher = fs.watch(watchPath, { recursive: true }, (eventType: string, filename: string | null) => {
694
+ if (eventType !== 'change' && eventType !== 'rename') {
695
+ return
696
+ }
697
+ if (filename) {
698
+ pendingFiles.add(path.join(watchPath, filename))
699
+ }
700
+ else {
701
+ fullClear = true
702
+ }
703
+ if (debounceTimer) {
704
+ clearTimeout(debounceTimer)
705
+ }
706
+ debounceTimer = setTimeout(flush, 100)
575
707
  })
576
708
 
577
709
  return {
578
- close: () => watcher.close(),
710
+ close: () => {
711
+ if (debounceTimer) {
712
+ clearTimeout(debounceTimer)
713
+ }
714
+ watcher.close()
715
+ },
579
716
  }
580
717
  },
581
718
  }
package/src/index.ts CHANGED
@@ -1,14 +1,66 @@
1
+ /**
2
+ * @stacksjs/bun-router — a fast, type-safe router for Bun.
3
+ *
4
+ * Main entry point. The most commonly used exports:
5
+ * - `Router` — the primary router class (from `./router`)
6
+ * - `FluentRouter` / `router` — chainable routing API
7
+ * - `Auth`, `JWT`, `ApiKeyManager`, `OAuth2Helper` — auth helpers
8
+ * - `Container`, `createContainer` — dependency-injection container
9
+ * - `url()` / `registerNamedRoute()` — named-route URL generation
10
+ * - `Errors` — namespaced error/exception types
11
+ */
12
+
13
+ // Authentication (JWT, API keys, OAuth2)
1
14
  export { default as Auth } from './auth'
2
15
  export * from './auth'
16
+
17
+ // Runtime configuration
3
18
  export * from './config'
19
+
20
+ // Dependency-injection container. Exported by name — the decorator module
21
+ // (`Get`, `Post`, `Controller`, …) would collide with router exports if
22
+ // star-exported; import those from `@stacksjs/bun-router/container/decorators`.
23
+ export {
24
+ BindingBuilder,
25
+ Container,
26
+ createContainer,
27
+ getContainer,
28
+ setContainer,
29
+ } from './container/container'
30
+ export type { Binding, BindingScope, ContainerOptions, ResolutionContext, Token } from './container/container'
31
+ export { ContextualContainer, EnvironmentManager } from './container/contextual-binding'
32
+ export { DefaultServiceProviderManager } from './container/service-provider'
33
+
34
+ // Error types and helpers (namespaced — many generic names)
4
35
  export * as Errors from './errors'
36
+
37
+ // Built-in middleware (CORS, CSRF, sessions, rate limiting, …)
5
38
  export * from './middleware'
6
- export { getCurrentRequest, request, runWithRequest, setCurrentRequest } from './request/context'
39
+
40
+ // Request context (AsyncLocalStorage-backed current request; lazy — see enableRequestContext)
41
+ export { enableRequestContext, getCurrentRequest, request, runWithRequest, setCurrentRequest } from './request/context'
42
+
43
+ // Session stores and manager
7
44
  export * from './session'
45
+
46
+ // Response factory helpers
8
47
  export * from './response/response-factory'
48
+
49
+ // The Router class and routing features (fluent API, throttling, caching, …)
9
50
  export * from './router'
10
51
  export { MiddlewareGroupRegistry, middlewareGroups } from './router/middleware-groups'
52
+
53
+ // Testing utilities
11
54
  export * from './testing'
55
+
56
+ // Public types
12
57
  export * from './types'
58
+
59
+ // Validation (Laravel-style rules, custom rules, middleware)
60
+ export * from './validation/validator'
61
+
62
+ // Named-route URL generation
13
63
  export { clearNamedRoutes, getNamedRoutePath, getNamedRoutes, registerNamedRoute, url, type UrlOptions } from './url'
64
+
65
+ // Path/template utilities
14
66
  export * from './utils'
@@ -1,32 +1,100 @@
1
1
  import type { EnhancedRequest, NextFunction } from '../types'
2
2
  import { config } from '../config'
3
3
 
4
- // Default CORS headers
4
+ // Default CORS headers.
5
+ //
6
+ // IMPORTANT: when credentials are NOT enabled it is safe to allow any origin
7
+ // with `*`. The default config below deliberately keeps credentials disabled.
8
+ // The `*` + `Allow-Credentials: true` combination is forbidden by the CORS
9
+ // spec and is dangerous (it lets any site read credentialed responses), so the
10
+ // logic in `getCorsHeaders` guarantees that combination can never be emitted.
5
11
  const DEFAULT_CORS_HEADERS: Record<string, string> = {
6
12
  'Access-Control-Allow-Origin': '*',
7
13
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
8
14
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With, Accept, Origin',
9
15
  'Access-Control-Max-Age': '86400',
10
- 'Access-Control-Allow-Credentials': 'true',
11
16
  }
12
17
 
13
18
  export default class Cors {
14
- private getCorsHeaders(): Record<string, string> {
15
- const corsConfig = (config.server as any)?.cors
19
+ /**
20
+ * Resolve the `Access-Control-Allow-Origin` value for a request.
21
+ *
22
+ * Supports a string origin (`'*'` or an explicit origin) or an array of
23
+ * allowed origins. For an allowlist, the request `Origin` is reflected back
24
+ * only when it is present in the list. When credentials are enabled a literal
25
+ * `*` is never returned (it would be both spec-violating and dangerous).
26
+ */
27
+ private resolveAllowOrigin(
28
+ configuredOrigin: string | string[] | undefined,
29
+ credentials: boolean,
30
+ requestOrigin: string | null,
31
+ ): { value: string | null, vary: boolean } {
32
+ // Explicit allowlist: only reflect the request origin when it matches.
33
+ if (Array.isArray(configuredOrigin)) {
34
+ if (requestOrigin && configuredOrigin.includes(requestOrigin)) {
35
+ return { value: requestOrigin, vary: true }
36
+ }
37
+ // Origin not allowed: do not emit an Allow-Origin header at all.
38
+ return { value: null, vary: true }
39
+ }
40
+
41
+ const origin = configuredOrigin || '*'
42
+
43
+ if (origin === '*') {
44
+ // The `*` wildcard cannot be combined with credentials. When credentials
45
+ // are enabled, reflect the specific request origin instead (which is the
46
+ // only spec-compliant way to support credentialed cross-origin requests).
47
+ if (credentials) {
48
+ if (requestOrigin) {
49
+ return { value: requestOrigin, vary: true }
50
+ }
51
+ // No request origin to reflect — omit the header rather than send `*`.
52
+ return { value: null, vary: true }
53
+ }
54
+ return { value: '*', vary: false }
55
+ }
56
+
57
+ // A single explicit origin was configured.
58
+ return { value: origin, vary: false }
59
+ }
60
+
61
+ private getCorsHeaders(req: EnhancedRequest): Record<string, string> {
62
+ const corsConfig = config.server?.cors
63
+ const requestOrigin = req.headers.get('origin')
64
+
16
65
  if (corsConfig && corsConfig.enabled) {
17
- return {
18
- 'Access-Control-Allow-Origin': corsConfig.origin || '*',
66
+ const credentials = Boolean(corsConfig.credentials)
67
+ const { value: allowOrigin, vary } = this.resolveAllowOrigin(corsConfig.origin, credentials, requestOrigin)
68
+
69
+ const headers: Record<string, string> = {
19
70
  'Access-Control-Allow-Methods': Array.isArray(corsConfig.methods) ? corsConfig.methods.join(', ') : DEFAULT_CORS_HEADERS['Access-Control-Allow-Methods'],
20
71
  'Access-Control-Allow-Headers': Array.isArray(corsConfig.allowedHeaders) ? corsConfig.allowedHeaders.join(', ') : DEFAULT_CORS_HEADERS['Access-Control-Allow-Headers'],
21
72
  'Access-Control-Max-Age': String(corsConfig.maxAge || 86400),
22
- 'Access-Control-Allow-Credentials': String(corsConfig.credentials || false),
23
73
  }
74
+
75
+ if (allowOrigin !== null) {
76
+ headers['Access-Control-Allow-Origin'] = allowOrigin
77
+ }
78
+
79
+ // Only advertise credentials when an origin is actually allowed. Never
80
+ // pair credentials with a `*` wildcard.
81
+ if (credentials && allowOrigin !== null && allowOrigin !== '*') {
82
+ headers['Access-Control-Allow-Credentials'] = 'true'
83
+ }
84
+
85
+ if (vary) {
86
+ headers.Vary = 'Origin'
87
+ }
88
+
89
+ return headers
24
90
  }
91
+
92
+ // No CORS config: fall back to the permissive (but credential-free) defaults.
25
93
  return { ...DEFAULT_CORS_HEADERS }
26
94
  }
27
95
 
28
96
  async handle(req: EnhancedRequest, next: NextFunction): Promise<Response> {
29
- const corsHeaders = this.getCorsHeaders()
97
+ const corsHeaders = this.getCorsHeaders(req)
30
98
 
31
99
  // Handle preflight OPTIONS request
32
100
  if (req.method === 'OPTIONS') {
@@ -1,25 +1,27 @@
1
- import type { EnhancedRequest, NextFunction } from '../types'
2
- import { createHash, randomBytes } from 'node:crypto'
1
+ import type { EnhancedRequest, NextFunction, SecurityConfig } from '../types'
2
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
3
3
  import { config } from '../config'
4
4
 
5
+ /** Maximum number of issued tokens kept for verification (oldest evicted first). */
6
+ const MAX_TOKENS = 10_000
7
+
8
+ /** Token lifetime in milliseconds. */
9
+ const TOKEN_TTL_MS = 2 * 60 * 60 * 1000 // 2 hours
10
+
5
11
  export default class Csrf {
6
- private static tokens = new Map<string, string>()
12
+ // Issued tokens with their issue timestamp. Bounded (LRU by insertion
13
+ // order) and TTL'd — the previous implementation grew without limit.
14
+ private static tokens = new Map<string, number>()
7
15
 
8
16
  async handle(req: EnhancedRequest, next: NextFunction): Promise<Response> {
9
- const csrfConfig = config.server?.security?.csrf || {} as any
10
-
11
- // Skip CSRF protection for safe methods
12
- if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
13
- const response = await next()
14
- return response || new Response('Not Found', { status: 404 })
15
- }
17
+ const csrfConfig: Partial<SecurityConfig['csrf']> = config.server?.security?.csrf ?? {}
16
18
 
17
- // Skip for methods that don't modify state
18
19
  const method = req.method.toUpperCase()
19
20
  const ignoredMethods = ['GET', 'HEAD', 'OPTIONS', ...(csrfConfig?.ignoreMethods || [])]
20
21
 
21
22
  if (ignoredMethods.includes(method)) {
22
- // For safe methods, generate a new token for the response
23
+ // Safe methods don't need verification, but the response carries a
24
+ // fresh token cookie so the client has something to submit later
23
25
  const token = this.generateToken(csrfConfig?.secret || 'csrf-secret')
24
26
 
25
27
  // Continue to next middleware
@@ -50,8 +52,8 @@ export default class Csrf {
50
52
  const newHeaders = new Headers(response.headers)
51
53
  newHeaders.append('Set-Cookie', cookieOptions.join('; '))
52
54
 
53
- // Store token for verification
54
- Csrf.tokens.set(token, token)
55
+ // Store token for verification (bounded + TTL)
56
+ Csrf.storeToken(token)
55
57
 
56
58
  return new Response(response.body, {
57
59
  status: response.status,
@@ -60,19 +62,33 @@ export default class Csrf {
60
62
  })
61
63
  }
62
64
 
65
+ // Bearer-authed requests can't be CSRF'd — the token doesn't
66
+ // ride on cookies, so a hostile origin can't trick a browser into
67
+ // sending it. Mirrors Laravel Sanctum / Django REST framework /
68
+ // express-csurf semantics. Filed against stacksjs/stacks#1922.
69
+ const authHeader = req.headers.get('authorization') ?? req.headers.get('Authorization')
70
+ if (authHeader && /^Bearer\s+\S/i.test(authHeader)) {
71
+ const response = await next()
72
+ return response || new Response('Not Found', { status: 404 })
73
+ }
74
+
63
75
  // For unsafe methods, verify the CSRF token
64
76
  // Check for token in headers or in the request body
65
- const token = req.headers.get('X-CSRF-TOKEN')
77
+ const rawToken = req.headers.get('X-CSRF-TOKEN')
66
78
  || req.jsonBody?.csrf_token
67
79
  || req.jsonBody?._token
68
80
  || null
81
+ const token = typeof rawToken === 'string' ? rawToken : null
69
82
 
70
83
  // Get token from cookies for comparison
71
84
  const cookies = this.parseCookies(req)
72
85
  const cookieToken = cookies[csrfConfig?.cookie?.name || 'csrf-token']
73
86
 
74
- // Verify the token
75
- if (!token || !cookieToken || token !== cookieToken || !Csrf.tokens.has(token)) {
87
+ // Verify the token: double-submit value must match the cookie
88
+ // (constant-time comparison) and must have been issued by us
89
+ if (!token || !cookieToken
90
+ || !Csrf.safeCompare(token, cookieToken)
91
+ || !Csrf.isTokenValid(token)) {
76
92
  return new Response(
77
93
  JSON.stringify({ error: 'CSRF token validation failed' }),
78
94
  {
@@ -87,6 +103,42 @@ export default class Csrf {
87
103
  return response || new Response('Not Found', { status: 404 })
88
104
  }
89
105
 
106
+ /**
107
+ * Constant-time string comparison — a plain `!==` leaks how many leading
108
+ * characters of the token were correct through response timing.
109
+ */
110
+ private static safeCompare(a: string, b: string): boolean {
111
+ const bufA = Buffer.from(a)
112
+ const bufB = Buffer.from(b)
113
+ if (bufA.length !== bufB.length) {
114
+ return false
115
+ }
116
+ return timingSafeEqual(bufA, bufB)
117
+ }
118
+
119
+ private static storeToken(token: string): void {
120
+ if (Csrf.tokens.size >= MAX_TOKENS) {
121
+ // Evict the oldest issued token (Map preserves insertion order)
122
+ const oldest = Csrf.tokens.keys().next().value
123
+ if (oldest !== undefined) {
124
+ Csrf.tokens.delete(oldest)
125
+ }
126
+ }
127
+ Csrf.tokens.set(token, Date.now())
128
+ }
129
+
130
+ private static isTokenValid(token: string): boolean {
131
+ const issuedAt = Csrf.tokens.get(token)
132
+ if (issuedAt === undefined) {
133
+ return false
134
+ }
135
+ if (Date.now() - issuedAt > TOKEN_TTL_MS) {
136
+ Csrf.tokens.delete(token)
137
+ return false
138
+ }
139
+ return true
140
+ }
141
+
90
142
  private generateToken(secret: string): string {
91
143
  const randomString = randomBytes(16).toString('hex')
92
144
  return createHash('sha256')
@@ -99,10 +151,16 @@ export default class Csrf {
99
151
  if (!cookieHeader)
100
152
  return {}
101
153
 
102
- return cookieHeader.split(';').reduce((cookies, cookie) => {
103
- const [name, value] = cookie.trim().split('=')
104
- cookies[name] = value
105
- return cookies
106
- }, {} as Record<string, string>)
154
+ const cookies: Record<string, string> = {}
155
+ for (const cookie of cookieHeader.split(';')) {
156
+ const eqIndex = cookie.indexOf('=')
157
+ if (eqIndex === -1)
158
+ continue
159
+ const name = cookie.slice(0, eqIndex).trim()
160
+ if (!name)
161
+ continue
162
+ cookies[name] = cookie.slice(eqIndex + 1).trim()
163
+ }
164
+ return cookies
107
165
  }
108
166
  }
@@ -15,7 +15,7 @@ export interface DDoSProtectionOptions {
15
15
  skipSuccessfulRequests?: boolean
16
16
  skipFailedRequests?: boolean
17
17
  keyGenerator?: (req: EnhancedRequest) => string
18
- onLimitReached?: (req: EnhancedRequest, rateLimitInfo: any) => Response | Promise<Response>
18
+ onLimitReached?: (req: EnhancedRequest, rateLimitInfo: DDoSRateLimitInfo) => Response | Promise<Response>
19
19
  store?: 'memory' | 'redis'
20
20
  redis?: {
21
21
  url: string
@@ -31,6 +31,14 @@ interface RequestInfo {
31
31
  blockExpires?: number
32
32
  }
33
33
 
34
+ /**
35
+ * Rate-limit details passed to `onLimitReached` when a client is blocked.
36
+ */
37
+ export interface DDoSRateLimitInfo extends RequestInfo {
38
+ /** Seconds until the block lifts (mirrors the Retry-After header) */
39
+ retryAfter: number
40
+ }
41
+
34
42
  export default class DDoSProtection {
35
43
  private options: DDoSProtectionOptions
36
44
  private requestStore: Map<string, RequestInfo> = new Map()
@@ -91,6 +99,8 @@ export default class DDoSProtection {
91
99
  }
92
100
  }
93
101
  }, this.options.windowSize! / 2) // Cleanup every half window
102
+ // Background housekeeping must not keep the process alive
103
+ this.cleanupInterval.unref?.()
94
104
  }
95
105
 
96
106
  private isWhitelisted(ip: string): boolean {
@@ -105,6 +115,20 @@ export default class DDoSProtection {
105
115
  let info = this.requestStore.get(key)
106
116
 
107
117
  if (!info) {
118
+ // Hard cap on tracked clients: a spoofed-IP flood would otherwise
119
+ // grow the store faster than the interval cleanup can drain it.
120
+ // Evicting the oldest entries (Map insertion order) only resets
121
+ // counters for long-idle clients.
122
+ if (this.requestStore.size >= 100_000) {
123
+ let toEvict = Math.ceil(this.requestStore.size / 10)
124
+ for (const oldestKey of this.requestStore.keys()) {
125
+ this.requestStore.delete(oldestKey)
126
+ if (--toEvict <= 0) {
127
+ break
128
+ }
129
+ }
130
+ }
131
+
108
132
  info = {
109
133
  count: 0,
110
134
  firstRequest: now,
@@ -149,7 +173,7 @@ export default class DDoSProtection {
149
173
  info.blockExpires = now + this.options.blockDuration!
150
174
  }
151
175
 
152
- private createBlockedResponse(req: EnhancedRequest, info: RequestInfo): Response {
176
+ private createBlockedResponse(req: EnhancedRequest, info: RequestInfo): Response | Promise<Response> {
153
177
  const retryAfter = Math.ceil((info.blockExpires! - Date.now()) / 1000)
154
178
 
155
179
  const headers = {
@@ -160,7 +184,7 @@ export default class DDoSProtection {
160
184
  }
161
185
 
162
186
  if (this.options.onLimitReached) {
163
- return this.options.onLimitReached(req, { ...info, retryAfter }) as Response
187
+ return this.options.onLimitReached(req, { ...info, retryAfter })
164
188
  }
165
189
 
166
190
  return new Response(JSON.stringify({
@@ -1,6 +1,10 @@
1
1
  import type { EnhancedRequest, NextFunction } from '../types'
2
2
  import { config } from '../config'
3
3
 
4
+ // Hoisted format regexes — previously compiled on every validation call
5
+ const EMAIL_REGEX = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
6
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
7
+
4
8
  export interface ValidationRule {
5
9
  type: 'string' | 'number' | 'boolean' | 'email' | 'url' | 'uuid' | 'date' | 'regex' | 'custom'
6
10
  required?: boolean
@@ -148,8 +152,7 @@ export default class InputValidation {
148
152
  break
149
153
 
150
154
  case 'email': {
151
- const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
152
- if (typeof value === 'string' && !emailRegex.test(value)) {
155
+ if (typeof value === 'string' && !EMAIL_REGEX.test(value)) {
153
156
  errors.push({
154
157
  field: fieldPath,
155
158
  message: 'Must be a valid email address',
@@ -174,8 +177,7 @@ export default class InputValidation {
174
177
  break
175
178
 
176
179
  case 'uuid': {
177
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
178
- if (typeof value === 'string' && !uuidRegex.test(value)) {
180
+ if (typeof value === 'string' && !UUID_REGEX.test(value)) {
179
181
  errors.push({
180
182
  field: fieldPath,
181
183
  message: 'Must be a valid UUID',
@@ -233,8 +235,10 @@ export default class InputValidation {
233
235
  })
234
236
  }
235
237
 
236
- // Pattern validation
237
- if (rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) {
238
+ // Pattern validation — skipped for type 'regex', which already
239
+ // validated the pattern above (running it twice produced duplicate
240
+ // errors for the same field)
241
+ if (rule.type !== 'regex' && rule.pattern && typeof value === 'string' && !rule.pattern.test(value)) {
238
242
  errors.push({
239
243
  field: fieldPath,
240
244
  message: 'Does not match required pattern',
@@ -182,6 +182,8 @@ export default class PerformanceAlerting {
182
182
  this.cleanupOldNotifications()
183
183
  this.autoResolveAlerts()
184
184
  }, 10000) // Check every 10 seconds
185
+ // Background checks must not keep the process alive
186
+ this.checkInterval.unref?.()
185
187
  }
186
188
 
187
189
  private evaluateRules(): void {
@@ -269,6 +269,8 @@ export default class PerformanceMonitor implements Middleware {
269
269
  this.cleanupInterval = setInterval(() => {
270
270
  this.cleanupOldMetrics()
271
271
  }, 60000) // Cleanup every minute
272
+ // Background housekeeping must not keep the process alive
273
+ this.cleanupInterval.unref?.()
272
274
  }
273
275
 
274
276
  private cleanupOldMetrics(): void {