@ayepi/plugin 0.1.0 → 0.2.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/README.md +1 -1
- package/ayepi-plugin.md +219 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -102,5 +102,5 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
102
102
|
|
|
103
103
|
- [`ayepi-plugin.md`](./ayepi-plugin.md)
|
|
104
104
|
|
|
105
|
-
They
|
|
105
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/plugin).
|
|
106
106
|
|
package/ayepi-plugin.md
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# @ayepi/plugin — reference
|
|
2
|
+
|
|
3
|
+
A plugin system layered on `@ayepi/core`: compose an API from independent **plugins**
|
|
4
|
+
and install/uninstall them into a **running** server, in dependency order, with each
|
|
5
|
+
plugin's dependency context, state service, and lifecycle wired up.
|
|
6
|
+
|
|
7
|
+
This doc is the authoritative reference. For the core primitives it builds on, see
|
|
8
|
+
`ayepi-core.md` (`Server.install`/`uninstall`, `localClient`, `provide`).
|
|
9
|
+
|
|
10
|
+
## Mental model
|
|
11
|
+
|
|
12
|
+
A **plugin** is a self-contained slice of an API:
|
|
13
|
+
|
|
14
|
+
| field / method | what it is |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `name` | unique id; the key dependents reference it by (config) |
|
|
17
|
+
| `spec` | the frontend-safe API contract (a normal `spec()`) (config) |
|
|
18
|
+
| `state(ctx)?` | a **service object** (functions + data) that dependents call directly (config) |
|
|
19
|
+
| `requires?` | the plugins it depends on (config) |
|
|
20
|
+
| `.handlers((ctx) => …)` | a (partial) handler bag — multiple calls merge |
|
|
21
|
+
| `.middleware(def, (ctx) => impl)` / `.middleware(bound)` | bind this plugin's own middleware |
|
|
22
|
+
| `.lifecycle((ctx) => …)` | `up` / `down` (drain) / `stop` (teardown) hooks |
|
|
23
|
+
|
|
24
|
+
`plugin({ name, requires, spec, state })` returns a **builder**; chain the ctx-aware
|
|
25
|
+
`.handlers` / `.middleware` / `.lifecycle` methods (mirroring core's `implement()`).
|
|
26
|
+
Each returns a new builder, so a plugin is just `plugin(config).handlers(…)….lifecycle(…)`.
|
|
27
|
+
|
|
28
|
+
A **host** (`createPluginHost(app)`) installs plugins into one running `Server`. It
|
|
29
|
+
resolves dependencies, builds each plugin's **context**, runs `lifecycle.up`, then
|
|
30
|
+
hot-mounts its spec + handlers via `Server.install`.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
plugin({ name, requires, spec, state }).middleware(…).handlers(…).lifecycle(…) ── a builder value
|
|
34
|
+
createPluginHost(app)
|
|
35
|
+
.install(p) → require deps installed → build ctx → state(ctx) → up() → app.install(spec, [impl(ctx)])
|
|
36
|
+
.uninstall(n) → refuse if a live dependent → down() → app.uninstall(handle) → stop()
|
|
37
|
+
.shutdown() → uninstall all, dependents first
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## `plugin(config)` → a builder
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
const users = plugin({
|
|
44
|
+
name: 'users',
|
|
45
|
+
requires: [auth] as const, // typed dependency plugins
|
|
46
|
+
spec: usersSpec,
|
|
47
|
+
state: (ctx) => ({ find: (id: string) => store.get(id) }), // ctx: { deps, emit }
|
|
48
|
+
})
|
|
49
|
+
.middleware(localMw, (ctx) => localImpl) // ctx: { deps, emit, state }; binds only this plugin's own middleware
|
|
50
|
+
.handlers((ctx) => ({
|
|
51
|
+
me: ({ data }) => ctx.deps.auth.state.requireUser(data.token), // a dep's state service
|
|
52
|
+
list: () => ctx.state.allUsers(), // this plugin's own state
|
|
53
|
+
}))
|
|
54
|
+
.lifecycle((ctx) => ({ up: () => store.connect(), stop: () => store.close() }));
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`plugin()` is pure — it packages the config and the chained factories. The plugin is
|
|
58
|
+
inert until a host installs it. All type parameters are inferred
|
|
59
|
+
(`Name`/`Spec`/`State`/`Deps`). Because the builder carries no implementation in its
|
|
60
|
+
*config*, `typeof users` is non-circular — so handlers/middleware can be typed against
|
|
61
|
+
it in other files (see below).
|
|
62
|
+
|
|
63
|
+
### The context (`ctx`)
|
|
64
|
+
|
|
65
|
+
Every callback receives a context built from the host's registry:
|
|
66
|
+
|
|
67
|
+
- **`ctx.deps.<name>`** — for each plugin in `requires`, a `{ state, call, emit }`
|
|
68
|
+
handle:
|
|
69
|
+
- `state` — that dependency's exported state service (its functions/data).
|
|
70
|
+
- `call(name, data, opts?)` — call one of its endpoints **in-process** (full chain
|
|
71
|
+
+ validation, no HTTP), typed against its spec. Backed by core's `localClient`.
|
|
72
|
+
- `emit(event, …)` — emit one of its events, typed against its spec.
|
|
73
|
+
- **`ctx.emit(event, …)`** — emit **this** plugin's own events.
|
|
74
|
+
- **`ctx.state`** — this plugin's own computed `state` (on `.handlers`/`.middleware`/
|
|
75
|
+
`.lifecycle`; the `state` factory itself runs with just `{ deps, emit }`).
|
|
76
|
+
|
|
77
|
+
Handlers close over `ctx` lexically, so they reach deps/state with no middleware
|
|
78
|
+
plumbing. (If *middleware* — not just handlers — needs the context, inject it with
|
|
79
|
+
core's `provide`.)
|
|
80
|
+
|
|
81
|
+
### State: the "better private functions"
|
|
82
|
+
|
|
83
|
+
`state(ctx)` returns a service object that dependents consume via `ctx.deps.<you>.state`.
|
|
84
|
+
It is computed **once** at install and memoized; a plugin installed later gets the live
|
|
85
|
+
reference. Use it for internal logic dependents should call directly (no endpoint, no
|
|
86
|
+
auth) — the typed replacement for ad-hoc private exports. Use **endpoint calls**
|
|
87
|
+
(`ctx.deps.<dep>.call`) when you genuinely want the dependency's public endpoint
|
|
88
|
+
behavior (its validation + middleware run; pass `opts.headers` if it needs auth).
|
|
89
|
+
|
|
90
|
+
## Defining handlers & middleware in other files
|
|
91
|
+
|
|
92
|
+
In a larger codebase you won't write every handler inline. Because `plugin(config)`
|
|
93
|
+
returns a builder that carries **no implementation in its config**, `typeof builder` is
|
|
94
|
+
non-circular — so you can type out-of-line handlers and middleware impls against it and
|
|
95
|
+
fold them in with the chain methods. (Capture the builder in its own `const` first, so
|
|
96
|
+
the handler files import a stable `typeof`.)
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// notes.ts
|
|
100
|
+
export const notesDef = plugin({
|
|
101
|
+
name: 'notes',
|
|
102
|
+
requires: [auth] as const,
|
|
103
|
+
spec: notesSpec,
|
|
104
|
+
state: (): NotesService => ({ count: …, add: …, all: … }), // encapsulate state here
|
|
105
|
+
});
|
|
106
|
+
import { addNote, listNotes } from './notes.handlers';
|
|
107
|
+
export const notes = notesDef
|
|
108
|
+
.middleware(mw, mwImpl) // (ctx) => impl, typed via PluginMiddleware
|
|
109
|
+
.handlers((ctx) => ({ addNote: addNote(ctx), listNotes: listNotes(ctx) }))
|
|
110
|
+
.lifecycle(() => ({ up, stop }));
|
|
111
|
+
|
|
112
|
+
// notes.handlers.ts
|
|
113
|
+
import type { notesDef } from './notes'; // type-only → no runtime cycle
|
|
114
|
+
import type { PluginHandlers } from '@ayepi/plugin';
|
|
115
|
+
type H = PluginHandlers<typeof notesDef>;
|
|
116
|
+
|
|
117
|
+
export const addNote: H['addNote'] = (ctx) => ({ data }) => {
|
|
118
|
+
const author = ctx.deps.auth.state.verify(data.token); // ← the `auth` dependency, fully typed
|
|
119
|
+
const note = ctx.state.add(data.text, author!); // ← this plugin's own state
|
|
120
|
+
ctx.emit('noteAdded', note); // ← this plugin's own event
|
|
121
|
+
return note;
|
|
122
|
+
};
|
|
123
|
+
export const listNotes: H['listNotes'] = (ctx) => () => ctx.state.all();
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The helper types:
|
|
127
|
+
|
|
128
|
+
| type | what it is |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
| `PluginHandlers<typeof def>` | a record `{ [endpoint]: (ctx) => Handler }` — index it: `['addNote']` |
|
|
131
|
+
| `PluginHandler<typeof def, 'addNote'>` | a single handler factory `(ctx) => Handler` |
|
|
132
|
+
| `PluginMiddleware<typeof def, typeof mw>` | a middleware-impl factory `(ctx) => ImplFor<mw>` for the def `mw` |
|
|
133
|
+
| `CtxOf<typeof def>` | the plugin's context type (`{ deps, emit, state }`) |
|
|
134
|
+
|
|
135
|
+
A handler/middleware factory takes the plugin's `ctx` and returns the actual
|
|
136
|
+
handler/impl; you apply it (`addNote(ctx)`) inside `.handlers`, or hand a
|
|
137
|
+
`PluginMiddleware` factory straight to `.middleware(def, …)`. The same types work on a
|
|
138
|
+
finished plugin value too (`PluginHandlers<typeof notes>`). Keep encapsulated state
|
|
139
|
+
(stores, clients) inside `state(ctx)` and reach it from handlers via `ctx.state`.
|
|
140
|
+
|
|
141
|
+
## `createPluginHost(app)`
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const app = server(spec({ endpoints: {} }), []); // boot (nearly) empty, or carry a core spec
|
|
145
|
+
const host = createPluginHost(app);
|
|
146
|
+
|
|
147
|
+
await host.install(auth);
|
|
148
|
+
await host.install(users); // requires auth → must be installed first (else throws)
|
|
149
|
+
host.installed(); // ['auth', 'users']
|
|
150
|
+
await host.uninstall('users');
|
|
151
|
+
await host.shutdown(); // uninstall everything, dependents before deps
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| method | behavior |
|
|
155
|
+
| --- | --- |
|
|
156
|
+
| `install(plugin)` | requires deps installed; builds ctx + `state`; runs `up`; `app.install`s the spec. On a mount error, runs `stop` to roll back. Throws on duplicate name or missing dependency. |
|
|
157
|
+
| `uninstall(name)` | refuses while a live dependent remains; runs `down` → `app.uninstall` → `stop`. Throws if not installed. |
|
|
158
|
+
| `installed()` | the installed plugin names, in install order. |
|
|
159
|
+
| `shutdown()` | uninstalls every plugin in dependency-safe order (dependents first). |
|
|
160
|
+
|
|
161
|
+
Install/uninstall are `async` (lifecycle hooks may be async).
|
|
162
|
+
|
|
163
|
+
**Teardown is isolated.** A `down`/`stop` hook (or route removal) that throws during
|
|
164
|
+
`uninstall`/`shutdown` can't strand a plugin half-removed or abort teardown of the others —
|
|
165
|
+
each step runs and the plugin is always removed from the registry. An install rollback
|
|
166
|
+
surfaces the **original mount error** even if the rollback `stop` throws. Pass an observer to
|
|
167
|
+
notice these swallowed failures: `createPluginHost(app, { onError: (err, phase, plugin) => … })`
|
|
168
|
+
where `phase` is `'down' | 'stop' | 'remove'` (off by default; it must not throw).
|
|
169
|
+
|
|
170
|
+
## Hot install/uninstall
|
|
171
|
+
|
|
172
|
+
Installing/uninstalling happens on the **live** server: `Server.install` adds the
|
|
173
|
+
plugin's endpoints, events, routes, and middleware and refreshes the manifest +
|
|
174
|
+
OpenAPI/AsyncAPI caches; `uninstall` removes exactly them and clears their ws
|
|
175
|
+
subscriptions. A request to an uninstalled route gets a normal `404`. The rest of the
|
|
176
|
+
server keeps serving throughout.
|
|
177
|
+
|
|
178
|
+
## Shared middleware across plugins
|
|
179
|
+
|
|
180
|
+
If two plugins share a middleware (e.g. a common `auth` def), **import the same def
|
|
181
|
+
object** and **bind it once** — in the owning plugin's `implement`. A dependent
|
|
182
|
+
plugin that uses the shared def in its chain does **not** re-bind it; the server's
|
|
183
|
+
global impl map resolves it. Re-binding the same def throws `duplicate implementation`.
|
|
184
|
+
|
|
185
|
+
## Events
|
|
186
|
+
|
|
187
|
+
`ctx.emit` and `ctx.deps.<dep>.emit` both delegate to the server's runtime `emit`
|
|
188
|
+
(global by event name). The host guarantees event-name uniqueness across plugins via
|
|
189
|
+
the install-time collision check. Uninstalling a plugin clears its event channels'
|
|
190
|
+
subscriptions.
|
|
191
|
+
|
|
192
|
+
## Collisions & failure modes
|
|
193
|
+
|
|
194
|
+
`install` throws on: a duplicate plugin name, a missing dependency, or a spec that
|
|
195
|
+
collides with the live server — a duplicate **endpoint name**, **`METHOD path`**,
|
|
196
|
+
**ws id**, or **event channel**. `uninstall` throws if the plugin isn't installed or
|
|
197
|
+
still has a live dependent. A plugin whose `lifecycle.up` succeeds but whose mount
|
|
198
|
+
fails has its `stop` run (rollback) and is not registered.
|
|
199
|
+
|
|
200
|
+
> Namespacing across plugins is by convention for now — choose distinct endpoint
|
|
201
|
+
> names/paths (e.g. prefix a module's paths with `.path('/billing')`). Auto-prefixing
|
|
202
|
+
> mounts are a possible future addition.
|
|
203
|
+
|
|
204
|
+
## What it builds on (core)
|
|
205
|
+
|
|
206
|
+
- **`Server.install(spec, builders) → MountHandle` / `Server.uninstall(handle)`** —
|
|
207
|
+
hot registry mutation with collision checks and cache invalidation.
|
|
208
|
+
- **`localClient(app, spec) → LocalClient<S>`** and **`Server.call`** — the
|
|
209
|
+
in-process, no-serialization caller (transport `'local'`) behind `ctx.deps.*.call`.
|
|
210
|
+
- **`provide(name, value|factory)`** — inject a typed value onto context; the
|
|
211
|
+
primitive for handing services/data to handlers and middleware.
|
|
212
|
+
|
|
213
|
+
## Example
|
|
214
|
+
|
|
215
|
+
[`examples/08-plugins`](../../examples/08-plugins) — `auth` → `notes` → `stats`
|
|
216
|
+
(a two-level dependency chain). `notes` authenticates via `auth`'s `state.verify`,
|
|
217
|
+
emits its own `noteAdded`, and exports a `count()` service that `stats` reads. The
|
|
218
|
+
server hot-uninstalls/reinstalls `stats` on a timer (the `/stats` route blinks) and
|
|
219
|
+
the host refuses to uninstall `auth` while `notes` depends on it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ayepi-*.md"
|
|
22
23
|
],
|
|
23
24
|
"exports": {
|
|
24
25
|
".": {
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"node": ">=18"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@ayepi/core": "^0.
|
|
41
|
+
"@ayepi/core": "^0.2.0"
|
|
41
42
|
},
|
|
42
43
|
"devDependencies": {
|
|
43
44
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -45,7 +46,7 @@
|
|
|
45
46
|
"tsdown": "^0.12.0",
|
|
46
47
|
"vitest": "^2.1.8",
|
|
47
48
|
"zod": "^4.4.3",
|
|
48
|
-
"@ayepi/core": "0.
|
|
49
|
+
"@ayepi/core": "0.2.0"
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|
|
51
52
|
"ayepi",
|