@c9up/ream 0.2.13 → 0.2.15

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 (192) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +6 -1
  4. package/dist/Application.js.map +1 -1
  5. package/dist/GracefulShutdown.js +6 -5
  6. package/dist/GracefulShutdown.js.map +1 -1
  7. package/dist/Provider.d.ts +20 -2
  8. package/dist/Provider.d.ts.map +1 -1
  9. package/dist/Provider.js.map +1 -1
  10. package/dist/bodyparser/MultipartFile.d.ts +33 -7
  11. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  12. package/dist/bodyparser/MultipartFile.js +61 -12
  13. package/dist/bodyparser/MultipartFile.js.map +1 -1
  14. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  15. package/dist/bodyparser/parseSize.js +3 -2
  16. package/dist/bodyparser/parseSize.js.map +1 -1
  17. package/dist/commands/MigrateBase.d.ts.map +1 -1
  18. package/dist/commands/MigrateBase.js +4 -1
  19. package/dist/commands/MigrateBase.js.map +1 -1
  20. package/dist/console/Kernel.d.ts.map +1 -1
  21. package/dist/console/Kernel.js +7 -0
  22. package/dist/console/Kernel.js.map +1 -1
  23. package/dist/console/types.d.ts +14 -0
  24. package/dist/console/types.d.ts.map +1 -1
  25. package/dist/console/types.js.map +1 -1
  26. package/dist/container/Container.d.ts +19 -3
  27. package/dist/container/Container.d.ts.map +1 -1
  28. package/dist/container/Container.js +89 -31
  29. package/dist/container/Container.js.map +1 -1
  30. package/dist/container/ContainerResolver.js.map +1 -1
  31. package/dist/container/types.d.ts +11 -1
  32. package/dist/container/types.d.ts.map +1 -1
  33. package/dist/decorators/Service.d.ts +1 -1
  34. package/dist/decorators/Service.d.ts.map +1 -1
  35. package/dist/env/Env.d.ts.map +1 -1
  36. package/dist/env/Env.js +4 -2
  37. package/dist/env/Env.js.map +1 -1
  38. package/dist/env/interpolate.d.ts.map +1 -1
  39. package/dist/env/interpolate.js +5 -3
  40. package/dist/env/interpolate.js.map +1 -1
  41. package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
  42. package/dist/errors/FuzzyMatcher.js +30 -13
  43. package/dist/errors/FuzzyMatcher.js.map +1 -1
  44. package/dist/errors/PipelineStageError.d.ts.map +1 -1
  45. package/dist/errors/PipelineStageError.js +4 -2
  46. package/dist/errors/PipelineStageError.js.map +1 -1
  47. package/dist/events/Emitter.d.ts +21 -4
  48. package/dist/events/Emitter.d.ts.map +1 -1
  49. package/dist/events/Emitter.js +6 -2
  50. package/dist/events/Emitter.js.map +1 -1
  51. package/dist/events/testing/FakeBus.d.ts +1 -1
  52. package/dist/events/testing/FakeBus.d.ts.map +1 -1
  53. package/dist/events/testing/FakeBus.js.map +1 -1
  54. package/dist/graphql/GraphQLEngine.d.ts +2 -2
  55. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  56. package/dist/graphql/GraphQLEngine.js +4 -2
  57. package/dist/graphql/GraphQLEngine.js.map +1 -1
  58. package/dist/helpers/bytes.d.ts.map +1 -1
  59. package/dist/helpers/bytes.js +13 -2
  60. package/dist/helpers/bytes.js.map +1 -1
  61. package/dist/helpers/string.js +3 -3
  62. package/dist/helpers/string.js.map +1 -1
  63. package/dist/http/Exception.d.ts +8 -4
  64. package/dist/http/Exception.d.ts.map +1 -1
  65. package/dist/http/Exception.js +14 -8
  66. package/dist/http/Exception.js.map +1 -1
  67. package/dist/http/Request.d.ts.map +1 -1
  68. package/dist/http/Request.js +10 -7
  69. package/dist/http/Request.js.map +1 -1
  70. package/dist/http/Response.d.ts.map +1 -1
  71. package/dist/http/Response.js +4 -9
  72. package/dist/http/Response.js.map +1 -1
  73. package/dist/http/SseStream.d.ts.map +1 -1
  74. package/dist/http/SseStream.js +10 -1
  75. package/dist/http/SseStream.js.map +1 -1
  76. package/dist/http/etag.d.ts +44 -0
  77. package/dist/http/etag.d.ts.map +1 -0
  78. package/dist/http/etag.js +65 -0
  79. package/dist/http/etag.js.map +1 -0
  80. package/dist/http/mime.d.ts +33 -0
  81. package/dist/http/mime.d.ts.map +1 -0
  82. package/dist/http/mime.js +1347 -0
  83. package/dist/http/mime.js.map +1 -0
  84. package/dist/index.d.ts +3 -3
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/native/generated.d.ts +12 -8
  88. package/dist/native/generated.d.ts.map +1 -1
  89. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  90. package/dist/openapi/OpenApiGenerator.js +6 -4
  91. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  92. package/dist/router/Router.d.ts +10 -1
  93. package/dist/router/Router.d.ts.map +1 -1
  94. package/dist/router/Router.js +22 -10
  95. package/dist/router/Router.js.map +1 -1
  96. package/dist/rpc/RpcRouter.d.ts +16 -3
  97. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  98. package/dist/rpc/RpcRouter.js +34 -3
  99. package/dist/rpc/RpcRouter.js.map +1 -1
  100. package/dist/scheduler/Schedule.d.ts +6 -1
  101. package/dist/scheduler/Schedule.d.ts.map +1 -1
  102. package/dist/scheduler/Schedule.js +5 -0
  103. package/dist/scheduler/Schedule.js.map +1 -1
  104. package/dist/security/CookieSigner.d.ts.map +1 -1
  105. package/dist/security/CookieSigner.js +12 -5
  106. package/dist/security/CookieSigner.js.map +1 -1
  107. package/dist/security/SignedUrl.js +3 -2
  108. package/dist/security/SignedUrl.js.map +1 -1
  109. package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
  110. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  111. package/dist/session/drivers/DatabaseDriver.js +23 -3
  112. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  113. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  114. package/dist/session/drivers/RedisDriver.js +10 -2
  115. package/dist/session/drivers/RedisDriver.js.map +1 -1
  116. package/dist/storage/StaticMiddleware.d.ts.map +1 -1
  117. package/dist/storage/StaticMiddleware.js +78 -59
  118. package/dist/storage/StaticMiddleware.js.map +1 -1
  119. package/dist/testing/ApiResponse.d.ts.map +1 -1
  120. package/dist/testing/ApiResponse.js +10 -11
  121. package/dist/testing/ApiResponse.js.map +1 -1
  122. package/dist/testing/TestClient.js +6 -6
  123. package/dist/testing/TestClient.js.map +1 -1
  124. package/dist/utils/Macroable.d.ts +6 -2
  125. package/dist/utils/Macroable.d.ts.map +1 -1
  126. package/dist/utils/Macroable.js +4 -0
  127. package/dist/utils/Macroable.js.map +1 -1
  128. package/dist/utils/objectPath.d.ts.map +1 -1
  129. package/dist/utils/objectPath.js +14 -6
  130. package/dist/utils/objectPath.js.map +1 -1
  131. package/events.darwin-arm64.node +0 -0
  132. package/events.darwin-x64.node +0 -0
  133. package/events.linux-arm64-gnu.node +0 -0
  134. package/events.linux-x64-gnu.node +0 -0
  135. package/events.win32-x64-msvc.node +0 -0
  136. package/index.darwin-arm64.node +0 -0
  137. package/index.darwin-x64.node +0 -0
  138. package/index.linux-arm64-gnu.node +0 -0
  139. package/index.linux-x64-gnu.node +0 -0
  140. package/index.win32-x64-msvc.node +0 -0
  141. package/package.json +5 -7
  142. package/scheduler.darwin-arm64.node +0 -0
  143. package/scheduler.darwin-x64.node +0 -0
  144. package/scheduler.linux-arm64-gnu.node +0 -0
  145. package/scheduler.linux-x64-gnu.node +0 -0
  146. package/scheduler.win32-x64-msvc.node +0 -0
  147. package/scripts/build-napi-types.mjs +4 -3
  148. package/scripts/generate-mime.mjs +119 -0
  149. package/scripts/generate-napi-types.mjs +28 -12
  150. package/src/Application.ts +6 -1
  151. package/src/GracefulShutdown.ts +5 -5
  152. package/src/Provider.ts +21 -2
  153. package/src/bodyparser/MultipartFile.ts +94 -16
  154. package/src/bodyparser/parseSize.ts +3 -2
  155. package/src/commands/MigrateBase.ts +4 -1
  156. package/src/console/BaseCommand.ts +1 -1
  157. package/src/console/Kernel.ts +8 -0
  158. package/src/console/types.ts +15 -0
  159. package/src/container/Container.ts +120 -57
  160. package/src/container/ContainerResolver.ts +1 -1
  161. package/src/container/types.ts +11 -1
  162. package/src/decorators/Service.ts +1 -1
  163. package/src/env/Env.ts +4 -2
  164. package/src/env/interpolate.ts +5 -3
  165. package/src/errors/FuzzyMatcher.ts +34 -13
  166. package/src/errors/PipelineStageError.ts +4 -2
  167. package/src/events/Emitter.ts +34 -8
  168. package/src/events/testing/FakeBus.ts +12 -5
  169. package/src/graphql/GraphQLEngine.ts +11 -7
  170. package/src/helpers/bytes.ts +13 -2
  171. package/src/helpers/string.ts +3 -3
  172. package/src/http/Exception.ts +14 -8
  173. package/src/http/Request.ts +8 -5
  174. package/src/http/Response.ts +4 -8
  175. package/src/http/SseStream.ts +16 -7
  176. package/src/http/etag.ts +68 -0
  177. package/src/http/mime.ts +1347 -0
  178. package/src/index.ts +3 -3
  179. package/src/native/generated.ts +12 -8
  180. package/src/openapi/OpenApiGenerator.ts +6 -4
  181. package/src/router/Router.ts +31 -11
  182. package/src/rpc/RpcRouter.ts +38 -8
  183. package/src/scheduler/Schedule.ts +8 -1
  184. package/src/security/CookieSigner.ts +15 -5
  185. package/src/security/SignedUrl.ts +3 -2
  186. package/src/session/drivers/DatabaseDriver.ts +36 -8
  187. package/src/session/drivers/RedisDriver.ts +13 -5
  188. package/src/storage/StaticMiddleware.ts +81 -66
  189. package/src/testing/ApiResponse.ts +9 -10
  190. package/src/testing/TestClient.ts +6 -6
  191. package/src/utils/Macroable.ts +6 -2
  192. package/src/utils/objectPath.ts +12 -6
package/src/index.ts CHANGED
@@ -101,7 +101,7 @@ export { default as SessionMiddleware } from './session/SessionMiddleware.js'
101
101
 
102
102
  export type { BodyParserConfig } from './bodyparser/BodyParserMiddleware.js'
103
103
  export { default as BodyParserMiddleware } from './bodyparser/BodyParserMiddleware.js'
104
- export type { FileValidationOptions } from './bodyparser/MultipartFile.js'
104
+ export type { FileValidationOptions, MimeType } from './bodyparser/MultipartFile.js'
105
105
  export { MultipartFile } from './bodyparser/MultipartFile.js'
106
106
 
107
107
  // ─── JSON-RPC ───────────────────────────────────────────────
@@ -254,7 +254,7 @@ export { ConsoleKernel, defineConfig, Ignitor, prettyPrintError } from './Ignito
254
254
  // ─── Providers ──────────────────────────────────────────────
255
255
 
256
256
  export { errors } from './errors/aggregate.js'
257
- export type { AppContext, ProviderContract } from './Provider.js'
257
+ export type { AppContext, ConfigReader, ProviderContract } from './Provider.js'
258
258
  export { Provider } from './Provider.js'
259
259
  export type {
260
260
  AuthenticatorName,
@@ -378,4 +378,4 @@ export {
378
378
  StatsTracker,
379
379
  } from './scheduler/index.js'
380
380
 
381
- export const VERSION = '0.2.13'
381
+ export const VERSION = '0.2.15'
@@ -87,8 +87,14 @@ export declare class HyperServer {
87
87
  * the safe default for a dev machine.
88
88
  */
89
89
  constructor(port?: number | undefined | null, host?: string | undefined | null)
90
- /** Register the request handler. Callback receives JSON request string, must return JSON response string. */
91
- onRequest(callback: (request: string) => Promise<string> | string): void
90
+ /**
91
+ * Register the request handler.
92
+ *
93
+ * The request arrives as an OBJECT (`serde_json::Value` → `JsObject`) and
94
+ * the handler answers with a `NapiResponse` object — not the JSON strings
95
+ * this comment described until the boundary stopped carrying them.
96
+ */
97
+ onRequest(callback: (request: Record<string, unknown>) => Promise<NapiResponse>): void
92
98
  /**
93
99
  * Configure the trusted-proxy CIDR list used by `request.ip` resolution.
94
100
  * Must be called BEFORE `listen()`. Empty list = legacy permissive mode
@@ -155,12 +161,10 @@ export declare class HyperServer {
155
161
  */
156
162
  writeStreamBytes(streamId: string, chunk: Uint8Array): Promise<boolean>
157
163
  /**
158
- * Close a stream from the server side. The matching response body
159
- * finishes cleanly (hyper writes the final chunk + `0
160
-
161
- ` for
162
- * HTTP/1.1 chunked encoding). Idempotent.
163
- */
164
+ * Close a stream from the server side. The matching response body
165
+ * finishes cleanly (hyper writes the final chunk + `0\r\n\r\n` for
166
+ * HTTP/1.1 chunked encoding). Idempotent.
167
+ */
164
168
  closeStream(streamId: string): Promise<boolean>
165
169
  /**
166
170
  * Install a one-shot callback fired the moment the matching stream
@@ -170,9 +170,9 @@ export class OpenApiGenerator {
170
170
  }
171
171
 
172
172
  if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
173
- const validatorName = route.validators[0]
174
- const schema = this.#runeSchemas.get(validatorName)
175
- if (schema) {
173
+ const [validatorName] = route.validators
174
+ const schema = validatorName === undefined ? undefined : this.#runeSchemas.get(validatorName)
175
+ if (schema && validatorName !== undefined) {
176
176
  operation.requestBody = {
177
177
  required: true,
178
178
  content: { 'application/json': { schema: this.#runeToJsonSchema(schema) } },
@@ -197,7 +197,9 @@ export class OpenApiGenerator {
197
197
  /** Extract parameter names from path. */
198
198
  #extractPathParams(path: string): string[] {
199
199
  const matches = path.matchAll(/:(\w+)\??/g)
200
- return [...matches].map((m) => m[1])
200
+ // Group 1 is required by the pattern, so every match carries it; the
201
+ // filter is how that is stated instead of asserted.
202
+ return [...matches].map((m) => m[1]).filter((name) => name !== undefined)
201
203
  }
202
204
 
203
205
  /** Generate a summary from route metadata. */
@@ -25,7 +25,16 @@ export interface SignedUrlOptions {
25
25
 
26
26
  // ─── Types ──────────────────────────────────────────────────
27
27
 
28
- export type RouteHandlerFunction = (ctx: HttpContext) => Promise<void> | void
28
+ /**
29
+ * A route handler. Whatever it returns is awaited and dropped — the response
30
+ * is written through `ctx.response`.
31
+ *
32
+ * `unknown`, not `Promise<void> | void`: a bare `void` return type accepts a
33
+ * function returning anything, but a UNION containing it does not, so
34
+ * `router.get('/x', () => 'ok')` was a type error for returning a value the
35
+ * kernel already ignores.
36
+ */
37
+ export type RouteHandlerFunction = (ctx: HttpContext) => unknown
29
38
 
30
39
  /**
31
40
  * Controller tuple: [ControllerClass, 'methodName'].
@@ -267,7 +276,13 @@ export class RouteBuilder extends Macroable {
267
276
 
268
277
  /** The primary definition — accessors read from it (all verbs share pattern/name). */
269
278
  #primary(): RouteDefinition {
270
- return this.#routes[0]
279
+ const [first] = this.#routes
280
+ if (first === undefined) {
281
+ // A route is constructed with at least one verb, so this cannot happen —
282
+ // and this is where that is stated rather than asserted past.
283
+ throw new Error('Route has no definitions')
284
+ }
285
+ return first
271
286
  }
272
287
 
273
288
  /** Name this route (for URL generation and redirects). */
@@ -1557,9 +1572,13 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1557
1572
  const params: MatchedParams = {}
1558
1573
  for (let i = 0; i < wildcardIdx; i++) {
1559
1574
  const part = patternParts[i]
1575
+ const actual = actualParts[i]
1576
+ // Both are read out of arrays whose length was checked above; naming
1577
+ // them is what turns that check into something the compiler shares.
1578
+ if (part === undefined || actual === undefined) return null
1560
1579
  if (part.startsWith(':')) {
1561
- params[part.substring(1)] = safeDecodeURIComponent(actualParts[i])
1562
- } else if (part !== actualParts[i]) {
1580
+ params[part.substring(1)] = safeDecodeURIComponent(actual)
1581
+ } else if (part !== actual) {
1563
1582
  return null
1564
1583
  }
1565
1584
  }
@@ -1579,22 +1598,22 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1579
1598
 
1580
1599
  const params: MatchedParams = {}
1581
1600
 
1582
- for (let i = 0; i < patternParts.length; i++) {
1583
- const part = patternParts[i]
1601
+ for (const [i, part] of patternParts.entries()) {
1584
1602
  const isOptional = part.endsWith('?')
1603
+ const actual = actualParts[i]
1585
1604
 
1586
1605
  if (part.startsWith(':')) {
1587
1606
  const paramName = isOptional ? part.slice(1, -1) : part.substring(1)
1588
- if (i < actualParts.length) {
1607
+ if (actual !== undefined) {
1589
1608
  // Percent-decode the captured segment (query strings already are) so
1590
1609
  // `/users/Jos%C3%A9` yields `José` and matchers test the real value.
1591
1610
  // safeDecode falls back to the raw segment on malformed input.
1592
- params[paramName] = safeDecodeURIComponent(actualParts[i])
1611
+ params[paramName] = safeDecodeURIComponent(actual)
1593
1612
  }
1594
1613
  // Optional param with no actual part — skip (param not set)
1595
- } else if (i < actualParts.length && part !== actualParts[i]) {
1614
+ } else if (actual !== undefined && part !== actual) {
1596
1615
  return null
1597
- } else if (i >= actualParts.length && !isOptional) {
1616
+ } else if (actual === undefined && !isOptional) {
1598
1617
  return null
1599
1618
  }
1600
1619
  }
@@ -1612,7 +1631,8 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1612
1631
  * Comparing them verbatim sends that request to a fallback, or to a 404.
1613
1632
  */
1614
1633
  function matchDomain(pattern: string, host: string): boolean {
1615
- const actualHost = host.split(':')[0].toLowerCase() // strip port
1634
+ const [hostname = ''] = host.split(':') // strip port
1635
+ const actualHost = hostname.toLowerCase()
1616
1636
  const wanted = pattern.toLowerCase()
1617
1637
  if (wanted === actualHost) return true
1618
1638
  if (wanted.startsWith('*.')) {
@@ -30,7 +30,7 @@ export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown>
30
30
  * the shared `'middleware'` registry, and `validator:<name>` schemas.
31
31
  */
32
32
  interface RpcContainer {
33
- make<T>(target: new (...args: unknown[]) => T): Promise<T>
33
+ make<T>(target: abstract new (...args: never[]) => T): Promise<T>
34
34
  resolve<T>(token: string): Promise<T>
35
35
  has(token: string): boolean
36
36
  }
@@ -151,11 +151,21 @@ export class RpcRouter {
151
151
  return new RpcMethodBuilder(def)
152
152
  }
153
153
 
154
- /** Register methods from a controller — auto-registers all public methods. */
155
- namespace(
156
- prefix: string,
157
- controller: new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>,
158
- ): void {
154
+ /**
155
+ * Register methods from a controller — auto-registers all public methods.
156
+ *
157
+ * The parameter used to demand
158
+ * `new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>`,
159
+ * which NO real controller satisfies: a class with any non-method property
160
+ * fails the `Record`, and a method declared `bump(): number` fails the
161
+ * `unknown[]` parameters, which are compared contravariantly. So the
162
+ * documented call — `rpc.namespace('task', TaskController)` — did not
163
+ * typecheck, and nothing noticed because the tests were outside `tsc`.
164
+ *
165
+ * Any class, then. The body already checks that what it found is callable
166
+ * and names the method when it is not.
167
+ */
168
+ namespace(prefix: string, controller: abstract new (...args: never[]) => object): void {
159
169
  const proto = controller.prototype
160
170
  const methods = Object.getOwnPropertyNames(proto).filter(
161
171
  (m) => m !== 'constructor' && typeof proto[m] === 'function',
@@ -164,8 +174,18 @@ export class RpcRouter {
164
174
  this.method(`${prefix}.${methodName}`, async (ctx, params) => {
165
175
  // Resolve through the container on every call (fresh DI per request,
166
176
  // like GraphQLEngine), falling back to a bare `new` when unset.
167
- const instance = this.#container ? await this.#container.make(controller) : new controller()
168
- return instance[methodName](ctx, params)
177
+ // `Reflect.construct`, not `new controller()`: the token may be an
178
+ // abstract class, which the runtime builds happily and `new` refuses.
179
+ const instance: object = this.#container
180
+ ? await this.#container.make(controller)
181
+ : Reflect.construct(controller, [])
182
+ const handler = Reflect.get(instance, methodName)
183
+ if (typeof handler !== 'function') {
184
+ throw new Error(
185
+ `RPC method '${prefix}.${methodName}' is not a function on ${controller.name}`,
186
+ )
187
+ }
188
+ return handler.call(instance, ctx, params)
169
189
  })
170
190
  }
171
191
  }
@@ -199,6 +219,16 @@ export class RpcRouter {
199
219
 
200
220
  // Batch support (max 50 to prevent DoS)
201
221
  if (Array.isArray(body)) {
222
+ // §6: "If the batch rpc call itself fails to be recognized as an valid
223
+ // JSON or as an Array with at least one value, the response from the
224
+ // Server MUST be a single Response object." An empty batch answered with
225
+ // 204 No Content, which is what this replies when every call in a batch
226
+ // was a notification — so a client that sent nothing was told its calls
227
+ // had run.
228
+ if (body.length === 0) {
229
+ ctx.response.status(200).json(rpcError(-32600, 'Invalid Request', null))
230
+ return
231
+ }
202
232
  if (body.length > 50) {
203
233
  ctx.response.status(400).json(rpcError(-32600, 'Batch too large (max 50)', null))
204
234
  return
@@ -90,7 +90,14 @@ export function Schedule(cronExpr: string): MethodDecorator {
90
90
  * none). Returns a shallow copy so callers cannot mutate the stored
91
91
  * metadata by accident.
92
92
  */
93
- export function getScheduleMetadata(target: Constructor): ScheduleMetadata[] {
93
+ /**
94
+ * `abstract new`, not `Constructor`: reading metadata off a class never
95
+ * constructs it, and the service registry holds abstract ones too since a
96
+ * contextual binding binds against an abstract base.
97
+ */
98
+ export function getScheduleMetadata(
99
+ target: abstract new (...args: never[]) => unknown,
100
+ ): ScheduleMetadata[] {
94
101
  const stored: ScheduleMetadata[] = Reflect.getOwnMetadata(SCHEDULE_METADATA_KEY, target) ?? []
95
102
  return [...stored]
96
103
  }
@@ -225,12 +225,22 @@ export class CookieSigner {
225
225
  }
226
226
 
227
227
  decrypt<T = string>(encrypted: string, purpose?: string): T | null {
228
- const parts = encrypted.split('.')
229
- if (parts.length !== 3) return null
228
+ // Destructured rather than indexed, and `extra` is what keeps the old
229
+ // length check honest: three names bind on a four-part string too, so the
230
+ // fourth has to be looked at to reject it.
231
+ const [ivPart, dataPart, tagPart, extra] = encrypted.split('.')
232
+ if (
233
+ ivPart === undefined ||
234
+ dataPart === undefined ||
235
+ tagPart === undefined ||
236
+ extra !== undefined
237
+ ) {
238
+ return null
239
+ }
230
240
  try {
231
- const iv = Buffer.from(parts[0], 'base64url')
232
- const data = Buffer.from(parts[1], 'base64url')
233
- const tag = Buffer.from(parts[2], 'base64url')
241
+ const iv = Buffer.from(ivPart, 'base64url')
242
+ const data = Buffer.from(dataPart, 'base64url')
243
+ const tag = Buffer.from(tagPart, 'base64url')
234
244
  const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
235
245
  decipher.setAuthTag(tag)
236
246
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
@@ -55,8 +55,9 @@ function parseExpiry(value: string | number): number {
55
55
  if (typeof value === 'number') return value
56
56
  const match = value.match(/^(\d+)(s|m|h|d)$/)
57
57
  if (!match) return 3600
58
- const num = parseInt(match[1], 10)
59
- switch (match[2]) {
58
+ const [, digits, unit] = match
59
+ const num = parseInt(digits ?? '0', 10)
60
+ switch (unit) {
60
61
  case 's':
61
62
  return num
62
63
  case 'm':
@@ -8,10 +8,28 @@ import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
8
8
  * app's own wrapper.
9
9
  */
10
10
  export interface SessionDbConnection {
11
- query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
11
+ /**
12
+ * Rows, as rows: `Record<string, unknown>`, not a shape the caller names.
13
+ *
14
+ * `query<T>` let this driver ASSERT what a SELECT returned — a promise the
15
+ * database never made and the connection cannot keep. It also made the
16
+ * interface unimplementable by anything but a real driver: a generic method
17
+ * must work for every `T`, so no fake could satisfy it, and this file's tests
18
+ * sat outside the typecheck because of it.
19
+ *
20
+ * The columns are read through {@link textColumn} below instead, which is
21
+ * what a row from a database deserves.
22
+ */
23
+ query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>
12
24
  execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
13
25
  }
14
26
 
27
+ /** A column that must be text to be usable, or `null`. */
28
+ function textColumn(row: Record<string, unknown>, name: string): string | null {
29
+ const value = row[name]
30
+ return typeof value === 'string' ? value : null
31
+ }
32
+
15
33
  export interface DatabaseDriverOptions {
16
34
  connection: SessionDbConnection
17
35
  /** Table holding the sessions. Default `sessions`. */
@@ -71,17 +89,20 @@ export class DatabaseDriver implements SessionDriverWithTagging {
71
89
  }
72
90
 
73
91
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
74
- const rows = await this.#db.query<{ data: string; expires_at: number | string }>(
75
- `SELECT data, expires_at FROM ${this.#table} WHERE id = ?`,
76
- [sessionId],
77
- )
92
+ const rows = await this.#db.query(`SELECT data, expires_at FROM ${this.#table} WHERE id = ?`, [
93
+ sessionId,
94
+ ])
78
95
  const row = rows[0]
79
96
  if (!row) return null
80
97
  if (Number(row.expires_at) < Date.now()) {
81
98
  await this.destroy(sessionId)
82
99
  return null
83
100
  }
84
- return parseData(row.data)
101
+ const data = textColumn(row, 'data')
102
+ // A row whose payload is not text is not a session — the same outcome as a
103
+ // row that will not parse.
104
+ if (data === null) return null
105
+ return parseData(data)
85
106
  }
86
107
 
87
108
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
@@ -157,11 +178,18 @@ export class DatabaseDriver implements SessionDriverWithTagging {
157
178
 
158
179
  /** Every non-expired session this user holds — their active devices. */
159
180
  async tagged(userId: string | number): Promise<TaggedSession[]> {
160
- const rows = await this.#db.query<{ id: string; data: string }>(
181
+ const rows = await this.#db.query(
161
182
  `SELECT id, data FROM ${this.#table} WHERE user_id = ? AND expires_at >= ?`,
162
183
  [String(userId), Date.now()],
163
184
  )
164
- return rows.map((row) => ({ id: row.id, data: parseData(row.data) }))
185
+ const sessions: TaggedSession[] = []
186
+ for (const row of rows) {
187
+ const id = textColumn(row, 'id')
188
+ const data = textColumn(row, 'data')
189
+ if (id === null || data === null) continue
190
+ sessions.push({ id, data: parseData(data) })
191
+ }
192
+ return sessions
165
193
  }
166
194
 
167
195
  /** Delete every expired row — for a scheduled job, since nothing else does. */
@@ -67,11 +67,19 @@ export class RedisDriver implements SessionDriverWithTagging {
67
67
  }
68
68
  if (!this.#pending) {
69
69
  const resolver = this.#source
70
- this.#pending = Promise.resolve(resolver()).then((client) => {
71
- this.#resolved = client
72
- this.#pending = undefined
73
- return client
74
- })
70
+ // `finally`, not the success path: clearing it only on success left a
71
+ // REJECTED promise in the slot, and every later call returned that same
72
+ // rejection. One refused connection at boot — Redis still starting, a
73
+ // network blip — and the driver never tried again for the life of the
74
+ // process. Bay and echo already clear it this way.
75
+ this.#pending = Promise.resolve(resolver())
76
+ .then((client) => {
77
+ this.#resolved = client
78
+ return client
79
+ })
80
+ .finally(() => {
81
+ this.#pending = undefined
82
+ })
75
83
  }
76
84
  return this.#pending
77
85
  }
@@ -9,10 +9,13 @@
9
9
  * @implements MISS-25
10
10
  */
11
11
 
12
- import { createHash } from 'node:crypto'
13
12
  import * as fs from 'node:fs'
13
+ import type { FileHandle } from 'node:fs/promises'
14
+ import * as fsp from 'node:fs/promises'
14
15
  import * as path from 'node:path'
16
+ import { matchesIfNoneMatch, statTag } from '../http/etag.js'
15
17
  import type { HttpContext } from '../http/HttpContext.js'
18
+ import { contentType } from '../http/mime.js'
16
19
 
17
20
  export interface StaticConfig {
18
21
  /** Root directory to serve files from. */
@@ -51,32 +54,6 @@ const DEFAULT_EXTENSIONS = [
51
54
  '.zip',
52
55
  ]
53
56
 
54
- const MIME_TYPES: Record<string, string> = {
55
- '.html': 'text/html',
56
- '.css': 'text/css',
57
- '.js': 'application/javascript',
58
- '.json': 'application/json',
59
- '.xml': 'application/xml',
60
- '.txt': 'text/plain',
61
- '.png': 'image/png',
62
- '.jpg': 'image/jpeg',
63
- '.jpeg': 'image/jpeg',
64
- '.gif': 'image/gif',
65
- '.svg': 'image/svg+xml',
66
- '.ico': 'image/x-icon',
67
- '.webp': 'image/webp',
68
- '.woff': 'font/woff',
69
- '.woff2': 'font/woff2',
70
- '.ttf': 'font/ttf',
71
- '.eot': 'application/vnd.ms-fontobject',
72
- '.mp4': 'video/mp4',
73
- '.webm': 'video/webm',
74
- '.mp3': 'audio/mpeg',
75
- '.ogg': 'audio/ogg',
76
- '.pdf': 'application/pdf',
77
- '.zip': 'application/zip',
78
- }
79
-
80
57
  export class StaticMiddleware {
81
58
  #root: string
82
59
  #prefix: string
@@ -95,46 +72,65 @@ export class StaticMiddleware {
95
72
  return next()
96
73
  }
97
74
 
98
- const resolved = this.#resolveServableFile(ctx.request.path())
75
+ const resolved = await this.#openServableFile(ctx.request.path())
99
76
  if (!resolved) return next()
100
- const { realFilePath, ext, stat } = resolved
101
-
102
- const etag = createHash('md5').update(`${stat.size}-${stat.mtimeMs}`).digest('hex')
103
-
104
- // ETag check — 304 Not Modified
105
- const ifNoneMatch = ctx.request.header('if-none-match')
106
- if (ifNoneMatch === etag) {
107
- ctx.response.status(304)
108
- return
109
- }
77
+ const { handle, ext, stat } = resolved
110
78
 
111
- const mime = MIME_TYPES[ext] ?? 'application/octet-stream'
112
- ctx.response.header('Content-Type', mime)
113
- ctx.response.header('Content-Length', String(stat.size))
114
- ctx.response.header('Cache-Control', `public, max-age=${this.#maxAge}`)
115
- ctx.response.header('ETag', etag)
116
-
117
- if (ctx.request.method() === 'HEAD') {
79
+ // The handle is ours until the read stream takes it; every path that does
80
+ // not hand it over closes it, or the process leaks a descriptor per 304.
81
+ let owned = true
82
+ try {
83
+ const etag = statTag(stat)
84
+
85
+ if (matchesIfNoneMatch(ctx.request.header('if-none-match'), etag)) {
86
+ ctx.response.status(304)
87
+ return
88
+ }
89
+
90
+ // One table for the whole package (`src/http/mime.ts`), generated from
91
+ // mime-db. A second hand-written one here is how `.js` came to be
92
+ // served as `application/javascript` from one code path and
93
+ // `text/javascript` from the other.
94
+ ctx.response.header('Content-Type', contentType(ext) || 'application/octet-stream')
95
+ ctx.response.header('Content-Length', String(stat.size))
96
+ ctx.response.header('Cache-Control', `public, max-age=${this.#maxAge}`)
97
+ ctx.response.header('ETag', etag)
98
+
99
+ if (ctx.request.method() === 'HEAD') {
100
+ ctx.response.status(200)
101
+ return
102
+ }
103
+
104
+ // Streamed, not read whole.
105
+ //
106
+ // `readFileSync` stalled the event loop for EVERY concurrent request
107
+ // while one client asked for a file, and the allowed extensions include
108
+ // `.mp4`, `.zip` and `.pdf` — so one large asset became every other
109
+ // request's latency, and each request allocated the file again. The
110
+ // stream applies backpressure and holds one chunk at a time.
111
+ //
112
+ // The read stream owns the handle from here and closes it at end.
118
113
  ctx.response.status(200)
119
- return
114
+ owned = false
115
+ await ctx.response.stream(handle.createReadStream())
116
+ } finally {
117
+ if (owned) await handle.close().catch(() => {})
120
118
  }
121
-
122
- // Send the raw Buffer — `content.toString()` would interpret PNG /
123
- // PDF / ZIP bytes as UTF-8 and replace any byte the encoding can't
124
- // round-trip (anything in the surrogate range, plus invalid
125
- // sequences) with U+FFFD.
126
- const content = fs.readFileSync(realFilePath)
127
- ctx.response.status(200).sendBuffer(content)
128
119
  }
129
120
 
130
121
  /**
131
- * Resolve a request path to a real, in-root, existing file, or `null` when it
132
- * is not servable (wrong prefix/extension, outside the root, missing, or a
133
- * symlink escaping the root). Returning `null` tells `handle` to call `next()`.
122
+ * Open a request path as a real, in-root, regular file, or answer `null`
123
+ * when it is not servable (wrong prefix/extension, outside the root,
124
+ * missing, or a symlink escaping the root). `null` tells `handle` to call
125
+ * `next()`.
126
+ *
127
+ * The file is OPENED here rather than merely checked, and its metadata read
128
+ * from the descriptor: what gets served is then the same file that passed
129
+ * the checks, which a path re-read after the fact cannot promise.
134
130
  */
135
- #resolveServableFile(
131
+ async #openServableFile(
136
132
  reqPath: string,
137
- ): { realFilePath: string; ext: string; stat: fs.Stats } | null {
133
+ ): Promise<{ handle: FileHandle; ext: string; stat: fs.Stats } | null> {
138
134
  // Match only when the prefix is followed by `/` or end-of-path —
139
135
  // otherwise `/staticx/foo.js` would be intercepted when the configured
140
136
  // prefix is `/static`, hijacking unrelated routes.
@@ -152,7 +148,7 @@ export class StaticMiddleware {
152
148
  // Two containment checks. The first uses `path.sep` to reject sibling
153
149
  // directories with a shared prefix (`/var/www-static-secret` slipping
154
150
  // past a guard for `/var/www-static`). The second resolves symlinks
155
- // via realpathSync so a symlink planted inside the root cannot point
151
+ // via realpath so a symlink planted inside the root cannot point
156
152
  // outside it. We do realpath AFTER the lexical check so a non-existent
157
153
  // path returns `next()` rather than throwing.
158
154
  const rootWithSep = this.#root.endsWith(path.sep) ? this.#root : this.#root + path.sep
@@ -160,15 +156,11 @@ export class StaticMiddleware {
160
156
  return null
161
157
  }
162
158
 
163
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
164
- return null
165
- }
166
-
167
159
  let realFilePath: string
168
160
  let realRoot: string
169
161
  try {
170
- realFilePath = fs.realpathSync(filePath)
171
- realRoot = fs.realpathSync(this.#root)
162
+ realFilePath = await fsp.realpath(filePath)
163
+ realRoot = await fsp.realpath(this.#root)
172
164
  } catch {
173
165
  return null
174
166
  }
@@ -177,6 +169,29 @@ export class StaticMiddleware {
177
169
  return null
178
170
  }
179
171
 
180
- return { realFilePath, ext, stat: fs.statSync(realFilePath) }
172
+ // O_NOFOLLOW closes the window the checks above leave open: between
173
+ // `realpath` and the read, the last path component can be replaced by a
174
+ // symlink pointing anywhere, and a read by name follows it. Refusing to
175
+ // follow a link at open time makes that swap fail the open instead of
176
+ // serving a file outside the root. A parent directory swapped in the same
177
+ // window is not covered — that needs openat(), which Node does not expose.
178
+ let handle: FileHandle
179
+ try {
180
+ handle = await fsp.open(realFilePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
181
+ } catch {
182
+ return null
183
+ }
184
+
185
+ try {
186
+ const stat = await handle.stat()
187
+ if (!stat.isFile()) {
188
+ await handle.close()
189
+ return null
190
+ }
191
+ return { handle, ext, stat }
192
+ } catch {
193
+ await handle.close().catch(() => {})
194
+ return null
195
+ }
181
196
  }
182
197
  }
@@ -149,15 +149,14 @@ function parseMultipartResponse(
149
149
  // Trim the trailing CRLF before the next boundary.
150
150
  let content = part.subarray(headerEnd + 4)
151
151
  if (content.subarray(-2).toString('binary') === '\r\n') content = content.subarray(0, -2)
152
- const nameMatch = headerText.match(/name="([^"]*)"/i)
153
- if (!nameMatch) continue
154
- const name = nameMatch[1]
155
- const fileMatch = headerText.match(/filename="([^"]*)"/i)
156
- if (fileMatch) {
152
+ const [, name] = headerText.match(/name="([^"]*)"/i) ?? []
153
+ if (name === undefined) continue
154
+ const [, filename] = headerText.match(/filename="([^"]*)"/i) ?? []
155
+ if (filename !== undefined) {
157
156
  const typeMatch = headerText.match(/content-type:\s*([^\r\n]+)/i)
158
157
  files[name] = {
159
158
  fieldName: name,
160
- filename: fileMatch[1],
159
+ filename,
161
160
  type: typeMatch?.[1]?.trim() ?? 'application/octet-stream',
162
161
  size: content.length,
163
162
  content,
@@ -355,8 +354,8 @@ export class ApiResponse {
355
354
  /** Charset from `Content-Type`, if any — helix `charset()`. */
356
355
  charset(): string | undefined {
357
356
  const ct = this.#raw.headers['content-type'] ?? ''
358
- const m = ct.match(/charset=([^;]+)/i)
359
- return m ? m[1].trim() : undefined
357
+ const [, charset] = ct.match(/charset=([^;]+)/i) ?? []
358
+ return charset?.trim()
360
359
  }
361
360
 
362
361
  /** All response cookies, parsed from `Set-Cookie` — helix `cookies()`. */
@@ -380,8 +379,8 @@ export class ApiResponse {
380
379
  if (!link) return {}
381
380
  const out: Record<string, string> = {}
382
381
  for (const part of link.split(',')) {
383
- const m = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/)
384
- if (m) out[m[2]] = m[1]
382
+ const [, url, rel] = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/) ?? []
383
+ if (url !== undefined && rel !== undefined) out[rel] = url
385
384
  }
386
385
  return out
387
386
  }