@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 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. `manager.stop` continues past a failing
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 (and logged to `console.error` if you
76
- do not supply one) rather than swallowed.
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
- const RANGE_PATTERN = /^\s*(\^|~|>=|<=|>|<|=)?\s*(.+?)\s*$/;
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 < b.prerelease ? -1 : 1;
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
- console.error(`[@zudojs/plugins] Listener for "${eventName}" threw.`, emitError);
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(() => reject(new PluginTimeoutError(pluginName, timeout, {
54
- metadata: { phase },
55
- })), Math.min(timeout, MAX_TIMER_DELAY));
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 || registered.state === "disposed")
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 || registered.state === "disposed")
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 || registered.state === "disposed")
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
- console.error(`[@zudojs/plugins] Plugin "${pluginName}" failed during teardown.`, error);
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.0.0",
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.0.0",
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",