@setu-ts/kernel 0.0.0-stage → 0.10.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/AGENT_CARD.md +47 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/dist/application/application.d.ts +174 -0
- package/dist/application/application.d.ts.map +1 -0
- package/dist/application/application.js +1905 -0
- package/dist/application/application.js.map +1 -0
- package/dist/application/inject-body.d.ts +58 -0
- package/dist/application/inject-body.d.ts.map +1 -0
- package/dist/application/inject-body.js +82 -0
- package/dist/application/inject-body.js.map +1 -0
- package/dist/context/request-context.d.ts +32 -0
- package/dist/context/request-context.d.ts.map +1 -0
- package/dist/context/request-context.js +136 -0
- package/dist/context/request-context.js.map +1 -0
- package/dist/context/response.d.ts +60 -0
- package/dist/context/response.d.ts.map +1 -0
- package/dist/context/response.js +193 -0
- package/dist/context/response.js.map +1 -0
- package/dist/diagnostics/buffer.d.ts +143 -0
- package/dist/diagnostics/buffer.d.ts.map +1 -0
- package/dist/diagnostics/buffer.js +190 -0
- package/dist/diagnostics/buffer.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +284 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +810 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/projection.d.ts +153 -0
- package/dist/diagnostics/projection.d.ts.map +1 -0
- package/dist/diagnostics/projection.js +226 -0
- package/dist/diagnostics/projection.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
- package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
- package/dist/lifecycle/lifecycle-manager.js +206 -0
- package/dist/lifecycle/lifecycle-manager.js.map +1 -0
- package/dist/pipeline/execute-chain.d.ts +71 -0
- package/dist/pipeline/execute-chain.d.ts.map +1 -0
- package/dist/pipeline/execute-chain.js +104 -0
- package/dist/pipeline/execute-chain.js.map +1 -0
- package/dist/pipeline/middleware-pipeline.d.ts +68 -0
- package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
- package/dist/pipeline/middleware-pipeline.js +82 -0
- package/dist/pipeline/middleware-pipeline.js.map +1 -0
- package/dist/registry/plugin-resolver.d.ts +42 -0
- package/dist/registry/plugin-resolver.d.ts.map +1 -0
- package/dist/registry/plugin-resolver.js +140 -0
- package/dist/registry/plugin-resolver.js.map +1 -0
- package/dist/registry/service-registry.d.ts +110 -0
- package/dist/registry/service-registry.d.ts.map +1 -0
- package/dist/registry/service-registry.js +197 -0
- package/dist/registry/service-registry.js.map +1 -0
- package/dist/router/route-matcher.d.ts +85 -0
- package/dist/router/route-matcher.d.ts.map +1 -0
- package/dist/router/route-matcher.js +105 -0
- package/dist/router/route-matcher.js.map +1 -0
- package/dist/router/router.d.ts +130 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +350 -0
- package/dist/router/router.js.map +1 -0
- package/package.json +49 -3
- package/src/application/application.ts +2326 -0
- package/src/application/inject-body.ts +130 -0
- package/src/context/request-context.ts +169 -0
- package/src/context/response.ts +246 -0
- package/src/diagnostics/buffer.ts +243 -0
- package/src/diagnostics/collector.ts +1030 -0
- package/src/diagnostics/projection.ts +322 -0
- package/src/index.ts +20 -0
- package/src/lifecycle/lifecycle-manager.ts +282 -0
- package/src/pipeline/execute-chain.ts +162 -0
- package/src/pipeline/middleware-pipeline.ts +129 -0
- package/src/registry/plugin-resolver.ts +185 -0
- package/src/registry/service-registry.ts +262 -0
- package/src/router/route-matcher.ts +134 -0
- 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
|
+
}
|