@setu-ts/kernel 0.0.0-bootstrap.0 → 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 +143 -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 +40 -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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared middleware-chain executor — classic next()-chaining with a
|
|
3
|
+
* double-`next` guard and defense-in-depth against a stage that responds
|
|
4
|
+
* AND calls `next()`.
|
|
5
|
+
*
|
|
6
|
+
* Both the global pipeline ({@linkcode MiddlewarePipeline}) and the
|
|
7
|
+
* per-route middleware chain (dispatched from the application) delegate to
|
|
8
|
+
* this helper so the chaining semantics are defined in exactly one place
|
|
9
|
+
* (AI_GUIDELINES §11.1 — DRY).
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { DiagnosticsEventOutcome, IRequestContext, MiddlewareFunction } from '@setu-ts/common';
|
|
14
|
+
|
|
15
|
+
import type { ResponseBuilder } from '../context/response.ts';
|
|
16
|
+
import { monotonicElapsed } from '../diagnostics/projection.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One executed stage's completion record, reported in actual completion order.
|
|
20
|
+
* Skipped stages (the defense-in-depth early returns) emit no record.
|
|
21
|
+
*
|
|
22
|
+
* @since 0.8.0
|
|
23
|
+
*/
|
|
24
|
+
export interface ChainStageObservation {
|
|
25
|
+
/** 0-based index of the stage in the chain it was registered in. */
|
|
26
|
+
readonly position: number;
|
|
27
|
+
/** How the stage completed. */
|
|
28
|
+
readonly outcome: Extract<
|
|
29
|
+
DiagnosticsEventOutcome,
|
|
30
|
+
'ok' | 'error' | 'short-circuit' | 'downstream-skipped'
|
|
31
|
+
>;
|
|
32
|
+
/** Monotonic start offset, or `null` when no runtime clock is available. */
|
|
33
|
+
readonly startedAtMs: number | null;
|
|
34
|
+
/** Inclusive monotonic elapsed ms (including downstream `next()` work). */
|
|
35
|
+
readonly durationMs: number | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The diagnostics observer the application passes to instrument a chain. The
|
|
40
|
+
* clock is injected because observation must not read ambient time.
|
|
41
|
+
*
|
|
42
|
+
* @since 0.8.0
|
|
43
|
+
*/
|
|
44
|
+
export interface ChainObserver {
|
|
45
|
+
/** Monotonic clock; returns `null` when no runtime clock is available. */
|
|
46
|
+
readonly clock: () => number | null;
|
|
47
|
+
/** Called once per EXECUTED stage, in completion order. */
|
|
48
|
+
readonly onStage: (observation: ChainStageObservation) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Executes a chain of middleware with classic `next()`-chaining semantics.
|
|
53
|
+
*
|
|
54
|
+
* Each middleware receives a `next` function that advances to the next
|
|
55
|
+
* stage. A stage that returns without calling `next()` short-circuits the
|
|
56
|
+
* chain — subsequent stages and the terminal are not invoked. As
|
|
57
|
+
* defense-in-depth, before invoking the next stage (and before the
|
|
58
|
+
* terminal) the executor also checks `ResponseBuilder.ended` on
|
|
59
|
+
* `ctx.response`: if a stage has already produced a terminal response
|
|
60
|
+
* (e.g. called `json()`), downstream stages are skipped even if that
|
|
61
|
+
* stage incorrectly called `next()`.
|
|
62
|
+
*
|
|
63
|
+
* @param chain - The ordered middleware functions to execute
|
|
64
|
+
* @param ctx - The request context (its `response` is a {@linkcode ResponseBuilder})
|
|
65
|
+
* @param terminal - Called when every middleware has called `next()`
|
|
66
|
+
* @param names - Optional diagnostic names, positionally matching `chain`, used
|
|
67
|
+
* to identify the offending stage in the double-`next()` error. The global
|
|
68
|
+
* pipeline supplies the names registered through `MiddlewareOptions.name`;
|
|
69
|
+
* route-level chains have none and fall back to the function's own name.
|
|
70
|
+
* @param observer - Optional diagnostics observer. When absent the executor
|
|
71
|
+
* performs no additional work beyond the original path; when present, each
|
|
72
|
+
* stage's completion is observed inline without changing dispatch semantics —
|
|
73
|
+
* a throw is recorded and re-thrown with its identity intact, and the
|
|
74
|
+
* `downstream-skipped` outcome records a stage that called `next()` after the
|
|
75
|
+
* response had already ended.
|
|
76
|
+
* @throws {Error} If `next()` is called multiple times within a single middleware
|
|
77
|
+
* @since 0.1.0
|
|
78
|
+
*/
|
|
79
|
+
export async function executeChain(
|
|
80
|
+
chain: readonly MiddlewareFunction[],
|
|
81
|
+
ctx: IRequestContext,
|
|
82
|
+
terminal: () => Promise<void>,
|
|
83
|
+
names?: readonly string[],
|
|
84
|
+
observer?: ChainObserver,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
let index = 0;
|
|
87
|
+
|
|
88
|
+
// Defense in depth: stop once a prior stage has ended the response. The
|
|
89
|
+
// check is guarded so callers passing a minimal context (e.g. the
|
|
90
|
+
// pipeline unit tests with an empty object) don't crash on a missing
|
|
91
|
+
// `response` — only a real ResponseBuilder exposes `ended`.
|
|
92
|
+
const responseEnded = (): boolean => {
|
|
93
|
+
const response = ctx.response as Partial<ResponseBuilder> | undefined;
|
|
94
|
+
return typeof response?.ended === 'boolean' ? response.ended : false;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const elapsed = (startedAtMs: number | null): number | null =>
|
|
98
|
+
observer === undefined ? null : monotonicElapsed(observer.clock, startedAtMs);
|
|
99
|
+
|
|
100
|
+
const run = async (): Promise<void> => {
|
|
101
|
+
if (index >= chain.length) {
|
|
102
|
+
// Defense in depth: do not run the terminal if a prior stage
|
|
103
|
+
// already ended the response.
|
|
104
|
+
if (responseEnded()) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await terminal();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Defense in depth: a prior stage ended the response — stop.
|
|
111
|
+
if (responseEnded()) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const fn = chain[index]!;
|
|
115
|
+
const position = index;
|
|
116
|
+
index++;
|
|
117
|
+
let nextCalled = false;
|
|
118
|
+
let downstreamSkipped = false;
|
|
119
|
+
const next: () => Promise<void> = () => {
|
|
120
|
+
if (nextCalled) {
|
|
121
|
+
// Prefer the name registered via `MiddlewareOptions.name`, then the
|
|
122
|
+
// function's own name. `fn.name` is ALWAYS a string — `''` for an
|
|
123
|
+
// anonymous function — so `??` never fell back and the message read
|
|
124
|
+
// "…in middleware " with a blank name. `||` is the correct operator.
|
|
125
|
+
const label = names?.[position] || fn.name || '<anonymous>';
|
|
126
|
+
throw new Error(
|
|
127
|
+
`next() called multiple times in middleware ${label}`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
nextCalled = true;
|
|
131
|
+
if (observer !== undefined && responseEnded()) {
|
|
132
|
+
// The stage called next() after ending the response; run()'s own
|
|
133
|
+
// ended check below will skip the downstream. Recorded for the
|
|
134
|
+
// caller's completion record — the outcome, not a new behavior.
|
|
135
|
+
downstreamSkipped = true;
|
|
136
|
+
}
|
|
137
|
+
return run();
|
|
138
|
+
};
|
|
139
|
+
const startedAtMs = observer === undefined ? null : observer.clock();
|
|
140
|
+
try {
|
|
141
|
+
await fn(ctx, next);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (observer !== undefined) {
|
|
144
|
+
observer.onStage({
|
|
145
|
+
position,
|
|
146
|
+
outcome: 'error',
|
|
147
|
+
startedAtMs,
|
|
148
|
+
durationMs: elapsed(startedAtMs),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
if (observer !== undefined) {
|
|
154
|
+
observer.onStage({
|
|
155
|
+
position,
|
|
156
|
+
outcome: !nextCalled ? 'short-circuit' : downstreamSkipped ? 'downstream-skipped' : 'ok',
|
|
157
|
+
startedAtMs,
|
|
158
|
+
durationMs: elapsed(startedAtMs),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
await run();
|
|
164
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware pipeline — ASP.NET Core-style ordered execution with priority
|
|
3
|
+
* bands, short-circuiting, and double-next protection.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
IMiddlewareApi,
|
|
9
|
+
IRequestContext,
|
|
10
|
+
MiddlewareFunction,
|
|
11
|
+
MiddlewareOptions,
|
|
12
|
+
} from '@setu-ts/common';
|
|
13
|
+
|
|
14
|
+
import { executeChain } from './execute-chain.ts';
|
|
15
|
+
import type { ChainObserver } from './execute-chain.ts';
|
|
16
|
+
|
|
17
|
+
interface MiddlewareEntry {
|
|
18
|
+
fn: MiddlewareFunction;
|
|
19
|
+
priority: number;
|
|
20
|
+
name: string;
|
|
21
|
+
index: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PRIORITY = 500;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One compiled global middleware stage, projected for diagnostics: the
|
|
28
|
+
* declared name, its priority, and its execution position in the stable
|
|
29
|
+
* priority sort.
|
|
30
|
+
*
|
|
31
|
+
* @since 0.8.0
|
|
32
|
+
*/
|
|
33
|
+
export interface MiddlewareStageDescriptor {
|
|
34
|
+
/** The name passed through `MiddlewareOptions`, or the generated placeholder. */
|
|
35
|
+
readonly name: string;
|
|
36
|
+
/** The stage's execution priority. */
|
|
37
|
+
readonly priority: number;
|
|
38
|
+
/** 1-based execution position in the compiled chain. */
|
|
39
|
+
readonly position: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Middleware pipeline: collect middleware with priorities, compile into a
|
|
44
|
+
* sorted chain, then execute with classic next()-chaining semantics.
|
|
45
|
+
*/
|
|
46
|
+
export class MiddlewarePipeline implements IMiddlewareApi {
|
|
47
|
+
readonly #entries: MiddlewareEntry[] = [];
|
|
48
|
+
#compiled: MiddlewareFunction[] | null = null;
|
|
49
|
+
/** Diagnostic names, positionally matching {@linkcode MiddlewarePipeline.compile}'s output. */
|
|
50
|
+
#compiledNames: string[] | null = null;
|
|
51
|
+
/** Compiled stage descriptors, positionally matching the chain (diagnostics seam). */
|
|
52
|
+
#compiledDescriptors: MiddlewareStageDescriptor[] | null = null;
|
|
53
|
+
|
|
54
|
+
add(middleware: MiddlewareFunction, options?: MiddlewareOptions): void {
|
|
55
|
+
if (this.#compiled !== null) {
|
|
56
|
+
throw new Error('Cannot add middleware after the pipeline has been compiled.');
|
|
57
|
+
}
|
|
58
|
+
this.#entries.push({
|
|
59
|
+
fn: middleware,
|
|
60
|
+
priority: options?.priority ?? DEFAULT_PRIORITY,
|
|
61
|
+
name: options?.name ?? `<anonymous-${this.#entries.length}>`,
|
|
62
|
+
index: this.#entries.length,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sorts middleware stably by (priority, insertion order) and freezes the
|
|
68
|
+
* chain. After calling, no further middleware may be added.
|
|
69
|
+
*/
|
|
70
|
+
compile(): readonly MiddlewareFunction[] {
|
|
71
|
+
if (this.#compiled !== null) {
|
|
72
|
+
return this.#compiled;
|
|
73
|
+
}
|
|
74
|
+
const sorted = [...this.#entries].sort(
|
|
75
|
+
(a, b) => a.priority - b.priority || a.index - b.index,
|
|
76
|
+
);
|
|
77
|
+
this.#compiled = sorted.map((entry) => entry.fn);
|
|
78
|
+
this.#compiledNames = sorted.map((entry) => entry.name);
|
|
79
|
+
this.#compiledDescriptors = sorted.map((entry, position) => ({
|
|
80
|
+
name: entry.name,
|
|
81
|
+
priority: entry.priority,
|
|
82
|
+
position: position + 1,
|
|
83
|
+
}));
|
|
84
|
+
return this.#compiled;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Diagnostic names of the compiled chain, in execution order.
|
|
89
|
+
*
|
|
90
|
+
* Each entry is the `name` passed through {@linkcode MiddlewareOptions}, or a
|
|
91
|
+
* generated `<anonymous-N>` placeholder. Returns an empty array before
|
|
92
|
+
* {@linkcode MiddlewarePipeline.compile} runs.
|
|
93
|
+
*
|
|
94
|
+
* @returns The compiled stage names, positionally matching the compiled chain
|
|
95
|
+
* @internal Diagnostics seam — the kernel passes these to `executeChain` so a
|
|
96
|
+
* double-`next()` error can name the offending stage.
|
|
97
|
+
*/
|
|
98
|
+
compiledNames(): readonly string[] {
|
|
99
|
+
return this.#compiledNames ?? [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The compiled stages projected as bounded descriptors, in execution order.
|
|
104
|
+
*
|
|
105
|
+
* @returns Empty before {@linkcode MiddlewarePipeline.compile} runs
|
|
106
|
+
* @internal Diagnostics seam — the collector captures these once, at
|
|
107
|
+
* compile, so middleware nodes and stage records carry the same identity.
|
|
108
|
+
* @since 0.8.0
|
|
109
|
+
*/
|
|
110
|
+
compiledDescriptors(): readonly MiddlewareStageDescriptor[] {
|
|
111
|
+
return this.#compiledDescriptors ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Executes the compiled pipeline using classic next()-chaining.
|
|
116
|
+
*
|
|
117
|
+
* @param ctx - The request context
|
|
118
|
+
* @param terminal - Called when all middleware have completed
|
|
119
|
+
* @param observer - Optional diagnostics observer, forwarded to the shared
|
|
120
|
+
* chain executor; absent performs no additional work
|
|
121
|
+
* @throws {Error} If next() is called multiple times in a single middleware
|
|
122
|
+
*/
|
|
123
|
+
async execute(
|
|
124
|
+
ctx: IRequestContext,
|
|
125
|
+
terminal: () => Promise<void>,
|
|
126
|
+
observer?: ChainObserver,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const chain = this.#compiled ?? this.compile();
|
|
129
|
+
await executeChain(chain, ctx, terminal, this.compiledNames(), observer);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin dependency resolution: topological sort with cycle detection,
|
|
3
|
+
* priority ordering, and the mandatory-runtime-first rule
|
|
4
|
+
* (ARCHITECTURE.md §5, §7).
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import { CAPABILITIES } from '@setu-ts/common';
|
|
9
|
+
import type { CapabilityToken, IPlugin } from '@setu-ts/common';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves plugin registration order.
|
|
13
|
+
*
|
|
14
|
+
* Order is determined by: dependency edges first (a plugin's dependencies
|
|
15
|
+
* register before it), then declared priority (lower first), then original
|
|
16
|
+
* registration order. The plugin providing the `runtime` capability always
|
|
17
|
+
* registers first, regardless of priority.
|
|
18
|
+
*/
|
|
19
|
+
export function resolvePluginOrder(plugins: readonly IPlugin[]): readonly IPlugin[] {
|
|
20
|
+
assertUniqueNames(plugins);
|
|
21
|
+
const providers = buildProviderIndex(plugins);
|
|
22
|
+
|
|
23
|
+
const runtimeProvider = providers.get(CAPABILITIES.RUNTIME);
|
|
24
|
+
if (runtimeProvider === undefined) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`No plugin provides the mandatory '${CAPABILITIES.RUNTIME}' capability. ` +
|
|
27
|
+
`Register a runtime plugin (e.g. RuntimePlugin from @setu-ts/runtime).`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const dependenciesOf = (plugin: IPlugin): IPlugin[] => {
|
|
32
|
+
const edges: IPlugin[] = [];
|
|
33
|
+
// Every plugin implicitly depends on the runtime provider so it can rely
|
|
34
|
+
// on ctx.runtime during registration.
|
|
35
|
+
if (plugin !== runtimeProvider) {
|
|
36
|
+
edges.push(runtimeProvider);
|
|
37
|
+
}
|
|
38
|
+
for (const token of plugin.dependencies ?? []) {
|
|
39
|
+
const provider = providers.get(token);
|
|
40
|
+
if (provider === undefined) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Plugin '${plugin.name}' depends on capability '${token}', but no registered plugin provides it.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
if (provider !== plugin) {
|
|
46
|
+
edges.push(provider);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const token of plugin.optionalDependencies ?? []) {
|
|
50
|
+
const provider = providers.get(token);
|
|
51
|
+
if (provider !== undefined && provider !== plugin) {
|
|
52
|
+
edges.push(provider);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return edges;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return topologicalSort(plugins, dependenciesOf);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One plugin's declared `consumes` capability for which no provider exists.
|
|
63
|
+
* Used to build a soft startup diagnostic in the application.
|
|
64
|
+
*
|
|
65
|
+
* Unlike a missing {@linkcode IPlugin.dependencies} entry (which throws in
|
|
66
|
+
* {@linkcode resolvePluginOrder}), an unsatisfied `consumes` is not a
|
|
67
|
+
* resolve-time error: the plugin resolves the capability lazily at request
|
|
68
|
+
* time, so the failure would otherwise surface only later, as a runtime throw
|
|
69
|
+
* from `services.get`. Reporting it at startup names the consumer up front.
|
|
70
|
+
*/
|
|
71
|
+
export interface UnsatisfiedConsumer {
|
|
72
|
+
/** Name of the plugin that declares the consumed capability. */
|
|
73
|
+
readonly plugin: string;
|
|
74
|
+
/** The consumed capability token that no registered plugin provides. */
|
|
75
|
+
readonly capability: CapabilityToken;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Finds every `consumes` capability, across all plugins, that `isProvided`
|
|
80
|
+
* reports as absent.
|
|
81
|
+
*
|
|
82
|
+
* Pure by design: the caller supplies the provider check — in the kernel, the
|
|
83
|
+
* live service registry's `has` after all plugins have registered, so
|
|
84
|
+
* capabilities registered imperatively (not declared in `provides`) still
|
|
85
|
+
* count as satisfied and never produce a false warning.
|
|
86
|
+
*
|
|
87
|
+
* @param plugins - All registered plugins
|
|
88
|
+
* @param isProvided - Predicate: does a provider exist for this capability?
|
|
89
|
+
* @returns One entry per unsatisfied (plugin, capability) pair, in plugin then
|
|
90
|
+
* declaration order
|
|
91
|
+
*/
|
|
92
|
+
export function findUnsatisfiedConsumers(
|
|
93
|
+
plugins: readonly IPlugin[],
|
|
94
|
+
isProvided: (token: CapabilityToken) => boolean,
|
|
95
|
+
): readonly UnsatisfiedConsumer[] {
|
|
96
|
+
const unsatisfied: UnsatisfiedConsumer[] = [];
|
|
97
|
+
for (const plugin of plugins) {
|
|
98
|
+
for (const capability of plugin.consumes ?? []) {
|
|
99
|
+
if (!isProvided(capability)) {
|
|
100
|
+
unsatisfied.push({ plugin: plugin.name, capability });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return unsatisfied;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertUniqueNames(plugins: readonly IPlugin[]): void {
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
for (const plugin of plugins) {
|
|
110
|
+
if (seen.has(plugin.name)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Duplicate plugin name '${plugin.name}'. To replace a plugin, register the ` +
|
|
113
|
+
`replacement's services with { override: true } instead of reusing the name.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
seen.add(plugin.name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Indexes which plugin provides each capability token. A plugin implicitly
|
|
122
|
+
* provides its own name, so `dependencies: ['logger-plugin']` also works.
|
|
123
|
+
*/
|
|
124
|
+
function buildProviderIndex(plugins: readonly IPlugin[]): Map<CapabilityToken, IPlugin> {
|
|
125
|
+
const providers = new Map<CapabilityToken, IPlugin>();
|
|
126
|
+
for (const plugin of plugins) {
|
|
127
|
+
for (const token of [plugin.name, ...(plugin.provides ?? [])]) {
|
|
128
|
+
const existing = providers.get(token);
|
|
129
|
+
if (existing !== undefined && existing !== plugin) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Capability '${token}' is provided by both '${existing.name}' and '${plugin.name}'. ` +
|
|
132
|
+
`Multi-provider capabilities must be registered with { multi: true } at the service level.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
providers.set(token, plugin);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return providers;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Depth-first topological sort. Within the same dependency level, plugins
|
|
143
|
+
* are visited by (priority, registration order), which the DFS preserves in
|
|
144
|
+
* the output.
|
|
145
|
+
*/
|
|
146
|
+
function topologicalSort(
|
|
147
|
+
plugins: readonly IPlugin[],
|
|
148
|
+
dependenciesOf: (plugin: IPlugin) => IPlugin[],
|
|
149
|
+
): readonly IPlugin[] {
|
|
150
|
+
const DEFAULT_PRIORITY = 500;
|
|
151
|
+
const seeds = plugins
|
|
152
|
+
.map((plugin, index) => ({ plugin, index }))
|
|
153
|
+
.sort(
|
|
154
|
+
(a, b) =>
|
|
155
|
+
(a.plugin.priority ?? DEFAULT_PRIORITY) - (b.plugin.priority ?? DEFAULT_PRIORITY) ||
|
|
156
|
+
a.index - b.index,
|
|
157
|
+
)
|
|
158
|
+
.map(({ plugin }) => plugin);
|
|
159
|
+
|
|
160
|
+
const ordered: IPlugin[] = [];
|
|
161
|
+
const done = new Set<IPlugin>();
|
|
162
|
+
const inProgress = new Set<IPlugin>();
|
|
163
|
+
|
|
164
|
+
const visit = (plugin: IPlugin, chain: readonly string[]): void => {
|
|
165
|
+
if (done.has(plugin)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (inProgress.has(plugin)) {
|
|
169
|
+
const cycle = [...chain.slice(chain.indexOf(plugin.name)), plugin.name].join(' -> ');
|
|
170
|
+
throw new Error(`Circular plugin dependency detected: ${cycle}`);
|
|
171
|
+
}
|
|
172
|
+
inProgress.add(plugin);
|
|
173
|
+
for (const dependency of dependenciesOf(plugin)) {
|
|
174
|
+
visit(dependency, [...chain, plugin.name]);
|
|
175
|
+
}
|
|
176
|
+
inProgress.delete(plugin);
|
|
177
|
+
done.add(plugin);
|
|
178
|
+
ordered.push(plugin);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
for (const plugin of seeds) {
|
|
182
|
+
visit(plugin, []);
|
|
183
|
+
}
|
|
184
|
+
return ordered;
|
|
185
|
+
}
|