@c9up/ream 0.1.33 → 0.2.0

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 (215) hide show
  1. package/dist/Application.d.ts +68 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +142 -6
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ConfigLoader.d.ts +4 -0
  6. package/dist/ConfigLoader.d.ts.map +1 -1
  7. package/dist/ConfigLoader.js +0 -8
  8. package/dist/ConfigLoader.js.map +1 -1
  9. package/dist/HealthCheck.d.ts +14 -7
  10. package/dist/HealthCheck.d.ts.map +1 -1
  11. package/dist/HealthCheck.js +21 -12
  12. package/dist/HealthCheck.js.map +1 -1
  13. package/dist/HttpKernel.d.ts +12 -0
  14. package/dist/HttpKernel.d.ts.map +1 -1
  15. package/dist/HttpKernel.js +15 -4
  16. package/dist/HttpKernel.js.map +1 -1
  17. package/dist/Ignitor.d.ts +8 -0
  18. package/dist/Ignitor.d.ts.map +1 -1
  19. package/dist/Ignitor.js +11 -0
  20. package/dist/Ignitor.js.map +1 -1
  21. package/dist/bodyparser/BodyParserMiddleware.d.ts +13 -2
  22. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  23. package/dist/bodyparser/BodyParserMiddleware.js +50 -30
  24. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  25. package/dist/bodyparser/MultipartFile.d.ts +65 -6
  26. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  27. package/dist/bodyparser/MultipartFile.js +100 -7
  28. package/dist/bodyparser/MultipartFile.js.map +1 -1
  29. package/dist/console/BaseCommand.d.ts +8 -1
  30. package/dist/console/BaseCommand.d.ts.map +1 -1
  31. package/dist/console/BaseCommand.js +1 -1
  32. package/dist/console/BaseCommand.js.map +1 -1
  33. package/dist/console/Kernel.d.ts +15 -0
  34. package/dist/console/Kernel.d.ts.map +1 -1
  35. package/dist/console/Kernel.js +17 -0
  36. package/dist/console/Kernel.js.map +1 -1
  37. package/dist/directories.d.ts +52 -0
  38. package/dist/directories.d.ts.map +1 -0
  39. package/dist/directories.js +38 -0
  40. package/dist/directories.js.map +1 -0
  41. package/dist/events/Emitter.d.ts +49 -6
  42. package/dist/events/Emitter.d.ts.map +1 -1
  43. package/dist/events/Emitter.js +117 -7
  44. package/dist/events/Emitter.js.map +1 -1
  45. package/dist/events/EventsBuffer.d.ts +47 -0
  46. package/dist/events/EventsBuffer.d.ts.map +1 -0
  47. package/dist/events/EventsBuffer.js +107 -0
  48. package/dist/events/EventsBuffer.js.map +1 -0
  49. package/dist/events/index.d.ts +2 -0
  50. package/dist/events/index.d.ts.map +1 -1
  51. package/dist/events/index.js +1 -0
  52. package/dist/events/index.js.map +1 -1
  53. package/dist/health/BaseCheck.d.ts +19 -0
  54. package/dist/health/BaseCheck.d.ts.map +1 -0
  55. package/dist/health/BaseCheck.js +24 -0
  56. package/dist/health/BaseCheck.js.map +1 -0
  57. package/dist/health/HealthChecks.d.ts +15 -0
  58. package/dist/health/HealthChecks.d.ts.map +1 -0
  59. package/dist/health/HealthChecks.js +69 -0
  60. package/dist/health/HealthChecks.js.map +1 -0
  61. package/dist/health/Result.d.ts +27 -0
  62. package/dist/health/Result.d.ts.map +1 -0
  63. package/dist/health/Result.js +55 -0
  64. package/dist/health/Result.js.map +1 -0
  65. package/dist/health/checks/DiskSpaceCheck.d.ts +29 -0
  66. package/dist/health/checks/DiskSpaceCheck.d.ts.map +1 -0
  67. package/dist/health/checks/DiskSpaceCheck.js +58 -0
  68. package/dist/health/checks/DiskSpaceCheck.js.map +1 -0
  69. package/dist/health/checks/MemoryHeapCheck.d.ts +21 -0
  70. package/dist/health/checks/MemoryHeapCheck.d.ts.map +1 -0
  71. package/dist/health/checks/MemoryHeapCheck.js +55 -0
  72. package/dist/health/checks/MemoryHeapCheck.js.map +1 -0
  73. package/dist/health/checks/MemoryRSSCheck.d.ts +21 -0
  74. package/dist/health/checks/MemoryRSSCheck.d.ts.map +1 -0
  75. package/dist/health/checks/MemoryRSSCheck.js +55 -0
  76. package/dist/health/checks/MemoryRSSCheck.js.map +1 -0
  77. package/dist/health/checks/memory.d.ts +24 -0
  78. package/dist/health/checks/memory.d.ts.map +1 -0
  79. package/dist/health/checks/memory.js +88 -0
  80. package/dist/health/checks/memory.js.map +1 -0
  81. package/dist/health/index.d.ts +9 -0
  82. package/dist/health/index.d.ts.map +1 -0
  83. package/dist/health/index.js +8 -0
  84. package/dist/health/index.js.map +1 -0
  85. package/dist/health/tracing.d.ts +12 -0
  86. package/dist/health/tracing.d.ts.map +1 -0
  87. package/dist/health/tracing.js +11 -0
  88. package/dist/health/tracing.js.map +1 -0
  89. package/dist/health/types.d.ts +51 -0
  90. package/dist/health/types.d.ts.map +1 -0
  91. package/dist/health/types.js +5 -0
  92. package/dist/health/types.js.map +1 -0
  93. package/dist/helpers/bytes.d.ts +28 -0
  94. package/dist/helpers/bytes.d.ts.map +1 -0
  95. package/dist/helpers/bytes.js +63 -0
  96. package/dist/helpers/bytes.js.map +1 -0
  97. package/dist/helpers/string.d.ts +4 -0
  98. package/dist/helpers/string.d.ts.map +1 -1
  99. package/dist/helpers/string.js +2 -0
  100. package/dist/helpers/string.js.map +1 -1
  101. package/dist/http/HttpContext.d.ts +38 -2
  102. package/dist/http/HttpContext.d.ts.map +1 -1
  103. package/dist/http/HttpContext.js +81 -3
  104. package/dist/http/HttpContext.js.map +1 -1
  105. package/dist/http/Request.d.ts +57 -3
  106. package/dist/http/Request.d.ts.map +1 -1
  107. package/dist/http/Request.js +51 -38
  108. package/dist/http/Request.js.map +1 -1
  109. package/dist/http/Response.d.ts +82 -2
  110. package/dist/http/Response.d.ts.map +1 -1
  111. package/dist/http/Response.js +240 -13
  112. package/dist/http/Response.js.map +1 -1
  113. package/dist/http/SseStream.d.ts +10 -0
  114. package/dist/http/SseStream.d.ts.map +1 -1
  115. package/dist/http/SseStream.js.map +1 -1
  116. package/dist/index.d.ts +7 -2
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +4 -1
  119. package/dist/index.js.map +1 -1
  120. package/dist/migrations/MigrationRegistry.d.ts +28 -0
  121. package/dist/migrations/MigrationRegistry.d.ts.map +1 -0
  122. package/dist/migrations/MigrationRegistry.js +41 -0
  123. package/dist/migrations/MigrationRegistry.js.map +1 -0
  124. package/dist/migrations/types.d.ts +64 -0
  125. package/dist/migrations/types.d.ts.map +1 -0
  126. package/dist/migrations/types.js +15 -0
  127. package/dist/migrations/types.js.map +1 -0
  128. package/dist/router/Router.d.ts +112 -9
  129. package/dist/router/Router.d.ts.map +1 -1
  130. package/dist/router/Router.js +206 -28
  131. package/dist/router/Router.js.map +1 -1
  132. package/dist/security/CookieSigner.d.ts +43 -4
  133. package/dist/security/CookieSigner.d.ts.map +1 -1
  134. package/dist/security/CookieSigner.js +68 -10
  135. package/dist/security/CookieSigner.js.map +1 -1
  136. package/dist/session/Session.d.ts +46 -1
  137. package/dist/session/Session.d.ts.map +1 -1
  138. package/dist/session/Session.js +57 -4
  139. package/dist/session/Session.js.map +1 -1
  140. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  141. package/dist/session/SessionMiddleware.js +19 -9
  142. package/dist/session/SessionMiddleware.js.map +1 -1
  143. package/dist/session/drivers/CookieDriver.d.ts +1 -1
  144. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  145. package/dist/session/drivers/CookieDriver.js +3 -3
  146. package/dist/session/drivers/CookieDriver.js.map +1 -1
  147. package/dist/session/drivers/DatabaseDriver.d.ts +1 -1
  148. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  149. package/dist/session/drivers/DatabaseDriver.js +2 -2
  150. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  151. package/dist/session/drivers/FileDriver.d.ts +1 -1
  152. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  153. package/dist/session/drivers/FileDriver.js +7 -5
  154. package/dist/session/drivers/FileDriver.js.map +1 -1
  155. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  156. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  157. package/dist/session/drivers/MemoryDriver.js +1 -1
  158. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  159. package/dist/session/drivers/RedisDriver.d.ts +1 -1
  160. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  161. package/dist/session/drivers/RedisDriver.js +11 -4
  162. package/dist/session/drivers/RedisDriver.js.map +1 -1
  163. package/events.win32-x64-msvc.node +0 -0
  164. package/index.darwin-arm64.node +0 -0
  165. package/index.darwin-x64.node +0 -0
  166. package/index.linux-arm64-gnu.node +0 -0
  167. package/index.linux-x64-gnu.node +0 -0
  168. package/index.win32-x64-msvc.node +0 -0
  169. package/package.json +5 -2
  170. package/scheduler.win32-x64-msvc.node +0 -0
  171. package/src/Application.ts +169 -6
  172. package/src/ConfigLoader.ts +6 -0
  173. package/src/HealthCheck.ts +27 -14
  174. package/src/HttpKernel.ts +27 -4
  175. package/src/Ignitor.ts +17 -0
  176. package/src/bodyparser/BodyParserMiddleware.ts +70 -36
  177. package/src/bodyparser/MultipartFile.ts +125 -8
  178. package/src/console/BaseCommand.ts +8 -1
  179. package/src/console/Kernel.ts +18 -0
  180. package/src/directories.ts +81 -0
  181. package/src/events/Emitter.ts +129 -13
  182. package/src/events/EventsBuffer.ts +131 -0
  183. package/src/events/index.ts +7 -0
  184. package/src/health/BaseCheck.ts +30 -0
  185. package/src/health/HealthChecks.ts +83 -0
  186. package/src/health/Result.ts +69 -0
  187. package/src/health/checks/DiskSpaceCheck.ts +69 -0
  188. package/src/health/checks/MemoryHeapCheck.ts +68 -0
  189. package/src/health/checks/MemoryRSSCheck.ts +68 -0
  190. package/src/health/checks/memory.ts +115 -0
  191. package/src/health/index.ts +13 -0
  192. package/src/health/tracing.ts +15 -0
  193. package/src/health/types.ts +51 -0
  194. package/src/helpers/bytes.ts +71 -0
  195. package/src/helpers/string.ts +2 -0
  196. package/src/http/HttpContext.ts +86 -4
  197. package/src/http/Request.ts +82 -10
  198. package/src/http/Response.ts +283 -14
  199. package/src/http/SseStream.ts +10 -0
  200. package/src/index.ts +27 -1
  201. package/src/migrations/MigrationRegistry.ts +49 -0
  202. package/src/migrations/types.ts +68 -0
  203. package/src/router/Router.ts +241 -32
  204. package/src/security/CookieSigner.ts +92 -18
  205. package/src/session/Session.ts +71 -5
  206. package/src/session/SessionMiddleware.ts +19 -9
  207. package/src/session/drivers/CookieDriver.ts +4 -4
  208. package/src/session/drivers/DatabaseDriver.ts +3 -3
  209. package/src/session/drivers/FileDriver.ts +8 -6
  210. package/src/session/drivers/MemoryDriver.ts +2 -2
  211. package/src/session/drivers/RedisDriver.ts +12 -5
  212. package/tsconfig.app.json +13 -13
  213. package/tsconfig.base.json +19 -0
  214. package/tsconfig.client.json +16 -0
  215. package/tsconfig.package.json +15 -0
@@ -66,7 +66,7 @@ export class Request extends Macroable {
66
66
  #signedUrl?: SignedUrl
67
67
  #allowMethodSpoofing = false
68
68
  #trustProxy = false
69
- #routeInfo?: { name?: string; pattern: string }
69
+ #routeInfo?: { name?: string; pattern: string; reference?: string }
70
70
  #response?: { fresh(): boolean }
71
71
  #parsedBody: Dict<unknown> | undefined
72
72
  #parsedQs: Dict<unknown> | undefined
@@ -122,13 +122,32 @@ export class Request extends Macroable {
122
122
  return this.#raw.method
123
123
  }
124
124
 
125
+ /**
126
+ * The context this request belongs to (AdonisJS `request.ctx`).
127
+ *
128
+ * Wired by HttpContext. Optional because a Request built by hand — a test,
129
+ * a fixture — has no context around it.
130
+ */
131
+ ctx?: object
132
+
133
+ /**
134
+ * Seat the parsed body (AdonisJS `setInitialBody`).
135
+ *
136
+ * What a body parser calls once it has decoded the payload. An alias of
137
+ * {@link setParsedBody}, which is the name ream used first — both are here so
138
+ * a middleware written against either resolves.
139
+ */
140
+ setInitialBody(body: Dict<unknown>): void {
141
+ this.setParsedBody(body)
142
+ }
143
+
125
144
  /** @internal Give the request access to its response — wired by HttpContext (for `fresh()`). */
126
145
  setResponse(response: { fresh(): boolean }): void {
127
146
  this.#response = response
128
147
  }
129
148
 
130
149
  /** @internal Record the matched route — wired by HttpContext (for `matchesRoute()`). */
131
- setRouteInfo(info: { name?: string; pattern: string }): void {
150
+ setRouteInfo(info: { name?: string; pattern: string; reference?: string }): void {
132
151
  this.#routeInfo = info
133
152
  }
134
153
 
@@ -146,10 +165,26 @@ export class Request extends Macroable {
146
165
  return !this.fresh()
147
166
  }
148
167
 
149
- /** True when the matched route's name or pattern equals `identifier` (AdonisJS `matchesRoute`). */
150
- matchesRoute(identifier: string): boolean {
151
- if (!this.#routeInfo) return false
152
- return this.#routeInfo.name === identifier || this.#routeInfo.pattern === identifier
168
+ /**
169
+ * True when the matched route's name, pattern, or controller reference is one
170
+ * of `identifier` (AdonisJS `matchesRoute`).
171
+ *
172
+ * Takes a list as well as a single value, as upstream does — the usual call
173
+ * is "am I on any of these routes", and a caller with several had to write
174
+ * the `.some()` themselves.
175
+ *
176
+ * The reference is the `'ControllerName.method'` form the router already
177
+ * accepts as a handler, which is the identity AdonisJS matches on through
178
+ * `route.handler.reference`. Absent for an inline handler, which has no name
179
+ * to be addressed by.
180
+ */
181
+ matchesRoute(identifier: string | string[]): boolean {
182
+ const info = this.#routeInfo
183
+ if (!info) return false
184
+ const candidates = Array.isArray(identifier) ? identifier : [identifier]
185
+ return candidates.some(
186
+ (one) => info.name === one || info.pattern === one || info.reference === one,
187
+ )
153
188
  }
154
189
 
155
190
  /**
@@ -161,8 +196,15 @@ export class Request extends Macroable {
161
196
  this.#allowMethodSpoofing = enabled
162
197
  }
163
198
 
164
- /** Request URL (path + query string). */
165
- url(includeQs = true): string {
199
+ /**
200
+ * The request URL — the PATHNAME by default, as in AdonisJS
201
+ * (`url(includeQueryString)`, false unless asked). Ream defaulted to
202
+ * including the query string, so a value used as a cache key, a log line or
203
+ * a route comparison silently carried the query with it.
204
+ *
205
+ * Pass `true` for path + query; {@link completeUrl} adds the origin.
206
+ */
207
+ url(includeQs = false): string {
166
208
  if (includeQs && this.#raw.query) {
167
209
  return `${this.#raw.path}?${this.#raw.query}`
168
210
  }
@@ -330,7 +372,14 @@ export class Request extends Macroable {
330
372
  * Signed cookie value, verified with APP_KEY (AdonisJS default). Returns
331
373
  * `defaultValue` (or null) when absent OR when the signature is invalid
332
374
  * (tampered / not signed).
375
+ *
376
+ * NAMED DEVIATION — the fallback is `??`, where AdonisJS writes `||`. A
377
+ * cookie legitimately holding `0`, `false` or `""` is a value, not an
378
+ * absence, and upstream hands back the default for all three. Kept.
333
379
  */
380
+ cookie(name: string): string | null
381
+ cookie(name: string, defaultValue: string): string
382
+ cookie(name: string, defaultValue?: string): string | null
334
383
  cookie(name: string, defaultValue?: string): string | null {
335
384
  const raw = this.plainCookie<string>(name, undefined, { encoded: false })
336
385
  const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
@@ -347,6 +396,14 @@ export class Request extends Macroable {
347
396
  *
348
397
  * Pass `encoded: false` to skip unpacking entirely.
349
398
  */
399
+ plainCookie<T = string>(name: string): T | null
400
+ plainCookie<T = string>(name: string, defaultValue: T, options?: { encoded?: boolean }): T
401
+ plainCookie<T = string>(
402
+ name: string,
403
+ defaultValue: undefined,
404
+ options?: { encoded?: boolean },
405
+ ): T | null
406
+ plainCookie<T = string>(name: string, defaultValue?: T, options?: { encoded?: boolean }): T | null
350
407
  plainCookie<T = string>(
351
408
  name: string,
352
409
  defaultValue?: T,
@@ -365,6 +422,9 @@ export class Request extends Macroable {
365
422
  * Returns `defaultValue` (or null) when absent, undecryptable, or no
366
423
  * encryption service.
367
424
  */
425
+ encryptedCookie(name: string): string | null
426
+ encryptedCookie(name: string, defaultValue: string): string
427
+ encryptedCookie(name: string, defaultValue?: string): string | null
368
428
  encryptedCookie(name: string, defaultValue?: string): string | null {
369
429
  const raw = this.plainCookie<string>(name, undefined, { encoded: false })
370
430
  const value = raw === null || !this.#cookieSigner ? null : this.#cookieSigner.decrypt(raw)
@@ -384,7 +444,9 @@ export class Request extends Macroable {
384
444
  */
385
445
  hasValidSignature(purpose?: string): boolean {
386
446
  if (!this.#signedUrl) return false
387
- return this.#signedUrl.verify(this.url(), purpose)
447
+ // `url(true)`: the signature covers the path AND the query string —
448
+ // the expiry and the signature itself live in the query.
449
+ return this.#signedUrl.verify(this.url(true), purpose)
388
450
  }
389
451
 
390
452
  /** Get the raw body as a string (decoded from base64 if binary). */
@@ -407,6 +469,9 @@ export class Request extends Macroable {
407
469
  // ─── Headers ──────────────────────────────────────────────
408
470
 
409
471
  /** Get a single request header (case-insensitive), or `defaultValue` when absent. */
472
+ header(key: string): string | undefined
473
+ header(key: string, defaultValue: string): string
474
+ header(key: string, defaultValue?: string): string | undefined
410
475
  header(key: string, defaultValue?: string): string | undefined {
411
476
  return this.#raw.headers[key.toLowerCase()] ?? defaultValue
412
477
  }
@@ -426,6 +491,9 @@ export class Request extends Macroable {
426
491
  * value was not a string would be worse than the path it describes.
427
492
  * Use {@link params} to get the segments themselves.
428
493
  */
494
+ param(key: string): string | undefined
495
+ param(key: string, defaultValue: string): string
496
+ param(key: string, defaultValue?: string): string | undefined
429
497
  param(key: string, defaultValue?: string): string | undefined {
430
498
  const value = this.#params[key]
431
499
  if (value === undefined) return defaultValue
@@ -663,7 +731,11 @@ export class Request extends Macroable {
663
731
  all(): Dict<unknown> {
664
732
  if (!this.#merged) {
665
733
  this.#ensureParsedBody()
666
- this.#merged = { ...this.qs(), ...this.#parsedBody }
734
+ // `{ ...body, ...qs }` — the QUERY STRING wins, as in AdonisJS
735
+ // (`#requestData = { ...#requestBody, ...#requestQs }`). Ream had it the
736
+ // other way round, so `?id=1` with a body `{ id: 2 }` read 2 here and 1
737
+ // there — silently, on the request field an app trusts most.
738
+ this.#merged = { ...this.#parsedBody, ...this.qs() }
667
739
  // Snapshot the first-seen input as the immutable "original" (flash old-input).
668
740
  if (this.#original === undefined) this.#original = { ...this.#merged }
669
741
  }
@@ -7,11 +7,20 @@
7
7
  * @implements FR21
8
8
  */
9
9
 
10
- import { readFileSync } from 'node:fs'
10
+ import { randomUUID } from 'node:crypto'
11
+ import { createReadStream } from 'node:fs'
12
+ import { readFile, stat } from 'node:fs/promises'
11
13
  import { basename } from 'node:path'
12
14
  import etag from 'etag'
13
15
  import { contentType } from 'mime-types'
14
16
  import { durationToSeconds } from '../helpers/duration.js'
17
+
18
+ /**
19
+ * Default ceiling on a buffered response body — the same 100MB the Rust layer
20
+ * caps an incoming body at (`MAX_BODY_BYTES` in crates/ream-http/src/server.rs).
21
+ */
22
+ const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024
23
+
15
24
  import type { CookieSigner } from '../security/CookieSigner.js'
16
25
  import { Macroable } from '../utils/Macroable.js'
17
26
  import { E_HTTP_REQUEST_ABORTED } from './Exception.js'
@@ -133,13 +142,32 @@ export interface CookieOptions {
133
142
 
134
143
  export class Response extends Macroable {
135
144
  #status = 200
136
- /** A body still being drained by {@link stream}; awaited by {@link settle}. */
145
+ /** A body still being drained by {@link stream}; awaited by {@link finish}. */
137
146
  #pendingStream: Promise<void> | undefined
147
+ /** The detached chunk pump, when one is running. See {@link streamed}. */
148
+ #pumping: Promise<void> | undefined
149
+ /** Whether {@link finish} has run — its idempotence guard. */
150
+ #finalised = false
138
151
  #headers: Record<string, string> = {}
139
152
  #cookies: string[] = []
140
153
  #body = ''
141
154
  readonly #finishCallbacks: Array<(err: Error | null) => void> = []
142
155
  #finished = false
156
+ /**
157
+ * The context this response belongs to (AdonisJS `response.ctx`).
158
+ *
159
+ * Wired by HttpContext. Optional because a Response built by hand — a test,
160
+ * a fixture — has no context around it.
161
+ */
162
+ ctx?: object
163
+
164
+ /** Default JSONP callback name (AdonisJS `http.jsonpCallbackName`). */
165
+ #jsonpCallbackName = 'callback'
166
+ /**
167
+ * Ceiling on a buffered response body. Mirrors the 100MB the Rust layer caps
168
+ * an incoming body at, so the two directions are explained the same way.
169
+ */
170
+ #maxBodyBytes = DEFAULT_MAX_RESPONSE_BYTES
143
171
  #redirectBuilderFactory?: () => RedirectBuilder
144
172
  #streamBackend?: StreamBackend
145
173
  #streamId?: string
@@ -268,7 +296,16 @@ export class Response extends Macroable {
268
296
  * (valid in JSON, but break JS), so neither the callback nor the payload can
269
297
  * inject script.
270
298
  */
271
- jsonp(body: unknown, callbackName = 'callback'): void {
299
+ /**
300
+ * Default callback name when `jsonp()` is called without one (AdonisJS reads
301
+ * it from `http.jsonpCallbackName`). Injected by HttpKernel, the same way the
302
+ * cookie signer and signed-URL services are.
303
+ */
304
+ setJsonpCallbackName(name: string): void {
305
+ this.#jsonpCallbackName = name
306
+ }
307
+
308
+ jsonp(body: unknown, callbackName: string = this.#jsonpCallbackName): void {
272
309
  // Sanitise the callback name to identifier-safe characters — the JSONP XSS
273
310
  // guard (an attacker-controlled callback must not inject script).
274
311
  const safeCallback = callbackName.replace(/[^\w$.]/g, '')
@@ -323,6 +360,7 @@ export class Response extends Macroable {
323
360
  * unambiguous.
324
361
  */
325
362
  sendBuffer(buffer: Buffer): void {
363
+ this.#assertBodyFits(buffer.length)
326
364
  if (!this.#headers['content-type']) {
327
365
  this.#headers['content-type'] = 'application/octet-stream'
328
366
  }
@@ -331,6 +369,34 @@ export class Response extends Macroable {
331
369
  this.#finished = true
332
370
  }
333
371
 
372
+ /**
373
+ * Refuse a body too large to hold in memory.
374
+ *
375
+ * A response body is serialised whole across the NAPI boundary — there is no
376
+ * chunked write yet — and a binary one is base64'd on the way, so it costs
377
+ * roughly 2.3x its size in transient memory: the Buffer, the encoded string,
378
+ * and the Rust-side decode. Without a ceiling a large file did not fail, it
379
+ * grew until the process died, with no message naming the cause.
380
+ *
381
+ * Raise it with `maxResponseBytes` in the kernel config when you know the
382
+ * memory is there. For anything genuinely large, hand out a signed URL from
383
+ * `@c9up/archive` instead and let the client fetch it from storage — the
384
+ * bytes then never pass through the server at all.
385
+ */
386
+ #assertBodyFits(bytes: number): void {
387
+ if (bytes <= this.#maxBodyBytes) return
388
+ throw new Error(
389
+ `[E_RESPONSE_TOO_LARGE] Response body is ${Math.round(bytes / 1_048_576)}MB, over the ${Math.round(this.#maxBodyBytes / 1_048_576)}MB ceiling. ` +
390
+ 'The body is held whole in memory (and base64-encoded, ~2.3x) because responses are not streamed yet. ' +
391
+ 'Raise `maxResponseBytes` in the kernel config, or serve large files with a signed URL from @c9up/archive.',
392
+ )
393
+ }
394
+
395
+ /** @internal Set the ceiling — injected by HttpKernel from its config. */
396
+ setMaxBodyBytes(bytes: number): void {
397
+ this.#maxBodyBytes = bytes
398
+ }
399
+
334
400
  /** Send 204 No Content. */
335
401
  noContent(): void {
336
402
  this.#status = 204
@@ -588,14 +654,31 @@ export class Response extends Macroable {
588
654
 
589
655
  // ─── Redirect ─────────────────────────────────────────────
590
656
 
591
- /** Get a redirect builder. */
592
- redirect(): RedirectBuilder {
593
- if (this.#redirectBuilderFactory) {
594
- return this.#redirectBuilderFactory()
657
+ /**
658
+ * Redirect, or get the builder.
659
+ *
660
+ * `redirect()` with no argument hands back the builder to chain on. With a
661
+ * PATH it redirects immediately, as AdonisJS does
662
+ * (`redirect(path, forwardQueryString?, statusCode?)`); `'back'` goes to the
663
+ * referrer. Ream always returned the builder, so `response.redirect('/login')`
664
+ * — the shortest and most obvious spelling — silently did nothing.
665
+ */
666
+ redirect(): RedirectBuilder
667
+ redirect(path: string, forwardQueryString?: boolean, statusCode?: number): void
668
+ redirect(path?: string, forwardQueryString = false, statusCode = 302): RedirectBuilder | void {
669
+ if (!this.#redirectBuilderFactory) {
670
+ throw new Error(
671
+ 'redirect() requires an HttpContext. Response was created outside a request handler.',
672
+ )
595
673
  }
596
- throw new Error(
597
- 'redirect() requires an HttpContext. Response was created outside a request handler.',
598
- )
674
+ const builder = this.#redirectBuilderFactory()
675
+ if (path === undefined) return builder
676
+ if (forwardQueryString) builder.withQs()
677
+ if (path === 'back') {
678
+ builder.status(statusCode).back()
679
+ return
680
+ }
681
+ builder.status(statusCode).toPath(path)
599
682
  }
600
683
 
601
684
  /** @internal Set the redirect builder factory (injected by HttpContext). */
@@ -670,7 +753,40 @@ export class Response extends Macroable {
670
753
  * @internal Wait for a body still being produced (see {@link stream}). The
671
754
  * kernel calls this before serializing; everything else resolves at once.
672
755
  */
673
- async settle(): Promise<void> {
756
+ /**
757
+ * Resolves when a streamed body has finished feeding the socket.
758
+ *
759
+ * Deliberately NOT part of {@link finish}: the response has to reach Rust
760
+ * with its stream id before the body can be attached, so waiting for the
761
+ * pump there would deadlock — and closing the stream first is what made the
762
+ * client see `E_STREAM_UNKNOWN` instead of the file. This is for a test that
763
+ * wants the finished body, and for a shutdown that would rather let an
764
+ * in-flight download end than cut it.
765
+ */
766
+ streamed(): Promise<void> {
767
+ return this.#pumping ?? Promise.resolve()
768
+ }
769
+
770
+ /**
771
+ * Finalise the response — the terminal step (AdonisJS `response.finish()`).
772
+ *
773
+ * Same role and same guarantees as upstream: idempotent, it stamps the
774
+ * request id, and afterwards nothing more is added to the response. The
775
+ * kernel calls it before reading the response out; an app rarely needs to.
776
+ *
777
+ * NAMED DEVIATION — it returns a promise where upstream returns void, and it
778
+ * writes nothing. Ream never owns the socket: the whole response crosses the
779
+ * NAPI boundary and Rust writes it, so "finish" here means *sealed and ready
780
+ * to hand over* rather than *flushed*. The await is for a body still being
781
+ * produced — `download()` and a buffered `stream()` fill it asynchronously.
782
+ *
783
+ * A STREAMED body is deliberately not awaited: it feeds the socket after the
784
+ * response has been handed over. See {@link streamed}.
785
+ */
786
+ async finish(): Promise<void> {
787
+ if (this.#finalised) return
788
+ this.#finalised = true
789
+ this.setRequestId()
674
790
  const pending = this.#pendingStream
675
791
  if (pending === undefined) return
676
792
  this.#pendingStream = undefined
@@ -687,9 +803,25 @@ export class Response extends Macroable {
687
803
  generateEtag = false,
688
804
  errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
689
805
  ): void {
690
- let content: Buffer
806
+ // Read asynchronously, parked on the same pending-body slot `stream()`
807
+ // uses and `finish()` already awaits before serialisation. It used to be
808
+ // `readFileSync`, which stalled the event loop for EVERY concurrent
809
+ // request while one client downloaded — the file's size became every
810
+ // other request's latency.
811
+ this.#pendingStream = this.#readForDownload(filePath, generateEtag, errorCallback)
812
+ }
813
+
814
+ async #readForDownload(
815
+ filePath: string,
816
+ generateEtag: boolean,
817
+ errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
818
+ ): Promise<void> {
819
+ // Stat first: a missing or unreadable file must answer 404 BEFORE any
820
+ // header goes out, and once a stream starts there is no status left to
821
+ // change.
822
+ let size: number
691
823
  try {
692
- content = readFileSync(filePath)
824
+ size = (await stat(filePath)).size
693
825
  } catch (error) {
694
826
  if (errorCallback) {
695
827
  const [message, status] = errorCallback(error as NodeJS.ErrnoException)
@@ -699,8 +831,20 @@ export class Response extends Macroable {
699
831
  this.status(404).send('Not Found')
700
832
  return
701
833
  }
834
+
702
835
  const ct = contentType(basename(filePath))
703
836
  if (ct) this.#headers['content-type'] = ct
837
+
838
+ // An ETag is a hash of the whole file, so asking for one asks for the file
839
+ // in memory. Without one, stream it: the body goes out as it is read and
840
+ // nothing bigger than a chunk is ever held.
841
+ if (!generateEtag && this.#streamBackend?.writeStreamBytes !== undefined) {
842
+ this.#headers['content-length'] = String(size)
843
+ await this.#pipe(createReadStream(filePath), errorCallback)
844
+ return
845
+ }
846
+
847
+ const content = await readFile(filePath)
704
848
  if (generateEtag) this.setEtag(content)
705
849
  this.sendBuffer(content)
706
850
  }
@@ -771,11 +915,78 @@ export class Response extends Macroable {
771
915
  body: NodeJS.ReadableStream,
772
916
  errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
773
917
  ): Promise<void> {
774
- const draining = this.#drain(body, errorCallback)
918
+ const draining = this.#pipe(body, errorCallback)
775
919
  this.#pendingStream = draining
776
920
  return draining
777
921
  }
778
922
 
923
+ /**
924
+ * Send a readable as the body.
925
+ *
926
+ * Chunks go to the socket as they arrive when the host can carry them —
927
+ * nothing bigger than one chunk is ever held, so the file's size stops being
928
+ * the process's memory ceiling. A host with no binary stream backend (a unit
929
+ * test, a mock server) falls back to draining into memory, which is what
930
+ * this always used to do.
931
+ */
932
+ async #pipe(
933
+ body: NodeJS.ReadableStream,
934
+ errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
935
+ ): Promise<void> {
936
+ const backend = this.#streamBackend
937
+ const writeBytes = backend?.writeStreamBytes
938
+ if (backend === undefined || writeBytes === undefined) {
939
+ return this.#drain(body, errorCallback)
940
+ }
941
+
942
+ const streamId = randomUUID()
943
+ if (!(await backend.registerStream(streamId))) {
944
+ // The id collided — vanishingly unlikely, but answering with a body
945
+ // nobody feeds would hang the client forever.
946
+ return this.#drain(body, errorCallback)
947
+ }
948
+ if (!this.#headers['content-type']) {
949
+ this.#headers['content-type'] = 'application/octet-stream'
950
+ }
951
+ this.#streamId = streamId
952
+ this.#finished = true
953
+
954
+ // Pump DETACHED, exactly as `sse()` does. What is awaited here is only the
955
+ // registration: the kernel awaits this before handing the response back to
956
+ // Rust, and Rust then looks the id up to attach the body. Awaiting the pump
957
+ // instead would close the stream before that lookup ever happened, and the
958
+ // client would get `E_STREAM_UNKNOWN` in place of the file.
959
+ this.#pumping = this.#pump(backend, writeBytes.bind(backend), streamId, body, errorCallback)
960
+ }
961
+
962
+ /** Feed a registered stream until the source ends or the client leaves. */
963
+ async #pump(
964
+ backend: StreamBackend,
965
+ writeBytes: (id: string, chunk: Uint8Array) => Promise<boolean>,
966
+ streamId: string,
967
+ body: NodeJS.ReadableStream,
968
+ errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
969
+ ): Promise<void> {
970
+ try {
971
+ for await (const chunk of body) {
972
+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
973
+ // `false` means the client is gone. Stop reading the source rather
974
+ // than pumping a whole file into a closed socket.
975
+ if (!(await writeBytes(streamId, bytes))) break
976
+ }
977
+ } catch (err) {
978
+ // The headers are already out, so there is no status left to change: end
979
+ // the body and let the client see a short read. `errorCallback` is still
980
+ // consulted for whatever logging it does.
981
+ errorCallback?.(err as NodeJS.ErrnoException)
982
+ } finally {
983
+ // Never rethrows: this runs detached, and an unhandled rejection here
984
+ // would take the process down over one client's download.
985
+ await backend.closeStream(streamId).catch(() => {})
986
+ }
987
+ }
988
+
989
+ /** Read the whole body into memory. The fallback when nothing can stream. */
779
990
  async #drain(
780
991
  body: NodeJS.ReadableStream,
781
992
  errorCallback?: (error: NodeJS.ErrnoException) => [string, number?],
@@ -991,6 +1202,64 @@ export class Response extends Macroable {
991
1202
  return this.#finished
992
1203
  }
993
1204
 
1205
+ // ─── State (AdonisJS getters) ──────────────────────────────
1206
+ //
1207
+ // Same questions AdonisJS answers on its response, under the same names. Ours
1208
+ // only had `isFinished()`, so a migrated `if (response.finished)` read
1209
+ // `undefined` and took the wrong branch without a word.
1210
+
1211
+ /** Whether the response has been written out. */
1212
+ get finished(): boolean {
1213
+ return this.#finished
1214
+ }
1215
+
1216
+ /** Whether a body has been set. */
1217
+ get hasContent(): boolean {
1218
+ return this.#body.length > 0
1219
+ }
1220
+
1221
+ /** Whether the body is a stream still being drained. */
1222
+ get hasStream(): boolean {
1223
+ return this.#pendingStream !== undefined
1224
+ }
1225
+
1226
+ /** Whether a body of any kind is waiting to go out. */
1227
+ get hasLazyBody(): boolean {
1228
+ return this.hasContent || this.hasStream
1229
+ }
1230
+
1231
+ /**
1232
+ * Whether the headers have gone out.
1233
+ *
1234
+ * Ream serialises the whole response across the NAPI boundary in one step,
1235
+ * so headers and body leave together: this is true exactly when the response
1236
+ * is finished, rather than tracking a separate Node `headersSent`.
1237
+ */
1238
+ get headersSent(): boolean {
1239
+ return this.#finished
1240
+ }
1241
+
1242
+ /** Whether nothing has been sent yet — the window a middleware can still write in. */
1243
+ get isPending(): boolean {
1244
+ return !this.headersSent && !this.finished
1245
+ }
1246
+
1247
+ /**
1248
+ * Echo the caller's `x-request-id` back on the response (AdonisJS
1249
+ * `setRequestId`).
1250
+ *
1251
+ * Ream already READS that header into `ctx.id` — validating its shape and
1252
+ * generating one when it is missing — but never sent it back, so a caller
1253
+ * could not tie a response to the request id it issued. Nothing is echoed
1254
+ * when the client sent none: inventing one here would hand back an id the
1255
+ * caller never used.
1256
+ */
1257
+ setRequestId(): this {
1258
+ const incoming = this.#request?.header('x-request-id')
1259
+ if (incoming) this.header('x-request-id', incoming)
1260
+ return this
1261
+ }
1262
+
994
1263
  /** @internal Set body directly (used by redirect, exception handler). */
995
1264
  setBody(body: string): void {
996
1265
  this.#body = body
@@ -37,6 +37,16 @@
37
37
  export interface StreamBackend {
38
38
  registerStream(streamId: string): Promise<boolean>
39
39
  writeStream(streamId: string, chunk: string): Promise<boolean>
40
+ /**
41
+ * Push a BINARY chunk, waiting for room rather than dropping it.
42
+ *
43
+ * `writeStream` is shaped for events: text, and a full buffer discards the
44
+ * frame — right when a missed event is a missed event, wrong when the client
45
+ * is reassembling a file and a missing frame is a corrupt download. Optional
46
+ * so a host that only serves SSE need not implement it; `response.stream()`
47
+ * falls back to buffering when it is absent.
48
+ */
49
+ writeStreamBytes?(streamId: string, chunk: Uint8Array): Promise<boolean>
40
50
  closeStream(streamId: string): Promise<boolean>
41
51
  onStreamDisconnect(streamId: string, callback: () => void): void
42
52
  }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export { Container } from './container/Container.js'
14
14
  export { ContainerResolver } from './container/ContainerResolver.js'
15
15
  export { ContextualBindingsBuilder } from './container/ContextualBindingsBuilder.js'
16
16
  export type { Binding, ServiceFactory, ServiceScope, ServiceToken } from './container/types.js'
17
+ export { type DirectoriesNode, directories } from './directories.js'
17
18
  export { Env, EnvValidationException } from './env/Env.js'
18
19
  export { loadEnvFiles } from './env/loadEnvFiles.js'
19
20
  export { Secret } from './env/Secret.js'
@@ -25,6 +26,13 @@ export {
25
26
  type SchemaNode,
26
27
  type StringOptions,
27
28
  } from './env/schema.js'
29
+ export { MigrationRegistry } from './migrations/MigrationRegistry.js'
30
+ export type {
31
+ MigrationRunnerContract,
32
+ MigrationState,
33
+ MigrationStatusNode,
34
+ RegisteredMigrationSource,
35
+ } from './migrations/types.js'
28
36
 
29
37
  // ─── HTTP (new — AdonisJS-compatible) ───────────────────────
30
38
 
@@ -164,8 +172,16 @@ export type {
164
172
  RouteDefinition,
165
173
  RouteHandler,
166
174
  RouteHandlerFunction,
175
+ SignedUrlOptions,
176
+ } from './router/Router.js'
177
+ export {
178
+ GroupBuilder,
179
+ matchers,
180
+ OnRouteBuilder,
181
+ RouteBuilder,
182
+ Router,
183
+ UrlBuilder,
167
184
  } from './router/Router.js'
168
- export { GroupBuilder, matchers, OnRouteBuilder, RouteBuilder, Router } from './router/Router.js'
169
185
 
170
186
  // ─── Middleware ──────────────────────────────────────────────
171
187
 
@@ -290,12 +306,22 @@ export { setServer } from './services/server.js'
290
306
  // `@c9up/ream/events` subpath so importing the main barrel stays lazy.
291
307
  export type { EventsConfig } from './events/config.js'
292
308
  export { defineConfig as defineEventsConfig } from './events/config.js'
309
+ export type {
310
+ AsyncUnsubscribeFunction,
311
+ UnsubscribeFunction,
312
+ } from './events/Emitter.js'
293
313
  export {
294
314
  BaseEvent,
295
315
  Emitter,
296
316
  type EmitterResolver,
297
317
  type ListenerClass,
298
318
  } from './events/Emitter.js'
319
+ export {
320
+ type BufferedEvent,
321
+ type BufferedEventName,
322
+ type EventFinder,
323
+ EventsBuffer,
324
+ } from './events/EventsBuffer.js'
299
325
  export type {
300
326
  ErrorReporter,
301
327
  LockBackend,