@rudderjs/router 1.0.0 → 1.1.1

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