@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.
Files changed (183) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts +26 -6
  3. package/dist/Application.d.ts.map +1 -1
  4. package/dist/Application.js +40 -12
  5. package/dist/Application.js.map +1 -1
  6. package/dist/ErrorBoundary.d.ts.map +1 -1
  7. package/dist/ErrorBoundary.js +2 -1
  8. package/dist/ErrorBoundary.js.map +1 -1
  9. package/dist/Ignitor.d.ts +13 -0
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +149 -26
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/bodyparser/MultipartFile.d.ts +8 -0
  14. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  15. package/dist/bodyparser/MultipartFile.js +46 -1
  16. package/dist/bodyparser/MultipartFile.js.map +1 -1
  17. package/dist/commands/Migrate.d.ts +9 -0
  18. package/dist/commands/Migrate.d.ts.map +1 -0
  19. package/dist/commands/Migrate.js +16 -0
  20. package/dist/commands/Migrate.js.map +1 -0
  21. package/dist/commands/MigrateBase.d.ts +27 -0
  22. package/dist/commands/MigrateBase.d.ts.map +1 -0
  23. package/dist/commands/MigrateBase.js +77 -0
  24. package/dist/commands/MigrateBase.js.map +1 -0
  25. package/dist/commands/MigrateRollback.d.ts +9 -0
  26. package/dist/commands/MigrateRollback.d.ts.map +1 -0
  27. package/dist/commands/MigrateRollback.js +16 -0
  28. package/dist/commands/MigrateRollback.js.map +1 -0
  29. package/dist/commands/MigrateStatus.d.ts +9 -0
  30. package/dist/commands/MigrateStatus.d.ts.map +1 -0
  31. package/dist/commands/MigrateStatus.js +19 -0
  32. package/dist/commands/MigrateStatus.js.map +1 -0
  33. package/dist/commands/ScheduleList.d.ts +18 -0
  34. package/dist/commands/ScheduleList.d.ts.map +1 -0
  35. package/dist/commands/ScheduleList.js +55 -0
  36. package/dist/commands/ScheduleList.js.map +1 -0
  37. package/dist/commands/ScheduleRun.d.ts +19 -0
  38. package/dist/commands/ScheduleRun.d.ts.map +1 -0
  39. package/dist/commands/ScheduleRun.js +61 -0
  40. package/dist/commands/ScheduleRun.js.map +1 -0
  41. package/dist/commands/index.d.ts +15 -0
  42. package/dist/commands/index.d.ts.map +1 -0
  43. package/dist/commands/index.js +63 -0
  44. package/dist/commands/index.js.map +1 -0
  45. package/dist/commands/scheduler_resolver.d.ts +12 -0
  46. package/dist/commands/scheduler_resolver.d.ts.map +1 -0
  47. package/dist/commands/scheduler_resolver.js +19 -0
  48. package/dist/commands/scheduler_resolver.js.map +1 -0
  49. package/dist/env/Env.d.ts.map +1 -1
  50. package/dist/env/Env.js +2 -1
  51. package/dist/env/Env.js.map +1 -1
  52. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  53. package/dist/env/loadEnvFiles.js +5 -1
  54. package/dist/env/loadEnvFiles.js.map +1 -1
  55. package/dist/env/nodeEnv.d.ts +24 -0
  56. package/dist/env/nodeEnv.d.ts.map +1 -0
  57. package/dist/env/nodeEnv.js +40 -0
  58. package/dist/env/nodeEnv.js.map +1 -0
  59. package/dist/errors/ReamError.d.ts +6 -1
  60. package/dist/errors/ReamError.d.ts.map +1 -1
  61. package/dist/errors/ReamError.js +8 -2
  62. package/dist/errors/ReamError.js.map +1 -1
  63. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  64. package/dist/graphql/GraphQLEngine.js +3 -2
  65. package/dist/graphql/GraphQLEngine.js.map +1 -1
  66. package/dist/http/Exception.d.ts.map +1 -1
  67. package/dist/http/Exception.js +2 -1
  68. package/dist/http/Exception.js.map +1 -1
  69. package/dist/http/Request.d.ts +18 -3
  70. package/dist/http/Request.d.ts.map +1 -1
  71. package/dist/http/Request.js +71 -8
  72. package/dist/http/Request.js.map +1 -1
  73. package/dist/http/Response.d.ts +11 -4
  74. package/dist/http/Response.d.ts.map +1 -1
  75. package/dist/http/Response.js +46 -17
  76. package/dist/http/Response.js.map +1 -1
  77. package/dist/index.d.ts +3 -3
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +2 -2
  80. package/dist/index.js.map +1 -1
  81. package/dist/router/Router.d.ts.map +1 -1
  82. package/dist/router/Router.js +14 -5
  83. package/dist/router/Router.js.map +1 -1
  84. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  85. package/dist/rpc/RpcRouter.js +2 -1
  86. package/dist/rpc/RpcRouter.js.map +1 -1
  87. package/dist/scheduler/ScheduleProvider.d.ts +14 -0
  88. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  89. package/dist/scheduler/ScheduleProvider.js +62 -14
  90. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  91. package/dist/scheduler/config.d.ts +29 -0
  92. package/dist/scheduler/config.d.ts.map +1 -0
  93. package/dist/scheduler/config.js +14 -0
  94. package/dist/scheduler/config.js.map +1 -0
  95. package/dist/scheduler/index.d.ts +4 -0
  96. package/dist/scheduler/index.d.ts.map +1 -1
  97. package/dist/scheduler/index.js +3 -0
  98. package/dist/scheduler/index.js.map +1 -1
  99. package/dist/scheduler/locks/LockBackend.d.ts +13 -3
  100. package/dist/scheduler/locks/LockBackend.d.ts.map +1 -1
  101. package/dist/scheduler/locks/LockBackend.js +20 -4
  102. package/dist/scheduler/locks/LockBackend.js.map +1 -1
  103. package/dist/scheduler/locks/MemoryLockBackend.d.ts +5 -6
  104. package/dist/scheduler/locks/MemoryLockBackend.d.ts.map +1 -1
  105. package/dist/scheduler/locks/MemoryLockBackend.js +5 -13
  106. package/dist/scheduler/locks/MemoryLockBackend.js.map +1 -1
  107. package/dist/scheduler/locks/RedisLockBackend.d.ts +53 -0
  108. package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -0
  109. package/dist/scheduler/locks/RedisLockBackend.js +83 -0
  110. package/dist/scheduler/locks/RedisLockBackend.js.map +1 -0
  111. package/dist/scheduler/locks/locks.d.ts +39 -0
  112. package/dist/scheduler/locks/locks.d.ts.map +1 -0
  113. package/dist/scheduler/locks/locks.js +42 -0
  114. package/dist/scheduler/locks/locks.js.map +1 -0
  115. package/dist/scheduler/locks/quasar.d.ts +17 -0
  116. package/dist/scheduler/locks/quasar.d.ts.map +1 -0
  117. package/dist/scheduler/locks/quasar.js +51 -0
  118. package/dist/scheduler/locks/quasar.js.map +1 -0
  119. package/dist/security/CookieSigner.d.ts +16 -13
  120. package/dist/security/CookieSigner.d.ts.map +1 -1
  121. package/dist/security/CookieSigner.js +33 -14
  122. package/dist/security/CookieSigner.js.map +1 -1
  123. package/dist/services/encryption.d.ts +12 -2
  124. package/dist/services/encryption.d.ts.map +1 -1
  125. package/dist/services/encryption.js +11 -1
  126. package/dist/services/encryption.js.map +1 -1
  127. package/dist/session/Session.d.ts +8 -7
  128. package/dist/session/Session.d.ts.map +1 -1
  129. package/dist/session/Session.js.map +1 -1
  130. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  131. package/dist/session/SessionMiddleware.js +4 -1
  132. package/dist/session/SessionMiddleware.js.map +1 -1
  133. package/dist/testing/ApiResponse.d.ts.map +1 -1
  134. package/dist/testing/ApiResponse.js +1 -1
  135. package/dist/testing/ApiResponse.js.map +1 -1
  136. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  137. package/dist/testing/RequestBuilder.js +1 -1
  138. package/dist/testing/RequestBuilder.js.map +1 -1
  139. package/dist/utils/Macroable.d.ts.map +1 -1
  140. package/dist/utils/Macroable.js +1 -0
  141. package/dist/utils/Macroable.js.map +1 -1
  142. package/events.win32-x64-msvc.node +0 -0
  143. package/index.win32-x64-msvc.node +0 -0
  144. package/package.json +9 -1
  145. package/scheduler.win32-x64-msvc.node +0 -0
  146. package/src/Application.ts +42 -15
  147. package/src/ErrorBoundary.ts +3 -1
  148. package/src/Ignitor.ts +160 -30
  149. package/src/bodyparser/MultipartFile.ts +48 -1
  150. package/src/commands/Migrate.ts +18 -0
  151. package/src/commands/MigrateBase.ts +81 -0
  152. package/src/commands/MigrateRollback.ts +18 -0
  153. package/src/commands/MigrateStatus.ts +22 -0
  154. package/src/commands/ScheduleList.ts +66 -0
  155. package/src/commands/ScheduleRun.ts +65 -0
  156. package/src/commands/index.ts +73 -0
  157. package/src/commands/scheduler_resolver.ts +23 -0
  158. package/src/env/Env.ts +2 -1
  159. package/src/env/loadEnvFiles.ts +5 -1
  160. package/src/env/nodeEnv.ts +38 -0
  161. package/src/errors/ReamError.ts +9 -2
  162. package/src/graphql/GraphQLEngine.ts +3 -2
  163. package/src/http/Exception.ts +2 -1
  164. package/src/http/Request.ts +73 -8
  165. package/src/http/Response.ts +58 -19
  166. package/src/index.ts +8 -1
  167. package/src/router/Router.ts +14 -5
  168. package/src/rpc/RpcRouter.ts +2 -1
  169. package/src/scheduler/ScheduleProvider.ts +65 -15
  170. package/src/scheduler/config.ts +34 -0
  171. package/src/scheduler/index.ts +8 -0
  172. package/src/scheduler/locks/LockBackend.ts +26 -3
  173. package/src/scheduler/locks/MemoryLockBackend.ts +5 -19
  174. package/src/scheduler/locks/RedisLockBackend.ts +114 -0
  175. package/src/scheduler/locks/locks.ts +49 -0
  176. package/src/scheduler/locks/quasar.ts +67 -0
  177. package/src/security/CookieSigner.ts +36 -14
  178. package/src/services/encryption.ts +12 -2
  179. package/src/session/Session.ts +8 -7
  180. package/src/session/SessionMiddleware.ts +4 -1
  181. package/src/testing/ApiResponse.ts +1 -2
  182. package/src/testing/RequestBuilder.ts +1 -2
  183. package/src/utils/Macroable.ts +1 -0
@@ -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
- const value = raw === null ? null : this.#cookieSigner ? this.#cookieSigner.unsign(raw) : raw
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-safe view of the request (AdonisJS `serialize`), for logs and error
679
- * reports. Carries no body: it may hold credentials, and a log line is the
680
- * last place they should land.
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
- host: this.host(),
689
- headers: this.headers(),
690
- qs: this.qs(),
691
- params: this.params(),
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)
@@ -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.#body = safeStringify(data)
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.#body = `/**/ typeof ${safeCallback} === 'function' && ${safeCallback}(${json});`
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.#body = data
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.#body = safeStringify(data)
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.#body = String(data)
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 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.',
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(path?: string, forwardQueryString = false, statusCode = 302): RedirectBuilder | void {
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 (AdonisJS default). The value is HMAC-signed
1067
- * with the app's `APP_KEY` (via the injected signer) so tampering is detected
1068
- * on read; `request.cookie()` verifies + unwraps it. Falls back to a plain
1069
- * cookie when no APP_KEY / encryption service is configured.
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
- const signed = this.#cookieSigner ? this.#cookieSigner.sign(value) : value
1073
- return this.#writeCookie(name, signed, options)
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 = 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.8'
381
+ export const VERSION = '0.2.10'
@@ -1602,12 +1602,21 @@ function matchPath(pattern: string, actual: string): MatchedParams | null {
1602
1602
  return params
1603
1603
  }
1604
1604
 
1605
- /** Match domain pattern against actual host. Supports wildcards like *.example.com */
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
- if (pattern === actualHost) return true
1609
- if (pattern.startsWith('*.')) {
1610
- const suffix = pattern.slice(1) // ".example.com"
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
@@ -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 = process.env.NODE_ENV !== 'production'
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
- #booted = false
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 = options.scheduler ?? new 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
- if (this.#booted) return
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'
@@ -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. Redis, database
7
- * advisory-lock, and other backends live in user-land — this module
8
- * ships only the interface and a single-process `MemoryLockBackend`.
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. For
5
- * horizontally-scaled deployments, implement `LockBackend` against a
6
- * shared store (Redis `SET NX PX`, Postgres advisory locks, etc.) —
7
- * those drivers live in user-land per the project's "agnostic per
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 { ReamError } from '../../errors/ReamError.js'
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