@ayepi/core 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.
@@ -0,0 +1,253 @@
1
+ <!--
2
+ ayepi-core-types.md — reference for `@ayepi/core`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/core` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/core` — types under the hood
11
+
12
+ This file explains the "painfully-typed" machinery: how definition-time validation, disjoint
13
+ kinds, and payload inference are derived purely at the type level. Most consumers never touch
14
+ these directly — they exist so that `endpoint()`, `client().call()`, and handlers infer
15
+ precisely with **no `any`/`unknown`** leaking to your editor. For the runtime fields see
16
+ `ayepi-core-endpoints.md` (`Manifest`); for usage see `ayepi-core-client.md`.
17
+
18
+ ## `CheckCfg` — definition-time config validation
19
+
20
+ `endpoint()` / `.group()` / `.endpoint()` intersect your config with `CheckCfg<C, LP, PFX>`:
21
+
22
+ ```ts
23
+ function endpoint<const C extends EndpointConfig>(cfg: C & CheckCfg<C, EmptyObject, EmptyObject>): Endpoint<C, …>
24
+ ```
25
+
26
+ `CheckCfg` is a type that resolves to `{}` (an empty constraint, valid) **or** to an object
27
+ whose offending property holds an **error tuple**. Because the error lands on the actual
28
+ property (`path`, `params`, `query`, `body`, `files`), the compile error points at the exact
29
+ field you got wrong. It enforces:
30
+
31
+ - a custom string path may only reference **declared** param keys;
32
+ - each param key is declared exactly once (own template vs prefix vs `params` schema);
33
+ - kinds are **disjoint**: query ∉ path, body ∉ path∪query, files ∉ path∪query∪body;
34
+ - a **non-object body excludes** params/query/files (it *is* the data).
35
+
36
+ ```ts
37
+ // the error type form (simplified):
38
+ // { readonly query: readonly ['query keys collide with path params:', <colliding keys>] }
39
+ ```
40
+
41
+ Errors are emitted as `readonly ['message', Keys]` **tuples** (not plain strings) on purpose:
42
+ two conflicting messages on the same property won't collapse to `never`, so the diagnostic
43
+ survives. Cross-prefix position coverage is additionally validated at `spec()` time (runtime
44
+ throw). The negative cases in [`example.ts`](./example.ts) (`@ts-expect-error`) pin every one
45
+ of these checks.
46
+
47
+ The `path`` tag has its own compile guard (`CheckTplParts`): each interpolated schema must
48
+ accept **string** input, else the error tuple
49
+ `['path param schema must accept string input:', K]` lands on that segment.
50
+
51
+ ## Disjoint-kind proofs
52
+
53
+ The disjointness `CheckCfg` proves at compile time is what makes the **single `data`
54
+ payload** lossless and reversible. Because path/query/body/files own disjoint key sets:
55
+
56
+ - the client merges them into one `data` to send, and the server splits one `data` back into
57
+ kinds by walking the manifest's `p`/`q`/`b`/`f` key tables (`splitData` / `kindsFromData`) —
58
+ a trivial key-table lookup, no ambiguity;
59
+ - a **non-object body** can't merge, so it *is* the `data` and the other kinds are banned
60
+ alongside it.
61
+
62
+ The same disjointness is re-checked at `spec()` time (`normalizeEndpoint` throws on any
63
+ collision, duplicate param declaration, or position/coverage mismatch) so misconfiguration
64
+ fails at module init even if types were bypassed (e.g. via `as never`).
65
+
66
+ ## Payload inference
67
+
68
+ All of these are pure type derivations over an `AnyEndpoint`'s config (`payload.ts`), keyed
69
+ on `z.input` (request side) vs `z.output` (response/handler side).
70
+
71
+ ### `ClientData<E>` — the single `data` argument
72
+
73
+ The merged path + query + body + files object the client sends, or the raw value when the
74
+ body is a non-object:
75
+
76
+ ```ts
77
+ type ClientData<E> = NonMergeableBody<E> extends true ? BRaw<E,'in'> : ClientFlat<E>
78
+ ```
79
+
80
+ ```ts
81
+ ClientData<getUser> // { id: string }
82
+ ClientData<updateUser> // { id: string; name: string; age?: number } (path :id + body merged)
83
+ ClientData<searchDocs> // { q: string; limit?: unknown; filters: string[] } (query + body)
84
+ ClientData<echoText> // string (non-object body IS data)
85
+ ClientData<uploadDoc> // { doc: File; title: string } (files + body merged)
86
+ ClientData<health> // {} (no data)
87
+ ```
88
+
89
+ ### `CallArgs<E>` — the positional `call()` arguments
90
+
91
+ Computed per endpoint so the call site is exactly right:
92
+
93
+ ```ts
94
+ sdk.call('health') // CallArgs = [opts?] — no data
95
+ sdk.call('echoText', 'hi') // CallArgs = [data, opts?] — non-object body required
96
+ sdk.call('getUser', { id }) // CallArgs = [data, opts?] — required data (some key required)
97
+ sdk.call('ingestData', { tag }, { stream }) // CallArgs = [data, opts] — streamIn forces required opts
98
+ ```
99
+
100
+ Rules (from `CallArgs`): streaming-input endpoints **require** `opts` (it carries `stream`);
101
+ a non-object body is a required positional value; data-less endpoints take `opts?` first; an
102
+ all-optional `data` becomes `data?`.
103
+
104
+ ### `CallOpts<E>` — the per-call options
105
+
106
+ ```ts
107
+ type CallOpts<E> = CallOptsBase
108
+ & (IsHttpOnly<E> extends true ? { transport?: 'http' } : { transport?: 'http' | 'ws' })
109
+ & (HasRawStreamIn<E> extends true ? { stream: StreamBody }
110
+ : HasItemStreamIn<E> extends true ? { stream: AsyncIterable<…> | (() => AsyncIterable<…>) }
111
+ : {})
112
+ ```
113
+
114
+ - `transport` is **narrowed to `'http'`** for httpOnly endpoints (files / raw streams) — so
115
+ `{ transport: 'ws' }` is a compile error there.
116
+ - `stream` is **required** (not optional) on streaming-input endpoints: `StreamBody`
117
+ (`ReadableStream<Uint8Array> | Blob | ArrayBuffer | string`) for raw, or a typed
118
+ `AsyncIterable` for item streams.
119
+ - `CallOptsBase` also carries `signal?` (abort), `headers?` (per-call), and
120
+ `onUploadProgress?: (p: UploadProgress) => void` — request-upload progress for file/body
121
+ uploads (routes the request via `XMLHttpRequest`; browser-only, see `ayepi-core-client.md`).
122
+
123
+ `IsHttpOnly<E>` is `true` when `httpOnly: true`, or files, or a raw `streamIn`/`streamOut`
124
+ are present — typed item streams stay ws-eligible.
125
+
126
+ ### `CallReturn<E>` — what `call()` resolves to
127
+
128
+ ```ts
129
+ CallReturn<streamRows> // AsyncIterable<{ i: number; squared: number }> — typed item stream
130
+ CallReturn<downloadZip> // Promise<ReadableStream<Uint8Array>> — raw stream
131
+ CallReturn<createThing> // Promise<{ status: 200; data: … } | { status: 201; data: … }> — multi-status union
132
+ CallReturn<getUser> // Promise<{ id: string; name: string; role: … }> — single response
133
+ CallReturn<health> // Promise<void> — no response
134
+ ```
135
+
136
+ ### `HandlerPayload<S, E>` — what a handler receives
137
+
138
+ The middleware context spreads at the **root**, alongside a single merged `data` and gated
139
+ extras. Note: there are **no `params`/`query`/`body` objects** — only the merged `data`.
140
+
141
+ ```ts
142
+ type GetUserP = HandlerPayload<Api, getUser>
143
+ GetUserP['data'] // { id: string }
144
+ GetUserP['user'] // User — from auth middleware ctx, at the root
145
+ GetUserP['req'] // Request
146
+ GetUserP['signal'] // AbortSignal
147
+ GetUserP['emit'] // EmitFn<S>
148
+ GetUserP['status'] // (code: number) => void
149
+ GetUserP['cookie'] // (name, value, opts?: CookieOptions) => void
150
+ ```
151
+
152
+ Always present: `req`, `signal`, `emit`, `status()`, `header()`, `cookie()`. **Gated** by
153
+ config:
154
+
155
+ - `data` — present unless there's no data at all;
156
+ - `stream` — `ReadableStream<Uint8Array>` (raw `streamIn`) or typed `AsyncIterable` (item `streamIn`);
157
+ - `out` / `download()` / `length()` — only on **raw `streamOut`** endpoints (pipe target,
158
+ dynamic filename, declared byte length for Range/Content-Length/HEAD);
159
+ - `headers` / `cookies` — only when declared (the parsed `z.output`);
160
+ - `fail` — only when `errors` are declared (`FailFn`).
161
+
162
+ ```ts
163
+ type FailFn<Errors> = <S extends keyof Errors & number>(
164
+ status: S, data: Errors[S] extends z.ZodType ? z.input<Errors[S]> : never,
165
+ ) => never
166
+ ```
167
+
168
+ Reserved root names (a middleware ctx key colliding with one throws at runtime): `data`,
169
+ `stream`, `headers`, `cookies`, `out`, `download`, `length`, `fail`, `status`, `header`,
170
+ `cookie`, `req`, `signal`, `emit`.
171
+
172
+ ### `HandlerReturn<E>` — what a handler may return
173
+
174
+ Mirrors `CallReturn`, wrapped in `MaybePromise<T> = T | Promise<T>`:
175
+
176
+ ```ts
177
+ HandlerReturn<streamRows> // MaybePromise<AsyncIterable<{ i; squared }>> — async generator
178
+ HandlerReturn<downloadZip> // MaybePromise<ReadableStream<Uint8Array> | AsyncIterable<string|Uint8Array> | void>
179
+ HandlerReturn<createThing> // MaybePromise<{ status: 200; data } | { status: 201; data }> — pick a status
180
+ HandlerReturn<getUser> // MaybePromise<{ id; name; role }>
181
+ ```
182
+
183
+ `HandlerFor<S, E> = (payload: HandlerPayload<S,E>) => HandlerReturn<E>` is the type each
184
+ `implement(spec).handlers({...})` entry is checked against — a wrong shape or missing handler
185
+ is a compile error.
186
+
187
+ ### `emit` types
188
+
189
+ ```ts
190
+ type EmitArgs<Ev> = Get<Ev,'params'> extends z.ZodType
191
+ ? [params: z.input<…>, data: z.input<Ev['data']>] // parameterized channel
192
+ : [data: z.input<Ev['data']>] // broadcast channel
193
+ type EmitFn<S> = <K extends keyof EventsOf<S> & string>(name: K, ...args: EmitArgs<EventsOf<S>[K]>) => void
194
+ ```
195
+
196
+ ## Multi-status unions
197
+
198
+ A `responses` map produces a discriminated `{ status, data }` union in **both** directions.
199
+ The handler returns one branch (often `as const` so the literal status narrows), and the
200
+ client receives the union to switch on:
201
+
202
+ ```ts
203
+ // handler:
204
+ createThing: ({ data }) =>
205
+ data.name === 'existing'
206
+ ? ({ status: 200, data: { existing: data.name } } as const)
207
+ : ({ status: 201, data: { id: `thing-${data.name}` } } as const)
208
+
209
+ // client:
210
+ const r = await sdk.call('createThing', { name })
211
+ if (r.status === 201) r.data.id // narrowed to the 201 branch
212
+ ```
213
+
214
+ Returning an undeclared status is a compile error (and a runtime throw at the server's
215
+ validation step). `multi: true` in the manifest tells the client to read `{ status, data }`.
216
+
217
+ ## The `Manifest` types
218
+
219
+ ```ts
220
+ interface Manifest {
221
+ readonly endpoints: Readonly<Record<string, ManifestEndpoint>>
222
+ readonly events: Readonly<Record<string, ManifestEvent>>
223
+ }
224
+ ```
225
+
226
+ `ManifestEndpoint` / `ManifestEvent` are the zod-free runtime routing data (full field list
227
+ in `ayepi-core-endpoints.md`). They carry exactly enough — `method`, `path`, `ws`,
228
+ `httpOnly`, streaming flags, and the `p`/`q`/`b`/`f` key tables — for the client to build a
229
+ request, split/merge `data`, and pick a transport **without any zod schemas**.
230
+
231
+ ## Why the spec is a single source of truth (shared type-only with the client)
232
+
233
+ - The **server** consumes the spec at runtime (it holds the zod schemas, parses inputs,
234
+ validates outputs, generates docs and the manifest).
235
+ - The **client** consumes the spec's **type** only — `client<typeof api>(...)`. Since
236
+ `import type` is erased at build, the client's argument/return types are derived precisely
237
+ from the same declaration the server uses, while the runtime stays zod-free and routes from
238
+ the plain `Manifest`.
239
+
240
+ One declaration ⇒ server validation, client types, wire format, docs, and manifest all stay
241
+ in lockstep. There is no second schema to drift. (`example.ts` enforces this with
242
+ `Expect<Equal<…>>` type tests and `@ts-expect-error` negatives — when this doc and the
243
+ example disagree, the example wins.)
244
+
245
+ ## Exported type-utility helpers
246
+
247
+ `Simplify<T>` (flatten an intersection into one object literal), `MaybePromise<T>`
248
+ (`T | Promise<T>`), and `Json` (the closed JSON-shaped value the doc generators produce and
249
+ accept in patch callbacks). The payload types above are all exported:
250
+ `ClientData`, `CallArgs`, `CallOpts`, `CallOptsBase`, `UploadProgress`, `CallReturn`, `HandlerPayload`,
251
+ `HandlerReturn`, `HandlerFor`, `FailFn`, `StreamBody`, `IsHttpOnly`, `EmitArgs`, `EmitFn`,
252
+ plus `CheckCfg`, `Endpoint`, `AnyEndpoint`, `EndpointConfig`, `AnySpec`, `SpecShape`,
253
+ `EventConfig`, `EventsOf`, and the `Manifest` types.
package/ayepi-core.md ADDED
@@ -0,0 +1,235 @@
1
+ <!--
2
+ ayepi-core.md — reference for `@ayepi/core`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/core` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/core` — overview
11
+
12
+ `@ayepi/core` is a **zod-first, painfully-typed HTTP + WebSocket API library**. You
13
+ declare endpoints and events **once** with [zod v4](https://zod.dev) schemas as the
14
+ single source of truth, and from that one declaration you get:
15
+
16
+ - a **typed server** (`app.fetch(Request) => Response`, plus a ws frame handler),
17
+ - a **typed client** (`sdk.call` / `sdk.url` / `sdk.on`),
18
+ - **OpenAPI 3.1 + AsyncAPI 3.0** documents,
19
+ - a **zod-free runtime manifest** the browser can route from without shipping schemas.
20
+
21
+ It is **fetch-native**: web-standard `Request`/`Response`/streams everywhere. It runs on
22
+ Node, Bun, Deno, Cloudflare Workers, and Lambda. Runtime adapters (`@ayepi/node`,
23
+ `@ayepi/bun`, `@ayepi/deno`) only add WebSocket-upgrade glue at the edge.
24
+
25
+ ```sh
26
+ pnpm add @ayepi/core zod # zod is a peer dependency (^4)
27
+ ```
28
+
29
+ ```ts
30
+ import { spec, endpoint, server, client } from '@ayepi/core'
31
+ import { client } from '@ayepi/core/client' // zod-free browser entry
32
+ ```
33
+
34
+ ## The mental model
35
+
36
+ Five ideas carry the whole library:
37
+
38
+ 1. **Schemas are the source of truth.** Every endpoint kind (path params, query, body,
39
+ files, headers, cookies) is a zod schema. From them, the request/response types, the
40
+ handler payload type, the client `call()` signature, the wire format, and the docs are
41
+ all derived. You never restate a shape.
42
+
43
+ 2. **Disjoint kinds → one `data` payload.** Path params, query, body, and files own
44
+ *disjoint* keys and merge losslessly into a single typed `data` object — in both
45
+ directions (client sends one `data`, handler receives one `data`). Disjointness is
46
+ proven at compile time (via `CheckCfg`) and re-checked at `spec()` time. A *non-object*
47
+ body can't merge, so it **is** the `data`. (Headers and cookies are separate kinds —
48
+ they ride `opts.headers` and surface as their own `headers`/`cookies` payload props,
49
+ never merged into `data`.)
50
+
51
+ 3. **HTTP _and_ WebSocket from one declaration.** Every eligible endpoint is callable over
52
+ either transport; typed item streams ride both. Raw byte streams and file uploads are
53
+ HTTP-only.
54
+
55
+ 4. **The client routes from a zod-free `Manifest`.** The manifest is a plain-data routing
56
+ table — key tables per endpoint plus method/path/streaming flags. The browser bundle
57
+ needs the manifest, never the schemas.
58
+
59
+ 5. **No `any`.** The public generic surface infers precisely; the painful typing is an
60
+ implementation detail (see `ayepi-core-types.md`).
61
+
62
+ ## The pipeline: `spec()` → `implement()` → `server()` → `client()`
63
+
64
+ ```ts
65
+ spec({ endpoints, events, doc? }) // validate + brand the declaration (defs only — frontend-safe)
66
+ → implement(spec) // chainable builder…
67
+ .middleware(def, impl) // …bind each middleware def to its impl
68
+ .handlers({ ... }) // …type each handler against its endpoint
69
+ → server(spec, [builder], opts?) // assemble app.fetch + app.ws + app.emit + docs
70
+ → client<typeof spec>({ ... }) // typed sdk.call / sdk.url / sdk.on
71
+ ```
72
+
73
+ - **`spec(shape)`** finalizes endpoints + events into a validated spec object. It runs
74
+ every runtime sanity check (flag exclusivity, kind shapes) and full path / coverage /
75
+ disjointness validation, throwing immediately so misconfiguration fails at module init.
76
+ The spec references middleware only as **defs** (contracts, no runtime code), so it stays
77
+ **frontend-safe** — importable from the browser bundle without pulling in secrets or node
78
+ deps. See `ayepi-core-middleware.md`.
79
+ - **`implement(spec)`** returns a **chainable builder**. `.middleware(def, impl)` binds a
80
+ middleware def to its runtime impl; `.handlers({...})` / `.handle(name, fn)` type each
81
+ handler against its endpoint. Every method returns the builder, so calls chain. Split
82
+ handlers/bindings across multiple builders if you like.
83
+ - **`server(spec, [builders], opts?)`** assembles the runtime from the builders produced by
84
+ `implement()`. A **missing handler is a compile error naming the endpoint**; duplicate/unknown
85
+ handlers throw at startup; and **any middleware def left unbound throws at assembly**.
86
+ - **`client<typeof spec>({...})`** creates the typed SDK. It needs a `Manifest` (or the
87
+ spec) and a `baseUrl`; the spec type parameter is **type-only** (erased at build).
88
+
89
+ ## A complete minimal end-to-end example
90
+
91
+ ```ts
92
+ // api.ts (frontend-safe: @ayepi/core + zod only) — defs + spec
93
+ import { z } from 'zod'
94
+ import { middleware, endpoint, spec, ctx } from '@ayepi/core'
95
+
96
+ /* 1. middleware def — declares it provides { user }; the impl is bound later */
97
+ const auth = middleware('auth', { provides: ctx<{ user: { id: string; name: string } }>() })
98
+
99
+ /* 2. spec — schemas are the single source of truth */
100
+ export const api = spec({
101
+ endpoints: {
102
+ health: endpoint({}), // POST /health, no input, 204
103
+ ...auth.group({
104
+ getUser: { params: z.object({ id: z.string() }), response: z.object({ id: z.string(), name: z.string() }) },
105
+ updateUser: {
106
+ method: 'PATCH',
107
+ path: '/users/:id',
108
+ params: z.object({ id: z.string() }),
109
+ body: z.object({ name: z.string().min(1) }), // merges with :id into one `data`
110
+ response: z.object({ id: z.string(), name: z.string() }),
111
+ },
112
+ }),
113
+ },
114
+ events: {
115
+ jobProgress: { params: z.object({ jobId: z.string() }), data: z.object({ pct: z.number() }) },
116
+ },
117
+ })
118
+ export { auth }
119
+ ```
120
+
121
+ ```ts
122
+ // server.ts (secrets, node deps) — bind impls + handlers, then assemble
123
+ import { implement, server, reject } from '@ayepi/core'
124
+ import { api, auth } from './api'
125
+
126
+ /* 3. one chainable builder: bind the middleware impl, then the handlers.
127
+ handlers get one merged `data`, ctx (`user`) at the root, and a typed `emit`. */
128
+ const impl = implement(api)
129
+ /* auth impl — provides { user }; the value passed to next() matches the def's ctx<…>() */
130
+ .middleware(auth, async (io) => {
131
+ if (io.req.headers.get('authorization') !== 'Bearer secret') throw reject(401, 'UNAUTHORIZED')
132
+ return io.next({ user: { id: 'u1', name: 'Phil' } })
133
+ })
134
+ .handlers({
135
+ health: () => {},
136
+ getUser: ({ data, user }) => ({ id: data.id, name: user.name }),
137
+ updateUser: ({ data, emit }) => {
138
+ emit('jobProgress', { jobId: 'job-1' }, { pct: 100 })
139
+ return { id: data.id, name: data.name }
140
+ },
141
+ })
142
+
143
+ /* 4. server — a missing handler OR an unbound middleware def is caught here
144
+ (handler: compile error naming the endpoint; unbound def: throws at assembly) */
145
+ export const app = server(api, [impl], { cors: { origin: '*' } })
146
+ ```
147
+
148
+ ```ts
149
+ /* 5. client — zod-free entry, type-only spec import */
150
+ import { client } from '@ayepi/core/client'
151
+ import type { api } from './api'
152
+
153
+ const sdk = client<typeof api>({ baseUrl: 'https://api.example.dev', manifest })
154
+ const user = await sdk.call('getUser', { id: 'u1' }) // { id: string; name: string }
155
+ const off = sdk.on('jobProgress', { jobId: 'job-1' }, (d) => console.log(d.pct))
156
+ ```
157
+
158
+ `app.fetch(request)` is the entire HTTP surface — run it in-process (tests), on Node, Bun,
159
+ Deno, or any fetch-native runtime. See the runnable, feature-exhaustive
160
+ [`example.ts`](./example.ts) (it doubles as the type-test suite).
161
+
162
+ ## Docs and manifest generation
163
+
164
+ A server exposes three generators, all derived from the same spec:
165
+
166
+ ```ts
167
+ app.openapi({ title: 'API', version: '1.0.0' }) // OpenAPI 3.1 — paths, params, security, errors, multi-status
168
+ app.asyncapi({ title, version }) // AsyncAPI 3.0 — event channels + endpoint ws channels
169
+ app.manifest() // the zod-free runtime routing table
170
+ ```
171
+
172
+ For AsyncAPI, **WebSocket endpoints are modeled as request/reply over separate channels** —
173
+ the reply channel documents both the success frame (`{ id, $status, data }`) and the error
174
+ frame (`{ id, $status, $error, $code, data }`) — alongside the server-pushed event channels.
175
+
176
+ You can also let the server host interactive docs (specs computed once, cached in memory;
177
+ viewer pages loaded from a CDN, no bundled doc dependency):
178
+
179
+ ```ts
180
+ const app = server(api, [handlers], { docs: true })
181
+ // GET /docs/openapi.json GET /docs/asyncapi.json
182
+ // GET /docs/swagger → Swagger UI GET /docs/redoc → ReDoc GET /docs/asyncapi → AsyncAPI viewer
183
+ ```
184
+
185
+ Customize or disable individual pages with a `DocsOptions` object
186
+ (`{ swagger: '/api-docs', redoc: false, info: { title, version } }`). The HTML builders
187
+ `swaggerHtml`, `redocHtml`, `asyncapiHtml` are exported if you'd rather mount them yourself.
188
+
189
+ ### Acquiring the manifest for the client
190
+
191
+ The client routes from a **`Manifest`**. Get the manifest one of three ways:
192
+
193
+ - `app.manifest()` on the running server,
194
+ - `manifestFromSpec(spec)` from the spec (importing this pulls zod into the bundle),
195
+ - pass the **spec itself** to `client({ manifest: spec })` (convenient, but ships zod).
196
+
197
+ The recommended frontend pattern is to commit a prebuilt manifest (plain JSON) and import
198
+ it as a value — the browser bundle then stays schema-free. See `ayepi-core-client.md`.
199
+
200
+ ## Hot install + in-process calls
201
+
202
+ A running `Server` is mutable and self-callable:
203
+
204
+ - **`app.install(spec, builders) → MountHandle`** mounts another spec's endpoints, events,
205
+ routes, and middleware **onto the live server** (the manifest + OpenAPI/AsyncAPI caches
206
+ refresh; collisions on endpoint name / `METHOD path` / ws id / event throw).
207
+ **`app.uninstall(handle)`** removes exactly them and clears their subscriptions. A shared
208
+ middleware def already bound by an earlier mount is reused (bind it once).
209
+ - **`localClient(app, spec) → LocalClient<S>`** (and the loose `app.call(name, data, opts?)`)
210
+ invoke an endpoint **in-process** by name with just a data payload — full chain +
211
+ validation, no HTTP serialization; the invocation's `io.transport` is `'local'`.
212
+
213
+ These are the primitives [`@ayepi/plugin`](../plugin) builds a hot-pluggable plugin system on.
214
+
215
+ ## This doc set
216
+
217
+ - **`ayepi-core.md`** (this file) — overview, mental model, the pipeline, docs/manifest.
218
+ - **`ayepi-core-endpoints.md`** — everything `endpoint()` / `spec()` accept: methods, paths
219
+ and the `` path`` `` tag, body/query/params/headers/cookies/files, streaming
220
+ (`streamIn`/`streamOut`), multi-status `responses`, declared `errors`, encodings,
221
+ downloads, ws ids, docs, and the `Manifest` field reference.
222
+ - **`ayepi-core-middleware.md`** — the middleware **def** (`middleware()` /
223
+ `middleware.loader()` + `ctx<P>()` provides) vs **impl** split, binding via the chainable
224
+ `implement(api).middleware(def, impl)` builder and the binding requirement, the
225
+ `use(...)` free-function composition helper and the `.group()`/`.with()`/`.path()`/
226
+ `.endpoint()` builders, the per-invocation `io` context
227
+ (`transport`/`route`/`ws`/`signal`/`setHeader`/`status`), auth patterns, loaders, declared
228
+ errors and security-scheme docs, `reject()`, the new exported impl/bound types, and chain
229
+ execution semantics.
230
+ - **`ayepi-core-client.md`** — `client()`, `wsTransport()`, `sdk.call`/`url`/`on`, transport
231
+ selection, typed item streams, events, opt-in validation, `ApiError`, the ws `$status`/
232
+ `$error` frame protocol, and the zod-free `@ayepi/core/client` entry.
233
+ - **`ayepi-core-types.md`** — how the painful typing works: `CheckCfg`, disjoint-kind
234
+ proofs, payload inference (`ClientData`/`CallArgs`/`CallReturn`/`CallOpts`/
235
+ `HandlerPayload`/`HandlerReturn`), multi-status unions, and the `Manifest` types.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "zod-first, painfully-typed HTTP + WebSocket API library with OpenAPI 3.1 + AsyncAPI 3.0 generation",
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
  ".": {