@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 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 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.
105
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/plugin).
106
106
 
@@ -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.1.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.1.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.1.0"
49
+ "@ayepi/core": "0.2.0"
49
50
  },
50
51
  "keywords": [
51
52
  "ayepi",