@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 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 (and logged to `console.error` if you
81
- 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.
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
- 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
  *
@@ -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
- 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 });
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.0",
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.1",
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",