@zudojs/plugins 1.1.0 → 1.2.1
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 +17 -2
- package/dist/pluginLifecycle/pluginLifecycle.abandoned.d.ts +30 -0
- package/dist/pluginLifecycle/pluginLifecycle.abandoned.js +46 -0
- package/dist/pluginLifecycle/pluginLifecycle.core.d.ts +9 -0
- package/dist/pluginLifecycle/pluginLifecycle.core.js +38 -6
- package/dist/pluginLifecycle/pluginLifecycle.report.d.ts +16 -0
- package/dist/pluginLifecycle/pluginLifecycle.report.js +31 -0
- package/dist/pluginManager/pluginManager.core.d.ts +8 -0
- package/dist/pluginManager/pluginManager.core.js +6 -1
- package/package.json +2 -4
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Plugin manager, registry, dependency resolver, lifecycle controller, events, and diagnostics. The basis for extending a Zudojs app with third-party functionality.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-plugins](https://zudojs.oyinlola.site/docs/packages-plugins) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-plugins.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## When to use
|
|
6
12
|
|
|
7
13
|
Import this when you need:
|
|
@@ -77,8 +83,16 @@ starting still-registered dependents on top of disposed dependencies).
|
|
|
77
83
|
Unregister the disposed plugins and register fresh instances, or create a
|
|
78
84
|
new manager. `manager.stop` continues past a failing
|
|
79
85
|
plugin so one bad `stop` cannot strand the rest; those failures are
|
|
80
|
-
reported through the `onError` option
|
|
81
|
-
|
|
86
|
+
reported through the `onError` option rather than swallowed. Without
|
|
87
|
+
`onError` they go to the `logger` option, or else the logger on the
|
|
88
|
+
context passed to `start()`/`stop()`; with no logger at all they are
|
|
89
|
+
raised as a `ZudoPluginWarning` process warning.
|
|
90
|
+
|
|
91
|
+
A hook that exceeds `hookTimeout` keeps running (it cannot be cancelled).
|
|
92
|
+
Before a plugin whose hook timed out is disposed, the manager waits up to
|
|
93
|
+
another `hookTimeout` for that hook to settle, and calls `stop()` if a
|
|
94
|
+
timed-out `start()` went on to succeed. If `start()` finishes even later,
|
|
95
|
+
`stop()` runs as soon as it does.
|
|
82
96
|
|
|
83
97
|
## Dependency order and versions
|
|
84
98
|
|
|
@@ -115,6 +129,7 @@ new PluginManager({
|
|
|
115
129
|
checkVersions: true, // enforce declared dependency versions (default)
|
|
116
130
|
hookTimeout: 5_000, // bound each lifecycle hook; 0 = unbounded (default)
|
|
117
131
|
onError: (error, pluginName) => report(error, pluginName), // teardown failures
|
|
132
|
+
logger, // used for teardown failures when onError is omitted
|
|
118
133
|
allowedCapabilities: ["http", "db"], // reject plugins requesting anything else
|
|
119
134
|
events: eventSink, // receives `plugin:registered`
|
|
120
135
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { RegisteredPlugin } from "../pluginRegistry/pluginRegistry.core.js";
|
|
2
|
+
/** Outcome of waiting for an abandoned hook. */
|
|
3
|
+
export type AbandonedOutcome = "resolved" | "rejected" | "pending";
|
|
4
|
+
/**
|
|
5
|
+
* Tracks lifecycle hooks abandoned by `hookTimeout`.
|
|
6
|
+
*
|
|
7
|
+
* `Promise.race` cannot cancel the losing hook, so a plugin whose
|
|
8
|
+
* `start()` timed out kept starting after rollback had already disposed
|
|
9
|
+
* it, and its `stop()` never ran: the port it went on to open had no
|
|
10
|
+
* owner. Recording the hook lets teardown wait for it and stop the
|
|
11
|
+
* plugin once it has actually started.
|
|
12
|
+
*/
|
|
13
|
+
export declare class AbandonedHooks {
|
|
14
|
+
#private;
|
|
15
|
+
/** Records a hook that outlived its timeout. */
|
|
16
|
+
record(registered: RegisteredPlugin, phase: string, hook: Promise<void>): void;
|
|
17
|
+
/**
|
|
18
|
+
* Takes the plugin's abandoned hook, if any, and waits up to
|
|
19
|
+
* `graceMs` for it to settle.
|
|
20
|
+
*
|
|
21
|
+
* @returns The phase and outcome, plus the hook itself so a caller can
|
|
22
|
+
* still react when it settles after the grace period.
|
|
23
|
+
*/
|
|
24
|
+
take(registered: RegisteredPlugin, graceMs: number): Promise<{
|
|
25
|
+
phase: string;
|
|
26
|
+
outcome: AbandonedOutcome;
|
|
27
|
+
hook: Promise<void>;
|
|
28
|
+
} | undefined>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=pluginLifecycle.abandoned.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks lifecycle hooks abandoned by `hookTimeout`.
|
|
3
|
+
*
|
|
4
|
+
* `Promise.race` cannot cancel the losing hook, so a plugin whose
|
|
5
|
+
* `start()` timed out kept starting after rollback had already disposed
|
|
6
|
+
* it, and its `stop()` never ran: the port it went on to open had no
|
|
7
|
+
* owner. Recording the hook lets teardown wait for it and stop the
|
|
8
|
+
* plugin once it has actually started.
|
|
9
|
+
*/
|
|
10
|
+
export class AbandonedHooks {
|
|
11
|
+
#hooks = new Map();
|
|
12
|
+
/** Records a hook that outlived its timeout. */
|
|
13
|
+
record(registered, phase, hook) {
|
|
14
|
+
this.#hooks.set(registered, { phase, hook });
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Takes the plugin's abandoned hook, if any, and waits up to
|
|
18
|
+
* `graceMs` for it to settle.
|
|
19
|
+
*
|
|
20
|
+
* @returns The phase and outcome, plus the hook itself so a caller can
|
|
21
|
+
* still react when it settles after the grace period.
|
|
22
|
+
*/
|
|
23
|
+
async take(registered, graceMs) {
|
|
24
|
+
const entry = this.#hooks.get(registered);
|
|
25
|
+
if (entry === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
this.#hooks.delete(registered);
|
|
28
|
+
let timer;
|
|
29
|
+
const pending = new Promise((resolve) => {
|
|
30
|
+
timer = setTimeout(() => resolve("pending"), graceMs);
|
|
31
|
+
timer.unref?.();
|
|
32
|
+
});
|
|
33
|
+
try {
|
|
34
|
+
const outcome = await Promise.race([
|
|
35
|
+
entry.hook.then(() => "resolved", () => "rejected"),
|
|
36
|
+
pending,
|
|
37
|
+
]);
|
|
38
|
+
return { phase: entry.phase, outcome, hook: entry.hook };
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (timer !== undefined)
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=pluginLifecycle.abandoned.js.map
|
|
@@ -20,6 +20,7 @@ export interface LifecycleControllerOptions {
|
|
|
20
20
|
*/
|
|
21
21
|
export declare class LifecycleController {
|
|
22
22
|
private readonly options;
|
|
23
|
+
private readonly abandoned;
|
|
23
24
|
constructor(options?: LifecycleControllerOptions);
|
|
24
25
|
/**
|
|
25
26
|
* Runs a hook under the configured timeout, clearing the timer either
|
|
@@ -47,6 +48,14 @@ export declare class LifecycleController {
|
|
|
47
48
|
* reaches a terminal state so it cannot be disposed twice.
|
|
48
49
|
*/
|
|
49
50
|
dispose<TPlugin extends Plugin>(registered: RegisteredPlugin<TPlugin>, context: PluginContext): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Waits (up to `hookTimeout`) for a hook abandoned by a timeout before
|
|
53
|
+
* the plugin is disposed, and runs `stop()` for a plugin whose timed-out
|
|
54
|
+
* `start()` went on to succeed. When the hook is still running after
|
|
55
|
+
* that grace period, `stop()` runs as soon as it does finish, and any
|
|
56
|
+
* failure is reported through the context logger.
|
|
57
|
+
*/
|
|
58
|
+
private releaseAbandoned;
|
|
50
59
|
/**
|
|
51
60
|
* Moves a plugin to `failed`, via `stopping` when required.
|
|
52
61
|
*/
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { isValidTransition } from "../pluginTypes/pluginState.type.js";
|
|
2
2
|
import { PluginDisposeError, PluginStateError, PluginTimeoutError, } from "@zudojs/errors";
|
|
3
3
|
import { PLUGIN_EVENTS, createPluginLifecycleEvent, } from "../pluginEvents/pluginEvent.core.js";
|
|
4
|
+
import { AbandonedHooks } from "./pluginLifecycle.abandoned.js";
|
|
5
|
+
import { reportPluginFailure } from "./pluginLifecycle.report.js";
|
|
4
6
|
/**
|
|
5
7
|
* Emits a plugin lifecycle event if the context supports events.
|
|
6
8
|
*
|
|
@@ -17,7 +19,7 @@ function emitLifecycleEvent(context, eventName, plugin, state, previousState, er
|
|
|
17
19
|
}
|
|
18
20
|
catch (emitError) {
|
|
19
21
|
queueMicrotask(() => {
|
|
20
|
-
|
|
22
|
+
reportPluginFailure(context.logger, `Listener for "${eventName}" threw.`, emitError, { plugin: plugin.name, event: eventName });
|
|
21
23
|
});
|
|
22
24
|
}
|
|
23
25
|
}
|
|
@@ -28,6 +30,7 @@ const MAX_TIMER_DELAY = 2_147_483_647;
|
|
|
28
30
|
*/
|
|
29
31
|
export class LifecycleController {
|
|
30
32
|
options;
|
|
33
|
+
abandoned = new AbandonedHooks();
|
|
31
34
|
constructor(options = {}) {
|
|
32
35
|
this.options = options;
|
|
33
36
|
}
|
|
@@ -35,7 +38,7 @@ export class LifecycleController {
|
|
|
35
38
|
* Runs a hook under the configured timeout, clearing the timer either
|
|
36
39
|
* way so a completed hook never leaves one armed.
|
|
37
40
|
*/
|
|
38
|
-
async runHook(pluginName, phase, run) {
|
|
41
|
+
async runHook(pluginName, phase, run, onAbandoned) {
|
|
39
42
|
const timeout = this.options.hookTimeout ?? 0;
|
|
40
43
|
if (timeout <= 0) {
|
|
41
44
|
await run();
|
|
@@ -50,9 +53,12 @@ export class LifecycleController {
|
|
|
50
53
|
await Promise.race([
|
|
51
54
|
hook,
|
|
52
55
|
new Promise((_, reject) => {
|
|
53
|
-
timer = setTimeout(() =>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
timer = setTimeout(() => {
|
|
57
|
+
onAbandoned?.(hook);
|
|
58
|
+
reject(new PluginTimeoutError(pluginName, timeout, {
|
|
59
|
+
metadata: { phase },
|
|
60
|
+
}));
|
|
61
|
+
}, Math.min(timeout, MAX_TIMER_DELAY));
|
|
56
62
|
timer.unref?.();
|
|
57
63
|
}),
|
|
58
64
|
]);
|
|
@@ -89,7 +95,7 @@ export class LifecycleController {
|
|
|
89
95
|
registered.setState(transient);
|
|
90
96
|
emitLifecycleEvent(context, startEvent, metadata, transient, from);
|
|
91
97
|
try {
|
|
92
|
-
await this.runHook(metadata.name, transient, run);
|
|
98
|
+
await this.runHook(metadata.name, transient, run, (hook) => this.abandoned.record(registered, transient, hook));
|
|
93
99
|
this.ensureTransition(registered, settled);
|
|
94
100
|
registered.setState(settled);
|
|
95
101
|
emitLifecycleEvent(context, endEvent, metadata, settled, transient);
|
|
@@ -119,6 +125,7 @@ export class LifecycleController {
|
|
|
119
125
|
registered.setState("disposing");
|
|
120
126
|
emitLifecycleEvent(context, PLUGIN_EVENTS.DISPOSING, metadata, "disposing", from);
|
|
121
127
|
const errors = [];
|
|
128
|
+
await this.releaseAbandoned(registered, context, errors);
|
|
122
129
|
// Take the list before running it: a disposable that registers
|
|
123
130
|
// another during teardown must not extend the loop indefinitely.
|
|
124
131
|
const disposables = registered.disposables.splice(0, registered.disposables.length);
|
|
@@ -152,6 +159,31 @@ export class LifecycleController {
|
|
|
152
159
|
throw error;
|
|
153
160
|
}
|
|
154
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Waits (up to `hookTimeout`) for a hook abandoned by a timeout before
|
|
164
|
+
* the plugin is disposed, and runs `stop()` for a plugin whose timed-out
|
|
165
|
+
* `start()` went on to succeed. When the hook is still running after
|
|
166
|
+
* that grace period, `stop()` runs as soon as it does finish, and any
|
|
167
|
+
* failure is reported through the context logger.
|
|
168
|
+
*/
|
|
169
|
+
async releaseAbandoned(registered, context, errors) {
|
|
170
|
+
const taken = await this.abandoned.take(registered, this.options.hookTimeout ?? 0);
|
|
171
|
+
if (taken === undefined || taken.phase !== "starting")
|
|
172
|
+
return;
|
|
173
|
+
const name = registered.plugin.metadata.name;
|
|
174
|
+
const stop = () => this.runHook(name, "stopping", () => registered.plugin.stop?.(context));
|
|
175
|
+
if (taken.outcome === "resolved") {
|
|
176
|
+
try {
|
|
177
|
+
await stop();
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
errors.push(error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (taken.outcome === "pending") {
|
|
184
|
+
void taken.hook.then(() => stop().catch((error) => reportPluginFailure(context.logger, `Plugin "${name}" failed to stop after a late start.`, error, { plugin: name })), () => undefined);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
155
187
|
/**
|
|
156
188
|
* Moves a plugin to `failed`, via `stopping` when required.
|
|
157
189
|
*/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PluginLogger } from "../pluginTypes/pluginContext.type.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reports a plugin failure that has no caller left to throw to.
|
|
4
|
+
*
|
|
5
|
+
* Goes through the supplied logger when there is one, so the failure is
|
|
6
|
+
* structured and redacted like every other log line. Without a logger
|
|
7
|
+
* it is raised as a process warning (`ZudoPluginWarning`) rather than
|
|
8
|
+
* written with `console.error`.
|
|
9
|
+
*
|
|
10
|
+
* @param logger - The plugin context's (or manager's) logger, if any.
|
|
11
|
+
* @param message - What failed.
|
|
12
|
+
* @param error - The underlying error.
|
|
13
|
+
* @param fields - Extra structured context.
|
|
14
|
+
*/
|
|
15
|
+
export declare function reportPluginFailure(logger: PluginLogger | undefined, message: string, error: unknown, fields?: Record<string, unknown>): void;
|
|
16
|
+
//# sourceMappingURL=pluginLifecycle.report.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reports a plugin failure that has no caller left to throw to.
|
|
3
|
+
*
|
|
4
|
+
* Goes through the supplied logger when there is one, so the failure is
|
|
5
|
+
* structured and redacted like every other log line. Without a logger
|
|
6
|
+
* it is raised as a process warning (`ZudoPluginWarning`) rather than
|
|
7
|
+
* written with `console.error`.
|
|
8
|
+
*
|
|
9
|
+
* @param logger - The plugin context's (or manager's) logger, if any.
|
|
10
|
+
* @param message - What failed.
|
|
11
|
+
* @param error - The underlying error.
|
|
12
|
+
* @param fields - Extra structured context.
|
|
13
|
+
*/
|
|
14
|
+
export function reportPluginFailure(logger, message, error, fields = {}) {
|
|
15
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
16
|
+
if (logger) {
|
|
17
|
+
try {
|
|
18
|
+
logger.error(message, { ...fields, error: detail });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// A throwing logger falls through to the process warning.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const emit = globalThis.process?.emitWarning;
|
|
26
|
+
emit?.(`[@zudojs/plugins] ${message}`, {
|
|
27
|
+
type: "ZudoPluginWarning",
|
|
28
|
+
detail,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=pluginLifecycle.report.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from "../pluginTypes/plugin.type.js";
|
|
2
2
|
import type { PluginContext, PluginEvents } from "../pluginTypes/pluginContext.type.js";
|
|
3
3
|
import type { PluginDiagnosticReport } from "../pluginDiagnostics/pluginDiagnostic.core.js";
|
|
4
|
+
import type { PluginLogger } from "../pluginTypes/pluginContext.type.js";
|
|
4
5
|
/**
|
|
5
6
|
* Options for a plugin manager.
|
|
6
7
|
*/
|
|
@@ -40,6 +41,11 @@ export interface PluginManagerOptions {
|
|
|
40
41
|
* emitted.
|
|
41
42
|
*/
|
|
42
43
|
readonly events?: PluginEvents;
|
|
44
|
+
/**
|
|
45
|
+
* Logger for teardown failures when `onError` is not supplied.
|
|
46
|
+
* Defaults to the logger on the context passed to `start()`/`stop()`.
|
|
47
|
+
*/
|
|
48
|
+
readonly logger?: PluginLogger;
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Plugin manager coordinates registration, dependency resolution, lifecycle, and disposal.
|
|
@@ -59,6 +65,8 @@ export declare class PluginManager {
|
|
|
59
65
|
*/
|
|
60
66
|
private startupOrder;
|
|
61
67
|
private starting;
|
|
68
|
+
/** Logger of the most recent start()/stop() context. */
|
|
69
|
+
private contextLogger;
|
|
62
70
|
/**
|
|
63
71
|
* Per-plugin context views and their abort handles.
|
|
64
72
|
*
|
|
@@ -6,6 +6,7 @@ import { PluginError, PluginRegistrationError, PluginStateError, } from "@zudojs
|
|
|
6
6
|
import { createOwnedPluginContext } from "../pluginIntegration/pluginContext.core.js";
|
|
7
7
|
import { buildDiagnosticReport } from "../pluginDiagnostics/pluginDiagnostic.core.js";
|
|
8
8
|
import { PLUGIN_EVENTS, createPluginLifecycleEvent, } from "../pluginEvents/pluginEvent.core.js";
|
|
9
|
+
import { reportPluginFailure } from "../pluginLifecycle/pluginLifecycle.report.js";
|
|
9
10
|
/**
|
|
10
11
|
* States from which a plugin still owns resources that must be released.
|
|
11
12
|
*/
|
|
@@ -33,6 +34,8 @@ export class PluginManager {
|
|
|
33
34
|
*/
|
|
34
35
|
startupOrder = [];
|
|
35
36
|
starting = false;
|
|
37
|
+
/** Logger of the most recent start()/stop() context. */
|
|
38
|
+
contextLogger;
|
|
36
39
|
/**
|
|
37
40
|
* Per-plugin context views and their abort handles.
|
|
38
41
|
*
|
|
@@ -117,6 +120,7 @@ export class PluginManager {
|
|
|
117
120
|
throw new PluginRegistrationError("Plugin manager start is already in progress.");
|
|
118
121
|
}
|
|
119
122
|
this.starting = true;
|
|
123
|
+
this.contextLogger = context.logger ?? this.contextLogger;
|
|
120
124
|
try {
|
|
121
125
|
const resolution = this.resolver.resolve(this.toDependencyMap());
|
|
122
126
|
assertResolutionValid(resolution);
|
|
@@ -181,6 +185,7 @@ export class PluginManager {
|
|
|
181
185
|
* rather than swallowed.
|
|
182
186
|
*/
|
|
183
187
|
async stop(context) {
|
|
188
|
+
this.contextLogger = context.logger ?? this.contextLogger;
|
|
184
189
|
for (const registered of this.teardownOrder()) {
|
|
185
190
|
if (registered.state !== "started")
|
|
186
191
|
continue;
|
|
@@ -328,7 +333,7 @@ export class PluginManager {
|
|
|
328
333
|
return;
|
|
329
334
|
}
|
|
330
335
|
queueMicrotask(() => {
|
|
331
|
-
|
|
336
|
+
reportPluginFailure(this.options.logger ?? this.contextLogger, `Plugin "${pluginName}" failed during teardown.`, error, { plugin: pluginName });
|
|
332
337
|
});
|
|
333
338
|
}
|
|
334
339
|
toDependencyMap() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/plugins",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Plugin system for extending Zudojs applications with modular capabilities.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -24,9 +24,7 @@
|
|
|
24
24
|
"!dist/.tsbuildinfo"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@zudojs/errors": "1.0
|
|
28
|
-
"@zudojs/constants": "1.0.1",
|
|
29
|
-
"@zudojs/types": "1.0.0"
|
|
27
|
+
"@zudojs/errors": "1.2.0"
|
|
30
28
|
},
|
|
31
29
|
"devDependencies": {
|
|
32
30
|
"@types/node": "^26.4.1",
|