@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.
- package/README.md +1 -1
- package/ayepi-core-client.md +441 -0
- package/ayepi-core-endpoints.md +363 -0
- package/ayepi-core-middleware.md +446 -0
- package/ayepi-core-types.md +253 -0
- package/ayepi-core.md +235 -0
- package/package.json +3 -2
|
@@ -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.
|
|
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
|
".": {
|