@c9up/ream 0.1.33 → 0.2.0

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 (215) hide show
  1. package/dist/Application.d.ts +68 -0
  2. package/dist/Application.d.ts.map +1 -1
  3. package/dist/Application.js +142 -6
  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/HealthCheck.d.ts +14 -7
  10. package/dist/HealthCheck.d.ts.map +1 -1
  11. package/dist/HealthCheck.js +21 -12
  12. package/dist/HealthCheck.js.map +1 -1
  13. package/dist/HttpKernel.d.ts +12 -0
  14. package/dist/HttpKernel.d.ts.map +1 -1
  15. package/dist/HttpKernel.js +15 -4
  16. package/dist/HttpKernel.js.map +1 -1
  17. package/dist/Ignitor.d.ts +8 -0
  18. package/dist/Ignitor.d.ts.map +1 -1
  19. package/dist/Ignitor.js +11 -0
  20. package/dist/Ignitor.js.map +1 -1
  21. package/dist/bodyparser/BodyParserMiddleware.d.ts +13 -2
  22. package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
  23. package/dist/bodyparser/BodyParserMiddleware.js +50 -30
  24. package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
  25. package/dist/bodyparser/MultipartFile.d.ts +65 -6
  26. package/dist/bodyparser/MultipartFile.d.ts.map +1 -1
  27. package/dist/bodyparser/MultipartFile.js +100 -7
  28. package/dist/bodyparser/MultipartFile.js.map +1 -1
  29. package/dist/console/BaseCommand.d.ts +8 -1
  30. package/dist/console/BaseCommand.d.ts.map +1 -1
  31. package/dist/console/BaseCommand.js +1 -1
  32. package/dist/console/BaseCommand.js.map +1 -1
  33. package/dist/console/Kernel.d.ts +15 -0
  34. package/dist/console/Kernel.d.ts.map +1 -1
  35. package/dist/console/Kernel.js +17 -0
  36. package/dist/console/Kernel.js.map +1 -1
  37. package/dist/directories.d.ts +52 -0
  38. package/dist/directories.d.ts.map +1 -0
  39. package/dist/directories.js +38 -0
  40. package/dist/directories.js.map +1 -0
  41. package/dist/events/Emitter.d.ts +49 -6
  42. package/dist/events/Emitter.d.ts.map +1 -1
  43. package/dist/events/Emitter.js +117 -7
  44. package/dist/events/Emitter.js.map +1 -1
  45. package/dist/events/EventsBuffer.d.ts +47 -0
  46. package/dist/events/EventsBuffer.d.ts.map +1 -0
  47. package/dist/events/EventsBuffer.js +107 -0
  48. package/dist/events/EventsBuffer.js.map +1 -0
  49. package/dist/events/index.d.ts +2 -0
  50. package/dist/events/index.d.ts.map +1 -1
  51. package/dist/events/index.js +1 -0
  52. package/dist/events/index.js.map +1 -1
  53. package/dist/health/BaseCheck.d.ts +19 -0
  54. package/dist/health/BaseCheck.d.ts.map +1 -0
  55. package/dist/health/BaseCheck.js +24 -0
  56. package/dist/health/BaseCheck.js.map +1 -0
  57. package/dist/health/HealthChecks.d.ts +15 -0
  58. package/dist/health/HealthChecks.d.ts.map +1 -0
  59. package/dist/health/HealthChecks.js +69 -0
  60. package/dist/health/HealthChecks.js.map +1 -0
  61. package/dist/health/Result.d.ts +27 -0
  62. package/dist/health/Result.d.ts.map +1 -0
  63. package/dist/health/Result.js +55 -0
  64. package/dist/health/Result.js.map +1 -0
  65. package/dist/health/checks/DiskSpaceCheck.d.ts +29 -0
  66. package/dist/health/checks/DiskSpaceCheck.d.ts.map +1 -0
  67. package/dist/health/checks/DiskSpaceCheck.js +58 -0
  68. package/dist/health/checks/DiskSpaceCheck.js.map +1 -0
  69. package/dist/health/checks/MemoryHeapCheck.d.ts +21 -0
  70. package/dist/health/checks/MemoryHeapCheck.d.ts.map +1 -0
  71. package/dist/health/checks/MemoryHeapCheck.js +55 -0
  72. package/dist/health/checks/MemoryHeapCheck.js.map +1 -0
  73. package/dist/health/checks/MemoryRSSCheck.d.ts +21 -0
  74. package/dist/health/checks/MemoryRSSCheck.d.ts.map +1 -0
  75. package/dist/health/checks/MemoryRSSCheck.js +55 -0
  76. package/dist/health/checks/MemoryRSSCheck.js.map +1 -0
  77. package/dist/health/checks/memory.d.ts +24 -0
  78. package/dist/health/checks/memory.d.ts.map +1 -0
  79. package/dist/health/checks/memory.js +88 -0
  80. package/dist/health/checks/memory.js.map +1 -0
  81. package/dist/health/index.d.ts +9 -0
  82. package/dist/health/index.d.ts.map +1 -0
  83. package/dist/health/index.js +8 -0
  84. package/dist/health/index.js.map +1 -0
  85. package/dist/health/tracing.d.ts +12 -0
  86. package/dist/health/tracing.d.ts.map +1 -0
  87. package/dist/health/tracing.js +11 -0
  88. package/dist/health/tracing.js.map +1 -0
  89. package/dist/health/types.d.ts +51 -0
  90. package/dist/health/types.d.ts.map +1 -0
  91. package/dist/health/types.js +5 -0
  92. package/dist/health/types.js.map +1 -0
  93. package/dist/helpers/bytes.d.ts +28 -0
  94. package/dist/helpers/bytes.d.ts.map +1 -0
  95. package/dist/helpers/bytes.js +63 -0
  96. package/dist/helpers/bytes.js.map +1 -0
  97. package/dist/helpers/string.d.ts +4 -0
  98. package/dist/helpers/string.d.ts.map +1 -1
  99. package/dist/helpers/string.js +2 -0
  100. package/dist/helpers/string.js.map +1 -1
  101. package/dist/http/HttpContext.d.ts +38 -2
  102. package/dist/http/HttpContext.d.ts.map +1 -1
  103. package/dist/http/HttpContext.js +81 -3
  104. package/dist/http/HttpContext.js.map +1 -1
  105. package/dist/http/Request.d.ts +57 -3
  106. package/dist/http/Request.d.ts.map +1 -1
  107. package/dist/http/Request.js +51 -38
  108. package/dist/http/Request.js.map +1 -1
  109. package/dist/http/Response.d.ts +82 -2
  110. package/dist/http/Response.d.ts.map +1 -1
  111. package/dist/http/Response.js +240 -13
  112. package/dist/http/Response.js.map +1 -1
  113. package/dist/http/SseStream.d.ts +10 -0
  114. package/dist/http/SseStream.d.ts.map +1 -1
  115. package/dist/http/SseStream.js.map +1 -1
  116. package/dist/index.d.ts +7 -2
  117. package/dist/index.d.ts.map +1 -1
  118. package/dist/index.js +4 -1
  119. package/dist/index.js.map +1 -1
  120. package/dist/migrations/MigrationRegistry.d.ts +28 -0
  121. package/dist/migrations/MigrationRegistry.d.ts.map +1 -0
  122. package/dist/migrations/MigrationRegistry.js +41 -0
  123. package/dist/migrations/MigrationRegistry.js.map +1 -0
  124. package/dist/migrations/types.d.ts +64 -0
  125. package/dist/migrations/types.d.ts.map +1 -0
  126. package/dist/migrations/types.js +15 -0
  127. package/dist/migrations/types.js.map +1 -0
  128. package/dist/router/Router.d.ts +112 -9
  129. package/dist/router/Router.d.ts.map +1 -1
  130. package/dist/router/Router.js +206 -28
  131. package/dist/router/Router.js.map +1 -1
  132. package/dist/security/CookieSigner.d.ts +43 -4
  133. package/dist/security/CookieSigner.d.ts.map +1 -1
  134. package/dist/security/CookieSigner.js +68 -10
  135. package/dist/security/CookieSigner.js.map +1 -1
  136. package/dist/session/Session.d.ts +46 -1
  137. package/dist/session/Session.d.ts.map +1 -1
  138. package/dist/session/Session.js +57 -4
  139. package/dist/session/Session.js.map +1 -1
  140. package/dist/session/SessionMiddleware.d.ts.map +1 -1
  141. package/dist/session/SessionMiddleware.js +19 -9
  142. package/dist/session/SessionMiddleware.js.map +1 -1
  143. package/dist/session/drivers/CookieDriver.d.ts +1 -1
  144. package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
  145. package/dist/session/drivers/CookieDriver.js +3 -3
  146. package/dist/session/drivers/CookieDriver.js.map +1 -1
  147. package/dist/session/drivers/DatabaseDriver.d.ts +1 -1
  148. package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
  149. package/dist/session/drivers/DatabaseDriver.js +2 -2
  150. package/dist/session/drivers/DatabaseDriver.js.map +1 -1
  151. package/dist/session/drivers/FileDriver.d.ts +1 -1
  152. package/dist/session/drivers/FileDriver.d.ts.map +1 -1
  153. package/dist/session/drivers/FileDriver.js +7 -5
  154. package/dist/session/drivers/FileDriver.js.map +1 -1
  155. package/dist/session/drivers/MemoryDriver.d.ts +1 -1
  156. package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
  157. package/dist/session/drivers/MemoryDriver.js +1 -1
  158. package/dist/session/drivers/MemoryDriver.js.map +1 -1
  159. package/dist/session/drivers/RedisDriver.d.ts +1 -1
  160. package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
  161. package/dist/session/drivers/RedisDriver.js +11 -4
  162. package/dist/session/drivers/RedisDriver.js.map +1 -1
  163. package/events.win32-x64-msvc.node +0 -0
  164. package/index.darwin-arm64.node +0 -0
  165. package/index.darwin-x64.node +0 -0
  166. package/index.linux-arm64-gnu.node +0 -0
  167. package/index.linux-x64-gnu.node +0 -0
  168. package/index.win32-x64-msvc.node +0 -0
  169. package/package.json +5 -2
  170. package/scheduler.win32-x64-msvc.node +0 -0
  171. package/src/Application.ts +169 -6
  172. package/src/ConfigLoader.ts +6 -0
  173. package/src/HealthCheck.ts +27 -14
  174. package/src/HttpKernel.ts +27 -4
  175. package/src/Ignitor.ts +17 -0
  176. package/src/bodyparser/BodyParserMiddleware.ts +70 -36
  177. package/src/bodyparser/MultipartFile.ts +125 -8
  178. package/src/console/BaseCommand.ts +8 -1
  179. package/src/console/Kernel.ts +18 -0
  180. package/src/directories.ts +81 -0
  181. package/src/events/Emitter.ts +129 -13
  182. package/src/events/EventsBuffer.ts +131 -0
  183. package/src/events/index.ts +7 -0
  184. package/src/health/BaseCheck.ts +30 -0
  185. package/src/health/HealthChecks.ts +83 -0
  186. package/src/health/Result.ts +69 -0
  187. package/src/health/checks/DiskSpaceCheck.ts +69 -0
  188. package/src/health/checks/MemoryHeapCheck.ts +68 -0
  189. package/src/health/checks/MemoryRSSCheck.ts +68 -0
  190. package/src/health/checks/memory.ts +115 -0
  191. package/src/health/index.ts +13 -0
  192. package/src/health/tracing.ts +15 -0
  193. package/src/health/types.ts +51 -0
  194. package/src/helpers/bytes.ts +71 -0
  195. package/src/helpers/string.ts +2 -0
  196. package/src/http/HttpContext.ts +86 -4
  197. package/src/http/Request.ts +82 -10
  198. package/src/http/Response.ts +283 -14
  199. package/src/http/SseStream.ts +10 -0
  200. package/src/index.ts +27 -1
  201. package/src/migrations/MigrationRegistry.ts +49 -0
  202. package/src/migrations/types.ts +68 -0
  203. package/src/router/Router.ts +241 -32
  204. package/src/security/CookieSigner.ts +92 -18
  205. package/src/session/Session.ts +71 -5
  206. package/src/session/SessionMiddleware.ts +19 -9
  207. package/src/session/drivers/CookieDriver.ts +4 -4
  208. package/src/session/drivers/DatabaseDriver.ts +3 -3
  209. package/src/session/drivers/FileDriver.ts +8 -6
  210. package/src/session/drivers/MemoryDriver.ts +2 -2
  211. package/src/session/drivers/RedisDriver.ts +12 -5
  212. package/tsconfig.app.json +13 -13
  213. package/tsconfig.base.json +19 -0
  214. package/tsconfig.client.json +16 -0
  215. package/tsconfig.package.json +15 -0
@@ -0,0 +1,49 @@
1
+ import type { RegisteredMigrationSource } from './types.js'
2
+
3
+ /**
4
+ * Where every store's migration runner registers itself.
5
+ *
6
+ * Bound in the container as `migrations`, always — an app with no data package
7
+ * gets an EMPTY registry, never a missing binding. That distinction is what
8
+ * lets the CLI tell "this app has no migrations" from "this app predates the
9
+ * registry", and say something useful in each case instead of failing the same
10
+ * way twice.
11
+ */
12
+ export class MigrationRegistry {
13
+ readonly #sources = new Map<string, RegisteredMigrationSource>()
14
+
15
+ /**
16
+ * Register a store's runner. Registering the same name twice is refused:
17
+ * silently replacing it would mean one of two providers migrates nothing,
18
+ * and the run would still report success.
19
+ */
20
+ register(source: RegisteredMigrationSource): this {
21
+ if (this.#sources.has(source.name)) {
22
+ throw new Error(
23
+ `A migration source named '${source.name}' is already registered. Two providers cannot claim one name — one of them would migrate nothing while the run still reported success.`,
24
+ )
25
+ }
26
+ this.#sources.set(source.name, source)
27
+ return this
28
+ }
29
+
30
+ /** Every registered store, in registration order. */
31
+ all(): RegisteredMigrationSource[] {
32
+ return [...this.#sources.values()]
33
+ }
34
+
35
+ /** One store by name, or `undefined`. */
36
+ get(name: string): RegisteredMigrationSource | undefined {
37
+ return this.#sources.get(name)
38
+ }
39
+
40
+ /** The registered names, for an error message that lists what IS available. */
41
+ names(): string[] {
42
+ return [...this.#sources.keys()]
43
+ }
44
+
45
+ /** Whether anything registered. */
46
+ get isEmpty(): boolean {
47
+ return this.#sources.size === 0
48
+ }
49
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The contract a data package implements so `ream migrate` can drive it.
3
+ *
4
+ * REAM PARTICULARITY, deliberate — AdonisJS has no equivalent, because Lucid is
5
+ * its only migration source: `node ace migration:run` can name it directly.
6
+ * Ream expects several stores in one app (a relational one, a time-series one,
7
+ * whatever comes next), so the CLI must not name any of them. A store registers
8
+ * itself and the CLI stays generic for good.
9
+ *
10
+ * The vocabulary is Adonis' where Adonis has one: a status node is
11
+ * `{ name, status, batch? }`, spelled `status` as Lucid's `MigrationListNode`
12
+ * spells it.
13
+ */
14
+
15
+ /** Whether a migration has run. */
16
+ export type MigrationState = 'applied' | 'pending'
17
+
18
+ /** One migration's position, as `ream migrate:status` prints it. */
19
+ export interface MigrationStatusNode {
20
+ /** The migration's name, without extension. */
21
+ name: string
22
+ status: MigrationState
23
+ /** The batch it was applied in, when it has been. */
24
+ batch?: number
25
+ }
26
+
27
+ /**
28
+ * What a store's migration runner must provide.
29
+ *
30
+ * Only three methods are REQUIRED — the three `ream migrate`,
31
+ * `migrate:rollback` and `migrate:status` call. Everything else is optional on
32
+ * purpose: a new store must be able to register with a runner that only knows
33
+ * how to go forward, rather than stub methods it cannot honour. A caller that
34
+ * needs an optional one checks for it and says so, instead of crashing.
35
+ */
36
+ export interface MigrationRunnerContract {
37
+ /** Apply every pending migration. Resolves to the names applied, in order. */
38
+ migrate(): Promise<string[]>
39
+ /** Undo the last batch. Resolves to the names rolled back. */
40
+ rollback(): Promise<string[]>
41
+ /** Every known migration and whether it has run. */
42
+ status(): Promise<MigrationStatusNode[]>
43
+
44
+ /** Undo every migration. */
45
+ reset?(): Promise<string[]>
46
+ /** Reset, then migrate. */
47
+ refresh?(): Promise<unknown>
48
+ /** Drop everything, then migrate. */
49
+ fresh?(): Promise<unknown>
50
+ /** What `migrate()` would do, without doing it. */
51
+ dryRun?(): Promise<unknown>
52
+ /** Release a lock a crashed run left behind. */
53
+ forceUnlock?(): Promise<boolean>
54
+ }
55
+
56
+ /** A registered store, as the CLI sees it. */
57
+ export interface RegisteredMigrationSource {
58
+ /**
59
+ * How the store is named on the command line — `atlas`, `eon`, …
60
+ *
61
+ * It is what `ream migrate --only <name>` matches and what prefixes the
62
+ * output, so it belongs to the store, not to the app.
63
+ */
64
+ name: string
65
+ /** Where its migration files live, for the message when there are none. */
66
+ directory?: string
67
+ runner: MigrationRunnerContract
68
+ }
@@ -57,8 +57,24 @@ export type LazyControllerAction = [loader: LazyControllerImport, method: string
57
57
  */
58
58
  export type RouteHandler = RouteHandlerFunction | ControllerAction | LazyControllerAction | string
59
59
 
60
- /** Param matcher — regex or predefined matcher. */
61
- export type ParamMatcher = RegExp | { pattern: RegExp }
60
+ /**
61
+ * Param matcher — a bare RegExp, or the AdonisJS `{ match, cast }` shape.
62
+ *
63
+ * `cast` turns the captured string into the value the handler wants, so
64
+ * `where('id', matchers.number())` gives `params.id` as a NUMBER. Ream carried
65
+ * only the pattern, so a matched param stayed a string and every controller
66
+ * re-parsed it. `pattern` is ream's older spelling of `match`, still accepted.
67
+ */
68
+ export type ParamMatcher =
69
+ | RegExp
70
+ | {
71
+ /** AdonisJS spelling. */
72
+ match?: RegExp
73
+ /** Ream's older spelling of {@link match}. */
74
+ pattern?: RegExp
75
+ /** Casts the captured segment before it reaches `ctx.params`. */
76
+ cast?: (value: string) => unknown
77
+ }
62
78
 
63
79
  export interface RouteDefinition {
64
80
  method: string
@@ -166,17 +182,22 @@ export interface MatchResult {
166
182
  // ─── Matchers ───────────────────────────────────────────────
167
183
 
168
184
  export const matchers = {
169
- /** Match numeric params only. */
185
+ /** Match numeric params only, and hand the handler a number. */
170
186
  number(): ParamMatcher {
171
- return { pattern: /^\d+$/ }
187
+ return { match: /^\d+$/, cast: (value) => Number(value) }
172
188
  },
173
189
  /** Match UUID v4 params only. */
174
190
  uuid(): ParamMatcher {
175
- return { pattern: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i }
191
+ return {
192
+ match: /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
193
+ // AdonisJS lowercases it, so a route keyed on the param is stable
194
+ // whatever case the client sent.
195
+ cast: (value) => value.toLowerCase(),
196
+ }
176
197
  },
177
198
  /** Match slug params only (lowercase alphanumeric + hyphens). */
178
199
  slug(): ParamMatcher {
179
- return { pattern: /^[a-z0-9]+(?:-[a-z0-9]+)*$/ }
200
+ return { match: /^[a-z0-9]+(?:-[a-z0-9]+)*$/ }
180
201
  },
181
202
  }
182
203
 
@@ -190,15 +211,43 @@ export const matchers = {
190
211
  * anchoring here reproduces AdonisJS's *effective* full-segment semantics.
191
212
  */
192
213
  function compileMatcher(matcher: ParamMatcher | RegExp | string): ParamMatcher {
193
- if (typeof matcher === 'string') return { pattern: new RegExp(`^(?:${matcher})$`) }
194
- return matcher instanceof RegExp ? { pattern: matcher } : matcher
214
+ if (typeof matcher === 'string') return { match: new RegExp(`^(?:${matcher})$`) }
215
+ return matcher instanceof RegExp ? { match: matcher } : matcher
216
+ }
217
+
218
+ /**
219
+ * Run each matcher's `cast` over the params it matched.
220
+ *
221
+ * The wildcard holds several segments, so the cast applies to each of them.
222
+ * A matcher without a cast leaves its param untouched.
223
+ */
224
+ function applyCasts(params: MatchedParams, matchers: Record<string, ParamMatcher>): MatchedParams {
225
+ let out: MatchedParams | undefined
226
+ for (const [param, value] of Object.entries(params)) {
227
+ const matcher = matchers[param]
228
+ const cast = matcher instanceof RegExp ? undefined : matcher?.cast
229
+ if (!cast) continue
230
+ out ??= { ...params }
231
+ // The cast's return is the caller's claim about its own param; the router
232
+ // only carries it.
233
+ out[param] = (
234
+ Array.isArray(value) ? value.map((v) => cast(v)) : cast(value)
235
+ ) as MatchedParams[string]
236
+ }
237
+ return out ?? params
238
+ }
239
+
240
+ /** The RegExp a matcher tests with, whichever spelling it used. */
241
+ function matcherPattern(matcher: ParamMatcher): RegExp | undefined {
242
+ if (matcher instanceof RegExp) return matcher
243
+ return matcher.match ?? matcher.pattern
195
244
  }
196
245
 
197
246
  // ─── RouteBuilder ───────────────────────────────────────────
198
247
 
199
248
  /**
200
249
  * Fluent route builder — mutates the already-registered route definition(s).
201
- * Backed by an array so a multi-verb `router.route(['GET','POST'], …)` returns
250
+ * Backed by an array so a multi-verb `router.route(pattern, ['GET','POST'], …)` returns
202
251
  * ONE builder that fans every mutation across all its verbs; the single-verb
203
252
  * helpers (`get`/`post`/…) back it with a one-element array.
204
253
  */
@@ -808,14 +857,16 @@ export class Router extends Macroable {
808
857
  // ─── Route registration ───────────────────────────────────
809
858
 
810
859
  /**
811
- * Register a route for one verb or several (AdonisJS `route(pattern, methods)`
812
- * accepts an array). A multi-verb call returns a single builder that
813
- * configures every verb at once.
860
+ * Register a route for one verb or several — `route(pattern, methods,
861
+ * handler)`, the AdonisJS argument order. Ream took `(method, path)`, so a
862
+ * call copied from an AdonisJS app registered the pattern as the verb.
863
+ *
864
+ * A multi-verb call returns a single builder that configures every verb.
814
865
  */
815
- route(method: string | string[], path: string, handler: RouteHandler): RouteBuilder {
816
- const methods = Array.isArray(method) ? method : [method]
817
- const defs = methods.map((verb) => {
818
- const def = this.#createDefinition(verb, path, handler)
866
+ route(pattern: string, methods: string | string[], handler: RouteHandler): RouteBuilder {
867
+ const verbs = Array.isArray(methods) ? methods : [methods]
868
+ const defs = verbs.map((verb) => {
869
+ const def = this.#createDefinition(verb, pattern, handler)
819
870
  this.#routes.push(def)
820
871
  return def
821
872
  })
@@ -824,36 +875,36 @@ export class Router extends Macroable {
824
875
  }
825
876
 
826
877
  get(path: string, handler: RouteHandler): RouteBuilder {
827
- return this.route('GET', path, handler)
878
+ return this.route(path, 'GET', handler)
828
879
  }
829
880
 
830
881
  post(path: string, handler: RouteHandler): RouteBuilder {
831
- return this.route('POST', path, handler)
882
+ return this.route(path, 'POST', handler)
832
883
  }
833
884
 
834
885
  put(path: string, handler: RouteHandler): RouteBuilder {
835
- return this.route('PUT', path, handler)
886
+ return this.route(path, 'PUT', handler)
836
887
  }
837
888
 
838
889
  patch(path: string, handler: RouteHandler): RouteBuilder {
839
- return this.route('PATCH', path, handler)
890
+ return this.route(path, 'PATCH', handler)
840
891
  }
841
892
 
842
893
  delete(path: string, handler: RouteHandler): RouteBuilder {
843
- return this.route('DELETE', path, handler)
894
+ return this.route(path, 'DELETE', handler)
844
895
  }
845
896
 
846
897
  head(path: string, handler: RouteHandler): RouteBuilder {
847
- return this.route('HEAD', path, handler)
898
+ return this.route(path, 'HEAD', handler)
848
899
  }
849
900
 
850
901
  options(path: string, handler: RouteHandler): RouteBuilder {
851
- return this.route('OPTIONS', path, handler)
902
+ return this.route(path, 'OPTIONS', handler)
852
903
  }
853
904
 
854
905
  /** Register a route for all HTTP methods. */
855
906
  any(path: string, handler: RouteHandler): RouteBuilder {
856
- return this.route('*', path, handler)
907
+ return this.route(path, '*', handler)
857
908
  }
858
909
 
859
910
  // ─── Resource routes ──────────────────────────────────────
@@ -1096,16 +1147,16 @@ export class Router extends Macroable {
1096
1147
  // separate gates meant the global always had the final say, so a route
1097
1148
  // could never loosen it — a global `id: /^[0-9]+$/` plus a route-level
1098
1149
  // slug matcher 404'd instead of matching.
1099
- if (
1100
- !this.#validateMatchers(params, {
1101
- ...this.#globalMatchers,
1102
- ...route.matchers,
1103
- })
1104
- ) {
1150
+ const effective = { ...this.#globalMatchers, ...route.matchers }
1151
+ if (!this.#validateMatchers(params, effective)) {
1105
1152
  continue
1106
1153
  }
1107
1154
 
1108
- return { route, params }
1155
+ // A matcher's `cast` turns the captured segment into the value the
1156
+ // handler wants — `where('id', matchers.number())` hands `params.id`
1157
+ // a NUMBER, as in AdonisJS. Ream carried only the pattern, so every
1158
+ // controller re-parsed a string the router had already validated.
1159
+ return { route, params: applyCasts(params, effective) }
1109
1160
  }
1110
1161
  }
1111
1162
 
@@ -1270,6 +1321,96 @@ export class Router extends Macroable {
1270
1321
  return this.#routes.filter((route) => !route.deleted)
1271
1322
  }
1272
1323
 
1324
+ /**
1325
+ * A fluent URL builder (AdonisJS `router.builder()` / `urlBuilder`).
1326
+ *
1327
+ * `urlFor` takes everything at once; this is the form upstream gives you
1328
+ * when the pieces arrive separately — params here, query string there, a
1329
+ * prefix from config:
1330
+ *
1331
+ * ```ts
1332
+ * router.builder().params({ id: '1' }).qs({ tab: 'posts' }).make('users.show')
1333
+ * router.builder().prefixUrl('https://acme.test').makeSigned('invite', { expiresIn: '1h' })
1334
+ * ```
1335
+ */
1336
+ builder(): UrlBuilder {
1337
+ return new UrlBuilder(this)
1338
+ }
1339
+
1340
+ /** AdonisJS' property name for {@link builder}. */
1341
+ get urlBuilder(): UrlBuilder {
1342
+ return this.builder()
1343
+ }
1344
+
1345
+ /**
1346
+ * A builder that prefixes every URL with `domain` (AdonisJS
1347
+ * `builderForDomain`).
1348
+ *
1349
+ * The domain is a prefix here, not a lookup key: routes are matched by host
1350
+ * at request time, and a named route is unique across the table.
1351
+ */
1352
+ builderForDomain(domain: string): UrlBuilder {
1353
+ return this.builder().prefixUrl(domain)
1354
+ }
1355
+
1356
+ /**
1357
+ * The parameter names a route pattern declares, in order (AdonisJS
1358
+ * `parsePattern`).
1359
+ *
1360
+ * `:id` and `:slug?` both count; the optional marker is reported rather than
1361
+ * being part of the name.
1362
+ */
1363
+ parsePattern(pattern: string): Array<{ name: string; optional: boolean }> {
1364
+ const tokens: Array<{ name: string; optional: boolean }> = []
1365
+ for (const match of pattern.matchAll(/:([A-Za-z_][\w]*)(\?)?/g)) {
1366
+ tokens.push({ name: match[1] ?? '', optional: match[2] === '?' })
1367
+ }
1368
+ return tokens
1369
+ }
1370
+
1371
+ /**
1372
+ * Every route, grouped by the domain it answers on (AdonisJS `toJSON`).
1373
+ *
1374
+ * Routes registered without a domain land under `root`, as upstream keys
1375
+ * them. This is what a route list — `ream inspect`, an OpenAPI generator, a
1376
+ * debug screen — reads.
1377
+ */
1378
+ toJSON(): Record<string, RouteDefinition[]> {
1379
+ const byDomain: Record<string, RouteDefinition[]> = {}
1380
+ for (const route of this.getRoutes()) {
1381
+ const key = route.domain ?? 'root'
1382
+ const bucket = byDomain[key] ?? []
1383
+ bucket.push(route)
1384
+ byDomain[key] = bucket
1385
+ }
1386
+ return byDomain
1387
+ }
1388
+
1389
+ /**
1390
+ * Whether any route is scoped to a domain (AdonisJS `usingDomains`).
1391
+ *
1392
+ * Lets the matcher skip host resolution entirely when nothing asked for it.
1393
+ */
1394
+ get usingDomains(): boolean {
1395
+ return this.getRoutes().some((route) => route.domain !== undefined)
1396
+ }
1397
+
1398
+ /**
1399
+ * Freeze the route table and build the lookup index (AdonisJS `commit`).
1400
+ *
1401
+ * The index is built lazily on the first `match()`, so this is only needed
1402
+ * to pay that cost up front — at the end of boot rather than inside the
1403
+ * first request.
1404
+ */
1405
+ commit(): void {
1406
+ this.#buildIndex()
1407
+ }
1408
+
1409
+ /** Whether the lookup index has been built (AdonisJS `commited`). */
1410
+ get commited(): boolean {
1411
+ return !this.#indexDirty
1412
+ }
1413
+
1273
1414
  /**
1274
1415
  * Clear all registered routes AND registries (used by hot-reload). Router
1275
1416
  * middleware, named middleware, and global matchers are reset too — the
@@ -1302,7 +1443,8 @@ export class Router extends Macroable {
1302
1443
  // The wildcard holds several segments; every one has to satisfy the
1303
1444
  // matcher, or a `/assets/*` guard would pass on its first segment alone.
1304
1445
  const values = Array.isArray(value) ? value : [value]
1305
- const regex = matcher instanceof RegExp ? matcher : matcher.pattern
1446
+ const regex = matcherPattern(matcher)
1447
+ if (!regex) continue
1306
1448
  // A user-supplied /g or /y regex is stateful (`lastIndex` advances on
1307
1449
  // match) — without a reset, the same URL alternates match/no-match
1308
1450
  // across requests. Reset before every test.
@@ -1387,3 +1529,70 @@ function matchDomain(pattern: string, host: string): boolean {
1387
1529
  }
1388
1530
  return false
1389
1531
  }
1532
+
1533
+ /**
1534
+ * Builds a URL for a named route, one piece at a time (AdonisJS `UrlBuilder`).
1535
+ *
1536
+ * Every method returns the builder, and `make` / `makeSigned` are the two ways
1537
+ * out. It defers to {@link Router.urlFor} and {@link Router.makeSignedUrl}, so
1538
+ * a missing param fails the same way whichever spelling you used.
1539
+ */
1540
+ export class UrlBuilder {
1541
+ readonly #router: Router
1542
+ #params: Record<string, string> = {}
1543
+ #qs: Record<string, string> = {}
1544
+ #prefix = ''
1545
+ #lookup = true
1546
+
1547
+ constructor(router: Router) {
1548
+ this.#router = router
1549
+ }
1550
+
1551
+ /** Route params, by name. */
1552
+ params(params: Record<string, string>): this {
1553
+ this.#params = { ...this.#params, ...params }
1554
+ return this
1555
+ }
1556
+
1557
+ /** Query-string params appended to the built URL. */
1558
+ qs(qs: Record<string, string>): this {
1559
+ this.#qs = { ...this.#qs, ...qs }
1560
+ return this
1561
+ }
1562
+
1563
+ /** A prefix — a domain, a base path — put in front of the result. */
1564
+ prefixUrl(prefix: string): this {
1565
+ this.#prefix = prefix.replace(/\/$/, '')
1566
+ return this
1567
+ }
1568
+
1569
+ /**
1570
+ * Treat what `make()` receives as a literal path instead of a route name
1571
+ * (AdonisJS `disableRouteLookup`).
1572
+ */
1573
+ disableRouteLookup(): this {
1574
+ this.#lookup = false
1575
+ return this
1576
+ }
1577
+
1578
+ /** The URL. */
1579
+ make(identifier: string): string {
1580
+ const path = this.#lookup ? this.#router.urlFor(identifier, this.#params) : identifier
1581
+ const query = new URLSearchParams(this.#qs).toString()
1582
+ return `${this.#prefix}${path}${query ? `?${query}` : ''}`
1583
+ }
1584
+
1585
+ /**
1586
+ * The URL with an HMAC signature (AdonisJS `makeSigned`).
1587
+ *
1588
+ * The query string is folded into the signature, so a link cannot be
1589
+ * re-pointed by editing it.
1590
+ */
1591
+ makeSigned(identifier: string, options?: SignedUrlOptions): string {
1592
+ const signed = this.#router.makeSignedUrl(identifier, this.#params, {
1593
+ ...options,
1594
+ qs: { ...this.#qs, ...options?.qs },
1595
+ })
1596
+ return `${this.#prefix}${signed}`
1597
+ }
1598
+ }
@@ -40,8 +40,12 @@ export class E_INSECURE_APP_KEY extends Error {
40
40
 
41
41
  /** The envelope carried inside an encrypted or signed value. */
42
42
  interface Envelope {
43
- /** The payload. */
44
- m: string
43
+ /**
44
+ * The payload. Any JSON value, not just a string: AdonisJS' verifier signs
45
+ * whatever you hand it, and a caller signing `{ id: 1 }` should not have to
46
+ * stringify it first.
47
+ */
48
+ m: unknown
45
49
  /** Epoch-ms expiry, when one was given. */
46
50
  e?: number
47
51
  /** The purpose it was sealed for, when one was given. */
@@ -69,25 +73,36 @@ export class CookieSigner {
69
73
  * another — a password-reset token presented as a session cookie fails here
70
74
  * rather than being honoured.
71
75
  */
72
- #seal(value: string, expiresIn?: number, purpose?: string): string {
76
+ #seal(value: unknown, expiresIn?: number, purpose?: string): string {
73
77
  const envelope: Envelope = { m: value }
74
78
  if (expiresIn !== undefined) envelope.e = Date.now() + expiresIn
75
79
  if (purpose !== undefined) envelope.p = purpose
76
80
  return JSON.stringify(envelope)
77
81
  }
78
82
 
79
- /** Unwrap a sealed payload, refusing an expired one or the wrong purpose. */
80
- #open(raw: string, purpose?: string): string | null {
81
- let envelope: Envelope
83
+ /**
84
+ * Unwrap a sealed payload, refusing an expired one or the wrong purpose.
85
+ *
86
+ * Anything that is not an envelope is refused rather than handed back.
87
+ * `#seal` only ever produces one, so a non-envelope did not come from this
88
+ * class — and returning it would skip the expiry and purpose checks below,
89
+ * which is precisely the downgrade those checks exist to prevent. Failing
90
+ * closed costs a value nothing here produced; failing open costs the
91
+ * guarantee that a token sealed for one use cannot be replayed into another.
92
+ */
93
+ #open(raw: string, purpose?: string): unknown {
94
+ let parsed: unknown
82
95
  try {
83
- const parsed: unknown = JSON.parse(raw)
84
- if (typeof parsed !== 'object' || parsed === null) return null
85
- envelope = parsed as Envelope
96
+ parsed = JSON.parse(raw)
86
97
  } catch {
87
- // Not an envelope — a value sealed before this format existed.
88
- return raw
98
+ return null
89
99
  }
90
- if (typeof envelope.m !== 'string') return raw
100
+ if (typeof parsed !== 'object' || parsed === null) return null
101
+ const envelope = parsed as Envelope
102
+ // The KEY must be there, whatever it holds — that is what distinguishes an
103
+ // envelope from an arbitrary JSON document, and a signed `null` or `0` is a
104
+ // legitimate payload.
105
+ if (!('m' in envelope)) return null
91
106
  if (envelope.e !== undefined && envelope.e < Date.now()) return null
92
107
  if ((envelope.p ?? undefined) !== purpose) return null
93
108
  return envelope.m
@@ -99,27 +114,86 @@ export class CookieSigner {
99
114
  * `expiresIn` is in milliseconds; `purpose` scopes the signature so it cannot
100
115
  * be replayed somewhere else.
101
116
  */
102
- sign(value: string, expiresIn?: number, purpose?: string): string {
117
+ sign(value: unknown, expiresIn?: number, purpose?: string): string {
103
118
  const sealed = Buffer.from(this.#seal(value, expiresIn, purpose)).toString('base64url')
104
119
  const sig = hmacSign(sealed, this.#secret)
105
120
  return `${sealed}.${sig}`
106
121
  }
107
122
 
108
123
  /** Verify and unwrap, returning null when tampered, expired, or mis-purposed. */
109
- unsign(signed: string, purpose?: string): string | null {
124
+ unsign<T = string>(signed: string, purpose?: string): T | null {
110
125
  const lastDot = signed.lastIndexOf('.')
111
126
  if (lastDot === -1) return null
112
127
  const sealed = signed.slice(0, lastDot)
113
128
  const sig = signed.slice(lastDot + 1)
114
129
  if (!hmacVerify(sealed, sig, this.#secret)) return null
115
130
  try {
116
- return this.#open(Buffer.from(sealed, 'base64url').toString('utf8'), purpose)
131
+ return this.#open(Buffer.from(sealed, 'base64url').toString('utf8'), purpose) as T | null
117
132
  } catch {
118
133
  return null
119
134
  }
120
135
  }
121
136
 
122
- encrypt(value: string, expiresIn?: number, purpose?: string): string {
137
+ /**
138
+ * The cipher in use (AdonisJS `Encryption.algorithm`).
139
+ *
140
+ * `aes-256-gcm`, not upstream's `aes-256-cbc` — see the deviation at the top
141
+ * of this file.
142
+ */
143
+ get algorithm(): 'aes-256-gcm' {
144
+ return 'aes-256-gcm'
145
+ }
146
+
147
+ /**
148
+ * base64url helpers (AdonisJS `Encryption.base64`).
149
+ *
150
+ * url-safe and unpadded, which is what every value this class produces uses
151
+ * — a signed or encrypted payload travels in a cookie or a URL, where `+`,
152
+ * `/` and `=` do not survive. `decode` answers `null` on anything that does
153
+ * not round-trip rather than handing back mangled bytes.
154
+ */
155
+ get base64(): {
156
+ encode(value: string): string
157
+ decode(value: string): string | null
158
+ urlEncode(value: string): string
159
+ urlDecode(value: string): string | null
160
+ } {
161
+ const encode = (value: string) => Buffer.from(value, 'utf8').toString('base64url')
162
+ const decode = (value: string): string | null => {
163
+ const out = Buffer.from(value, 'base64url').toString('utf8')
164
+ return Buffer.from(out, 'utf8').toString('base64url') === value ? out : null
165
+ }
166
+ return { encode, decode, urlEncode: encode, urlDecode: decode }
167
+ }
168
+
169
+ /**
170
+ * Sign and verify WITHOUT encrypting (AdonisJS `Encryption.verifier`).
171
+ *
172
+ * The same `sign` / `unsign` this class already exposes, under the name and
173
+ * shape upstream puts them: an app calling `encryption.verifier.sign(...)`
174
+ * found nothing here.
175
+ */
176
+ get verifier(): {
177
+ sign(payload: unknown, expiresIn?: number, purpose?: string): string
178
+ unsign<T = string>(payload: string, purpose?: string): T | null
179
+ } {
180
+ return {
181
+ sign: (payload, expiresIn, purpose) => this.sign(payload, expiresIn, purpose),
182
+ unsign: <T>(payload: string, purpose?: string) => this.unsign<T>(payload, purpose),
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Another signer on a different secret (AdonisJS `Encryption.child`).
188
+ *
189
+ * What key rotation is built on: keep the old secret's signer around to read
190
+ * values already in the wild while the new one writes.
191
+ */
192
+ child(secret?: string): CookieSigner {
193
+ return new CookieSigner(secret ?? this.#secret)
194
+ }
195
+
196
+ encrypt(value: unknown, expiresIn?: number, purpose?: string): string {
123
197
  const iv = randomBytes(12)
124
198
  const cipher = createCipheriv('aes-256-gcm', this.#keyBuffer, iv)
125
199
  const sealed = this.#seal(value, expiresIn, purpose)
@@ -128,7 +202,7 @@ export class CookieSigner {
128
202
  return `${iv.toString('base64url')}.${encrypted.toString('base64url')}.${tag.toString('base64url')}`
129
203
  }
130
204
 
131
- decrypt(encrypted: string, purpose?: string): string | null {
205
+ decrypt<T = string>(encrypted: string, purpose?: string): T | null {
132
206
  const parts = encrypted.split('.')
133
207
  if (parts.length !== 3) return null
134
208
  try {
@@ -138,7 +212,7 @@ export class CookieSigner {
138
212
  const decipher = createDecipheriv('aes-256-gcm', this.#keyBuffer, iv)
139
213
  decipher.setAuthTag(tag)
140
214
  const raw = Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
141
- return this.#open(raw, purpose)
215
+ return this.#open(raw, purpose) as T | null
142
216
  } catch {
143
217
  return null
144
218
  }