@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/README.md +214 -2
- package/boost/guidelines.md +53 -0
- package/dist/index.d.ts +296 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +614 -16
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
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,
|
|
120
|
-
this.namedRoutes.set(name,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
599
|
+
path: composed.path,
|
|
198
600
|
handler,
|
|
199
|
-
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
|
-
|
|
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
|
-
|
|
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]);
|