@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
@@ -4,16 +4,29 @@
4
4
  * Advanced TypeScript utilities for inferring route parameter types from URL patterns
5
5
  */
6
6
 
7
- // Base types for route parameter extraction
8
- export type ExtractRouteParams<T extends string> = T extends `${infer _Start}:${infer Param}/${infer Rest}`
9
- ? { [K in Param]: string } & ExtractRouteParams<`/${Rest}`>
10
- : T extends `${infer _Start}:${infer Param}?${infer Rest}`
11
- ? { [K in Param]?: string } & ExtractRouteParams<Rest>
12
- : T extends `${infer _Start}:${infer Param}`
13
- ? { [K in Param]: string }
14
- : T extends `${infer _Start}*${infer Rest}`
15
- ? { '*': string } & ExtractRouteParams<Rest>
16
- : Record<string, never>
7
+ /** Strip an inline constraint from a brace parameter: `id:[0-9]+` → `id` */
8
+ type StripInlineConstraint<T extends string> = T extends `${infer Name}:${string}` ? Name : T
9
+
10
+ // Base types for route parameter extraction.
11
+ //
12
+ // The router's canonical parameter syntax is `{param}` (with `{param?}` for
13
+ // optional and `{param:regex}` for constrained parameters) — that form is
14
+ // handled first. The legacy Express-style `:param` clauses are kept for
15
+ // backward compatibility with code written against the colon syntax.
16
+ export type ExtractRouteParams<T extends string> =
17
+ T extends `${string}{${infer Param}}${infer Rest}`
18
+ ? (Param extends `${infer Inner}?`
19
+ ? { [K in StripInlineConstraint<Inner>]?: string }
20
+ : { [K in StripInlineConstraint<Param>]: string }) & ExtractRouteParams<Rest>
21
+ : T extends `${infer _Start}:${infer Param}/${infer Rest}`
22
+ ? { [K in Param]: string } & ExtractRouteParams<`/${Rest}`>
23
+ : T extends `${infer _Start}:${infer Param}?${infer Rest}`
24
+ ? { [K in Param]?: string } & ExtractRouteParams<Rest>
25
+ : T extends `${infer _Start}:${infer Param}`
26
+ ? { [K in Param]: string }
27
+ : T extends `${infer _Start}*${infer Rest}`
28
+ ? { '*': string } & ExtractRouteParams<Rest>
29
+ : Record<string, never>
17
30
 
18
31
  // Advanced parameter extraction with type constraints
19
32
  export type ExtractTypedParams<T extends string> = T extends `${infer _Start}:${infer Param}<${infer Type}>${infer Rest}`
@@ -214,11 +227,13 @@ export interface RouteGroup<TPrefix extends string = '', TContext = object> {
214
227
  routes: TypedRoute<any, any, any, TContext>[]
215
228
  }
216
229
 
217
- // Middleware type (forward declaration - will be defined in middleware types)
230
+ // Middleware type (forward declaration — the canonical definition lives in
231
+ // middleware-types.ts; this one mirrors its narrow defaults so the two
232
+ // can't drift apart silently)
218
233
  export interface TypedMiddleware<
219
- TRequest = any,
220
- TResponse = any,
221
- TNext = any,
234
+ TRequest = Request,
235
+ TResponse = Response,
236
+ TNext = () => Promise<TResponse>,
222
237
  _TContext = Record<string, never>,
223
238
  > {
224
239
  (request: TRequest, next: TNext): Promise<TResponse> | TResponse
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Server } from 'bun'
2
2
  import type { Router } from './router/router'
3
+ import type { SessionManager } from './session/index'
3
4
  import type { QueryPreservationConfig } from './utils/query-preservation'
4
5
  import type {
5
6
  AuthContext,
@@ -807,6 +808,12 @@ export interface EnhancedRequest extends Request, Omit<RequestMacroMethods, 'ip'
807
808
  * Parsed JSON body (if Content-Type is application/json)
808
809
  */
809
810
  jsonBody?: Record<string, unknown>
811
+ /**
812
+ * Raw unparsed request body cache, populated on first `rawBody()` read (or up
813
+ * front by a framework body parser). Kept so webhook signature checks get the
814
+ * exact bytes the client sent.
815
+ */
816
+ _rawBody?: string
810
817
  /**
811
818
  * Form body data (if Content-Type is multipart/form-data or application/x-www-form-urlencoded)
812
819
  */
@@ -816,9 +823,11 @@ export interface EnhancedRequest extends Request, Omit<RequestMacroMethods, 'ip'
816
823
  */
817
824
  files?: UploadedFile[]
818
825
  /**
819
- * Session data (if session middleware is used)
826
+ * Session for the current request. The built-in session middleware
827
+ * attaches a `SessionManager` (with `get`/`set`/`flash`/… methods);
828
+ * testing utilities may attach plain `SessionData`.
820
829
  */
821
- session?: SessionData
830
+ session?: SessionManager | SessionData
822
831
  /**
823
832
  * User data (if authentication middleware is used)
824
833
  */
@@ -1057,6 +1066,13 @@ export interface Route {
1057
1066
  pattern?: {
1058
1067
  exec: (url: URL) => PatternMatchResult | null
1059
1068
  }
1069
+ /**
1070
+ * Set to `false` (via `router.withoutNativeDispatch()`) to keep this
1071
+ * route on the fetch-handler matcher even when the server runs with
1072
+ * `nativeRoutes: true` — e.g. when it must respect registration-order
1073
+ * precedence against an overlapping pattern.
1074
+ */
1075
+ nativeDispatch?: boolean
1060
1076
  }
1061
1077
 
1062
1078
  /**
@@ -1102,6 +1118,22 @@ export interface ServerOptions<T extends WebSocketData = WebSocketData> extends
1102
1118
  * These bypass the fetch handler entirely for zero-allocation serving (~15% faster).
1103
1119
  */
1104
1120
  static?: Record<string, Response>
1121
+ /**
1122
+ * Opt-in: register compatible routes with `Bun.serve`'s native router
1123
+ * (`routes`), skipping the fetch handler's URL parsing and route
1124
+ * matching for them. Compatible means: standard HTTP method, no
1125
+ * domain scoping, no `where()`/inline constraints, no optional
1126
+ * params, and `{param}`s spanning whole segments. Everything else —
1127
+ * incompatible routes, 404/405 handling, HEAD fallback, the generic
1128
+ * OPTIONS preflight — still flows through the fetch handler with
1129
+ * identical semantics.
1130
+ *
1131
+ * Caveat: Bun matches by specificity (exact > param > wildcard).
1132
+ * Apps that rely on registration order between same-shape overlapping
1133
+ * patterns should leave this off. Routes registered after `serve()`
1134
+ * only join the native table after `reload()`.
1135
+ */
1136
+ nativeRoutes?: boolean
1105
1137
  /**
1106
1138
  * Development mode options for Bun's dev server.
1107
1139
  * When true, enables HMR and development features.
@@ -1273,12 +1305,33 @@ export type MiddlewareWithParams<T extends string = string> =
1273
1305
  export type RouteParam<T extends string> = T extends `{${infer P}}` ? P : never
1274
1306
 
1275
1307
  /**
1276
- * Extract parameters from route path
1308
+ * Strip an inline constraint from a parameter token:
1309
+ * `id:[0-9]+` → `id`, `slug` → `slug`
1310
+ */
1311
+ export type ExtractParamName<T extends string> =
1312
+ T extends `${infer Name}:${string}` ? Name : T
1313
+
1314
+ /**
1315
+ * Extract parameters from a route path into a narrowly-typed object.
1316
+ *
1317
+ * Handles the full parameter grammar:
1318
+ * - `{id}` → `{ id: string }`
1319
+ * - `{id?}` (optional) → `{ id?: string }`
1320
+ * - `{id:[0-9]+}` (inline constraint) → `{ id: string }`
1321
+ * - trailing `*` wildcard → `{ wildcard: string }`
1322
+ *
1323
+ * @example
1324
+ * type P = ExtractRouteParams<'/users/{userId}/posts/{slug?}'>
1325
+ * // ^? { userId: string } & { slug?: string }
1277
1326
  */
1278
1327
  export type ExtractRouteParams<T extends string> =
1279
1328
  T extends `${string}{${infer Param}}${infer Rest}`
1280
- ? { [K in Param]: string } & ExtractRouteParams<Rest>
1281
- : object
1329
+ ? (Param extends `${infer Inner}?`
1330
+ ? { [K in ExtractParamName<Inner>]?: string }
1331
+ : { [K in ExtractParamName<Param>]: string }) & ExtractRouteParams<Rest>
1332
+ : T extends `${string}*${string}`
1333
+ ? { wildcard: string }
1334
+ : object
1282
1335
 
1283
1336
  /**
1284
1337
  * Route method constraints
@@ -1735,8 +1788,8 @@ export interface StreamingOptions {
1735
1788
  /**
1736
1789
  * SSE (Server-Sent Events) data structure
1737
1790
  */
1738
- export interface SSEData {
1739
- data: any
1791
+ export interface SSEData<T = unknown> {
1792
+ data: T
1740
1793
  event?: string
1741
1794
  id?: string
1742
1795
  retry?: number
@@ -1933,6 +1986,9 @@ export interface RequestMacroMethods {
1933
1986
  bearerToken: () => string | null
1934
1987
  basicAuth: () => { username: string, password: string } | null
1935
1988
 
1989
+ /** Raw unparsed request body as a string (cached). Needed for webhook signature verification (Stripe/GitHub/Slack), where a re-serialized jsonBody won't match the HMAC. */
1990
+ rawBody: () => Promise<string>
1991
+
1936
1992
  // Headers
1937
1993
  hasHeader: (name: string) => boolean
1938
1994
  header: (name: string, defaultValue?: string) => string | null
package/src/url.ts CHANGED
@@ -100,8 +100,13 @@ export function url(
100
100
  }
101
101
  }
102
102
 
103
- // Strip any unfilled optional placeholders (e.g. `/posts/{slug?}` → `/posts/`)
104
- resolved = resolved.replace(/\/\{[^}]+\?\}/g, '')
103
+ // Strip any unfilled optional placeholders and tidy the slashes they
104
+ // leave behind (`/posts/{slug?}` → `/posts`, `/a/{v?}/b` → `/a/b`)
105
+ if (resolved.includes('?}')) {
106
+ resolved = resolved.replace(/\{[^}]+\?\}/g, '').replace(/\/{2,}/g, '/')
107
+ if (resolved.length > 1 && resolved.endsWith('/'))
108
+ resolved = resolved.slice(0, -1)
109
+ }
105
110
 
106
111
  // Throw on unfilled required placeholders so the caller sees the bug.
107
112
  const missing = resolved.match(/\{([^}?]+)\}/g)
package/src/utils.ts CHANGED
@@ -83,6 +83,83 @@ export function createPathRegex(pattern: string): RegExp {
83
83
  return new RegExp(`^${regexPattern}$`)
84
84
  }
85
85
 
86
+ /**
87
+ * Per-pattern matching data. A route's pattern never changes, so the
88
+ * segment split, param-name extraction, and optional-param count are derived
89
+ * once and cached — turning `matchPath` from "regex + split on every request"
90
+ * into a tight array walk. Only the incoming `path` is processed per call.
91
+ */
92
+ interface CompiledSegment {
93
+ isParam: boolean
94
+ paramName: string
95
+ isOptional: boolean
96
+ literal: string
97
+ }
98
+
99
+ interface CompiledPattern {
100
+ isWildcard: boolean
101
+ wildcardBase: string
102
+ segments: CompiledSegment[]
103
+ optionalCount: number
104
+ }
105
+
106
+ const PARAM_SEGMENT_RE = /\{([^}:]+)(?::[^}]+)?(\?)?\}/
107
+ const OPTIONAL_SEGMENT_RE = /\{[^}]+\?\}/
108
+
109
+ const compiledPatternCache = new Map<string, CompiledPattern>()
110
+ // Cached compiled constraint regexes; `null` marks an invalid pattern so we
111
+ // don't re-attempt construction on every request.
112
+ const constraintRegexCache = new Map<string, RegExp | null>()
113
+
114
+ function compilePattern(pattern: string): CompiledPattern {
115
+ const cached = compiledPatternCache.get(pattern)
116
+ if (cached)
117
+ return cached
118
+
119
+ const isWildcard = pattern.endsWith('/*')
120
+ const normalizedPattern = normalizePath(pattern)
121
+ let optionalCount = 0
122
+ const segments: CompiledSegment[] = normalizedPattern
123
+ .split('/')
124
+ .filter(Boolean)
125
+ .map((seg) => {
126
+ const isOptional = OPTIONAL_SEGMENT_RE.test(seg)
127
+ if (isOptional)
128
+ optionalCount++
129
+ const paramMatch = seg.match(PARAM_SEGMENT_RE)
130
+ return {
131
+ isParam: !!paramMatch,
132
+ paramName: paramMatch ? paramMatch[1].replace('?', '') : '',
133
+ isOptional,
134
+ literal: seg,
135
+ }
136
+ })
137
+
138
+ const compiled: CompiledPattern = {
139
+ isWildcard,
140
+ wildcardBase: isWildcard ? pattern.slice(0, -2) : '',
141
+ segments,
142
+ optionalCount,
143
+ }
144
+ compiledPatternCache.set(pattern, compiled)
145
+ return compiled
146
+ }
147
+
148
+ function getConstraintRegex(constraint: string): RegExp | null {
149
+ const cached = constraintRegexCache.get(constraint)
150
+ if (cached !== undefined)
151
+ return cached
152
+ let regex: RegExp | null
153
+ try {
154
+ regex = new RegExp(`^${constraint}$`)
155
+ }
156
+ catch {
157
+ regex = null
158
+ }
159
+ constraintRegexCache.set(constraint, regex)
160
+ return regex
161
+ }
162
+
86
163
  /**
87
164
  * Matches a path against a pattern and extracts parameters
88
165
  * @param pattern The path pattern (e.g., '/users/{id}')
@@ -97,71 +174,59 @@ export function matchPath(
97
174
  params: Record<string, string>,
98
175
  constraints?: Record<string, string>,
99
176
  ): boolean {
100
- // Both paths should be normalized
101
- const normalizedPattern = normalizePath(pattern)
102
- const normalizedPath = normalizePath(path)
177
+ const compiled = compilePattern(pattern)
103
178
 
104
- // Special case for wildcard patterns
105
- if (pattern.endsWith('/*')) {
106
- const basePattern = pattern.slice(0, -2)
107
- if (path.startsWith(basePattern)) {
108
- return true
109
- }
179
+ // Special case for wildcard patterns. Matches the original semantics:
180
+ // a prefix hit short-circuits to true, otherwise we fall through to the
181
+ // per-segment match below.
182
+ if (compiled.isWildcard && path.startsWith(compiled.wildcardBase)) {
183
+ return true
110
184
  }
111
185
 
112
- // Handle patterns with parameters
113
- const patternSegments = normalizedPattern.split('/').filter(Boolean)
114
- const pathSegments = normalizedPath.split('/').filter(Boolean)
186
+ const patternSegments = compiled.segments
187
+ const pathSegments = normalizePath(path).split('/').filter(Boolean)
115
188
 
116
189
  // Quick check - if segments don't match (accounting for optional params) then no match
117
190
  // For mandatory parameters, the pattern and path must have same number of segments
118
- const optionalParamCount = (normalizedPattern.match(/\{[^}]+\?\}/g) || []).length
119
-
120
- if (pathSegments.length < patternSegments.length - optionalParamCount
191
+ if (pathSegments.length < patternSegments.length - compiled.optionalCount
121
192
  || pathSegments.length > patternSegments.length) {
122
193
  return false
123
194
  }
124
195
 
125
196
  // Match each segment
126
197
  for (let i = 0; i < patternSegments.length; i++) {
127
- const patternSegment = patternSegments[i]
198
+ const seg = patternSegments[i]
128
199
  const pathSegment = pathSegments[i]
129
200
 
130
201
  // If we've run out of path segments and this is not an optional parameter
131
202
  if (pathSegment === undefined) {
132
- if (patternSegment.match(/\{[^}]+\?\}/)) {
203
+ if (seg.isOptional) {
133
204
  // This is an optional parameter and we have no path segment for it
134
205
  continue
135
206
  }
136
- else {
137
- // Required parameter or static segment with no matching path segment
138
- return false
139
- }
207
+ // Required parameter or static segment with no matching path segment
208
+ return false
140
209
  }
141
210
 
142
- // Check if this segment is a parameter
143
- const paramMatch = patternSegment.match(/\{([^}:]+)(?::[^}]+)?(\?)?\}/)
144
- if (paramMatch) {
145
- // This is a parameter segment, extract param name
146
- const paramName = paramMatch[1].replace('?', '')
211
+ if (seg.isParam) {
147
212
  // Store the parameter value
148
- params[paramName] = pathSegment
213
+ params[seg.paramName] = pathSegment
149
214
 
150
215
  // Check constraints if they exist
151
- if (constraints && constraints[paramName]) {
152
- try {
153
- const regex = new RegExp(`^${constraints[paramName]}$`)
216
+ if (constraints && constraints[seg.paramName]) {
217
+ const regex = getConstraintRegex(constraints[seg.paramName])
218
+ if (regex) {
154
219
  if (!regex.test(pathSegment)) {
155
220
  return false
156
221
  }
157
222
  }
158
- catch {
223
+ else {
159
224
  // If regex is invalid, treat as no constraint
160
- console.warn(`Invalid constraint regex for parameter ${paramName}: ${constraints[paramName]}`)
225
+ console.warn(`Invalid constraint regex for parameter ${seg.paramName}: ${constraints[seg.paramName]}`)
161
226
  }
162
227
  }
163
228
  }
164
- else if (patternSegment !== pathSegment) {
229
+ else if (seg.literal !== pathSegment) {
165
230
  // Static segment doesn't match
166
231
  return false
167
232
  }
@@ -254,7 +319,7 @@ export async function resolveViewPath(
254
319
  * @param template The HTML template string
255
320
  * @param data The data to inject into the template
256
321
  */
257
- export function processHtmlTemplate(template: string, data: Record<string, any>): string {
322
+ export function processHtmlTemplate(template: string, data: Record<string, unknown>): string {
258
323
  // Process general variables {{ varName }}
259
324
  let result = template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
260
325
  const trimmedKey = key.trim()
@@ -274,7 +339,7 @@ export function processHtmlTemplate(template: string, data: Record<string, any>)
274
339
  /**
275
340
  * Process conditional statements in templates
276
341
  */
277
- function processConditionals(template: string, data: Record<string, any>): string {
342
+ function processConditionals(template: string, data: Record<string, unknown>): string {
278
343
  // Match {{#if condition}} content {{/if}} or {{#if condition}} content {{else}} alternative {{/if}}
279
344
  return template.replace(
280
345
  /\{\{#if ([^}]+)\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g,
@@ -290,7 +355,7 @@ function processConditionals(template: string, data: Record<string, any>): strin
290
355
  /**
291
356
  * Process loop statements in templates
292
357
  */
293
- function processLoops(template: string, data: Record<string, any>): string {
358
+ function processLoops(template: string, data: Record<string, unknown>): string {
294
359
  // Match {{#each items}} content {{/each}}
295
360
  return template.replace(
296
361
  /\{\{#each ([^}]+)\}\}([\s\S]*?)\{\{\/each\}\}/g,
@@ -320,8 +385,11 @@ function processLoops(template: string, data: Record<string, any>): string {
320
385
  * @param obj The object to extract value from
321
386
  * @param path The path to the value (e.g., 'user.profile.name')
322
387
  */
323
- function getNestedValue(obj: Record<string, any>, path: string): any {
324
- return path.split('.').reduce((prev, curr) => {
325
- return prev && prev[curr] !== undefined ? prev[curr] : undefined
388
+ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
389
+ return path.split('.').reduce<unknown>((prev, curr) => {
390
+ if (prev && typeof prev === 'object' && curr in prev) {
391
+ return (prev as Record<string, unknown>)[curr]
392
+ }
393
+ return undefined
326
394
  }, obj)
327
395
  }
@@ -7,6 +7,12 @@
7
7
  import type { EnhancedRequest, NextFunction } from '../types'
8
8
  import { ValidationException } from '../errors/exceptions'
9
9
 
10
+ // Hoisted email check. Pragmatic RFC 5322 approximation: requires one `@`,
11
+ // a non-empty local part without surrounding/consecutive dots, and a domain
12
+ // with at least one label dot and a 2+ char TLD. The previous regex
13
+ // accepted addresses like `a@.com` and consecutive dots.
14
+ const EMAIL_REGEX = /^[A-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Z0-9](?:[A-Z0-9-]*[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]*[A-Z0-9])?)*\.[A-Z]{2,}$/i
15
+
10
16
  export interface ValidationRule {
11
17
  name: string
12
18
  validate: (value: any, parameters: string[], field: string, data: Record<string, any>) => Promise<boolean> | boolean
@@ -74,10 +80,7 @@ export const BuiltInRules: Record<string, ValidationRule> = {
74
80
 
75
81
  email: {
76
82
  name: 'email',
77
- validate: (value) => {
78
- const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
79
- return typeof value === 'string' && emailRegex.test(value)
80
- },
83
+ validate: value => typeof value === 'string' && EMAIL_REGEX.test(value),
81
84
  message: 'The :field field must be a valid email address.',
82
85
  },
83
86
 
@@ -539,8 +539,13 @@ export class WebSocketCluster {
539
539
  break
540
540
 
541
541
  case 'stats':
542
- // Handle stats updates from other workers
542
+ // Handle stats updates from other workers. Bounded: with worker
543
+ // churn the per-worker entries would otherwise accumulate forever.
543
544
  if (message.data && message.workerId) {
545
+ const workerIds = Object.keys(this.stats.workerStats)
546
+ if (workerIds.length >= 1000 && !(message.workerId in this.stats.workerStats)) {
547
+ delete this.stats.workerStats[workerIds[0]]
548
+ }
544
549
  this.stats.workerStats[message.workerId] = message.data
545
550
  }
546
551
  break
@@ -623,6 +628,8 @@ export class WebSocketCluster {
623
628
  timestamp: now,
624
629
  })
625
630
  }, this.config.heartbeatInterval)
631
+ // The heartbeat must not keep the process alive on shutdown
632
+ this.heartbeatTimer.unref?.()
626
633
  }
627
634
 
628
635
  /**