@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 +21 -0
- package/README.md +106 -0
- package/dist/index.cjs +167 -0
- package/dist/index.d.cts +179 -0
- package/dist/index.d.ts +179 -0
- package/dist/index.js +165 -0
- package/package.json +65 -0
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;
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|