@c9up/ream 0.1.25 → 0.1.27

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 (302) hide show
  1. package/README.md +65 -0
  2. package/dist/ConfigLoader.d.ts +46 -5
  3. package/dist/ConfigLoader.d.ts.map +1 -1
  4. package/dist/ConfigLoader.js +60 -9
  5. package/dist/ConfigLoader.js.map +1 -1
  6. package/dist/HttpKernel.d.ts.map +1 -1
  7. package/dist/HttpKernel.js +46 -6
  8. package/dist/HttpKernel.js.map +1 -1
  9. package/dist/Ignitor.d.ts +115 -7
  10. package/dist/Ignitor.d.ts.map +1 -1
  11. package/dist/Ignitor.js +254 -40
  12. package/dist/Ignitor.js.map +1 -1
  13. package/dist/console/BaseCommand.d.ts +217 -0
  14. package/dist/console/BaseCommand.d.ts.map +1 -0
  15. package/dist/console/BaseCommand.js +340 -0
  16. package/dist/console/BaseCommand.js.map +1 -0
  17. package/dist/console/Console.d.ts +86 -0
  18. package/dist/console/Console.d.ts.map +1 -0
  19. package/dist/console/Console.js +134 -0
  20. package/dist/console/Console.js.map +1 -0
  21. package/dist/console/ExceptionHandler.d.ts +28 -0
  22. package/dist/console/ExceptionHandler.d.ts.map +1 -0
  23. package/dist/console/ExceptionHandler.js +76 -0
  24. package/dist/console/ExceptionHandler.js.map +1 -0
  25. package/dist/console/HelpCommand.d.ts +16 -0
  26. package/dist/console/HelpCommand.d.ts.map +1 -0
  27. package/dist/console/HelpCommand.js +40 -0
  28. package/dist/console/HelpCommand.js.map +1 -0
  29. package/dist/console/IndexGenerator.d.ts +24 -0
  30. package/dist/console/IndexGenerator.d.ts.map +1 -0
  31. package/dist/console/IndexGenerator.js +65 -0
  32. package/dist/console/IndexGenerator.js.map +1 -0
  33. package/dist/console/Kernel.d.ts +298 -0
  34. package/dist/console/Kernel.d.ts.map +1 -0
  35. package/dist/console/Kernel.js +1120 -0
  36. package/dist/console/Kernel.js.map +1 -0
  37. package/dist/console/ListCommand.d.ts +18 -0
  38. package/dist/console/ListCommand.d.ts.map +1 -0
  39. package/dist/console/ListCommand.js +60 -0
  40. package/dist/console/ListCommand.js.map +1 -0
  41. package/dist/console/assertions.d.ts +20 -0
  42. package/dist/console/assertions.d.ts.map +1 -0
  43. package/dist/console/assertions.js +84 -0
  44. package/dist/console/assertions.js.map +1 -0
  45. package/dist/console/cliui.d.ts +248 -0
  46. package/dist/console/cliui.d.ts.map +1 -0
  47. package/dist/console/cliui.js +587 -0
  48. package/dist/console/cliui.js.map +1 -0
  49. package/dist/console/decorators.d.ts +90 -0
  50. package/dist/console/decorators.d.ts.map +1 -0
  51. package/dist/console/decorators.js +142 -0
  52. package/dist/console/decorators.js.map +1 -0
  53. package/dist/console/index.d.ts +32 -0
  54. package/dist/console/index.d.ts.map +1 -0
  55. package/dist/console/index.js +24 -0
  56. package/dist/console/index.js.map +1 -0
  57. package/dist/console/loaders.d.ts +52 -0
  58. package/dist/console/loaders.d.ts.map +1 -0
  59. package/dist/console/loaders.js +131 -0
  60. package/dist/console/loaders.js.map +1 -0
  61. package/dist/console/parser.d.ts +86 -0
  62. package/dist/console/parser.d.ts.map +1 -0
  63. package/dist/console/parser.js +440 -0
  64. package/dist/console/parser.js.map +1 -0
  65. package/dist/console/prompts.d.ts +124 -0
  66. package/dist/console/prompts.d.ts.map +1 -0
  67. package/dist/console/prompts.js +356 -0
  68. package/dist/console/prompts.js.map +1 -0
  69. package/dist/console/tracing.d.ts +17 -0
  70. package/dist/console/tracing.d.ts.map +1 -0
  71. package/dist/console/tracing.js +11 -0
  72. package/dist/console/tracing.js.map +1 -0
  73. package/dist/console/types.d.ts +255 -0
  74. package/dist/console/types.d.ts.map +1 -0
  75. package/dist/console/types.js +25 -0
  76. package/dist/console/types.js.map +1 -0
  77. package/dist/console/ui.d.ts +20 -0
  78. package/dist/console/ui.d.ts.map +1 -0
  79. package/dist/console/ui.js +31 -0
  80. package/dist/console/ui.js.map +1 -0
  81. package/dist/console/utils.d.ts +27 -0
  82. package/dist/console/utils.d.ts.map +1 -0
  83. package/dist/console/utils.js +56 -0
  84. package/dist/console/utils.js.map +1 -0
  85. package/dist/container/Container.d.ts +23 -3
  86. package/dist/container/Container.d.ts.map +1 -1
  87. package/dist/container/Container.js +177 -70
  88. package/dist/container/Container.js.map +1 -1
  89. package/dist/container/types.d.ts +14 -2
  90. package/dist/container/types.d.ts.map +1 -1
  91. package/dist/decorators/Service.d.ts +7 -0
  92. package/dist/decorators/Service.d.ts.map +1 -1
  93. package/dist/decorators/Service.js +23 -3
  94. package/dist/decorators/Service.js.map +1 -1
  95. package/dist/env/Env.d.ts +29 -4
  96. package/dist/env/Env.d.ts.map +1 -1
  97. package/dist/env/Env.js +27 -4
  98. package/dist/env/Env.js.map +1 -1
  99. package/dist/env/Secret.d.ts +11 -0
  100. package/dist/env/Secret.d.ts.map +1 -0
  101. package/dist/env/Secret.js +36 -0
  102. package/dist/env/Secret.js.map +1 -0
  103. package/dist/env/interpolate.d.ts +26 -0
  104. package/dist/env/interpolate.d.ts.map +1 -0
  105. package/dist/env/interpolate.js +48 -0
  106. package/dist/env/interpolate.js.map +1 -0
  107. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  108. package/dist/env/loadEnvFiles.js +14 -4
  109. package/dist/env/loadEnvFiles.js.map +1 -1
  110. package/dist/env/schema.d.ts +23 -6
  111. package/dist/env/schema.d.ts.map +1 -1
  112. package/dist/env/schema.js +67 -33
  113. package/dist/env/schema.js.map +1 -1
  114. package/dist/errors/ReamError.d.ts +25 -0
  115. package/dist/errors/ReamError.d.ts.map +1 -1
  116. package/dist/errors/ReamError.js +5 -0
  117. package/dist/errors/ReamError.js.map +1 -1
  118. package/dist/errors/prettyPrintError.d.ts +9 -0
  119. package/dist/errors/prettyPrintError.d.ts.map +1 -0
  120. package/dist/errors/prettyPrintError.js +22 -0
  121. package/dist/errors/prettyPrintError.js.map +1 -0
  122. package/dist/events/Emitter.d.ts +1 -1
  123. package/dist/events/Emitter.d.ts.map +1 -1
  124. package/dist/events/Emitter.js +1 -1
  125. package/dist/events/Emitter.js.map +1 -1
  126. package/dist/events/EventsProvider.d.ts +2 -2
  127. package/dist/events/EventsProvider.d.ts.map +1 -1
  128. package/dist/events/EventsProvider.js +3 -3
  129. package/dist/events/EventsProvider.js.map +1 -1
  130. package/dist/graphql/GraphQLEngine.d.ts +1 -1
  131. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  132. package/dist/graphql/GraphQLEngine.js +2 -2
  133. package/dist/graphql/GraphQLEngine.js.map +1 -1
  134. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  135. package/dist/graphql/GraphQLProvider.js +7 -3
  136. package/dist/graphql/GraphQLProvider.js.map +1 -1
  137. package/dist/http/Exception.d.ts +63 -1
  138. package/dist/http/Exception.d.ts.map +1 -1
  139. package/dist/http/Exception.js +185 -48
  140. package/dist/http/Exception.js.map +1 -1
  141. package/dist/http/HttpContext.d.ts +53 -7
  142. package/dist/http/HttpContext.d.ts.map +1 -1
  143. package/dist/http/HttpContext.js +56 -15
  144. package/dist/http/HttpContext.js.map +1 -1
  145. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  146. package/dist/http/RedirectBuilder.js +4 -1
  147. package/dist/http/RedirectBuilder.js.map +1 -1
  148. package/dist/http/Request.d.ts +141 -10
  149. package/dist/http/Request.d.ts.map +1 -1
  150. package/dist/http/Request.js +244 -34
  151. package/dist/http/Request.js.map +1 -1
  152. package/dist/http/Response.d.ts +176 -9
  153. package/dist/http/Response.d.ts.map +1 -1
  154. package/dist/http/Response.js +404 -16
  155. package/dist/http/Response.js.map +1 -1
  156. package/dist/index.d.ts +19 -8
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +20 -5
  159. package/dist/index.js.map +1 -1
  160. package/dist/middleware/Pipeline.d.ts +29 -8
  161. package/dist/middleware/Pipeline.d.ts.map +1 -1
  162. package/dist/middleware/Pipeline.js +40 -2
  163. package/dist/middleware/Pipeline.js.map +1 -1
  164. package/dist/openapi/OpenApiProvider.js +4 -4
  165. package/dist/openapi/OpenApiProvider.js.map +1 -1
  166. package/dist/router/Router.d.ts +223 -31
  167. package/dist/router/Router.d.ts.map +1 -1
  168. package/dist/router/Router.js +520 -102
  169. package/dist/router/Router.js.map +1 -1
  170. package/dist/rpc/RpcProvider.d.ts +1 -0
  171. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  172. package/dist/rpc/RpcProvider.js +7 -3
  173. package/dist/rpc/RpcProvider.js.map +1 -1
  174. package/dist/rpc/RpcRouter.d.ts +2 -2
  175. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  176. package/dist/rpc/RpcRouter.js +17 -5
  177. package/dist/rpc/RpcRouter.js.map +1 -1
  178. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  179. package/dist/scheduler/ScheduleProvider.js +7 -15
  180. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  181. package/dist/server/Server.d.ts +17 -4
  182. package/dist/server/Server.d.ts.map +1 -1
  183. package/dist/server/Server.js +41 -7
  184. package/dist/server/Server.js.map +1 -1
  185. package/dist/services/app.d.ts +1 -1
  186. package/dist/services/app.js +1 -1
  187. package/dist/services/console.d.ts +21 -0
  188. package/dist/services/console.d.ts.map +1 -0
  189. package/dist/services/console.js +35 -0
  190. package/dist/services/console.js.map +1 -0
  191. package/dist/session/SessionMiddleware.js +3 -3
  192. package/dist/session/SessionMiddleware.js.map +1 -1
  193. package/dist/storage/StaticProvider.js +1 -1
  194. package/dist/storage/StaticProvider.js.map +1 -1
  195. package/dist/testing/ApiResponse.d.ts +218 -0
  196. package/dist/testing/ApiResponse.d.ts.map +1 -0
  197. package/dist/testing/ApiResponse.js +576 -0
  198. package/dist/testing/ApiResponse.js.map +1 -0
  199. package/dist/testing/RequestBuilder.d.ts +155 -35
  200. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  201. package/dist/testing/RequestBuilder.js +455 -109
  202. package/dist/testing/RequestBuilder.js.map +1 -1
  203. package/dist/testing/TestClient.d.ts +53 -37
  204. package/dist/testing/TestClient.d.ts.map +1 -1
  205. package/dist/testing/TestClient.js +84 -52
  206. package/dist/testing/TestClient.js.map +1 -1
  207. package/dist/utils/Macroable.d.ts +30 -0
  208. package/dist/utils/Macroable.d.ts.map +1 -0
  209. package/dist/utils/Macroable.js +43 -0
  210. package/dist/utils/Macroable.js.map +1 -0
  211. package/dist/utils/inflect.d.ts +17 -0
  212. package/dist/utils/inflect.d.ts.map +1 -0
  213. package/dist/utils/inflect.js +32 -0
  214. package/dist/utils/inflect.js.map +1 -0
  215. package/dist/utils/objectPath.d.ts +57 -0
  216. package/dist/utils/objectPath.d.ts.map +1 -0
  217. package/dist/utils/objectPath.js +141 -0
  218. package/dist/utils/objectPath.js.map +1 -0
  219. package/events.darwin-arm64.node +0 -0
  220. package/events.darwin-x64.node +0 -0
  221. package/events.linux-arm64-gnu.node +0 -0
  222. package/events.linux-x64-gnu.node +0 -0
  223. package/events.win32-x64-msvc.node +0 -0
  224. package/index.darwin-arm64.node +0 -0
  225. package/index.darwin-x64.node +0 -0
  226. package/index.linux-arm64-gnu.node +0 -0
  227. package/index.linux-x64-gnu.node +0 -0
  228. package/index.win32-x64-msvc.node +0 -0
  229. package/package.json +20 -2
  230. package/scheduler.darwin-arm64.node +0 -0
  231. package/scheduler.darwin-x64.node +0 -0
  232. package/scheduler.linux-arm64-gnu.node +0 -0
  233. package/scheduler.linux-x64-gnu.node +0 -0
  234. package/scheduler.win32-x64-msvc.node +0 -0
  235. package/src/ConfigLoader.ts +72 -9
  236. package/src/HttpKernel.ts +53 -15
  237. package/src/Ignitor.ts +364 -45
  238. package/src/console/BaseCommand.ts +467 -0
  239. package/src/console/Console.ts +174 -0
  240. package/src/console/ExceptionHandler.ts +91 -0
  241. package/src/console/HelpCommand.ts +37 -0
  242. package/src/console/IndexGenerator.ts +79 -0
  243. package/src/console/Kernel.ts +1382 -0
  244. package/src/console/ListCommand.ts +55 -0
  245. package/src/console/assertions.ts +114 -0
  246. package/src/console/cliui.ts +756 -0
  247. package/src/console/decorators.ts +199 -0
  248. package/src/console/index.ts +67 -0
  249. package/src/console/loaders.ts +145 -0
  250. package/src/console/parser.ts +598 -0
  251. package/src/console/prompts.ts +548 -0
  252. package/src/console/tracing.ts +22 -0
  253. package/src/console/types.ts +280 -0
  254. package/src/console/ui.ts +37 -0
  255. package/src/console/utils.ts +69 -0
  256. package/src/container/Container.ts +198 -72
  257. package/src/container/types.ts +15 -2
  258. package/src/decorators/Service.ts +28 -3
  259. package/src/env/Env.ts +49 -5
  260. package/src/env/Secret.ts +41 -0
  261. package/src/env/interpolate.ts +54 -0
  262. package/src/env/loadEnvFiles.ts +12 -4
  263. package/src/env/schema.ts +109 -35
  264. package/src/errors/ReamError.ts +31 -0
  265. package/src/errors/prettyPrintError.ts +20 -0
  266. package/src/events/Emitter.ts +2 -2
  267. package/src/events/EventsProvider.ts +5 -5
  268. package/src/graphql/GraphQLEngine.ts +4 -4
  269. package/src/graphql/GraphQLProvider.ts +7 -3
  270. package/src/http/Exception.ts +214 -37
  271. package/src/http/HttpContext.ts +87 -18
  272. package/src/http/RedirectBuilder.ts +4 -1
  273. package/src/http/Request.ts +264 -34
  274. package/src/http/Response.ts +478 -27
  275. package/src/index.ts +41 -6
  276. package/src/middleware/Pipeline.ts +77 -6
  277. package/src/openapi/OpenApiProvider.ts +4 -4
  278. package/src/router/Router.ts +631 -113
  279. package/src/rpc/RpcProvider.ts +6 -2
  280. package/src/rpc/RpcRouter.ts +22 -7
  281. package/src/scheduler/ScheduleProvider.ts +7 -15
  282. package/src/server/Server.ts +49 -10
  283. package/src/services/app.ts +1 -1
  284. package/src/services/console.ts +42 -0
  285. package/src/session/SessionMiddleware.ts +4 -4
  286. package/src/storage/StaticProvider.ts +1 -1
  287. package/src/testing/ApiResponse.ts +691 -0
  288. package/src/testing/RequestBuilder.ts +577 -137
  289. package/src/testing/TestClient.ts +110 -77
  290. package/src/utils/Macroable.ts +54 -0
  291. package/src/utils/inflect.ts +29 -0
  292. package/src/utils/objectPath.ts +142 -0
  293. package/dist/console/CommandRunner.d.ts +0 -18
  294. package/dist/console/CommandRunner.d.ts.map +0 -1
  295. package/dist/console/CommandRunner.js +0 -65
  296. package/dist/console/CommandRunner.js.map +0 -1
  297. package/dist/decorators/Lazy.d.ts +0 -19
  298. package/dist/decorators/Lazy.d.ts.map +0 -1
  299. package/dist/decorators/Lazy.js +0 -53
  300. package/dist/decorators/Lazy.js.map +0 -1
  301. package/src/console/CommandRunner.ts +0 -82
  302. package/src/decorators/Lazy.ts +0 -57
@@ -7,8 +7,21 @@
7
7
  import type { HttpContext } from '../http/HttpContext.js'
8
8
  import { safeDecodeURIComponent } from '../http/urlDecode.js'
9
9
  import type { MiddlewareFunction } from '../middleware/Pipeline.js'
10
+ import type { SignedUrl } from '../security/SignedUrl.js'
10
11
  import type { MiddlewareEntry } from '../server/Server.js'
11
- import { resolveMiddlewareEntry } from '../server/Server.js'
12
+ import { resolveMiddlewareEntry, resolveParametrizedMiddlewareEntry } from '../server/Server.js'
13
+ import { singular, snakeCase } from '../utils/inflect.js'
14
+ import { Macroable } from '../utils/Macroable.js'
15
+
16
+ /** Options for {@link Router.makeSignedUrl}. */
17
+ export interface SignedUrlOptions {
18
+ /** Extra query-string params folded into the signature. */
19
+ qs?: Record<string, string>
20
+ /** Lifetime before the link expires — seconds (number) or a `'30m'`/`'2h'`/`'1d'` string. */
21
+ expiresIn?: string | number
22
+ /** Namespaces the signature so a URL signed for one purpose can't be reused for another. */
23
+ purpose?: string
24
+ }
12
25
 
13
26
  // ─── Types ──────────────────────────────────────────────────
14
27
 
@@ -22,8 +35,21 @@ export type RouteHandlerFunction = (ctx: HttpContext) => Promise<void> | void
22
35
  // biome-ignore lint/suspicious/noExplicitAny: required — TypeScript contravariance makes it impossible to type "any constructor" without `any`
23
36
  export type ControllerAction = [target: new (...args: any[]) => any, method: string]
24
37
 
25
- /** A route handler is either a closure or a controller tuple. */
26
- export type RouteHandler = RouteHandlerFunction | ControllerAction
38
+ /** Any controller class (derived from {@link ControllerAction} — no fresh `any`). */
39
+ export type ControllerConstructor = ControllerAction[0]
40
+
41
+ /** A lazy controller import: `() => import('#controllers/users_controller')`. */
42
+ export type LazyControllerImport = () => Promise<{ default: ControllerConstructor }>
43
+
44
+ /** Lazy controller tuple: [() => import(...), 'methodName']. */
45
+ export type LazyControllerAction = [loader: LazyControllerImport, method: string]
46
+
47
+ /**
48
+ * A route handler is a closure, a controller tuple, a lazy-import tuple, or a
49
+ * magic string reference `'ControllerName.method'` resolved through the
50
+ * router's controller registry ({@link Router.controllers}).
51
+ */
52
+ export type RouteHandler = RouteHandlerFunction | ControllerAction | LazyControllerAction | string
27
53
 
28
54
  /** Param matcher — regex or predefined matcher. */
29
55
  export type ParamMatcher = RegExp | { pattern: RegExp }
@@ -45,6 +71,77 @@ export interface RouteDefinition {
45
71
  domain?: string
46
72
  matchers: Record<string, ParamMatcher>
47
73
  deprecates?: { version: string; sunset?: string }
74
+ /**
75
+ * Soft-delete flag (AdonisJS `route.markAsDeleted()`). A resource's
76
+ * `.only()`/`.except()`/`.apiOnly()` mark filtered-out routes deleted rather
77
+ * than splicing them, so the builder chain stays intact. Deleted routes are
78
+ * skipped by the match index and `getRoutes()`.
79
+ */
80
+ deleted?: boolean
81
+ /**
82
+ * Lazily-imported controller (from a `[() => import(...), 'method']` tuple or
83
+ * a resolved string reference). The HttpKernel awaits the loader on first
84
+ * request and promotes it to {@link controller}, so @Guard metadata + DI
85
+ * resolution flow through the normal controller path.
86
+ */
87
+ lazyController?: { loader: LazyControllerImport; method: string }
88
+ /**
89
+ * Unresolved `'ControllerName.method'` string reference. Resolved to
90
+ * {@link lazyController} against the router's controller registry when the
91
+ * route index is (re)built — a reference to an unregistered controller is a
92
+ * hard error there.
93
+ */
94
+ stringRef?: { controller: string; method: string }
95
+ }
96
+
97
+ /**
98
+ * Distinguish a lazy-import loader from a controller class in a handler tuple.
99
+ * Arrow/plain function loaders (`() => import(...)`) have no own `prototype`
100
+ * property; class constructors do.
101
+ */
102
+ function isLazyControllerLoader(
103
+ first: ControllerConstructor | LazyControllerImport,
104
+ ): first is LazyControllerImport {
105
+ return !('prototype' in first)
106
+ }
107
+
108
+ /** Build a route definition from a method, path, and handler. */
109
+ function makeDefinition(method: string, path: string, handler: RouteHandler): RouteDefinition {
110
+ const def: RouteDefinition = {
111
+ method: method.toUpperCase(),
112
+ path,
113
+ handler: null,
114
+ middleware: [],
115
+ inlineMiddleware: [],
116
+ guards: [],
117
+ roles: [],
118
+ permissions: [],
119
+ validators: [],
120
+ matchers: {},
121
+ }
122
+
123
+ if (typeof handler === 'string') {
124
+ // Magic string reference 'ControllerName.method' — split on the LAST dot so
125
+ // controller names may themselves be dotted; resolved at #buildIndex time.
126
+ const dot = handler.lastIndexOf('.')
127
+ if (dot <= 0 || dot === handler.length - 1) {
128
+ throw new Error(
129
+ `Invalid controller reference "${handler}" — expected the form "ControllerName.method".`,
130
+ )
131
+ }
132
+ def.stringRef = { controller: handler.slice(0, dot), method: handler.slice(dot + 1) }
133
+ } else if (Array.isArray(handler)) {
134
+ const [first, methodName] = handler
135
+ if (isLazyControllerLoader(first)) {
136
+ def.lazyController = { loader: first, method: methodName }
137
+ } else {
138
+ def.controller = { target: first, method: methodName }
139
+ }
140
+ } else {
141
+ def.handler = handler
142
+ }
143
+
144
+ return def
48
145
  }
49
146
 
50
147
  export interface MatchResult {
@@ -69,56 +166,102 @@ export const matchers = {
69
166
  },
70
167
  }
71
168
 
169
+ /**
170
+ * Normalise a param matcher into the internal `{ pattern }` shape.
171
+ *
172
+ * A string matcher (AdonisJS `.where('id', '[0-9]+')`) is compiled to an
173
+ * anchored RegExp. AdonisJS builds `new RegExp(matcher)` unanchored and relies
174
+ * on `matchit`'s per-segment anchoring; ream tests matchers per-segment without
175
+ * that implicit anchoring (and its predefined matchers are already `^…$`), so
176
+ * anchoring here reproduces AdonisJS's *effective* full-segment semantics.
177
+ */
178
+ function compileMatcher(matcher: ParamMatcher | RegExp | string): ParamMatcher {
179
+ if (typeof matcher === 'string') return { pattern: new RegExp(`^(?:${matcher})$`) }
180
+ return matcher instanceof RegExp ? { pattern: matcher } : matcher
181
+ }
182
+
72
183
  // ─── RouteBuilder ───────────────────────────────────────────
73
184
 
74
- /** Fluent route builder — mutates the already-registered route definition. */
75
- export class RouteBuilder {
76
- #route: RouteDefinition
185
+ /**
186
+ * Fluent route builder — mutates the already-registered route definition(s).
187
+ * Backed by an array so a multi-verb `router.route(['GET','POST'], …)` returns
188
+ * ONE builder that fans every mutation across all its verbs; the single-verb
189
+ * helpers (`get`/`post`/…) back it with a one-element array.
190
+ */
191
+ export class RouteBuilder extends Macroable {
192
+ #routes: RouteDefinition[]
193
+
194
+ constructor(routes: RouteDefinition | RouteDefinition[]) {
195
+ super()
196
+ this.#routes = Array.isArray(routes) ? routes : [routes]
197
+ }
77
198
 
78
- constructor(route: RouteDefinition) {
79
- this.#route = route
199
+ /** Apply `fn` to every backing definition, returning `this` for chaining. */
200
+ #each(fn: (route: RouteDefinition) => void): this {
201
+ for (const route of this.#routes) fn(route)
202
+ return this
203
+ }
204
+
205
+ /** The primary definition — accessors read from it (all verbs share pattern/name). */
206
+ #primary(): RouteDefinition {
207
+ return this.#routes[0]
80
208
  }
81
209
 
82
210
  /** Name this route (for URL generation and redirects). */
83
211
  as(name: string): this {
84
- this.#route.name = name
85
- return this
212
+ return this.#each((route) => {
213
+ route.name = name
214
+ })
215
+ }
216
+
217
+ /** Prepend a URL prefix to this single route (AdonisJS route-level `prefix`). */
218
+ prefix(prefix: string): this {
219
+ return this.#each((route) => {
220
+ route.path = prefix + route.path
221
+ })
86
222
  }
87
223
 
88
224
  /** Add named middleware. */
89
225
  middleware(...names: string[]): this {
90
- this.#route.middleware.push(...names)
91
- return this
226
+ return this.#each((route) => {
227
+ route.middleware.push(...names)
228
+ })
92
229
  }
93
230
 
94
231
  /** Add inline middleware functions. */
95
232
  use(...mw: MiddlewareFunction[]): this {
96
- this.#route.inlineMiddleware.push(...mw)
97
- return this
233
+ return this.#each((route) => {
234
+ route.inlineMiddleware.push(...mw)
235
+ })
98
236
  }
99
237
 
100
238
  /** Add authentication guards. */
101
239
  guard(...guards: string[]): this {
102
- this.#route.guards.push(...guards)
103
- return this
240
+ return this.#each((route) => {
241
+ route.guards.push(...guards)
242
+ })
104
243
  }
105
244
 
106
245
  /** Require specific roles. */
107
246
  role(...roles: string[]): this {
108
- this.#route.roles.push(...roles)
109
- return this
247
+ return this.#each((route) => {
248
+ route.roles.push(...roles)
249
+ })
110
250
  }
111
251
 
112
252
  /** Require specific permissions. */
113
253
  permission(...permissions: string[]): this {
114
- this.#route.permissions.push(...permissions)
115
- return this
254
+ return this.#each((route) => {
255
+ route.permissions.push(...permissions)
256
+ })
116
257
  }
117
258
 
118
- /** Add a param constraint. */
119
- where(param: string, matcher: ParamMatcher | RegExp): this {
120
- this.#route.matchers[param] = matcher instanceof RegExp ? { pattern: matcher } : matcher
121
- return this
259
+ /** Add a param constraint — RegExp, predefined matcher, or a string pattern. */
260
+ where(param: string, matcher: ParamMatcher | RegExp | string): this {
261
+ const compiled = compileMatcher(matcher)
262
+ return this.#each((route) => {
263
+ route.matchers[param] = compiled
264
+ })
122
265
  }
123
266
 
124
267
  /**
@@ -137,41 +280,75 @@ export class RouteBuilder {
137
280
  * An unregistered name is a hard error — validation is never silently skipped.
138
281
  */
139
282
  validate(validator: string): this {
140
- this.#route.validators.push(validator)
141
- return this
283
+ return this.#each((route) => {
284
+ route.validators.push(validator)
285
+ })
142
286
  }
143
287
 
144
288
  /** Set API version. */
145
289
  version(v: string): this {
146
- this.#route.version = v
147
- return this
290
+ return this.#each((route) => {
291
+ route.version = v
292
+ })
148
293
  }
149
294
 
150
295
  /** Restrict to a specific domain. */
151
296
  domain(d: string): this {
152
- this.#route.domain = d
153
- return this
297
+ return this.#each((route) => {
298
+ route.domain = d
299
+ })
154
300
  }
155
301
 
156
302
  /** Mark as deprecated. */
157
303
  deprecates(version: string, options?: { sunset?: string }): this {
158
- this.#route.deprecates = { version, sunset: options?.sunset }
159
- return this
304
+ return this.#each((route) => {
305
+ route.deprecates = { version, sunset: options?.sunset }
306
+ })
307
+ }
308
+
309
+ /** The route's name, or undefined when unnamed (AdonisJS `route.getName`). */
310
+ getName(): string | undefined {
311
+ return this.#primary().name
160
312
  }
161
313
 
162
- /** @internal Get the underlying definition. */
314
+ /** The route's URL pattern (AdonisJS `route.getPattern`). */
315
+ getPattern(): string {
316
+ return this.#primary().path
317
+ }
318
+
319
+ /** Replace the route's URL pattern (AdonisJS `route.setPattern`). */
320
+ setPattern(pattern: string): this {
321
+ return this.#each((route) => {
322
+ route.path = pattern
323
+ })
324
+ }
325
+
326
+ /** Soft-delete this route so it stops matching (AdonisJS `route.markAsDeleted`). */
327
+ markAsDeleted(): this {
328
+ return this.#each((route) => {
329
+ route.deleted = true
330
+ })
331
+ }
332
+
333
+ /** True when the route has been soft-deleted (AdonisJS `route.isDeleted`). */
334
+ isDeleted(): boolean {
335
+ return this.#primary().deleted === true
336
+ }
337
+
338
+ /** @internal Get the underlying (primary) definition. */
163
339
  getDefinition(): RouteDefinition {
164
- return this.#route
340
+ return this.#primary()
165
341
  }
166
342
  }
167
343
 
168
344
  // ─── GroupBuilder ───────────────────────────────────────────
169
345
 
170
346
  /** Fluent group builder — returned by router.group(callback). */
171
- export class GroupBuilder {
347
+ export class GroupBuilder extends Macroable {
172
348
  #routes: RouteDefinition[]
173
349
 
174
350
  constructor(routes: RouteDefinition[]) {
351
+ super()
175
352
  this.#routes = routes
176
353
  }
177
354
 
@@ -207,12 +384,19 @@ export class GroupBuilder {
207
384
  return this
208
385
  }
209
386
 
210
- /** Prefix route names for all routes in the group. */
387
+ /**
388
+ * Prefix route names for all routes in the group (AdonisJS `group.as`).
389
+ * Every route MUST already be named — prefixing an unnamed route is
390
+ * meaningless, so it throws rather than silently skipping (AdonisJS parity).
391
+ */
211
392
  as(namePrefix: string): this {
212
393
  for (const route of this.#routes) {
213
- if (route.name) {
214
- route.name = `${namePrefix}.${route.name}`
394
+ if (!route.name) {
395
+ throw new Error(
396
+ `[E_MISSING_ROUTE_NAME] Cannot apply group name "${namePrefix}": route ${route.method} ${route.path} has no name. Name every route in a named group (\`.as(...)\`), or move the unnamed route out of the group.`,
397
+ )
215
398
  }
399
+ route.name = `${namePrefix}.${route.name}`
216
400
  }
217
401
  return this
218
402
  }
@@ -226,8 +410,8 @@ export class GroupBuilder {
226
410
  }
227
411
 
228
412
  /** Add param matcher to all routes in the group. */
229
- where(param: string, matcher: ParamMatcher | RegExp): this {
230
- const m = matcher instanceof RegExp ? { pattern: matcher } : matcher
413
+ where(param: string, matcher: ParamMatcher | RegExp | string): this {
414
+ const m = compileMatcher(matcher)
231
415
  for (const route of this.#routes) {
232
416
  if (!(param in route.matchers)) {
233
417
  route.matchers[param] = m
@@ -237,6 +421,206 @@ export class GroupBuilder {
237
421
  }
238
422
  }
239
423
 
424
+ // ─── RouteResource ──────────────────────────────────────────
425
+
426
+ /** Resourceful action names (AdonisJS resource). */
427
+ export type ResourceAction = 'index' | 'create' | 'store' | 'show' | 'edit' | 'update' | 'destroy'
428
+
429
+ /** Middleware accepted per-action on a resource: a named string or an inline fn. */
430
+ type ResourceMiddleware = string | MiddlewareFunction
431
+
432
+ /**
433
+ * Resourceful route builder — returned by `router.resource()` /
434
+ * `router.shallowResource()` (AdonisJS `RouteResource` parity).
435
+ *
436
+ * Generates the seven RESTful routes (index, create, store, show, edit,
437
+ * update, destroy — `update` registered for both PUT and PATCH) and exposes
438
+ * the fluent filtering/config API: `only`/`except`/`apiOnly`/`params`/`tap`/
439
+ * `where`/`use`/`middleware`/`as`. Nested resources use dot-notation
440
+ * (`posts.comments` → `/posts/:post_id/comments/:id`).
441
+ */
442
+ export class RouteResource extends Macroable {
443
+ #resource: string
444
+ #controller: ControllerAction[0]
445
+ #shallow: boolean
446
+ #register: (def: RouteDefinition) => void
447
+ #markDirty: () => void
448
+ /** Resource name → param placeholder (`comments` → `:id`, `posts` → `:post_id`). */
449
+ #params: Record<string, string> = {}
450
+ #baseName: string
451
+
452
+ /** The generated routes, in declaration order (AdonisJS `resource.routes`). */
453
+ readonly routes: RouteBuilder[] = []
454
+
455
+ constructor(options: {
456
+ resource: string
457
+ controller: ControllerAction[0]
458
+ shallow: boolean
459
+ register: (def: RouteDefinition) => void
460
+ markDirty: () => void
461
+ }) {
462
+ super()
463
+ const resource = options.resource.replace(/^\//, '').replace(/\/$/, '')
464
+ if (!resource) throw new Error(`Invalid resource name "${options.resource}"`)
465
+ this.#resource = resource
466
+ this.#controller = options.controller
467
+ this.#shallow = options.shallow
468
+ this.#register = options.register
469
+ this.#markDirty = options.markDirty
470
+ this.#baseName = resource.split('.').map(snakeCase).join('.')
471
+ this.#buildRoutes()
472
+ }
473
+
474
+ #createRoute(pattern: string, method: string, action: ResourceAction): void {
475
+ const def = makeDefinition(method, `/${pattern}`, [this.#controller, action])
476
+ def.name = `${this.#baseName}.${action}`
477
+ this.#register(def)
478
+ this.routes.push(new RouteBuilder(def))
479
+ }
480
+
481
+ #buildRoutes(): void {
482
+ const segments = this.#resource.split('.')
483
+ const main = segments.pop() ?? this.#resource
484
+ this.#params[main] = ':id'
485
+
486
+ const parentPath = segments
487
+ .map((parent) => {
488
+ const param = `:${snakeCase(singular(parent))}_id`
489
+ this.#params[parent] = param
490
+ return `${parent}/${param}`
491
+ })
492
+ .join('/')
493
+
494
+ const baseURI = parentPath ? `${parentPath}/${main}` : main
495
+ // Shallow member routes drop the parent prefix — the id alone identifies
496
+ // the record (AdonisJS shallow resources).
497
+ const memberBase = this.#shallow ? main : baseURI
498
+
499
+ this.#createRoute(baseURI, 'GET', 'index')
500
+ this.#createRoute(`${baseURI}/create`, 'GET', 'create')
501
+ this.#createRoute(baseURI, 'POST', 'store')
502
+ this.#createRoute(`${memberBase}/:id`, 'GET', 'show')
503
+ this.#createRoute(`${memberBase}/:id/edit`, 'GET', 'edit')
504
+ this.#createRoute(`${memberBase}/:id`, 'PUT', 'update')
505
+ this.#createRoute(`${memberBase}/:id`, 'PATCH', 'update')
506
+ this.#createRoute(`${memberBase}/:id`, 'DELETE', 'destroy')
507
+ }
508
+
509
+ /** Routes whose name ends with one of `names` (the action suffix). */
510
+ #matching(names: string[]): RouteBuilder[] {
511
+ return this.routes.filter((route) => {
512
+ const name = route.getName()
513
+ return name !== undefined && names.some((action) => name.endsWith(action))
514
+ })
515
+ }
516
+
517
+ /** Keep only the given actions; soft-delete the rest (AdonisJS `only`). */
518
+ only(names: ResourceAction[]): this {
519
+ const keep = new Set(this.#matching(names))
520
+ for (const route of this.routes) if (!keep.has(route)) route.markAsDeleted()
521
+ this.#markDirty()
522
+ return this
523
+ }
524
+
525
+ /** Register all actions except the given ones (AdonisJS `except`). */
526
+ except(names: ResourceAction[]): this {
527
+ for (const route of this.#matching(names)) route.markAsDeleted()
528
+ this.#markDirty()
529
+ return this
530
+ }
531
+
532
+ /** Drop the form-rendering `create` + `edit` actions (AdonisJS `apiOnly`). */
533
+ apiOnly(): this {
534
+ return this.except(['create', 'edit'])
535
+ }
536
+
537
+ /** Constrain a param across every action route (AdonisJS `resource.where`). */
538
+ where(key: string, matcher: ParamMatcher | RegExp | string): this {
539
+ for (const route of this.routes) route.where(key, matcher)
540
+ return this
541
+ }
542
+
543
+ /**
544
+ * Configure the resource's routes. `tap(cb)` runs `cb` for every non-deleted
545
+ * route; `tap(actions, cb)` only for routes matching `actions` (AdonisJS `tap`).
546
+ */
547
+ tap(callback: (route: RouteBuilder) => void): this
548
+ tap(actions: ResourceAction | ResourceAction[], callback: (route: RouteBuilder) => void): this
549
+ tap(
550
+ actionsOrCallback: ResourceAction | ResourceAction[] | ((route: RouteBuilder) => void),
551
+ callback?: (route: RouteBuilder) => void,
552
+ ): this {
553
+ if (typeof actionsOrCallback === 'function') {
554
+ for (const route of this.routes) if (!route.isDeleted()) actionsOrCallback(route)
555
+ return this
556
+ }
557
+ const names = Array.isArray(actionsOrCallback) ? actionsOrCallback : [actionsOrCallback]
558
+ if (callback) {
559
+ for (const route of this.#matching(names)) if (!route.isDeleted()) callback(route)
560
+ }
561
+ return this
562
+ }
563
+
564
+ /** Rename the `:id` param of one or more resources (AdonisJS `params`). */
565
+ params(resources: Record<string, string>): this {
566
+ for (const [resource, param] of Object.entries(resources)) {
567
+ const existing = this.#params[resource]
568
+ if (!existing) continue
569
+ this.#params[resource] = `:${param}`
570
+ for (const route of this.routes) {
571
+ route.setPattern(
572
+ route.getPattern().replace(`${resource}/${existing}`, `${resource}/:${param}`),
573
+ )
574
+ }
575
+ }
576
+ this.#markDirty()
577
+ return this
578
+ }
579
+
580
+ /**
581
+ * Attach middleware to specific actions (or `'*'` for all) — AdonisJS `use`.
582
+ * Named middleware are strings; inline middleware are functions.
583
+ */
584
+ use(
585
+ actions: ResourceAction | ResourceAction[] | '*',
586
+ middleware: ResourceMiddleware | ResourceMiddleware[],
587
+ ): this {
588
+ const list = Array.isArray(middleware) ? middleware : [middleware]
589
+ const apply = (route: RouteBuilder): void => {
590
+ for (const mw of list) {
591
+ if (typeof mw === 'string') route.middleware(mw)
592
+ else route.use(mw)
593
+ }
594
+ }
595
+ if (actions === '*') this.tap(apply)
596
+ else this.tap(actions, apply)
597
+ return this
598
+ }
599
+
600
+ /** Alias for {@link RouteResource.use} (AdonisJS `middleware`). */
601
+ middleware(
602
+ actions: ResourceAction | ResourceAction[] | '*',
603
+ middleware: ResourceMiddleware | ResourceMiddleware[],
604
+ ): this {
605
+ return this.use(actions, middleware)
606
+ }
607
+
608
+ /**
609
+ * Rename the base of every route name (AdonisJS `as`). `normalizeName`
610
+ * snake_cases the new base by default.
611
+ */
612
+ as(name: string, normalizeName = true): this {
613
+ const newBase = normalizeName ? snakeCase(name) : name
614
+ for (const route of this.routes) {
615
+ const current = route.getName()
616
+ if (current) route.as(current.replace(this.#baseName, newBase))
617
+ }
618
+ this.#baseName = newBase
619
+ this.#markDirty()
620
+ return this
621
+ }
622
+ }
623
+
240
624
  // ─── View engine helper ───────────────────────────────────────
241
625
 
242
626
  interface ViewEngine {
@@ -254,7 +638,7 @@ function isViewEngine(value: unknown): value is ViewEngine {
254
638
 
255
639
  // ─── OnRouteBuilder ─────────────────────────────────────────
256
640
 
257
- /** Builder for on(path).render(view) and on(path).redirect(target). */
641
+ /** Builder for on(path).render(view) and on(path).redirect(namedRoute). */
258
642
  export class OnRouteBuilder {
259
643
  #router: Router
260
644
  #path: string
@@ -277,17 +661,21 @@ export class OnRouteBuilder {
277
661
  })
278
662
  }
279
663
 
280
- /** Redirect to a path. */
281
- redirect(target: string, status = 302): RouteBuilder {
664
+ /**
665
+ * Redirect to a NAMED route (AdonisJS brisk `redirect`). Mirrors AdonisJS:
666
+ * the first argument is a route name, not a path — use {@link redirectToPath}
667
+ * for a fixed URL.
668
+ */
669
+ redirect(name: string, params?: Record<string, string>, status = 302): RouteBuilder {
282
670
  return this.#router.get(this.#path, async (ctx) => {
283
- ctx.response.redirect().status(status).toPath(target)
671
+ ctx.response.redirect().status(status).toRoute(name, params)
284
672
  })
285
673
  }
286
674
 
287
- /** Redirect to a named route. */
288
- redirectToRoute(name: string, params?: Record<string, string>, status = 302): RouteBuilder {
675
+ /** Redirect to a fixed path/URL (AdonisJS brisk `redirectToPath`). */
676
+ redirectToPath(target: string, status = 302): RouteBuilder {
289
677
  return this.#router.get(this.#path, async (ctx) => {
290
- ctx.response.redirect().status(status).toRoute(name, params)
678
+ ctx.response.redirect().status(status).toPath(target)
291
679
  })
292
680
  }
293
681
  }
@@ -295,11 +683,15 @@ export class OnRouteBuilder {
295
683
  // ─── Router ─────────────────────────────────────────────────
296
684
 
297
685
  /** Main Router. */
298
- export class Router {
686
+ export class Router extends Macroable {
299
687
  #routes: RouteDefinition[] = []
300
688
  #globalMatchers: Record<string, ParamMatcher> = {}
301
689
  #routerMiddleware: MiddlewareFunction[] = []
302
690
  #namedMiddleware: Map<string, MiddlewareFunction> = new Map()
691
+ /** Controller registry: name → lazy import, for string handler references. */
692
+ #controllers: Map<string, LazyControllerImport> = new Map()
693
+ /** APP_KEY-backed signed-URL helper (injected by the Ignitor when set). */
694
+ #signedUrl?: SignedUrl
303
695
 
304
696
  /** Index: static routes by "METHOD:path" for O(1) exact match. */
305
697
  #staticIndex: Map<string, RouteDefinition> = new Map()
@@ -327,19 +719,49 @@ export class Router {
327
719
  }
328
720
 
329
721
  /**
330
- * Register named middleware collection.
722
+ * Register a named middleware collection and return per-name FACTORIES
723
+ * (AdonisJS `defineNamedMiddleware`). Call a factory to bind per-route
724
+ * arguments, then attach the result inline:
725
+ *
331
726
  * export const middleware = router.named({
332
727
  * auth: () => import('#middleware/auth_middleware'),
333
728
  * })
729
+ * router.get('/admin', [AdminController, 'index']).use([middleware.auth({ guards: ['web'] })])
730
+ *
731
+ * By-name usage (`route.middleware('auth')`) still runs the middleware with
732
+ * no arguments — the factory form is only needed to pass `args` to `handle`.
334
733
  */
335
- named(collection: Record<string, MiddlewareEntry>): Record<string, MiddlewareFunction> {
336
- const resolved: Record<string, MiddlewareFunction> = {}
734
+ named(
735
+ collection: Record<string, MiddlewareEntry>,
736
+ ): Record<string, (args?: unknown) => MiddlewareFunction> {
737
+ const factories: Record<string, (args?: unknown) => MiddlewareFunction> = {}
337
738
  for (const [name, mw] of Object.entries(collection)) {
338
- const fn = resolveMiddlewareEntry(mw)
339
- this.#namedMiddleware.set(name, fn)
340
- resolved[name] = fn
739
+ // By-name resolution (`route.middleware('auth')`) uses the no-arg form.
740
+ this.#namedMiddleware.set(name, resolveMiddlewareEntry(mw))
741
+ // The returned factory bakes in per-route args for inline `.use()`.
742
+ factories[name] = resolveParametrizedMiddlewareEntry(mw)
743
+ }
744
+ return factories
745
+ }
746
+
747
+ /**
748
+ * Register controllers for magic string handler references (AdonisJS lazy
749
+ * controller pattern). Each entry maps a controller name to a lazy import:
750
+ *
751
+ * router.controllers({ UsersController: () => import('#controllers/users_controller') })
752
+ * router.get('/users', 'UsersController.index')
753
+ *
754
+ * The class is imported on first request and resolved through the IoC
755
+ * container (so @inject + @Guard/@Role decorators work). A string reference
756
+ * to a name that was never registered fails loudly when the route index is
757
+ * built.
758
+ */
759
+ controllers(map: Record<string, LazyControllerImport>): this {
760
+ for (const [name, loader] of Object.entries(map)) {
761
+ this.#controllers.set(name, loader)
341
762
  }
342
- return resolved
763
+ this.#indexDirty = true
764
+ return this
343
765
  }
344
766
 
345
767
  /** Get the router-level middleware stack. */
@@ -354,12 +776,20 @@ export class Router {
354
776
 
355
777
  // ─── Route registration ───────────────────────────────────
356
778
 
357
- /** Register a route with any HTTP method. */
358
- route(method: string, path: string, handler: RouteHandler): RouteBuilder {
359
- const def = this.#createDefinition(method, path, handler)
360
- this.#routes.push(def)
779
+ /**
780
+ * Register a route for one verb or several (AdonisJS `route(pattern, methods)`
781
+ * accepts an array). A multi-verb call returns a single builder that
782
+ * configures every verb at once.
783
+ */
784
+ route(method: string | string[], path: string, handler: RouteHandler): RouteBuilder {
785
+ const methods = Array.isArray(method) ? method : [method]
786
+ const defs = methods.map((verb) => {
787
+ const def = this.#createDefinition(verb, path, handler)
788
+ this.#routes.push(def)
789
+ return def
790
+ })
361
791
  this.#indexDirty = true
362
- return new RouteBuilder(def)
792
+ return new RouteBuilder(defs)
363
793
  }
364
794
 
365
795
  get(path: string, handler: RouteHandler): RouteBuilder {
@@ -398,40 +828,54 @@ export class Router {
398
828
  // ─── Resource routes ──────────────────────────────────────
399
829
 
400
830
  /**
401
- * Register resourceful routes for a controller.
402
- * Generates: index, store, show, update, destroy
831
+ * Register resourceful routes for a controller (AdonisJS `resource`).
832
+ *
833
+ * Generates index / create / store / show / edit / update / destroy —
834
+ * `update` for both PUT and PATCH. Returns a {@link RouteResource} for
835
+ * fluent filtering: `.only()`, `.except()`, `.apiOnly()`, `.params()`,
836
+ * `.tap()`, `.where()`, `.use()`, `.as()`.
837
+ *
838
+ * Nested resources use dot-notation (`posts.comments` →
839
+ * `/posts/:post_id/comments/:id`).
403
840
  *
404
841
  * Usage:
405
842
  * router.resource('posts', PostsController)
406
- * // GET /posts → PostsController.index
407
- * // POST /posts → PostsController.store
408
- * // GET /posts/:id → PostsController.show
409
- * // PUT /posts/:id → PostsController.update
410
- * // DELETE /posts/:id → PostsController.destroy
843
+ * router.resource('posts', PostsController).apiOnly()
411
844
  */
412
- // biome-ignore lint/suspicious/noExplicitAny: see ControllerAction
413
- resource(path: string, controller: new (...args: any[]) => any): GroupBuilder {
414
- const baseName = path.replace(/\//g, '.')
415
- const routes: RouteDefinition[] = []
416
-
417
- const actions: Array<{ method: string; suffix: string; action: string; nameSuffix: string }> = [
418
- { method: 'GET', suffix: '', action: 'index', nameSuffix: 'index' },
419
- { method: 'POST', suffix: '', action: 'store', nameSuffix: 'store' },
420
- { method: 'GET', suffix: '/:id', action: 'show', nameSuffix: 'show' },
421
- { method: 'PUT', suffix: '/:id', action: 'update', nameSuffix: 'update' },
422
- { method: 'PATCH', suffix: '/:id', action: 'update', nameSuffix: 'update' },
423
- { method: 'DELETE', suffix: '/:id', action: 'destroy', nameSuffix: 'destroy' },
424
- ]
425
-
426
- for (const { method, suffix, action, nameSuffix } of actions) {
427
- const def = this.#createDefinition(method, `/${path}${suffix}`, [controller, action])
428
- def.name = `${baseName}.${nameSuffix}`
429
- routes.push(def)
430
- this.#routes.push(def)
431
- }
432
- this.#indexDirty = true
845
+ resource(path: string, controller: ControllerAction[0]): RouteResource {
846
+ return new RouteResource({
847
+ resource: path,
848
+ controller,
849
+ shallow: false,
850
+ register: (def) => {
851
+ this.#routes.push(def)
852
+ this.#indexDirty = true
853
+ },
854
+ markDirty: () => {
855
+ this.#indexDirty = true
856
+ },
857
+ })
858
+ }
433
859
 
434
- return new GroupBuilder(routes)
860
+ /**
861
+ * Register a shallow resource (AdonisJS `shallowResource`). Member routes
862
+ * (show/edit/update/destroy) drop the parent prefix, since the record id
863
+ * alone identifies it: `posts.comments` → `/comments/:id` for members but
864
+ * `/posts/:post_id/comments` for the collection.
865
+ */
866
+ shallowResource(path: string, controller: ControllerAction[0]): RouteResource {
867
+ return new RouteResource({
868
+ resource: path,
869
+ controller,
870
+ shallow: true,
871
+ register: (def) => {
872
+ this.#routes.push(def)
873
+ this.#indexDirty = true
874
+ },
875
+ markDirty: () => {
876
+ this.#indexDirty = true
877
+ },
878
+ })
435
879
  }
436
880
 
437
881
  // ─── Groups ───────────────────────────────────────────────
@@ -508,8 +952,8 @@ export class Router {
508
952
  // ─── Global matchers ──────────────────────────────────────
509
953
 
510
954
  /** Set a global param matcher (applied to all routes). */
511
- where(param: string, matcher: ParamMatcher | RegExp): this {
512
- this.#globalMatchers[param] = matcher instanceof RegExp ? { pattern: matcher } : matcher
955
+ where(param: string, matcher: ParamMatcher | RegExp | string): this {
956
+ this.#globalMatchers[param] = compileMatcher(matcher)
513
957
  return this
514
958
  }
515
959
 
@@ -522,6 +966,20 @@ export class Router {
522
966
  this.#nameIndex.clear()
523
967
 
524
968
  for (const route of this.#routes) {
969
+ // Soft-deleted routes (resource .only()/.except()/.apiOnly()) never match.
970
+ if (route.deleted) continue
971
+ // Resolve a string handler reference against the controller registry.
972
+ if (route.stringRef && !route.lazyController) {
973
+ const loader = this.#controllers.get(route.stringRef.controller)
974
+ if (!loader) {
975
+ throw new Error(
976
+ `[E_UNREGISTERED_CONTROLLER] Route ${route.method} ${route.path} references ` +
977
+ `controller '${route.stringRef.controller}', which is not registered. Register it ` +
978
+ `with router.controllers({ ${route.stringRef.controller}: () => import('...') }).`,
979
+ )
980
+ }
981
+ route.lazyController = { loader, method: route.stringRef.method }
982
+ }
525
983
  // Named route index
526
984
  if (route.name) {
527
985
  this.#nameIndex.set(route.name, route)
@@ -647,6 +1105,33 @@ export class Router {
647
1105
  return this.urlFor(name, params)
648
1106
  }
649
1107
 
1108
+ /** @internal Inject the APP_KEY-backed signed-URL helper (wired by the Ignitor). */
1109
+ setSignedUrl(signedUrl: SignedUrl): void {
1110
+ this.#signedUrl = signedUrl
1111
+ }
1112
+
1113
+ /**
1114
+ * Generate a tamper-proof signed URL for a named route (AdonisJS
1115
+ * `makeSignedUrl`). Delegates to the {@link SignedUrl} helper: appends an HMAC
1116
+ * `signature`, plus a signed `expires` timestamp when `expiresIn` is given.
1117
+ * Verified on the request side by `request.hasValidSignature()`.
1118
+ *
1119
+ * Requires an APP_KEY-backed signer (registered when `APP_KEY` is set).
1120
+ */
1121
+ makeSignedUrl(name: string, params?: Record<string, string>, options?: SignedUrlOptions): string {
1122
+ if (!this.#signedUrl) {
1123
+ throw new Error(
1124
+ '[E_NO_APP_KEY] makeSignedUrl() requires an APP_KEY-backed signer. Set APP_KEY (>= 16 chars) so the encryption service is registered.',
1125
+ )
1126
+ }
1127
+ const path = this.urlFor(name, params)
1128
+ const withQs = options?.qs ? `${path}?${new URLSearchParams(options.qs).toString()}` : path
1129
+ return this.#signedUrl.make(withQs, {
1130
+ expiresIn: options?.expiresIn,
1131
+ purpose: options?.purpose,
1132
+ })
1133
+ }
1134
+
650
1135
  /**
651
1136
  * Map of every NAMED route's `name` → path pattern, e.g.
652
1137
  * `{ 'users.show': '/users/:id' }`. Serialize this into a page so a
@@ -663,11 +1148,62 @@ export class Router {
663
1148
  return manifest
664
1149
  }
665
1150
 
1151
+ /**
1152
+ * Generate a TypeScript source string typing every named route and its
1153
+ * params (AdonisJS `generateTypes`) — a `RouteName` union plus a
1154
+ * `RouteParams` map inferred from each pattern's `:param` segments. Write it
1155
+ * to a `.d.ts` so `urlFor(name, params)` is type-checked. Returns the source;
1156
+ * the caller decides where to persist it.
1157
+ */
1158
+ generateTypes(): string {
1159
+ if (this.#indexDirty) this.#buildIndex()
1160
+ const names = [...this.#nameIndex.keys()].sort()
1161
+ if (names.length === 0) {
1162
+ return 'export type RouteName = never\nexport interface RouteParams {}\n'
1163
+ }
1164
+ const union = names.map((name) => ` | ${JSON.stringify(name)}`).join('\n')
1165
+ const entries = names.map((name) => {
1166
+ const pattern = this.#nameIndex.get(name)?.path ?? ''
1167
+ const params = [...pattern.matchAll(/:([A-Za-z_]\w*)(\??)/g)]
1168
+ const shape =
1169
+ params.length === 0
1170
+ ? 'Record<string, never>'
1171
+ : `{ ${params.map((m) => `${JSON.stringify(m[1])}${m[2] === '?' ? '?' : ''}: string`).join('; ')} }`
1172
+ return ` ${JSON.stringify(name)}: ${shape}`
1173
+ })
1174
+ return `export type RouteName =\n${union}\n\nexport interface RouteParams {\n${entries.join('\n')}\n}\n`
1175
+ }
1176
+
1177
+ // ─── Lookup ───────────────────────────────────────────────
1178
+
1179
+ /**
1180
+ * Find a registered route by name or exact pattern (AdonisJS `router.find`).
1181
+ * Returns null when nothing matches. Soft-deleted routes are ignored.
1182
+ */
1183
+ find(identifier: string): RouteDefinition | null {
1184
+ if (this.#indexDirty) this.#buildIndex()
1185
+ const byName = this.#nameIndex.get(identifier)
1186
+ if (byName) return byName
1187
+ return this.#routes.find((route) => !route.deleted && route.path === identifier) ?? null
1188
+ }
1189
+
1190
+ /** Like {@link find} but throws when the route is absent (AdonisJS `findOrFail`). */
1191
+ findOrFail(identifier: string): RouteDefinition {
1192
+ const route = this.find(identifier)
1193
+ if (!route) throw new Error(`Cannot find route for '${identifier}'`)
1194
+ return route
1195
+ }
1196
+
1197
+ /** True when a route with the given name or pattern exists (AdonisJS `router.has`). */
1198
+ has(identifier: string): boolean {
1199
+ return this.find(identifier) !== null
1200
+ }
1201
+
666
1202
  // ─── Accessors ────────────────────────────────────────────
667
1203
 
668
- /** Get all registered routes (for OpenAPI generation, introspection). */
1204
+ /** Get all registered (non-deleted) routes (for OpenAPI generation, introspection). */
669
1205
  getRoutes(): RouteDefinition[] {
670
- return [...this.#routes]
1206
+ return this.#routes.filter((route) => !route.deleted)
671
1207
  }
672
1208
 
673
1209
  /**
@@ -680,6 +1216,7 @@ export class Router {
680
1216
  this.#routes = []
681
1217
  this.#routerMiddleware = []
682
1218
  this.#namedMiddleware.clear()
1219
+ this.#controllers.clear()
683
1220
  this.#globalMatchers = {}
684
1221
  this.#indexDirty = true
685
1222
  }
@@ -691,26 +1228,7 @@ export class Router {
691
1228
  // ─── Internals ────────────────────────────────────────────
692
1229
 
693
1230
  #createDefinition(method: string, path: string, handler: RouteHandler): RouteDefinition {
694
- const def: RouteDefinition = {
695
- method: method.toUpperCase(),
696
- path,
697
- handler: null,
698
- middleware: [],
699
- inlineMiddleware: [],
700
- guards: [],
701
- roles: [],
702
- permissions: [],
703
- validators: [],
704
- matchers: {},
705
- }
706
-
707
- if (Array.isArray(handler)) {
708
- def.controller = { target: handler[0], method: handler[1] }
709
- } else {
710
- def.handler = handler
711
- }
712
-
713
- return def
1231
+ return makeDefinition(method, path, handler)
714
1232
  }
715
1233
 
716
1234
  #validateMatchers(