@gobing-ai/ts-infra 0.3.5 → 0.3.7
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 +95 -60
- package/dist/application/index.d.ts +16 -18
- package/dist/application/index.d.ts.map +1 -1
- package/dist/application/index.js +59 -96
- package/dist/application/plugins/builtins.d.ts +64 -0
- package/dist/application/plugins/builtins.d.ts.map +1 -0
- package/dist/application/plugins/builtins.js +146 -0
- package/dist/application/plugins/host.d.ts +61 -0
- package/dist/application/plugins/host.d.ts.map +1 -0
- package/dist/application/plugins/host.js +131 -0
- package/dist/application/plugins/index.d.ts +3 -0
- package/dist/application/plugins/index.d.ts.map +1 -0
- package/dist/application/plugins/index.js +2 -0
- package/dist/application/plugins/types.d.ts +69 -0
- package/dist/application/plugins/types.d.ts.map +1 -0
- package/dist/application/plugins/types.js +10 -0
- package/dist/application/types.d.ts +7 -0
- package/dist/application/types.d.ts.map +1 -1
- package/dist/application-node.d.ts.map +1 -1
- package/dist/application-node.js +40 -57
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/scheduler/cloudflare.d.ts.map +1 -1
- package/dist/scheduler/cloudflare.js +9 -2
- package/dist/scheduler/factory.d.ts +4 -8
- package/dist/scheduler/factory.d.ts.map +1 -1
- package/dist/scheduler/factory.js +12 -22
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +1 -1
- package/dist/scheduler/wrap-handler.d.ts +8 -4
- package/dist/scheduler/wrap-handler.d.ts.map +1 -1
- package/dist/scheduler/wrap-handler.js +8 -4
- package/dist/telemetry/index.d.ts +1 -2
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +1 -2
- package/dist/telemetry/metrics.d.ts +9 -1
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +22 -1
- package/dist/telemetry/sdk.d.ts +33 -1
- package/dist/telemetry/sdk.d.ts.map +1 -1
- package/dist/telemetry/sdk.js +14 -1
- package/package.json +5 -5
- package/src/application/index.ts +72 -102
- package/src/application/plugins/builtins.ts +178 -0
- package/src/application/plugins/host.ts +143 -0
- package/src/application/plugins/index.ts +3 -0
- package/src/application/plugins/types.ts +86 -0
- package/src/application/types.ts +7 -0
- package/src/application-node.ts +43 -61
- package/src/index.ts +0 -2
- package/src/scheduler/cloudflare.ts +16 -5
- package/src/scheduler/factory.ts +15 -26
- package/src/scheduler/index.ts +1 -1
- package/src/scheduler/wrap-handler.ts +8 -4
- package/src/telemetry/index.ts +9 -2
- package/src/telemetry/metrics.ts +22 -1
- package/src/telemetry/sdk.ts +51 -2
- package/dist/telemetry/config.d.ts +0 -41
- package/dist/telemetry/config.d.ts.map +0 -1
- package/dist/telemetry/config.js +0 -21
- package/src/telemetry/config.ts +0 -59
package/src/application/index.ts
CHANGED
|
@@ -14,10 +14,11 @@ import { attachDefaultObservers, createLifecycleBus } from '../event-bus/default
|
|
|
14
14
|
import { EventBus } from '../event-bus/event-bus';
|
|
15
15
|
import type { BusLifecycleEvents, EventMap } from '../event-bus/types';
|
|
16
16
|
import type { InfraEvents } from '../events';
|
|
17
|
-
import { getLogger,
|
|
18
|
-
import { initScheduler
|
|
17
|
+
import { getLogger, type Logger } from '../logger';
|
|
18
|
+
import { initScheduler } from '../scheduler/factory';
|
|
19
19
|
import type { SchedulerAdapter } from '../scheduler/types';
|
|
20
|
-
import {
|
|
20
|
+
import { loggerPlugin, schedulerPlugin, telemetryPlugin, userCallbackPlugin } from './plugins/builtins';
|
|
21
|
+
import { PluginHost } from './plugins/host';
|
|
21
22
|
import type {
|
|
22
23
|
ApplicationBootstrapConfig,
|
|
23
24
|
ApplicationBootstrapOptions,
|
|
@@ -30,11 +31,7 @@ import type {
|
|
|
30
31
|
|
|
31
32
|
interface RuntimeState<TAppConfig, TEvents extends EventMap> {
|
|
32
33
|
app: ApplicationRuntime<TAppConfig, TEvents> | undefined;
|
|
33
|
-
|
|
34
|
-
schedulerAdapter?: SchedulerAdapter;
|
|
35
|
-
schedulerStarted: boolean;
|
|
36
|
-
loggerInitialized: boolean;
|
|
37
|
-
telemetryInitialized: boolean;
|
|
34
|
+
pluginHost: PluginHost;
|
|
38
35
|
stopped: boolean;
|
|
39
36
|
}
|
|
40
37
|
|
|
@@ -50,31 +47,13 @@ async function performShutdown<TAppConfig, TEvents extends EventMap>(
|
|
|
50
47
|
const app = state.app;
|
|
51
48
|
if (!app) return;
|
|
52
49
|
|
|
53
|
-
// 1.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
await state.schedulerAdapter.stop().catch(() => {});
|
|
61
|
-
state.schedulerStarted = false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 3. Close DB adapter
|
|
65
|
-
if (app.db) {
|
|
66
|
-
try {
|
|
67
|
-
app.db.close();
|
|
68
|
-
} catch {
|
|
69
|
-
/* best-effort */
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// 4. Shutdown telemetry
|
|
74
|
-
if (state.telemetryInitialized) {
|
|
75
|
-
await shutdownTelemetry();
|
|
76
|
-
state.telemetryInitialized = false;
|
|
77
|
-
}
|
|
50
|
+
// 1. Stop + unload plugins in reverse registration order (fail-soft).
|
|
51
|
+
// Scheduler stop and telemetry shutdown run here via their onStop hooks.
|
|
52
|
+
await state.pluginHost.stopAll(reason);
|
|
53
|
+
await state.pluginHost.unloadAll(reason);
|
|
54
|
+
// Shutdown is complete — the host's stopAll/unloadAll calls every plugin's
|
|
55
|
+
// onStop/onUnload in reverse registration order, including user callback,
|
|
56
|
+
// scheduler, and service teardown. No inline steps.
|
|
78
57
|
}
|
|
79
58
|
|
|
80
59
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
@@ -86,24 +65,20 @@ async function performShutdown<TAppConfig, TEvents extends EventMap>(
|
|
|
86
65
|
* Accepts injected dependencies; never opens files, reads config from disk,
|
|
87
66
|
* or wires runtime-specific exporters.
|
|
88
67
|
*
|
|
89
|
-
* Startup
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* 6. Initialize scheduler + register entries
|
|
96
|
-
* 7. Call user `start(app)` callback
|
|
97
|
-
* 8. Start scheduler if `autoStart`
|
|
68
|
+
* Startup is plugin-driven. Built-in service plugins are registered in dependency
|
|
69
|
+
* order, then `loadAll()` + `startAll()` run them forward:
|
|
70
|
+
* 1. Resolve bootstrap config; build EventBus + PluginHost
|
|
71
|
+
* 2. Register built-ins in order: logger → telemetry → [caller plugins] →
|
|
72
|
+
* user-callback → scheduler (scheduler last so autoStart runs after user start)
|
|
73
|
+
* 3. `loadAll()` then `startAll()` — `failFast` plugins abort boot on failure
|
|
98
74
|
*
|
|
99
|
-
* Shutdown
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* 4. Shut down telemetry
|
|
75
|
+
* Shutdown is the reverse fan-out: `stopAll(reason)` → `unloadAll(reason)` calls
|
|
76
|
+
* every plugin's `onStop`/`onUnload` in reverse registration order (scheduler stop,
|
|
77
|
+
* user `stop(app, reason)`, telemetry shutdown, owned-DB close). Caller-injected
|
|
78
|
+
* `services.db` is caller-owned and never closed here.
|
|
104
79
|
*
|
|
105
|
-
* If
|
|
106
|
-
*
|
|
80
|
+
* If startup fails, the host's reverse-order `stopAll('error')`/`unloadAll('error')`
|
|
81
|
+
* tears down whatever started before rethrowing. `stop()` is idempotent.
|
|
107
82
|
*
|
|
108
83
|
* @example
|
|
109
84
|
* ```ts
|
|
@@ -151,41 +126,14 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
151
126
|
|
|
152
127
|
const state: RuntimeState<TAppConfig, TEvents> = {
|
|
153
128
|
app: undefined,
|
|
154
|
-
userStop: options.stop,
|
|
155
|
-
schedulerAdapter: undefined,
|
|
156
|
-
schedulerStarted: false,
|
|
157
|
-
loggerInitialized: false,
|
|
158
|
-
telemetryInitialized: false,
|
|
159
129
|
stopped: false,
|
|
130
|
+
pluginHost: undefined as unknown as PluginHost,
|
|
160
131
|
};
|
|
161
132
|
|
|
162
133
|
try {
|
|
163
|
-
// ── 1.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
logger = options.services.logger;
|
|
167
|
-
} else if (loggingConfig.enabled) {
|
|
168
|
-
await initializeLogger({
|
|
169
|
-
level: loggingConfig.level,
|
|
170
|
-
console: loggingConfig.console,
|
|
171
|
-
fileSink: loggingConfig.fileSink,
|
|
172
|
-
json: loggingConfig.json,
|
|
173
|
-
});
|
|
174
|
-
logger = getLogger('bootstrap');
|
|
175
|
-
} else {
|
|
176
|
-
logger = getLogger('bootstrap');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── 2. Initialize telemetry ────────────────────────────────────
|
|
180
|
-
if (telemetryConfig.enabled) {
|
|
181
|
-
initTelemetry({
|
|
182
|
-
enabled: telemetryConfig.enabled,
|
|
183
|
-
serviceName: telemetryConfig.serviceName,
|
|
184
|
-
environment: telemetryConfig.environment,
|
|
185
|
-
dbStatementDebug: telemetryConfig.dbStatementDebug,
|
|
186
|
-
});
|
|
187
|
-
state.telemetryInitialized = true;
|
|
188
|
-
}
|
|
134
|
+
// ── 1. Resolve logger (init deferred to loggerPlugin) ───────────
|
|
135
|
+
const logger: Logger = options.services?.logger ?? getLogger('bootstrap');
|
|
136
|
+
const loggerInjected = !!options.services?.logger;
|
|
189
137
|
|
|
190
138
|
// ── 3. Create lifecycle bus + EventBus ─────────────────────────
|
|
191
139
|
const lifecycleBus =
|
|
@@ -206,14 +154,22 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
206
154
|
let scheduler: SchedulerAdapter | undefined;
|
|
207
155
|
if (schedulerConfig.enabled) {
|
|
208
156
|
const adapter = options.services?.scheduler ?? schedOpts?.adapter;
|
|
209
|
-
|
|
210
|
-
setSchedulerAdapter(adapter);
|
|
211
|
-
}
|
|
212
|
-
scheduler = initScheduler(schedOpts?.entries);
|
|
213
|
-
state.schedulerAdapter = scheduler;
|
|
157
|
+
scheduler = initScheduler(adapter, schedOpts?.entries);
|
|
214
158
|
}
|
|
215
159
|
|
|
216
|
-
// ──
|
|
160
|
+
// ── 5.5 Plugin host + built-in service plugins ─────────────────
|
|
161
|
+
const pluginHost: PluginHost =
|
|
162
|
+
options.services?.pluginHost ?? new PluginHost(events as unknown as EventBus<EventMap>);
|
|
163
|
+
state.pluginHost = pluginHost;
|
|
164
|
+
|
|
165
|
+
// Register built-in service plugins in dependency order: logger -> telemetry.
|
|
166
|
+
pluginHost.register(loggerPlugin(loggingConfig, loggerInjected));
|
|
167
|
+
pluginHost.register(telemetryPlugin(telemetryConfig));
|
|
168
|
+
// Caller-injected `services.db` is NOT closed by the portable layer — it is
|
|
169
|
+
// caller-owned (task 0028). Only adapters the bootstrap CREATES (the Node
|
|
170
|
+
// subpath) are wrapped in a `dbPlugin` whose onStop closes them.
|
|
171
|
+
|
|
172
|
+
// ── Build runtime handle (before startAll so plugins can capture it) ─
|
|
217
173
|
const resolvedConfig: ApplicationBootstrapConfig = {
|
|
218
174
|
logging: loggingConfig,
|
|
219
175
|
events: { enabled: eventsEnabled, lifecycle: eventsLifecycle, defaultObservers: eventsDefaultObservers },
|
|
@@ -221,7 +177,6 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
221
177
|
scheduler: schedulerConfig,
|
|
222
178
|
};
|
|
223
179
|
|
|
224
|
-
// ── Build runtime handle ───────────────────────────────────────
|
|
225
180
|
const app: ApplicationRuntime<TAppConfig, TEvents> = {
|
|
226
181
|
config: resolvedConfig,
|
|
227
182
|
appConfig: options.appConfig as TAppConfig,
|
|
@@ -230,36 +185,51 @@ export async function runApplication<TAppConfig = unknown, TEvents extends Event
|
|
|
230
185
|
lifecycleBus,
|
|
231
186
|
db,
|
|
232
187
|
scheduler,
|
|
188
|
+
pluginHost,
|
|
233
189
|
stop: (reason?: ApplicationStopReason) => performShutdown(state, reason ?? 'manual'),
|
|
234
190
|
};
|
|
235
191
|
state.app = app;
|
|
236
192
|
|
|
237
|
-
// ──
|
|
238
|
-
|
|
193
|
+
// ── Register caller-provided plugins (before user callback) ─────────
|
|
194
|
+
if (options.plugins) {
|
|
195
|
+
for (const p of options.plugins) {
|
|
196
|
+
pluginHost.register(p);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
239
199
|
|
|
240
|
-
// ──
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
200
|
+
// ── Register user-callback plugin (after services, before scheduler) ─
|
|
201
|
+
pluginHost.register(
|
|
202
|
+
userCallbackPlugin(
|
|
203
|
+
options.start,
|
|
204
|
+
options.stop as ((app: ApplicationRuntime<TAppConfig, TEvents>, reason: string) => void) | undefined,
|
|
205
|
+
app,
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// ── Register scheduler plugin (LAST — autoStart after user callback) ─
|
|
210
|
+
if (schedulerConfig.enabled && scheduler) {
|
|
211
|
+
pluginHost.register(schedulerPlugin(scheduler, schedulerConfig.autoStart));
|
|
244
212
|
}
|
|
245
213
|
|
|
214
|
+
// ── Load + start (built-in failFast=rethrow on critical failure) ─
|
|
215
|
+
await pluginHost.loadAll();
|
|
216
|
+
await pluginHost.startAll();
|
|
246
217
|
return app;
|
|
247
218
|
} catch (error) {
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// DB
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if (state.telemetryInitialized) {
|
|
255
|
-
await shutdownTelemetry();
|
|
256
|
-
}
|
|
219
|
+
// Startup failed: tear down whatever started, in reverse registration
|
|
220
|
+
// order, via the host. Each plugin's onStop/onUnload is best-effort, so a
|
|
221
|
+
// partially-started ring still releases its resources (telemetry, scheduler,
|
|
222
|
+
// owned DB). Caller-injected services.db is caller-owned and not touched.
|
|
223
|
+
await state.pluginHost.stopAll('error');
|
|
224
|
+
await state.pluginHost.unloadAll('error');
|
|
257
225
|
throw error;
|
|
258
226
|
}
|
|
259
227
|
}
|
|
260
228
|
|
|
261
229
|
export type { BusLifecycleEvents, EventMap } from '../event-bus/types';
|
|
262
230
|
export type { InfraEvents } from '../events';
|
|
231
|
+
export type { PluginHost } from './plugins/host';
|
|
232
|
+
export type { Plugin, PluginSummary } from './plugins/types';
|
|
263
233
|
// Re-export types
|
|
264
234
|
export type {
|
|
265
235
|
ApplicationBootstrapConfig,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in service plugins for the application bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* Each factory returns a `Plugin` that maps an existing init/shutdown pair onto
|
|
5
|
+
* the PluginHost lifecycle: `onStart` = init, `onStop` = teardown. Absent
|
|
6
|
+
* hooks are omitted (no-op by absence, not by stub).
|
|
7
|
+
*
|
|
8
|
+
* @module application/plugins
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { LogLevel } from '../../logger';
|
|
12
|
+
import { initializeLogger } from '../../logger';
|
|
13
|
+
import { initMetrics, shutdownMetrics } from '../../telemetry/metrics';
|
|
14
|
+
import { initTelemetry, shutdownTelemetry } from '../../telemetry/sdk';
|
|
15
|
+
import type { ApplicationBootstrapConfig, DbAdapterLike } from '../types';
|
|
16
|
+
import type { Plugin } from './types';
|
|
17
|
+
|
|
18
|
+
// ── Telemetry ──────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Built-in plugin for telemetry + metrics.
|
|
22
|
+
*
|
|
23
|
+
* `onStart`: `initTelemetry` + `initMetrics` (pre-warm instruments).
|
|
24
|
+
* `onStop`: `shutdownMetrics` + `shutdownTelemetry` (reverse of init).
|
|
25
|
+
* `failFast: true` — a failing telemetry init aborts the bootstrap.
|
|
26
|
+
*/
|
|
27
|
+
export function telemetryPlugin(config: ApplicationBootstrapConfig['telemetry']): Plugin {
|
|
28
|
+
return {
|
|
29
|
+
name: 'builtin:telemetry',
|
|
30
|
+
version: '0.0.0',
|
|
31
|
+
failFast: true,
|
|
32
|
+
onLoad: async () => {},
|
|
33
|
+
onStart: async () => {
|
|
34
|
+
if (config.enabled) {
|
|
35
|
+
initTelemetry({
|
|
36
|
+
enabled: config.enabled,
|
|
37
|
+
serviceName: config.serviceName,
|
|
38
|
+
environment: config.environment,
|
|
39
|
+
dbStatementDebug: config.dbStatementDebug,
|
|
40
|
+
});
|
|
41
|
+
initMetrics();
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
onStop: async () => {
|
|
45
|
+
if (config.enabled) {
|
|
46
|
+
shutdownMetrics();
|
|
47
|
+
await shutdownTelemetry();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Logger ─────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Built-in plugin for the structured logger.
|
|
57
|
+
*
|
|
58
|
+
* `onStart`: `initializeLogger` (skipped when an injected logger is present).
|
|
59
|
+
* No `onStop` — the logger has no teardown.
|
|
60
|
+
* `failFast: true` — a failing logger init aborts the bootstrap.
|
|
61
|
+
*/
|
|
62
|
+
export function loggerPlugin(
|
|
63
|
+
config: {
|
|
64
|
+
enabled: boolean;
|
|
65
|
+
level: LogLevel;
|
|
66
|
+
console: boolean;
|
|
67
|
+
json: boolean;
|
|
68
|
+
fileSink?: ((line: string) => void) | undefined;
|
|
69
|
+
},
|
|
70
|
+
injected?: boolean,
|
|
71
|
+
): Plugin {
|
|
72
|
+
return {
|
|
73
|
+
name: 'builtin:logger',
|
|
74
|
+
version: '0.0.0',
|
|
75
|
+
failFast: true,
|
|
76
|
+
onLoad: async () => {},
|
|
77
|
+
onStart: async () => {
|
|
78
|
+
if (config.enabled && !injected) {
|
|
79
|
+
await initializeLogger({
|
|
80
|
+
level: config.level,
|
|
81
|
+
console: config.console,
|
|
82
|
+
json: config.json,
|
|
83
|
+
fileSink: config.fileSink,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── User callback ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
import type { EventMap } from '../../event-bus/types';
|
|
93
|
+
import type { ApplicationRuntime } from '../types';
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Built-in plugin that wraps the user's `start`/`stop` callbacks.
|
|
97
|
+
*
|
|
98
|
+
* `onStart`: calls `options.start(app)`. `failFast: true`.
|
|
99
|
+
* `onStop`: calls `options.stop(app, reason)`. Registered after services,
|
|
100
|
+
* before scheduler, so stopAll/reverse-order places it after
|
|
101
|
+
* scheduler.stop and before service teardown.
|
|
102
|
+
*/
|
|
103
|
+
export function userCallbackPlugin<TAppConfig, TEvents extends EventMap>(
|
|
104
|
+
start: (app: ApplicationRuntime<TAppConfig, TEvents>) => Promise<void> | void,
|
|
105
|
+
stop: ((app: ApplicationRuntime<TAppConfig, TEvents>, reason: string) => Promise<void> | void) | undefined,
|
|
106
|
+
app?: ApplicationRuntime<TAppConfig, TEvents>,
|
|
107
|
+
): Plugin {
|
|
108
|
+
return {
|
|
109
|
+
name: 'builtin:user-callback',
|
|
110
|
+
version: '0.0.0',
|
|
111
|
+
failFast: true,
|
|
112
|
+
onLoad: async () => {},
|
|
113
|
+
onStart: async () => {
|
|
114
|
+
if (app) await start(app);
|
|
115
|
+
},
|
|
116
|
+
onStop:
|
|
117
|
+
stop && app
|
|
118
|
+
? async (_host, reason) => {
|
|
119
|
+
await stop(app, reason ?? 'manual');
|
|
120
|
+
}
|
|
121
|
+
: undefined,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Scheduler ──────────────────────────────────────────────────────────────
|
|
126
|
+
import type { SchedulerAdapter } from '../../scheduler/types';
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Built-in plugin for the scheduler.
|
|
130
|
+
*
|
|
131
|
+
* `onStart`: `adapter.start()` when `autoStart` is true.
|
|
132
|
+
* `onStop`: `adapter.stop()` — fail-soft.
|
|
133
|
+
*
|
|
134
|
+
* Registered LAST so autoStart always runs after the user callback.
|
|
135
|
+
*/
|
|
136
|
+
export function schedulerPlugin(adapter: SchedulerAdapter, autoStart: boolean): Plugin {
|
|
137
|
+
return {
|
|
138
|
+
name: 'builtin:scheduler',
|
|
139
|
+
version: '0.0.0',
|
|
140
|
+
failFast: true,
|
|
141
|
+
onLoad: async () => {},
|
|
142
|
+
onStart: async () => {
|
|
143
|
+
if (autoStart) {
|
|
144
|
+
await adapter.start();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
onStop: async () => {
|
|
148
|
+
// Fail-soft — the host always catches stop/unload errors
|
|
149
|
+
await adapter.stop();
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── DB (reason-aware, for owned adapters only) ────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Built-in plugin for a DB adapter the bootstrap OWNS.
|
|
158
|
+
*
|
|
159
|
+
* `onStop(reason)`: `db.close()` — best-effort.
|
|
160
|
+
* Register ONLY when the bootstrap creates the adapter (Node subpath).
|
|
161
|
+
* Do NOT register for caller-injected `services.db` — those are caller-owned.
|
|
162
|
+
*/
|
|
163
|
+
export function dbPlugin(db: DbAdapterLike): Plugin {
|
|
164
|
+
return {
|
|
165
|
+
name: 'builtin:db',
|
|
166
|
+
version: '0.0.0',
|
|
167
|
+
failFast: false,
|
|
168
|
+
onLoad: async () => {},
|
|
169
|
+
onStart: async () => {},
|
|
170
|
+
onStop: async () => {
|
|
171
|
+
try {
|
|
172
|
+
db.close();
|
|
173
|
+
} catch {
|
|
174
|
+
/* best-effort */
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare `PluginHost` — owns an insertion-ordered set of plugins and drives
|
|
3
|
+
* lifecycle fan-out (load → start → stop → unload) with fail-soft semantics
|
|
4
|
+
* for start/stop/unload and fail-fast for load.
|
|
5
|
+
*
|
|
6
|
+
* This is a runtime concern: it needs a logger and event bus, both provided
|
|
7
|
+
* by the application bootstrap. No capabilities, no trust ladder.
|
|
8
|
+
*
|
|
9
|
+
* @module application/plugins
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EventBus } from '../../event-bus/event-bus';
|
|
13
|
+
import type { EventMap } from '../../event-bus/types';
|
|
14
|
+
import { getLogger, type Logger } from '../../logger';
|
|
15
|
+
import type { Plugin, PluginSummary } from './types';
|
|
16
|
+
|
|
17
|
+
// ── PluginHost ─────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Plugin host: registers plugins in insertion order and drives their lifecycle
|
|
21
|
+
* fan-out (load → start → stop → unload).
|
|
22
|
+
*
|
|
23
|
+
* The host stores `EventBus<EventMap>` (the base event contract) rather than
|
|
24
|
+
* a narrower `TEvents` subtype, because `EventBus` is invariant in its type
|
|
25
|
+
* parameter and plugins only need the base contract.
|
|
26
|
+
*/
|
|
27
|
+
export class PluginHost {
|
|
28
|
+
readonly logger: Logger;
|
|
29
|
+
readonly events: EventBus<EventMap>;
|
|
30
|
+
|
|
31
|
+
private readonly _plugins = new Map<string, Plugin>();
|
|
32
|
+
|
|
33
|
+
constructor(events: EventBus<EventMap>, opts?: { logger?: Logger }) {
|
|
34
|
+
this.events = events;
|
|
35
|
+
this.logger = opts?.logger ?? getLogger('plugin-host');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Registration ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** Register a plugin. Throws on duplicate name. */
|
|
41
|
+
register(plugin: Plugin): void {
|
|
42
|
+
if (this._plugins.has(plugin.name)) {
|
|
43
|
+
throw new Error(`Plugin already registered: ${plugin.name}`);
|
|
44
|
+
}
|
|
45
|
+
this._plugins.set(plugin.name, plugin);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Remove a plugin by name. No-op if absent. */
|
|
49
|
+
unregister(name: string): void {
|
|
50
|
+
this._plugins.delete(name);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Check whether a plugin is registered. */
|
|
54
|
+
has(name: string): boolean {
|
|
55
|
+
return this._plugins.has(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** List registered plugins in registration order. */
|
|
59
|
+
list(): readonly PluginSummary[] {
|
|
60
|
+
const result: PluginSummary[] = [];
|
|
61
|
+
for (const p of this._plugins.values()) {
|
|
62
|
+
result.push({ name: p.name, version: p.version });
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Lifecycle fan-out ───────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Fail-fast: calls `onLoad` on every plugin in registration order.
|
|
71
|
+
* A throwing hook aborts the bootstrap.
|
|
72
|
+
*/
|
|
73
|
+
async loadAll(): Promise<void> {
|
|
74
|
+
for (const plugin of this._plugins.values()) {
|
|
75
|
+
this.logger.debug(`Loading plugin: ${plugin.name}`, { name: plugin.name });
|
|
76
|
+
await plugin.onLoad(this);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Calls `onStart` on every plugin in registration order.
|
|
82
|
+
* Plugins with `failFast: true` rethrow (aborting boot); others log + skip.
|
|
83
|
+
*/
|
|
84
|
+
async startAll(): Promise<void> {
|
|
85
|
+
for (const plugin of this._plugins.values()) {
|
|
86
|
+
if (!plugin.onStart) continue;
|
|
87
|
+
try {
|
|
88
|
+
this.logger.debug(`Starting plugin: ${plugin.name}`, { name: plugin.name });
|
|
89
|
+
await plugin.onStart(this);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (plugin.failFast) {
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
this.logger.error(`Plugin start hook failed: ${plugin.name}`, {
|
|
95
|
+
name: plugin.name,
|
|
96
|
+
error: (err as Error).message,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fail-soft: calls `onStop` on every plugin in **reverse** registration order.
|
|
104
|
+
* A throwing hook is logged + skipped.
|
|
105
|
+
* `reason` is forwarded to each plugin's `onStop(host, reason?)`.
|
|
106
|
+
*/
|
|
107
|
+
async stopAll(reason?: string): Promise<void> {
|
|
108
|
+
const reversed = [...this._plugins.values()].reverse();
|
|
109
|
+
for (const plugin of reversed) {
|
|
110
|
+
if (!plugin.onStop) continue;
|
|
111
|
+
try {
|
|
112
|
+
this.logger.debug(`Stopping plugin: ${plugin.name}`, { name: plugin.name, reason });
|
|
113
|
+
await plugin.onStop(this, reason);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
this.logger.error(`Plugin stop hook failed: ${plugin.name}`, {
|
|
116
|
+
name: plugin.name,
|
|
117
|
+
error: (err as Error).message,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fail-soft: calls `onUnload` on every plugin in **reverse** registration order.
|
|
125
|
+
* A throwing hook is logged + skipped.
|
|
126
|
+
* `reason` is forwarded to each plugin's `onUnload(host, reason?)`.
|
|
127
|
+
*/
|
|
128
|
+
async unloadAll(reason?: string): Promise<void> {
|
|
129
|
+
const reversed = [...this._plugins.values()].reverse();
|
|
130
|
+
for (const plugin of reversed) {
|
|
131
|
+
if (!plugin.onUnload) continue;
|
|
132
|
+
try {
|
|
133
|
+
this.logger.debug(`Unloading plugin: ${plugin.name}`, { name: plugin.name, reason });
|
|
134
|
+
await plugin.onUnload(this, reason);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.logger.error(`Plugin unload hook failed: ${plugin.name}`, {
|
|
137
|
+
name: plugin.name,
|
|
138
|
+
error: (err as Error).message,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable plugin lifecycle contract.
|
|
3
|
+
*
|
|
4
|
+
* The `Plugin` interface is a minimal runtime-neutral lifecycle contract:
|
|
5
|
+
* no capabilities, no trust ladder, no manifest schema — just `onLoad` / `onUnload`
|
|
6
|
+
* and `onStart` / `onStop` hooks. Names are deliberately runtime-neutral so
|
|
7
|
+
* CLI and long-lived server apps share the same semantics.
|
|
8
|
+
*
|
|
9
|
+
* @module application/plugins
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { EventBus } from '../../event-bus/event-bus';
|
|
13
|
+
import type { EventMap } from '../../event-bus/types';
|
|
14
|
+
import type { Logger } from '../../logger';
|
|
15
|
+
|
|
16
|
+
// ── Plugin contract ────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal plugin lifecycle contract.
|
|
20
|
+
*
|
|
21
|
+
* All hooks receive the host reference so a plugin can access the runtime
|
|
22
|
+
* logger, event bus, and other plugins.
|
|
23
|
+
*/
|
|
24
|
+
export interface Plugin {
|
|
25
|
+
/** Unique name. Used for dedup and lookup in the host. */
|
|
26
|
+
readonly name: string;
|
|
27
|
+
|
|
28
|
+
/** Semver-compatible version string. Informational only in this cut. */
|
|
29
|
+
readonly version: string;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* When `true`, a throwing `onStart` aborts the bootstrap (fail-fast).
|
|
33
|
+
* When absent/false, a throwing `onStart` is logged and skipped (fail-soft).
|
|
34
|
+
* Has no effect on `loadAll` (always fail-fast) or `stopAll`/`unloadAll`
|
|
35
|
+
* (always fail-soft — teardown is best-effort).
|
|
36
|
+
*/
|
|
37
|
+
readonly failFast?: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Called during `PluginHost.loadAll()` — fail-fast.
|
|
41
|
+
* A throwing `onLoad` aborts the bootstrap.
|
|
42
|
+
*/
|
|
43
|
+
onLoad(host: PluginHost): void | Promise<void>;
|
|
44
|
+
|
|
45
|
+
/** Called during `PluginHost.unloadAll()` — fail-soft. */
|
|
46
|
+
onUnload?(host: PluginHost, reason?: string): void | Promise<void>;
|
|
47
|
+
|
|
48
|
+
/** Called during `PluginHost.startAll()` — fail-soft. */
|
|
49
|
+
onStart?(host: PluginHost): void | Promise<void>;
|
|
50
|
+
|
|
51
|
+
/** Called during `PluginHost.stopAll()` — fail-soft. */
|
|
52
|
+
onStop?(host: PluginHost, reason?: string): void | Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Plugin host public shape ───────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Summary view of a registered plugin (no references, just metadata).
|
|
59
|
+
*/
|
|
60
|
+
export interface PluginSummary {
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly version: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── PluginHost structural interface ────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Structural contract for the plugin host.
|
|
69
|
+
*
|
|
70
|
+
* This is the shape exposed on `ApplicationRuntime.pluginHost`. Consumers
|
|
71
|
+
* that need to register/unregister plugins at runtime use this interface.
|
|
72
|
+
*/
|
|
73
|
+
export interface PluginHost {
|
|
74
|
+
readonly logger: Logger;
|
|
75
|
+
readonly events: EventBus<EventMap>;
|
|
76
|
+
|
|
77
|
+
register(plugin: Plugin): void;
|
|
78
|
+
unregister(name: string): void;
|
|
79
|
+
has(name: string): boolean;
|
|
80
|
+
list(): readonly PluginSummary[];
|
|
81
|
+
|
|
82
|
+
loadAll(): Promise<void>;
|
|
83
|
+
startAll(): Promise<void>;
|
|
84
|
+
stopAll(reason?: string): Promise<void>;
|
|
85
|
+
unloadAll(reason?: string): Promise<void>;
|
|
86
|
+
}
|