@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/dist/index.js CHANGED
@@ -89,12 +89,55 @@ export const Put = createMethodDecorator('PUT');
89
89
  export const Patch = createMethodDecorator('PATCH');
90
90
  export const Delete = createMethodDecorator('DELETE');
91
91
  export const Options = createMethodDecorator('OPTIONS');
92
+ // ─── Path utilities ────────────────────────────────────────
93
+ /**
94
+ * Remove every balanced `{...}` block from a path. Used to peel off
95
+ * `where*()` regex constraint segments before scanning the path for `:param`
96
+ * names. Brace nesting is honoured so quantifier braces (`{8}`, `{4}`) inside
97
+ * a constraint don't terminate the block early.
98
+ */
99
+ function stripRegexSegments(path) {
100
+ let out = '';
101
+ let i = 0;
102
+ while (i < path.length) {
103
+ if (path[i] !== '{') {
104
+ out += path[i];
105
+ i++;
106
+ continue;
107
+ }
108
+ let depth = 1;
109
+ i++;
110
+ while (i < path.length && depth > 0) {
111
+ if (path[i] === '{')
112
+ depth++;
113
+ else if (path[i] === '}')
114
+ depth--;
115
+ i++;
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ // ─── Route param constraint patterns ──────────────────────
121
+ //
122
+ // Reusable regex shards consumed by `RouteBuilder.where*()` and exported so
123
+ // app code can compose its own Hono `:param{pattern}` strings if needed.
124
+ /** Matches one or more digits — `[0-9]+`. */
125
+ export const ROUTE_PATTERN_NUMBER = '[0-9]+';
126
+ /** Matches one or more ASCII letters — `[A-Za-z]+`. */
127
+ export const ROUTE_PATTERN_ALPHA = '[A-Za-z]+';
128
+ /** Matches one or more ASCII letters or digits — `[A-Za-z0-9]+`. */
129
+ export const ROUTE_PATTERN_ALPHANUM = '[A-Za-z0-9]+';
130
+ /** Matches a UUID of any version (case-insensitive). */
131
+ export 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}';
132
+ /** Matches a Crockford base32 ULID (26 chars). */
133
+ export const ROUTE_PATTERN_ULID = '[0-7][0-9A-HJKMNP-TV-Z]{25}';
92
134
  // ─── RouteBuilder ──────────────────────────────────────────
93
135
  /**
94
- * Returned by `router.get/post/etc` — allows naming the registered route.
136
+ * Returned by `router.get/post/etc` — allows naming the registered route and
137
+ * constraining `:param` segments via Laravel-style `where*` shortcuts.
95
138
  *
96
139
  * @example
97
- * router.get('/users/:id', handler).name('users.show')
140
+ * router.get('/users/:id', handler).name('users.show').whereNumber('id')
98
141
  * route('users.show', { id: 1 }) // → '/users/1'
99
142
  */
100
143
  export class RouteBuilder {
@@ -106,22 +149,164 @@ export class RouteBuilder {
106
149
  }
107
150
  /** Assign a name to this route for use with `route()` and `Url.signedRoute()`. */
108
151
  name(n) {
109
- this._router._registerName(n, this.definition.path);
152
+ // Pass the definition itself so later `where*()` calls (which mutate
153
+ // `definition.path`) are reflected when the named route is looked up.
154
+ this._router._registerName(n, this.definition);
155
+ return this;
156
+ }
157
+ /**
158
+ * Constrain a `:param` segment with a custom regex. Accepts either a string
159
+ * (used verbatim) or a `RegExp` (its `.source` is taken — `/^/`, `/$/`, and
160
+ * flags are ignored, since Hono anchors per-segment).
161
+ *
162
+ * Mutates the route's path in place to `:param{pattern}` (Hono regex syntax).
163
+ * Calling `where*` again on the same param overwrites the previous pattern.
164
+ *
165
+ * Throws if the route path has no `:param` segment.
166
+ */
167
+ where(param, regex) {
168
+ const pattern = regex instanceof RegExp ? regex.source : regex;
169
+ const path = this.definition.path;
170
+ let out = '';
171
+ let matched = false;
172
+ let i = 0;
173
+ while (i < path.length) {
174
+ if (path[i] !== ':') {
175
+ out += path[i];
176
+ i++;
177
+ continue;
178
+ }
179
+ // Scan a `:paramName(?)?{balanced regex}?` segment.
180
+ let j = i + 1;
181
+ while (j < path.length && /[A-Za-z0-9_]/.test(path[j] ?? ''))
182
+ j++;
183
+ const name = path.slice(i + 1, j);
184
+ let opt = '';
185
+ if (path[j] === '?') {
186
+ opt = '?';
187
+ j++;
188
+ }
189
+ // Consume a balanced `{ ... }` block (handles `[0-9]{8}`-style nesting).
190
+ let bodyEnd = j;
191
+ if (path[j] === '{') {
192
+ let depth = 1;
193
+ bodyEnd = j + 1;
194
+ while (bodyEnd < path.length && depth > 0) {
195
+ if (path[bodyEnd] === '{')
196
+ depth++;
197
+ else if (path[bodyEnd] === '}')
198
+ depth--;
199
+ bodyEnd++;
200
+ }
201
+ }
202
+ if (name === param) {
203
+ out += `:${name}${opt}{${pattern}}`;
204
+ matched = true;
205
+ }
206
+ else {
207
+ out += path.slice(i, bodyEnd);
208
+ }
209
+ i = bodyEnd;
210
+ }
211
+ if (!matched) {
212
+ throw new Error(`[RudderJS Router] where("${param}", ...) — route path "${path}" has no :${param} segment.`);
213
+ }
214
+ this.definition.path = out;
110
215
  return this;
111
216
  }
217
+ /** Constrain `:param` to one or more digits. */
218
+ whereNumber(param) { return this.where(param, ROUTE_PATTERN_NUMBER); }
219
+ /** Constrain `:param` to one or more ASCII letters. */
220
+ whereAlpha(param) { return this.where(param, ROUTE_PATTERN_ALPHA); }
221
+ /** Constrain `:param` to one or more ASCII letters or digits. */
222
+ whereAlphaNumeric(param) { return this.where(param, ROUTE_PATTERN_ALPHANUM); }
223
+ /** Constrain `:param` to a UUID of any version. */
224
+ whereUuid(param) { return this.where(param, ROUTE_PATTERN_UUID); }
225
+ /** Constrain `:param` to a Crockford base32 ULID. */
226
+ whereUlid(param) { return this.where(param, ROUTE_PATTERN_ULID); }
227
+ /**
228
+ * Constrain `:param` to one of the supplied literal values. Each value is
229
+ * regex-escaped, so `'a.b'` matches the literal string `a.b`, not "a then any
230
+ * char then b". Throws when `values` is empty.
231
+ *
232
+ * @example
233
+ * router.get('/posts/:status', handler).whereIn('status', ['draft', 'published'])
234
+ */
235
+ whereIn(param, values) {
236
+ if (values.length === 0) {
237
+ throw new Error(`[RudderJS Router] whereIn("${param}", []) — values must be non-empty.`);
238
+ }
239
+ const escaped = values.map(v => String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
240
+ return this.where(param, `(?:${escaped.join('|')})`);
241
+ }
242
+ /**
243
+ * Restrict this route to a specific subdomain. The template is matched against
244
+ * the request's `Host` header (port stripped, case-insensitive); `:param`
245
+ * segments capture into `req.params` alongside path params.
246
+ *
247
+ * @example
248
+ * router.get('/users', listUsers).domain('api.example.com')
249
+ * router.get('/admin', dash).domain(':tenant.example.com') // captures req.params.tenant
250
+ */
251
+ domain(template) {
252
+ this.definition.host = template;
253
+ return this;
254
+ }
255
+ /**
256
+ * Custom response when an explicit route binding (`router.bind('user', User)`)
257
+ * resolves to `null`. Receives the request and the not-found error; return any
258
+ * value a handler may return (`Response`, plain object → JSON, string → body,
259
+ * or `undefined` after writing to `res` directly). Does not fire for optional
260
+ * bindings — those quietly resolve to `null` instead.
261
+ *
262
+ * @example
263
+ * router.get('/users/:user', show)
264
+ * .missing((_req, err) => Response.json({ error: err.message }, { status: 404 }))
265
+ */
266
+ missing(fn) {
267
+ this.definition.missing = fn;
268
+ return this;
269
+ }
270
+ }
271
+ /**
272
+ * Thrown by route binding middleware when a required `{param}` cannot be
273
+ * resolved into a model instance. `@rudderjs/core` picks up the duck-typed
274
+ * `httpStatus` and renders this as an HTTP 404; apps can catch it explicitly
275
+ * to render a custom not-found page.
276
+ */
277
+ export class RouteModelNotFoundError extends Error {
278
+ model;
279
+ param;
280
+ value;
281
+ /** Duck-typed signal to `@rudderjs/core`'s exception handler. */
282
+ httpStatus = 404;
283
+ constructor(model, param, value) {
284
+ super(`[RudderJS] No ${model} matched route parameter "${param}" with value "${value}".`);
285
+ this.name = 'RouteModelNotFoundError';
286
+ this.model = model;
287
+ this.param = param;
288
+ this.value = value;
289
+ }
112
290
  }
113
291
  // ─── Router ────────────────────────────────────────────────
114
292
  export class Router {
115
293
  routes = [];
116
294
  globalMiddleware = [];
117
295
  namedRoutes = new Map();
296
+ bindings = new Map();
297
+ /**
298
+ * Active `group()` scopes, outermost first. Synchronous module-level state
299
+ * is fine — route loaders execute synchronously at module evaluation, and
300
+ * `group()` only mutates the stack inside its own callback's lifetime.
301
+ */
302
+ _groupStack = [];
118
303
  /** @internal — called by RouteBuilder */
119
- _registerName(name, path) {
120
- this.namedRoutes.set(name, path);
304
+ _registerName(name, def) {
305
+ this.namedRoutes.set(name, def);
121
306
  }
122
- /** Look up a named route's path. */
307
+ /** Look up a named route's path. Reflects any `where*()` mutations. */
123
308
  getNamedRoute(name) {
124
- return this.namedRoutes.get(name);
309
+ return this.namedRoutes.get(name)?.path;
125
310
  }
126
311
  /**
127
312
  * Check whether a named route is registered.
@@ -138,23 +323,209 @@ export class Router {
138
323
  }
139
324
  /** All registered named routes. */
140
325
  listNamed() {
141
- return Object.fromEntries(this.namedRoutes);
326
+ const out = {};
327
+ for (const [name, def] of this.namedRoutes)
328
+ out[name] = def.path;
329
+ return out;
142
330
  }
143
- /** Clear registered routes, middleware, and named routes. */
331
+ /** Clear registered routes, middleware, named routes, and route bindings. */
144
332
  reset() {
145
333
  this.routes = [];
146
334
  this.globalMiddleware = [];
147
335
  this.namedRoutes.clear();
336
+ this.bindings.clear();
337
+ this._groupStack = [];
338
+ return this;
339
+ }
340
+ /**
341
+ * Run `fn` with a group scope active. Every route registered (via fluent
342
+ * `.get()`/`.post()`/etc. or `registerController()`) inside `fn` inherits
343
+ * the group's `prefix`, `domain`, and `middleware`. Nested calls compose:
344
+ * prefixes concatenate, middleware stacks accumulate, the innermost defined
345
+ * `domain` wins.
346
+ *
347
+ * Distinct from `runWithGroup('web' | 'api', …)` — that tags routes with
348
+ * their middleware-group label (web vs api) and is called once by the
349
+ * framework's route loader. `router.group()` is the user-facing scoping
350
+ * primitive; both can be active at the same time.
351
+ *
352
+ * @example
353
+ * router.group({ prefix: '/admin', middleware: [adminAuth] }, () => {
354
+ * router.get('/users', listUsers) // GET /admin/users (with adminAuth)
355
+ * router.get('/posts', listPosts) // GET /admin/posts (with adminAuth)
356
+ * })
357
+ *
358
+ * router.group({ domain: ':tenant.example.com', prefix: '/api' }, () => {
359
+ * router.get('/me', me) // GET :tenant.example.com/api/me
360
+ * })
361
+ */
362
+ group(opts, fn) {
363
+ this._groupStack = [...this._groupStack, opts];
364
+ try {
365
+ fn();
366
+ }
367
+ finally {
368
+ this._groupStack = this._groupStack.slice(0, -1);
369
+ }
148
370
  return this;
149
371
  }
372
+ /**
373
+ * Compose the active group stack into the values used to register a route.
374
+ * Path prefixes concatenate (and collapse `/+` to `/`), middleware stacks
375
+ * concatenate, the innermost defined `host` wins.
376
+ */
377
+ _applyGroupStack(path, middleware) {
378
+ if (this._groupStack.length === 0) {
379
+ return { path, middleware, host: undefined };
380
+ }
381
+ let prefix = '';
382
+ let host;
383
+ const groupMw = [];
384
+ for (const g of this._groupStack) {
385
+ if (g.prefix)
386
+ prefix += g.prefix;
387
+ if (g.domain)
388
+ host = g.domain;
389
+ if (g.middleware)
390
+ groupMw.push(...g.middleware);
391
+ }
392
+ const composedPath = `${prefix}${path}`.replace(/\/+/g, '/');
393
+ return {
394
+ path: composedPath,
395
+ middleware: [...groupMw, ...middleware],
396
+ host,
397
+ };
398
+ }
150
399
  /** Register a global middleware (runs on every route). */
151
400
  use(middleware) {
152
401
  this.globalMiddleware.push(middleware);
153
402
  return this;
154
403
  }
404
+ /**
405
+ * Bind a route parameter name to a resolver. When a route's path contains
406
+ * `:<name>`, the matching string param is resolved before the handler runs;
407
+ * the result is exposed as `req.bound[name]`. The raw string remains in
408
+ * `req.params[name]` so existing code keeps working.
409
+ *
410
+ * Resolvers are duck-typed — pass any class with a static `findForRoute(val)`
411
+ * method (`@rudderjs/orm` Model classes match by default). Bindings are
412
+ * opt-in: routes whose path does not include the bound `:name` are unaffected.
413
+ *
414
+ * @example
415
+ * import { router } from '@rudderjs/router'
416
+ * import { User } from '../app/Models/User.js'
417
+ *
418
+ * router.bind('user', User)
419
+ * router.get('/users/:user', (req) => req.bound!['user'])
420
+ *
421
+ * // Custom column → declare on the model:
422
+ * class Post extends Model {
423
+ * static override routeKey = 'slug'
424
+ * }
425
+ * router.bind('post', Post) // resolves /posts/:post by slug
426
+ *
427
+ * // Optional binding — null when missing instead of 404:
428
+ * router.bind('viewer', User, { optional: true })
429
+ */
430
+ bind(name, resolver, options = {}) {
431
+ this.bindings.set(name, { resolver, optional: options.optional ?? false });
432
+ return this;
433
+ }
434
+ /** All registered route bindings, keyed by param name. */
435
+ listBindings() {
436
+ const out = {};
437
+ for (const [name, binding] of this.bindings)
438
+ out[name] = binding.resolver;
439
+ return out;
440
+ }
441
+ /**
442
+ * Build the per-route binding middleware. Walks the route's `:param` segments,
443
+ * looks them up in the binding map, and resolves each before calling `next()`.
444
+ * No-op for routes whose path contains no bound params.
445
+ *
446
+ * Takes the full `RouteDefinition` (not just path) so the closure can capture
447
+ * `def.missing` — the per-route 404 customisation set via `RouteBuilder.missing()`.
448
+ */
449
+ _buildBindingMiddleware(def) {
450
+ // Strip `{regex}` constraint segments from `where*()` before scanning for
451
+ // param names — otherwise a `:` inside a custom pattern could be misread
452
+ // as a route param. Uses balanced-brace stripping to support nested `{n}`
453
+ // quantifiers (e.g. UUID's `[0-9a-f]{8}-...`).
454
+ const stripped = stripRegexSegments(def.path);
455
+ const paramNames = [...stripped.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)\??/g)].map(m => m[1]);
456
+ const matches = [];
457
+ for (const name of paramNames) {
458
+ const binding = this.bindings.get(name);
459
+ if (binding)
460
+ matches.push([name, binding]);
461
+ }
462
+ if (matches.length === 0)
463
+ return null;
464
+ return async (req, res, next) => {
465
+ // Lazy-init bound bag so handlers always see an object.
466
+ const bound = req.bound ?? {};
467
+ req.bound = bound;
468
+ for (const [name, binding] of matches) {
469
+ const raw = req.params[name];
470
+ let err = null;
471
+ if (raw === undefined || raw === '') {
472
+ if (binding.optional) {
473
+ bound[name] = null;
474
+ continue;
475
+ }
476
+ err = new RouteModelNotFoundError(binding.resolver.name, name, '');
477
+ }
478
+ else {
479
+ const resolved = await binding.resolver.findForRoute(raw);
480
+ if (resolved === null || resolved === undefined) {
481
+ if (binding.optional) {
482
+ bound[name] = null;
483
+ continue;
484
+ }
485
+ err = new RouteModelNotFoundError(binding.resolver.name, name, raw);
486
+ }
487
+ else {
488
+ bound[name] = resolved;
489
+ }
490
+ }
491
+ if (err) {
492
+ if (def.missing) {
493
+ // Route opted into a custom 404 — dispatch the result the same
494
+ // way registerRoute() handles a route handler's return value.
495
+ const result = await def.missing(req, err);
496
+ if (result instanceof Response) {
497
+ ;
498
+ res.raw.res = result;
499
+ return;
500
+ }
501
+ if (typeof result === 'string') {
502
+ res.send(result);
503
+ return;
504
+ }
505
+ if (result !== undefined && result !== null) {
506
+ res.json(result);
507
+ return;
508
+ }
509
+ // undefined → callback wrote to res directly; trust that.
510
+ return;
511
+ }
512
+ throw err;
513
+ }
514
+ }
515
+ await next();
516
+ };
517
+ }
155
518
  /** Manually register a route. Returns `this` for bulk registration. */
156
519
  add(method, path, handler, middleware = []) {
157
- const def = { method, path, handler, middleware };
520
+ const composed = this._applyGroupStack(path, middleware);
521
+ const def = {
522
+ method,
523
+ path: composed.path,
524
+ handler,
525
+ middleware: composed.middleware,
526
+ };
527
+ if (composed.host)
528
+ def.host = composed.host;
158
529
  const group = currentGroup();
159
530
  if (group)
160
531
  def.group = group;
@@ -169,7 +540,15 @@ export class Router {
169
540
  delete(path, handler, middleware) { return this._rb('DELETE', path, handler, middleware); }
170
541
  all(path, handler, middleware) { return this._rb('ALL', path, handler, middleware); }
171
542
  _rb(method, path, handler, middleware = []) {
172
- const def = { method, path, handler, middleware };
543
+ const composed = this._applyGroupStack(path, middleware);
544
+ const def = {
545
+ method,
546
+ path: composed.path,
547
+ handler,
548
+ middleware: composed.middleware,
549
+ };
550
+ if (composed.host)
551
+ def.host = composed.host;
173
552
  const group = currentGroup();
174
553
  if (group)
175
554
  def.group = group;
@@ -192,12 +571,15 @@ export class Router {
192
571
  value: `${ControllerClass.name}@${String(route.handlerKey)}`,
193
572
  configurable: true,
194
573
  });
574
+ const composed = this._applyGroupStack(fullPath, [...ctrlMw, ...route.middleware]);
195
575
  const def = {
196
576
  method: route.method,
197
- path: fullPath,
577
+ path: composed.path,
198
578
  handler,
199
- middleware: [...ctrlMw, ...route.middleware],
579
+ middleware: composed.middleware,
200
580
  };
581
+ if (composed.host)
582
+ def.host = composed.host;
201
583
  if (group)
202
584
  def.group = group;
203
585
  this.routes.push(def);
@@ -208,13 +590,204 @@ export class Router {
208
590
  mount(server) {
209
591
  for (const mw of this.globalMiddleware)
210
592
  server.applyMiddleware(mw);
211
- for (const route of this.routes)
212
- server.registerRoute(route);
593
+ for (const route of this.routes) {
594
+ const bindingMw = this._buildBindingMiddleware(route);
595
+ if (bindingMw) {
596
+ server.registerRoute({
597
+ ...route,
598
+ middleware: [bindingMw, ...route.middleware],
599
+ });
600
+ }
601
+ else {
602
+ server.registerRoute(route);
603
+ }
604
+ }
213
605
  }
214
606
  /** All registered routes — useful for `routes:list`. */
215
607
  list() {
216
608
  return [...this.routes];
217
609
  }
610
+ // ── Resource controllers ───────────────────────────────────
611
+ /**
612
+ * Register the canonical seven CRUD routes for a plain controller class:
613
+ * `index`, `create`, `store`, `show`, `edit`, `update`, `destroy`. Methods
614
+ * the controller doesn't implement are silently skipped, so a partial
615
+ * controller works without `only`/`except` boilerplate.
616
+ *
617
+ * The `update` route is registered for both `PUT` and `PATCH`. Route names
618
+ * default to `<name>.<verb>` (`posts.show`, `posts.update`). The path param
619
+ * defaults to a naive singular (`posts` → `:post`); pass
620
+ * `{ parameters: { posts: 'article' } }` to override.
621
+ *
622
+ * Use plain method names — no decorators. Call `router.registerController()`
623
+ * for decorator-driven controllers instead.
624
+ *
625
+ * @example
626
+ * router.resource('posts', PostController)
627
+ * router.resource('posts', PostController, { only: ['index', 'show'] })
628
+ * router.resource('posts', PostController, { middleware: [authMw] })
629
+ */
630
+ resource(name, Ctrl, opts = {}) {
631
+ return this._registerResource(name, Ctrl, RESOURCE_VERBS, opts);
632
+ }
633
+ /**
634
+ * Register an API-only resource — the same routes as `resource()` minus
635
+ * `create` and `edit`, since those render HTML forms and have no JSON
636
+ * equivalent.
637
+ *
638
+ * @example
639
+ * router.apiResource('posts', PostController)
640
+ */
641
+ apiResource(name, Ctrl, opts = {}) {
642
+ return this._registerResource(name, Ctrl, RESOURCE_VERBS, {
643
+ ...opts,
644
+ except: [...(opts.except ?? []), 'create', 'edit'],
645
+ });
646
+ }
647
+ /**
648
+ * Register a singleton resource — `show`, `edit`, `update` only. Use for
649
+ * "the current user's profile" / "the application's settings" style
650
+ * resources where there's only ever one of the thing.
651
+ *
652
+ * Add a creation flow with `.creatable()` (registers `create` + `store`) or
653
+ * a deletion flow with `.destroyable()` (registers `destroy`).
654
+ *
655
+ * @example
656
+ * router.singleton('profile', ProfileController) // /profile + /profile/edit
657
+ * router.singleton('profile', ProfileController).creatable() // also /profile/create + POST /profile
658
+ */
659
+ singleton(name, Ctrl, opts = {}) {
660
+ const reg = this._registerResource(name, Ctrl, SINGLETON_VERBS, opts);
661
+ return new SingletonRegistration(reg.builders, this, name, Ctrl, opts);
662
+ }
663
+ /** @internal — shared registration loop for resource/apiResource/singleton. */
664
+ _registerResource(name, Ctrl, table, opts) {
665
+ const instance = new Ctrl();
666
+ const verbs = filterVerbs(table, opts);
667
+ const paramName = opts.parameters?.[name] ?? singularize(name);
668
+ const builders = [];
669
+ for (const spec of verbs) {
670
+ const fn = instance[spec.verb];
671
+ if (typeof fn !== 'function')
672
+ continue; // partial controller — skip
673
+ const path = spec.path(name, paramName);
674
+ const handler = fn.bind(instance);
675
+ Object.defineProperty(handler, 'name', {
676
+ value: `${Ctrl.name}@${spec.verb}`,
677
+ configurable: true,
678
+ });
679
+ const builder = this._rb(spec.method, path, handler, opts.middleware ?? []);
680
+ builder.name(opts.names?.[spec.verb] ?? `${name}.${spec.nameSuffix}`);
681
+ builders.push(builder);
682
+ if (spec.verb === 'update') {
683
+ // `update` registers PUT + PATCH at the same path. The PATCH route
684
+ // doesn't get a name to avoid a collision with the PUT route's name —
685
+ // both verbs resolve the same path, so `route('posts.update')` works
686
+ // for either.
687
+ const patch = this._rb('PATCH', path, handler, opts.middleware ?? []);
688
+ builders.push(patch);
689
+ }
690
+ }
691
+ return new ResourceRegistration(builders);
692
+ }
693
+ }
694
+ const RESOURCE_VERBS = [
695
+ { verb: 'index', method: 'GET', path: (n) => `/${n}`, nameSuffix: 'index' },
696
+ { verb: 'create', method: 'GET', path: (n) => `/${n}/create`, nameSuffix: 'create' },
697
+ { verb: 'store', method: 'POST', path: (n) => `/${n}`, nameSuffix: 'store' },
698
+ { verb: 'show', method: 'GET', path: (n, p) => `/${n}/:${p}`, nameSuffix: 'show' },
699
+ { verb: 'edit', method: 'GET', path: (n, p) => `/${n}/:${p}/edit`, nameSuffix: 'edit' },
700
+ { verb: 'update', method: 'PUT', path: (n, p) => `/${n}/:${p}`, nameSuffix: 'update' },
701
+ { verb: 'destroy', method: 'DELETE', path: (n, p) => `/${n}/:${p}`, nameSuffix: 'destroy' },
702
+ ];
703
+ const SINGLETON_VERBS = [
704
+ { verb: 'show', method: 'GET', path: (n) => `/${n}`, nameSuffix: 'show' },
705
+ { verb: 'edit', method: 'GET', path: (n) => `/${n}/edit`, nameSuffix: 'edit' },
706
+ { verb: 'update', method: 'PUT', path: (n) => `/${n}`, nameSuffix: 'update' },
707
+ ];
708
+ const SINGLETON_CREATE_VERBS = [
709
+ { verb: 'create', method: 'GET', path: (n) => `/${n}/create`, nameSuffix: 'create' },
710
+ { verb: 'store', method: 'POST', path: (n) => `/${n}`, nameSuffix: 'store' },
711
+ ];
712
+ const SINGLETON_DESTROY_VERBS = [
713
+ { verb: 'destroy', method: 'DELETE', path: (n) => `/${n}`, nameSuffix: 'destroy' },
714
+ ];
715
+ function filterVerbs(table, opts) {
716
+ let verbs = table;
717
+ if (opts.only) {
718
+ const allow = new Set(opts.only);
719
+ verbs = verbs.filter(v => allow.has(v.verb));
720
+ }
721
+ if (opts.except) {
722
+ const deny = new Set(opts.except);
723
+ verbs = verbs.filter(v => !deny.has(v.verb));
724
+ }
725
+ return verbs;
726
+ }
727
+ /**
728
+ * Naive English singularizer for the default resource param name. Handles the
729
+ * three patterns Laravel users hit constantly (`posts → post`,
730
+ * `categories → category`, `boxes → box`). Anything irregular — `people`,
731
+ * `data`, etc. — should be overridden via the `parameters` option, exactly
732
+ * as in Laravel.
733
+ */
734
+ function singularize(name) {
735
+ if (/[^aeiou]ies$/i.test(name))
736
+ return name.slice(0, -3) + 'y'; // categories → category
737
+ if (/(s|x|z|ch|sh)es$/i.test(name))
738
+ return name.slice(0, -2); // boxes → box
739
+ if (/s$/i.test(name) && !/ss$/i.test(name))
740
+ return name.slice(0, -1); // posts → post
741
+ return name;
742
+ }
743
+ /**
744
+ * Returned by `router.resource()`/`apiResource()`. The `builders` array holds
745
+ * one `RouteBuilder` per registered route in declaration order — apply
746
+ * `where*()`, additional middleware, or rename individual routes by indexing
747
+ * directly. The `update` PATCH alias is included as a separate builder
748
+ * immediately after its PUT counterpart.
749
+ */
750
+ export class ResourceRegistration {
751
+ builders;
752
+ constructor(builders) {
753
+ this.builders = builders;
754
+ }
755
+ }
756
+ /**
757
+ * Returned by `router.singleton()`. Adds two opt-in helpers on top of
758
+ * `ResourceRegistration` for resources that also expose a creation flow
759
+ * (`.creatable()`) or deletion flow (`.destroyable()`).
760
+ */
761
+ export class SingletonRegistration extends ResourceRegistration {
762
+ _router;
763
+ _name;
764
+ _Ctrl;
765
+ _opts;
766
+ constructor(builders, _router, _name, _Ctrl, _opts) {
767
+ super(builders);
768
+ this._router = _router;
769
+ this._name = _name;
770
+ this._Ctrl = _Ctrl;
771
+ this._opts = _opts;
772
+ }
773
+ /**
774
+ * Add `GET /<name>/create` and `POST /<name>` — the create/store half of a
775
+ * full resource. Skipped for any verb the controller doesn't implement.
776
+ */
777
+ creatable() {
778
+ const reg = this._router._registerResource(this._name, this._Ctrl, SINGLETON_CREATE_VERBS, this._opts);
779
+ this.builders.push(...reg.builders);
780
+ return this;
781
+ }
782
+ /**
783
+ * Add `DELETE /<name>` — the destroy half of a full resource. Skipped if
784
+ * the controller doesn't implement `destroy()`.
785
+ */
786
+ destroyable() {
787
+ const reg = this._router._registerResource(this._name, this._Ctrl, SINGLETON_DESTROY_VERBS, this._opts);
788
+ this.builders.push(...reg.builders);
789
+ return this;
790
+ }
218
791
  }
219
792
  // ─── Global router instance ────────────────────────────────
220
793
  export const router = new Router();
@@ -237,7 +810,10 @@ export function route(name, params = {}) {
237
810
  if (path === undefined)
238
811
  throw new Error(`[RudderJS] Named route "${name}" is not defined.`);
239
812
  const used = new Set();
240
- let result = path.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)(\?)?/g, (_match, key, optional) => {
813
+ // Strip `:param{regex}` constraint segments before substitution so the
814
+ // simple param-name regex doesn't get confused by nested braces (UUID's
815
+ // `[0-9a-f]{8}-...{12}` etc.).
816
+ let result = stripRegexSegments(path).replace(/:([a-zA-Z_][a-zA-Z0-9_]*)(\?)?/g, (_match, key, optional) => {
241
817
  if (key in params) {
242
818
  used.add(key);
243
819
  return String(params[key]);