@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.
- package/LICENSE +21 -0
- package/README.md +144 -2
- package/dist/application/application.d.ts +172 -0
- package/dist/application/application.d.ts.map +1 -0
- package/dist/application/application.js +1862 -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 +185 -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 +812 -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 +102 -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 +48 -3
- package/src/application/application.ts +2299 -0
- package/src/application/inject-body.ts +131 -0
- package/src/context/request-context.ts +169 -0
- package/src/context/response.ts +254 -0
- package/src/diagnostics/buffer.ts +241 -0
- package/src/diagnostics/collector.ts +1031 -0
- package/src/diagnostics/projection.ts +322 -0
- package/src/index.ts +20 -0
- package/src/lifecycle/lifecycle-manager.ts +287 -0
- package/src/pipeline/execute-chain.ts +164 -0
- package/src/pipeline/middleware-pipeline.ts +131 -0
- package/src/registry/plugin-resolver.ts +185 -0
- package/src/registry/service-registry.ts +267 -0
- package/src/router/route-matcher.ts +131 -0
- package/src/router/router.ts +485 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-token service registry — the framework's primary service
|
|
3
|
+
* resolution mechanism (ARCHITECTURE.md §6).
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
CapabilityToken,
|
|
9
|
+
IServiceRegistry,
|
|
10
|
+
RegisterOptions,
|
|
11
|
+
ServiceFactory,
|
|
12
|
+
} from '@setu-ts/common';
|
|
13
|
+
|
|
14
|
+
interface Registration {
|
|
15
|
+
instance?: object;
|
|
16
|
+
factory?: ServiceFactory<object>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A successful registry mutation reported to the diagnostics collector, using
|
|
21
|
+
* token metadata alone. Duplicate-registration REFUSALS never produce an
|
|
22
|
+
* event: they throw before any mutation happens, and a refused registration
|
|
23
|
+
* is not a successful one.
|
|
24
|
+
*
|
|
25
|
+
* @since 0.8.0
|
|
26
|
+
*/
|
|
27
|
+
export type RegistryDiagnosticEvent = {
|
|
28
|
+
readonly kind: 'register-single' | 'register-multi' | 'unregister';
|
|
29
|
+
readonly token: CapabilityToken;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The collector-facing sink for successful registry mutations. Distinct from
|
|
34
|
+
* the startup logging observer, which keeps its own shape and behavior.
|
|
35
|
+
*
|
|
36
|
+
* @since 0.8.0
|
|
37
|
+
*/
|
|
38
|
+
export type RegistryDiagnosticsSink = (event: RegistryDiagnosticEvent) => void;
|
|
39
|
+
|
|
40
|
+
function resolveRegistration(registration: Registration): object {
|
|
41
|
+
if (registration.instance === undefined) {
|
|
42
|
+
// Lazy factory: instantiate on first lookup, cache for subsequent ones.
|
|
43
|
+
registration.instance = registration.factory!();
|
|
44
|
+
}
|
|
45
|
+
return registration.instance;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Default {@linkcode IServiceRegistry} implementation. Request scopes are
|
|
50
|
+
* modeled as child registries that fall back to their parent for lookups
|
|
51
|
+
* while keeping their own registrations isolated.
|
|
52
|
+
*/
|
|
53
|
+
export class ServiceRegistry implements IServiceRegistry {
|
|
54
|
+
readonly #single = new Map<CapabilityToken, Registration>();
|
|
55
|
+
readonly #multi = new Map<CapabilityToken, Registration[]>();
|
|
56
|
+
readonly #parent: ServiceRegistry | undefined;
|
|
57
|
+
#sealed = false;
|
|
58
|
+
#observer: ((kind: 'override' | 'unregister', token: CapabilityToken) => void) | undefined;
|
|
59
|
+
#diagnosticsSink: RegistryDiagnosticsSink | undefined;
|
|
60
|
+
#missExplanation: ((token: CapabilityToken) => string | undefined) | undefined;
|
|
61
|
+
|
|
62
|
+
constructor(parent?: ServiceRegistry) {
|
|
63
|
+
this.#parent = parent;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Creates a request-scoped child registry that falls back to this one. */
|
|
67
|
+
createChild(): ServiceRegistry {
|
|
68
|
+
return new ServiceRegistry(this);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Prevents future application-scoped registrations. */
|
|
72
|
+
seal(): void {
|
|
73
|
+
this.#sealed = true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Installs the diagnostics sink that receives successful registration and
|
|
78
|
+
* removal events. Pass `undefined` to detach (teardown).
|
|
79
|
+
*
|
|
80
|
+
* @param sink - The collector-facing sink, or `undefined`
|
|
81
|
+
* @since 0.8.0
|
|
82
|
+
*/
|
|
83
|
+
setDiagnosticsSink(sink: RegistryDiagnosticsSink | undefined): void {
|
|
84
|
+
this.#diagnosticsSink = sink;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Installs the explanation {@linkcode ServiceRegistry.get} gives for a token
|
|
89
|
+
* it cannot find. The application knows what the registry cannot: whether
|
|
90
|
+
* its plugins have registered yet, and which plugin provides a token. When
|
|
91
|
+
* the function returns a string, it replaces the generic advice in the
|
|
92
|
+
* error; `undefined` keeps that advice.
|
|
93
|
+
*
|
|
94
|
+
* @param explain - Explains a missing token, or answers `undefined`
|
|
95
|
+
*/
|
|
96
|
+
setMissExplanation(explain: (token: CapabilityToken) => string | undefined): void {
|
|
97
|
+
this.#missExplanation = explain;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Returns the ALREADY-RESOLVED instance for a token without ever executing
|
|
102
|
+
* a factory, using the same single-registration lookup precedence as
|
|
103
|
+
* {@linkcode ServiceRegistry.get} — own single registration, then own first
|
|
104
|
+
* multi-provider, then the parent. An unresolved LOCAL registration answers
|
|
105
|
+
* `undefined` and does NOT fall past to the parent: the answer "present but
|
|
106
|
+
* never constructed" must not turn into a foreign instance. Multi-providers
|
|
107
|
+
* are never enumerated, nothing is constructed, and the method is absent
|
|
108
|
+
* from {@linkcode IServiceRegistry} by design — it exists so kernel
|
|
109
|
+
* diagnostics can read an optional capability (telemetry identifiers) on the
|
|
110
|
+
* execution path without forcing a lazy service into existence, and it is
|
|
111
|
+
* not part of the public barrel.
|
|
112
|
+
*
|
|
113
|
+
* @typeParam T - The service's interface type
|
|
114
|
+
* @param token - The capability token to peek
|
|
115
|
+
* @returns The cached instance, or `undefined` when absent or unresolved
|
|
116
|
+
* @since 0.8.0
|
|
117
|
+
*/
|
|
118
|
+
peekResolved<T extends object>(token: CapabilityToken): T | undefined {
|
|
119
|
+
const own = this.#single.get(token) ?? this.#multi.get(token)?.[0];
|
|
120
|
+
if (own !== undefined) {
|
|
121
|
+
return own.instance === undefined ? undefined : (own.instance as T);
|
|
122
|
+
}
|
|
123
|
+
if (this.#parent === undefined) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return this.#parent.peekResolved<T>(token);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reports whether `instance` is the CURRENT provider a resolving
|
|
131
|
+
* {@linkcode ServiceRegistry.get} would select for `token` — WITHOUT ever
|
|
132
|
+
* executing a factory.
|
|
133
|
+
*
|
|
134
|
+
* The lookup precedence is IDENTICAL to `get`'s: own single registration,
|
|
135
|
+
* then own first multi-provider, then the parent. The comparison is an
|
|
136
|
+
* identity check against the registration's already-resolved instance. A
|
|
137
|
+
* registration whose instance has not been constructed yet (a lazy factory
|
|
138
|
+
* that has not run) answers `false` — it does NOT fall past to the parent,
|
|
139
|
+
* because the answer "present but never constructed" must not be mistaken
|
|
140
|
+
* for a foreign instance. Nothing is constructed, no multi-provider list is
|
|
141
|
+
* enumerated, and the current value is never returned.
|
|
142
|
+
*
|
|
143
|
+
* This is the non-resolving identity predicate of
|
|
144
|
+
* {@linkcode IServiceRegistry.isCurrent}: a consumer can verify it still
|
|
145
|
+
* owns a known provider without forcing a lazy replacement into existence.
|
|
146
|
+
*
|
|
147
|
+
* @typeParam T - The service's interface type
|
|
148
|
+
* @param token - The capability token to check
|
|
149
|
+
* @param instance - The instance to compare against the current registration
|
|
150
|
+
* @returns `true` only when `instance` IS the registration `get` would select
|
|
151
|
+
* @since 0.8.0
|
|
152
|
+
*/
|
|
153
|
+
isCurrent<T extends object>(token: CapabilityToken, instance: T): boolean {
|
|
154
|
+
const own = this.#single.get(token) ?? this.#multi.get(token)?.[0];
|
|
155
|
+
if (own !== undefined) {
|
|
156
|
+
return own.instance === instance;
|
|
157
|
+
}
|
|
158
|
+
if (this.#parent === undefined) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return this.#parent.isCurrent(token, instance);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Installs the application-owned callback for startup-time mutations. */
|
|
165
|
+
setObserver(
|
|
166
|
+
observer: (kind: 'override' | 'unregister', token: CapabilityToken) => void,
|
|
167
|
+
): void {
|
|
168
|
+
this.#observer = observer;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
register<T extends object>(
|
|
172
|
+
token: CapabilityToken,
|
|
173
|
+
service: T,
|
|
174
|
+
options?: RegisterOptions,
|
|
175
|
+
): void {
|
|
176
|
+
this.#store(token, { instance: service }, options);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registerFactory<T extends object>(
|
|
180
|
+
token: CapabilityToken,
|
|
181
|
+
factory: ServiceFactory<T>,
|
|
182
|
+
options?: RegisterOptions,
|
|
183
|
+
): void {
|
|
184
|
+
this.#store(token, { factory }, options);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
get<T extends object>(token: CapabilityToken): T {
|
|
188
|
+
const registration = this.#lookup(token);
|
|
189
|
+
if (registration === undefined) {
|
|
190
|
+
const advice = this.#missExplanation?.(token) ??
|
|
191
|
+
'Register a plugin that provides it, or check the token spelling against CAPABILITIES.';
|
|
192
|
+
throw new Error(`No service registered for capability '${token}'. ${advice}`);
|
|
193
|
+
}
|
|
194
|
+
return resolveRegistration(registration) as T;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
getAll<T extends object>(token: CapabilityToken): readonly T[] {
|
|
198
|
+
const own = this.#multi.get(token) ?? [];
|
|
199
|
+
const inherited = this.#parent?.getAll<T>(token) ?? [];
|
|
200
|
+
const single = this.#single.get(token);
|
|
201
|
+
return [
|
|
202
|
+
...inherited,
|
|
203
|
+
...(single ? [resolveRegistration(single) as T] : []),
|
|
204
|
+
...own.map((registration) => resolveRegistration(registration) as T),
|
|
205
|
+
];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
has(token: CapabilityToken): boolean {
|
|
209
|
+
return this.#single.has(token) || this.#multi.has(token) || (this.#parent?.has(token) ?? false);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
unregister(token: CapabilityToken): boolean {
|
|
213
|
+
this.#assertMutable(token, 'unregister');
|
|
214
|
+
const hadSingle = this.#single.delete(token);
|
|
215
|
+
const hadMulti = this.#multi.delete(token);
|
|
216
|
+
const removed = hadSingle || hadMulti;
|
|
217
|
+
if (removed) {
|
|
218
|
+
this.#observer?.('unregister', token);
|
|
219
|
+
this.#diagnosticsSink?.({ kind: 'unregister', token });
|
|
220
|
+
}
|
|
221
|
+
return removed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#lookup(token: CapabilityToken): Registration | undefined {
|
|
225
|
+
const own = this.#single.get(token) ?? this.#multi.get(token)?.[0];
|
|
226
|
+
if (own !== undefined) {
|
|
227
|
+
return own;
|
|
228
|
+
}
|
|
229
|
+
if (this.#parent === undefined) {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return this.#parent.#lookup(token);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#store(token: CapabilityToken, registration: Registration, options?: RegisterOptions): void {
|
|
236
|
+
this.#assertMutable(token, 'register');
|
|
237
|
+
if (options?.multi) {
|
|
238
|
+
const providers = this.#multi.get(token) ?? [];
|
|
239
|
+
providers.push(registration);
|
|
240
|
+
this.#multi.set(token, providers);
|
|
241
|
+
this.#diagnosticsSink?.({ kind: 'register-multi', token });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
// Conflicts are checked against this registry only: a request-scoped
|
|
245
|
+
// child may deliberately shadow an application-scoped service.
|
|
246
|
+
if (this.#single.has(token) && !options?.override) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Capability '${token}' is already registered. Use { override: true } to replace it.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
if (this.#single.has(token) && options?.override) {
|
|
252
|
+
this.#observer?.('override', token);
|
|
253
|
+
}
|
|
254
|
+
this.#single.set(token, registration);
|
|
255
|
+
this.#diagnosticsSink?.({ kind: 'register-single', token });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#assertMutable(token: CapabilityToken, operation: 'register' | 'unregister'): void {
|
|
259
|
+
if (this.#sealed) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Cannot ${operation} capability '${token}' after runBootstrap() has completed. ` +
|
|
262
|
+
`Register during a plugin's register(), onInit, or onBootstrap hook, or use ` +
|
|
263
|
+
`request-scoped ctx.services inside middleware.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route matcher utilities — path decoding guard and pattern-parsing helpers
|
|
3
|
+
* for the kernel router.
|
|
4
|
+
*
|
|
5
|
+
* As of Milestone 22, route *matching* is delegated to Hono inside the
|
|
6
|
+
* [`Router`](./router.ts). This module exports:
|
|
7
|
+
*
|
|
8
|
+
* - {@linkcode isPathDecodable} — used by the application to reject malformed
|
|
9
|
+
* percent-escapes with a 400 **before** routing.
|
|
10
|
+
* - {@linkcode Segment}, {@linkcode parsePattern}, {@linkcode staticSegmentCount},
|
|
11
|
+
* {@linkcode wildcardSegmentCount} — parsing primitives shared with the Router's
|
|
12
|
+
* tie-break logic.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface StaticSegment {
|
|
18
|
+
type: 'static';
|
|
19
|
+
value: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ParamSegment {
|
|
23
|
+
type: 'param';
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface WildcardSegment {
|
|
28
|
+
type: 'wildcard';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A parsed route segment — a static path component, a `:name` parameter
|
|
33
|
+
* placeholder, or the `*` wildcard.
|
|
34
|
+
*
|
|
35
|
+
* The wildcard arm was added in M70g. Before it, `*` parsed as a STATIC segment,
|
|
36
|
+
* so `/*` scored the same specificity as `/openapi.json` and the tie was decided
|
|
37
|
+
* by registration order alone — which silently removed every single-segment
|
|
38
|
+
* plugin route registered after an application catch-all.
|
|
39
|
+
*
|
|
40
|
+
* @since 0.1.0
|
|
41
|
+
*/
|
|
42
|
+
export type Segment = StaticSegment | ParamSegment | WildcardSegment;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parses a route pattern like `/users/:id` into segments.
|
|
46
|
+
*
|
|
47
|
+
* @param pattern - The route pattern to parse
|
|
48
|
+
* @returns An array of `Segment` objects
|
|
49
|
+
* @internal Used only at registration time for tie-break statics counting.
|
|
50
|
+
*/
|
|
51
|
+
export function parsePattern(pattern: string): readonly Segment[] {
|
|
52
|
+
const normalized = pattern === '/' ? '/' : pattern.replace(/\/+$/, '');
|
|
53
|
+
if (normalized === '/') {
|
|
54
|
+
return [{ type: 'static', value: '' }];
|
|
55
|
+
}
|
|
56
|
+
return normalized.slice(1).split('/').map((part) => {
|
|
57
|
+
if (part.startsWith(':')) {
|
|
58
|
+
return { type: 'param', name: part.slice(1) };
|
|
59
|
+
}
|
|
60
|
+
// Exactly `*`, never a segment merely CONTAINING one: `/a*` is not a pattern
|
|
61
|
+
// any first-party plugin registers, and treating it as a wildcard would lower
|
|
62
|
+
// the specificity of a path that matches literally.
|
|
63
|
+
if (part === '*') {
|
|
64
|
+
return { type: 'wildcard' };
|
|
65
|
+
}
|
|
66
|
+
return { type: 'static', value: part };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Counts the literal path components in a pattern.
|
|
72
|
+
*
|
|
73
|
+
* Neither a `:param` nor a `*` wildcard counts: both match text the pattern does
|
|
74
|
+
* not name, so neither makes the route more specific.
|
|
75
|
+
*
|
|
76
|
+
* @param segments - The parsed segments array
|
|
77
|
+
* @returns The count of static segments
|
|
78
|
+
* @internal Used only at registration time for tie-break specificity.
|
|
79
|
+
*/
|
|
80
|
+
export function staticSegmentCount(segments: readonly Segment[]): number {
|
|
81
|
+
let count = 0;
|
|
82
|
+
for (const segment of segments) {
|
|
83
|
+
if (segment.type === 'static') {
|
|
84
|
+
count++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return count;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Counts the `*` wildcard segments in a pattern.
|
|
92
|
+
*
|
|
93
|
+
* The router's second tie-break key, read ASCENDING: between two routes with the
|
|
94
|
+
* same number of literal components, the one matching less arbitrary text is the
|
|
95
|
+
* more specific. That is what puts `/a/:id` ahead of `/a/*`, which before M70g
|
|
96
|
+
* lost in both registration orders because `*` was counted as a literal.
|
|
97
|
+
*
|
|
98
|
+
* @param segments - The parsed segments array
|
|
99
|
+
* @returns The count of wildcard segments
|
|
100
|
+
* @internal Used only at registration time for tie-break specificity.
|
|
101
|
+
*/
|
|
102
|
+
export function wildcardSegmentCount(segments: readonly Segment[]): number {
|
|
103
|
+
let count = 0;
|
|
104
|
+
for (const segment of segments) {
|
|
105
|
+
if (segment.type === 'wildcard') {
|
|
106
|
+
count++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return count;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Reports whether a path can be percent-decoded without error.
|
|
114
|
+
*
|
|
115
|
+
* A malformed percent-escape (e.g. `%zz`, a truncated `%2`, or a bare `%`)
|
|
116
|
+
* makes {@linkcode decodeURIComponent} throw. The application uses this to
|
|
117
|
+
* reject a malformed request path as a `400` before it reaches routing,
|
|
118
|
+
* where an unguarded decode would otherwise surface as a `500`.
|
|
119
|
+
*
|
|
120
|
+
* @param path - The raw (still percent-encoded) request path
|
|
121
|
+
* @returns `true` when the path decodes cleanly, `false` when it is malformed
|
|
122
|
+
* @since 0.1.0
|
|
123
|
+
*/
|
|
124
|
+
export function isPathDecodable(path: string): boolean {
|
|
125
|
+
try {
|
|
126
|
+
decodeURIComponent(path);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|