@arki/dot 0.1.4 → 0.3.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/README.md +57 -0
- package/dist/cli/index.d.ts +8 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +46 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/render-graph.d.ts +36 -0
- package/dist/cli/render-graph.d.ts.map +1 -0
- package/dist/cli/render-graph.js +70 -0
- package/dist/cli/render-graph.js.map +1 -0
- package/dist/cli/render-openapi.d.ts +38 -0
- package/dist/cli/render-openapi.d.ts.map +1 -0
- package/dist/cli/render-openapi.js +131 -0
- package/dist/cli/render-openapi.js.map +1 -0
- package/dist/define-app.d.ts +10 -0
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +2 -0
- package/dist/define-app.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/kernel/app-instance.d.ts +2 -0
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +67 -17
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +2 -0
- package/dist/lifecycle.js.map +1 -1
- package/dist/manifest.d.ts +19 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/pip-contract.d.ts +24 -2
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +0 -25
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +1 -1
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js.map +1 -1
- package/dist/signals.d.ts +65 -0
- package/dist/signals.d.ts.map +1 -0
- package/dist/signals.js +97 -0
- package/dist/signals.js.map +1 -0
- package/dist/test-harness.d.ts +75 -1
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +71 -0
- package/dist/test-harness.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +68 -3
- package/src/cli/render-graph.ts +88 -0
- package/src/cli/render-openapi.ts +147 -0
- package/src/define-app.ts +13 -0
- package/src/index.ts +6 -2
- package/src/kernel/app-instance.ts +113 -51
- package/src/lifecycle.ts +2 -0
- package/src/manifest.ts +19 -0
- package/src/pip-contract.ts +31 -3
- package/src/pip.ts +1 -0
- package/src/signals.ts +134 -0
- package/src/test-harness.ts +140 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `dot explain --graph` / `dot doctor --graph`.
|
|
3
|
+
*
|
|
4
|
+
* Emits the app's pip graph as [Mermaid](https://mermaid.js.org) `flowchart`
|
|
5
|
+
* source — paste-able into GitHub markdown, docs, and mermaid.live.
|
|
6
|
+
*
|
|
7
|
+
* The nodes are the pips in declaration order (which IS boot order in v2 —
|
|
8
|
+
* the numbering makes that visible); the edges are the manifest's
|
|
9
|
+
* **observed** dependency edges, recorded by the kernel when a pip's need
|
|
10
|
+
* was satisfied during boot. `explain` never boots, so its graph shows
|
|
11
|
+
* declaration order with whatever edges configure-time metadata declared;
|
|
12
|
+
* `doctor` boots, so its graph shows the real wiring.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DotAppManifest } from '../manifest.js';
|
|
16
|
+
import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
|
|
17
|
+
|
|
18
|
+
/** Envelope payload for graph output. */
|
|
19
|
+
export type GraphData = {
|
|
20
|
+
format: 'mermaid';
|
|
21
|
+
/** Mermaid `flowchart` source. */
|
|
22
|
+
source: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Escape a pip name for use inside a quoted Mermaid node label. */
|
|
26
|
+
function escapeLabel(name: string): string {
|
|
27
|
+
return name.replaceAll('"', '#quot;');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build Mermaid `flowchart` source from a manifest. Deterministic: node
|
|
32
|
+
* ids follow declaration order, edges follow manifest order.
|
|
33
|
+
*/
|
|
34
|
+
export function buildMermaidGraph(manifest: DotAppManifest): string {
|
|
35
|
+
const lines: string[] = ['flowchart TD'];
|
|
36
|
+
const idByPip = new Map<string, string>();
|
|
37
|
+
|
|
38
|
+
for (const [index, pip] of manifest.pips.entries()) {
|
|
39
|
+
const id = `p${index.toString()}`;
|
|
40
|
+
idByPip.set(pip.name, id);
|
|
41
|
+
const order = (index + 1).toString();
|
|
42
|
+
const version = pip.version === undefined ? '' : `@${pip.version}`;
|
|
43
|
+
lines.push(` ${id}["${order} · ${escapeLabel(pip.name)}${escapeLabel(version)}"]`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const edge of manifest.dependencies) {
|
|
47
|
+
const from = idByPip.get(edge.from);
|
|
48
|
+
const to = idByPip.get(edge.to);
|
|
49
|
+
// Edges referencing unknown pips would be a kernel bug — skip rather
|
|
50
|
+
// than emit invalid Mermaid.
|
|
51
|
+
if (from === undefined || to === undefined) continue;
|
|
52
|
+
lines.push(` ${from} -->|${edge.kind}| ${to}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return `${lines.join('\n')}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Render the graph output. Plain mode prints raw Mermaid source (pipe it
|
|
60
|
+
* straight into a markdown code fence); `--json` wraps it in the standard
|
|
61
|
+
* CLI envelope under `data.source`.
|
|
62
|
+
*/
|
|
63
|
+
export function renderGraph(
|
|
64
|
+
source: { manifest: DotAppManifest; command: 'explain' | 'doctor' },
|
|
65
|
+
opts: RenderOptions,
|
|
66
|
+
): DotCliEnvelope<GraphData> {
|
|
67
|
+
const mermaid = buildMermaidGraph(source.manifest);
|
|
68
|
+
const nowFactory = opts.now ?? (() => new Date());
|
|
69
|
+
const envelope: DotCliEnvelope<GraphData> = {
|
|
70
|
+
status: 'success',
|
|
71
|
+
command: source.command,
|
|
72
|
+
generatedAt: nowFactory().toISOString(),
|
|
73
|
+
data: { format: 'mermaid', source: mermaid },
|
|
74
|
+
errors: [],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const out =
|
|
78
|
+
opts.out ??
|
|
79
|
+
((line: string) => {
|
|
80
|
+
process.stdout.write(line);
|
|
81
|
+
});
|
|
82
|
+
if (opts.json) {
|
|
83
|
+
out(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
84
|
+
} else {
|
|
85
|
+
out(mermaid);
|
|
86
|
+
}
|
|
87
|
+
return envelope;
|
|
88
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer for `dot explain --openapi`.
|
|
3
|
+
*
|
|
4
|
+
* Emits an OpenAPI 3.1 document straight from `manifest.routes` — no boot
|
|
5
|
+
* required. Adapters (e.g. `@arki/http`'s `registerRoutes`) convert their
|
|
6
|
+
* validation schemas to JSON Schema at `configure` time and register them
|
|
7
|
+
* via `ctx.registerRoute`, so the manifest already carries everything the
|
|
8
|
+
* document needs: methods, paths, per-location input schemas, output
|
|
9
|
+
* schemas, and the `streaming` flag for `text/event-stream` responses.
|
|
10
|
+
*
|
|
11
|
+
* Deterministic: paths and operations follow manifest registration order
|
|
12
|
+
* (which follows pip declaration order). Only `transport: 'http'` routes
|
|
13
|
+
* are rendered — rpc mounts have their own schema story.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { DotAppManifest, RouteManifest } from '../manifest.js';
|
|
17
|
+
import type { DotCliEnvelope, RenderOptions } from './render-explain.js';
|
|
18
|
+
|
|
19
|
+
/** Envelope payload for OpenAPI output. */
|
|
20
|
+
export type OpenApiData = {
|
|
21
|
+
format: 'openapi';
|
|
22
|
+
/** OpenAPI 3.1 document. */
|
|
23
|
+
document: Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function openApiPath(path: string): string {
|
|
27
|
+
return path.replaceAll(/:(\w+)/g, '{$1}');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathParamNames(path: string): readonly string[] {
|
|
31
|
+
return [...path.matchAll(/:(\w+)/g)]
|
|
32
|
+
.map(match => match[1])
|
|
33
|
+
.filter((name): name is string => name !== undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildOperation(route: RouteManifest): Record<string, unknown> {
|
|
37
|
+
const parameters: Record<string, unknown>[] = pathParamNames(route.path ?? '').map(name => ({
|
|
38
|
+
name,
|
|
39
|
+
in: 'path',
|
|
40
|
+
required: true,
|
|
41
|
+
schema: { type: 'string' },
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const querySchema = route.input?.query;
|
|
45
|
+
if (querySchema !== undefined) {
|
|
46
|
+
const properties = (querySchema['properties'] ?? {}) as Readonly<Record<string, unknown>>;
|
|
47
|
+
const required = new Set(querySchema['required'] as readonly string[] | undefined);
|
|
48
|
+
for (const [name, propertySchema] of Object.entries(properties)) {
|
|
49
|
+
parameters.push({ name, in: 'query', required: required.has(name), schema: propertySchema });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const operation: Record<string, unknown> = {
|
|
54
|
+
operationId: route.id,
|
|
55
|
+
tags: [route.pip],
|
|
56
|
+
};
|
|
57
|
+
if (route.description !== undefined) operation['summary'] = route.description;
|
|
58
|
+
if (parameters.length > 0) operation['parameters'] = parameters;
|
|
59
|
+
|
|
60
|
+
const bodySchema = route.input?.body;
|
|
61
|
+
if (bodySchema !== undefined) {
|
|
62
|
+
operation['requestBody'] = {
|
|
63
|
+
required: true,
|
|
64
|
+
content: { 'application/json': { schema: bodySchema } },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (route.streaming === true) {
|
|
69
|
+
operation['responses'] = {
|
|
70
|
+
'200': {
|
|
71
|
+
description: 'event stream',
|
|
72
|
+
content: {
|
|
73
|
+
'text/event-stream': route.output === undefined ? {} : { schema: route.output },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
} else if (route.output === undefined) {
|
|
78
|
+
operation['responses'] = { '200': { description: 'success' } };
|
|
79
|
+
} else {
|
|
80
|
+
operation['responses'] = {
|
|
81
|
+
'200': {
|
|
82
|
+
description: 'success',
|
|
83
|
+
content: { 'application/json': { schema: route.output } },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return operation;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build an OpenAPI 3.1 document from a manifest. First registration wins
|
|
93
|
+
* on a method+path collision — collisions are an app bug the serving
|
|
94
|
+
* adapter rejects at boot; the document renderer stays total.
|
|
95
|
+
*/
|
|
96
|
+
export function buildOpenApiDocument(manifest: DotAppManifest): Record<string, unknown> {
|
|
97
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
98
|
+
for (const route of manifest.routes) {
|
|
99
|
+
if (route.transport !== 'http') continue;
|
|
100
|
+
if (route.path === undefined) continue;
|
|
101
|
+
const path = openApiPath(route.path);
|
|
102
|
+
const method = (route.method ?? 'GET').toLowerCase();
|
|
103
|
+
const entry = (paths[path] ??= {});
|
|
104
|
+
if (entry[method] !== undefined) continue;
|
|
105
|
+
entry[method] = buildOperation(route);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
openapi: '3.1.0',
|
|
109
|
+
info: {
|
|
110
|
+
title: manifest.app.name,
|
|
111
|
+
version: manifest.app.version ?? '0.0.0',
|
|
112
|
+
},
|
|
113
|
+
paths,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Render the OpenAPI output. Plain mode prints the document as JSON
|
|
119
|
+
* (pipe it into a file or a swagger viewer); `--json` wraps it in the
|
|
120
|
+
* standard CLI envelope under `data.document`.
|
|
121
|
+
*/
|
|
122
|
+
export function renderOpenApi(
|
|
123
|
+
source: { manifest: DotAppManifest; command: 'explain' },
|
|
124
|
+
opts: RenderOptions,
|
|
125
|
+
): DotCliEnvelope<OpenApiData> {
|
|
126
|
+
const document = buildOpenApiDocument(source.manifest);
|
|
127
|
+
const nowFactory = opts.now ?? (() => new Date());
|
|
128
|
+
const envelope: DotCliEnvelope<OpenApiData> = {
|
|
129
|
+
status: 'success',
|
|
130
|
+
command: source.command,
|
|
131
|
+
generatedAt: nowFactory().toISOString(),
|
|
132
|
+
data: { format: 'openapi', document },
|
|
133
|
+
errors: [],
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const out =
|
|
137
|
+
opts.out ??
|
|
138
|
+
((line: string) => {
|
|
139
|
+
process.stdout.write(line);
|
|
140
|
+
});
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
out(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
143
|
+
} else {
|
|
144
|
+
out(`${JSON.stringify(document, null, 2)}\n`);
|
|
145
|
+
}
|
|
146
|
+
return envelope;
|
|
147
|
+
}
|
package/src/define-app.ts
CHANGED
|
@@ -176,6 +176,7 @@ type BuilderState = {
|
|
|
176
176
|
pips: AnyPip[];
|
|
177
177
|
config?: Readonly<Record<string, unknown>>;
|
|
178
178
|
observers?: readonly DotLifecycleObserver[];
|
|
179
|
+
hookTimeoutMs?: number;
|
|
179
180
|
};
|
|
180
181
|
|
|
181
182
|
/**
|
|
@@ -204,6 +205,16 @@ export function defineApp(
|
|
|
204
205
|
* `configured.subscribe(...)` or `app.subscribe(...)`.
|
|
205
206
|
*/
|
|
206
207
|
observers?: readonly DotLifecycleObserver[];
|
|
208
|
+
/**
|
|
209
|
+
* Watchdog budget (ms) for each async hook invocation (`boot`, `start`,
|
|
210
|
+
* `stop`, `dispose` — `configure` is sync). A hook exceeding the budget
|
|
211
|
+
* fails with `DOT_LIFECYCLE_E015` naming the pip and hook, and the
|
|
212
|
+
* kernel applies its normal failure rules (boot rollback, teardown
|
|
213
|
+
* aggregation). The hook's promise itself cannot be cancelled — the
|
|
214
|
+
* watchdog makes the hang *visible*, it does not kill it. Default:
|
|
215
|
+
* no watchdog.
|
|
216
|
+
*/
|
|
217
|
+
hookTimeoutMs?: number;
|
|
207
218
|
} = {},
|
|
208
219
|
): DotAppBuilder<EmptyShape> {
|
|
209
220
|
const state: BuilderState = {
|
|
@@ -212,6 +223,7 @@ export function defineApp(
|
|
|
212
223
|
pips: [],
|
|
213
224
|
config: options.config,
|
|
214
225
|
observers: options.observers,
|
|
226
|
+
hookTimeoutMs: options.hookTimeoutMs,
|
|
215
227
|
};
|
|
216
228
|
return makeBuilder<EmptyShape>(state);
|
|
217
229
|
}
|
|
@@ -223,6 +235,7 @@ function buildImpl(state: BuilderState): DotAppImpl {
|
|
|
223
235
|
pips: state.pips,
|
|
224
236
|
config: state.config,
|
|
225
237
|
observers: state.observers,
|
|
238
|
+
hookTimeoutMs: state.hookTimeoutMs,
|
|
226
239
|
});
|
|
227
240
|
}
|
|
228
241
|
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type {
|
|
|
20
20
|
CtxOf,
|
|
21
21
|
DotConfigureContext,
|
|
22
22
|
EmptyShape,
|
|
23
|
+
InferredProvides,
|
|
23
24
|
KernelCtx,
|
|
24
25
|
Lazy,
|
|
25
26
|
LazyService,
|
|
@@ -80,8 +81,11 @@ export type {
|
|
|
80
81
|
export { renderTimeline } from './timeline.js';
|
|
81
82
|
export type { RenderTimelineOptions } from './timeline.js';
|
|
82
83
|
|
|
83
|
-
export { testApp, bootTestApp } from './test-harness.js';
|
|
84
|
-
export type { TestAppOptions } from './test-harness.js';
|
|
84
|
+
export { testApp, bootTestApp, testPip } from './test-harness.js';
|
|
85
|
+
export type { TestAppOptions, TestPipBuilder } from './test-harness.js';
|
|
86
|
+
|
|
87
|
+
export { hookSignals } from './signals.js';
|
|
88
|
+
export type { HookSignalsOptions, SignalTarget } from './signals.js';
|
|
85
89
|
|
|
86
90
|
// Task 9b: CLI envelope type is exported so adapter packages can produce the
|
|
87
91
|
// same shape from related tooling (release-tooling, pip scaffolds, etc.).
|
|
@@ -104,6 +104,8 @@ export type DotAppInternalConfig = {
|
|
|
104
104
|
* happen before there's a public seam to call `subscribe()` on.
|
|
105
105
|
*/
|
|
106
106
|
observers?: readonly DotLifecycleObserver[];
|
|
107
|
+
/** Per-hook watchdog budget in ms — see `defineApp`'s option of the same name. */
|
|
108
|
+
hookTimeoutMs?: number;
|
|
107
109
|
};
|
|
108
110
|
|
|
109
111
|
/**
|
|
@@ -122,6 +124,9 @@ export class DotAppImpl {
|
|
|
122
124
|
/** Macro-state of the app. */
|
|
123
125
|
#state: DotLifecycleState = 'defined';
|
|
124
126
|
|
|
127
|
+
/** Per-hook watchdog budget (ms); `undefined` = no watchdog. */
|
|
128
|
+
readonly #hookTimeoutMs: number | undefined;
|
|
129
|
+
|
|
125
130
|
/** Manifest finalised after `configure`. */
|
|
126
131
|
#manifest: DotAppManifest;
|
|
127
132
|
|
|
@@ -180,6 +185,7 @@ export class DotAppImpl {
|
|
|
180
185
|
constructor(config: DotAppInternalConfig) {
|
|
181
186
|
this.#appName = config.appName;
|
|
182
187
|
this.#appVersion = config.appVersion;
|
|
188
|
+
this.#hookTimeoutMs = config.hookTimeoutMs;
|
|
183
189
|
this.#config = Object.freeze({ ...config.config });
|
|
184
190
|
this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
|
|
185
191
|
this.#observers = new Set(config.observers);
|
|
@@ -675,6 +681,44 @@ export class DotAppImpl {
|
|
|
675
681
|
return ctx;
|
|
676
682
|
}
|
|
677
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Race one hook invocation against the app's `hookTimeoutMs` watchdog.
|
|
686
|
+
* No budget configured → plain pass-through. On timeout the returned
|
|
687
|
+
* promise rejects with `DOT_LIFECYCLE_E015` naming the pip and hook,
|
|
688
|
+
* and the call site's existing catch path applies the phase's normal
|
|
689
|
+
* failure rules (boot rollback, start cascade, teardown aggregation).
|
|
690
|
+
* The hook's own promise cannot be cancelled — the watchdog makes a
|
|
691
|
+
* hang visible and bounded; it does not kill the hung work.
|
|
692
|
+
*/
|
|
693
|
+
async #withHookBudget<T>(pipName: string, hook: DotLifecycleHook, run: () => Promise<T> | T): Promise<T> {
|
|
694
|
+
const budget = this.#hookTimeoutMs;
|
|
695
|
+
if (budget === undefined) return run();
|
|
696
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
697
|
+
const watchdog = new Promise<never>((_resolve, reject) => {
|
|
698
|
+
timer = setTimeout(() => {
|
|
699
|
+
reject(
|
|
700
|
+
new DotLifecycleError({
|
|
701
|
+
code: DotLifecycleErrorCode.HookTimeout,
|
|
702
|
+
phase: hook,
|
|
703
|
+
pip: pipName,
|
|
704
|
+
message:
|
|
705
|
+
`${hook} hook of pip "${pipName}" exceeded the ${budget.toString()}ms hookTimeoutMs watchdog. ` +
|
|
706
|
+
`The kernel treats the hook as failed and applies its normal rollback/aggregation rules, but ` +
|
|
707
|
+
`cannot cancel the hook's promise — find the hang (a missing await? a connection that never ` +
|
|
708
|
+
`settles?) or raise the budget in defineApp(name, { hookTimeoutMs }).`,
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
}, budget);
|
|
712
|
+
// A pending watchdog must never keep an otherwise-finished process alive.
|
|
713
|
+
timer.unref?.();
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
return await Promise.race([Promise.resolve(run()), watchdog]);
|
|
717
|
+
} finally {
|
|
718
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
678
722
|
/**
|
|
679
723
|
* Inner boot loop. Separated from `#runBoot` so the phase span wrapper
|
|
680
724
|
* stays thin and the loop body — which orchestrates rollback on
|
|
@@ -770,26 +814,33 @@ export class DotAppImpl {
|
|
|
770
814
|
this.#emitHook('boot', pip.name, record.order, 'starting');
|
|
771
815
|
let result: ServiceRecord | void;
|
|
772
816
|
try {
|
|
773
|
-
result = await
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
817
|
+
result = await this.#withHookBudget(pip.name, 'boot', () =>
|
|
818
|
+
withPipHookSpan(
|
|
819
|
+
{
|
|
820
|
+
appName: this.#appName,
|
|
821
|
+
pipName: pip.name,
|
|
822
|
+
pipVersion: pip.version,
|
|
823
|
+
hook: 'boot',
|
|
824
|
+
order: record.order,
|
|
825
|
+
logger: pipLogger,
|
|
826
|
+
},
|
|
827
|
+
// Erasure boundary: hooks are stored as `(ctx: never) => ...`;
|
|
828
|
+
// the kernel is the one caller allowed to cross it.
|
|
829
|
+
() => pip.hooks.boot!(ctx as never),
|
|
830
|
+
),
|
|
785
831
|
);
|
|
786
832
|
} catch (error) {
|
|
833
|
+
const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
|
|
787
834
|
return fail({
|
|
788
835
|
record,
|
|
789
|
-
code: DotLifecycleErrorCode.BootFailed,
|
|
790
|
-
message:
|
|
791
|
-
|
|
792
|
-
|
|
836
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.BootFailed,
|
|
837
|
+
message: timedOut
|
|
838
|
+
? error.message
|
|
839
|
+
: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
|
|
840
|
+
remediation: timedOut
|
|
841
|
+
? `Find the hang in the boot() hook of "${pip.name}" — a missing await or a connection that never settles — or raise defineApp's hookTimeoutMs.`
|
|
842
|
+
: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
|
|
843
|
+
docsAnchor: timedOut ? 'hook-timeout' : 'boot-failed',
|
|
793
844
|
durationMs: performance.now() - started,
|
|
794
845
|
cause: error,
|
|
795
846
|
rollback: bootedRecords,
|
|
@@ -917,25 +968,32 @@ export class DotAppImpl {
|
|
|
917
968
|
const startedAt = performance.now();
|
|
918
969
|
this.#emitHook('start', pip.name, record.order, 'starting');
|
|
919
970
|
try {
|
|
920
|
-
await
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
971
|
+
await this.#withHookBudget(pip.name, 'start', () =>
|
|
972
|
+
withPipHookSpan(
|
|
973
|
+
{
|
|
974
|
+
appName: this.#appName,
|
|
975
|
+
pipName: pip.name,
|
|
976
|
+
pipVersion: pip.version,
|
|
977
|
+
hook: 'start',
|
|
978
|
+
order: record.order,
|
|
979
|
+
logger: pipLogger,
|
|
980
|
+
},
|
|
981
|
+
() => pip.hooks.start!(ctx as never),
|
|
982
|
+
),
|
|
930
983
|
);
|
|
931
984
|
} catch (error) {
|
|
932
985
|
const durationMs = performance.now() - startedAt;
|
|
986
|
+
const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
|
|
933
987
|
const issue = makeIssue({
|
|
934
|
-
code: DotLifecycleErrorCode.StartFailed,
|
|
988
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
935
989
|
pip: pip.name,
|
|
936
|
-
message:
|
|
937
|
-
|
|
938
|
-
|
|
990
|
+
message: timedOut
|
|
991
|
+
? error.message
|
|
992
|
+
: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
|
|
993
|
+
remediation: timedOut
|
|
994
|
+
? `Find the hang in the start() hook of "${pip.name}" — or raise defineApp's hookTimeoutMs. DOT rolls back as for any start failure.`
|
|
995
|
+
: `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
|
|
996
|
+
docsAnchor: timedOut ? 'hook-timeout' : 'start-failed',
|
|
939
997
|
});
|
|
940
998
|
record.issues.push(issue);
|
|
941
999
|
record.lifecycleDiagnostics.push({
|
|
@@ -958,7 +1016,7 @@ export class DotAppImpl {
|
|
|
958
1016
|
this.#manifest = this.#buildManifest();
|
|
959
1017
|
const failures = [...stopFailures, ...disposeFailures];
|
|
960
1018
|
throw new DotLifecycleError({
|
|
961
|
-
code: DotLifecycleErrorCode.StartFailed,
|
|
1019
|
+
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
962
1020
|
phase: 'start',
|
|
963
1021
|
pip: pip.name,
|
|
964
1022
|
message: issue.message,
|
|
@@ -1053,16 +1111,18 @@ export class DotAppImpl {
|
|
|
1053
1111
|
const startedAt = performance.now();
|
|
1054
1112
|
this.#emitHook('stop', record.pip.name, record.order, 'starting');
|
|
1055
1113
|
try {
|
|
1056
|
-
await
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1114
|
+
await this.#withHookBudget(record.pip.name, 'stop', () =>
|
|
1115
|
+
withPipHookSpan(
|
|
1116
|
+
{
|
|
1117
|
+
appName: this.#appName,
|
|
1118
|
+
pipName: record.pip.name,
|
|
1119
|
+
pipVersion: record.pip.version,
|
|
1120
|
+
hook: 'stop',
|
|
1121
|
+
order: record.order,
|
|
1122
|
+
logger: pipLogger,
|
|
1123
|
+
},
|
|
1124
|
+
() => record.pip.hooks.stop!(ctx as never),
|
|
1125
|
+
),
|
|
1066
1126
|
);
|
|
1067
1127
|
record.started = false;
|
|
1068
1128
|
const durationMs = performance.now() - startedAt;
|
|
@@ -1265,16 +1325,18 @@ export class DotAppImpl {
|
|
|
1265
1325
|
const startedAt = performance.now();
|
|
1266
1326
|
this.#emitHook('dispose', record.pip.name, record.order, 'starting');
|
|
1267
1327
|
try {
|
|
1268
|
-
await
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1328
|
+
await this.#withHookBudget(record.pip.name, 'dispose', () =>
|
|
1329
|
+
withPipHookSpan(
|
|
1330
|
+
{
|
|
1331
|
+
appName: this.#appName,
|
|
1332
|
+
pipName: record.pip.name,
|
|
1333
|
+
pipVersion: record.pip.version,
|
|
1334
|
+
hook: 'dispose',
|
|
1335
|
+
order: record.order,
|
|
1336
|
+
logger: pipLogger,
|
|
1337
|
+
},
|
|
1338
|
+
() => record.pip.hooks.dispose!(ctx as never),
|
|
1339
|
+
),
|
|
1278
1340
|
);
|
|
1279
1341
|
record.booted = false;
|
|
1280
1342
|
const durationMs = performance.now() - startedAt;
|
package/src/lifecycle.ts
CHANGED
|
@@ -76,6 +76,8 @@ export const DotLifecycleErrorCode = {
|
|
|
76
76
|
ServiceCollision: 'DOT_LIFECYCLE_E013',
|
|
77
77
|
/** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
|
|
78
78
|
ReservedServiceKey: 'DOT_LIFECYCLE_E014',
|
|
79
|
+
/** A lifecycle hook exceeded the app's `hookTimeoutMs` watchdog. */
|
|
80
|
+
HookTimeout: 'DOT_LIFECYCLE_E015',
|
|
79
81
|
} as const;
|
|
80
82
|
|
|
81
83
|
export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
|
package/src/manifest.ts
CHANGED
|
@@ -71,6 +71,25 @@ export type RouteManifest = {
|
|
|
71
71
|
method?: string;
|
|
72
72
|
path?: string;
|
|
73
73
|
transport: RouteTransport;
|
|
74
|
+
/** Human summary, rendered into docs/OpenAPI output. */
|
|
75
|
+
description?: string;
|
|
76
|
+
/**
|
|
77
|
+
* JSON Schema for the request inputs, keyed by location. Adapters
|
|
78
|
+
* convert their validation schemas (e.g. zod) at `configure` time so the
|
|
79
|
+
* manifest stays plain serializable data — `dot explain --openapi`
|
|
80
|
+
* renders straight from here without booting.
|
|
81
|
+
*/
|
|
82
|
+
input?: {
|
|
83
|
+
readonly query?: Readonly<Record<string, unknown>>;
|
|
84
|
+
readonly body?: Readonly<Record<string, unknown>>;
|
|
85
|
+
};
|
|
86
|
+
/** JSON Schema for the success response (or per-event schema when `streaming`). */
|
|
87
|
+
output?: Readonly<Record<string, unknown>>;
|
|
88
|
+
/**
|
|
89
|
+
* The response is an open stream (e.g. `text/event-stream`) rather than
|
|
90
|
+
* a single JSON document.
|
|
91
|
+
*/
|
|
92
|
+
streaming?: boolean;
|
|
74
93
|
};
|
|
75
94
|
|
|
76
95
|
/** Single service published by a pip. */
|
package/src/pip-contract.ts
CHANGED
|
@@ -313,8 +313,25 @@ export type DotConfigureContext = {
|
|
|
313
313
|
* is returned from the `boot` hook.
|
|
314
314
|
*/
|
|
315
315
|
registerService(name: string, kind: ServiceKind): void;
|
|
316
|
-
/**
|
|
317
|
-
|
|
316
|
+
/**
|
|
317
|
+
* Register a route this pip exposes. The optional `description`,
|
|
318
|
+
* `input`/`output` JSON Schemas, and `streaming` flag flow into
|
|
319
|
+
* `manifest.routes` untouched — `dot explain --openapi` renders from
|
|
320
|
+
* them without booting the app.
|
|
321
|
+
*/
|
|
322
|
+
registerRoute(route: {
|
|
323
|
+
id: string;
|
|
324
|
+
method?: string;
|
|
325
|
+
path?: string;
|
|
326
|
+
transport: RouteTransport;
|
|
327
|
+
description?: string;
|
|
328
|
+
input?: {
|
|
329
|
+
readonly query?: Readonly<Record<string, unknown>>;
|
|
330
|
+
readonly body?: Readonly<Record<string, unknown>>;
|
|
331
|
+
};
|
|
332
|
+
output?: Readonly<Record<string, unknown>>;
|
|
333
|
+
streaming?: boolean;
|
|
334
|
+
}): void;
|
|
318
335
|
/** Mark the pip as participating in a lifecycle hook. */
|
|
319
336
|
registerLifecycleHook(hook: 'configure' | 'boot' | 'start' | 'stop' | 'dispose'): void;
|
|
320
337
|
/** Append `provides` capability strings (informational, manifest-only). */
|
|
@@ -393,6 +410,17 @@ export type AnyPip = Pip<ServiceRecord, ServiceRecord>;
|
|
|
393
410
|
* });
|
|
394
411
|
* ```
|
|
395
412
|
*/
|
|
413
|
+
/**
|
|
414
|
+
* A `boot` that returns `void` gives `TProvides` no inference candidate,
|
|
415
|
+
* and TypeScript then substitutes the CONSTRAINT (`ServiceRecord & …`,
|
|
416
|
+
* whose `keyof` is `string`) rather than the declared default. That wide
|
|
417
|
+
* record would poison the app builder's collision check for every later
|
|
418
|
+
* `.use()` — `keyof TAvail & string` is never empty. Detect the fallback
|
|
419
|
+
* by its tell (a full string index signature — no `pip()`-authored
|
|
420
|
+
* provides record has one) and collapse it to "provides nothing".
|
|
421
|
+
*/
|
|
422
|
+
export type InferredProvides<TP extends ServiceRecord> = string extends keyof TP ? EmptyShape : TP;
|
|
423
|
+
|
|
396
424
|
export function pip<
|
|
397
425
|
TShape extends NeedsShape & NoReservedKeys = EmptyShape,
|
|
398
426
|
TProvides extends ServiceRecord & NoReservedKeys = EmptyShape,
|
|
@@ -405,7 +433,7 @@ export function pip<
|
|
|
405
433
|
readonly start?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
|
|
406
434
|
readonly stop?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
|
|
407
435
|
readonly dispose?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
|
|
408
|
-
}): Pip<WireNeeds<TShape>, TProvides
|
|
436
|
+
}): Pip<WireNeeds<TShape>, InferredProvides<TProvides>> {
|
|
409
437
|
return {
|
|
410
438
|
name: def.name,
|
|
411
439
|
...(def.version === undefined ? {} : { version: def.version }),
|