@c9up/ream 0.1.32 → 0.1.34

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 (193) hide show
  1. package/dist/Application.d.ts +27 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +29 -0
  4. package/dist/Application.js.map +1 -1
  5. package/dist/ConfigLoader.d.ts +4 -0
  6. package/dist/ConfigLoader.d.ts.map +1 -1
  7. package/dist/ConfigLoader.js +0 -8
  8. package/dist/ConfigLoader.js.map +1 -1
  9. package/dist/ErrorBoundary.d.ts.map +1 -1
  10. package/dist/ErrorBoundary.js +13 -1
  11. package/dist/ErrorBoundary.js.map +1 -1
  12. package/dist/HttpKernel.d.ts +8 -0
  13. package/dist/HttpKernel.d.ts.map +1 -1
  14. package/dist/HttpKernel.js +35 -12
  15. package/dist/HttpKernel.js.map +1 -1
  16. package/dist/Ignitor.d.ts +8 -0
  17. package/dist/Ignitor.d.ts.map +1 -1
  18. package/dist/Ignitor.js +39 -0
  19. package/dist/Ignitor.js.map +1 -1
  20. package/dist/bodyparser/BodyParserMiddleware.d.ts +11 -4
  21. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  22. package/dist/bodyparser/BodyParserMiddleware.js +55 -43
  23. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  24. package/dist/bodyparser/qsParse.d.ts +52 -0
  25. package/dist/bodyparser/qsParse.d.ts.map +1 -0
  26. package/dist/bodyparser/qsParse.js +161 -0
  27. package/dist/bodyparser/qsParse.js.map +1 -0
  28. package/dist/bootstrap/hyper-server.d.ts.map +1 -1
  29. package/dist/bootstrap/hyper-server.js +2 -8
  30. package/dist/bootstrap/hyper-server.js.map +1 -1
  31. package/dist/container/Container.d.ts +41 -0
  32. package/dist/container/Container.d.ts.map +1 -1
  33. package/dist/container/Container.js +193 -26
  34. package/dist/container/Container.js.map +1 -1
  35. package/dist/container/ContainerResolver.d.ts +46 -0
  36. package/dist/container/ContainerResolver.d.ts.map +1 -0
  37. package/dist/container/ContainerResolver.js +60 -0
  38. package/dist/container/ContainerResolver.js.map +1 -0
  39. package/dist/container/ContextualBindingsBuilder.d.ts +20 -0
  40. package/dist/container/ContextualBindingsBuilder.d.ts.map +1 -0
  41. package/dist/container/ContextualBindingsBuilder.js +31 -0
  42. package/dist/container/ContextualBindingsBuilder.js.map +1 -0
  43. package/dist/container/types.d.ts +9 -6
  44. package/dist/container/types.d.ts.map +1 -1
  45. package/dist/decorators/Service.d.ts +1 -5
  46. package/dist/decorators/Service.d.ts.map +1 -1
  47. package/dist/decorators/Service.js +10 -1
  48. package/dist/decorators/Service.js.map +1 -1
  49. package/dist/events/Emitter.d.ts +66 -6
  50. package/dist/events/Emitter.d.ts.map +1 -1
  51. package/dist/events/Emitter.js +165 -28
  52. package/dist/events/Emitter.js.map +1 -1
  53. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  54. package/dist/graphql/GraphQLEngine.js +5 -5
  55. package/dist/graphql/GraphQLEngine.js.map +1 -1
  56. package/dist/helpers/duration.d.ts +32 -0
  57. package/dist/helpers/duration.d.ts.map +1 -0
  58. package/dist/helpers/duration.js +74 -0
  59. package/dist/helpers/duration.js.map +1 -0
  60. package/dist/helpers/napi-loader.d.ts +10 -1
  61. package/dist/helpers/napi-loader.d.ts.map +1 -1
  62. package/dist/helpers/napi-loader.js +18 -3
  63. package/dist/helpers/napi-loader.js.map +1 -1
  64. package/dist/helpers/types.d.ts +1 -1
  65. package/dist/helpers/types.d.ts.map +1 -1
  66. package/dist/http/Exception.d.ts.map +1 -1
  67. package/dist/http/Exception.js +17 -5
  68. package/dist/http/Exception.js.map +1 -1
  69. package/dist/http/HttpContext.d.ts +24 -5
  70. package/dist/http/HttpContext.d.ts.map +1 -1
  71. package/dist/http/HttpContext.js +12 -2
  72. package/dist/http/HttpContext.js.map +1 -1
  73. package/dist/http/RedirectBuilder.d.ts +48 -2
  74. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  75. package/dist/http/RedirectBuilder.js +95 -14
  76. package/dist/http/RedirectBuilder.js.map +1 -1
  77. package/dist/http/Request.d.ts +143 -8
  78. package/dist/http/Request.d.ts.map +1 -1
  79. package/dist/http/Request.js +241 -21
  80. package/dist/http/Request.js.map +1 -1
  81. package/dist/http/Response.d.ts +74 -3
  82. package/dist/http/Response.d.ts.map +1 -1
  83. package/dist/http/Response.js +177 -4
  84. package/dist/http/Response.js.map +1 -1
  85. package/dist/index.d.ts +9 -3
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +5 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/router/Router.d.ts +36 -7
  90. package/dist/router/Router.d.ts.map +1 -1
  91. package/dist/router/Router.js +66 -22
  92. package/dist/router/Router.js.map +1 -1
  93. package/dist/security/CookieSigner.d.ts +35 -4
  94. package/dist/security/CookieSigner.d.ts.map +1 -1
  95. package/dist/security/CookieSigner.js +99 -10
  96. package/dist/security/CookieSigner.js.map +1 -1
  97. package/dist/server/Server.d.ts +2 -3
  98. package/dist/server/Server.d.ts.map +1 -1
  99. package/dist/server/Server.js +30 -13
  100. package/dist/server/Server.js.map +1 -1
  101. package/dist/session/ReadOnlyValuesStore.d.ts +24 -0
  102. package/dist/session/ReadOnlyValuesStore.d.ts.map +1 -0
  103. package/dist/session/ReadOnlyValuesStore.js +45 -0
  104. package/dist/session/ReadOnlyValuesStore.js.map +1 -0
  105. package/dist/session/Session.d.ts +181 -9
  106. package/dist/session/Session.d.ts.map +1 -1
  107. package/dist/session/Session.js +284 -33
  108. package/dist/session/Session.js.map +1 -1
  109. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  110. package/dist/session/SessionMiddleware.js +87 -33
  111. package/dist/session/SessionMiddleware.js.map +1 -1
  112. package/dist/session/drivers/DatabaseDriver.d.ts +66 -0
  113. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -0
  114. package/dist/session/drivers/DatabaseDriver.js +132 -0
  115. package/dist/session/drivers/DatabaseDriver.js.map +1 -0
  116. package/dist/session/drivers/FileDriver.d.ts +22 -0
  117. package/dist/session/drivers/FileDriver.d.ts.map +1 -0
  118. package/dist/session/drivers/FileDriver.js +66 -0
  119. package/dist/session/drivers/FileDriver.js.map +1 -0
  120. package/dist/session/drivers/MemoryDriver.d.ts +10 -2
  121. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  122. package/dist/session/drivers/MemoryDriver.js +70 -0
  123. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  124. package/dist/session/drivers/RedisDriver.d.ts +10 -2
  125. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  126. package/dist/session/drivers/RedisDriver.js +58 -0
  127. package/dist/session/drivers/RedisDriver.js.map +1 -1
  128. package/dist/session/templateTags.d.ts +42 -0
  129. package/dist/session/templateTags.d.ts.map +1 -0
  130. package/dist/session/templateTags.js +101 -0
  131. package/dist/session/templateTags.js.map +1 -0
  132. package/dist/testing/RequestBuilder.d.ts +38 -1
  133. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  134. package/dist/testing/RequestBuilder.js +40 -1
  135. package/dist/testing/RequestBuilder.js.map +1 -1
  136. package/dist/testing/TestClient.d.ts +3 -1
  137. package/dist/testing/TestClient.d.ts.map +1 -1
  138. package/dist/testing/TestClient.js +3 -1
  139. package/dist/testing/TestClient.js.map +1 -1
  140. package/dist/types/helpers.d.ts +1 -1
  141. package/dist/types/helpers.d.ts.map +1 -1
  142. package/dist/utils/Macroable.d.ts +14 -5
  143. package/dist/utils/Macroable.d.ts.map +1 -1
  144. package/dist/utils/Macroable.js +20 -18
  145. package/dist/utils/Macroable.js.map +1 -1
  146. package/events.win32-x64-msvc.node +0 -0
  147. package/index.win32-x64-msvc.node +0 -0
  148. package/package.json +1 -1
  149. package/scheduler.win32-x64-msvc.node +0 -0
  150. package/src/Application.ts +39 -0
  151. package/src/ConfigLoader.ts +6 -0
  152. package/src/ErrorBoundary.ts +13 -1
  153. package/src/HttpKernel.ts +45 -19
  154. package/src/Ignitor.ts +39 -0
  155. package/src/bodyparser/BodyParserMiddleware.ts +66 -44
  156. package/src/bodyparser/qsParse.ts +179 -0
  157. package/src/bootstrap/hyper-server.ts +2 -9
  158. package/src/container/Container.ts +265 -25
  159. package/src/container/ContainerResolver.ts +84 -0
  160. package/src/container/ContextualBindingsBuilder.ts +41 -0
  161. package/src/container/types.ts +11 -7
  162. package/src/decorators/Service.ts +16 -3
  163. package/src/events/Emitter.ts +200 -25
  164. package/src/graphql/GraphQLEngine.ts +7 -8
  165. package/src/helpers/duration.ts +71 -0
  166. package/src/helpers/napi-loader.ts +19 -3
  167. package/src/helpers/types.ts +4 -1
  168. package/src/http/Exception.ts +17 -5
  169. package/src/http/HttpContext.ts +28 -5
  170. package/src/http/RedirectBuilder.ts +126 -11
  171. package/src/http/Request.ts +303 -13
  172. package/src/http/Response.ts +213 -6
  173. package/src/index.ts +17 -3
  174. package/src/router/Router.ts +98 -33
  175. package/src/security/CookieSigner.ts +102 -10
  176. package/src/server/Server.ts +36 -16
  177. package/src/session/ReadOnlyValuesStore.ts +52 -0
  178. package/src/session/Session.ts +364 -37
  179. package/src/session/SessionMiddleware.ts +93 -30
  180. package/src/session/drivers/DatabaseDriver.ts +184 -0
  181. package/src/session/drivers/FileDriver.ts +82 -0
  182. package/src/session/drivers/MemoryDriver.ts +75 -2
  183. package/src/session/drivers/RedisDriver.ts +77 -2
  184. package/src/session/templateTags.ts +124 -0
  185. package/src/testing/RequestBuilder.ts +76 -0
  186. package/src/testing/TestClient.ts +10 -2
  187. package/src/types/helpers.ts +4 -1
  188. package/src/utils/Macroable.ts +23 -8
  189. package/dist/utils/inflect.d.ts +0 -17
  190. package/dist/utils/inflect.d.ts.map +0 -1
  191. package/dist/utils/inflect.js +0 -32
  192. package/dist/utils/inflect.js.map +0 -1
  193. package/src/utils/inflect.ts +0 -29
@@ -33,18 +33,35 @@ type ListenerConstructor<T = unknown> = new (...args: never[]) => ListenerClass<
33
33
 
34
34
  type Listener<T = unknown> = ListenerFn<T> | ListenerConstructor<T>
35
35
 
36
- /** Resolver for instantiating listener classes with DI. */
37
- export interface ContainerResolver {
36
+ /**
37
+ * Resolver for instantiating listener classes with DI — the narrow shape the
38
+ * emitter needs, so a test can hand it a fake without building a container.
39
+ * The real {@link import('../container/ContainerResolver.js').ContainerResolver}
40
+ * satisfies it.
41
+ */
42
+ export interface EmitterResolver {
38
43
  make<T>(target: new (...args: never[]) => T): Promise<T>
39
44
  }
40
45
 
46
+ /**
47
+ * @deprecated Use {@link EmitterResolver}. The name now belongs to the real
48
+ * per-request resolver exported from `@c9up/ream`, as in AdonisJS.
49
+ */
50
+ export type ContainerResolver = EmitterResolver
51
+
52
+ /**
53
+ * What `on` / `once` / `onAny` hand back: call it to stop listening. Named
54
+ * after the AdonisJS type of the same shape.
55
+ */
56
+ export type UnsubscribeFunction = () => void
57
+
41
58
  export class Emitter {
42
59
  private bus: EventBus
43
- private resolver?: ContainerResolver
60
+ private resolver?: EmitterResolver
44
61
  private classListeners: Map<EventConstructor, Listener[]> = new Map()
45
62
  private stringListeners: Map<string, ListenerFn[]> = new Map()
46
63
 
47
- constructor(bus: EventBus, resolver?: ContainerResolver) {
64
+ constructor(bus: EventBus, resolver?: EmitterResolver) {
48
65
  this.bus = bus
49
66
  this.resolver = resolver
50
67
  }
@@ -56,46 +73,184 @@ export class Emitter {
56
73
  * emitter.on(TaskDeclared, SendNotification) // listener class
57
74
  * emitter.on(TaskDeclared, (event) => { ... }) // inline function
58
75
  */
59
- on<T>(event: EventConstructor<T>, listener: Listener<T>): void
76
+ on<T>(event: EventConstructor<T>, listener: Listener<T>): UnsubscribeFunction
60
77
  /**
61
78
  * Listen for a string-based event.
62
79
  * emitter.on('user:registered', (user) => { ... })
63
80
  * emitter.on<User>('user:registered', (user) => { ... }) // typed payload
81
+ *
82
+ * Returns an unsubscribe function, as AdonisJS does — keeping the listener
83
+ * reference around just to pass it back to `off` is the boilerplate that
84
+ * function removes.
64
85
  */
65
- on<T>(event: string, listener: ListenerFn<T>): void
66
- on(event: EventConstructor | string, listener: Listener): void {
86
+ on<T>(event: string, listener: ListenerFn<T>): UnsubscribeFunction
87
+ on(event: EventConstructor | string, listener: Listener): UnsubscribeFunction {
67
88
  if (typeof event === 'string') {
68
89
  const list = this.stringListeners.get(event) ?? []
69
90
  list.push(listener as ListenerFn)
70
91
  this.stringListeners.set(event, list)
71
- } else {
72
- const list = this.classListeners.get(event) ?? []
73
- list.push(listener)
74
- this.classListeners.set(event, list)
92
+ return () => {
93
+ this.off(event, listener as ListenerFn)
94
+ }
95
+ }
96
+ const list = this.classListeners.get(event) ?? []
97
+ list.push(listener)
98
+ this.classListeners.set(event, list)
99
+ return () => {
100
+ const current = this.classListeners.get(event)
101
+ if (!current) return
102
+ const index = current.indexOf(listener)
103
+ if (index !== -1) current.splice(index, 1)
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Listen for the NEXT occurrence only (AdonisJS `once`).
109
+ *
110
+ * The listener removes itself before running, so a handler that emits the
111
+ * same event does not re-enter it.
112
+ */
113
+ once<T>(event: string, listener: ListenerFn<T>): void {
114
+ const wrapper: ListenerFn = (data) => {
115
+ this.off(event, wrapper)
116
+ return (listener as ListenerFn)(data)
75
117
  }
118
+ this.on(event, wrapper as ListenerFn<T>)
119
+ }
120
+
121
+ /**
122
+ * Register the listener only when `condition` holds (AdonisJS `listenIf`) —
123
+ * the shape a feature flag takes at boot, without an `if` around every call.
124
+ */
125
+ listenIf<T>(condition: boolean, event: string, listener: ListenerFn<T>): void {
126
+ if (condition) this.on(event, listener)
127
+ }
128
+
129
+ /** Alias of {@link on} (AdonisJS names it `listen`). */
130
+ listen<T>(event: string, listener: ListenerFn<T>): void {
131
+ this.on(event, listener)
132
+ }
133
+
134
+ /** Report listener failures (AdonisJS `onError`). */
135
+ onError(listener: (event: string, error: unknown) => void): void {
136
+ this.on('emitter:error', (payload) => {
137
+ const detail = payload as { event?: string; error?: unknown }
138
+ listener(detail?.event ?? 'unknown', detail?.error)
139
+ })
140
+ }
141
+
142
+ /** Remove one listener from an event (AdonisJS `off`). */
143
+ off(event: string, listener: ListenerFn): void {
144
+ const list = this.stringListeners.get(event)
145
+ if (!list) return
146
+ const index = list.indexOf(listener)
147
+ if (index !== -1) list.splice(index, 1)
148
+ if (list.length === 0) this.stringListeners.delete(event)
149
+ }
150
+
151
+ /** Alias of {@link off} (AdonisJS `clearListener`). */
152
+ clearListener(event: string, listener: ListenerFn): void {
153
+ this.off(event, listener)
154
+ }
155
+
156
+ /** Drop every listener of one event (AdonisJS `clearListeners`). */
157
+ clearListeners(event: string): void {
158
+ this.stringListeners.delete(event)
159
+ }
160
+
161
+ /** Drop every listener of every event (AdonisJS `clearAllListeners`). */
162
+ clearAllListeners(): void {
163
+ this.stringListeners.clear()
164
+ this.classListeners.clear()
165
+ }
166
+
167
+ /** How many listeners an event has, or all of them (AdonisJS `listenerCount`). */
168
+ listenerCount(event?: string): number {
169
+ if (event !== undefined) return this.stringListeners.get(event)?.length ?? 0
170
+ let total = 0
171
+ for (const list of this.stringListeners.values()) total += list.length
172
+ for (const list of this.classListeners.values()) total += list.length
173
+ return total
76
174
  }
77
175
 
78
176
  // ─── Emit ─────────────────────────────────────────────────
79
177
 
80
178
  /**
81
- * Emit a string-based event.
179
+ * Run an event's listeners, in parallel or one after another.
180
+ *
181
+ * A listener that throws is reported and does NOT stop the others: one
182
+ * failing subscriber must not silently cancel the rest of the reaction to an
183
+ * event it does not own.
82
184
  */
83
- emit(event: string, data: unknown): void {
84
- // Dispatch to string listeners
85
- const listeners = this.stringListeners.get(event)
86
- if (listeners) {
87
- for (const fn of listeners) {
88
- Promise.resolve(fn(data)).catch((err) => {
89
- // Surface listener errors instead of swallowing them
90
- this.emitError(event, err)
91
- })
185
+ async #dispatchListeners(event: string, data: unknown, serial: boolean): Promise<void> {
186
+ const listeners = [
187
+ ...(this.stringListeners.get(event) ?? []),
188
+ ...this.#wildcardListenersFor(event),
189
+ ]
190
+ if (listeners.length === 0) return
191
+ const run = async (fn: ListenerFn): Promise<void> => {
192
+ try {
193
+ await fn(data)
194
+ } catch (err) {
195
+ this.emitError(event, err)
92
196
  }
93
197
  }
198
+ if (serial) {
199
+ for (const fn of listeners) await run(fn)
200
+ return
201
+ }
202
+ await Promise.all(listeners.map(run))
203
+ }
204
+
205
+ /** Listeners registered through `onAny` whose pattern matches `event`. */
206
+ #wildcardListenersFor(_event: string): ListenerFn[] {
207
+ return []
208
+ }
94
209
 
95
- // Also push through EventBus for cross-service / Rust subscribers.
96
- // `#wrapForBus` prepends the correlation envelope when set so the ID
97
- // reaches the other side; without it `setCorrelationId()` was a no-op.
98
- void this.bus.emit(event, JSON.stringify(this.#wrapForBus(data))).catch((err: unknown) => {
210
+ /**
211
+ * Emit and run the listeners ONE AT A TIME (AdonisJS `emitSerial`).
212
+ *
213
+ * Use it when order matters — a listener that seeds what the next one reads.
214
+ */
215
+ async emitSerial(event: string, data: unknown): Promise<void> {
216
+ await this.#dispatchListeners(event, data, true)
217
+ this.#publishToBus(event, data)
218
+ }
219
+
220
+ /**
221
+ * Emit a string-based event.
222
+ *
223
+ * Returns a promise resolving once every listener has finished, as AdonisJS
224
+ * does — awaiting it is how a handler makes sure the work it triggered
225
+ * actually happened before it answers. Listeners run in PARALLEL; use
226
+ * {@link emitSerial} when one has to finish before the next starts.
227
+ *
228
+ * Not awaiting it keeps the old fire-and-forget behaviour: a listener that
229
+ * rejects is reported through `emitError`, never left unhandled.
230
+ */
231
+ async emit(event: string, data: unknown): Promise<void> {
232
+ await this.#dispatchListeners(event, data, false)
233
+ this.#publishToBus(event, data)
234
+ }
235
+
236
+ /**
237
+ * Push the event onto the bus for cross-service / Rust subscribers.
238
+ *
239
+ * `#wrapForBus` prepends the correlation envelope when set so the ID reaches
240
+ * the other side; without it `setCorrelationId()` was a no-op.
241
+ */
242
+ #publishToBus(event: string, data: unknown): void {
243
+ // Serialize INSIDE the guard. `JSON.stringify` throws synchronously on a
244
+ // cycle or a BigInt, and it was evaluated as an argument — so the throw
245
+ // escaped past the `.catch` and crashed the caller.
246
+ let payload: string
247
+ try {
248
+ payload = JSON.stringify(this.#wrapForBus(data))
249
+ } catch (err) {
250
+ this.emitError(event, err)
251
+ return
252
+ }
253
+ void this.bus.emit(event, payload).catch((err: unknown) => {
99
254
  this.emitError(event, err)
100
255
  })
101
256
  }
@@ -161,7 +316,27 @@ export class Emitter {
161
316
  * emitter.onAny('order.*', (name, data) => { ... }) // single segment
162
317
  * emitter.onAny('order.**', (name, data) => { ... }) // deep match
163
318
  */
319
+ async onAny(listener: (eventName: string, data: unknown) => void): Promise<number>
320
+ async onAny(
321
+ pattern: string,
322
+ listener: (eventName: string, data: unknown) => void,
323
+ ): Promise<number>
164
324
  async onAny(
325
+ patternOrListener: string | ((eventName: string, data: unknown) => void),
326
+ maybeListener?: (eventName: string, data: unknown) => void,
327
+ ): Promise<number> {
328
+ // `onAny(listener)` — every event, the AdonisJS signature. `**` is the
329
+ // deep-match pattern the Rust matcher already understands, so the two
330
+ // forms share one implementation.
331
+ const pattern = typeof patternOrListener === 'string' ? patternOrListener : '**'
332
+ const listener = typeof patternOrListener === 'string' ? maybeListener : patternOrListener
333
+ if (listener === undefined) {
334
+ throw new TypeError('onAny() requires a listener')
335
+ }
336
+ return this.#subscribeAny(pattern, listener)
337
+ }
338
+
339
+ async #subscribeAny(
165
340
  pattern: string,
166
341
  listener: (eventName: string, data: unknown) => void,
167
342
  ): Promise<number> {
@@ -509,6 +509,11 @@ export class GraphQLEngine {
509
509
  }
510
510
 
511
511
  /** Parse and validate an unknown request body as a GraphQL request object. */
512
+ /** GraphQL `variables` must be a keyed object — never an array or null. */
513
+ function isVariablesRecord(value: unknown): value is Record<string, unknown> {
514
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
515
+ }
516
+
512
517
  function parseGraphQLBody(body: unknown): GraphQLRequest | null {
513
518
  if (!body || typeof body !== 'object' || Array.isArray(body)) return null
514
519
  // After typeof check, body is a non-null non-array object; index via 'in' narrowing
@@ -516,14 +521,8 @@ function parseGraphQLBody(body: unknown): GraphQLRequest | null {
516
521
  if (typeof q !== 'string') return null
517
522
 
518
523
  let variables: Record<string, unknown> | undefined
519
- if (
520
- 'variables' in body &&
521
- body.variables !== null &&
522
- typeof body.variables === 'object' &&
523
- !Array.isArray(body.variables)
524
- ) {
525
- // biome-ignore lint/suspicious/noExplicitAny: narrowed to non-null non-array object above; branded as Record for safe spreading
526
- variables = body.variables as any as Record<string, unknown>
524
+ if ('variables' in body && isVariablesRecord(body.variables)) {
525
+ variables = body.variables
527
526
  }
528
527
 
529
528
  const rawOpName = 'operationName' in body ? body.operationName : undefined
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Duration parsing, matching what AdonisJS accepts.
3
+ *
4
+ * Adonis runs every duration through `string.seconds.parse()`
5
+ * (`@poppinss/string`), which delegates string forms to `@lukeed/ms`. A config
6
+ * carrying `age: '2 hours'` or `maxAge: '30 mins'` therefore has to work here
7
+ * too — this is a value an app moves over verbatim.
8
+ *
9
+ * Two rules taken straight from that implementation, both easy to get wrong:
10
+ *
11
+ * - A **number is already in seconds** and is returned untouched. It is NOT
12
+ * milliseconds, whatever a reading of the guides suggests: `seconds.parse`
13
+ * starts with `if (typeof duration === 'number') return duration`.
14
+ * - A **string with no unit is in milliseconds**. `@lukeed/ms` falls through
15
+ * its unit branches and returns the bare number as ms, so Adonis' `'7200'`
16
+ * is seven seconds, not two hours. Treating it as seconds — which is the
17
+ * intuitive reading — would silently stretch every such value by 1000.
18
+ */
19
+
20
+ /**
21
+ * The unit vocabulary of `@lukeed/ms`, in the order its regex tests them.
22
+ * Written out rather than reusing its regex so the accepted spellings are
23
+ * readable: `2h`, `2 hrs`, `2 hours` all mean the same thing.
24
+ */
25
+ const DURATION_PATTERN =
26
+ /^(-?(?:\d+)?\.?\d+) *(m(?:illiseconds?|s(?:ecs?)?))?(s(?:ec(?:onds?|s)?)?)?(m(?:in(?:utes?|s)?)?)?(h(?:ours?|rs?)?)?(d(?:ays?)?)?(w(?:eeks?|ks?)?)?(y(?:ears?|rs?)?)?$/
27
+
28
+ const SECOND = 1000
29
+ const MINUTE = SECOND * 60
30
+ const HOUR = MINUTE * 60
31
+ const DAY = HOUR * 24
32
+ const YEAR = DAY * 365.25
33
+
34
+ /**
35
+ * Parse a duration string to MILLISECONDS, or `undefined` when it is not a
36
+ * duration. Mirrors `@lukeed/ms`'s `parse`, including its quirk that a value
37
+ * with no unit is milliseconds.
38
+ */
39
+ export function parseDurationMs(value: string): number | undefined {
40
+ const matched = DURATION_PATTERN.exec(value.toLowerCase())
41
+ if (matched === null) return undefined
42
+ const amount = Number.parseFloat(matched[1] ?? '')
43
+ // `@lukeed/ms` rejects a zero amount here (`(num = parseFloat(...))` is
44
+ // falsy), and so must we, or `'0 hours'` would part company with Adonis.
45
+ if (!amount) return undefined
46
+ if (matched[3] !== undefined) return amount * SECOND
47
+ if (matched[4] !== undefined) return amount * MINUTE
48
+ if (matched[5] !== undefined) return amount * HOUR
49
+ if (matched[6] !== undefined) return amount * DAY
50
+ if (matched[7] !== undefined) return amount * DAY * 7
51
+ if (matched[8] !== undefined) return amount * YEAR
52
+ // No unit — and `matched[2]` (an explicit `ms`) lands here too.
53
+ return amount
54
+ }
55
+
56
+ /**
57
+ * A duration as SECONDS, the way `string.seconds.parse` returns it: a number
58
+ * passes through untouched, a string is parsed and floored.
59
+ *
60
+ * @param label - what is being parsed, for the error message.
61
+ */
62
+ export function durationToSeconds(value: number | string, label: string): number {
63
+ if (typeof value === 'number') return Math.trunc(value)
64
+ const milliseconds = parseDurationMs(value)
65
+ if (milliseconds === undefined) {
66
+ throw new Error(
67
+ `Cannot read "${value}" as ${label}. Use a number of seconds, or a duration such as '30m', '2 hours' or '7 days'.`,
68
+ )
69
+ }
70
+ return Math.floor(milliseconds / SECOND)
71
+ }
@@ -13,7 +13,16 @@ import { arch, platform } from 'node:process'
13
13
  import { fileURLToPath } from 'node:url'
14
14
  import { ReamError } from '../errors/ReamError.js'
15
15
 
16
- /** Platform triple suffix (matches napi-rs naming). */
16
+ /**
17
+ * Platform triple suffix (matches napi-rs naming).
18
+ *
19
+ * The ONE map for `@c9up/ream`. Every native package carries its own — they are
20
+ * independently publishable and must not import each other — so a new target
21
+ * still has to be added per package; within ream it is added here once.
22
+ *
23
+ * Linux binaries target GLIBC. musl (Alpine) is not a supported target, and the
24
+ * loader says so rather than reporting a bare "not found".
25
+ */
17
26
  export const NAPI_PLATFORM_MAP: Record<string, string> = {
18
27
  'linux-x64': 'linux-x64-gnu',
19
28
  'linux-arm64': 'linux-arm64-gnu',
@@ -94,13 +103,20 @@ export function loadNapi<T>(options: LoadNapiOptions): T {
94
103
  }
95
104
  }
96
105
 
106
+ // On a musl host the glibc binary is present but never loads, so "not found"
107
+ // sends the reader looking for a missing file that is right there. Name the
108
+ // real reason instead.
109
+ const muslHint = suffix.endsWith('-gnu')
110
+ ? ' If you are on Alpine/musl, note the prebuilt Linux binaries target glibc — musl is not a supported target.'
111
+ : ''
97
112
  throw new ReamError(
98
113
  `${errorCodePrefix}_NAPI_NOT_FOUND`,
99
114
  `${binaryName} NAPI binary not found. Expected ${binaryName}.${suffix}.node`,
100
115
  {
101
- hint:
116
+ hint: `${
102
117
  options.notFoundHint ??
103
- "Run 'pnpm --filter @c9up/ream build:rust' to build the native module.",
118
+ "Run 'pnpm --filter @c9up/ream build:rust' to build the native module."
119
+ }${muslHint}`,
104
120
  },
105
121
  )
106
122
  }
@@ -25,7 +25,10 @@ export type UnwrapOpaque<T> = T extends Opaque<infer Base, string> ? Base : T
25
25
  export type AsyncOrSync<T> = T | Promise<T>
26
26
 
27
27
  /** Constructor type. */
28
- export type Constructor<T = unknown, Args extends unknown[] = unknown[]> = new (...args: Args) => T
28
+ // The default `Args` is `never[]`, not `unknown[]`: parameters are
29
+ // contravariant, so only a rest of `never` accepts EVERY concrete parameter
30
+ // list. Pass `Args` explicitly when you need to call the constructor.
31
+ export type Constructor<T = unknown, Args extends unknown[] = never[]> = new (...args: Args) => T
29
32
 
30
33
  /** Abstract constructor type. */
31
34
  export type AbstractConstructor<T = unknown> = abstract new (...args: unknown[]) => T
@@ -42,11 +42,23 @@ export class Exception extends Error {
42
42
  constructor(message: string, options?: { status?: number; code?: string }) {
43
43
  super(message)
44
44
  this.name = this.constructor.name
45
- // biome-ignore lint/suspicious/noExplicitAny: this.constructor is typed as Function; subclass pattern requires the cast
46
- const ctor = this.constructor as any as typeof Exception
47
- this.status = options?.status ?? ctor.status
48
- this.code = options?.code ?? ctor.code
49
- if (ctor.help !== undefined) this.help = ctor.help
45
+ // `this.constructor` is typed `Function`, which carries none of the statics
46
+ // a subclass sets (`status`, `code`, `help`). Reading them through
47
+ // `Reflect.get` states that we are looking at OPTIONAL statics, which is
48
+ // exactly the contract — a subclass need not define any of them.
49
+ const ctor = this.constructor
50
+ const staticNumber = (key: string): number | undefined => {
51
+ const v = Reflect.get(ctor, key)
52
+ return typeof v === 'number' ? v : undefined
53
+ }
54
+ const staticString = (key: string): string | undefined => {
55
+ const v = Reflect.get(ctor, key)
56
+ return typeof v === 'string' ? v : undefined
57
+ }
58
+ this.status = options?.status ?? staticNumber('status') ?? Exception.status
59
+ this.code = options?.code ?? staticString('code') ?? Exception.code
60
+ const help = staticString('help')
61
+ if (help !== undefined) this.help = help
50
62
  }
51
63
 
52
64
  /** Override to self-handle the exception (convert to HTTP response). */
@@ -10,6 +10,7 @@
10
10
  import { AsyncLocalStorage } from 'node:async_hooks'
11
11
  import type { ServiceToken } from '../container/types.js'
12
12
  import type { Emitter } from '../events/Emitter.js'
13
+ import type { MatchedParams } from '../router/Router.js'
13
14
  import type { CookieSigner } from '../security/CookieSigner.js'
14
15
  import type { SignedUrl } from '../security/SignedUrl.js'
15
16
  import type { Session } from '../session/Session.js'
@@ -87,7 +88,19 @@ export interface Authorizer {
87
88
  // pattern as warden/blackhole middleware), so nothing cross-imports it.
88
89
  interface ContainerResolver {
89
90
  /** Resolve/construct a service by token (class, string, or symbol). Async (AdonisJS parity). */
90
- make<T>(token: ServiceToken): Promise<T>
91
+ make<T>(token: ServiceToken, runtimeValues?: unknown[]): Promise<T>
92
+ /** Call a method with its dependencies injected (AdonisJS `resolver.call`). */
93
+ call<T, K extends string & keyof T>(
94
+ instance: T,
95
+ method: K,
96
+ runtimeValues?: unknown[],
97
+ ): Promise<unknown>
98
+ /** Bind a value for THIS request only (AdonisJS `resolver.bindValue`). */
99
+ bindValue<T>(token: ServiceToken, value: T): void
100
+ /** Whether the token resolves (AdonisJS `resolver.hasBinding`). */
101
+ hasBinding(token: ServiceToken): boolean
102
+ /** Whether every token resolves (AdonisJS `resolver.hasAllBindings`). */
103
+ hasAllBindings(tokens: ServiceToken[]): boolean
91
104
  }
92
105
 
93
106
  /**
@@ -204,7 +217,16 @@ export class HttpContext extends Macroable {
204
217
  readonly response: Response
205
218
 
206
219
  /** Route parameters extracted from the URL pattern. */
207
- readonly params: Record<string, string>
220
+ /** Matched route parameters — `*` holds the segments it swallowed, as an array. */
221
+ /**
222
+ * Route parameters.
223
+ *
224
+ * `string | string[]`, not AdonisJS's `any`: a catch-all `*` really is the
225
+ * ARRAY of segments on both sides, so the `any` upstream only hides it. Use
226
+ * {@link Request.param} when you want the single-value form — it joins a
227
+ * catch-all with `/` and returns `string | undefined`.
228
+ */
229
+ readonly params: MatchedParams
208
230
 
209
231
  /** Information about the matched route. */
210
232
  readonly route: RouteInfo
@@ -246,8 +268,9 @@ export class HttpContext extends Macroable {
246
268
 
247
269
  /**
248
270
  * Per-request IoC resolver (Adonis idiom: `ctx.containerResolver.make(...)`).
249
- * Populated by `HttpKernel` from the application container. Undefined only
250
- * when the context was built without one (e.g. a mock in a unit test).
271
+ * Built by `HttpKernel` with `container.createResolver()`, so a value bound
272
+ * on it belongs to THIS request and no other. Undefined only when the context
273
+ * was built without one (e.g. a mock in a unit test).
251
274
  * Agnostic middleware resolves host services through this, never by importing
252
275
  * `@c9up/ream`. See {@link ContainerResolver}.
253
276
  */
@@ -303,7 +326,7 @@ export class HttpContext extends Macroable {
303
326
  constructor(
304
327
  id: string,
305
328
  rawRequest: RawRequest,
306
- params: Record<string, string>,
329
+ params: MatchedParams,
307
330
  route: RouteInfo,
308
331
  containerResolver?: ContainerResolver,
309
332
  ) {