@rudderjs/router 1.0.0 → 1.1.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.
package/README.md CHANGED
@@ -39,6 +39,33 @@ router.get('/invoices/:id/download', handler, [ValidateSignature()]).name('invoi
39
39
 
40
40
  ---
41
41
 
42
+ ## Parameter constraints (`where*()`)
43
+
44
+ Constrain `:param` segments to a regex with Laravel-style `where*()` shortcuts. Internally, the path is rewritten to Hono's `:param{regex}` syntax so non-matching requests 404 before they reach the handler.
45
+
46
+ ```ts
47
+ router.get('/users/:id', handler).whereNumber('id').name('users.show')
48
+ router.get('/u/:id', handler).whereUuid('id')
49
+ router.get('/posts/:status', handler).whereIn('status', ['draft', 'published'])
50
+ router.get('/n/:n', handler).where('n', /\d{3,5}/)
51
+ ```
52
+
53
+ | Method | Pattern |
54
+ |--------|---------|
55
+ | `where(param, regex)` | Custom — string or `RegExp` (uses `.source`) |
56
+ | `whereNumber(param)` | `[0-9]+` |
57
+ | `whereAlpha(param)` | `[A-Za-z]+` |
58
+ | `whereAlphaNumeric(param)` | `[A-Za-z0-9]+` |
59
+ | `whereUuid(param)` | UUID, any version |
60
+ | `whereUlid(param)` | Crockford base32 ULID (26 chars) |
61
+ | `whereIn(param, values)` | Alternation over regex-escaped literals |
62
+
63
+ Chains in any order with `.name()`; calling another `where*()` for the same param overwrites. Throws if the route path has no `:param` segment. The patterns are also exported as constants — `ROUTE_PATTERN_NUMBER`, `ROUTE_PATTERN_ALPHA`, `ROUTE_PATTERN_ALPHANUM`, `ROUTE_PATTERN_UUID`, `ROUTE_PATTERN_ULID`.
64
+
65
+ > Decorator routes (`@Get('/users/:id')`) don't return a `RouteBuilder`, so `where*()` is fluent-only in v1.
66
+
67
+ ---
68
+
42
69
  ## `route()` — URL generation
43
70
 
44
71
  Generate a URL from a named route. Route parameters are substituted; unused params are appended as a query string.
@@ -146,6 +173,176 @@ router.post('/admin', handler, [authMiddleware, adminMiddleware])
146
173
 
147
174
  ---
148
175
 
176
+ ## Route model binding
177
+
178
+ Bind a `:param` segment to any class with a static `findForRoute(value)` method (a `@rudderjs/orm` Model is the canonical fit). Resolution runs as a prepended per-route middleware before your handler, exposing the result as `req.bound!.<name>`.
179
+
180
+ ```ts
181
+ import { router } from '@rudderjs/router'
182
+ import { User } from '../app/Models/User.js'
183
+
184
+ router.bind('user', User)
185
+
186
+ router.get('/users/:user', (req) => {
187
+ const user = req.bound!['user'] // a User instance, or 404 was thrown
188
+ return user
189
+ })
190
+
191
+ // Optional binding — null instead of 404 when missing
192
+ router.bind('viewer', User, { optional: true })
193
+ ```
194
+
195
+ Returning `null` from `findForRoute` triggers `RouteModelNotFoundError` (HTTP 404). The raw string remains in `req.params[name]` regardless. Routes whose path doesn't include a bound param are unaffected.
196
+
197
+ The `RouteResolver` contract is duck-typed — `name: string` + `findForRoute(value): unknown | Promise<unknown | null>` — so the router doesn't depend on `@rudderjs/orm`.
198
+
199
+ ### Custom 404 with `.missing()`
200
+
201
+ Override the default 404 response per route. Receives the request and the binding error; return a value the route handler may return — `Response`, plain object → JSON, string → body, or `undefined` (you wrote to `res` directly).
202
+
203
+ ```ts
204
+ router.get('/users/:user', show)
205
+ .missing((_req, err) => Response.json({ error: err.message }, { status: 404 }))
206
+
207
+ router.get('/posts/:post', show)
208
+ .missing((_req, err) => ({ message: `Post ${err.value} not found` })) // → 200 JSON
209
+ ```
210
+
211
+ Optional bindings do NOT trigger `.missing()` — they quietly resolve to `null` instead.
212
+
213
+ ---
214
+
215
+ ## Route groups (`router.group()`)
216
+
217
+ Apply a `prefix`, `domain`, or shared `middleware` stack to every route registered inside the callback. Nested groups concatenate prefixes and middleware; the innermost defined `domain` wins (hosts can't compose).
218
+
219
+ ```ts
220
+ import { router } from '@rudderjs/router'
221
+
222
+ router.group({ prefix: '/admin', middleware: [adminAuth] }, () => {
223
+ router.get('/users', listUsers) // GET /admin/users (with adminAuth)
224
+ router.get('/posts', listPosts) // GET /admin/posts (with adminAuth)
225
+ })
226
+
227
+ router.group({ domain: ':tenant.example.com', prefix: '/api' }, () => {
228
+ router.get('/me', me) // GET :tenant.example.com/api/me
229
+ })
230
+
231
+ // Nested
232
+ router.group({ prefix: '/api' }, () => {
233
+ router.group({ prefix: '/v1', middleware: [throttle] }, () => {
234
+ router.get('/users', listUsers) // GET /api/v1/users (with throttle)
235
+ })
236
+ })
237
+ ```
238
+
239
+ `router.group()` is the user-facing scoping primitive. Distinct from `runWithGroup('web' | 'api', …)` — that tags routes with their middleware-group label and is called once by the framework's route loader. Both can be active at the same time.
240
+
241
+ ---
242
+
243
+ ## Subdomain routing (`.domain()`)
244
+
245
+ Restrict a route to a specific host. The template is matched against the request's `Host` header (port stripped, case-insensitive); `:param` segments capture into `req.params` alongside path params.
246
+
247
+ ```ts
248
+ router.get('/users', listUsers).domain('api.example.com')
249
+ router.get('/me', me).domain(':tenant.example.com')
250
+ // req.params.tenant === 'acme' for Host: acme.example.com
251
+
252
+ router.group({ domain: 'admin.example.com', middleware: [adminAuth] }, () => {
253
+ router.get('/dashboard', dash) // GET admin.example.com/dashboard
254
+ })
255
+ ```
256
+
257
+ Mismatched hosts return 404. Subdomain `:param` and path `:param` of the same name collide — path wins.
258
+
259
+ ---
260
+
261
+ ## Resource controllers (`router.resource()`)
262
+
263
+ Wire the seven canonical CRUD routes from a plain controller class — Laravel's `Route::resource` for RudderJS. No decorators; methods are matched by name.
264
+
265
+ ```ts
266
+ import { router } from '@rudderjs/router'
267
+
268
+ class PostController {
269
+ async index (_ctx) { /* GET /posts → list */ }
270
+ async create (_ctx) { /* GET /posts/create → form */ }
271
+ async store (_ctx) { /* POST /posts → persist */ }
272
+ async show (_ctx) { /* GET /posts/:post → one */ }
273
+ async edit (_ctx) { /* GET /posts/:post/edit → form */ }
274
+ async update (_ctx) { /* PUT|PATCH /posts/:post → update */ }
275
+ async destroy (_ctx) { /* DELETE /posts/:post → delete */ }
276
+ }
277
+
278
+ router.resource('posts', PostController)
279
+ ```
280
+
281
+ | Verb | Method | Path | Route name |
282
+ |--------|--------|------|------------|
283
+ | index | `GET` | `/posts` | `posts.index` |
284
+ | create | `GET` | `/posts/create` | `posts.create` |
285
+ | store | `POST` | `/posts` | `posts.store` |
286
+ | show | `GET` | `/posts/:post` | `posts.show` |
287
+ | edit | `GET` | `/posts/:post/edit` | `posts.edit` |
288
+ | update | `PUT`+`PATCH` | `/posts/:post` | `posts.update` |
289
+ | destroy | `DELETE` | `/posts/:post` | `posts.destroy` |
290
+
291
+ Methods the controller doesn't implement are silently skipped, so partial controllers work with no `only`/`except` boilerplate.
292
+
293
+ ### `apiResource()` — drops the HTML form pages
294
+
295
+ ```ts
296
+ router.apiResource('posts', PostController)
297
+ // no posts.create, no posts.edit
298
+ ```
299
+
300
+ ### `singleton()` — for "the one of these"
301
+
302
+ ```ts
303
+ router.singleton('profile', ProfileController)
304
+ // GET /profile posts.show (named profile.show)
305
+ // GET /profile/edit profile.edit
306
+ // PUT /profile profile.update
307
+ // PATCH /profile (alias)
308
+
309
+ router.singleton('profile', ProfileController).creatable() // adds GET /profile/create + POST /profile
310
+ router.singleton('profile', ProfileController).destroyable() // adds DELETE /profile
311
+ ```
312
+
313
+ ### Options
314
+
315
+ ```ts
316
+ router.resource('posts', PostController, {
317
+ only: ['index', 'show'],
318
+ except: ['destroy'],
319
+ parameters: { posts: 'article' }, // /posts/:article instead of /posts/:post
320
+ names: { show: 'posts.detail' },
321
+ middleware: [authMw],
322
+ })
323
+ ```
324
+
325
+ ### Per-route customisation via `.builders[]`
326
+
327
+ The registration object exposes the underlying `RouteBuilder[]` — apply `where*()`, additional `.middleware()`, or rename one route in isolation:
328
+
329
+ ```ts
330
+ const reg = router.resource('posts', PostController)
331
+ reg.builders[3].whereNumber('post') // constrain show route only
332
+ ```
333
+
334
+ Builders are in declaration order: `index`, `create`, `store`, `show`, `edit`, `update` (PUT), `update` (PATCH alias), `destroy` — minus any verb the controller skipped.
335
+
336
+ ### Scaffolder
337
+
338
+ ```bash
339
+ pnpm rudder make:controller PostController --resource # full 7-verb stub
340
+ pnpm rudder make:controller PostController --api # API-only (no create/edit)
341
+ pnpm rudder make:controller ProfileController --singleton
342
+ ```
343
+
344
+ ---
345
+
149
346
  ## Mounting onto a server adapter
150
347
 
151
348
  ```ts
@@ -169,20 +366,35 @@ router.mount(serverAdapter)
169
366
  | `all(path, handler, mw?)` | `RouteBuilder` | Register route matching any method |
170
367
  | `add(method, path, handler, mw?)` | `this` | Register route with explicit method |
171
368
  | `use(middleware)` | `this` | Register global middleware |
369
+ | `bind(name, resolver, opts?)` | `this` | Bind a `:param` to a `RouteResolver` (e.g. an ORM Model) for auto-resolution |
370
+ | `listBindings()` | `Record<string, RouteResolver>` | All registered route bindings |
371
+ | `group(opts, fn)` | `this` | Apply prefix/domain/middleware to every route registered inside `fn` |
372
+ | `resource(name, Ctrl, opts?)` | `ResourceRegistration` | Register the seven canonical RESTful routes |
373
+ | `apiResource(name, Ctrl, opts?)` | `ResourceRegistration` | Resource minus `create`/`edit` |
374
+ | `singleton(name, Ctrl, opts?)` | `SingletonRegistration` | `show`/`edit`/`update` for a single-instance resource |
172
375
  | `registerController(Class)` | `this` | Register decorator-based controller |
173
376
  | `mount(serverAdapter)` | `void` | Apply middleware + routes to adapter |
174
377
  | `list()` | `RouteDefinition[]` | All registered routes |
175
378
  | `listNamed()` | `Record<string, string>` | All named routes |
176
379
  | `getNamedRoute(name)` | `string \| undefined` | Path for a named route |
177
- | `reset()` | `this` | Clear routes, middleware, and named routes |
380
+ | `reset()` | `this` | Clear routes, middleware, named routes, and bindings |
178
381
 
179
382
  ### `RouteBuilder`
180
383
 
181
- Returned by the shorthand route methods. Allows naming the registered route.
384
+ Returned by the shorthand route methods. Allows naming the registered route and constraining `:param` segments.
182
385
 
183
386
  | Method | Description |
184
387
  |--------|-------------|
185
388
  | `.name(n)` | Assign a name to the route |
389
+ | `.where(param, regex)` | Constrain `:param` to a custom regex (string or `RegExp`) |
390
+ | `.whereNumber(param)` | Shortcut for `[0-9]+` |
391
+ | `.whereAlpha(param)` | Shortcut for `[A-Za-z]+` |
392
+ | `.whereAlphaNumeric(param)` | Shortcut for `[A-Za-z0-9]+` |
393
+ | `.whereUuid(param)` | Shortcut for any-version UUID |
394
+ | `.whereUlid(param)` | Shortcut for Crockford base32 ULID |
395
+ | `.whereIn(param, values)` | Constrain `:param` to one of the supplied literals |
396
+ | `.domain(template)` | Restrict to a host; `:param` segments capture into `req.params` |
397
+ | `.missing(fn)` | Custom 404 callback when an explicit binding fails to resolve |
186
398
 
187
399
  ### `Url`
188
400
 
@@ -34,6 +34,59 @@ route('posts.show', { slug: 'hello' }) // optional ':id?' segment omitted
34
34
 
35
35
  Throws if a required parameter is missing or the name is not registered.
36
36
 
37
+ ### Parameter constraints
38
+
39
+ ```ts
40
+ import { Route } from '@rudderjs/router'
41
+
42
+ Route.get('/users/:id', handler).whereNumber('id').name('users.show')
43
+ Route.get('/u/:id', handler).whereUuid('id')
44
+ Route.get('/posts/:status', handler).whereIn('status', ['draft', 'published'])
45
+ Route.get('/n/:n', handler).where('n', /\d{3,5}/)
46
+ ```
47
+
48
+ Available shortcuts: `whereNumber` / `whereAlpha` / `whereAlphaNumeric` / `whereUuid` / `whereUlid` / `whereIn(param, values)`. Base method `.where(param, regex)` accepts a string or `RegExp`. Throws when the path has no `:param` segment, or when `whereIn` gets an empty values array. Order-independent against `.name()`.
49
+
50
+ > Fluent-only — decorator routes (`@Get('/users/:id')`) don't return a `RouteBuilder`.
51
+
52
+ ### Route groups
53
+
54
+ ```ts
55
+ import { router } from '@rudderjs/router'
56
+
57
+ router.group({ prefix: '/admin', middleware: [adminAuth] }, () => {
58
+ router.get('/users', listUsers) // GET /admin/users (with adminAuth)
59
+ })
60
+
61
+ router.group({ domain: ':tenant.example.com', prefix: '/api' }, () => {
62
+ router.get('/me', me) // GET :tenant.example.com/api/me
63
+ })
64
+ ```
65
+
66
+ Nested groups concatenate prefixes and middleware; innermost defined `domain` wins. `router.group()` is the user-facing scoping primitive — distinct from `runWithGroup('web' | 'api', …)` (the framework's web/api middleware-group tag).
67
+
68
+ ### Subdomain routing
69
+
70
+ ```ts
71
+ router.get('/users', listUsers).domain('api.example.com')
72
+ router.get('/me', me).domain(':tenant.example.com')
73
+ // req.params.tenant === 'acme' for Host: acme.example.com
74
+ ```
75
+
76
+ Mismatched hosts return 404. Subdomain `:param` and path `:param` of the same name collide — path wins.
77
+
78
+ ### Route binding 404 customisation
79
+
80
+ ```ts
81
+ router.get('/users/:user', show)
82
+ .missing((_req, err) => Response.json({ error: err.message }, { status: 404 }))
83
+
84
+ router.get('/posts/:post', show)
85
+ .missing((_req, err) => ({ message: `Post ${err.value} not found` }))
86
+ ```
87
+
88
+ Returns: `Response`, plain object → JSON, string → body, or `undefined` (callback wrote to `res` directly). Optional bindings do NOT trigger `.missing()`.
89
+
37
90
  ### Route-level middleware
38
91
 
39
92
  ```ts
package/dist/index.d.ts CHANGED
@@ -12,11 +12,22 @@ export declare const Put: (path?: string) => MethodDecorator;
12
12
  export declare const Patch: (path?: string) => MethodDecorator;
13
13
  export declare const Delete: (path?: string) => MethodDecorator;
14
14
  export declare const Options: (path?: string) => MethodDecorator;
15
+ /** Matches one or more digits — `[0-9]+`. */
16
+ export declare const ROUTE_PATTERN_NUMBER = "[0-9]+";
17
+ /** Matches one or more ASCII letters — `[A-Za-z]+`. */
18
+ export declare const ROUTE_PATTERN_ALPHA = "[A-Za-z]+";
19
+ /** Matches one or more ASCII letters or digits — `[A-Za-z0-9]+`. */
20
+ export declare const ROUTE_PATTERN_ALPHANUM = "[A-Za-z0-9]+";
21
+ /** Matches a UUID of any version (case-insensitive). */
22
+ export declare const ROUTE_PATTERN_UUID = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
23
+ /** Matches a Crockford base32 ULID (26 chars). */
24
+ export declare const ROUTE_PATTERN_ULID = "[0-7][0-9A-HJKMNP-TV-Z]{25}";
15
25
  /**
16
- * Returned by `router.get/post/etc` — allows naming the registered route.
26
+ * Returned by `router.get/post/etc` — allows naming the registered route and
27
+ * constraining `:param` segments via Laravel-style `where*` shortcuts.
17
28
  *
18
29
  * @example
19
- * router.get('/users/:id', handler).name('users.show')
30
+ * router.get('/users/:id', handler).name('users.show').whereNumber('id')
20
31
  * route('users.show', { id: 1 }) // → '/users/1'
21
32
  */
22
33
  export declare class RouteBuilder {
@@ -25,14 +36,124 @@ export declare class RouteBuilder {
25
36
  constructor(definition: RouteDefinition, _router: Router);
26
37
  /** Assign a name to this route for use with `route()` and `Url.signedRoute()`. */
27
38
  name(n: string): this;
39
+ /**
40
+ * Constrain a `:param` segment with a custom regex. Accepts either a string
41
+ * (used verbatim) or a `RegExp` (its `.source` is taken — `/^/`, `/$/`, and
42
+ * flags are ignored, since Hono anchors per-segment).
43
+ *
44
+ * Mutates the route's path in place to `:param{pattern}` (Hono regex syntax).
45
+ * Calling `where*` again on the same param overwrites the previous pattern.
46
+ *
47
+ * Throws if the route path has no `:param` segment.
48
+ */
49
+ where(param: string, regex: string | RegExp): this;
50
+ /** Constrain `:param` to one or more digits. */
51
+ whereNumber(param: string): this;
52
+ /** Constrain `:param` to one or more ASCII letters. */
53
+ whereAlpha(param: string): this;
54
+ /** Constrain `:param` to one or more ASCII letters or digits. */
55
+ whereAlphaNumeric(param: string): this;
56
+ /** Constrain `:param` to a UUID of any version. */
57
+ whereUuid(param: string): this;
58
+ /** Constrain `:param` to a Crockford base32 ULID. */
59
+ whereUlid(param: string): this;
60
+ /**
61
+ * Constrain `:param` to one of the supplied literal values. Each value is
62
+ * regex-escaped, so `'a.b'` matches the literal string `a.b`, not "a then any
63
+ * char then b". Throws when `values` is empty.
64
+ *
65
+ * @example
66
+ * router.get('/posts/:status', handler).whereIn('status', ['draft', 'published'])
67
+ */
68
+ whereIn(param: string, values: readonly (string | number)[]): this;
69
+ /**
70
+ * Restrict this route to a specific subdomain. The template is matched against
71
+ * the request's `Host` header (port stripped, case-insensitive); `:param`
72
+ * segments capture into `req.params` alongside path params.
73
+ *
74
+ * @example
75
+ * router.get('/users', listUsers).domain('api.example.com')
76
+ * router.get('/admin', dash).domain(':tenant.example.com') // captures req.params.tenant
77
+ */
78
+ domain(template: string): this;
79
+ /**
80
+ * Custom response when an explicit route binding (`router.bind('user', User)`)
81
+ * resolves to `null`. Receives the request and the not-found error; return any
82
+ * value a handler may return (`Response`, plain object → JSON, string → body,
83
+ * or `undefined` after writing to `res` directly). Does not fire for optional
84
+ * bindings — those quietly resolve to `null` instead.
85
+ *
86
+ * @example
87
+ * router.get('/users/:user', show)
88
+ * .missing((_req, err) => Response.json({ error: err.message }, { status: 404 }))
89
+ */
90
+ missing(fn: NonNullable<RouteDefinition['missing']>): this;
91
+ }
92
+ /**
93
+ * Options accepted by `router.group(opts, fn)`. Each route registered inside
94
+ * `fn` inherits the prefix, domain, and middleware. Nested groups concatenate
95
+ * prefixes and middleware; the innermost defined `domain` wins (hosts can't
96
+ * compose). Distinct from `runWithGroup('web' | 'api', …)`, which only tags
97
+ * routes with their middleware-group label.
98
+ */
99
+ export interface RouteGroupOptions {
100
+ /** Path prefix applied to every route registered in the group. */
101
+ prefix?: string;
102
+ /** Subdomain template applied to every route registered in the group. */
103
+ domain?: string;
104
+ /** Middleware prepended to every route's chain (before per-route middleware). */
105
+ middleware?: MiddlewareHandler[];
106
+ }
107
+ /**
108
+ * Duck-typed contract for any object that resolves a string route parameter
109
+ * into a value (typically a Model instance, but the router doesn't depend on
110
+ * `@rudderjs/orm` — anything with a static `findForRoute` method works).
111
+ *
112
+ * Returning `null` signals "not found" — the router maps that to a thrown
113
+ * `RouteModelNotFoundError`, which the framework's HTTP layer renders as a 404.
114
+ */
115
+ export interface RouteResolver {
116
+ /** Owning class name — used for error messages only. */
117
+ name: string;
118
+ /** Resolve the raw param value. Return `null` for not-found. */
119
+ findForRoute(value: string): Promise<unknown | null> | unknown | null;
120
+ }
121
+ export interface RouteBindingOptions {
122
+ /**
123
+ * When `true`, an absent or unresolvable param value silently sets
124
+ * `req.bound[name] = null` instead of throwing. Useful for shared routes
125
+ * that may or may not have a logged-in subject.
126
+ */
127
+ optional?: boolean;
128
+ }
129
+ /**
130
+ * Thrown by route binding middleware when a required `{param}` cannot be
131
+ * resolved into a model instance. `@rudderjs/core` picks up the duck-typed
132
+ * `httpStatus` and renders this as an HTTP 404; apps can catch it explicitly
133
+ * to render a custom not-found page.
134
+ */
135
+ export declare class RouteModelNotFoundError extends Error {
136
+ readonly model: string;
137
+ readonly param: string;
138
+ readonly value: string;
139
+ /** Duck-typed signal to `@rudderjs/core`'s exception handler. */
140
+ readonly httpStatus = 404;
141
+ constructor(model: string, param: string, value: string);
28
142
  }
29
143
  export declare class Router {
30
144
  private routes;
31
145
  private globalMiddleware;
32
146
  private namedRoutes;
147
+ private bindings;
148
+ /**
149
+ * Active `group()` scopes, outermost first. Synchronous module-level state
150
+ * is fine — route loaders execute synchronously at module evaluation, and
151
+ * `group()` only mutates the stack inside its own callback's lifetime.
152
+ */
153
+ private _groupStack;
33
154
  /** @internal — called by RouteBuilder */
34
- _registerName(name: string, path: string): void;
35
- /** Look up a named route's path. */
155
+ _registerName(name: string, def: RouteDefinition): void;
156
+ /** Look up a named route's path. Reflects any `where*()` mutations. */
36
157
  getNamedRoute(name: string): string | undefined;
37
158
  /**
38
159
  * Check whether a named route is registered.
@@ -47,10 +168,77 @@ export declare class Router {
47
168
  has(name: string): boolean;
48
169
  /** All registered named routes. */
49
170
  listNamed(): Record<string, string>;
50
- /** Clear registered routes, middleware, and named routes. */
171
+ /** Clear registered routes, middleware, named routes, and route bindings. */
51
172
  reset(): this;
173
+ /**
174
+ * Run `fn` with a group scope active. Every route registered (via fluent
175
+ * `.get()`/`.post()`/etc. or `registerController()`) inside `fn` inherits
176
+ * the group's `prefix`, `domain`, and `middleware`. Nested calls compose:
177
+ * prefixes concatenate, middleware stacks accumulate, the innermost defined
178
+ * `domain` wins.
179
+ *
180
+ * Distinct from `runWithGroup('web' | 'api', …)` — that tags routes with
181
+ * their middleware-group label (web vs api) and is called once by the
182
+ * framework's route loader. `router.group()` is the user-facing scoping
183
+ * primitive; both can be active at the same time.
184
+ *
185
+ * @example
186
+ * router.group({ prefix: '/admin', middleware: [adminAuth] }, () => {
187
+ * router.get('/users', listUsers) // GET /admin/users (with adminAuth)
188
+ * router.get('/posts', listPosts) // GET /admin/posts (with adminAuth)
189
+ * })
190
+ *
191
+ * router.group({ domain: ':tenant.example.com', prefix: '/api' }, () => {
192
+ * router.get('/me', me) // GET :tenant.example.com/api/me
193
+ * })
194
+ */
195
+ group(opts: RouteGroupOptions, fn: () => void): this;
196
+ /**
197
+ * Compose the active group stack into the values used to register a route.
198
+ * Path prefixes concatenate (and collapse `/+` to `/`), middleware stacks
199
+ * concatenate, the innermost defined `host` wins.
200
+ */
201
+ private _applyGroupStack;
52
202
  /** Register a global middleware (runs on every route). */
53
203
  use(middleware: MiddlewareHandler): this;
204
+ /**
205
+ * Bind a route parameter name to a resolver. When a route's path contains
206
+ * `:<name>`, the matching string param is resolved before the handler runs;
207
+ * the result is exposed as `req.bound[name]`. The raw string remains in
208
+ * `req.params[name]` so existing code keeps working.
209
+ *
210
+ * Resolvers are duck-typed — pass any class with a static `findForRoute(val)`
211
+ * method (`@rudderjs/orm` Model classes match by default). Bindings are
212
+ * opt-in: routes whose path does not include the bound `:name` are unaffected.
213
+ *
214
+ * @example
215
+ * import { router } from '@rudderjs/router'
216
+ * import { User } from '../app/Models/User.js'
217
+ *
218
+ * router.bind('user', User)
219
+ * router.get('/users/:user', (req) => req.bound!['user'])
220
+ *
221
+ * // Custom column → declare on the model:
222
+ * class Post extends Model {
223
+ * static override routeKey = 'slug'
224
+ * }
225
+ * router.bind('post', Post) // resolves /posts/:post by slug
226
+ *
227
+ * // Optional binding — null when missing instead of 404:
228
+ * router.bind('viewer', User, { optional: true })
229
+ */
230
+ bind(name: string, resolver: RouteResolver, options?: RouteBindingOptions): this;
231
+ /** All registered route bindings, keyed by param name. */
232
+ listBindings(): Record<string, RouteResolver>;
233
+ /**
234
+ * Build the per-route binding middleware. Walks the route's `:param` segments,
235
+ * looks them up in the binding map, and resolves each before calling `next()`.
236
+ * No-op for routes whose path contains no bound params.
237
+ *
238
+ * Takes the full `RouteDefinition` (not just path) so the closure can capture
239
+ * `def.missing` — the per-route 404 customisation set via `RouteBuilder.missing()`.
240
+ */
241
+ private _buildBindingMiddleware;
54
242
  /** Manually register a route. Returns `this` for bulk registration. */
55
243
  add(method: HttpMethod, path: string, handler: RouteHandler, middleware?: MiddlewareHandler[]): this;
56
244
  get(path: string, handler: RouteHandler, middleware?: MiddlewareHandler[]): RouteBuilder;
@@ -66,6 +254,107 @@ export declare class Router {
66
254
  mount(server: ServerAdapter): void;
67
255
  /** All registered routes — useful for `routes:list`. */
68
256
  list(): RouteDefinition[];
257
+ /**
258
+ * Register the canonical seven CRUD routes for a plain controller class:
259
+ * `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`. Methods
260
+ * the controller doesn't implement are silently skipped, so a partial
261
+ * controller works without `only`/`except` boilerplate.
262
+ *
263
+ * The `update` route is registered for both `PUT` and `PATCH`. Route names
264
+ * default to `<name>.<verb>` (`posts.show`, `posts.update`). The path param
265
+ * defaults to a naive singular (`posts` → `:post`); pass
266
+ * `{ parameters: { posts: 'article' } }` to override.
267
+ *
268
+ * Use plain method names — no decorators. Call `router.registerController()`
269
+ * for decorator-driven controllers instead.
270
+ *
271
+ * @example
272
+ * router.resource('posts', PostController)
273
+ * router.resource('posts', PostController, { only: ['index', 'show'] })
274
+ * router.resource('posts', PostController, { middleware: [authMw] })
275
+ */
276
+ resource(name: string, Ctrl: new () => object, opts?: ResourceOptions): ResourceRegistration;
277
+ /**
278
+ * Register an API-only resource — the same routes as `resource()` minus
279
+ * `create` and `edit`, since those render HTML forms and have no JSON
280
+ * equivalent.
281
+ *
282
+ * @example
283
+ * router.apiResource('posts', PostController)
284
+ */
285
+ apiResource(name: string, Ctrl: new () => object, opts?: ResourceOptions): ResourceRegistration;
286
+ /**
287
+ * Register a singleton resource — `show`, `edit`, `update` only. Use for
288
+ * "the current user's profile" / "the application's settings" style
289
+ * resources where there's only ever one of the thing.
290
+ *
291
+ * Add a creation flow with `.creatable()` (registers `create` + `store`) or
292
+ * a deletion flow with `.destroyable()` (registers `destroy`).
293
+ *
294
+ * @example
295
+ * router.singleton('profile', ProfileController) // /profile + /profile/edit
296
+ * router.singleton('profile', ProfileController).creatable() // also /profile/create + POST /profile
297
+ */
298
+ singleton(name: string, Ctrl: new () => object, opts?: ResourceOptions): SingletonRegistration;
299
+ /** @internal — shared registration loop for resource/apiResource/singleton. */
300
+ _registerResource(name: string, Ctrl: new () => object, table: readonly ResourceVerbSpec[], opts: ResourceOptions): ResourceRegistration;
301
+ }
302
+ /** The seven canonical RESTful verbs Laravel's `Route::resource` exposes. */
303
+ export type ResourceVerb = 'index' | 'create' | 'store' | 'show' | 'edit' | 'update' | 'destroy';
304
+ interface ResourceVerbSpec {
305
+ verb: ResourceVerb;
306
+ method: HttpMethod;
307
+ path: (name: string, param: string) => string;
308
+ nameSuffix: string;
309
+ }
310
+ /**
311
+ * Options accepted by `router.resource`/`apiResource`/`singleton`.
312
+ *
313
+ * - `only`/`except` — restrict the verbs registered.
314
+ * - `parameters` — override the `:param` segment name for a given resource
315
+ * (e.g. `{ posts: 'article' }` → `/posts/:article`).
316
+ * - `names` — override the generated route names per verb.
317
+ * - `middleware` — applied to every route registered by the resource.
318
+ */
319
+ export interface ResourceOptions {
320
+ only?: readonly ResourceVerb[];
321
+ except?: readonly ResourceVerb[];
322
+ parameters?: Record<string, string>;
323
+ names?: Partial<Record<ResourceVerb, string>>;
324
+ middleware?: MiddlewareHandler[];
325
+ }
326
+ /**
327
+ * Returned by `router.resource()`/`apiResource()`. The `builders` array holds
328
+ * one `RouteBuilder` per registered route in declaration order — apply
329
+ * `where*()`, additional middleware, or rename individual routes by indexing
330
+ * directly. The `update` PATCH alias is included as a separate builder
331
+ * immediately after its PUT counterpart.
332
+ */
333
+ export declare class ResourceRegistration {
334
+ readonly builders: RouteBuilder[];
335
+ constructor(builders: RouteBuilder[]);
336
+ }
337
+ /**
338
+ * Returned by `router.singleton()`. Adds two opt-in helpers on top of
339
+ * `ResourceRegistration` for resources that also expose a creation flow
340
+ * (`.creatable()`) or deletion flow (`.destroyable()`).
341
+ */
342
+ export declare class SingletonRegistration extends ResourceRegistration {
343
+ private readonly _router;
344
+ private readonly _name;
345
+ private readonly _Ctrl;
346
+ private readonly _opts;
347
+ constructor(builders: RouteBuilder[], _router: Router, _name: string, _Ctrl: new () => object, _opts: ResourceOptions);
348
+ /**
349
+ * Add `GET /<name>/create` and `POST /<name>` — the create/store half of a
350
+ * full resource. Skipped for any verb the controller doesn't implement.
351
+ */
352
+ creatable(): this;
353
+ /**
354
+ * Add `DELETE /<name>` — the destroy half of a full resource. Skipped if
355
+ * the controller doesn't implement `destroy()`.
356
+ */
357
+ destroyable(): this;
69
358
  }
70
359
  export declare const router: Router;
71
360
  /** Alias for router — Laravel-style capitalised name */
@@ -126,4 +415,5 @@ export declare class Url {
126
415
  * router.get('/invoice/:id/download', handler, [ValidateSignature()])
127
416
  */
128
417
  export declare function ValidateSignature(): MiddlewareHandler;
418
+ export {};
129
419
  //# sourceMappingURL=index.d.ts.map