@setu-ts/kernel 0.0.0-stage → 0.9.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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -2
  3. package/dist/application/application.d.ts +172 -0
  4. package/dist/application/application.d.ts.map +1 -0
  5. package/dist/application/application.js +1862 -0
  6. package/dist/application/application.js.map +1 -0
  7. package/dist/application/inject-body.d.ts +58 -0
  8. package/dist/application/inject-body.d.ts.map +1 -0
  9. package/dist/application/inject-body.js +82 -0
  10. package/dist/application/inject-body.js.map +1 -0
  11. package/dist/context/request-context.d.ts +32 -0
  12. package/dist/context/request-context.d.ts.map +1 -0
  13. package/dist/context/request-context.js +136 -0
  14. package/dist/context/request-context.js.map +1 -0
  15. package/dist/context/response.d.ts +60 -0
  16. package/dist/context/response.d.ts.map +1 -0
  17. package/dist/context/response.js +193 -0
  18. package/dist/context/response.js.map +1 -0
  19. package/dist/diagnostics/buffer.d.ts +143 -0
  20. package/dist/diagnostics/buffer.d.ts.map +1 -0
  21. package/dist/diagnostics/buffer.js +185 -0
  22. package/dist/diagnostics/buffer.js.map +1 -0
  23. package/dist/diagnostics/collector.d.ts +284 -0
  24. package/dist/diagnostics/collector.d.ts.map +1 -0
  25. package/dist/diagnostics/collector.js +812 -0
  26. package/dist/diagnostics/collector.js.map +1 -0
  27. package/dist/diagnostics/projection.d.ts +153 -0
  28. package/dist/diagnostics/projection.d.ts.map +1 -0
  29. package/dist/diagnostics/projection.js +226 -0
  30. package/dist/diagnostics/projection.js.map +1 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +11 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
  36. package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
  37. package/dist/lifecycle/lifecycle-manager.js +206 -0
  38. package/dist/lifecycle/lifecycle-manager.js.map +1 -0
  39. package/dist/pipeline/execute-chain.d.ts +71 -0
  40. package/dist/pipeline/execute-chain.d.ts.map +1 -0
  41. package/dist/pipeline/execute-chain.js +104 -0
  42. package/dist/pipeline/execute-chain.js.map +1 -0
  43. package/dist/pipeline/middleware-pipeline.d.ts +68 -0
  44. package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
  45. package/dist/pipeline/middleware-pipeline.js +82 -0
  46. package/dist/pipeline/middleware-pipeline.js.map +1 -0
  47. package/dist/registry/plugin-resolver.d.ts +42 -0
  48. package/dist/registry/plugin-resolver.d.ts.map +1 -0
  49. package/dist/registry/plugin-resolver.js +140 -0
  50. package/dist/registry/plugin-resolver.js.map +1 -0
  51. package/dist/registry/service-registry.d.ts +110 -0
  52. package/dist/registry/service-registry.d.ts.map +1 -0
  53. package/dist/registry/service-registry.js +197 -0
  54. package/dist/registry/service-registry.js.map +1 -0
  55. package/dist/router/route-matcher.d.ts +85 -0
  56. package/dist/router/route-matcher.d.ts.map +1 -0
  57. package/dist/router/route-matcher.js +102 -0
  58. package/dist/router/route-matcher.js.map +1 -0
  59. package/dist/router/router.d.ts +130 -0
  60. package/dist/router/router.d.ts.map +1 -0
  61. package/dist/router/router.js +350 -0
  62. package/dist/router/router.js.map +1 -0
  63. package/package.json +48 -3
  64. package/src/application/application.ts +2299 -0
  65. package/src/application/inject-body.ts +131 -0
  66. package/src/context/request-context.ts +169 -0
  67. package/src/context/response.ts +254 -0
  68. package/src/diagnostics/buffer.ts +241 -0
  69. package/src/diagnostics/collector.ts +1031 -0
  70. package/src/diagnostics/projection.ts +322 -0
  71. package/src/index.ts +20 -0
  72. package/src/lifecycle/lifecycle-manager.ts +287 -0
  73. package/src/pipeline/execute-chain.ts +164 -0
  74. package/src/pipeline/middleware-pipeline.ts +131 -0
  75. package/src/registry/plugin-resolver.ts +185 -0
  76. package/src/registry/service-registry.ts +267 -0
  77. package/src/router/route-matcher.ts +131 -0
  78. package/src/router/router.ts +485 -0
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Router — programmatic route registration with method-based matching,
3
+ * parameter extraction, group facades, and static-over-param preference.
4
+ *
5
+ * Route matching is delegated to Hono as of Milestone 22; the kernel
6
+ * maintains a `RouteEntry` map so it can return `{ definition, params }`
7
+ * in the same shape the pipeline terminal expects, and apply its own
8
+ * deterministic tie-break (§3.6) when Hono returns multiple candidates.
9
+ *
10
+ * @module
11
+ */
12
+ import type {
13
+ HttpMethod,
14
+ IRouterApi,
15
+ RouteDefinition,
16
+ RouteHandler,
17
+ RouteInfo,
18
+ } from '@setu-ts/common';
19
+
20
+ import { parsePattern, staticSegmentCount, wildcardSegmentCount } from './route-matcher.ts';
21
+
22
+ export interface RouteEntry {
23
+ pattern: string;
24
+ method: HttpMethod;
25
+ definition: RouteDefinition;
26
+ index: number;
27
+ /**
28
+ * Static-segment count — hoisted to registration time (AI_GUIDELINES §14) and
29
+ * read by the tie-break in {@linkcode Router.match}.
30
+ *
31
+ * The parsed `Segment[]` it is derived from is deliberately NOT retained: it
32
+ * was stored on every entry and read by nothing once Hono took over matching
33
+ * in M22.
34
+ */
35
+ statics: number;
36
+ /**
37
+ * Wildcard-segment count — hoisted at registration beside {@linkcode
38
+ * RouteEntry.statics} and read ASCENDING by the tie-break in {@linkcode
39
+ * Router.match}.
40
+ *
41
+ * Before M70g a `*` was counted as a static segment, so `/*` tied with
42
+ * `/openapi.json` and outranked `/a/:id`. Counting it separately is what makes
43
+ * an application catch-all sort last instead of eating every single-segment
44
+ * route registered after it.
45
+ */
46
+ wildcards: number;
47
+ /** Plugin that registered this route, if it was registered by a plugin. */
48
+ owner?: string;
49
+ }
50
+
51
+ /**
52
+ * A route registration reported to the diagnostics collector at its owning
53
+ * mutation boundary, carrying only bounded registration primitives.
54
+ *
55
+ * @since 0.8.0
56
+ */
57
+ export interface RouteRegistrationEvent {
58
+ readonly kind: 'register';
59
+ /** The entry's insertion index — the identity dispatch uses to name the route. */
60
+ readonly entryIndex: number;
61
+ /**
62
+ * The method as registered — typed as the raw string deliberately, because
63
+ * projecting onto the supported verb vocabulary is the COLLECTOR's decision
64
+ * (unsupported input is omitted there, never widened).
65
+ */
66
+ readonly method: string;
67
+ /** The COMPLETE registered pattern, including any group prefix. */
68
+ readonly pattern: string;
69
+ /** The plugin that registered the route, when one was registering. */
70
+ readonly owner: string | undefined;
71
+ /** How many middleware stages the route's definition declares. */
72
+ readonly middlewareCount: number;
73
+ }
74
+
75
+ // Hono imports — use LinearRouter (not the default SmartRouter) because the
76
+ // kernel's tie-break (§3.6) needs Hono to return EVERY overlapping candidate
77
+ // for a path so it can re-rank them by static-segment count + registration
78
+ // order. LinearRouter matches routes linearly and yields all candidates;
79
+ // it also never raises RegExpRouter's UnsupportedPathError on overlapping
80
+ // param patterns. Extracted params are strings either way.
81
+ import { Hono } from 'hono';
82
+ import { LinearRouter } from 'hono/router/linear-router';
83
+ import type { Context as HonoContext, Next as HonoNext } from 'hono';
84
+
85
+ /**
86
+ * Programmatic router implementing {@linkcode IRouterApi}. Supports all 7
87
+ * HTTP verbs, route groups with prefix composition, and static-over-param
88
+ * matching preference.
89
+ *
90
+ * @since 0.3.0
91
+ */
92
+ /**
93
+ * Key under which a route's {@linkcode RouteEntry} is carried on the stub
94
+ * handler Hono holds, so a match resolves the entry with a property read
95
+ * rather than a string key and a `Map` lookup on every request (M87).
96
+ */
97
+ const ROUTE_ENTRY = Symbol('setu.router.entry');
98
+
99
+ /** Shared empty params. Frozen, so a caller cannot mutate it into a shared surprise. */
100
+ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
101
+
102
+ /**
103
+ * Decodes Hono's raw (still percent-encoded) param values.
104
+ *
105
+ * Hono's low-level `router.match()` returns raw values — decoding normally
106
+ * happens in Hono's Context layer, which the kernel bypasses — so this
107
+ * preserves pre-M22 parity with the from-scratch matcher, which decoded per
108
+ * segment.
109
+ *
110
+ * Two allocations are avoided relative to the straightforward form (M87): a
111
+ * route with no params returns the shared frozen object rather than a fresh
112
+ * `{}`, and `decodeURIComponent` is skipped for a value containing no `%`,
113
+ * where it is an identity function (it does not decode `+`).
114
+ *
115
+ * @param raw - Hono's raw param record
116
+ * @returns The decoded params, or `null` when a value carries a malformed escape
117
+ */
118
+ function decodeParams(raw: Record<string, string>): Record<string, string> | null {
119
+ let params: Record<string, string> | undefined;
120
+ // `Object.keys`, not `for...in`: own enumerable properties only. Hono
121
+ // currently builds its params with a null prototype, so `for...in` happens
122
+ // to be equivalent — verified, not assumed — but that is an undocumented
123
+ // internal of a dependency, and if it ever became a `{}` literal a polluted
124
+ // `Object.prototype` would start injecting route params on every match. The
125
+ // `Object.entries` form this replaced was immune by construction; keeping
126
+ // that property costs nothing, since `keys` allocates one array where
127
+ // `entries` allocated one array plus a pair per key.
128
+ for (const key of Object.keys(raw)) {
129
+ const value = raw[key]!;
130
+ let decoded: string;
131
+ if (value.includes('%')) {
132
+ try {
133
+ decoded = decodeURIComponent(value);
134
+ } catch {
135
+ return null;
136
+ }
137
+ } else {
138
+ decoded = value;
139
+ }
140
+ (params ??= {})[key] = decoded;
141
+ }
142
+ return params ?? EMPTY_PARAMS;
143
+ }
144
+
145
+ export class Router implements IRouterApi {
146
+ readonly #routes: RouteEntry[] = [];
147
+ #index = 0;
148
+ readonly #hono = new Hono({ strict: false, router: new LinearRouter() });
149
+ /** Maps `${method} ${path}` → the kernel's RouteEntry. */
150
+ readonly #entryMap = new Map<string, RouteEntry>();
151
+ readonly #owner: () => string | undefined;
152
+ #diagnosticsSink: ((event: RouteRegistrationEvent) => void) | undefined;
153
+
154
+ constructor(owner: () => string | undefined = () => undefined) {
155
+ this.#owner = owner;
156
+ }
157
+
158
+ /**
159
+ * Installs the diagnostics sink that receives route registration events at
160
+ * the owning mutation boundary. Pass `undefined` to detach (teardown).
161
+ *
162
+ * @param sink - The collector-facing sink, or `undefined`
163
+ * @since 0.8.0
164
+ */
165
+ setDiagnosticsSink(sink: ((event: RouteRegistrationEvent) => void) | undefined): void {
166
+ this.#diagnosticsSink = sink;
167
+ }
168
+
169
+ #registerMethod(method: HttpMethod, path: string, route: RouteHandler | RouteDefinition): void {
170
+ const key = `${method} ${path}`;
171
+ const existing = this.#entryMap.get(key);
172
+ if (existing !== undefined) {
173
+ // Name the FIRST claimant. The message used to give the pattern and the
174
+ // second claimant, which is the half the stack trace already carries; the
175
+ // half a developer needs is who got there first — `StaticPlugin` at the
176
+ // root collides with the SSR catch-all, and the old message named neither
177
+ // plugin. `owner` has carried this since M68.
178
+ throw new Error(`Route '${key}' is already registered by ${describeRouteOwner(existing)}.`);
179
+ }
180
+ const definition: RouteDefinition = typeof route === 'function' ? { handler: route } : route;
181
+ const owner = this.#owner();
182
+ const segments = parsePattern(path);
183
+ // Hoist per-request work to registration time (AI_GUIDELINES §14): parse
184
+ // the pattern once here and keep only the static count the tie-break reads.
185
+ const entry: RouteEntry = {
186
+ pattern: path,
187
+ method,
188
+ definition,
189
+ index: this.#index++,
190
+ statics: staticSegmentCount(segments),
191
+ wildcards: wildcardSegmentCount(segments),
192
+ ...(owner === undefined ? {} : { owner }),
193
+ };
194
+ this.#routes.push(entry);
195
+ this.#entryMap.set(key, entry);
196
+ // Reported at the owning mutation boundary, after the entry exists — a
197
+ // duplicate-key refusal above never reaches this line.
198
+ this.#diagnosticsSink?.({
199
+ kind: 'register',
200
+ entryIndex: entry.index,
201
+ method,
202
+ pattern: path,
203
+ owner,
204
+ middlewareCount: definition.middleware?.length ?? 0,
205
+ });
206
+
207
+ // Register on Hono with a stub handler. The stub does NOT execute the
208
+ // framework handler — it exists only so Hono's matcher records the
209
+ // route and extracts params. The real handler runs through the custom
210
+ // pipeline + executeChain (§3.2 of M22 plan).
211
+ // Use `app.on()` for all methods (covers HEAD, OPTIONS which Hono
212
+ // doesn't expose as direct methods).
213
+ const stubHandler = (_c: HonoContext, _next: HonoNext) => {
214
+ // Stub — never called during matching.
215
+ return new Response();
216
+ };
217
+ // Carry the entry on the stub itself. Hono hands the handler back in the
218
+ // match tuple, so `match()` reads the entry with one property access
219
+ // instead of building a `${method} ${path}` key and consulting
220
+ // `#entryMap` on every request (M87). `#entryMap` remains the duplicate
221
+ // registration guard; this is a second, per-request-free route to the
222
+ // same object, not a second source of truth.
223
+ (stubHandler as unknown as { [ROUTE_ENTRY]: RouteEntry })[ROUTE_ENTRY] = entry;
224
+ // Hono's `on()` method is not typed in the public API; cast through unknown
225
+ // to avoid a direct `as any` while keeping the call site minimal.
226
+ type HonoOnHandler = (c: HonoContext, next: HonoNext) => Response | Promise<Response> | void;
227
+ interface HonoOn {
228
+ on(method: string, path: string, handler: HonoOnHandler): Response | Promise<Response>;
229
+ }
230
+ (this.#hono as unknown as HonoOn).on(method.toUpperCase(), path, stubHandler as HonoOnHandler);
231
+ }
232
+
233
+ get(path: string, route: RouteHandler | RouteDefinition): void {
234
+ this.#registerMethod('GET', path, route);
235
+ }
236
+
237
+ post(path: string, route: RouteHandler | RouteDefinition): void {
238
+ this.#registerMethod('POST', path, route);
239
+ }
240
+
241
+ put(path: string, route: RouteHandler | RouteDefinition): void {
242
+ this.#registerMethod('PUT', path, route);
243
+ }
244
+
245
+ patch(path: string, route: RouteHandler | RouteDefinition): void {
246
+ this.#registerMethod('PATCH', path, route);
247
+ }
248
+
249
+ delete(path: string, route: RouteHandler | RouteDefinition): void {
250
+ this.#registerMethod('DELETE', path, route);
251
+ }
252
+
253
+ head(path: string, route: RouteHandler | RouteDefinition): void {
254
+ this.#registerMethod('HEAD', path, route);
255
+ }
256
+
257
+ options(path: string, route: RouteHandler | RouteDefinition): void {
258
+ this.#registerMethod('OPTIONS', path, route);
259
+ }
260
+
261
+ group(prefix: string, configure: (router: IRouterApi) => void): void {
262
+ configure(new GroupRouter(this, prefix));
263
+ }
264
+
265
+ /**
266
+ * Finds the best matching route for the given method and path.
267
+ *
268
+ * Delegates to Hono's router for matching, then applies the kernel's own
269
+ * deterministic tie-break (§3.6 of M22 plan) when Hono returns multiple
270
+ * candidates of equal specificity.
271
+ *
272
+ * When multiple routes match, the ranking is:
273
+ *
274
+ * 1. more literal (static) segments;
275
+ * 2. then FEWER `*` wildcard segments;
276
+ * 3. then earliest registration order.
277
+ *
278
+ * The wildcard key is what makes an application catch-all lose to a route that
279
+ * names its path, in either registration order. Before M70g a `*` counted as a
280
+ * static segment, so `GET /*` tied with `GET /openapi.json` and won merely by
281
+ * registering first — which is how a full-stack application silently lost its
282
+ * OpenAPI endpoints.
283
+ *
284
+ * The rule compares COUNTS rather than comparing segment by segment, and that
285
+ * has one documented consequence: `/a/*` (one static, one wildcard) loses to
286
+ * `/:x/b` (one static, no wildcard) on a request for `/a/b`, where a
287
+ * per-position rule would prefer the literal `a`. That case is pinned by a test
288
+ * so a future change to per-segment ranking is a deliberate one.
289
+ *
290
+ * @param method - HTTP method
291
+ * @param path - Request path
292
+ * @returns The matched route entry and extracted params, or `null`
293
+ * @since 0.1.0
294
+ */
295
+ match(
296
+ method: HttpMethod,
297
+ path: string,
298
+ ): {
299
+ definition: RouteDefinition;
300
+ params: Record<string, string>;
301
+ /** The selected entry — dispatch names the actual route node from its index. */
302
+ entry: RouteEntry;
303
+ } | null {
304
+ // Delegate to Hono's router for matching.
305
+ // honoMatch shape: [[[handler, routeInfo], params], ...]
306
+ // honoMatch[0] is the flat array of candidates.
307
+ // Each candidate: [[handler, routeInfo], params] where routeInfo = {basePath, path, method}.
308
+ const honoMatch = this.#hono.router.match(method, path);
309
+
310
+ // Extract candidates from honoMatch[0].
311
+ type HonoCandidate = [
312
+ handlerRouteTuple: [unknown, Record<string, unknown>],
313
+ params: Record<string, string>,
314
+ ];
315
+ const candidatesRaw = honoMatch[0] as unknown as HonoCandidate[];
316
+
317
+ // Hono always returns honoMatch[0] as an array; early-return when empty
318
+ // covers the case where no routes are registered or nothing matches.
319
+ if (candidatesRaw.length === 0) {
320
+ return null;
321
+ }
322
+
323
+ // Fast path: exactly one candidate, which is every request in an
324
+ // application without overlapping patterns (M87). The candidates array,
325
+ // the per-candidate object literal and the sort below cannot change the
326
+ // outcome when there is nothing to tie-break against, so none of them is
327
+ // built. This mirrors Hono's own single-match bypass.
328
+ if (candidatesRaw.length === 1) {
329
+ const [handlerRouteTuple, rawParams] = candidatesRaw[0]!;
330
+ const entry = (handlerRouteTuple[0] as unknown as { [ROUTE_ENTRY]: RouteEntry })[ROUTE_ENTRY];
331
+ const params = decodeParams(rawParams);
332
+ // A malformed escape means this route does not match, mirroring the
333
+ // multi-candidate path's `continue` when every candidate is dropped.
334
+ return params === null ? null : { definition: entry.definition, params, entry };
335
+ }
336
+
337
+ // Build candidates array: map each Hono candidate to the kernel RouteEntry.
338
+ // Hono always provides well-formed routeInfo/method/path on matched
339
+ // candidates, and every candidate maps back to #entryMap since we
340
+ // register both simultaneously with identical paths.
341
+ const candidates: Array<{
342
+ routePath: string;
343
+ params: Record<string, string>;
344
+ entry: RouteEntry;
345
+ }> = [];
346
+
347
+ for (const [handlerRouteTuple, rawParams] of candidatesRaw) {
348
+ const routeInfo = handlerRouteTuple![1] as Record<string, unknown>;
349
+ const routePath = routeInfo.path as string;
350
+ const routeMethod = routeInfo.method as string;
351
+ const entry = this.#entryMap.get(`${routeMethod} ${routePath}`)!;
352
+ // Same decoder as the single-candidate path above (M87 review). Keeping
353
+ // a second inline copy here let the two disagree on identity as well as
354
+ // duplicating the rule: this one built a fresh `{}` for a param-less
355
+ // route while the fast path returns the shared frozen object, so
356
+ // whether `params` was mutable depended on how many routes happened to
357
+ // match. A malformed escape means this candidate does not match,
358
+ // mirroring the old matcher's `null` return.
359
+ const params = decodeParams(rawParams as Record<string, string>);
360
+ if (params === null) {
361
+ continue;
362
+ }
363
+ candidates.push({ routePath, params, entry });
364
+ }
365
+
366
+ // Every candidate may have been dropped for a malformed param escape.
367
+ if (candidates.length === 0) {
368
+ return null;
369
+ }
370
+
371
+ // If only one candidate, return it directly.
372
+ if (candidates.length === 1) {
373
+ const { entry, params } = candidates[0];
374
+ return { definition: entry.definition, params, entry };
375
+ }
376
+
377
+ // Tie-break: more static segments, then fewer wildcards, then earliest
378
+ // registration order (§3.6 of the M22 plan, extended by M70g).
379
+ candidates.sort((a, b) => {
380
+ if (a.entry.statics !== b.entry.statics) {
381
+ return b.entry.statics - a.entry.statics;
382
+ }
383
+ if (a.entry.wildcards !== b.entry.wildcards) {
384
+ return a.entry.wildcards - b.entry.wildcards;
385
+ }
386
+ return a.entry.index - b.entry.index;
387
+ });
388
+
389
+ const best = candidates[0];
390
+ return { definition: best.entry.definition, params: best.params, entry: best.entry };
391
+ }
392
+
393
+ /**
394
+ * Returns all registered route entries, including the registration
395
+ * bookkeeping (`index`, `statics`, `wildcards`) that drives the tie-break.
396
+ *
397
+ * @returns The route entries in registration order
398
+ * @internal Test/diagnostics seam — `Router` is not exported from the package
399
+ * barrel, and plugins read routes through the contract's `listRoutes()`.
400
+ */
401
+ getAll(): readonly RouteEntry[] {
402
+ return this.#routes;
403
+ }
404
+
405
+ listRoutes(): readonly RouteInfo[] {
406
+ return this.#routes.map((entry) => ({
407
+ method: entry.method,
408
+ path: entry.pattern,
409
+ ...(entry.owner === undefined ? {} : { owner: entry.owner }),
410
+ definition: entry.definition,
411
+ }));
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Names the party that registered a route, for the duplicate-route refusal.
417
+ *
418
+ * Module-private with one caller by design: the two arms exist so the message
419
+ * never says "registered by undefined", and a second call site would be a second
420
+ * place for that wording to drift.
421
+ *
422
+ * @param entry - The entry already holding the `METHOD path` key
423
+ * @returns `plugin 'name'` for a plugin-registered route, `the application` otherwise
424
+ */
425
+ function describeRouteOwner(entry: RouteEntry): string {
426
+ return entry.owner === undefined ? 'the application' : `plugin '${entry.owner}'`;
427
+ }
428
+
429
+ /**
430
+ * Route group facade — prefixes every path registered inside the group
431
+ * callback. Nested groups compose prefixes transitively.
432
+ *
433
+ * @since 0.1.0
434
+ */
435
+ class GroupRouter implements IRouterApi {
436
+ readonly #parent: Router;
437
+ readonly #prefix: string;
438
+
439
+ constructor(parent: Router, prefix: string) {
440
+ this.#parent = parent;
441
+ this.#prefix = prefix;
442
+ }
443
+
444
+ #resolvePath(path: string): string {
445
+ // Normalize: ensure single slash between prefix and path
446
+ const p = path === '/' ? '' : path;
447
+ return this.#prefix + p;
448
+ }
449
+
450
+ get(path: string, route: RouteHandler | RouteDefinition): void {
451
+ this.#parent.get(this.#resolvePath(path), route);
452
+ }
453
+
454
+ post(path: string, route: RouteHandler | RouteDefinition): void {
455
+ this.#parent.post(this.#resolvePath(path), route);
456
+ }
457
+
458
+ put(path: string, route: RouteHandler | RouteDefinition): void {
459
+ this.#parent.put(this.#resolvePath(path), route);
460
+ }
461
+
462
+ patch(path: string, route: RouteHandler | RouteDefinition): void {
463
+ this.#parent.patch(this.#resolvePath(path), route);
464
+ }
465
+
466
+ delete(path: string, route: RouteHandler | RouteDefinition): void {
467
+ this.#parent.delete(this.#resolvePath(path), route);
468
+ }
469
+
470
+ head(path: string, route: RouteHandler | RouteDefinition): void {
471
+ this.#parent.head(this.#resolvePath(path), route);
472
+ }
473
+
474
+ options(path: string, route: RouteHandler | RouteDefinition): void {
475
+ this.#parent.options(this.#resolvePath(path), route);
476
+ }
477
+
478
+ group(prefix: string, configure: (router: IRouterApi) => void): void {
479
+ this.#parent.group(this.#prefix + prefix, configure);
480
+ }
481
+
482
+ listRoutes(): readonly RouteInfo[] {
483
+ return this.#parent.listRoutes();
484
+ }
485
+ }