@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/src/types.ts ADDED
@@ -0,0 +1,196 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+
3
+ import type { HookSchemas, Hooks } from "./hooks.ts";
4
+
5
+ /** Any schema library implementing https://standardschema.dev: zod, valibot, ArkType... */
6
+ export type { StandardSchemaV1 };
7
+
8
+ export interface AppInfo {
9
+ /** Also the default `package.json` key plugins use for their manifest. */
10
+ name: string;
11
+ version: string;
12
+ }
13
+
14
+ export interface Logger {
15
+ debug(...args: unknown[]): void;
16
+ info(...args: unknown[]): void;
17
+ warn(...args: unknown[]): void;
18
+ error(...args: unknown[]): void;
19
+ }
20
+
21
+ /**
22
+ * `H` is the app's hook map, `S` its service map, e.g.
23
+ * interface MyHooks { "request": (req: Request) => void }
24
+ * interface MyServices { db: Database }
25
+ * `Options` is what the plugin receives in `ctx.options`; `Input` is what callers pass to
26
+ * `register()` / `load()`. They differ when `optionsSchema` applies defaults or transforms.
27
+ */
28
+ export interface Plugin<Options = any, H extends object = any, S extends object = any, Input = Options> {
29
+ /** Unique within a host. */
30
+ name: string;
31
+ version?: string;
32
+ /** Plugins that must set up and start before this one. If one fails, this plugin is disabled. */
33
+ dependsOn?: readonly string[];
34
+ /** Ordered before this plugin when present; ignored when missing or failed. */
35
+ optionalDependsOn?: readonly string[];
36
+ /**
37
+ * Validates the options before `setup`; `ctx.options` is the parsed result (defaults applied)
38
+ * and its type is inferred from the schema. Invalid options mark the plugin failed.
39
+ */
40
+ optionsSchema?: StandardSchemaV1<Input, Options>;
41
+ /** Register hooks and provide services. Avoid side effects (sockets, timers) here: that is what `start` is for. */
42
+ setup(ctx: PluginContext<Options, H, S>): void | Promise<void>;
43
+ /** Called once every plugin has set up, in dependency order. */
44
+ start?(ctx: PluginContext<Options, H, S>): void | Promise<void>;
45
+ /** Called in reverse dependency order. `ctx.signal` is already aborted; disposers run right after. */
46
+ stop?(ctx: PluginContext<Options, H, S>): void | Promise<void>;
47
+ }
48
+
49
+ export interface PluginContext<Options = any, H extends object = any, S extends object = any> {
50
+ readonly name: string;
51
+ readonly options: Options;
52
+ readonly app: AppInfo;
53
+ /** Aborted when the plugin starts stopping, fails, or times out. Pass it to fetch, timers, servers... */
54
+ readonly signal: AbortSignal;
55
+ readonly log: Logger;
56
+ /** Handlers registered here are removed automatically when the plugin is torn down. */
57
+ readonly hooks: Hooks<H>;
58
+ /** Removed automatically when the plugin is torn down. */
59
+ provide<K extends keyof S & string>(key: K, value: S[K]): void;
60
+ /** Throws if missing, or if the provider is not listed in `dependsOn` / `optionalDependsOn`. */
61
+ use<K extends keyof S & string>(key: K): S[K];
62
+ /** Like `use`, but returns undefined when the service is missing (for optional dependencies). */
63
+ tryUse<K extends keyof S & string>(key: K): S[K] | undefined;
64
+ /** Runs on teardown in reverse registration order, after `stop`. */
65
+ onDispose(fn: () => unknown): void;
66
+ }
67
+
68
+ export type PluginState =
69
+ | "registered" // known, not set up yet
70
+ | "setup" // setup() succeeded
71
+ | "started" // start() succeeded
72
+ | "stopped" // torn down cleanly
73
+ | "failed" // load/setup/start threw or timed out
74
+ | "disabled"; // skipped: missing/failed dependency or dependency cycle
75
+
76
+ export interface PluginInfo {
77
+ name: string;
78
+ version?: string;
79
+ /** "inline" for `host.register()`, otherwise the package specifier passed to `host.load()`. */
80
+ source: string;
81
+ state: PluginState;
82
+ /** Why the plugin failed or was disabled. */
83
+ reason?: string;
84
+ error?: unknown;
85
+ }
86
+
87
+ export type HookErrorPolicy =
88
+ /** Log the error, skip that handler and continue. */
89
+ | "log"
90
+ /** Log the error, then stop the plugin (state "failed") and the started plugins that depend on it. */
91
+ | "disable"
92
+ /** Make the hook call reject with the PluginError. */
93
+ | "throw";
94
+
95
+ export type FailurePolicy =
96
+ /** A failing plugin is torn down, its dependents disabled, and the rest keep running. */
97
+ | "isolate"
98
+ /** The first failure stops everything and rejects `start()` / `load()`. */
99
+ | "strict";
100
+
101
+ export interface HostOptions<H extends object = any> {
102
+ app: AppInfo;
103
+ /**
104
+ * Schemas for values plugins return from `waterfall` and `bail` hooks. A handler returning
105
+ * an invalid value makes the hook call reject with a PluginError naming that plugin.
106
+ */
107
+ hookSchemas?: NoInfer<HookSchemas<H>>;
108
+ /** Plugins declare the API version they target in their manifest; it must match. Bump it on breaking changes. Default 1. */
109
+ apiVersion?: number;
110
+ /** `package.json` field holding the plugin manifest. Defaults to `app.name`. */
111
+ manifestKey?: string;
112
+ logger?: Logger;
113
+ /** Per-plugin limit for each of setup/start/stop. 0 disables. Default 10s. */
114
+ timeoutMs?: number;
115
+ /** Default "isolate". */
116
+ failure?: FailurePolicy;
117
+ /**
118
+ * What happens when a plugin's hook handler throws, or returns a value its hook schema rejects,
119
+ * while the app runs. Errors from your app's own handlers always propagate. Default "log".
120
+ */
121
+ hookErrors?: HookErrorPolicy;
122
+ }
123
+
124
+ /** A package name (`"my-plugin"`, `"@scope/my-plugin"`), a path (`"./plugins/foo"`), or either with options. */
125
+ export type PluginSpec = string | readonly [specifier: string, options: unknown];
126
+
127
+ /** What a plugin package puts under its manifest key in package.json. */
128
+ export interface PluginManifest {
129
+ apiVersion: number;
130
+ /** Relative to the package root, e.g. "./dist/index.js". */
131
+ entry: string;
132
+ }
133
+
134
+ export type HostPhase = "idle" | "starting" | "started" | "stopping" | "stopped";
135
+ export type StateListener = (plugin: PluginInfo, previous: PluginState) => void;
136
+
137
+ export type ErrorPhase = "register" | "load" | "setup" | "start" | "stop" | "dispose" | "hook" | "service";
138
+
139
+ /** Every error the SDK raises about a specific plugin. `cause` holds the original error, if any. */
140
+ export class PluginError extends Error {
141
+ readonly plugin: string;
142
+ readonly phase: ErrorPhase;
143
+ /** The message without the `[plugin]` prefix. */
144
+ readonly reason: string;
145
+
146
+ constructor(plugin: string, phase: ErrorPhase, message: string, options?: ErrorOptions) {
147
+ super(`[${plugin}] ${message}`, options);
148
+ this.name = "PluginError";
149
+ this.plugin = plugin;
150
+ this.phase = phase;
151
+ this.reason = message;
152
+ }
153
+ }
154
+
155
+ export class TimeoutError extends Error {
156
+ constructor(ms: number) {
157
+ super(`timed out after ${ms}ms`);
158
+ this.name = "TimeoutError";
159
+ }
160
+ }
161
+
162
+ export class SchemaError extends Error {
163
+ readonly issues: readonly StandardSchemaV1.Issue[];
164
+
165
+ constructor(issues: readonly StandardSchemaV1.Issue[]) {
166
+ super(issues.map(formatIssue).join("; "));
167
+ this.name = "SchemaError";
168
+ this.issues = issues;
169
+ }
170
+ }
171
+
172
+ /** Runs a Standard Schema and returns its output, or throws a SchemaError listing the issues. */
173
+ export async function parseWith<T>(schema: StandardSchemaV1<unknown, T>, value: unknown): Promise<T> {
174
+ const result = await schema["~standard"].validate(value);
175
+ if (result.issues) throw new SchemaError(result.issues);
176
+
177
+ return result.value;
178
+ }
179
+
180
+ function formatIssue(issue: StandardSchemaV1.Issue): string {
181
+ const path = (issue.path ?? []).map((p) => String(typeof p === "object" ? p.key : p)).join(".");
182
+
183
+ return path ? `${path}: ${issue.message}` : issue.message;
184
+ }
185
+
186
+ // Duck-typed rather than branded: a plugin may have been built against a
187
+ // different copy of this SDK, so instanceof/symbol checks would reject it.
188
+ export function isPlugin(value: unknown): value is Plugin {
189
+ return (
190
+ typeof value === "object" &&
191
+ value !== null &&
192
+ typeof (value as Plugin).name === "string" &&
193
+ (value as Plugin).name.length > 0 &&
194
+ typeof (value as Plugin).setup === "function"
195
+ );
196
+ }