@c9up/ream 0.1.34 → 0.2.1
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.
- package/dist/Application.d.ts +68 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +142 -6
- package/dist/Application.js.map +1 -1
- package/dist/HealthCheck.d.ts +14 -7
- package/dist/HealthCheck.d.ts.map +1 -1
- package/dist/HealthCheck.js +21 -12
- package/dist/HealthCheck.js.map +1 -1
- package/dist/HttpKernel.d.ts +12 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +15 -4
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts +8 -8
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +11 -0
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts +13 -2
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +50 -30
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +75 -6
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +128 -8
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/console/BaseCommand.d.ts +8 -1
- package/dist/console/BaseCommand.d.ts.map +1 -1
- package/dist/console/BaseCommand.js +1 -1
- package/dist/console/BaseCommand.js.map +1 -1
- package/dist/console/Kernel.d.ts +15 -0
- package/dist/console/Kernel.d.ts.map +1 -1
- package/dist/console/Kernel.js +17 -0
- package/dist/console/Kernel.js.map +1 -1
- package/dist/directories.d.ts +52 -0
- package/dist/directories.d.ts.map +1 -0
- package/dist/directories.js +38 -0
- package/dist/directories.js.map +1 -0
- package/dist/events/Emitter.d.ts +49 -6
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +117 -7
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/EventsBuffer.d.ts +47 -0
- package/dist/events/EventsBuffer.d.ts.map +1 -0
- package/dist/events/EventsBuffer.js +107 -0
- package/dist/events/EventsBuffer.js.map +1 -0
- package/dist/events/index.d.ts +2 -0
- package/dist/events/index.d.ts.map +1 -1
- package/dist/events/index.js +1 -0
- package/dist/events/index.js.map +1 -1
- package/dist/health/BaseCheck.d.ts +19 -0
- package/dist/health/BaseCheck.d.ts.map +1 -0
- package/dist/health/BaseCheck.js +24 -0
- package/dist/health/BaseCheck.js.map +1 -0
- package/dist/health/HealthChecks.d.ts +15 -0
- package/dist/health/HealthChecks.d.ts.map +1 -0
- package/dist/health/HealthChecks.js +69 -0
- package/dist/health/HealthChecks.js.map +1 -0
- package/dist/health/Result.d.ts +27 -0
- package/dist/health/Result.d.ts.map +1 -0
- package/dist/health/Result.js +55 -0
- package/dist/health/Result.js.map +1 -0
- package/dist/health/checks/DiskSpaceCheck.d.ts +29 -0
- package/dist/health/checks/DiskSpaceCheck.d.ts.map +1 -0
- package/dist/health/checks/DiskSpaceCheck.js +58 -0
- package/dist/health/checks/DiskSpaceCheck.js.map +1 -0
- package/dist/health/checks/MemoryHeapCheck.d.ts +21 -0
- package/dist/health/checks/MemoryHeapCheck.d.ts.map +1 -0
- package/dist/health/checks/MemoryHeapCheck.js +55 -0
- package/dist/health/checks/MemoryHeapCheck.js.map +1 -0
- package/dist/health/checks/MemoryRSSCheck.d.ts +21 -0
- package/dist/health/checks/MemoryRSSCheck.d.ts.map +1 -0
- package/dist/health/checks/MemoryRSSCheck.js +55 -0
- package/dist/health/checks/MemoryRSSCheck.js.map +1 -0
- package/dist/health/checks/memory.d.ts +24 -0
- package/dist/health/checks/memory.d.ts.map +1 -0
- package/dist/health/checks/memory.js +88 -0
- package/dist/health/checks/memory.js.map +1 -0
- package/dist/health/index.d.ts +9 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +8 -0
- package/dist/health/index.js.map +1 -0
- package/dist/health/tracing.d.ts +12 -0
- package/dist/health/tracing.d.ts.map +1 -0
- package/dist/health/tracing.js +11 -0
- package/dist/health/tracing.js.map +1 -0
- package/dist/health/types.d.ts +51 -0
- package/dist/health/types.d.ts.map +1 -0
- package/dist/health/types.js +5 -0
- package/dist/health/types.js.map +1 -0
- package/dist/helpers/bytes.d.ts +28 -0
- package/dist/helpers/bytes.d.ts.map +1 -0
- package/dist/helpers/bytes.js +63 -0
- package/dist/helpers/bytes.js.map +1 -0
- package/dist/helpers/string.d.ts +4 -0
- package/dist/helpers/string.d.ts.map +1 -1
- package/dist/helpers/string.js +2 -0
- package/dist/helpers/string.js.map +1 -1
- package/dist/http/HttpContext.d.ts +30 -2
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +73 -3
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/Request.d.ts +42 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +51 -7
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +82 -2
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +240 -13
- package/dist/http/Response.js.map +1 -1
- package/dist/http/SseStream.d.ts +10 -0
- package/dist/http/SseStream.d.ts.map +1 -1
- package/dist/http/SseStream.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/migrations/MigrationRegistry.d.ts +28 -0
- package/dist/migrations/MigrationRegistry.d.ts.map +1 -0
- package/dist/migrations/MigrationRegistry.js +41 -0
- package/dist/migrations/MigrationRegistry.js.map +1 -0
- package/dist/migrations/types.d.ts +64 -0
- package/dist/migrations/types.d.ts.map +1 -0
- package/dist/migrations/types.js +15 -0
- package/dist/migrations/types.js.map +1 -0
- package/dist/router/Router.d.ts +112 -9
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +206 -28
- package/dist/router/Router.js.map +1 -1
- package/dist/security/CookieSigner.d.ts +43 -4
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +68 -10
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/session/Session.d.ts +46 -1
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +57 -4
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +19 -9
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +1 -1
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +3 -3
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +2 -2
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +1 -1
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +7 -5
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +1 -1
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +1 -1
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +11 -4
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +48 -31
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +169 -6
- package/src/HealthCheck.ts +27 -14
- package/src/HttpKernel.ts +27 -4
- package/src/Ignitor.ts +17 -8
- package/src/bodyparser/BodyParserMiddleware.ts +70 -36
- package/src/bodyparser/MultipartFile.ts +153 -8
- package/src/console/BaseCommand.ts +8 -1
- package/src/console/Kernel.ts +18 -0
- package/src/directories.ts +81 -0
- package/src/events/Emitter.ts +129 -13
- package/src/events/EventsBuffer.ts +131 -0
- package/src/events/index.ts +7 -0
- package/src/health/BaseCheck.ts +30 -0
- package/src/health/HealthChecks.ts +83 -0
- package/src/health/Result.ts +69 -0
- package/src/health/checks/DiskSpaceCheck.ts +69 -0
- package/src/health/checks/MemoryHeapCheck.ts +68 -0
- package/src/health/checks/MemoryRSSCheck.ts +68 -0
- package/src/health/checks/memory.ts +115 -0
- package/src/health/index.ts +13 -0
- package/src/health/tracing.ts +15 -0
- package/src/health/types.ts +51 -0
- package/src/helpers/bytes.ts +71 -0
- package/src/helpers/string.ts +2 -0
- package/src/http/HttpContext.ts +78 -4
- package/src/http/Request.ts +62 -10
- package/src/http/Response.ts +283 -14
- package/src/http/SseStream.ts +10 -0
- package/src/index.ts +27 -1
- package/src/migrations/MigrationRegistry.ts +49 -0
- package/src/migrations/types.ts +68 -0
- package/src/router/Router.ts +241 -32
- package/src/security/CookieSigner.ts +92 -18
- package/src/session/Session.ts +71 -5
- package/src/session/SessionMiddleware.ts +19 -9
- package/src/session/drivers/CookieDriver.ts +4 -4
- package/src/session/drivers/DatabaseDriver.ts +3 -3
- package/src/session/drivers/FileDriver.ts +8 -6
- package/src/session/drivers/MemoryDriver.ts +2 -2
- package/src/session/drivers/RedisDriver.ts +12 -5
- package/tsconfig.app.json +13 -13
- package/tsconfig.base.json +19 -0
- package/tsconfig.client.json +16 -0
- package/tsconfig.package.json +15 -0
package/src/http/Response.ts
CHANGED
|
@@ -7,11 +7,20 @@
|
|
|
7
7
|
* @implements FR21
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
package/src/http/SseStream.ts
CHANGED
|
@@ -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
|
+
}
|