@c9up/ream 0.2.8 → 0.2.10
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/README.md +1 -0
- package/dist/Application.d.ts +26 -6
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +40 -12
- package/dist/Application.js.map +1 -1
- package/dist/ErrorBoundary.d.ts.map +1 -1
- package/dist/ErrorBoundary.js +2 -1
- package/dist/ErrorBoundary.js.map +1 -1
- package/dist/Ignitor.d.ts +13 -0
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +149 -26
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/MultipartFile.d.ts +8 -0
- package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
- package/dist/bodyparser/MultipartFile.js +46 -1
- package/dist/bodyparser/MultipartFile.js.map +1 -1
- package/dist/commands/Migrate.d.ts +9 -0
- package/dist/commands/Migrate.d.ts.map +1 -0
- package/dist/commands/Migrate.js +16 -0
- package/dist/commands/Migrate.js.map +1 -0
- package/dist/commands/MigrateBase.d.ts +27 -0
- package/dist/commands/MigrateBase.d.ts.map +1 -0
- package/dist/commands/MigrateBase.js +77 -0
- package/dist/commands/MigrateBase.js.map +1 -0
- package/dist/commands/MigrateRollback.d.ts +9 -0
- package/dist/commands/MigrateRollback.d.ts.map +1 -0
- package/dist/commands/MigrateRollback.js +16 -0
- package/dist/commands/MigrateRollback.js.map +1 -0
- package/dist/commands/MigrateStatus.d.ts +9 -0
- package/dist/commands/MigrateStatus.d.ts.map +1 -0
- package/dist/commands/MigrateStatus.js +19 -0
- package/dist/commands/MigrateStatus.js.map +1 -0
- package/dist/commands/ScheduleList.d.ts +18 -0
- package/dist/commands/ScheduleList.d.ts.map +1 -0
- package/dist/commands/ScheduleList.js +55 -0
- package/dist/commands/ScheduleList.js.map +1 -0
- package/dist/commands/ScheduleRun.d.ts +19 -0
- package/dist/commands/ScheduleRun.d.ts.map +1 -0
- package/dist/commands/ScheduleRun.js +61 -0
- package/dist/commands/ScheduleRun.js.map +1 -0
- package/dist/commands/index.d.ts +15 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +63 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/scheduler_resolver.d.ts +12 -0
- package/dist/commands/scheduler_resolver.d.ts.map +1 -0
- package/dist/commands/scheduler_resolver.js +19 -0
- package/dist/commands/scheduler_resolver.js.map +1 -0
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +2 -1
- package/dist/env/Env.js.map +1 -1
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +5 -1
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/nodeEnv.d.ts +24 -0
- package/dist/env/nodeEnv.d.ts.map +1 -0
- package/dist/env/nodeEnv.js +40 -0
- package/dist/env/nodeEnv.js.map +1 -0
- package/dist/errors/ReamError.d.ts +6 -1
- package/dist/errors/ReamError.d.ts.map +1 -1
- package/dist/errors/ReamError.js +8 -2
- package/dist/errors/ReamError.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +2 -1
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +18 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +71 -8
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +11 -4
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +46 -17
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +14 -5
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +2 -1
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts +14 -0
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +62 -14
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/scheduler/config.d.ts +29 -0
- package/dist/scheduler/config.d.ts.map +1 -0
- package/dist/scheduler/config.js +14 -0
- package/dist/scheduler/config.js.map +1 -0
- package/dist/scheduler/index.d.ts +4 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +3 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/locks/LockBackend.d.ts +13 -3
- package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/LockBackend.js +20 -4
- package/dist/scheduler/locks/LockBackend.js.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
- package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
- package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
- package/dist/scheduler/locks/RedisLockBackend.js +83 -0
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
- package/dist/scheduler/locks/locks.d.ts +39 -0
- package/dist/scheduler/locks/locks.d.ts.map +1 -0
- package/dist/scheduler/locks/locks.js +42 -0
- package/dist/scheduler/locks/locks.js.map +1 -0
- package/dist/scheduler/locks/quasar.d.ts +17 -0
- package/dist/scheduler/locks/quasar.d.ts.map +1 -0
- package/dist/scheduler/locks/quasar.js +51 -0
- package/dist/scheduler/locks/quasar.js.map +1 -0
- package/dist/security/CookieSigner.d.ts +16 -13
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +33 -14
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/services/encryption.d.ts +12 -2
- package/dist/services/encryption.d.ts.map +1 -1
- package/dist/services/encryption.js +11 -1
- package/dist/services/encryption.js.map +1 -1
- package/dist/session/Session.d.ts +8 -7
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +4 -1
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/testing/ApiResponse.d.ts.map +1 -1
- package/dist/testing/ApiResponse.js +1 -1
- package/dist/testing/ApiResponse.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +1 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/utils/Macroable.d.ts.map +1 -1
- package/dist/utils/Macroable.js +1 -0
- package/dist/utils/Macroable.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +9 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Application.ts +42 -15
- package/src/ErrorBoundary.ts +3 -1
- package/src/Ignitor.ts +160 -30
- package/src/bodyparser/MultipartFile.ts +48 -1
- package/src/commands/Migrate.ts +18 -0
- package/src/commands/MigrateBase.ts +81 -0
- package/src/commands/MigrateRollback.ts +18 -0
- package/src/commands/MigrateStatus.ts +22 -0
- package/src/commands/ScheduleList.ts +66 -0
- package/src/commands/ScheduleRun.ts +65 -0
- package/src/commands/index.ts +73 -0
- package/src/commands/scheduler_resolver.ts +23 -0
- package/src/env/Env.ts +2 -1
- package/src/env/loadEnvFiles.ts +5 -1
- package/src/env/nodeEnv.ts +38 -0
- package/src/errors/ReamError.ts +9 -2
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +2 -1
- package/src/http/Request.ts +73 -8
- package/src/http/Response.ts +58 -19
- package/src/index.ts +8 -1
- package/src/router/Router.ts +14 -5
- package/src/rpc/RpcRouter.ts +2 -1
- package/src/scheduler/ScheduleProvider.ts +65 -15
- package/src/scheduler/config.ts +34 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/locks/LockBackend.ts +26 -3
- package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
- package/src/scheduler/locks/RedisLockBackend.ts +114 -0
- package/src/scheduler/locks/locks.ts +49 -0
- package/src/scheduler/locks/quasar.ts +67 -0
- package/src/security/CookieSigner.ts +36 -14
- package/src/services/encryption.ts +12 -2
- package/src/session/Session.ts +8 -7
- package/src/session/SessionMiddleware.ts +4 -1
- package/src/testing/ApiResponse.ts +1 -2
- package/src/testing/RequestBuilder.ts +1 -2
- package/src/utils/Macroable.ts +1 -0
package/src/http/Request.ts
CHANGED
|
@@ -383,7 +383,14 @@ export class Request extends Macroable {
|
|
|
383
383
|
cookie(name: string, defaultValue?: string): string | null
|
|
384
384
|
cookie(name: string, defaultValue?: string): string | null {
|
|
385
385
|
const raw = this.plainCookie<string>(name, undefined, { encoded: false })
|
|
386
|
-
|
|
386
|
+
// No signer means nothing here was ever signed. Handing the raw value back
|
|
387
|
+
// would present whatever the client wrote as a verified one — the reader
|
|
388
|
+
// cannot tell the difference, which is exactly the confusion signing
|
|
389
|
+
// exists to remove. `plainCookie()` is the way to read an unsigned value.
|
|
390
|
+
if (!this.#cookieSigner) return defaultValue ?? null
|
|
391
|
+
// The name is part of the signature, so a value moved to another cookie
|
|
392
|
+
// fails here rather than being honoured under its new name.
|
|
393
|
+
const value = raw === null ? null : this.#cookieSigner.unsign<string>(raw, name)
|
|
387
394
|
return value ?? defaultValue ?? null
|
|
388
395
|
}
|
|
389
396
|
|
|
@@ -675,20 +682,29 @@ export class Request extends Macroable {
|
|
|
675
682
|
}
|
|
676
683
|
|
|
677
684
|
/**
|
|
678
|
-
* A JSON
|
|
679
|
-
*
|
|
680
|
-
*
|
|
685
|
+
* A JSON view of the request (AdonisJS `serialize`).
|
|
686
|
+
*
|
|
687
|
+
* The whole request, as upstream reports it: body, cookies and every header
|
|
688
|
+
* verbatim. It is a debugging dump, and it carries credentials — an
|
|
689
|
+
* `authorization` header, a session cookie, a password field in the body.
|
|
690
|
+
*
|
|
691
|
+
* Do not put it in a log line. {@link serializeSafe} is the one built for
|
|
692
|
+
* that.
|
|
681
693
|
*/
|
|
682
694
|
serialize(): Record<string, unknown> {
|
|
683
695
|
return {
|
|
684
696
|
id: this.id(),
|
|
685
697
|
url: this.url(true),
|
|
698
|
+
query: this.parsedUrl().search?.slice(1) ?? '',
|
|
699
|
+
body: this.body(),
|
|
700
|
+
params: this.params(),
|
|
701
|
+
headers: this.headers(),
|
|
686
702
|
method: this.method(),
|
|
687
703
|
protocol: this.protocol(),
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
704
|
+
cookies: this.cookies(),
|
|
705
|
+
hostname: this.hostname(),
|
|
706
|
+
ip: this.ip(),
|
|
707
|
+
subdomains: this.subdomains(),
|
|
692
708
|
}
|
|
693
709
|
}
|
|
694
710
|
|
|
@@ -697,6 +713,28 @@ export class Request extends Macroable {
|
|
|
697
713
|
return this.serialize()
|
|
698
714
|
}
|
|
699
715
|
|
|
716
|
+
/**
|
|
717
|
+
* A view fit for a log line or an error report.
|
|
718
|
+
*
|
|
719
|
+
* Not upstream's — it has no such method, and its `serialize()` is a full
|
|
720
|
+
* dump. This one carries no body and no cookies, and the headers that hold a
|
|
721
|
+
* credential come back as `[redacted]` rather than as their value: the header
|
|
722
|
+
* is still LISTED, because knowing a request arrived with an `authorization`
|
|
723
|
+
* header is most of what a reader wants, and its contents are none of it.
|
|
724
|
+
*/
|
|
725
|
+
serializeSafe(): Record<string, unknown> {
|
|
726
|
+
return {
|
|
727
|
+
id: this.id(),
|
|
728
|
+
url: this.url(true),
|
|
729
|
+
method: this.method(),
|
|
730
|
+
protocol: this.protocol(),
|
|
731
|
+
host: this.host(),
|
|
732
|
+
headers: redactSensitiveHeaders(this.headers()),
|
|
733
|
+
qs: this.qs(),
|
|
734
|
+
params: this.params(),
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
700
738
|
// ─── Query string ─────────────────────────────────────────
|
|
701
739
|
|
|
702
740
|
/** Get parsed query string as an object. */
|
|
@@ -999,6 +1037,33 @@ function isPlainObject(value: unknown): value is Dict<unknown> {
|
|
|
999
1037
|
}
|
|
1000
1038
|
|
|
1001
1039
|
/** True for an IPv4 dotted-quad or a (bracketed) IPv6 literal — hosts with no subdomains. */
|
|
1040
|
+
/**
|
|
1041
|
+
* Headers whose VALUE is a credential. Listing them is useful; printing them
|
|
1042
|
+
* hands whoever reads the log a working key.
|
|
1043
|
+
*/
|
|
1044
|
+
const SENSITIVE_HEADERS = new Set([
|
|
1045
|
+
'authorization',
|
|
1046
|
+
'proxy-authorization',
|
|
1047
|
+
'cookie',
|
|
1048
|
+
'set-cookie',
|
|
1049
|
+
'x-api-key',
|
|
1050
|
+
'x-auth-token',
|
|
1051
|
+
'x-access-token',
|
|
1052
|
+
'x-csrf-token',
|
|
1053
|
+
'x-xsrf-token',
|
|
1054
|
+
'x-session-token',
|
|
1055
|
+
'x-amz-security-token',
|
|
1056
|
+
])
|
|
1057
|
+
|
|
1058
|
+
/** Replace each credential-bearing header's value with a marker, keeping the name. */
|
|
1059
|
+
function redactSensitiveHeaders(headers: Dict): Dict {
|
|
1060
|
+
const out: Dict = {}
|
|
1061
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1062
|
+
out[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[redacted]' : value
|
|
1063
|
+
}
|
|
1064
|
+
return out
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1002
1067
|
function isIpLiteral(host: string): boolean {
|
|
1003
1068
|
if (host.startsWith('[') || host.includes('::')) return true
|
|
1004
1069
|
return /^(\d{1,3}\.){3}\d{1,3}$/.test(host)
|
package/src/http/Response.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { readFile, stat } from 'node:fs/promises'
|
|
|
13
13
|
import { basename } from 'node:path'
|
|
14
14
|
import etag from 'etag'
|
|
15
15
|
import { contentType } from 'mime-types'
|
|
16
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
16
17
|
import { durationToSeconds } from '../helpers/duration.js'
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -60,8 +61,6 @@ function assertNoCRLF(name: string, value: string): void {
|
|
|
60
61
|
*/
|
|
61
62
|
function contentDisposition(disposition: string, filename: string): string {
|
|
62
63
|
const quoted = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
63
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching control
|
|
64
|
-
// characters is the point — they cannot travel in a header field.
|
|
65
64
|
const ascii = quoted.replace(/[^\x20-\x7e]/g, '_')
|
|
66
65
|
const base = `${disposition}; filename="${ascii}"`
|
|
67
66
|
if (ascii === quoted) return base
|
|
@@ -285,7 +284,7 @@ export class Response extends Macroable {
|
|
|
285
284
|
/** Send a JSON response. Sets content-type and stringifies. */
|
|
286
285
|
json(data: unknown): void {
|
|
287
286
|
this.#headers['content-type'] = 'application/json'
|
|
288
|
-
this.#
|
|
287
|
+
this.#setBody(safeStringify(data))
|
|
289
288
|
this.#finished = true
|
|
290
289
|
}
|
|
291
290
|
|
|
@@ -315,7 +314,7 @@ export class Response extends Macroable {
|
|
|
315
314
|
c === '\u2028' ? '\\u2028' : '\\u2029',
|
|
316
315
|
)
|
|
317
316
|
this.#headers['content-type'] = 'text/javascript; charset=utf-8'
|
|
318
|
-
this.#
|
|
317
|
+
this.#setBody(`/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${json});`)
|
|
319
318
|
this.#finished = true
|
|
320
319
|
}
|
|
321
320
|
|
|
@@ -330,7 +329,7 @@ export class Response extends Macroable {
|
|
|
330
329
|
? 'text/html; charset=utf-8'
|
|
331
330
|
: 'text/plain; charset=utf-8'
|
|
332
331
|
}
|
|
333
|
-
this.#
|
|
332
|
+
this.#setBody(data)
|
|
334
333
|
} else if (Buffer.isBuffer(data)) {
|
|
335
334
|
// Without this branch a Buffer would hit the `typeof === 'object'`
|
|
336
335
|
// path and be JSON-stringified into `{"type":"Buffer","data":[...]}`
|
|
@@ -339,12 +338,12 @@ export class Response extends Macroable {
|
|
|
339
338
|
return
|
|
340
339
|
} else if (typeof data === 'object' && data !== null) {
|
|
341
340
|
this.#headers['content-type'] = 'application/json'
|
|
342
|
-
this.#
|
|
341
|
+
this.#setBody(safeStringify(data))
|
|
343
342
|
} else if (data !== undefined && data !== null) {
|
|
344
343
|
if (!this.#headers['content-type']) {
|
|
345
344
|
this.#headers['content-type'] = 'text/plain'
|
|
346
345
|
}
|
|
347
|
-
this.#
|
|
346
|
+
this.#setBody(String(data))
|
|
348
347
|
}
|
|
349
348
|
this.#finished = true
|
|
350
349
|
}
|
|
@@ -383,12 +382,37 @@ export class Response extends Macroable {
|
|
|
383
382
|
* `@c9up/archive` instead and let the client fetch it from storage — the
|
|
384
383
|
* bytes then never pass through the server at all.
|
|
385
384
|
*/
|
|
385
|
+
/**
|
|
386
|
+
* Assign the body, refusing one too large to hold.
|
|
387
|
+
*
|
|
388
|
+
* Every textual path goes through here — `json()`, `send()`, `jsonp()`, a
|
|
389
|
+
* middleware rewriting the body. The ceiling used to live only in
|
|
390
|
+
* `sendBuffer()`, so `json(hugeObject)` and `send(hugeString)` walked past
|
|
391
|
+
* it and the process grew until it died, which is the failure the ceiling
|
|
392
|
+
* exists to name.
|
|
393
|
+
*
|
|
394
|
+
* Measured in UTF-8 BYTES, not characters: a string of accented or CJK text
|
|
395
|
+
* is one and a half to three times its length on the wire.
|
|
396
|
+
*/
|
|
397
|
+
#setBody(body: string): void {
|
|
398
|
+
this.#assertBodyFits(Buffer.byteLength(body, 'utf8'))
|
|
399
|
+
this.#body = body
|
|
400
|
+
}
|
|
401
|
+
|
|
386
402
|
#assertBodyFits(bytes: number): void {
|
|
387
403
|
if (bytes <= this.#maxBodyBytes) return
|
|
388
|
-
throw new
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
404
|
+
throw new ReamError(
|
|
405
|
+
'E_RESPONSE_TOO_LARGE',
|
|
406
|
+
`Response body is ${Math.round(bytes / 1_048_576)}MB, over the ${Math.round(this.#maxBodyBytes / 1_048_576)}MB ceiling.`,
|
|
407
|
+
{
|
|
408
|
+
context: {
|
|
409
|
+
bytes: String(bytes),
|
|
410
|
+
ceiling: String(this.#maxBodyBytes),
|
|
411
|
+
},
|
|
412
|
+
hint:
|
|
413
|
+
'The body is held whole in memory (and base64-encoded, ~2.3x) because responses are not streamed yet. ' +
|
|
414
|
+
'Raise `maxResponseBytes` in the kernel config, or serve large files with a signed URL from @c9up/archive.',
|
|
415
|
+
},
|
|
392
416
|
)
|
|
393
417
|
}
|
|
394
418
|
|
|
@@ -665,7 +689,11 @@ export class Response extends Macroable {
|
|
|
665
689
|
*/
|
|
666
690
|
redirect(): RedirectBuilder
|
|
667
691
|
redirect(path: string, forwardQueryString?: boolean, statusCode?: number): void
|
|
668
|
-
redirect(
|
|
692
|
+
redirect(
|
|
693
|
+
path?: string,
|
|
694
|
+
forwardQueryString = false,
|
|
695
|
+
statusCode = 302,
|
|
696
|
+
): RedirectBuilder | undefined {
|
|
669
697
|
if (!this.#redirectBuilderFactory) {
|
|
670
698
|
throw new Error(
|
|
671
699
|
'redirect() requires an HttpContext. Response was created outside a request handler.',
|
|
@@ -1063,14 +1091,25 @@ export class Response extends Macroable {
|
|
|
1063
1091
|
// ─── Cookies ──────────────────────────────────────────────
|
|
1064
1092
|
|
|
1065
1093
|
/**
|
|
1066
|
-
* Set a SIGNED response cookie (
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1094
|
+
* Set a SIGNED response cookie (the default). The value is HMAC-signed with
|
|
1095
|
+
* the app's `APP_KEY`, so tampering is detected on read; `request.cookie()`
|
|
1096
|
+
* verifies and unwraps it.
|
|
1097
|
+
*
|
|
1098
|
+
* Requires APP_KEY, and REFUSES rather than falling back to a plain cookie.
|
|
1099
|
+
* A silent fallback is the worst of both: the caller asked for integrity,
|
|
1100
|
+
* the value ships without it, and `request.cookie()` on the far side hands
|
|
1101
|
+
* back whatever the client wrote as though it had been verified.
|
|
1102
|
+
*
|
|
1103
|
+
* The cookie's NAME is signed along with its value, so a signed cookie
|
|
1104
|
+
* cannot be lifted onto another name and still verify.
|
|
1070
1105
|
*/
|
|
1071
1106
|
cookie(name: string, value: string, options?: CookieOptions): this {
|
|
1072
|
-
|
|
1073
|
-
|
|
1107
|
+
if (!this.#cookieSigner) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
'cookie() signs the value and needs APP_KEY — set it so the encryption service is registered, or use plainCookie() when the value does not need signing.',
|
|
1110
|
+
)
|
|
1111
|
+
}
|
|
1112
|
+
return this.#writeCookie(name, this.#cookieSigner.sign(value, undefined, name), options)
|
|
1074
1113
|
}
|
|
1075
1114
|
|
|
1076
1115
|
/**
|
|
@@ -1262,7 +1301,7 @@ export class Response extends Macroable {
|
|
|
1262
1301
|
|
|
1263
1302
|
/** @internal Set body directly (used by redirect, exception handler). */
|
|
1264
1303
|
setBody(body: string): void {
|
|
1265
|
-
this.#body
|
|
1304
|
+
this.#setBody(body)
|
|
1266
1305
|
this.#finished = true
|
|
1267
1306
|
}
|
|
1268
1307
|
|
package/src/index.ts
CHANGED
|
@@ -347,12 +347,16 @@ export {
|
|
|
347
347
|
export type {
|
|
348
348
|
ErrorReporter,
|
|
349
349
|
LockBackend,
|
|
350
|
+
LockBackendFactory,
|
|
351
|
+
LockRedisClient,
|
|
352
|
+
LockRedisResolver,
|
|
350
353
|
RunTaskOutcome,
|
|
351
354
|
ScheduleEvent,
|
|
352
355
|
ScheduleEventSink,
|
|
353
356
|
ScheduleInvocation,
|
|
354
357
|
ScheduleMetadata,
|
|
355
358
|
ScheduleProviderOptions,
|
|
359
|
+
SchedulerConfig,
|
|
356
360
|
SchedulerOptions,
|
|
357
361
|
ScheduleTaskCompletedEvent,
|
|
358
362
|
ScheduleTaskFailedEvent,
|
|
@@ -362,8 +366,11 @@ export type {
|
|
|
362
366
|
TaskStats,
|
|
363
367
|
} from './scheduler/index.js'
|
|
364
368
|
export {
|
|
369
|
+
defineSchedulerConfig,
|
|
365
370
|
getScheduleMetadata,
|
|
371
|
+
locks,
|
|
366
372
|
MemoryLockBackend,
|
|
373
|
+
RedisLockBackend,
|
|
367
374
|
SCHEDULE_METADATA_KEY,
|
|
368
375
|
Schedule,
|
|
369
376
|
ScheduleProvider,
|
|
@@ -371,4 +378,4 @@ export {
|
|
|
371
378
|
StatsTracker,
|
|
372
379
|
} from './scheduler/index.js'
|
|
373
380
|
|
|
374
|
-
export const VERSION = '0.2.
|
|
381
|
+
export const VERSION = '0.2.10'
|
package/src/router/Router.ts
CHANGED
|
@@ -1602,12 +1602,21 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
|
|
|
1602
1602
|
return params
|
|
1603
1603
|
}
|
|
1604
1604
|
|
|
1605
|
-
/**
|
|
1605
|
+
/**
|
|
1606
|
+
* Match a domain pattern against the request's host. Supports wildcards like
|
|
1607
|
+
* `*.example.com`.
|
|
1608
|
+
*
|
|
1609
|
+
* Both sides are lowercased: a hostname is case-insensitive (RFC 4343), so a
|
|
1610
|
+
* client that sends `API.Example.COM` — which is legal, and what some proxies
|
|
1611
|
+
* and older clients do — must reach the same route as `api.example.com`.
|
|
1612
|
+
* Comparing them verbatim sends that request to a fallback, or to a 404.
|
|
1613
|
+
*/
|
|
1606
1614
|
function matchDomain(pattern: string, host: string): boolean {
|
|
1607
|
-
const actualHost = host.split(':')[0] // strip port
|
|
1608
|
-
|
|
1609
|
-
if (
|
|
1610
|
-
|
|
1615
|
+
const actualHost = host.split(':')[0].toLowerCase() // strip port
|
|
1616
|
+
const wanted = pattern.toLowerCase()
|
|
1617
|
+
if (wanted === actualHost) return true
|
|
1618
|
+
if (wanted.startsWith('*.')) {
|
|
1619
|
+
const suffix = wanted.slice(1) // ".example.com"
|
|
1611
1620
|
return actualHost.endsWith(suffix) && actualHost.length > suffix.length
|
|
1612
1621
|
}
|
|
1613
1622
|
return false
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
parseRequest as parseRpcRequest,
|
|
19
19
|
buildError as rpcError,
|
|
20
20
|
} from '@c9up/comet'
|
|
21
|
+
import { currentNodeEnv } from '../env/nodeEnv.js'
|
|
21
22
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
22
23
|
import type { MiddlewareRegistry, RuntimeValidator } from '../middleware/Pipeline.js'
|
|
23
24
|
import { composeMiddleware, runValidator } from '../middleware/Pipeline.js'
|
|
@@ -316,7 +317,7 @@ export class RpcRouter {
|
|
|
316
317
|
return rpcError(err.code, message, id, err.data)
|
|
317
318
|
}
|
|
318
319
|
// Otherwise don't leak internal error details to the caller.
|
|
319
|
-
const isDev =
|
|
320
|
+
const isDev = currentNodeEnv() !== 'production'
|
|
320
321
|
const message = isDev && err instanceof Error ? err.message : 'Internal error'
|
|
321
322
|
return rpcError(-32603, message, id)
|
|
322
323
|
}
|
|
@@ -24,8 +24,9 @@ import { getServiceRegistry } from '../decorators/Service.js'
|
|
|
24
24
|
import { ReamError } from '../errors/ReamError.js'
|
|
25
25
|
import type { AppContext } from '../Provider.js'
|
|
26
26
|
import { Provider } from '../Provider.js'
|
|
27
|
+
import type { SchedulerConfig } from './config.js'
|
|
27
28
|
import { getScheduleMetadata } from './Schedule.js'
|
|
28
|
-
import { Scheduler } from './Scheduler.js'
|
|
29
|
+
import { Scheduler, type SchedulerOptions } from './Scheduler.js'
|
|
29
30
|
|
|
30
31
|
export interface ScheduleProviderOptions {
|
|
31
32
|
/**
|
|
@@ -40,12 +41,14 @@ let activeProvider: ScheduleProvider | undefined
|
|
|
40
41
|
|
|
41
42
|
export class ScheduleProvider extends Provider {
|
|
42
43
|
readonly scheduler: Scheduler
|
|
43
|
-
|
|
44
|
+
/** Task names already registered, so a second pass adds only what is new. */
|
|
45
|
+
readonly #known = new Set<string>()
|
|
44
46
|
#registered = false
|
|
45
47
|
|
|
46
48
|
constructor(app: AppContext, options: ScheduleProviderOptions = {}) {
|
|
47
49
|
super(app)
|
|
48
|
-
this.scheduler =
|
|
50
|
+
this.scheduler =
|
|
51
|
+
options.scheduler ?? new Scheduler(schedulerOptionsFrom(app.config.get('scheduler')))
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -82,8 +85,44 @@ export class ScheduleProvider extends Provider {
|
|
|
82
85
|
this.#registered = true
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Discovery runs at START, not at boot, and that is the whole point.
|
|
90
|
+
*
|
|
91
|
+
* `app/modules/**` is auto-loaded during the start phase — after every
|
|
92
|
+
* provider has booted. A `@Service()` carrying `@Schedule` there, which is
|
|
93
|
+
* where one naturally lives, was therefore registered into the service
|
|
94
|
+
* registry AFTER this had already read it. The task was never registered,
|
|
95
|
+
* and nothing said so: no error, no warning, the application started
|
|
96
|
+
* normally and the task simply never fired.
|
|
97
|
+
*
|
|
98
|
+
* Reading the registry once the modules are in place is what fixes it. A
|
|
99
|
+
* service declared anywhere earlier — a provider, a preload — is in the
|
|
100
|
+
* registry by then too, so nothing is lost by waiting.
|
|
101
|
+
*/
|
|
85
102
|
override async boot(): Promise<void> {
|
|
86
|
-
|
|
103
|
+
// Anything already in the registry — declared by a provider, or imported
|
|
104
|
+
// by one. The rest is caught at start().
|
|
105
|
+
this.#discover()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override async start(): Promise<void> {
|
|
109
|
+
this.#discover()
|
|
110
|
+
if (activeProvider !== undefined && activeProvider !== this) {
|
|
111
|
+
// A previous app's scheduler is still running — stop it before this one
|
|
112
|
+
// starts, otherwise every @Schedule task fires twice per tick.
|
|
113
|
+
activeProvider.scheduler.stop()
|
|
114
|
+
}
|
|
115
|
+
activeProvider = this
|
|
116
|
+
this.scheduler.start()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Walk the service registry and register every `@Schedule` it declares.
|
|
121
|
+
*
|
|
122
|
+
* Idempotent: a second call registers nothing twice, so an application that
|
|
123
|
+
* restarts its Ignitor does not end up firing everything in duplicate.
|
|
124
|
+
*/
|
|
125
|
+
#discover(): void {
|
|
87
126
|
const registry = getServiceRegistry()
|
|
88
127
|
const registered: string[] = []
|
|
89
128
|
|
|
@@ -118,6 +157,8 @@ export class ScheduleProvider extends Provider {
|
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
const taskName = `${target.name}.${methodName}`
|
|
160
|
+
// Registered on an earlier pass: skip rather than fire it twice.
|
|
161
|
+
if (this.#known.has(taskName)) continue
|
|
121
162
|
try {
|
|
122
163
|
this.scheduler.register(taskName, cronExpr, async () => {
|
|
123
164
|
// Resolve from the container on every fire so transient
|
|
@@ -133,6 +174,7 @@ export class ScheduleProvider extends Provider {
|
|
|
133
174
|
await (method as (...args: unknown[]) => unknown).call(instance)
|
|
134
175
|
})
|
|
135
176
|
registered.push(taskName)
|
|
177
|
+
this.#known.add(taskName)
|
|
136
178
|
} catch (cause) {
|
|
137
179
|
const inner = cause as ReamError & { message?: string; hint?: string }
|
|
138
180
|
throw new ReamError(
|
|
@@ -146,7 +188,6 @@ export class ScheduleProvider extends Provider {
|
|
|
146
188
|
}
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
|
-
this.#booted = true
|
|
150
191
|
} catch (err) {
|
|
151
192
|
// Roll back any partial registrations so a retry does not hit
|
|
152
193
|
// DUPLICATE_TASK and the scheduler is never half-initialized.
|
|
@@ -162,16 +203,6 @@ export class ScheduleProvider extends Provider {
|
|
|
162
203
|
}
|
|
163
204
|
}
|
|
164
205
|
|
|
165
|
-
override async start(): Promise<void> {
|
|
166
|
-
if (activeProvider !== undefined && activeProvider !== this) {
|
|
167
|
-
// A previous app's scheduler is still running — stop it before this one starts,
|
|
168
|
-
// otherwise every @Schedule task fires twice per tick.
|
|
169
|
-
activeProvider.scheduler.stop()
|
|
170
|
-
}
|
|
171
|
-
activeProvider = this
|
|
172
|
-
this.scheduler.start()
|
|
173
|
-
}
|
|
174
|
-
|
|
175
206
|
override async shutdown(): Promise<void> {
|
|
176
207
|
if (activeProvider === this) activeProvider = undefined
|
|
177
208
|
this.scheduler.stop()
|
|
@@ -180,3 +211,22 @@ export class ScheduleProvider extends Provider {
|
|
|
180
211
|
|
|
181
212
|
// Default export so reamrc's provider loader can `() => import('@c9up/ream/<feature>/provider')` (resolves to { default }), matching events/rpc. Named export above stays.
|
|
182
213
|
export default ScheduleProvider
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Turn `config/scheduler.ts` into the options the `Scheduler` takes.
|
|
217
|
+
*
|
|
218
|
+
* The lock is built here rather than in the config file because that file is
|
|
219
|
+
* read before the application boots — a Redis connection named in it does not
|
|
220
|
+
* exist yet, so the helpers hand back a factory and this calls it.
|
|
221
|
+
*/
|
|
222
|
+
function schedulerOptionsFrom(config: SchedulerConfig | undefined): SchedulerOptions {
|
|
223
|
+
if (config === undefined) return {}
|
|
224
|
+
const options: SchedulerOptions = {}
|
|
225
|
+
if (config.lock !== undefined) {
|
|
226
|
+
options.lockBackend = typeof config.lock === 'function' ? config.lock() : config.lock
|
|
227
|
+
}
|
|
228
|
+
if (config.defaultLockTtlMs !== undefined) {
|
|
229
|
+
options.defaultLockTtlMs = config.defaultLockTtlMs
|
|
230
|
+
}
|
|
231
|
+
return options
|
|
232
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Author-time config for `config/scheduler.ts`.
|
|
3
|
+
*
|
|
4
|
+
* import { defineConfig, locks } from '@c9up/ream/scheduler/config'
|
|
5
|
+
*
|
|
6
|
+
* export default defineConfig({
|
|
7
|
+
* lock: locks.redis({ connection: 'main' }),
|
|
8
|
+
* })
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LockBackend } from './locks/LockBackend.js'
|
|
12
|
+
import type { LockBackendFactory } from './locks/locks.js'
|
|
13
|
+
|
|
14
|
+
export interface SchedulerConfig {
|
|
15
|
+
/**
|
|
16
|
+
* The lock consulted before a task fires, so that only one instance runs
|
|
17
|
+
* it. Takes a backend, or a factory answering one — which is what the
|
|
18
|
+
* `locks.*` helpers return, so a config file can name a Redis lock before
|
|
19
|
+
* the connection exists.
|
|
20
|
+
*
|
|
21
|
+
* Left out, the scheduler locks nothing: every instance runs every task.
|
|
22
|
+
* Correct on one instance, and a duplicate run per replica past that.
|
|
23
|
+
*/
|
|
24
|
+
lock?: LockBackend | LockBackendFactory
|
|
25
|
+
/** How long a lease is held before it expires on its own. Default 60 000. */
|
|
26
|
+
defaultLockTtlMs?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defineConfig(config: SchedulerConfig): SchedulerConfig {
|
|
30
|
+
return config
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type { LockBackend } from './locks/LockBackend.js'
|
|
34
|
+
export { type LockBackendFactory, locks } from './locks/locks.js'
|
package/src/scheduler/index.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
export type { SchedulerConfig } from './config.js'
|
|
2
|
+
export { defineConfig as defineSchedulerConfig } from './config.js'
|
|
1
3
|
export type { LockBackend } from './locks/LockBackend.js'
|
|
4
|
+
export { type LockBackendFactory, locks } from './locks/locks.js'
|
|
2
5
|
export { MemoryLockBackend } from './locks/MemoryLockBackend.js'
|
|
6
|
+
export {
|
|
7
|
+
type LockRedisClient,
|
|
8
|
+
type LockRedisResolver,
|
|
9
|
+
RedisLockBackend,
|
|
10
|
+
} from './locks/RedisLockBackend.js'
|
|
3
11
|
export type { ErrorReporter } from './observability/ErrorReporter.js'
|
|
4
12
|
export type {
|
|
5
13
|
ScheduleEvent,
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The scheduler consults a `LockBackend` immediately before firing a
|
|
5
5
|
* registered task so that in multi-instance deployments only one
|
|
6
|
-
* instance runs a given task on each tick.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* instance runs a given task on each tick.
|
|
7
|
+
*
|
|
8
|
+
* Two backends ship with it: `MemoryLockBackend`, correct while the
|
|
9
|
+
* application runs in one process, and `RedisLockBackend`, which is
|
|
10
|
+
* what a second replica needs — see `locks` for the factories a
|
|
11
|
+
* config file names.
|
|
9
12
|
*
|
|
10
13
|
* @implements Story 28.3
|
|
11
14
|
*/
|
|
12
15
|
|
|
16
|
+
import { ReamError } from '../../errors/ReamError.js'
|
|
17
|
+
|
|
13
18
|
export interface LockBackend {
|
|
14
19
|
/**
|
|
15
20
|
* Attempt to acquire an exclusive lock on `name` for up to `ttlMs`
|
|
@@ -36,3 +41,21 @@ export interface LockBackend {
|
|
|
36
41
|
*/
|
|
37
42
|
release(name: string): Promise<void>
|
|
38
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Reject a TTL that cannot bound anything. A zero or negative lease
|
|
47
|
+
* expires the instant it is taken, so every instance would acquire the
|
|
48
|
+
* same lock and run the same task — the exact outcome locking exists to
|
|
49
|
+
* prevent, arriving silently.
|
|
50
|
+
*/
|
|
51
|
+
export function assertValidTtl(ttlMs: number): void {
|
|
52
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
53
|
+
throw new ReamError(
|
|
54
|
+
'SCHEDULE_INVALID_LOCK_TTL',
|
|
55
|
+
`Lock TTL must be a finite positive number, got ${ttlMs}`,
|
|
56
|
+
{
|
|
57
|
+
hint: 'Use a millisecond value greater than zero (typical range 1_000 - 600_000).',
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Default single-process lock backend backed by a `Map`.
|
|
3
3
|
*
|
|
4
|
-
* Correct for apps that run in a single Node.js process
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* package" rule.
|
|
4
|
+
* Correct for apps that run in a single Node.js process, and only
|
|
5
|
+
* those: two processes each keep their own map, so both acquire the
|
|
6
|
+
* same name and both run the task. Reach for `RedisLockBackend` the
|
|
7
|
+
* moment a second replica exists.
|
|
9
8
|
*
|
|
10
9
|
* Expiry is evaluated **lazily** at `acquire` time — no background
|
|
11
10
|
* timer, no `setInterval`. This preserves Story 28.1's
|
|
@@ -20,23 +19,10 @@
|
|
|
20
19
|
* @implements Story 28.3
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
|
-
import {
|
|
24
|
-
import type { LockBackend } from './LockBackend.js'
|
|
22
|
+
import { assertValidTtl, type LockBackend } from './LockBackend.js'
|
|
25
23
|
|
|
26
24
|
const SWEEP_THRESHOLD = 256
|
|
27
25
|
|
|
28
|
-
function assertValidTtl(ttlMs: number): void {
|
|
29
|
-
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
30
|
-
throw new ReamError(
|
|
31
|
-
'SCHEDULE_INVALID_LOCK_TTL',
|
|
32
|
-
`Lock TTL must be a finite positive number, got ${ttlMs}`,
|
|
33
|
-
{
|
|
34
|
-
hint: 'Use a millisecond value greater than zero (typical range 1_000 – 600_000).',
|
|
35
|
-
},
|
|
36
|
-
)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
26
|
export class MemoryLockBackend implements LockBackend {
|
|
41
27
|
#locks = new Map<string, number>()
|
|
42
28
|
|