@esauflores/plugin-sdk 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +213 -2
- package/dist/hooks.d.ts +76 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +155 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +12 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +78 -0
- package/dist/loader.js.map +1 -0
- package/dist/types.d.ts +135 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +49 -0
- package/dist/types.js.map +1 -0
- package/package.json +65 -4
- package/src/hooks.ts +253 -0
- package/src/index.ts +568 -0
- package/src/loader.ts +83 -0
- package/src/types.ts +196 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// plugin-sdk: a plugin system for Bun and Node 24+. Its only dependency is @standard-schema/spec (types only).
|
|
2
|
+
//
|
|
3
|
+
// Lifecycle: register/load → setup (all, in dependency order) → start (all) → stop (reverse order) → dispose
|
|
4
|
+
|
|
5
|
+
import { HookBus } from "./hooks.ts";
|
|
6
|
+
import {
|
|
7
|
+
type AppInfo,
|
|
8
|
+
type ErrorPhase,
|
|
9
|
+
type FailurePolicy,
|
|
10
|
+
type HostOptions,
|
|
11
|
+
type HookErrorPolicy,
|
|
12
|
+
type HostPhase,
|
|
13
|
+
isPlugin,
|
|
14
|
+
type Logger,
|
|
15
|
+
parseWith,
|
|
16
|
+
type Plugin,
|
|
17
|
+
PluginError,
|
|
18
|
+
type PluginContext,
|
|
19
|
+
type PluginInfo,
|
|
20
|
+
type PluginSpec,
|
|
21
|
+
type PluginState,
|
|
22
|
+
type StateListener,
|
|
23
|
+
TimeoutError,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
|
|
26
|
+
export * from "./types.ts";
|
|
27
|
+
export {
|
|
28
|
+
HookBus,
|
|
29
|
+
type HookErrorHandler,
|
|
30
|
+
type HookFn,
|
|
31
|
+
type HookInterceptor,
|
|
32
|
+
type HookName,
|
|
33
|
+
type HookOptions,
|
|
34
|
+
type HookOrder,
|
|
35
|
+
type Hooks,
|
|
36
|
+
type HookSchemas,
|
|
37
|
+
type HookStyle,
|
|
38
|
+
} from "./hooks.ts";
|
|
39
|
+
|
|
40
|
+
/** Identity helper for type inference. Untyped hooks/services; see `definePluginFor` for app-typed plugins. */
|
|
41
|
+
export function definePlugin<Options = void, Input = Options>(
|
|
42
|
+
plugin: Plugin<Options, any, any, Input>,
|
|
43
|
+
): Plugin<Options, any, any, Input> {
|
|
44
|
+
return plugin;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns a `definePlugin` bound to your app's hook and service maps:
|
|
49
|
+
* export const definePlugin = definePluginFor<MyHooks, MyServices>();
|
|
50
|
+
*/
|
|
51
|
+
export function definePluginFor<H extends object, S extends object = {}>() {
|
|
52
|
+
return <Options = void, Input = Options>(plugin: Plugin<Options, H, S, Input>): Plugin<Options, H, S, Input> =>
|
|
53
|
+
plugin;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const HOST = "<host>";
|
|
57
|
+
|
|
58
|
+
interface Entry {
|
|
59
|
+
plugin: Plugin;
|
|
60
|
+
options: unknown;
|
|
61
|
+
source: string;
|
|
62
|
+
state: PluginState;
|
|
63
|
+
reason?: string;
|
|
64
|
+
error?: unknown;
|
|
65
|
+
controller: AbortController;
|
|
66
|
+
disposers: Array<() => unknown>;
|
|
67
|
+
ctx?: PluginContext;
|
|
68
|
+
tornDown: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The host is single-use: once stopped it can't be started again. To reload plugins,
|
|
73
|
+
* create a new host (or restart the process; ES modules can't be unloaded).
|
|
74
|
+
*/
|
|
75
|
+
export class PluginHost<H extends object = any, S extends object = any> {
|
|
76
|
+
readonly app: AppInfo;
|
|
77
|
+
/** Host-owned view of the hook bus: emit hooks from your app through this. */
|
|
78
|
+
readonly hooks: HookBus<H>;
|
|
79
|
+
|
|
80
|
+
readonly #apiVersion: number;
|
|
81
|
+
readonly #manifestKey: string;
|
|
82
|
+
readonly #log: Logger;
|
|
83
|
+
readonly #timeoutMs: number;
|
|
84
|
+
readonly #failure: FailurePolicy;
|
|
85
|
+
readonly #hookErrors: HookErrorPolicy;
|
|
86
|
+
|
|
87
|
+
readonly #entries = new Map<string, Entry>();
|
|
88
|
+
readonly #services = new Map<string, { owner: string; value: unknown }>();
|
|
89
|
+
readonly #listeners = new Set<StateListener>();
|
|
90
|
+
|
|
91
|
+
#order: string[] = [];
|
|
92
|
+
#phase: HostPhase = "idle";
|
|
93
|
+
#starting?: Promise<void>;
|
|
94
|
+
#stopping?: Promise<void>;
|
|
95
|
+
|
|
96
|
+
constructor(options: HostOptions<H>) {
|
|
97
|
+
this.app = options.app;
|
|
98
|
+
this.hooks = new HookBus<H>(options.hookSchemas, (error) => this.#onHookError(error));
|
|
99
|
+
this.#apiVersion = options.apiVersion ?? 1;
|
|
100
|
+
this.#manifestKey = options.manifestKey ?? options.app.name;
|
|
101
|
+
this.#log = options.logger ?? console;
|
|
102
|
+
this.#timeoutMs = options.timeoutMs ?? 10_000;
|
|
103
|
+
this.#failure = options.failure ?? "isolate";
|
|
104
|
+
this.#hookErrors = options.hookErrors ?? "log";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get phase(): HostPhase {
|
|
108
|
+
return this.#phase;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Adds a plugin object directly (local plugins, or ones you imported yourself). */
|
|
112
|
+
register<O, I = O>(plugin: Plugin<O, H, S, I>, options?: I): this {
|
|
113
|
+
this.#add(plugin, options, "inline");
|
|
114
|
+
|
|
115
|
+
return this;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Loads installed plugin packages, resolving from `cwd`'s node_modules upwards.
|
|
120
|
+
* Imports run in parallel; plugins are registered in the order given.
|
|
121
|
+
* Under "isolate", a package that fails to load is listed as failed and skipped.
|
|
122
|
+
*/
|
|
123
|
+
async load(specs: readonly PluginSpec[], { cwd = process.cwd() }: { cwd?: string } = {}): Promise<this> {
|
|
124
|
+
this.#assertPhase("idle", "load plugins");
|
|
125
|
+
|
|
126
|
+
// Imported lazily so the core never pulls in node:fs unless you load packages.
|
|
127
|
+
const { loadPluginPackage } = await import("./loader.ts");
|
|
128
|
+
|
|
129
|
+
const normalized = specs.map((spec) => (typeof spec === "string" ? ([spec, undefined] as const) : spec));
|
|
130
|
+
const results = await Promise.allSettled(
|
|
131
|
+
normalized.map(([specifier]) =>
|
|
132
|
+
loadPluginPackage(specifier, { cwd, apiVersion: this.#apiVersion, manifestKey: this.#manifestKey }),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
for (const [i, result] of results.entries()) {
|
|
137
|
+
const [specifier, options] = normalized[i];
|
|
138
|
+
|
|
139
|
+
if (result.status === "fulfilled") {
|
|
140
|
+
this.#add(result.value, options, specifier);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (this.#failure === "strict") throw result.reason;
|
|
145
|
+
this.#log.error(result.reason);
|
|
146
|
+
|
|
147
|
+
if (!this.#entries.has(specifier)) {
|
|
148
|
+
this.#entries.set(specifier, {
|
|
149
|
+
...newEntry({ name: specifier, setup() {} }, options, specifier),
|
|
150
|
+
state: "failed",
|
|
151
|
+
reason: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
152
|
+
error: result.reason,
|
|
153
|
+
tornDown: true,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Sets up every plugin, then starts every plugin, both in dependency order. */
|
|
162
|
+
start(): Promise<void> {
|
|
163
|
+
this.#assertPhase("idle", "start");
|
|
164
|
+
|
|
165
|
+
this.#phase = "starting";
|
|
166
|
+
this.#starting = this.#start();
|
|
167
|
+
|
|
168
|
+
return this.#starting;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Stops started plugins in reverse order and tears everything down. Safe to call repeatedly or mid-start. */
|
|
172
|
+
stop(): Promise<void> {
|
|
173
|
+
this.#stopping ??= (async () => {
|
|
174
|
+
await this.#starting?.catch(() => {});
|
|
175
|
+
await this.#shutdown();
|
|
176
|
+
})();
|
|
177
|
+
|
|
178
|
+
return this.#stopping;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Services provided by the host are usable by every plugin without declaring a dependency. */
|
|
182
|
+
provide<K extends keyof S & string>(key: K, value: S[K]): void {
|
|
183
|
+
this.#provide(HOST, key, value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
service<K extends keyof S & string>(key: K): S[K] | undefined {
|
|
187
|
+
return this.#services.get(key)?.value as S[K] | undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
list(): PluginInfo[] {
|
|
191
|
+
return [...this.#entries.values()].map(info);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
get(name: string): PluginInfo | undefined {
|
|
195
|
+
const entry = this.#entries.get(name);
|
|
196
|
+
|
|
197
|
+
return entry && info(entry);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
onStateChange(listener: StateListener): () => void {
|
|
201
|
+
this.#listeners.add(listener);
|
|
202
|
+
|
|
203
|
+
return () => this.#listeners.delete(listener);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async #start(): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
const { order, unresolved } = resolveOrder([...this.#entries.values()].map((e) => e.plugin));
|
|
209
|
+
this.#order = order;
|
|
210
|
+
|
|
211
|
+
for (const name of unresolved) {
|
|
212
|
+
await this.#fail(
|
|
213
|
+
this.#entries.get(name)!,
|
|
214
|
+
"disabled",
|
|
215
|
+
"setup",
|
|
216
|
+
`dependency cycle among: ${unresolved.join(", ")}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const entry of this.#ordered("registered")) {
|
|
221
|
+
if (this.#stopping) return;
|
|
222
|
+
|
|
223
|
+
const blocker = this.#blockingDependency(entry, "setup");
|
|
224
|
+
if (blocker) await this.#fail(entry, "disabled", "setup", blocker);
|
|
225
|
+
else await this.#run(entry, "setup");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const entry of this.#ordered("setup")) {
|
|
229
|
+
if (this.#stopping) return;
|
|
230
|
+
|
|
231
|
+
const blocker = this.#blockingDependency(entry, "started");
|
|
232
|
+
if (blocker) await this.#fail(entry, "disabled", "start", blocker);
|
|
233
|
+
else await this.#run(entry, "start");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
this.#phase = "started";
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await this.#shutdown();
|
|
239
|
+
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #shutdown(): Promise<void> {
|
|
245
|
+
if (this.#phase === "stopped") return;
|
|
246
|
+
|
|
247
|
+
this.#phase = "stopping";
|
|
248
|
+
|
|
249
|
+
for (const name of [...this.#order].reverse()) {
|
|
250
|
+
const entry = this.#entries.get(name)!;
|
|
251
|
+
if (entry.state !== "started" && entry.state !== "setup") continue;
|
|
252
|
+
|
|
253
|
+
await this.#stopEntry(entry, entry.state === "started");
|
|
254
|
+
this.#setState(entry, "stopped");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.#phase = "stopped";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Aborts the signal, calls stop() if the plugin started, then tears it down. */
|
|
261
|
+
async #stopEntry(entry: Entry, callStop: boolean): Promise<void> {
|
|
262
|
+
entry.controller.abort(new Error("plugin is stopping"));
|
|
263
|
+
|
|
264
|
+
if (callStop && entry.plugin.stop) {
|
|
265
|
+
try {
|
|
266
|
+
await withTimeout(() => entry.plugin.stop!(entry.ctx!), this.#timeoutMs);
|
|
267
|
+
} catch (error) {
|
|
268
|
+
entry.error = error;
|
|
269
|
+
this.#log.error(new PluginError(entry.plugin.name, "stop", "stop failed", { cause: error }));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await this.#teardown(entry);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async #onHookError(error: PluginError): Promise<void> {
|
|
277
|
+
if (this.#hookErrors === "throw") throw error;
|
|
278
|
+
|
|
279
|
+
this.#log.error(error);
|
|
280
|
+
|
|
281
|
+
const entry = this.#entries.get(error.plugin);
|
|
282
|
+
if (this.#hookErrors === "disable" && this.#phase === "started" && entry?.state === "started") {
|
|
283
|
+
await this.#disableAtRuntime(entry, error);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Stops a running plugin as failed, and the started plugins that depend on it (directly or not) as disabled. */
|
|
288
|
+
async #disableAtRuntime(culprit: Entry, error: PluginError): Promise<void> {
|
|
289
|
+
const affected = new Set([culprit.plugin.name]);
|
|
290
|
+
|
|
291
|
+
// #order lists dependencies first, so one pass collects every transitive dependent.
|
|
292
|
+
for (const name of this.#order) {
|
|
293
|
+
if (this.#entries.get(name)!.plugin.dependsOn?.some((dep) => affected.has(dep))) affected.add(name);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const name of [...this.#order].reverse()) {
|
|
297
|
+
const entry = this.#entries.get(name)!;
|
|
298
|
+
if (!affected.has(name) || entry.state !== "started") continue;
|
|
299
|
+
|
|
300
|
+
// State changes first, so a concurrent hook error doesn't stop the same plugin twice.
|
|
301
|
+
if (entry === culprit) {
|
|
302
|
+
entry.reason = `disabled at runtime: ${error.reason}`;
|
|
303
|
+
entry.error = error;
|
|
304
|
+
this.#setState(entry, "failed");
|
|
305
|
+
} else {
|
|
306
|
+
entry.reason = `dependency "${culprit.plugin.name}" is failed`;
|
|
307
|
+
this.#setState(entry, "disabled");
|
|
308
|
+
this.#log.warn(`[${name}] ${entry.reason}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await this.#stopEntry(entry, true);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
*#ordered(state: PluginState): Generator<Entry> {
|
|
316
|
+
for (const name of this.#order) {
|
|
317
|
+
const entry = this.#entries.get(name)!;
|
|
318
|
+
if (entry.state === state) yield entry;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
#blockingDependency(entry: Entry, needed: PluginState): string | undefined {
|
|
323
|
+
for (const dep of entry.plugin.dependsOn ?? []) {
|
|
324
|
+
const target = this.#entries.get(dep);
|
|
325
|
+
|
|
326
|
+
if (!target) return `missing dependency "${dep}"`;
|
|
327
|
+
if (target.state !== needed) return `dependency "${dep}" is ${target.state}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async #run(entry: Entry, phase: "setup" | "start"): Promise<void> {
|
|
334
|
+
// Before the context exists, so ctx.options is the parsed value.
|
|
335
|
+
if (phase === "setup" && entry.plugin.optionsSchema) {
|
|
336
|
+
try {
|
|
337
|
+
entry.options = await parseWith(entry.plugin.optionsSchema, entry.options);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
340
|
+
await this.#fail(entry, "failed", "setup", `invalid options: ${detail}`, error);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const ctx = (entry.ctx ??= this.#createContext(entry));
|
|
346
|
+
const { plugin } = entry;
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await withTimeout(
|
|
350
|
+
() => (phase === "setup" ? plugin.setup(ctx) : plugin.start?.(ctx)),
|
|
351
|
+
this.#timeoutMs,
|
|
352
|
+
entry.controller,
|
|
353
|
+
);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
await this.#fail(
|
|
356
|
+
entry,
|
|
357
|
+
"failed",
|
|
358
|
+
phase,
|
|
359
|
+
`${phase} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
360
|
+
error,
|
|
361
|
+
);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.#setState(entry, phase === "setup" ? "setup" : "started");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Tears the plugin down and records why. Under "strict", also throws (and #start shuts everything down). */
|
|
369
|
+
async #fail(
|
|
370
|
+
entry: Entry,
|
|
371
|
+
state: "failed" | "disabled",
|
|
372
|
+
phase: ErrorPhase,
|
|
373
|
+
reason: string,
|
|
374
|
+
error?: unknown,
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
entry.reason = reason;
|
|
377
|
+
entry.error = error;
|
|
378
|
+
|
|
379
|
+
await this.#teardown(entry);
|
|
380
|
+
this.#setState(entry, state);
|
|
381
|
+
|
|
382
|
+
const err = new PluginError(entry.plugin.name, phase, reason, error === undefined ? undefined : { cause: error });
|
|
383
|
+
if (this.#failure === "strict") throw err;
|
|
384
|
+
|
|
385
|
+
if (state === "failed") this.#log.error(err);
|
|
386
|
+
else this.#log.warn(err.message);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async #teardown(entry: Entry): Promise<void> {
|
|
390
|
+
if (entry.tornDown) return;
|
|
391
|
+
entry.tornDown = true;
|
|
392
|
+
|
|
393
|
+
if (!entry.controller.signal.aborted) entry.controller.abort(new Error("plugin was torn down"));
|
|
394
|
+
|
|
395
|
+
for (const dispose of entry.disposers.splice(0).reverse()) {
|
|
396
|
+
try {
|
|
397
|
+
await withTimeout(dispose, this.#timeoutMs);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
this.#log.error(new PluginError(entry.plugin.name, "dispose", "disposer failed", { cause: error }));
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
this.hooks.removeOwner(entry.plugin.name);
|
|
404
|
+
|
|
405
|
+
for (const [key, service] of this.#services) {
|
|
406
|
+
if (service.owner === entry.plugin.name) this.#services.delete(key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
#createContext(entry: Entry): PluginContext {
|
|
411
|
+
const { name } = entry.plugin;
|
|
412
|
+
const allowed = new Set([name, HOST, ...(entry.plugin.dependsOn ?? []), ...(entry.plugin.optionalDependsOn ?? [])]);
|
|
413
|
+
|
|
414
|
+
const lookup = (key: string) => {
|
|
415
|
+
const service = this.#services.get(key);
|
|
416
|
+
|
|
417
|
+
if (service && !allowed.has(service.owner)) {
|
|
418
|
+
throw new PluginError(
|
|
419
|
+
name,
|
|
420
|
+
"service",
|
|
421
|
+
`uses "${key}" from "${service.owner}" without listing "${service.owner}" in dependsOn`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return service;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const tag = `[${this.app.name}:${name}]`;
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
name,
|
|
432
|
+
options: entry.options,
|
|
433
|
+
app: this.app,
|
|
434
|
+
signal: entry.controller.signal,
|
|
435
|
+
log: {
|
|
436
|
+
debug: (...args) => this.#log.debug(tag, ...args),
|
|
437
|
+
info: (...args) => this.#log.info(tag, ...args),
|
|
438
|
+
warn: (...args) => this.#log.warn(tag, ...args),
|
|
439
|
+
error: (...args) => this.#log.error(tag, ...args),
|
|
440
|
+
},
|
|
441
|
+
hooks: this.hooks.scope(name),
|
|
442
|
+
provide: (key, value) => this.#provide(name, key, value),
|
|
443
|
+
use: (key) => {
|
|
444
|
+
const service = lookup(key);
|
|
445
|
+
if (!service) throw new PluginError(name, "service", `service "${key}" is not provided`);
|
|
446
|
+
|
|
447
|
+
return service.value as any;
|
|
448
|
+
},
|
|
449
|
+
tryUse: (key) => lookup(key)?.value as any,
|
|
450
|
+
onDispose: (fn) => {
|
|
451
|
+
if (entry.tornDown) throw new PluginError(name, "dispose", "onDispose called after the plugin was torn down");
|
|
452
|
+
|
|
453
|
+
entry.disposers.push(fn);
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#provide(owner: string, key: string, value: unknown): void {
|
|
459
|
+
const existing = this.#services.get(key);
|
|
460
|
+
|
|
461
|
+
if (existing) {
|
|
462
|
+
throw new PluginError(owner, "service", `service "${key}" is already provided by "${existing.owner}"`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.#services.set(key, { owner, value });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
#add(plugin: Plugin, options: unknown, source: string): void {
|
|
469
|
+
this.#assertPhase("idle", "register plugins");
|
|
470
|
+
|
|
471
|
+
if (!isPlugin(plugin)) throw new PluginError(source, "register", "not a valid plugin (expected { name, setup })");
|
|
472
|
+
|
|
473
|
+
const existing = this.#entries.get(plugin.name);
|
|
474
|
+
if (existing) throw new PluginError(plugin.name, "register", `already registered (from ${existing.source})`);
|
|
475
|
+
|
|
476
|
+
this.#entries.set(plugin.name, newEntry(plugin, options, source));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#setState(entry: Entry, state: PluginState): void {
|
|
480
|
+
const previous = entry.state;
|
|
481
|
+
entry.state = state;
|
|
482
|
+
|
|
483
|
+
for (const listener of this.#listeners) {
|
|
484
|
+
try {
|
|
485
|
+
listener(info(entry), previous);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
this.#log.error("onStateChange listener threw", error);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
#assertPhase(expected: HostPhase, action: string): void {
|
|
493
|
+
if (this.#phase !== expected) throw new Error(`Cannot ${action}: host is ${this.#phase}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Orders plugins so dependencies come first. Ties keep registration order, so the
|
|
499
|
+
* result is deterministic. Dependencies that aren't registered are ignored here
|
|
500
|
+
* (the host reports them); plugins in, or blocked by, a cycle end up in `unresolved`.
|
|
501
|
+
*/
|
|
502
|
+
function resolveOrder(plugins: readonly Plugin[]): { order: string[]; unresolved: string[] } {
|
|
503
|
+
const names = new Set(plugins.map((p) => p.name));
|
|
504
|
+
const pending = plugins.map((p) => ({
|
|
505
|
+
name: p.name,
|
|
506
|
+
deps: [...(p.dependsOn ?? []), ...(p.optionalDependsOn ?? [])].filter((d) => names.has(d)),
|
|
507
|
+
}));
|
|
508
|
+
const done = new Set<string>();
|
|
509
|
+
const order: string[] = [];
|
|
510
|
+
|
|
511
|
+
for (let i = 0; i < pending.length;) {
|
|
512
|
+
if (pending[i].deps.every((d) => done.has(d))) {
|
|
513
|
+
const [node] = pending.splice(i, 1);
|
|
514
|
+
order.push(node.name);
|
|
515
|
+
done.add(node.name);
|
|
516
|
+
|
|
517
|
+
i = 0;
|
|
518
|
+
} else {
|
|
519
|
+
i++;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return { order, unresolved: pending.map((n) => n.name) };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Rejects after `ms` (and aborts `controller`). The underlying work can't be cancelled, only abandoned. */
|
|
527
|
+
async function withTimeout<T>(fn: () => T | Promise<T>, ms: number, controller?: AbortController): Promise<Awaited<T>> {
|
|
528
|
+
if (!(ms > 0) || !Number.isFinite(ms)) return await fn();
|
|
529
|
+
|
|
530
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
531
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
532
|
+
timer = setTimeout(() => {
|
|
533
|
+
const error = new TimeoutError(ms);
|
|
534
|
+
|
|
535
|
+
controller?.abort(error);
|
|
536
|
+
reject(error);
|
|
537
|
+
}, ms);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
return await Promise.race([Promise.resolve().then(fn), timeout]);
|
|
542
|
+
} finally {
|
|
543
|
+
clearTimeout(timer);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function newEntry(plugin: Plugin, options: unknown, source: string): Entry {
|
|
548
|
+
return {
|
|
549
|
+
plugin,
|
|
550
|
+
options,
|
|
551
|
+
source,
|
|
552
|
+
state: "registered",
|
|
553
|
+
controller: new AbortController(),
|
|
554
|
+
disposers: [],
|
|
555
|
+
tornDown: false,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function info(entry: Entry): PluginInfo {
|
|
560
|
+
return {
|
|
561
|
+
name: entry.plugin.name,
|
|
562
|
+
version: entry.plugin.version,
|
|
563
|
+
source: entry.source,
|
|
564
|
+
state: entry.state,
|
|
565
|
+
reason: entry.reason,
|
|
566
|
+
error: entry.error,
|
|
567
|
+
};
|
|
568
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The only module that touches the filesystem. index.ts imports it lazily from
|
|
2
|
+
// PluginHost.load(), so the core runs without Node APIs when you only use register().
|
|
3
|
+
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
import { isPlugin, PluginError, type Plugin, type PluginManifest } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Finds an installed plugin package, checks its manifest, and imports its entry.
|
|
12
|
+
* Doesn't use require.resolve / import.meta.resolve, which behave differently
|
|
13
|
+
* across runtimes and with ESM-only packages.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadPluginPackage(
|
|
16
|
+
specifier: string,
|
|
17
|
+
opts: { cwd: string; apiVersion: number; manifestKey: string },
|
|
18
|
+
): Promise<Plugin> {
|
|
19
|
+
const fail = (message: string, cause?: unknown) =>
|
|
20
|
+
new PluginError(specifier, "load", message, cause === undefined ? undefined : { cause });
|
|
21
|
+
|
|
22
|
+
const dir = await findPackageDir(specifier, opts.cwd);
|
|
23
|
+
if (!dir) throw fail(`package not found from ${opts.cwd} (is it installed?)`);
|
|
24
|
+
|
|
25
|
+
let pkg: Record<string, any>;
|
|
26
|
+
try {
|
|
27
|
+
pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw fail(`could not read ${join(dir, "package.json")}`, error);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const manifest: Partial<PluginManifest> | undefined = pkg[opts.manifestKey];
|
|
33
|
+
if (!manifest || typeof manifest !== "object") {
|
|
34
|
+
throw fail(`not a plugin: package.json has no "${opts.manifestKey}" field`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (manifest.apiVersion !== opts.apiVersion) {
|
|
38
|
+
const declared = typeof manifest.apiVersion === "number" ? `v${manifest.apiVersion}` : "no version";
|
|
39
|
+
throw fail(`targets plugin API ${declared}, but this host supports v${opts.apiVersion}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof manifest.entry !== "string") {
|
|
43
|
+
throw fail(`manifest "${opts.manifestKey}" has no "entry" (e.g. "./dist/index.js")`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const entry = resolve(dir, manifest.entry);
|
|
47
|
+
|
|
48
|
+
let mod: Record<string, unknown>;
|
|
49
|
+
try {
|
|
50
|
+
mod = await import(pathToFileURL(entry).href);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw fail(`failed to import ${entry}`, error);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const plugin = mod.default ?? mod.plugin;
|
|
56
|
+
if (!isPlugin(plugin)) throw fail(`${entry} must export a plugin ({ name, setup }) as its default export`);
|
|
57
|
+
|
|
58
|
+
return plugin;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function findPackageDir(specifier: string, cwd: string): Promise<string | undefined> {
|
|
62
|
+
if (specifier.startsWith(".") || isAbsolute(specifier)) {
|
|
63
|
+
const dir = resolve(cwd, specifier);
|
|
64
|
+
|
|
65
|
+
return (await isFile(join(dir, "package.json"))) ? dir : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Same lookup Node does: node_modules in cwd, then each parent directory.
|
|
69
|
+
for (let dir = resolve(cwd); ; dir = dirname(dir)) {
|
|
70
|
+
const candidate = join(dir, "node_modules", specifier);
|
|
71
|
+
|
|
72
|
+
if (await isFile(join(candidate, "package.json"))) return candidate;
|
|
73
|
+
if (dirname(dir) === dir) return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function isFile(path: string): Promise<boolean> {
|
|
78
|
+
try {
|
|
79
|
+
return (await stat(path)).isFile();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|