@zudojs/plugins 1.0.0 → 1.2.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 +23 -3
- package/dist/pluginDependencies/versionCheck.core.js +34 -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 +21 -5
- package/package.json +6 -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:
|
|
@@ -70,10 +76,23 @@ next begins, so a plugin may rely on its dependencies being installed by
|
|
|
70
76
|
the time its own `initialize` runs.
|
|
71
77
|
|
|
72
78
|
If any phase throws, `manager.start` stops and disposes everything it had
|
|
73
|
-
already brought up, then rethrows.
|
|
79
|
+
already brought up, then rethrows. A disposed plugin cannot be brought back:
|
|
80
|
+
calling `manager.start` again after `manager.stop`, or after a rolled-back
|
|
81
|
+
startup, throws `PluginStateError` rather than silently starting nothing (or
|
|
82
|
+
starting still-registered dependents on top of disposed dependencies).
|
|
83
|
+
Unregister the disposed plugins and register fresh instances, or create a
|
|
84
|
+
new manager. `manager.stop` continues past a failing
|
|
74
85
|
plugin so one bad `stop` cannot strand the rest; those failures are
|
|
75
|
-
reported through the `onError` option
|
|
76
|
-
|
|
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.
|
|
77
96
|
|
|
78
97
|
## Dependency order and versions
|
|
79
98
|
|
|
@@ -110,6 +129,7 @@ new PluginManager({
|
|
|
110
129
|
checkVersions: true, // enforce declared dependency versions (default)
|
|
111
130
|
hookTimeout: 5_000, // bound each lifecycle hook; 0 = unbounded (default)
|
|
112
131
|
onError: (error, pluginName) => report(error, pluginName), // teardown failures
|
|
132
|
+
logger, // used for teardown failures when onError is omitted
|
|
113
133
|
allowedCapabilities: ["http", "db"], // reject plugins requesting anything else
|
|
114
134
|
events: eventSink, // receives `plugin:registered`
|
|
115
135
|
});
|
|
@@ -31,7 +31,11 @@ export class PluginDependencyVersionError extends PluginDependencyError {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
34
|
-
|
|
34
|
+
// The input is already trimmed, so the outer `\s*` are unnecessary, and
|
|
35
|
+
// `(\S.*)` rather than `(.+?)\s*$`: `.` matches spaces too, so a lazy group
|
|
36
|
+
// followed by `\s*$` lets the engine split a whitespace run at every
|
|
37
|
+
// position before giving up.
|
|
38
|
+
const RANGE_PATTERN = /^(\^|~|>=|<=|>|<|=)?\s*(\S.*)$/;
|
|
35
39
|
/**
|
|
36
40
|
* Parses a semantic version, returning `undefined` if it is not one.
|
|
37
41
|
*/
|
|
@@ -63,7 +67,35 @@ export function compareVersions(a, b) {
|
|
|
63
67
|
return 1;
|
|
64
68
|
if (b.prerelease === undefined)
|
|
65
69
|
return -1;
|
|
66
|
-
return a.prerelease
|
|
70
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Compares two prerelease strings identifier by identifier, as semver
|
|
74
|
+
* specifies: numeric identifiers compare numerically and rank below
|
|
75
|
+
* alphanumeric ones, and a longer identifier list ranks higher when the
|
|
76
|
+
* shared prefix is equal. A plain string comparison put `alpha.10`
|
|
77
|
+
* before `alpha.9`.
|
|
78
|
+
*/
|
|
79
|
+
function comparePrerelease(a, b) {
|
|
80
|
+
const left = a.split(".");
|
|
81
|
+
const right = b.split(".");
|
|
82
|
+
const length = Math.min(left.length, right.length);
|
|
83
|
+
for (let index = 0; index < length; index += 1) {
|
|
84
|
+
const x = left[index];
|
|
85
|
+
const y = right[index];
|
|
86
|
+
if (x === y)
|
|
87
|
+
continue;
|
|
88
|
+
const xNumeric = /^\d+$/.test(x);
|
|
89
|
+
const yNumeric = /^\d+$/.test(y);
|
|
90
|
+
if (xNumeric && yNumeric)
|
|
91
|
+
return Number(x) - Number(y);
|
|
92
|
+
if (xNumeric)
|
|
93
|
+
return -1;
|
|
94
|
+
if (yNumeric)
|
|
95
|
+
return 1;
|
|
96
|
+
return x < y ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
return left.length - right.length;
|
|
67
99
|
}
|
|
68
100
|
/**
|
|
69
101
|
* Tests a version against a range.
|
|
@@ -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
|
*
|
|
@@ -2,10 +2,11 @@ import { PluginRegistryImpl } from "../pluginRegistry/pluginRegistry.core.js";
|
|
|
2
2
|
import { DependencyResolver, assertResolutionValid, } from "../pluginDependencies/dependencyResolver.core.js";
|
|
3
3
|
import { assertDependencyVersions } from "../pluginDependencies/versionCheck.core.js";
|
|
4
4
|
import { LifecycleController } from "../pluginLifecycle/pluginLifecycle.core.js";
|
|
5
|
-
import { PluginError, PluginRegistrationError } from "@zudojs/errors";
|
|
5
|
+
import { PluginError, PluginRegistrationError, PluginStateError, } from "@zudojs/errors";
|
|
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);
|
|
@@ -124,10 +128,21 @@ export class PluginManager {
|
|
|
124
128
|
assertDependencyVersions(this.plugins);
|
|
125
129
|
}
|
|
126
130
|
this.startupOrder = resolution.ordered;
|
|
131
|
+
// A disposed plugin has released its resources and the state
|
|
132
|
+
// machine offers no way back. `start()` used to skip such plugins
|
|
133
|
+
// silently — so a second `start()` after `stop()` resolved with
|
|
134
|
+
// nothing running, and after a rolled-back startup it brought up
|
|
135
|
+
// still-registered dependents on top of disposed dependencies.
|
|
136
|
+
for (const name of this.startupOrder) {
|
|
137
|
+
const registered = this.registry.get(name);
|
|
138
|
+
if (registered?.state === "disposed") {
|
|
139
|
+
throw new PluginStateError(name, "disposed", "installing");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
127
142
|
try {
|
|
128
143
|
for (const name of this.startupOrder) {
|
|
129
144
|
const registered = this.registry.get(name);
|
|
130
|
-
if (!registered
|
|
145
|
+
if (!registered)
|
|
131
146
|
continue;
|
|
132
147
|
// Guarded so a restart — where plugins are already installed
|
|
133
148
|
// and merely stopped — re-runs only the phases it needs.
|
|
@@ -137,7 +152,7 @@ export class PluginManager {
|
|
|
137
152
|
}
|
|
138
153
|
for (const name of this.startupOrder) {
|
|
139
154
|
const registered = this.registry.get(name);
|
|
140
|
-
if (!registered
|
|
155
|
+
if (!registered)
|
|
141
156
|
continue;
|
|
142
157
|
if (registered.state === "installed") {
|
|
143
158
|
await this.lifecycle.initialize(registered, this.contextFor(registered, context));
|
|
@@ -145,7 +160,7 @@ export class PluginManager {
|
|
|
145
160
|
}
|
|
146
161
|
for (const name of this.startupOrder) {
|
|
147
162
|
const registered = this.registry.get(name);
|
|
148
|
-
if (!registered
|
|
163
|
+
if (!registered)
|
|
149
164
|
continue;
|
|
150
165
|
if (registered.state === "initialized" ||
|
|
151
166
|
registered.state === "stopped") {
|
|
@@ -170,6 +185,7 @@ export class PluginManager {
|
|
|
170
185
|
* rather than swallowed.
|
|
171
186
|
*/
|
|
172
187
|
async stop(context) {
|
|
188
|
+
this.contextLogger = context.logger ?? this.contextLogger;
|
|
173
189
|
for (const registered of this.teardownOrder()) {
|
|
174
190
|
if (registered.state !== "started")
|
|
175
191
|
continue;
|
|
@@ -317,7 +333,7 @@ export class PluginManager {
|
|
|
317
333
|
return;
|
|
318
334
|
}
|
|
319
335
|
queueMicrotask(() => {
|
|
320
|
-
|
|
336
|
+
reportPluginFailure(this.options.logger ?? this.contextLogger, `Plugin "${pluginName}" failed during teardown.`, error, { plugin: pluginName });
|
|
321
337
|
});
|
|
322
338
|
}
|
|
323
339
|
toDependencyMap() {
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/plugins",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Plugin system for extending Zudojs applications with modular capabilities.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -20,9 +24,7 @@
|
|
|
20
24
|
"!dist/.tsbuildinfo"
|
|
21
25
|
],
|
|
22
26
|
"dependencies": {
|
|
23
|
-
"@zudojs/errors": "1.
|
|
24
|
-
"@zudojs/constants": "1.0.0",
|
|
25
|
-
"@zudojs/types": "1.0.0"
|
|
27
|
+
"@zudojs/errors": "1.1.0"
|
|
26
28
|
},
|
|
27
29
|
"devDependencies": {
|
|
28
30
|
"@types/node": "^26.4.1",
|