@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,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
+ }