@ayepi/plugin 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Diffenderfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @ayepi/plugin
2
+
3
+ A plugin system for [`@ayepi/core`](../core). Compose an API from independent
4
+ **plugins** — each a frontend-safe spec, its server implementation, an exported
5
+ **state** service, lifecycle hooks, and a `requires` list — and install/uninstall
6
+ them **into a running server**.
7
+
8
+ ```sh
9
+ pnpm add @ayepi/plugin @ayepi/core zod
10
+ ```
11
+
12
+ ## What a plugin is
13
+
14
+ A plugin bundles five things:
15
+
16
+ - **`spec`** — the frontend-safe API contract (a normal `spec()`).
17
+ - **`.handlers` / `.middleware`** — its handlers + middleware bindings (chained on the builder).
18
+ - **`state`** — a service object (functions + data) that *dependent* plugins call
19
+ directly, with no HTTP and no middleware (the "better private functions").
20
+ - **`.lifecycle`** — `up` / `down` (drain) / `stop` (teardown) hooks.
21
+ - **`requires`** — the plugins it depends on, available in its **context**.
22
+
23
+ `plugin({ name, requires, spec, state })` returns a **builder**; chain ctx-aware
24
+ `.middleware` / `.handlers` / `.lifecycle` (mirroring core's `implement()`). Each
25
+ callback receives a dependency **context** (`ctx`): `ctx.deps.<name>` exposes each
26
+ required plugin's `state` service, a typed in-process `call` for its endpoints, and an
27
+ `emit` for its events; `ctx.state` is this plugin's own computed state; `ctx.emit`
28
+ publishes its own events. So a plugin uses its dependencies with just a data payload —
29
+ no manual context threading.
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { spec, endpoint, server } from '@ayepi/core';
35
+ import { plugin, createPluginHost } from '@ayepi/plugin';
36
+ import { z } from 'zod';
37
+
38
+ const authSpec = spec({ endpoints: { login: endpoint({ body: z.object({ user: z.string() }), response: z.object({ token: z.string() }) }) } });
39
+ const auth = plugin({
40
+ name: 'auth',
41
+ spec: authSpec,
42
+ state: () => ({ verify: (t: string) => (t.startsWith('tok-') ? t.slice(4) : null) }),
43
+ }).handlers(() => ({ login: ({ data }) => ({ token: `tok-${data.user}` }) }));
44
+
45
+ const notesSpec = spec({ endpoints: { add: endpoint({ body: z.object({ token: z.string(), text: z.string() }), response: z.object({ ok: z.boolean() }) }) } });
46
+ const notes = plugin({
47
+ name: 'notes',
48
+ requires: [auth] as const,
49
+ spec: notesSpec,
50
+ }).handlers((ctx) => ({
51
+ add: ({ data }) => ({ ok: ctx.deps.auth.state.verify(data.token) !== null }), // ← dep's state service
52
+ }));
53
+
54
+ const app = server(spec({ endpoints: {} }), []); // boot (nearly) empty
55
+ const host = createPluginHost(app);
56
+ await host.install(auth); // base plugin
57
+ await host.install(notes); // requires auth → installed after it
58
+ // ... app.fetch(...) now serves /login and /add — added while the server is live ...
59
+ await host.shutdown(); // tears every plugin down in dependency order
60
+ ```
61
+
62
+ ## How it works
63
+
64
+ It builds on three core primitives:
65
+
66
+ - **`Server.install` / `uninstall`** — hot-mount/unmount a spec + builders on a live
67
+ server (routes, events, middleware, manifest, docs all refresh).
68
+ - **`localClient(app, spec)`** — the in-process, no-serialization caller behind
69
+ `ctx.deps.<dep>.call`.
70
+ - **`provide`** — inject typed values onto context.
71
+
72
+ The host installs in dependency order, builds each plugin's context from the
73
+ registry, runs `lifecycle.up`, then mounts it. `uninstall` reverses that (drain →
74
+ remove → teardown) and **refuses while a live dependent remains**.
75
+
76
+ ## Handlers in other files
77
+
78
+ For larger codebases, capture the builder in its own `const` and type handlers/
79
+ middleware in other files against `typeof builder` (non-circular, since the builder's
80
+ config carries no implementation), then fold them in with the chain methods:
81
+
82
+ ```ts
83
+ // notes.ts
84
+ export const notesDef = plugin({ name: 'notes', requires: [auth] as const, spec, state });
85
+ import { addNote } from './notes.handlers';
86
+ export const notes = notesDef.handlers((ctx) => ({ addNote: addNote(ctx) }));
87
+
88
+ // notes.handlers.ts
89
+ import type { notesDef } from './notes'; // type-only — no runtime cycle
90
+ export const addNote: PluginHandlers<typeof notesDef>['addNote'] =
91
+ (ctx) => ({ data }) => ctx.deps.auth.state.verify(data.token) ? ctx.state.add(…) : reject(401, 'UNAUTHORIZED');
92
+ ```
93
+
94
+ See **[`ayepi-plugin.md`](./ayepi-plugin.md)** for the full reference, and
95
+ [`examples/08-plugins`](../../examples/08-plugins) for a runnable demo (auth → notes
96
+ → stats, with hot uninstall/reinstall).
97
+
98
+ ## For AI coding agents
99
+
100
+ This package ships dense, machine-oriented reference docs written for **AI coding agents**
101
+ (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
102
+
103
+ - [`ayepi-plugin.md`](./ayepi-plugin.md)
104
+
105
+ They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/plugin) and are **not** shipped in the npm tarball.
106
+
package/dist/index.cjs ADDED
@@ -0,0 +1,167 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _ayepi_core = require("@ayepi/core");
3
+ //#region src/plugin.ts
4
+ /** Build an immutable builder over the given state. */
5
+ function makeBuilder(s) {
6
+ return {
7
+ name: s.name,
8
+ spec: s.spec,
9
+ requires: s.requires,
10
+ middleware(defOrBound, impl) {
11
+ const entry = impl ? {
12
+ def: defOrBound,
13
+ implFactory: impl
14
+ } : { bound: defOrBound };
15
+ return makeBuilder({
16
+ ...s,
17
+ mws: [...s.mws, entry]
18
+ });
19
+ },
20
+ handlers(factory) {
21
+ return makeBuilder({
22
+ ...s,
23
+ handlerFactories: [...s.handlerFactories, factory]
24
+ });
25
+ },
26
+ lifecycle(factory) {
27
+ return makeBuilder({
28
+ ...s,
29
+ lifecycleFactory: factory
30
+ });
31
+ },
32
+ __state: (ctx) => s.stateFactory ? s.stateFactory(ctx) : void 0,
33
+ __implement: (ctx) => {
34
+ let b = (0, _ayepi_core.implement)(s.spec);
35
+ for (const mw of s.mws) b = mw.bound ? b.middleware(mw.bound) : b.middleware(mw.def, mw.implFactory(ctx));
36
+ const bag = {};
37
+ for (const hf of s.handlerFactories) Object.assign(bag, hf(ctx));
38
+ return b.handlers(bag);
39
+ },
40
+ __lifecycle: (ctx) => s.lifecycleFactory ? s.lifecycleFactory(ctx) : {}
41
+ };
42
+ }
43
+ /**
44
+ * Create a plugin **builder** from its config. The builder is inert until a
45
+ * {@link createPluginHost | host} installs it — at which point its `state` is computed,
46
+ * its `lifecycle.up` runs, and its `spec` + handlers are mounted live. Chain
47
+ * `.middleware`/`.handlers`/`.lifecycle` to add the implementation.
48
+ *
49
+ * @typeParam Name - inferred from `config.name`.
50
+ * @typeParam Spec - inferred from `config.spec`.
51
+ * @typeParam State - inferred from `config.state`'s return (`undefined` if omitted).
52
+ * @typeParam Deps - inferred from `config.requires`.
53
+ */
54
+ function plugin(config) {
55
+ return makeBuilder({
56
+ name: config.name,
57
+ spec: config.spec,
58
+ requires: config.requires ?? [],
59
+ stateFactory: config.state,
60
+ mws: [],
61
+ handlerFactories: []
62
+ });
63
+ }
64
+ //#endregion
65
+ //#region src/host.ts
66
+ /**
67
+ * Create a {@link PluginHost} over a running {@link Server} (which may start nearly
68
+ * empty — e.g. `server(spec({ endpoints: {} }), [])` — or carry a core spec).
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * const app = server(spec({ endpoints: {} }), []);
73
+ * const host = createPluginHost(app);
74
+ * await host.install(auth); // a base plugin
75
+ * await host.install(users); // requires auth → installed after it
76
+ * // ... app.fetch(...) now serves both plugins' endpoints ...
77
+ * await host.shutdown();
78
+ * ```
79
+ */
80
+ function createPluginHost(app, opts = {}) {
81
+ const registry = /* @__PURE__ */ new Map();
82
+ const installSpec = app.install;
83
+ /** Report a swallowed teardown error (best-effort — a throwing `onError` is itself ignored). */
84
+ const report = (err, phase, plugin) => {
85
+ try {
86
+ opts.onError?.(err, phase, plugin);
87
+ } catch {}
88
+ };
89
+ /** Run a lifecycle hook so its failure is isolated — reported, not thrown — so teardown of the rest continues. */
90
+ const safe = async (hook, phase, plugin) => {
91
+ try {
92
+ await hook?.();
93
+ } catch (err) {
94
+ report(err, phase, plugin);
95
+ }
96
+ };
97
+ const liveDependents = (name) => [...registry.values()].filter((e) => e.plugin.requires.some((d) => d.name === name)).map((e) => e.plugin.name);
98
+ /** Assemble the `{ deps, emit }` base context for a plugin from the registry. */
99
+ function buildBaseCtx(plugin) {
100
+ const deps = {};
101
+ for (const dep of plugin.requires) {
102
+ const entry = registry.get(dep.name);
103
+ deps[dep.name] = {
104
+ state: entry.state,
105
+ call: (0, _ayepi_core.localClient)(app, dep.spec).call,
106
+ emit: app.emit
107
+ };
108
+ }
109
+ return {
110
+ deps,
111
+ emit: app.emit
112
+ };
113
+ }
114
+ async function install(plugin) {
115
+ if (registry.has(plugin.name)) throw new Error(`plugin "${plugin.name}" is already installed`);
116
+ for (const dep of plugin.requires) if (!registry.has(dep.name)) throw new Error(`plugin "${plugin.name}" requires "${dep.name}", which is not installed`);
117
+ const base = buildBaseCtx(plugin);
118
+ const internals = plugin;
119
+ const state = internals.__state(base);
120
+ const ctx = {
121
+ ...base,
122
+ state
123
+ };
124
+ const builder = internals.__implement(ctx);
125
+ const lc = internals.__lifecycle(ctx);
126
+ await lc.up?.();
127
+ let handle;
128
+ try {
129
+ handle = installSpec(plugin.spec, [builder]);
130
+ } catch (err) {
131
+ await safe(() => lc.stop?.(), "stop", plugin.name);
132
+ throw err;
133
+ }
134
+ registry.set(plugin.name, {
135
+ plugin,
136
+ state,
137
+ lc,
138
+ handle
139
+ });
140
+ }
141
+ async function uninstall(name) {
142
+ const entry = registry.get(name);
143
+ if (!entry) throw new Error(`plugin "${name}" is not installed`);
144
+ const dependents = liveDependents(name);
145
+ if (dependents.length > 0) throw new Error(`cannot uninstall "${name}": still required by ${dependents.map((d) => `"${d}"`).join(", ")}`);
146
+ await safe(() => entry.lc.down?.(), "down", name);
147
+ try {
148
+ app.uninstall(entry.handle);
149
+ } catch (err) {
150
+ report(err, "remove", name);
151
+ }
152
+ await safe(() => entry.lc.stop?.(), "stop", name);
153
+ registry.delete(name);
154
+ }
155
+ async function shutdown() {
156
+ while (registry.size > 0) await uninstall([...registry.keys()].find((n) => liveDependents(n).length === 0));
157
+ }
158
+ return {
159
+ install,
160
+ uninstall,
161
+ installed: () => [...registry.keys()],
162
+ shutdown
163
+ };
164
+ }
165
+ //#endregion
166
+ exports.createPluginHost = createPluginHost;
167
+ exports.plugin = plugin;
@@ -0,0 +1,179 @@
1
+ import { AnyEndpoint, AnyMiddleware, AnySpec, BoundMiddleware, EmitFn, HandlerFor, ImplFor, LocalClient, Server } from "@ayepi/core";
2
+
3
+ //#region src/plugin.d.ts
4
+
5
+ /** Loosest internal function type — replaces the banned `Function`. */
6
+ type AnyFn = (...args: never[]) => unknown;
7
+ /** Lifecycle hooks a plugin runs around install/uninstall, in dependency order. */
8
+ interface Lifecycle {
9
+ /** Start work — runs after the plugin's deps are up, before its endpoints serve. */
10
+ readonly up?: () => void | Promise<void>;
11
+ /** Drain — the pre-stop phase (stop accepting work, finish in-flight). */
12
+ readonly down?: () => void | Promise<void>;
13
+ /** Teardown — the post-stop phase (close resources). */
14
+ readonly stop?: () => void | Promise<void>;
15
+ }
16
+ /**
17
+ * The methods-free structural base every plugin builder satisfies — what the `*Of` /
18
+ * `PluginHandlers` / `CtxOf` helpers match on. Keeping `AnyPlugin` to this shape (no
19
+ * chain methods) is essential: comparing full builders would expand `.middleware`'s
20
+ * `ImplFor<M>` into `ImplFor<AnyMiddleware>` and recurse infinitely.
21
+ */
22
+ interface PluginShape<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> {
23
+ readonly name: Name;
24
+ readonly spec: Spec;
25
+ readonly requires: Deps;
26
+ /** @internal phantom carrier of the state-service type. */
27
+ readonly __state?: State;
28
+ }
29
+ /** Any plugin, erased — the constraint for `requires` lists and the host registry. */
30
+ type AnyPlugin = PluginShape<string, AnySpec, unknown, readonly AnyPlugin[]>;
31
+ /** Extract a plugin's exported state-service type. */
32
+ type StateOf<P> = P extends {
33
+ readonly __state?: infer S;
34
+ } ? S : never;
35
+ /** Extract a plugin's spec. */
36
+ type SpecOf<P> = P extends {
37
+ readonly spec: infer Sp extends AnySpec;
38
+ } ? Sp : never;
39
+ /** Extract a plugin's name. */
40
+ type NameOf<P> = P extends {
41
+ readonly name: infer N extends string;
42
+ } ? N : never;
43
+ /** Extract a plugin's `requires` tuple. */
44
+ type DepsOf<P> = P extends {
45
+ readonly requires: infer D extends readonly AnyPlugin[];
46
+ } ? D : never;
47
+ /** The handle a plugin gets for one of its dependencies. */
48
+ interface DepHandle<P extends AnyPlugin> {
49
+ /** The dependency's exported **state** service (its functions + data). */
50
+ readonly state: StateOf<P>;
51
+ /** Call one of the dependency's endpoints in-process, with just a data payload. */
52
+ readonly call: LocalClient<SpecOf<P>>['call'];
53
+ /** Emit one of the dependency's events. */
54
+ readonly emit: EmitFn<SpecOf<P>>;
55
+ }
56
+ /** The `deps` record on a plugin context — keyed by each required plugin's name. */
57
+ type DepsRecord<Deps extends readonly AnyPlugin[]> = { readonly [P in Deps[number] as NameOf<P>]: DepHandle<P> };
58
+ /** The context passed to a plugin's `state` builder — its dependencies + an `emit` for its own events. */
59
+ interface DepsCtx<Deps extends readonly AnyPlugin[], Spec extends AnySpec> {
60
+ /** Each required plugin's `{ state, call, emit }` handle, keyed by name. */
61
+ readonly deps: DepsRecord<Deps>;
62
+ /** Emit one of this plugin's own events. */
63
+ readonly emit: EmitFn<Spec>;
64
+ }
65
+ /** The context passed to a plugin's `.middleware` / `.handlers` / `.lifecycle` — {@link DepsCtx} plus this plugin's own `state`. */
66
+ interface PluginCtx<Deps extends readonly AnyPlugin[], Spec extends AnySpec, State> extends DepsCtx<Deps, Spec> {
67
+ /** This plugin's own computed `state` service. */
68
+ readonly state: State;
69
+ }
70
+ /** The config for {@link plugin} — the type-defining half (no implementation). */
71
+ interface PluginConfig<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> {
72
+ /** Unique plugin name (and the key dependents reference it by). */
73
+ readonly name: Name;
74
+ /** The plugins this one depends on — available in `ctx.deps` and installed first. */
75
+ readonly requires?: Deps;
76
+ /** The frontend-safe API contract this plugin contributes. */
77
+ readonly spec: Spec;
78
+ /** Build this plugin's exported **state** service from its dependency context (computed once at install). */
79
+ readonly state?: (ctx: DepsCtx<Deps, Spec>) => State;
80
+ }
81
+ /** A partial handler bag for a spec — what a `.handlers((ctx) => …)` factory returns (multiple merge). */
82
+ type PartialHandlers<Spec extends AnySpec> = { readonly [K in keyof Spec['endpoints'] & string]?: HandlerFor<Spec, Spec['endpoints'][K] & AnyEndpoint> };
83
+ /** The ctx-aware impl a `.middleware(def, …)` factory must return for def `M`. */
84
+ type MwImpl<M extends AnyMiddleware> = [AnyMiddleware] extends [M] ? AnyFn : ImplFor<M>;
85
+ /** The prebuilt bound pair a `.middleware(bound)` accepts for def `M`. */
86
+ type MwBound<M extends AnyMiddleware> = [AnyMiddleware] extends [M] ? {
87
+ readonly def: AnyMiddleware;
88
+ readonly impl: AnyFn;
89
+ } : BoundMiddleware<M>;
90
+ /**
91
+ * A plugin builder — the value {@link plugin} returns and the host installs. Chain
92
+ * `.middleware`/`.handlers`/`.lifecycle` (each ctx-aware, returning a new builder).
93
+ * `typeof builder` types out-of-line handlers/middleware.
94
+ *
95
+ * @typeParam Name - the plugin's unique name.
96
+ * @typeParam Spec - its API spec.
97
+ * @typeParam State - its exported state-service type.
98
+ * @typeParam Deps - the plugins it requires.
99
+ */
100
+ interface Plugin<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> extends PluginShape<Name, Spec, State, Deps> {
101
+ /** Bind a middleware def to a ctx-aware impl factory (binds only this plugin's own new middleware). */
102
+ middleware<M extends AnyMiddleware>(def: M, impl: (ctx: PluginCtx<Deps, Spec, State>) => MwImpl<M>): this;
103
+ /** Bind a prebuilt `{ def, impl }` pair (e.g. a package binder like `bearerAuth.server`). */
104
+ middleware<M extends AnyMiddleware>(bound: MwBound<M>): this;
105
+ /** Add a (partial) handler bag built from `ctx` — multiple calls merge. */
106
+ handlers(factory: (ctx: PluginCtx<Deps, Spec, State>) => PartialHandlers<Spec>): this;
107
+ /** Set lifecycle hooks built from `ctx`. */
108
+ lifecycle(factory: (ctx: PluginCtx<Deps, Spec, State>) => Lifecycle): this;
109
+ }
110
+ /** The dependency context type for a plugin `P` — `{ deps, emit, state }`. */
111
+ type CtxOf<P> = PluginCtx<DepsOf<P>, SpecOf<P>, StateOf<P>>;
112
+ /**
113
+ * The handler-factory record for a plugin `P` — one entry per endpoint, each a
114
+ * `(ctx) => handler` closing over the plugin's context. Type out-of-line handlers with
115
+ * `PluginHandlers<typeof p>['name']`.
116
+ */
117
+ type PluginHandlers<P> = { readonly [K in keyof SpecOf<P>['endpoints'] & string]: (ctx: CtxOf<P>) => HandlerFor<SpecOf<P>, SpecOf<P>['endpoints'][K] & AnyEndpoint> };
118
+ /** A single plugin handler factory — `PluginHandler<typeof p, 'addNote'>`. */
119
+ type PluginHandler<P, K extends string> = K extends keyof PluginHandlers<P> ? PluginHandlers<P>[K] : never;
120
+ /**
121
+ * A middleware-impl factory for a plugin `P` and middleware def `M` — a
122
+ * `(ctx) => ImplFor<M>` closing over the plugin's context. Type out-of-line middleware
123
+ * impls with `PluginMiddleware<typeof p, typeof mw>`.
124
+ */
125
+ type PluginMiddleware<P, M extends AnyMiddleware> = (ctx: CtxOf<P>) => ImplFor<M>;
126
+ /** The erased runtime a plugin value carries — read by {@link createPluginHost | the host}. @internal */
127
+
128
+ /**
129
+ * Create a plugin **builder** from its config. The builder is inert until a
130
+ * {@link createPluginHost | host} installs it — at which point its `state` is computed,
131
+ * its `lifecycle.up` runs, and its `spec` + handlers are mounted live. Chain
132
+ * `.middleware`/`.handlers`/`.lifecycle` to add the implementation.
133
+ *
134
+ * @typeParam Name - inferred from `config.name`.
135
+ * @typeParam Spec - inferred from `config.spec`.
136
+ * @typeParam State - inferred from `config.state`'s return (`undefined` if omitted).
137
+ * @typeParam Deps - inferred from `config.requires`.
138
+ */
139
+ declare function plugin<const Name extends string, Spec extends AnySpec, State = undefined, const Deps extends readonly AnyPlugin[] = readonly []>(config: PluginConfig<Name, Spec, State, Deps>): Plugin<Name, Spec, State, Deps>;
140
+ //#endregion
141
+ //#region src/host.d.ts
142
+ /** Options for {@link createPluginHost}. */
143
+ interface PluginHostOptions {
144
+ /**
145
+ * Observe an error thrown while **tearing a plugin down** — a `down`/`stop` lifecycle hook,
146
+ * or removing its routes. Teardown is best-effort: such an error is swallowed so it can't
147
+ * strand the plugin half-removed or abort `shutdown` of the others; this hook lets you notice.
148
+ * `phase` is `'down'`, `'stop'`, or `'remove'`. Off by default; it must not throw.
149
+ */
150
+ readonly onError?: (err: unknown, phase: 'down' | 'stop' | 'remove', plugin: string) => void;
151
+ }
152
+ /** Manages plugins installed into a running server — see {@link createPluginHost}. */
153
+ interface PluginHost {
154
+ /** Install a plugin (its `requires` must already be installed). Builds its ctx/state, runs `up`, mounts it live. */
155
+ install(plugin: AnyPlugin): Promise<void>;
156
+ /** Uninstall a plugin by name — drains (`down`), removes its routes/events, then tears down (`stop`). Throws if a live dependent remains. */
157
+ uninstall(name: string): Promise<void>;
158
+ /** The names of currently-installed plugins, in install order. */
159
+ installed(): readonly string[];
160
+ /** Uninstall every plugin in dependency-safe order (dependents before their deps). */
161
+ shutdown(): Promise<void>;
162
+ }
163
+ /**
164
+ * Create a {@link PluginHost} over a running {@link Server} (which may start nearly
165
+ * empty — e.g. `server(spec({ endpoints: {} }), [])` — or carry a core spec).
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * const app = server(spec({ endpoints: {} }), []);
170
+ * const host = createPluginHost(app);
171
+ * await host.install(auth); // a base plugin
172
+ * await host.install(users); // requires auth → installed after it
173
+ * // ... app.fetch(...) now serves both plugins' endpoints ...
174
+ * await host.shutdown();
175
+ * ```
176
+ */
177
+ declare function createPluginHost(app: Server<AnySpec>, opts?: PluginHostOptions): PluginHost;
178
+ //#endregion
179
+ export { type AnyPlugin, type CtxOf, type DepHandle, type DepsCtx, type DepsRecord, type Lifecycle, type NameOf, type PartialHandlers, type Plugin, type PluginConfig, type PluginCtx, type PluginHandler, type PluginHandlers, type PluginHost, type PluginHostOptions, type PluginMiddleware, type PluginShape, type SpecOf, type StateOf, createPluginHost, plugin };
@@ -0,0 +1,179 @@
1
+ import { AnyEndpoint, AnyMiddleware, AnySpec, BoundMiddleware, EmitFn, HandlerFor, ImplFor, LocalClient, Server } from "@ayepi/core";
2
+
3
+ //#region src/plugin.d.ts
4
+
5
+ /** Loosest internal function type — replaces the banned `Function`. */
6
+ type AnyFn = (...args: never[]) => unknown;
7
+ /** Lifecycle hooks a plugin runs around install/uninstall, in dependency order. */
8
+ interface Lifecycle {
9
+ /** Start work — runs after the plugin's deps are up, before its endpoints serve. */
10
+ readonly up?: () => void | Promise<void>;
11
+ /** Drain — the pre-stop phase (stop accepting work, finish in-flight). */
12
+ readonly down?: () => void | Promise<void>;
13
+ /** Teardown — the post-stop phase (close resources). */
14
+ readonly stop?: () => void | Promise<void>;
15
+ }
16
+ /**
17
+ * The methods-free structural base every plugin builder satisfies — what the `*Of` /
18
+ * `PluginHandlers` / `CtxOf` helpers match on. Keeping `AnyPlugin` to this shape (no
19
+ * chain methods) is essential: comparing full builders would expand `.middleware`'s
20
+ * `ImplFor<M>` into `ImplFor<AnyMiddleware>` and recurse infinitely.
21
+ */
22
+ interface PluginShape<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> {
23
+ readonly name: Name;
24
+ readonly spec: Spec;
25
+ readonly requires: Deps;
26
+ /** @internal phantom carrier of the state-service type. */
27
+ readonly __state?: State;
28
+ }
29
+ /** Any plugin, erased — the constraint for `requires` lists and the host registry. */
30
+ type AnyPlugin = PluginShape<string, AnySpec, unknown, readonly AnyPlugin[]>;
31
+ /** Extract a plugin's exported state-service type. */
32
+ type StateOf<P> = P extends {
33
+ readonly __state?: infer S;
34
+ } ? S : never;
35
+ /** Extract a plugin's spec. */
36
+ type SpecOf<P> = P extends {
37
+ readonly spec: infer Sp extends AnySpec;
38
+ } ? Sp : never;
39
+ /** Extract a plugin's name. */
40
+ type NameOf<P> = P extends {
41
+ readonly name: infer N extends string;
42
+ } ? N : never;
43
+ /** Extract a plugin's `requires` tuple. */
44
+ type DepsOf<P> = P extends {
45
+ readonly requires: infer D extends readonly AnyPlugin[];
46
+ } ? D : never;
47
+ /** The handle a plugin gets for one of its dependencies. */
48
+ interface DepHandle<P extends AnyPlugin> {
49
+ /** The dependency's exported **state** service (its functions + data). */
50
+ readonly state: StateOf<P>;
51
+ /** Call one of the dependency's endpoints in-process, with just a data payload. */
52
+ readonly call: LocalClient<SpecOf<P>>['call'];
53
+ /** Emit one of the dependency's events. */
54
+ readonly emit: EmitFn<SpecOf<P>>;
55
+ }
56
+ /** The `deps` record on a plugin context — keyed by each required plugin's name. */
57
+ type DepsRecord<Deps extends readonly AnyPlugin[]> = { readonly [P in Deps[number] as NameOf<P>]: DepHandle<P> };
58
+ /** The context passed to a plugin's `state` builder — its dependencies + an `emit` for its own events. */
59
+ interface DepsCtx<Deps extends readonly AnyPlugin[], Spec extends AnySpec> {
60
+ /** Each required plugin's `{ state, call, emit }` handle, keyed by name. */
61
+ readonly deps: DepsRecord<Deps>;
62
+ /** Emit one of this plugin's own events. */
63
+ readonly emit: EmitFn<Spec>;
64
+ }
65
+ /** The context passed to a plugin's `.middleware` / `.handlers` / `.lifecycle` — {@link DepsCtx} plus this plugin's own `state`. */
66
+ interface PluginCtx<Deps extends readonly AnyPlugin[], Spec extends AnySpec, State> extends DepsCtx<Deps, Spec> {
67
+ /** This plugin's own computed `state` service. */
68
+ readonly state: State;
69
+ }
70
+ /** The config for {@link plugin} — the type-defining half (no implementation). */
71
+ interface PluginConfig<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> {
72
+ /** Unique plugin name (and the key dependents reference it by). */
73
+ readonly name: Name;
74
+ /** The plugins this one depends on — available in `ctx.deps` and installed first. */
75
+ readonly requires?: Deps;
76
+ /** The frontend-safe API contract this plugin contributes. */
77
+ readonly spec: Spec;
78
+ /** Build this plugin's exported **state** service from its dependency context (computed once at install). */
79
+ readonly state?: (ctx: DepsCtx<Deps, Spec>) => State;
80
+ }
81
+ /** A partial handler bag for a spec — what a `.handlers((ctx) => …)` factory returns (multiple merge). */
82
+ type PartialHandlers<Spec extends AnySpec> = { readonly [K in keyof Spec['endpoints'] & string]?: HandlerFor<Spec, Spec['endpoints'][K] & AnyEndpoint> };
83
+ /** The ctx-aware impl a `.middleware(def, …)` factory must return for def `M`. */
84
+ type MwImpl<M extends AnyMiddleware> = [AnyMiddleware] extends [M] ? AnyFn : ImplFor<M>;
85
+ /** The prebuilt bound pair a `.middleware(bound)` accepts for def `M`. */
86
+ type MwBound<M extends AnyMiddleware> = [AnyMiddleware] extends [M] ? {
87
+ readonly def: AnyMiddleware;
88
+ readonly impl: AnyFn;
89
+ } : BoundMiddleware<M>;
90
+ /**
91
+ * A plugin builder — the value {@link plugin} returns and the host installs. Chain
92
+ * `.middleware`/`.handlers`/`.lifecycle` (each ctx-aware, returning a new builder).
93
+ * `typeof builder` types out-of-line handlers/middleware.
94
+ *
95
+ * @typeParam Name - the plugin's unique name.
96
+ * @typeParam Spec - its API spec.
97
+ * @typeParam State - its exported state-service type.
98
+ * @typeParam Deps - the plugins it requires.
99
+ */
100
+ interface Plugin<Name extends string, Spec extends AnySpec, State, Deps extends readonly AnyPlugin[]> extends PluginShape<Name, Spec, State, Deps> {
101
+ /** Bind a middleware def to a ctx-aware impl factory (binds only this plugin's own new middleware). */
102
+ middleware<M extends AnyMiddleware>(def: M, impl: (ctx: PluginCtx<Deps, Spec, State>) => MwImpl<M>): this;
103
+ /** Bind a prebuilt `{ def, impl }` pair (e.g. a package binder like `bearerAuth.server`). */
104
+ middleware<M extends AnyMiddleware>(bound: MwBound<M>): this;
105
+ /** Add a (partial) handler bag built from `ctx` — multiple calls merge. */
106
+ handlers(factory: (ctx: PluginCtx<Deps, Spec, State>) => PartialHandlers<Spec>): this;
107
+ /** Set lifecycle hooks built from `ctx`. */
108
+ lifecycle(factory: (ctx: PluginCtx<Deps, Spec, State>) => Lifecycle): this;
109
+ }
110
+ /** The dependency context type for a plugin `P` — `{ deps, emit, state }`. */
111
+ type CtxOf<P> = PluginCtx<DepsOf<P>, SpecOf<P>, StateOf<P>>;
112
+ /**
113
+ * The handler-factory record for a plugin `P` — one entry per endpoint, each a
114
+ * `(ctx) => handler` closing over the plugin's context. Type out-of-line handlers with
115
+ * `PluginHandlers<typeof p>['name']`.
116
+ */
117
+ type PluginHandlers<P> = { readonly [K in keyof SpecOf<P>['endpoints'] & string]: (ctx: CtxOf<P>) => HandlerFor<SpecOf<P>, SpecOf<P>['endpoints'][K] & AnyEndpoint> };
118
+ /** A single plugin handler factory — `PluginHandler<typeof p, 'addNote'>`. */
119
+ type PluginHandler<P, K extends string> = K extends keyof PluginHandlers<P> ? PluginHandlers<P>[K] : never;
120
+ /**
121
+ * A middleware-impl factory for a plugin `P` and middleware def `M` — a
122
+ * `(ctx) => ImplFor<M>` closing over the plugin's context. Type out-of-line middleware
123
+ * impls with `PluginMiddleware<typeof p, typeof mw>`.
124
+ */
125
+ type PluginMiddleware<P, M extends AnyMiddleware> = (ctx: CtxOf<P>) => ImplFor<M>;
126
+ /** The erased runtime a plugin value carries — read by {@link createPluginHost | the host}. @internal */
127
+
128
+ /**
129
+ * Create a plugin **builder** from its config. The builder is inert until a
130
+ * {@link createPluginHost | host} installs it — at which point its `state` is computed,
131
+ * its `lifecycle.up` runs, and its `spec` + handlers are mounted live. Chain
132
+ * `.middleware`/`.handlers`/`.lifecycle` to add the implementation.
133
+ *
134
+ * @typeParam Name - inferred from `config.name`.
135
+ * @typeParam Spec - inferred from `config.spec`.
136
+ * @typeParam State - inferred from `config.state`'s return (`undefined` if omitted).
137
+ * @typeParam Deps - inferred from `config.requires`.
138
+ */
139
+ declare function plugin<const Name extends string, Spec extends AnySpec, State = undefined, const Deps extends readonly AnyPlugin[] = readonly []>(config: PluginConfig<Name, Spec, State, Deps>): Plugin<Name, Spec, State, Deps>;
140
+ //#endregion
141
+ //#region src/host.d.ts
142
+ /** Options for {@link createPluginHost}. */
143
+ interface PluginHostOptions {
144
+ /**
145
+ * Observe an error thrown while **tearing a plugin down** — a `down`/`stop` lifecycle hook,
146
+ * or removing its routes. Teardown is best-effort: such an error is swallowed so it can't
147
+ * strand the plugin half-removed or abort `shutdown` of the others; this hook lets you notice.
148
+ * `phase` is `'down'`, `'stop'`, or `'remove'`. Off by default; it must not throw.
149
+ */
150
+ readonly onError?: (err: unknown, phase: 'down' | 'stop' | 'remove', plugin: string) => void;
151
+ }
152
+ /** Manages plugins installed into a running server — see {@link createPluginHost}. */
153
+ interface PluginHost {
154
+ /** Install a plugin (its `requires` must already be installed). Builds its ctx/state, runs `up`, mounts it live. */
155
+ install(plugin: AnyPlugin): Promise<void>;
156
+ /** Uninstall a plugin by name — drains (`down`), removes its routes/events, then tears down (`stop`). Throws if a live dependent remains. */
157
+ uninstall(name: string): Promise<void>;
158
+ /** The names of currently-installed plugins, in install order. */
159
+ installed(): readonly string[];
160
+ /** Uninstall every plugin in dependency-safe order (dependents before their deps). */
161
+ shutdown(): Promise<void>;
162
+ }
163
+ /**
164
+ * Create a {@link PluginHost} over a running {@link Server} (which may start nearly
165
+ * empty — e.g. `server(spec({ endpoints: {} }), [])` — or carry a core spec).
166
+ *
167
+ * @example
168
+ * ```ts
169
+ * const app = server(spec({ endpoints: {} }), []);
170
+ * const host = createPluginHost(app);
171
+ * await host.install(auth); // a base plugin
172
+ * await host.install(users); // requires auth → installed after it
173
+ * // ... app.fetch(...) now serves both plugins' endpoints ...
174
+ * await host.shutdown();
175
+ * ```
176
+ */
177
+ declare function createPluginHost(app: Server<AnySpec>, opts?: PluginHostOptions): PluginHost;
178
+ //#endregion
179
+ export { type AnyPlugin, type CtxOf, type DepHandle, type DepsCtx, type DepsRecord, type Lifecycle, type NameOf, type PartialHandlers, type Plugin, type PluginConfig, type PluginCtx, type PluginHandler, type PluginHandlers, type PluginHost, type PluginHostOptions, type PluginMiddleware, type PluginShape, type SpecOf, type StateOf, createPluginHost, plugin };
package/dist/index.js ADDED
@@ -0,0 +1,165 @@
1
+ import { implement, localClient } from "@ayepi/core";
2
+ //#region src/plugin.ts
3
+ /** Build an immutable builder over the given state. */
4
+ function makeBuilder(s) {
5
+ return {
6
+ name: s.name,
7
+ spec: s.spec,
8
+ requires: s.requires,
9
+ middleware(defOrBound, impl) {
10
+ const entry = impl ? {
11
+ def: defOrBound,
12
+ implFactory: impl
13
+ } : { bound: defOrBound };
14
+ return makeBuilder({
15
+ ...s,
16
+ mws: [...s.mws, entry]
17
+ });
18
+ },
19
+ handlers(factory) {
20
+ return makeBuilder({
21
+ ...s,
22
+ handlerFactories: [...s.handlerFactories, factory]
23
+ });
24
+ },
25
+ lifecycle(factory) {
26
+ return makeBuilder({
27
+ ...s,
28
+ lifecycleFactory: factory
29
+ });
30
+ },
31
+ __state: (ctx) => s.stateFactory ? s.stateFactory(ctx) : void 0,
32
+ __implement: (ctx) => {
33
+ let b = implement(s.spec);
34
+ for (const mw of s.mws) b = mw.bound ? b.middleware(mw.bound) : b.middleware(mw.def, mw.implFactory(ctx));
35
+ const bag = {};
36
+ for (const hf of s.handlerFactories) Object.assign(bag, hf(ctx));
37
+ return b.handlers(bag);
38
+ },
39
+ __lifecycle: (ctx) => s.lifecycleFactory ? s.lifecycleFactory(ctx) : {}
40
+ };
41
+ }
42
+ /**
43
+ * Create a plugin **builder** from its config. The builder is inert until a
44
+ * {@link createPluginHost | host} installs it — at which point its `state` is computed,
45
+ * its `lifecycle.up` runs, and its `spec` + handlers are mounted live. Chain
46
+ * `.middleware`/`.handlers`/`.lifecycle` to add the implementation.
47
+ *
48
+ * @typeParam Name - inferred from `config.name`.
49
+ * @typeParam Spec - inferred from `config.spec`.
50
+ * @typeParam State - inferred from `config.state`'s return (`undefined` if omitted).
51
+ * @typeParam Deps - inferred from `config.requires`.
52
+ */
53
+ function plugin(config) {
54
+ return makeBuilder({
55
+ name: config.name,
56
+ spec: config.spec,
57
+ requires: config.requires ?? [],
58
+ stateFactory: config.state,
59
+ mws: [],
60
+ handlerFactories: []
61
+ });
62
+ }
63
+ //#endregion
64
+ //#region src/host.ts
65
+ /**
66
+ * Create a {@link PluginHost} over a running {@link Server} (which may start nearly
67
+ * empty — e.g. `server(spec({ endpoints: {} }), [])` — or carry a core spec).
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * const app = server(spec({ endpoints: {} }), []);
72
+ * const host = createPluginHost(app);
73
+ * await host.install(auth); // a base plugin
74
+ * await host.install(users); // requires auth → installed after it
75
+ * // ... app.fetch(...) now serves both plugins' endpoints ...
76
+ * await host.shutdown();
77
+ * ```
78
+ */
79
+ function createPluginHost(app, opts = {}) {
80
+ const registry = /* @__PURE__ */ new Map();
81
+ const installSpec = app.install;
82
+ /** Report a swallowed teardown error (best-effort — a throwing `onError` is itself ignored). */
83
+ const report = (err, phase, plugin) => {
84
+ try {
85
+ opts.onError?.(err, phase, plugin);
86
+ } catch {}
87
+ };
88
+ /** Run a lifecycle hook so its failure is isolated — reported, not thrown — so teardown of the rest continues. */
89
+ const safe = async (hook, phase, plugin) => {
90
+ try {
91
+ await hook?.();
92
+ } catch (err) {
93
+ report(err, phase, plugin);
94
+ }
95
+ };
96
+ const liveDependents = (name) => [...registry.values()].filter((e) => e.plugin.requires.some((d) => d.name === name)).map((e) => e.plugin.name);
97
+ /** Assemble the `{ deps, emit }` base context for a plugin from the registry. */
98
+ function buildBaseCtx(plugin) {
99
+ const deps = {};
100
+ for (const dep of plugin.requires) {
101
+ const entry = registry.get(dep.name);
102
+ deps[dep.name] = {
103
+ state: entry.state,
104
+ call: localClient(app, dep.spec).call,
105
+ emit: app.emit
106
+ };
107
+ }
108
+ return {
109
+ deps,
110
+ emit: app.emit
111
+ };
112
+ }
113
+ async function install(plugin) {
114
+ if (registry.has(plugin.name)) throw new Error(`plugin "${plugin.name}" is already installed`);
115
+ for (const dep of plugin.requires) if (!registry.has(dep.name)) throw new Error(`plugin "${plugin.name}" requires "${dep.name}", which is not installed`);
116
+ const base = buildBaseCtx(plugin);
117
+ const internals = plugin;
118
+ const state = internals.__state(base);
119
+ const ctx = {
120
+ ...base,
121
+ state
122
+ };
123
+ const builder = internals.__implement(ctx);
124
+ const lc = internals.__lifecycle(ctx);
125
+ await lc.up?.();
126
+ let handle;
127
+ try {
128
+ handle = installSpec(plugin.spec, [builder]);
129
+ } catch (err) {
130
+ await safe(() => lc.stop?.(), "stop", plugin.name);
131
+ throw err;
132
+ }
133
+ registry.set(plugin.name, {
134
+ plugin,
135
+ state,
136
+ lc,
137
+ handle
138
+ });
139
+ }
140
+ async function uninstall(name) {
141
+ const entry = registry.get(name);
142
+ if (!entry) throw new Error(`plugin "${name}" is not installed`);
143
+ const dependents = liveDependents(name);
144
+ if (dependents.length > 0) throw new Error(`cannot uninstall "${name}": still required by ${dependents.map((d) => `"${d}"`).join(", ")}`);
145
+ await safe(() => entry.lc.down?.(), "down", name);
146
+ try {
147
+ app.uninstall(entry.handle);
148
+ } catch (err) {
149
+ report(err, "remove", name);
150
+ }
151
+ await safe(() => entry.lc.stop?.(), "stop", name);
152
+ registry.delete(name);
153
+ }
154
+ async function shutdown() {
155
+ while (registry.size > 0) await uninstall([...registry.keys()].find((n) => liveDependents(n).length === 0));
156
+ }
157
+ return {
158
+ install,
159
+ uninstall,
160
+ installed: () => [...registry.keys()],
161
+ shutdown
162
+ };
163
+ }
164
+ //#endregion
165
+ export { createPluginHost, plugin };
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@ayepi/plugin",
3
+ "version": "0.1.0",
4
+ "description": "A plugin system for @ayepi/core — compose an API from independent plugins (spec + implementation + state service + lifecycle + deps) and install/uninstall them into a running server",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/ClickerMonkey/ayepi.git",
12
+ "directory": "packages/plugin"
13
+ },
14
+ "homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/plugin#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/ClickerMonkey/ayepi/issues"
17
+ },
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "import": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "require": {
30
+ "types": "./dist/index.d.cts",
31
+ "default": "./dist/index.cjs"
32
+ }
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "peerDependencies": {
40
+ "@ayepi/core": "^0.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@vitest/coverage-v8": "^2.1.8",
44
+ "publint": "^0.3.0",
45
+ "tsdown": "^0.12.0",
46
+ "vitest": "^2.1.8",
47
+ "zod": "^4.4.3",
48
+ "@ayepi/core": "0.1.0"
49
+ },
50
+ "keywords": [
51
+ "ayepi",
52
+ "plugin",
53
+ "modular",
54
+ "plugins",
55
+ "lifecycle",
56
+ "dependency-injection",
57
+ "hot-reload"
58
+ ],
59
+ "scripts": {
60
+ "build": "tsdown",
61
+ "typecheck": "tsc --noEmit",
62
+ "test": "vitest run --coverage",
63
+ "publint": "publint"
64
+ }
65
+ }