@c9up/ream 0.1.34 → 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 (210) 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/HealthCheck.d.ts +14 -7
  6. package/dist/HealthCheck.d.ts.map +1 -1
  7. package/dist/HealthCheck.js +21 -12
  8. package/dist/HealthCheck.js.map +1 -1
  9. package/dist/HttpKernel.d.ts +12 -0
  10. package/dist/HttpKernel.d.ts.map +1 -1
  11. package/dist/HttpKernel.js +15 -4
  12. package/dist/HttpKernel.js.map +1 -1
  13. package/dist/Ignitor.d.ts +8 -0
  14. package/dist/Ignitor.d.ts.map +1 -1
  15. package/dist/Ignitor.js +11 -0
  16. package/dist/Ignitor.js.map +1 -1
  17. package/dist/bodyparser/BodyParserMiddleware.d.ts +13 -2
  18. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  19. package/dist/bodyparser/BodyParserMiddleware.js +50 -30
  20. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  21. package/dist/bodyparser/MultipartFile.d.ts +65 -6
  22. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  23. package/dist/bodyparser/MultipartFile.js +100 -7
  24. package/dist/bodyparser/MultipartFile.js.map +1 -1
  25. package/dist/console/BaseCommand.d.ts +8 -1
  26. package/dist/console/BaseCommand.d.ts.map +1 -1
  27. package/dist/console/BaseCommand.js +1 -1
  28. package/dist/console/BaseCommand.js.map +1 -1
  29. package/dist/console/Kernel.d.ts +15 -0
  30. package/dist/console/Kernel.d.ts.map +1 -1
  31. package/dist/console/Kernel.js +17 -0
  32. package/dist/console/Kernel.js.map +1 -1
  33. package/dist/directories.d.ts +52 -0
  34. package/dist/directories.d.ts.map +1 -0
  35. package/dist/directories.js +38 -0
  36. package/dist/directories.js.map +1 -0
  37. package/dist/events/Emitter.d.ts +49 -6
  38. package/dist/events/Emitter.d.ts.map +1 -1
  39. package/dist/events/Emitter.js +117 -7
  40. package/dist/events/Emitter.js.map +1 -1
  41. package/dist/events/EventsBuffer.d.ts +47 -0
  42. package/dist/events/EventsBuffer.d.ts.map +1 -0
  43. package/dist/events/EventsBuffer.js +107 -0
  44. package/dist/events/EventsBuffer.js.map +1 -0
  45. package/dist/events/index.d.ts +2 -0
  46. package/dist/events/index.d.ts.map +1 -1
  47. package/dist/events/index.js +1 -0
  48. package/dist/events/index.js.map +1 -1
  49. package/dist/health/BaseCheck.d.ts +19 -0
  50. package/dist/health/BaseCheck.d.ts.map +1 -0
  51. package/dist/health/BaseCheck.js +24 -0
  52. package/dist/health/BaseCheck.js.map +1 -0
  53. package/dist/health/HealthChecks.d.ts +15 -0
  54. package/dist/health/HealthChecks.d.ts.map +1 -0
  55. package/dist/health/HealthChecks.js +69 -0
  56. package/dist/health/HealthChecks.js.map +1 -0
  57. package/dist/health/Result.d.ts +27 -0
  58. package/dist/health/Result.d.ts.map +1 -0
  59. package/dist/health/Result.js +55 -0
  60. package/dist/health/Result.js.map +1 -0
  61. package/dist/health/checks/DiskSpaceCheck.d.ts +29 -0
  62. package/dist/health/checks/DiskSpaceCheck.d.ts.map +1 -0
  63. package/dist/health/checks/DiskSpaceCheck.js +58 -0
  64. package/dist/health/checks/DiskSpaceCheck.js.map +1 -0
  65. package/dist/health/checks/MemoryHeapCheck.d.ts +21 -0
  66. package/dist/health/checks/MemoryHeapCheck.d.ts.map +1 -0
  67. package/dist/health/checks/MemoryHeapCheck.js +55 -0
  68. package/dist/health/checks/MemoryHeapCheck.js.map +1 -0
  69. package/dist/health/checks/MemoryRSSCheck.d.ts +21 -0
  70. package/dist/health/checks/MemoryRSSCheck.d.ts.map +1 -0
  71. package/dist/health/checks/MemoryRSSCheck.js +55 -0
  72. package/dist/health/checks/MemoryRSSCheck.js.map +1 -0
  73. package/dist/health/checks/memory.d.ts +24 -0
  74. package/dist/health/checks/memory.d.ts.map +1 -0
  75. package/dist/health/checks/memory.js +88 -0
  76. package/dist/health/checks/memory.js.map +1 -0
  77. package/dist/health/index.d.ts +9 -0
  78. package/dist/health/index.d.ts.map +1 -0
  79. package/dist/health/index.js +8 -0
  80. package/dist/health/index.js.map +1 -0
  81. package/dist/health/tracing.d.ts +12 -0
  82. package/dist/health/tracing.d.ts.map +1 -0
  83. package/dist/health/tracing.js +11 -0
  84. package/dist/health/tracing.js.map +1 -0
  85. package/dist/health/types.d.ts +51 -0
  86. package/dist/health/types.d.ts.map +1 -0
  87. package/dist/health/types.js +5 -0
  88. package/dist/health/types.js.map +1 -0
  89. package/dist/helpers/bytes.d.ts +28 -0
  90. package/dist/helpers/bytes.d.ts.map +1 -0
  91. package/dist/helpers/bytes.js +63 -0
  92. package/dist/helpers/bytes.js.map +1 -0
  93. package/dist/helpers/string.d.ts +4 -0
  94. package/dist/helpers/string.d.ts.map +1 -1
  95. package/dist/helpers/string.js +2 -0
  96. package/dist/helpers/string.js.map +1 -1
  97. package/dist/http/HttpContext.d.ts +30 -2
  98. package/dist/http/HttpContext.d.ts.map +1 -1
  99. package/dist/http/HttpContext.js +73 -3
  100. package/dist/http/HttpContext.js.map +1 -1
  101. package/dist/http/Request.d.ts +42 -3
  102. package/dist/http/Request.d.ts.map +1 -1
  103. package/dist/http/Request.js +51 -7
  104. package/dist/http/Request.js.map +1 -1
  105. package/dist/http/Response.d.ts +82 -2
  106. package/dist/http/Response.d.ts.map +1 -1
  107. package/dist/http/Response.js +240 -13
  108. package/dist/http/Response.js.map +1 -1
  109. package/dist/http/SseStream.d.ts +10 -0
  110. package/dist/http/SseStream.d.ts.map +1 -1
  111. package/dist/http/SseStream.js.map +1 -1
  112. package/dist/index.d.ts +7 -2
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +4 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/migrations/MigrationRegistry.d.ts +28 -0
  117. package/dist/migrations/MigrationRegistry.d.ts.map +1 -0
  118. package/dist/migrations/MigrationRegistry.js +41 -0
  119. package/dist/migrations/MigrationRegistry.js.map +1 -0
  120. package/dist/migrations/types.d.ts +64 -0
  121. package/dist/migrations/types.d.ts.map +1 -0
  122. package/dist/migrations/types.js +15 -0
  123. package/dist/migrations/types.js.map +1 -0
  124. package/dist/router/Router.d.ts +112 -9
  125. package/dist/router/Router.d.ts.map +1 -1
  126. package/dist/router/Router.js +206 -28
  127. package/dist/router/Router.js.map +1 -1
  128. package/dist/security/CookieSigner.d.ts +43 -4
  129. package/dist/security/CookieSigner.d.ts.map +1 -1
  130. package/dist/security/CookieSigner.js +68 -10
  131. package/dist/security/CookieSigner.js.map +1 -1
  132. package/dist/session/Session.d.ts +46 -1
  133. package/dist/session/Session.d.ts.map +1 -1
  134. package/dist/session/Session.js +57 -4
  135. package/dist/session/Session.js.map +1 -1
  136. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  137. package/dist/session/SessionMiddleware.js +19 -9
  138. package/dist/session/SessionMiddleware.js.map +1 -1
  139. package/dist/session/drivers/CookieDriver.d.ts +1 -1
  140. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  141. package/dist/session/drivers/CookieDriver.js +3 -3
  142. package/dist/session/drivers/CookieDriver.js.map +1 -1
  143. package/dist/session/drivers/DatabaseDriver.d.ts +1 -1
  144. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  145. package/dist/session/drivers/DatabaseDriver.js +2 -2
  146. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  147. package/dist/session/drivers/FileDriver.d.ts +1 -1
  148. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  149. package/dist/session/drivers/FileDriver.js +7 -5
  150. package/dist/session/drivers/FileDriver.js.map +1 -1
  151. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  152. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  153. package/dist/session/drivers/MemoryDriver.js +1 -1
  154. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  155. package/dist/session/drivers/RedisDriver.d.ts +1 -1
  156. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  157. package/dist/session/drivers/RedisDriver.js +11 -4
  158. package/dist/session/drivers/RedisDriver.js.map +1 -1
  159. package/events.win32-x64-msvc.node +0 -0
  160. package/index.darwin-arm64.node +0 -0
  161. package/index.darwin-x64.node +0 -0
  162. package/index.linux-arm64-gnu.node +0 -0
  163. package/index.linux-x64-gnu.node +0 -0
  164. package/index.win32-x64-msvc.node +0 -0
  165. package/package.json +5 -2
  166. package/scheduler.win32-x64-msvc.node +0 -0
  167. package/src/Application.ts +169 -6
  168. package/src/HealthCheck.ts +27 -14
  169. package/src/HttpKernel.ts +27 -4
  170. package/src/Ignitor.ts +17 -0
  171. package/src/bodyparser/BodyParserMiddleware.ts +70 -36
  172. package/src/bodyparser/MultipartFile.ts +125 -8
  173. package/src/console/BaseCommand.ts +8 -1
  174. package/src/console/Kernel.ts +18 -0
  175. package/src/directories.ts +81 -0
  176. package/src/events/Emitter.ts +129 -13
  177. package/src/events/EventsBuffer.ts +131 -0
  178. package/src/events/index.ts +7 -0
  179. package/src/health/BaseCheck.ts +30 -0
  180. package/src/health/HealthChecks.ts +83 -0
  181. package/src/health/Result.ts +69 -0
  182. package/src/health/checks/DiskSpaceCheck.ts +69 -0
  183. package/src/health/checks/MemoryHeapCheck.ts +68 -0
  184. package/src/health/checks/MemoryRSSCheck.ts +68 -0
  185. package/src/health/checks/memory.ts +115 -0
  186. package/src/health/index.ts +13 -0
  187. package/src/health/tracing.ts +15 -0
  188. package/src/health/types.ts +51 -0
  189. package/src/helpers/bytes.ts +71 -0
  190. package/src/helpers/string.ts +2 -0
  191. package/src/http/HttpContext.ts +78 -4
  192. package/src/http/Request.ts +62 -10
  193. package/src/http/Response.ts +283 -14
  194. package/src/http/SseStream.ts +10 -0
  195. package/src/index.ts +27 -1
  196. package/src/migrations/MigrationRegistry.ts +49 -0
  197. package/src/migrations/types.ts +68 -0
  198. package/src/router/Router.ts +241 -32
  199. package/src/security/CookieSigner.ts +92 -18
  200. package/src/session/Session.ts +71 -5
  201. package/src/session/SessionMiddleware.ts +19 -9
  202. package/src/session/drivers/CookieDriver.ts +4 -4
  203. package/src/session/drivers/DatabaseDriver.ts +3 -3
  204. package/src/session/drivers/FileDriver.ts +8 -6
  205. package/src/session/drivers/MemoryDriver.ts +2 -2
  206. package/src/session/drivers/RedisDriver.ts +12 -5
  207. package/tsconfig.app.json +13 -13
  208. package/tsconfig.base.json +19 -0
  209. package/tsconfig.client.json +16 -0
  210. package/tsconfig.package.json +15 -0
@@ -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,
@@ -0,0 +1,49 @@
1
+ import type { RegisteredMigrationSource } from './types.js'
2
+
3
+ /**
4
+ * Where every store's migration runner registers itself.
5
+ *
6
+ * Bound in the container as `migrations`, always — an app with no data package
7
+ * gets an EMPTY registry, never a missing binding. That distinction is what
8
+ * lets the CLI tell "this app has no migrations" from "this app predates the
9
+ * registry", and say something useful in each case instead of failing the same
10
+ * way twice.
11
+ */
12
+ export class MigrationRegistry {
13
+ readonly #sources = new Map<string, RegisteredMigrationSource>()
14
+
15
+ /**
16
+ * Register a store's runner. Registering the same name twice is refused:
17
+ * silently replacing it would mean one of two providers migrates nothing,
18
+ * and the run would still report success.
19
+ */
20
+ register(source: RegisteredMigrationSource): this {
21
+ if (this.#sources.has(source.name)) {
22
+ throw new Error(
23
+ `A migration source named '${source.name}' is already registered. Two providers cannot claim one name — one of them would migrate nothing while the run still reported success.`,
24
+ )
25
+ }
26
+ this.#sources.set(source.name, source)
27
+ return this
28
+ }
29
+
30
+ /** Every registered store, in registration order. */
31
+ all(): RegisteredMigrationSource[] {
32
+ return [...this.#sources.values()]
33
+ }
34
+
35
+ /** One store by name, or `undefined`. */
36
+ get(name: string): RegisteredMigrationSource | undefined {
37
+ return this.#sources.get(name)
38
+ }
39
+
40
+ /** The registered names, for an error message that lists what IS available. */
41
+ names(): string[] {
42
+ return [...this.#sources.keys()]
43
+ }
44
+
45
+ /** Whether anything registered. */
46
+ get isEmpty(): boolean {
47
+ return this.#sources.size === 0
48
+ }
49
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The contract a data package implements so `ream migrate` can drive it.
3
+ *
4
+ * REAM PARTICULARITY, deliberate — AdonisJS has no equivalent, because Lucid is
5
+ * its only migration source: `node ace migration:run` can name it directly.
6
+ * Ream expects several stores in one app (a relational one, a time-series one,
7
+ * whatever comes next), so the CLI must not name any of them. A store registers
8
+ * itself and the CLI stays generic for good.
9
+ *
10
+ * The vocabulary is Adonis' where Adonis has one: a status node is
11
+ * `{ name, status, batch? }`, spelled `status` as Lucid's `MigrationListNode`
12
+ * spells it.
13
+ */
14
+
15
+ /** Whether a migration has run. */
16
+ export type MigrationState = 'applied' | 'pending'
17
+
18
+ /** One migration's position, as `ream migrate:status` prints it. */
19
+ export interface MigrationStatusNode {
20
+ /** The migration's name, without extension. */
21
+ name: string
22
+ status: MigrationState
23
+ /** The batch it was applied in, when it has been. */
24
+ batch?: number
25
+ }
26
+
27
+ /**
28
+ * What a store's migration runner must provide.
29
+ *
30
+ * Only three methods are REQUIRED — the three `ream migrate`,
31
+ * `migrate:rollback` and `migrate:status` call. Everything else is optional on
32
+ * purpose: a new store must be able to register with a runner that only knows
33
+ * how to go forward, rather than stub methods it cannot honour. A caller that
34
+ * needs an optional one checks for it and says so, instead of crashing.
35
+ */
36
+ export interface MigrationRunnerContract {
37
+ /** Apply every pending migration. Resolves to the names applied, in order. */
38
+ migrate(): Promise<string[]>
39
+ /** Undo the last batch. Resolves to the names rolled back. */
40
+ rollback(): Promise<string[]>
41
+ /** Every known migration and whether it has run. */
42
+ status(): Promise<MigrationStatusNode[]>
43
+
44
+ /** Undo every migration. */
45
+ reset?(): Promise<string[]>
46
+ /** Reset, then migrate. */
47
+ refresh?(): Promise<unknown>
48
+ /** Drop everything, then migrate. */
49
+ fresh?(): Promise<unknown>
50
+ /** What `migrate()` would do, without doing it. */
51
+ dryRun?(): Promise<unknown>
52
+ /** Release a lock a crashed run left behind. */
53
+ forceUnlock?(): Promise<boolean>
54
+ }
55
+
56
+ /** A registered store, as the CLI sees it. */
57
+ export interface RegisteredMigrationSource {
58
+ /**
59
+ * How the store is named on the command line — `atlas`, `eon`, …
60
+ *
61
+ * It is what `ream migrate --only <name>` matches and what prefixes the
62
+ * output, so it belongs to the store, not to the app.
63
+ */
64
+ name: string
65
+ /** Where its migration files live, for the message when there are none. */
66
+ directory?: string
67
+ runner: MigrationRunnerContract
68
+ }