@c9up/ream 0.2.14 → 0.2.16

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 (206) hide show
  1. package/README.md +1 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +6 -1
  4. package/dist/Application.js.map +1 -1
  5. package/dist/Ignitor.d.ts +9 -0
  6. package/dist/Ignitor.d.ts.map +1 -1
  7. package/dist/Ignitor.js +29 -3
  8. package/dist/Ignitor.js.map +1 -1
  9. package/dist/Provider.d.ts +20 -2
  10. package/dist/Provider.d.ts.map +1 -1
  11. package/dist/Provider.js.map +1 -1
  12. package/dist/bodyparser/BodyParserMiddleware.d.ts +1 -1
  13. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  14. package/dist/bodyparser/BodyParserMiddleware.js +12 -1
  15. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  16. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  17. package/dist/bodyparser/MultipartFile.js +7 -3
  18. package/dist/bodyparser/MultipartFile.js.map +1 -1
  19. package/dist/bodyparser/config.d.ts +15 -0
  20. package/dist/bodyparser/config.d.ts.map +1 -0
  21. package/dist/bodyparser/config.js +16 -0
  22. package/dist/bodyparser/config.js.map +1 -0
  23. package/dist/bodyparser/parseSize.d.ts.map +1 -1
  24. package/dist/bodyparser/parseSize.js +3 -2
  25. package/dist/bodyparser/parseSize.js.map +1 -1
  26. package/dist/commands/MigrateBase.d.ts.map +1 -1
  27. package/dist/commands/MigrateBase.js +4 -1
  28. package/dist/commands/MigrateBase.js.map +1 -1
  29. package/dist/config/moduleConfig.d.ts +15 -0
  30. package/dist/config/moduleConfig.d.ts.map +1 -0
  31. package/dist/config/moduleConfig.js +24 -0
  32. package/dist/config/moduleConfig.js.map +1 -0
  33. package/dist/console/Kernel.d.ts.map +1 -1
  34. package/dist/console/Kernel.js +7 -0
  35. package/dist/console/Kernel.js.map +1 -1
  36. package/dist/console/cliui.d.ts +2 -0
  37. package/dist/console/cliui.d.ts.map +1 -1
  38. package/dist/console/cliui.js +2 -1
  39. package/dist/console/cliui.js.map +1 -1
  40. package/dist/console/types.d.ts +14 -0
  41. package/dist/console/types.d.ts.map +1 -1
  42. package/dist/console/types.js.map +1 -1
  43. package/dist/container/Container.d.ts.map +1 -1
  44. package/dist/container/Container.js +6 -1
  45. package/dist/container/Container.js.map +1 -1
  46. package/dist/container/types.d.ts +11 -1
  47. package/dist/container/types.d.ts.map +1 -1
  48. package/dist/decorators/Service.d.ts +1 -1
  49. package/dist/decorators/Service.d.ts.map +1 -1
  50. package/dist/env/Env.d.ts.map +1 -1
  51. package/dist/env/Env.js +4 -2
  52. package/dist/env/Env.js.map +1 -1
  53. package/dist/env/interpolate.d.ts.map +1 -1
  54. package/dist/env/interpolate.js +5 -3
  55. package/dist/env/interpolate.js.map +1 -1
  56. package/dist/errors/FuzzyMatcher.d.ts.map +1 -1
  57. package/dist/errors/FuzzyMatcher.js +30 -13
  58. package/dist/errors/FuzzyMatcher.js.map +1 -1
  59. package/dist/errors/PipelineStageError.d.ts.map +1 -1
  60. package/dist/errors/PipelineStageError.js +4 -2
  61. package/dist/errors/PipelineStageError.js.map +1 -1
  62. package/dist/events/Emitter.d.ts +21 -4
  63. package/dist/events/Emitter.d.ts.map +1 -1
  64. package/dist/events/Emitter.js +6 -2
  65. package/dist/events/Emitter.js.map +1 -1
  66. package/dist/events/testing/FakeBus.d.ts +1 -1
  67. package/dist/events/testing/FakeBus.d.ts.map +1 -1
  68. package/dist/events/testing/FakeBus.js.map +1 -1
  69. package/dist/graphql/GraphQLEngine.d.ts +2 -2
  70. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  71. package/dist/graphql/GraphQLEngine.js +4 -2
  72. package/dist/graphql/GraphQLEngine.js.map +1 -1
  73. package/dist/helpers/bytes.d.ts.map +1 -1
  74. package/dist/helpers/bytes.js +13 -2
  75. package/dist/helpers/bytes.js.map +1 -1
  76. package/dist/helpers/string.js +3 -3
  77. package/dist/helpers/string.js.map +1 -1
  78. package/dist/http/Exception.d.ts +8 -4
  79. package/dist/http/Exception.d.ts.map +1 -1
  80. package/dist/http/Exception.js +14 -8
  81. package/dist/http/Exception.js.map +1 -1
  82. package/dist/http/Request.d.ts.map +1 -1
  83. package/dist/http/Request.js +10 -7
  84. package/dist/http/Request.js.map +1 -1
  85. package/dist/http/mime.d.ts.map +1 -1
  86. package/dist/http/mime.js +2 -1
  87. package/dist/http/mime.js.map +1 -1
  88. package/dist/index.d.ts +2 -2
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +1 -1
  91. package/dist/native/generated.d.ts +12 -8
  92. package/dist/native/generated.d.ts.map +1 -1
  93. package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
  94. package/dist/openapi/OpenApiGenerator.js +6 -4
  95. package/dist/openapi/OpenApiGenerator.js.map +1 -1
  96. package/dist/router/Router.d.ts +10 -1
  97. package/dist/router/Router.d.ts.map +1 -1
  98. package/dist/router/Router.js +22 -10
  99. package/dist/router/Router.js.map +1 -1
  100. package/dist/rpc/RpcRouter.d.ts +16 -3
  101. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  102. package/dist/rpc/RpcRouter.js +34 -3
  103. package/dist/rpc/RpcRouter.js.map +1 -1
  104. package/dist/scheduler/Schedule.d.ts +6 -1
  105. package/dist/scheduler/Schedule.d.ts.map +1 -1
  106. package/dist/scheduler/Schedule.js +5 -0
  107. package/dist/scheduler/Schedule.js.map +1 -1
  108. package/dist/scheduler/ScheduleProvider.d.ts +15 -11
  109. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  110. package/dist/scheduler/ScheduleProvider.js +21 -12
  111. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  112. package/dist/security/CookieSigner.d.ts.map +1 -1
  113. package/dist/security/CookieSigner.js +12 -5
  114. package/dist/security/CookieSigner.js.map +1 -1
  115. package/dist/security/SignedUrl.js +3 -2
  116. package/dist/security/SignedUrl.js.map +1 -1
  117. package/dist/session/SessionMiddleware.d.ts +2 -4
  118. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  119. package/dist/session/SessionMiddleware.js +13 -1
  120. package/dist/session/SessionMiddleware.js.map +1 -1
  121. package/dist/session/config.d.ts +25 -0
  122. package/dist/session/config.d.ts.map +1 -0
  123. package/dist/session/config.js +18 -0
  124. package/dist/session/config.js.map +1 -0
  125. package/dist/session/drivers/DatabaseDriver.d.ts +13 -1
  126. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  127. package/dist/session/drivers/DatabaseDriver.js +23 -3
  128. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  129. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  130. package/dist/session/drivers/RedisDriver.js +10 -2
  131. package/dist/session/drivers/RedisDriver.js.map +1 -1
  132. package/dist/testing/ApiResponse.d.ts.map +1 -1
  133. package/dist/testing/ApiResponse.js +10 -11
  134. package/dist/testing/ApiResponse.js.map +1 -1
  135. package/dist/testing/TestClient.js +6 -6
  136. package/dist/testing/TestClient.js.map +1 -1
  137. package/dist/utils/Macroable.d.ts +6 -2
  138. package/dist/utils/Macroable.d.ts.map +1 -1
  139. package/dist/utils/Macroable.js +4 -0
  140. package/dist/utils/Macroable.js.map +1 -1
  141. package/dist/utils/objectPath.d.ts.map +1 -1
  142. package/dist/utils/objectPath.js +14 -6
  143. package/dist/utils/objectPath.js.map +1 -1
  144. package/events.darwin-arm64.node +0 -0
  145. package/events.darwin-x64.node +0 -0
  146. package/events.linux-arm64-gnu.node +0 -0
  147. package/events.linux-x64-gnu.node +0 -0
  148. package/events.win32-x64-msvc.node +0 -0
  149. package/index.darwin-arm64.node +0 -0
  150. package/index.darwin-x64.node +0 -0
  151. package/index.linux-arm64-gnu.node +0 -0
  152. package/index.linux-x64-gnu.node +0 -0
  153. package/index.win32-x64-msvc.node +0 -0
  154. package/package.json +13 -4
  155. package/scheduler.darwin-arm64.node +0 -0
  156. package/scheduler.darwin-x64.node +0 -0
  157. package/scheduler.linux-arm64-gnu.node +0 -0
  158. package/scheduler.linux-x64-gnu.node +0 -0
  159. package/scheduler.win32-x64-msvc.node +0 -0
  160. package/scripts/build-napi-types.mjs +4 -3
  161. package/scripts/generate-napi-types.mjs +28 -12
  162. package/src/Application.ts +6 -1
  163. package/src/Ignitor.ts +42 -3
  164. package/src/Provider.ts +21 -2
  165. package/src/bodyparser/BodyParserMiddleware.ts +12 -1
  166. package/src/bodyparser/MultipartFile.ts +7 -3
  167. package/src/bodyparser/config.ts +18 -0
  168. package/src/bodyparser/parseSize.ts +3 -2
  169. package/src/commands/MigrateBase.ts +4 -1
  170. package/src/config/moduleConfig.ts +24 -0
  171. package/src/console/BaseCommand.ts +1 -1
  172. package/src/console/Kernel.ts +8 -0
  173. package/src/console/cliui.ts +2 -1
  174. package/src/console/types.ts +15 -0
  175. package/src/container/Container.ts +15 -6
  176. package/src/container/types.ts +11 -1
  177. package/src/decorators/Service.ts +1 -1
  178. package/src/env/Env.ts +4 -2
  179. package/src/env/interpolate.ts +5 -3
  180. package/src/errors/FuzzyMatcher.ts +34 -13
  181. package/src/errors/PipelineStageError.ts +4 -2
  182. package/src/events/Emitter.ts +34 -8
  183. package/src/events/testing/FakeBus.ts +12 -5
  184. package/src/graphql/GraphQLEngine.ts +11 -7
  185. package/src/helpers/bytes.ts +13 -2
  186. package/src/helpers/string.ts +3 -3
  187. package/src/http/Exception.ts +14 -8
  188. package/src/http/Request.ts +8 -5
  189. package/src/http/mime.ts +2 -1
  190. package/src/index.ts +2 -2
  191. package/src/native/generated.ts +12 -8
  192. package/src/openapi/OpenApiGenerator.ts +6 -4
  193. package/src/router/Router.ts +31 -11
  194. package/src/rpc/RpcRouter.ts +38 -8
  195. package/src/scheduler/Schedule.ts +8 -1
  196. package/src/scheduler/ScheduleProvider.ts +22 -12
  197. package/src/security/CookieSigner.ts +15 -5
  198. package/src/security/SignedUrl.ts +3 -2
  199. package/src/session/SessionMiddleware.ts +15 -2
  200. package/src/session/config.ts +29 -0
  201. package/src/session/drivers/DatabaseDriver.ts +36 -8
  202. package/src/session/drivers/RedisDriver.ts +13 -5
  203. package/src/testing/ApiResponse.ts +9 -10
  204. package/src/testing/TestClient.ts +6 -6
  205. package/src/utils/Macroable.ts +6 -2
  206. package/src/utils/objectPath.ts +12 -6
@@ -30,7 +30,7 @@ export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown>
30
30
  * the shared `'middleware'` registry, and `validator:<name>` schemas.
31
31
  */
32
32
  interface RpcContainer {
33
- make<T>(target: new (...args: unknown[]) => T): Promise<T>
33
+ make<T>(target: abstract new (...args: never[]) => T): Promise<T>
34
34
  resolve<T>(token: string): Promise<T>
35
35
  has(token: string): boolean
36
36
  }
@@ -151,11 +151,21 @@ export class RpcRouter {
151
151
  return new RpcMethodBuilder(def)
152
152
  }
153
153
 
154
- /** Register methods from a controller — auto-registers all public methods. */
155
- namespace(
156
- prefix: string,
157
- controller: new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>,
158
- ): void {
154
+ /**
155
+ * Register methods from a controller — auto-registers all public methods.
156
+ *
157
+ * The parameter used to demand
158
+ * `new (...args: unknown[]) => Record<string, (...args: unknown[]) => unknown>`,
159
+ * which NO real controller satisfies: a class with any non-method property
160
+ * fails the `Record`, and a method declared `bump(): number` fails the
161
+ * `unknown[]` parameters, which are compared contravariantly. So the
162
+ * documented call — `rpc.namespace('task', TaskController)` — did not
163
+ * typecheck, and nothing noticed because the tests were outside `tsc`.
164
+ *
165
+ * Any class, then. The body already checks that what it found is callable
166
+ * and names the method when it is not.
167
+ */
168
+ namespace(prefix: string, controller: abstract new (...args: never[]) => object): void {
159
169
  const proto = controller.prototype
160
170
  const methods = Object.getOwnPropertyNames(proto).filter(
161
171
  (m) => m !== 'constructor' && typeof proto[m] === 'function',
@@ -164,8 +174,18 @@ export class RpcRouter {
164
174
  this.method(`${prefix}.${methodName}`, async (ctx, params) => {
165
175
  // Resolve through the container on every call (fresh DI per request,
166
176
  // like GraphQLEngine), falling back to a bare `new` when unset.
167
- const instance = this.#container ? await this.#container.make(controller) : new controller()
168
- return instance[methodName](ctx, params)
177
+ // `Reflect.construct`, not `new controller()`: the token may be an
178
+ // abstract class, which the runtime builds happily and `new` refuses.
179
+ const instance: object = this.#container
180
+ ? await this.#container.make(controller)
181
+ : Reflect.construct(controller, [])
182
+ const handler = Reflect.get(instance, methodName)
183
+ if (typeof handler !== 'function') {
184
+ throw new Error(
185
+ `RPC method '${prefix}.${methodName}' is not a function on ${controller.name}`,
186
+ )
187
+ }
188
+ return handler.call(instance, ctx, params)
169
189
  })
170
190
  }
171
191
  }
@@ -199,6 +219,16 @@ export class RpcRouter {
199
219
 
200
220
  // Batch support (max 50 to prevent DoS)
201
221
  if (Array.isArray(body)) {
222
+ // §6: "If the batch rpc call itself fails to be recognized as an valid
223
+ // JSON or as an Array with at least one value, the response from the
224
+ // Server MUST be a single Response object." An empty batch answered with
225
+ // 204 No Content, which is what this replies when every call in a batch
226
+ // was a notification — so a client that sent nothing was told its calls
227
+ // had run.
228
+ if (body.length === 0) {
229
+ ctx.response.status(200).json(rpcError(-32600, 'Invalid Request', null))
230
+ return
231
+ }
202
232
  if (body.length > 50) {
203
233
  ctx.response.status(400).json(rpcError(-32600, 'Batch too large (max 50)', null))
204
234
  return
@@ -90,7 +90,14 @@ export function Schedule(cronExpr: string): MethodDecorator {
90
90
  * none). Returns a shallow copy so callers cannot mutate the stored
91
91
  * metadata by accident.
92
92
  */
93
- export function getScheduleMetadata(target: Constructor): ScheduleMetadata[] {
93
+ /**
94
+ * `abstract new`, not `Constructor`: reading metadata off a class never
95
+ * constructs it, and the service registry holds abstract ones too since a
96
+ * contextual binding binds against an abstract base.
97
+ */
98
+ export function getScheduleMetadata(
99
+ target: abstract new (...args: never[]) => unknown,
100
+ ): ScheduleMetadata[] {
94
101
  const stored: ScheduleMetadata[] = Reflect.getOwnMetadata(SCHEDULE_METADATA_KEY, target) ?? []
95
102
  return [...stored]
96
103
  }
@@ -10,7 +10,8 @@
10
10
  * throws, tasks 1..N-1 registered in the same boot are unregistered
11
11
  * before the error propagates, so the scheduler is never left in a
12
12
  * half-registered state.
13
- * - `start()` — launches the Rust tick loop.
13
+ * - `ready()` — discovers the tasks `app/modules/**` declared, then
14
+ * launches the Rust tick loop.
14
15
  * - `shutdown()` — cancels the Rust tick loop.
15
16
  *
16
17
  * The registered callback resolves the service **at invocation time**
@@ -86,26 +87,35 @@ export class ScheduleProvider extends Provider {
86
87
  }
87
88
 
88
89
  /**
89
- * Discovery runs at START, not at boot, and that is the whole point.
90
+ * The pass that matters runs at READY, and the ordering is the reason.
90
91
  *
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.
92
+ * A `@Service()` carrying `@Schedule` naturally lives in `app/modules/**`,
93
+ * which the application auto-loads at the END of the start phase — after
94
+ * every provider's `start()`. Discovering in `start()` therefore read the
95
+ * service registry before the modules had put anything in it: the task was
96
+ * never registered, and nothing said so. No error, no warning, the
97
+ * application started normally and the task simply never fired. `ready()`
98
+ * runs after the autoload, which is what makes the second pass real.
97
99
  *
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.
100
+ * The earlier passes are kept because they cost nothing and shorten the gap:
101
+ * a service declared by a provider or a preload is registered as soon as it
102
+ * is seen. `#discover()` is idempotent, so a task found twice is registered
103
+ * once.
101
104
  */
102
105
  override async boot(): Promise<void> {
103
106
  // Anything already in the registry — declared by a provider, or imported
104
- // by one. The rest is caught at start().
107
+ // by one. The rest is caught later.
105
108
  this.#discover()
106
109
  }
107
110
 
108
111
  override async start(): Promise<void> {
112
+ // Preloads have not run yet either; this catches whatever the boot phase
113
+ // added. The ticker deliberately does NOT start here.
114
+ this.#discover()
115
+ }
116
+
117
+ override async ready(): Promise<void> {
118
+ // The pass after `app/modules/**`.
109
119
  this.#discover()
110
120
  if (activeProvider !== undefined && activeProvider !== this) {
111
121
  // A previous app's scheduler is still running — stop it before this one
@@ -225,12 +225,22 @@ export class CookieSigner {
225
225
  }
226
226
 
227
227
  decrypt<T = string>(encrypted: string, purpose?: string): T | null {
228
- const parts = encrypted.split('.')
229
- if (parts.length !== 3) return null
228
+ // Destructured rather than indexed, and `extra` is what keeps the old
229
+ // length check honest: three names bind on a four-part string too, so the
230
+ // fourth has to be looked at to reject it.
231
+ const [ivPart, dataPart, tagPart, extra] = encrypted.split('.')
232
+ if (
233
+ ivPart === undefined ||
234
+ dataPart === undefined ||
235
+ tagPart === undefined ||
236
+ extra !== undefined
237
+ ) {
238
+ return null
239
+ }
230
240
  try {
231
- const iv = Buffer.from(parts[0], 'base64url')
232
- const data = Buffer.from(parts[1], 'base64url')
233
- const tag = Buffer.from(parts[2], 'base64url')
241
+ const iv = Buffer.from(ivPart, 'base64url')
242
+ const data = Buffer.from(dataPart, 'base64url')
243
+ const tag = Buffer.from(tagPart, 'base64url')
234
244
  const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
235
245
  decipher.setAuthTag(tag)
236
246
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
@@ -55,8 +55,9 @@ function parseExpiry(value: string | number): number {
55
55
  if (typeof value === 'number') return value
56
56
  const match = value.match(/^(\d+)(s|m|h|d)$/)
57
57
  if (!match) return 3600
58
- const num = parseInt(match[1], 10)
59
- switch (match[2]) {
58
+ const [, digits, unit] = match
59
+ const num = parseInt(digits ?? '0', 10)
60
+ switch (unit) {
60
61
  case 's':
61
62
  return num
62
63
  case 'm':
@@ -6,9 +6,11 @@
6
6
  */
7
7
 
8
8
  import { randomBytes } from 'node:crypto'
9
+ import { readModuleConfig } from '../config/moduleConfig.js'
9
10
  import { currentNodeEnv } from '../env/nodeEnv.js'
10
11
  import { durationToSeconds } from '../helpers/duration.js'
11
12
  import type { HttpContext } from '../http/HttpContext.js'
13
+ import type { SessionConfigInput } from './config.js'
12
14
  import { CookieDriver } from './drivers/CookieDriver.js'
13
15
  import { DatabaseDriver, type SessionDbConnection } from './drivers/DatabaseDriver.js'
14
16
  import { FileDriver } from './drivers/FileDriver.js'
@@ -16,7 +18,7 @@ import { MemoryDriver } from './drivers/MemoryDriver.js'
16
18
  import { RedisDriver } from './drivers/RedisDriver.js'
17
19
  import { quasarConnection } from './quasar.js'
18
20
  import { ReadOnlyValuesStore, type ValuePath } from './ReadOnlyValuesStore.js'
19
- import type { SessionConfig, SessionDriver } from './Session.js'
21
+ import type { SessionDriver } from './Session.js'
20
22
  import { Session } from './Session.js'
21
23
 
22
24
  interface ResolvedSessionConfig {
@@ -42,7 +44,18 @@ export default class SessionMiddleware {
42
44
  #cookieDriver: CookieDriver | null = null
43
45
  #config: ResolvedSessionConfig
44
46
 
45
- constructor(config?: SessionConfig & { secret?: string }) {
47
+ constructor(explicitConfig: SessionConfigInput | undefined = undefined) {
48
+ // The default value is load-bearing: an OPTIONAL parameter still counts
49
+ // toward `Function.length`, and the container refuses to auto-construct a
50
+ // class that declares parameters it has no metadata for. A parameter with a
51
+ // default counts for nothing, which is what lets
52
+ // `router.use([() => import('…')])` build this middleware.
53
+ // Nothing is passed when the container builds this middleware from
54
+ // `router.use([() => import('@c9up/ream/session_middleware')])`, which is
55
+ // the registration shape the framework documents. `config/session.ts` is
56
+ // where the settings live in that case — the same file the config loader
57
+ // already reads into `config.get('session')`.
58
+ const config = explicitConfig ?? readModuleConfig<SessionConfigInput>('session')
46
59
  // `store` + `stores` (AdonisJS) and `driver` (ream) name the same thing.
47
60
  // A named store supplies the driver and its own options, so
48
61
  // `{ store: 'redis', stores: { redis: { driver: 'redis' } } }` works.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `config/session.ts` — the session store's configuration, in the file the
3
+ * config loader reads (`config/<name>.ts` → `config.get('<name>')`).
4
+ *
5
+ * @example
6
+ * // config/session.ts
7
+ * import env from '#start/env.js'
8
+ * import { defineConfig } from '@c9up/ream/session/config'
9
+ *
10
+ * export default defineConfig({
11
+ * store: 'cookie',
12
+ * secret: env.get('APP_KEY'),
13
+ * })
14
+ */
15
+
16
+ import type { SessionConfig } from './Session.js'
17
+
18
+ /** What a session config file may declare. */
19
+ export type SessionConfigInput = SessionConfig & {
20
+ /**
21
+ * Signs the session cookie. Required by the cookie store, which carries the
22
+ * data itself and would otherwise hand the client something it could edit.
23
+ */
24
+ secret?: string
25
+ }
26
+
27
+ export function defineConfig(config: SessionConfigInput): SessionConfigInput {
28
+ return config
29
+ }
@@ -8,10 +8,28 @@ import type { SessionDriverWithTagging, TaggedSession } from '../Session.js'
8
8
  * app's own wrapper.
9
9
  */
10
10
  export interface SessionDbConnection {
11
- query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>
11
+ /**
12
+ * Rows, as rows: `Record<string, unknown>`, not a shape the caller names.
13
+ *
14
+ * `query<T>` let this driver ASSERT what a SELECT returned — a promise the
15
+ * database never made and the connection cannot keep. It also made the
16
+ * interface unimplementable by anything but a real driver: a generic method
17
+ * must work for every `T`, so no fake could satisfy it, and this file's tests
18
+ * sat outside the typecheck because of it.
19
+ *
20
+ * The columns are read through {@link textColumn} below instead, which is
21
+ * what a row from a database deserves.
22
+ */
23
+ query(sql: string, params?: unknown[]): Promise<Array<Record<string, unknown>>>
12
24
  execute(sql: string, params?: unknown[]): Promise<{ rowsAffected: number }>
13
25
  }
14
26
 
27
+ /** A column that must be text to be usable, or `null`. */
28
+ function textColumn(row: Record<string, unknown>, name: string): string | null {
29
+ const value = row[name]
30
+ return typeof value === 'string' ? value : null
31
+ }
32
+
15
33
  export interface DatabaseDriverOptions {
16
34
  connection: SessionDbConnection
17
35
  /** Table holding the sessions. Default `sessions`. */
@@ -71,17 +89,20 @@ export class DatabaseDriver implements SessionDriverWithTagging {
71
89
  }
72
90
 
73
91
  async read(sessionId: string): Promise<Record<string, unknown> | null> {
74
- const rows = await this.#db.query<{ data: string; expires_at: number | string }>(
75
- `SELECT data, expires_at FROM ${this.#table} WHERE id = ?`,
76
- [sessionId],
77
- )
92
+ const rows = await this.#db.query(`SELECT data, expires_at FROM ${this.#table} WHERE id = ?`, [
93
+ sessionId,
94
+ ])
78
95
  const row = rows[0]
79
96
  if (!row) return null
80
97
  if (Number(row.expires_at) < Date.now()) {
81
98
  await this.destroy(sessionId)
82
99
  return null
83
100
  }
84
- return parseData(row.data)
101
+ const data = textColumn(row, 'data')
102
+ // A row whose payload is not text is not a session — the same outcome as a
103
+ // row that will not parse.
104
+ if (data === null) return null
105
+ return parseData(data)
85
106
  }
86
107
 
87
108
  async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
@@ -157,11 +178,18 @@ export class DatabaseDriver implements SessionDriverWithTagging {
157
178
 
158
179
  /** Every non-expired session this user holds — their active devices. */
159
180
  async tagged(userId: string | number): Promise<TaggedSession[]> {
160
- const rows = await this.#db.query<{ id: string; data: string }>(
181
+ const rows = await this.#db.query(
161
182
  `SELECT id, data FROM ${this.#table} WHERE user_id = ? AND expires_at >= ?`,
162
183
  [String(userId), Date.now()],
163
184
  )
164
- return rows.map((row) => ({ id: row.id, data: parseData(row.data) }))
185
+ const sessions: TaggedSession[] = []
186
+ for (const row of rows) {
187
+ const id = textColumn(row, 'id')
188
+ const data = textColumn(row, 'data')
189
+ if (id === null || data === null) continue
190
+ sessions.push({ id, data: parseData(data) })
191
+ }
192
+ return sessions
165
193
  }
166
194
 
167
195
  /** Delete every expired row — for a scheduled job, since nothing else does. */
@@ -67,11 +67,19 @@ export class RedisDriver implements SessionDriverWithTagging {
67
67
  }
68
68
  if (!this.#pending) {
69
69
  const resolver = this.#source
70
- this.#pending = Promise.resolve(resolver()).then((client) => {
71
- this.#resolved = client
72
- this.#pending = undefined
73
- return client
74
- })
70
+ // `finally`, not the success path: clearing it only on success left a
71
+ // REJECTED promise in the slot, and every later call returned that same
72
+ // rejection. One refused connection at boot — Redis still starting, a
73
+ // network blip — and the driver never tried again for the life of the
74
+ // process. Bay and echo already clear it this way.
75
+ this.#pending = Promise.resolve(resolver())
76
+ .then((client) => {
77
+ this.#resolved = client
78
+ return client
79
+ })
80
+ .finally(() => {
81
+ this.#pending = undefined
82
+ })
75
83
  }
76
84
  return this.#pending
77
85
  }
@@ -149,15 +149,14 @@ function parseMultipartResponse(
149
149
  // Trim the trailing CRLF before the next boundary.
150
150
  let content = part.subarray(headerEnd + 4)
151
151
  if (content.subarray(-2).toString('binary') === '\r\n') content = content.subarray(0, -2)
152
- const nameMatch = headerText.match(/name="([^"]*)"/i)
153
- if (!nameMatch) continue
154
- const name = nameMatch[1]
155
- const fileMatch = headerText.match(/filename="([^"]*)"/i)
156
- if (fileMatch) {
152
+ const [, name] = headerText.match(/name="([^"]*)"/i) ?? []
153
+ if (name === undefined) continue
154
+ const [, filename] = headerText.match(/filename="([^"]*)"/i) ?? []
155
+ if (filename !== undefined) {
157
156
  const typeMatch = headerText.match(/content-type:\s*([^\r\n]+)/i)
158
157
  files[name] = {
159
158
  fieldName: name,
160
- filename: fileMatch[1],
159
+ filename,
161
160
  type: typeMatch?.[1]?.trim() ?? 'application/octet-stream',
162
161
  size: content.length,
163
162
  content,
@@ -355,8 +354,8 @@ export class ApiResponse {
355
354
  /** Charset from `Content-Type`, if any — helix `charset()`. */
356
355
  charset(): string | undefined {
357
356
  const ct = this.#raw.headers['content-type'] ?? ''
358
- const m = ct.match(/charset=([^;]+)/i)
359
- return m ? m[1].trim() : undefined
357
+ const [, charset] = ct.match(/charset=([^;]+)/i) ?? []
358
+ return charset?.trim()
360
359
  }
361
360
 
362
361
  /** All response cookies, parsed from `Set-Cookie` — helix `cookies()`. */
@@ -380,8 +379,8 @@ export class ApiResponse {
380
379
  if (!link) return {}
381
380
  const out: Record<string, string> = {}
382
381
  for (const part of link.split(',')) {
383
- const m = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/)
384
- if (m) out[m[2]] = m[1]
382
+ const [, url, rel] = part.match(/<([^>]+)>\s*;\s*rel="?([^";]+)"?/) ?? []
383
+ if (url !== undefined && rel !== undefined) out[rel] = url
385
384
  }
386
385
  return out
387
386
  }
@@ -368,15 +368,15 @@ export function parseHttpResponse(raw: string | Buffer): TestResponse {
368
368
 
369
369
  const lines = headerSection.split('\r\n')
370
370
  const statusLine = lines[0] ?? ''
371
- const statusMatch = statusLine.match(/HTTP\/\d+\.\d+ (\d+)/)
372
- const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : 0
371
+ const [, statusDigits] = statusLine.match(/HTTP\/\d+\.\d+ (\d+)/) ?? []
372
+ const status = statusDigits === undefined ? 0 : Number.parseInt(statusDigits, 10)
373
373
 
374
374
  const headers: Dict = {}
375
- for (let i = 1; i < lines.length; i++) {
376
- const colonIdx = lines[i].indexOf(':')
375
+ for (const line of lines.slice(1)) {
376
+ const colonIdx = line.indexOf(':')
377
377
  if (colonIdx === -1) continue
378
- const key = lines[i].slice(0, colonIdx).trim().toLowerCase()
379
- const value = lines[i].slice(colonIdx + 1).trim()
378
+ const key = line.slice(0, colonIdx).trim().toLowerCase()
379
+ const value = line.slice(colonIdx + 1).trim()
380
380
  headers[key] = value
381
381
  }
382
382
 
@@ -38,7 +38,11 @@ export class Macroable {
38
38
  * `HttpContext`, and two subclasses declaring the same name clobbered each
39
39
  * other silently.
40
40
  */
41
- static macro(this: typeof Macroable, name: string, fn: MacroFn): void {
41
+ // `{ prototype: object }`, not `typeof Macroable`: a subclass whose
42
+ // constructor takes arguments — `Request`, say — has a different static side,
43
+ // so `Request.macro(...)` was "the 'this' context of type 'typeof Request' is
44
+ // not assignable". What this needs from `this` is the prototype it defines on.
45
+ static macro(this: { prototype: object }, name: string, fn: MacroFn): void {
42
46
  Object.defineProperty(this.prototype, name, {
43
47
  value: fn,
44
48
  writable: true,
@@ -51,7 +55,7 @@ export class Macroable {
51
55
  * `getter`). With `singleton`, the value is computed once per instance and
52
56
  * cached.
53
57
  */
54
- static getter(this: typeof Macroable, name: string, fn: GetterFn, singleton = false): void {
58
+ static getter(this: { prototype: object }, name: string, fn: GetterFn, singleton = false): void {
55
59
  if (!singleton) {
56
60
  Object.defineProperty(this.prototype, name, { get: fn, configurable: true })
57
61
  return
@@ -77,9 +77,13 @@ export function hasPath(source: unknown, path: string): boolean {
77
77
  */
78
78
  export function setPath(target: UnknownRecord, path: string, value: unknown): UnknownRecord {
79
79
  const segments = toSegments(path)
80
+ // The last segment is the one written; everything before it is walked. Split
81
+ // off by name rather than by index, so neither has to be read back out of
82
+ // the array afterwards and re-checked for existence.
83
+ const last = segments.pop()
84
+ if (last === undefined) return target
80
85
  let current: UnknownRecord = target
81
- for (let i = 0; i < segments.length - 1; i += 1) {
82
- const key = segments[i]
86
+ for (const key of segments) {
83
87
  const next = current[key]
84
88
  if (!isIndexable(next)) {
85
89
  const created: UnknownRecord = {}
@@ -89,7 +93,7 @@ export function setPath(target: UnknownRecord, path: string, value: unknown): Un
89
93
  current = next
90
94
  }
91
95
  }
92
- current[segments[segments.length - 1]] = value
96
+ current[last] = value
93
97
  return target
94
98
  }
95
99
 
@@ -115,12 +119,14 @@ export function omitPaths(source: UnknownRecord, paths: string[]): UnknownRecord
115
119
  const result = structuredClone(source)
116
120
  for (const path of paths) {
117
121
  const segments = toSegments(path)
122
+ const last = segments.pop()
123
+ if (last === undefined) continue
118
124
  let current: unknown = result
119
- for (let i = 0; i < segments.length - 1; i += 1) {
125
+ for (const key of segments) {
120
126
  if (!isIndexable(current)) break
121
- current = current[segments[i]]
127
+ current = current[key]
122
128
  }
123
- if (isIndexable(current)) delete current[segments[segments.length - 1]]
129
+ if (isIndexable(current)) delete current[last]
124
130
  }
125
131
  return result
126
132
  }