@zudojs/plugins 0.0.1 → 1.0.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.
Files changed (64) hide show
  1. package/README.md +130 -29
  2. package/dist/index.d.ts +34 -13
  3. package/dist/index.js +28 -10
  4. package/dist/pluginDependencies/dependencyResolver.core.d.ts +47 -6
  5. package/dist/pluginDependencies/dependencyResolver.core.js +108 -46
  6. package/dist/pluginDependencies/index.d.ts +6 -2
  7. package/dist/pluginDependencies/index.js +4 -1
  8. package/dist/pluginDependencies/versionCheck.core.d.ts +67 -0
  9. package/dist/pluginDependencies/versionCheck.core.js +167 -0
  10. package/dist/pluginDiagnostics/pluginDiagnostic.core.d.ts +9 -0
  11. package/dist/pluginDiagnostics/pluginDiagnostic.core.js +16 -12
  12. package/dist/pluginIntegration/index.d.ts +2 -1
  13. package/dist/pluginIntegration/index.js +1 -1
  14. package/dist/pluginIntegration/pluginContext.core.d.ts +35 -5
  15. package/dist/pluginIntegration/pluginContext.core.js +28 -5
  16. package/dist/pluginLifecycle/pluginLifecycle.core.d.ts +42 -0
  17. package/dist/pluginLifecycle/pluginLifecycle.core.js +132 -70
  18. package/dist/pluginManager/pluginManager.core.d.ts +121 -3
  19. package/dist/pluginManager/pluginManager.core.js +271 -47
  20. package/dist/pluginRegistry/pluginRegistry.core.d.ts +15 -6
  21. package/dist/pluginRegistry/pluginRegistry.core.js +6 -1
  22. package/dist/pluginTypes/index.d.ts +1 -1
  23. package/dist/pluginTypes/pluginContext.type.d.ts +11 -3
  24. package/dist/pluginTypes/pluginState.type.js +6 -3
  25. package/package.json +15 -8
  26. package/dist/.tsbuildinfo +0 -1
  27. package/dist/index.d.ts.map +0 -1
  28. package/dist/index.js.map +0 -1
  29. package/dist/pluginDependencies/dependencyResolver.core.d.ts.map +0 -1
  30. package/dist/pluginDependencies/dependencyResolver.core.js.map +0 -1
  31. package/dist/pluginDependencies/index.d.ts.map +0 -1
  32. package/dist/pluginDependencies/index.js.map +0 -1
  33. package/dist/pluginDiagnostics/index.d.ts.map +0 -1
  34. package/dist/pluginDiagnostics/index.js.map +0 -1
  35. package/dist/pluginDiagnostics/pluginDiagnostic.core.d.ts.map +0 -1
  36. package/dist/pluginDiagnostics/pluginDiagnostic.core.js.map +0 -1
  37. package/dist/pluginEvents/index.d.ts.map +0 -1
  38. package/dist/pluginEvents/index.js.map +0 -1
  39. package/dist/pluginEvents/pluginEvent.core.d.ts.map +0 -1
  40. package/dist/pluginEvents/pluginEvent.core.js.map +0 -1
  41. package/dist/pluginIntegration/index.d.ts.map +0 -1
  42. package/dist/pluginIntegration/index.js.map +0 -1
  43. package/dist/pluginIntegration/pluginContext.core.d.ts.map +0 -1
  44. package/dist/pluginIntegration/pluginContext.core.js.map +0 -1
  45. package/dist/pluginLifecycle/pluginLifecycle.core.d.ts.map +0 -1
  46. package/dist/pluginLifecycle/pluginLifecycle.core.js.map +0 -1
  47. package/dist/pluginManager/pluginManager.core.d.ts.map +0 -1
  48. package/dist/pluginManager/pluginManager.core.js.map +0 -1
  49. package/dist/pluginRegistry/index.d.ts.map +0 -1
  50. package/dist/pluginRegistry/index.js.map +0 -1
  51. package/dist/pluginRegistry/pluginRegistry.core.d.ts.map +0 -1
  52. package/dist/pluginRegistry/pluginRegistry.core.js.map +0 -1
  53. package/dist/pluginTypes/index.d.ts.map +0 -1
  54. package/dist/pluginTypes/index.js.map +0 -1
  55. package/dist/pluginTypes/plugin.type.d.ts.map +0 -1
  56. package/dist/pluginTypes/plugin.type.js.map +0 -1
  57. package/dist/pluginTypes/pluginContext.type.d.ts.map +0 -1
  58. package/dist/pluginTypes/pluginContext.type.js.map +0 -1
  59. package/dist/pluginTypes/pluginDependency.type.d.ts.map +0 -1
  60. package/dist/pluginTypes/pluginDependency.type.js.map +0 -1
  61. package/dist/pluginTypes/pluginMetadata.type.d.ts.map +0 -1
  62. package/dist/pluginTypes/pluginMetadata.type.js.map +0 -1
  63. package/dist/pluginTypes/pluginState.type.d.ts.map +0 -1
  64. package/dist/pluginTypes/pluginState.type.js.map +0 -1
@@ -1,126 +1,188 @@
1
- import { VALID_STATE_TRANSITIONS } from "../pluginTypes/pluginState.type.js";
2
- import { PluginStateError } from "@zudojs/errors";
1
+ import { isValidTransition } from "../pluginTypes/pluginState.type.js";
2
+ import { PluginDisposeError, PluginStateError, PluginTimeoutError, } from "@zudojs/errors";
3
3
  import { PLUGIN_EVENTS, createPluginLifecycleEvent, } from "../pluginEvents/pluginEvent.core.js";
4
4
  /**
5
5
  * Emits a plugin lifecycle event if the context supports events.
6
+ *
7
+ * A throwing subscriber must not abort the lifecycle phase that emitted
8
+ * the event, so delivery failures are contained here.
6
9
  */
7
- function emitLifecycleEvent(context, eventName, pluginName, state, previousState, error) {
10
+ function emitLifecycleEvent(context, eventName, plugin, state, previousState, error) {
8
11
  if (!context.events?.emit) {
9
12
  return;
10
13
  }
11
- const event = createPluginLifecycleEvent({ name: pluginName }, state, previousState, error);
12
- context.events.emit(eventName, event);
14
+ const event = createPluginLifecycleEvent(plugin, state, previousState, error);
15
+ try {
16
+ context.events.emit(eventName, event);
17
+ }
18
+ catch (emitError) {
19
+ queueMicrotask(() => {
20
+ console.error(`[@zudojs/plugins] Listener for "${eventName}" threw.`, emitError);
21
+ });
22
+ }
13
23
  }
24
+ /** Largest delay a timer can represent. */
25
+ const MAX_TIMER_DELAY = 2_147_483_647;
14
26
  /**
15
27
  * Executes plugin lifecycle phases with state management and event emission.
16
28
  */
17
29
  export class LifecycleController {
18
- async install(registered, context) {
19
- const name = registered.plugin.metadata.name;
20
- const from = registered.state;
21
- this.ensureTransition(registered, "installing");
22
- registered.setState("installing");
23
- emitLifecycleEvent(context, PLUGIN_EVENTS.INSTALLING, name, "installing", from);
30
+ options;
31
+ constructor(options = {}) {
32
+ this.options = options;
33
+ }
34
+ /**
35
+ * Runs a hook under the configured timeout, clearing the timer either
36
+ * way so a completed hook never leaves one armed.
37
+ */
38
+ async runHook(pluginName, phase, run) {
39
+ const timeout = this.options.hookTimeout ?? 0;
40
+ if (timeout <= 0) {
41
+ await run();
42
+ return;
43
+ }
44
+ let timer;
45
+ const hook = Promise.resolve().then(run);
46
+ // The hook keeps running if the timeout wins; handle its eventual
47
+ // rejection so it is never unhandled.
48
+ hook.catch(() => { });
24
49
  try {
25
- await registered.plugin.install?.(context, registered.options);
26
- this.ensureTransition(registered, "installed");
27
- registered.setState("installed");
28
- emitLifecycleEvent(context, PLUGIN_EVENTS.INSTALLED, name, "installed", "installing");
50
+ await Promise.race([
51
+ hook,
52
+ new Promise((_, reject) => {
53
+ timer = setTimeout(() => reject(new PluginTimeoutError(pluginName, timeout, {
54
+ metadata: { phase },
55
+ })), Math.min(timeout, MAX_TIMER_DELAY));
56
+ timer.unref?.();
57
+ }),
58
+ ]);
29
59
  }
30
- catch (error) {
31
- registered.setState("failed");
32
- emitLifecycleEvent(context, PLUGIN_EVENTS.FAILED, name, "failed", from, error);
33
- throw error;
60
+ finally {
61
+ if (timer !== undefined) {
62
+ clearTimeout(timer);
63
+ }
34
64
  }
35
65
  }
66
+ async install(registered, context) {
67
+ await this.runPhase(registered, context, "installing", "installed", PLUGIN_EVENTS.INSTALLING, PLUGIN_EVENTS.INSTALLED, () => registered.plugin.install?.(context, registered.options));
68
+ }
36
69
  async initialize(registered, context) {
37
- const name = registered.plugin.metadata.name;
38
- const from = registered.state;
39
- this.ensureTransition(registered, "initializing");
40
- registered.setState("initializing");
41
- emitLifecycleEvent(context, PLUGIN_EVENTS.INITIALIZING, name, "initializing", from);
42
- try {
43
- await registered.plugin.initialize?.(context);
44
- this.ensureTransition(registered, "initialized");
45
- registered.setState("initialized");
46
- emitLifecycleEvent(context, PLUGIN_EVENTS.INITIALIZED, name, "initialized", "initializing");
47
- }
48
- catch (error) {
49
- registered.setState("failed");
50
- emitLifecycleEvent(context, PLUGIN_EVENTS.FAILED, name, "failed", from, error);
51
- throw error;
52
- }
70
+ await this.runPhase(registered, context, "initializing", "initialized", PLUGIN_EVENTS.INITIALIZING, PLUGIN_EVENTS.INITIALIZED, () => registered.plugin.initialize?.(context));
53
71
  }
54
72
  async start(registered, context) {
55
- const name = registered.plugin.metadata.name;
56
- const from = registered.state;
57
- this.ensureTransition(registered, "starting");
58
- registered.setState("starting");
59
- emitLifecycleEvent(context, PLUGIN_EVENTS.STARTING, name, "starting", from);
60
- try {
61
- await registered.plugin.start?.(context);
62
- this.ensureTransition(registered, "started");
63
- registered.setState("started");
64
- emitLifecycleEvent(context, PLUGIN_EVENTS.STARTED, name, "started", "starting");
65
- }
66
- catch (error) {
67
- registered.setState("failed");
68
- emitLifecycleEvent(context, PLUGIN_EVENTS.FAILED, name, "failed", from, error);
69
- throw error;
70
- }
73
+ await this.runPhase(registered, context, "starting", "started", PLUGIN_EVENTS.STARTING, PLUGIN_EVENTS.STARTED, () => registered.plugin.start?.(context));
71
74
  }
72
75
  async stop(registered, context) {
73
- const name = registered.plugin.metadata.name;
76
+ await this.runPhase(registered, context, "stopping", "stopped", PLUGIN_EVENTS.STOPPING, PLUGIN_EVENTS.STOPPED, () => registered.plugin.stop?.(context));
77
+ }
78
+ /**
79
+ * Runs one lifecycle phase, moving through its transient state.
80
+ *
81
+ * On failure the plugin moves to `failed` through the state machine
82
+ * rather than around it, so the recorded state is always one the
83
+ * machine actually permits.
84
+ */
85
+ async runPhase(registered, context, transient, settled, startEvent, endEvent, run) {
86
+ const metadata = registered.plugin.metadata;
74
87
  const from = registered.state;
75
- this.ensureTransition(registered, "stopping");
76
- registered.setState("stopping");
77
- emitLifecycleEvent(context, PLUGIN_EVENTS.STOPPING, name, "stopping", from);
88
+ this.ensureTransition(registered, transient);
89
+ registered.setState(transient);
90
+ emitLifecycleEvent(context, startEvent, metadata, transient, from);
78
91
  try {
79
- await registered.plugin.stop?.(context);
80
- this.ensureTransition(registered, "stopped");
81
- registered.setState("stopped");
82
- emitLifecycleEvent(context, PLUGIN_EVENTS.STOPPED, name, "stopped", "stopping");
92
+ await this.runHook(metadata.name, transient, run);
93
+ this.ensureTransition(registered, settled);
94
+ registered.setState(settled);
95
+ emitLifecycleEvent(context, endEvent, metadata, settled, transient);
83
96
  }
84
97
  catch (error) {
85
- registered.setState("failed");
86
- emitLifecycleEvent(context, PLUGIN_EVENTS.FAILED, name, "failed", from, error);
98
+ registered.setError(error);
99
+ this.transitionToFailed(registered);
100
+ emitLifecycleEvent(context, PLUGIN_EVENTS.FAILED, metadata, "failed", transient, error);
87
101
  throw error;
88
102
  }
89
103
  }
104
+ /**
105
+ * Disposes a plugin and everything it registered for cleanup.
106
+ *
107
+ * Disposables run in reverse registration order — the mirror of how
108
+ * they were acquired — and the list is emptied so a second dispose
109
+ * cannot run them again. Every failure is collected; the plugin still
110
+ * reaches a terminal state so it cannot be disposed twice.
111
+ */
90
112
  async dispose(registered, context) {
91
- const name = registered.plugin.metadata.name;
113
+ const metadata = registered.plugin.metadata;
92
114
  const from = registered.state;
115
+ if (from === "disposed" || from === "disposing") {
116
+ return;
117
+ }
93
118
  this.ensureTransition(registered, "disposing");
94
119
  registered.setState("disposing");
95
- emitLifecycleEvent(context, PLUGIN_EVENTS.DISPOSING, name, "disposing", from);
120
+ emitLifecycleEvent(context, PLUGIN_EVENTS.DISPOSING, metadata, "disposing", from);
96
121
  const errors = [];
97
- for (const disposable of registered.disposables) {
122
+ // Take the list before running it: a disposable that registers
123
+ // another during teardown must not extend the loop indefinitely.
124
+ const disposables = registered.disposables.splice(0, registered.disposables.length);
125
+ for (const disposable of disposables.reverse()) {
98
126
  try {
99
- await disposable.dispose();
127
+ await this.runHook(metadata.name, "disposing", () => disposable.dispose());
100
128
  }
101
129
  catch (error) {
102
130
  errors.push(error);
103
131
  }
104
132
  }
105
133
  try {
106
- await registered.plugin.dispose?.(context);
134
+ await this.runHook(metadata.name, "disposing", () => registered.plugin.dispose?.(context));
107
135
  }
108
136
  catch (error) {
109
137
  errors.push(error);
110
138
  }
111
139
  registered.setState("disposed");
112
- emitLifecycleEvent(context, PLUGIN_EVENTS.DISPOSED, name, "disposed", "disposing");
140
+ emitLifecycleEvent(context, PLUGIN_EVENTS.DISPOSED, metadata, "disposed", "disposing");
113
141
  if (errors.length > 0) {
114
- throw errors[0];
142
+ const error = new PluginDisposeError(errors.length === 1
143
+ ? `Plugin "${metadata.name}" failed to dispose.`
144
+ : `Plugin "${metadata.name}" reported ${errors.length} disposal failures.`, metadata.name, { cause: errors[0] });
145
+ // Every failure is retained; reporting only the first would hide
146
+ // the rest of a partially failed teardown.
147
+ Object.defineProperty(error, "errors", {
148
+ value: Object.freeze([...errors]),
149
+ enumerable: true,
150
+ configurable: true,
151
+ });
152
+ throw error;
153
+ }
154
+ }
155
+ /**
156
+ * Moves a plugin to `failed`, via `stopping` when required.
157
+ */
158
+ transitionToFailed(registered) {
159
+ if (registered.state === "failed") {
160
+ return;
115
161
  }
162
+ if (this.canTransition(registered.state, "failed")) {
163
+ registered.setState("failed");
164
+ return;
165
+ }
166
+ // A phase can fail from a settled state (a hook that threw after
167
+ // the state had already advanced). Route through `stopping`, which
168
+ // every settled state permits, so the machine stays consistent.
169
+ if (this.canTransition(registered.state, "stopping")) {
170
+ registered.setState("stopping");
171
+ }
172
+ registered.setState("failed");
173
+ }
174
+ canTransition(from, to) {
175
+ // Delegates to the exported predicate rather than re-deriving it:
176
+ // two copies of the transition rule can disagree, and the public
177
+ // `isValidTransition` must describe what the controller actually does.
178
+ return isValidTransition(from, to);
116
179
  }
117
180
  ensureTransition(registered, to) {
118
181
  const from = registered.state;
119
182
  if (from === to) {
120
183
  return;
121
184
  }
122
- const valid = VALID_STATE_TRANSITIONS[from]?.includes(to) ?? false;
123
- if (!valid) {
185
+ if (!this.canTransition(from, to)) {
124
186
  throw new PluginStateError(registered.plugin.metadata.name, from, to);
125
187
  }
126
188
  }
@@ -1,6 +1,46 @@
1
1
  import type { Plugin } from "../pluginTypes/plugin.type.js";
2
- import type { PluginContext } from "../pluginTypes/pluginContext.type.js";
2
+ import type { PluginContext, PluginEvents } from "../pluginTypes/pluginContext.type.js";
3
3
  import type { PluginDiagnosticReport } from "../pluginDiagnostics/pluginDiagnostic.core.js";
4
+ /**
5
+ * Options for a plugin manager.
6
+ */
7
+ export interface PluginManagerOptions {
8
+ /**
9
+ * Whether declared dependency versions are enforced at startup.
10
+ * Defaults to `true`. A declared version that is never checked is
11
+ * worse than no version at all.
12
+ */
13
+ readonly checkVersions?: boolean;
14
+ /**
15
+ * Receives errors raised during shutdown. Teardown continues past a
16
+ * failing plugin either way, but without this the failures are
17
+ * invisible.
18
+ */
19
+ readonly onError?: (error: unknown, pluginName: string) => void;
20
+ /**
21
+ * Maximum time a single lifecycle hook may run, in milliseconds.
22
+ * Defaults to `0` (unbounded). Set a value so one hanging plugin
23
+ * cannot hang boot or shutdown.
24
+ */
25
+ readonly hookTimeout?: number;
26
+ /**
27
+ * Capabilities plugins are permitted to declare.
28
+ *
29
+ * When set, a plugin whose `metadata.capabilities` includes anything
30
+ * outside this list is rejected at registration. Leave unset to allow
31
+ * any capability.
32
+ */
33
+ readonly allowedCapabilities?: readonly string[];
34
+ /**
35
+ * Event sink for `plugin:registered`.
36
+ *
37
+ * Registration happens before any plugin context exists, so the
38
+ * lifecycle events emitted through `context.events` cannot cover it.
39
+ * Without this, `PLUGIN_EVENTS.REGISTERED` was a name nothing ever
40
+ * emitted.
41
+ */
42
+ readonly events?: PluginEvents;
43
+ }
4
44
  /**
5
45
  * Plugin manager coordinates registration, dependency resolution, lifecycle, and disposal.
6
46
  */
@@ -9,14 +49,92 @@ export declare class PluginManager {
9
49
  private readonly resolver;
10
50
  private readonly lifecycle;
11
51
  private readonly plugins;
12
- constructor();
13
- register<TPlugin extends Plugin>(plugin: TPlugin): void;
52
+ private readonly options;
53
+ /**
54
+ * Dependency-first order from the last successful resolution.
55
+ *
56
+ * Shutdown reverses this rather than registration order: tearing down
57
+ * in registration order destroys a dependency while its dependents are
58
+ * still using it.
59
+ */
60
+ private startupOrder;
61
+ private starting;
62
+ /**
63
+ * Per-plugin context views and their abort handles.
64
+ *
65
+ * Each plugin gets a context whose `onDispose` and
66
+ * `registerDisposable` write to that plugin's own disposables list —
67
+ * the list the lifecycle actually drains — and whose `signal` this
68
+ * manager aborts on shutdown.
69
+ */
70
+ private readonly contexts;
71
+ constructor(options?: PluginManagerOptions);
72
+ /**
73
+ * Registers a plugin.
74
+ *
75
+ * @throws {PluginAlreadyRegisteredError} when the name is taken, so
76
+ * callers can implement idempotent registration.
77
+ */
78
+ register<TPlugin extends Plugin>(plugin: TPlugin, options?: unknown): void;
79
+ /**
80
+ * Removes a plugin that has not been started.
81
+ *
82
+ * Keeps the manager's own view in step with the registry; leaving a
83
+ * removed plugin in `plugins` would keep it in dependency resolution.
84
+ */
85
+ unregister(name: string): boolean;
14
86
  get<TPlugin extends Plugin>(name: string): TPlugin | undefined;
15
87
  has(name: string): boolean;
16
88
  list(): readonly Plugin[];
89
+ /**
90
+ * Installs, initializes and starts every plugin in dependency order.
91
+ *
92
+ * A failure anywhere rolls back: everything already brought up is
93
+ * stopped and disposed before the error is rethrown, so an aborted
94
+ * startup does not strand resources with no route to release them.
95
+ */
17
96
  start(context: PluginContext): Promise<void>;
97
+ /**
98
+ * Stops and disposes every plugin, in reverse dependency order.
99
+ *
100
+ * Teardown continues past a failing plugin so one bad `stop` cannot
101
+ * strand the rest, and every failure is reported through `onError`
102
+ * rather than swallowed.
103
+ */
18
104
  stop(context: PluginContext): Promise<void>;
19
105
  diagnostics(): PluginDiagnosticReport;
106
+ /**
107
+ * Tears down everything brought up by a failed startup.
108
+ */
109
+ private rollback;
110
+ /**
111
+ * Registered plugins in reverse dependency order.
112
+ *
113
+ * Falls back to reverse registration order for plugins registered
114
+ * after the last resolution, which is the best available guess.
115
+ */
116
+ private teardownOrder;
117
+ /**
118
+ * Returns the plugin's own context view, creating it on first use.
119
+ *
120
+ * The base context supplies the shared services; only the identity,
121
+ * the disposables collection and the abort signal are per-plugin.
122
+ */
123
+ private contextFor;
124
+ /**
125
+ * Aborts a plugin's signal, keeping the context available.
126
+ *
127
+ * The entry stays so `dispose` still receives the same — now aborted —
128
+ * context; removing it here would hand the dispose hook a fresh
129
+ * context whose signal had never fired.
130
+ */
131
+ private abortContext;
132
+ /**
133
+ * Emits `plugin:registered`, containing a throwing subscriber so a bad
134
+ * listener cannot fail the registration that triggered it.
135
+ */
136
+ private emitRegistered;
137
+ private report;
20
138
  private toDependencyMap;
21
139
  }
22
140
  //# sourceMappingURL=pluginManager.core.d.ts.map