@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,363 @@
1
+ <!--
2
+ ayepi-core-endpoints.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` — endpoints & spec
11
+
12
+ This file documents everything you can pass to `endpoint()` and `spec()`. For middleware
13
+ (which also produce endpoints via `.group()` / `.endpoint()`) see
14
+ `ayepi-core-middleware.md`; for the inferred types see `ayepi-core-types.md`.
15
+
16
+ ## `endpoint()` and `spec()`
17
+
18
+ ```ts
19
+ function endpoint<const C extends EndpointConfig>(cfg: C & CheckCfg<C, …>): Endpoint<C, …>
20
+ function spec<const S extends SpecShape>(spec: S): S
21
+ function manifestFromSpec(spec: AnySpec): Manifest
22
+ ```
23
+
24
+ - **`endpoint(cfg)`** declares one bare (middleware-less) endpoint. Endpoints guarded by
25
+ middleware are produced by `mw.endpoint(cfg)` / `mw.group({...})` / `stack.group({...})`
26
+ instead — same `EndpointConfig`.
27
+ - **`spec({ endpoints, events?, doc? })`** finalizes a `SpecShape` into a validated spec.
28
+ It throws at module-init on any violation, then stamps a cached zod-free manifest builder
29
+ on the spec so `client()` can take the spec directly.
30
+
31
+ ```ts
32
+ interface SpecShape {
33
+ readonly endpoints: Readonly<Record<string, AnyEndpoint>>
34
+ readonly events?: Readonly<Record<string, EventConfig>>
35
+ readonly doc?: SpecDoc // final patches over the generated OpenAPI/AsyncAPI docs
36
+ }
37
+ ```
38
+
39
+ ## `EndpointConfig` — the full surface
40
+
41
+ Every field below is optional. This is the real interface (from `endpoint.ts`):
42
+
43
+ ```ts
44
+ interface EndpointConfig {
45
+ readonly params?: z.ZodType // path params (z.object); keys must be positioned in the path
46
+ readonly query?: z.ZodType // query params (z.object)
47
+ readonly body?: z.ZodType // z.object → merges into data; any other schema → it IS the data
48
+ readonly files?: Readonly<Record<string, z.ZodType>> // multipart file fields; declaring files forces httpOnly
49
+ readonly headers?: z.ZodType // typed request headers (z.object, lowercase keys); never merged into data
50
+ readonly cookies?: z.ZodType // typed request cookies (z.object); server-side input only
51
+ readonly response?: z.ZodType // single success response schema
52
+ readonly responses?: Readonly<Record<number, z.ZodType>> // multi-status: { status, data } both ways
53
+ readonly errors?: Readonly<Record<number, z.ZodType>> // declared error statuses; types handler fail()
54
+ readonly bodyEncoding?: 'json' | 'urlencoded' // default 'json'
55
+ readonly streamEncoding?: 'ndjson' | 'sse' // typed item-stream out encoding; default 'ndjson'
56
+ readonly doc?: EndpointDoc // OpenAPI metadata
57
+ readonly method?: HttpMethod // 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'; default 'POST'
58
+ readonly path?: string | AnyPathTemplate // ':key' string, or a path`` template
59
+ readonly ws?: string // explicit WebSocket id (default: un-injected url pattern + method)
60
+ readonly httpOnly?: boolean // force HTTP-only (no ws)
61
+ readonly streamIn?: string | z.ZodType // raw byte stream (content-type), or typed NDJSON item stream
62
+ readonly streamOut?: string | z.ZodType // raw byte stream (content-type), or typed item stream
63
+ readonly download?: string // raw streamOut only: Content-Disposition filename
64
+ }
65
+ ```
66
+
67
+ ### Disjoint kinds → a single `data` payload
68
+
69
+ The central invariant: **every path-param key is declared exactly once** (loader XOR
70
+ template XOR `params` schema) and positioned exactly once; query keys are disjoint from
71
+ path; body keys from path∪query; files keys from all. That disjointness is what lets the
72
+ four kinds merge losslessly into one `data` object, in both directions.
73
+
74
+ ```ts
75
+ searchDocs: endpoint({
76
+ query: z.object({ q: z.string(), limit: z.coerce.number().int().default(10) }),
77
+ body: z.object({ filters: z.array(z.string()) }),
78
+ response: z.object({ hits: z.number() }),
79
+ })
80
+ // client: sdk.call('searchDocs', { q: 'x', filters: ['a'] }) // q from query, filters from body
81
+ // handler: ({ data }) => data.q, data.limit, data.filters // one merged object
82
+ ```
83
+
84
+ A **non-object body can't merge — it IS the data**, and excludes params/query/files:
85
+
86
+ ```ts
87
+ echoText: endpoint({ body: z.string(), response: z.object({ len: z.number() }) })
88
+ // sdk.call('echoText', 'hello') → handler: ({ data }) => data.length
89
+ ```
90
+
91
+ Collisions are caught at compile time (an error tuple lands on the offending config
92
+ property, e.g. `query`/`body`/`files`/`path`) and again at `spec()` time. Headers and
93
+ cookies are **separate kinds** — they surface as `headers` / `cookies` payload props, never
94
+ in `data`.
95
+
96
+ ### `method` and `path`
97
+
98
+ - `method` defaults to `'POST'`. `HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'`.
99
+ - `path` may be omitted (default path is `/<endpointName>` with any declared params
100
+ appended as trailing segments), a `:key` **string**, or a **`` path`` `` template**.
101
+ - A custom string path may only reference **declared** param keys (compile error otherwise).
102
+ - A `path`` template **declares + types** its params, so don't also declare those keys in
103
+ `params` (that's a "re-declares param keys" error).
104
+
105
+ ```ts
106
+ getReport: endpoint({ method: 'GET', path: reportPath, response: … }) // reportPath is a path`` template
107
+ listThings: endpoint({ method: 'GET', path: '/things/:id', params: z.object({ id: z.string() }) })
108
+ ```
109
+
110
+ ### The `` path`` `` template tag
111
+
112
+ Paths are modeled as a `PathPart[]` array (one entry per `/` segment) and
113
+ matched/built/parsed **segment by segment** with per-segment `encodeURIComponent` /
114
+ `decodeURIComponent` — never regex or string replacement over user input. So a `/` or
115
+ space inside a value round-trips losslessly.
116
+
117
+ ```ts
118
+ import { path } from '@ayepi/core'
119
+
120
+ const reportPath = path`/reports/${{ year: z.coerce.number().int() }}/${{ slug: z.string() }}`
121
+ reportPath.pattern // '/reports/:year/:slug'
122
+ reportPath.keys // ['year', 'slug']
123
+ reportPath.build({ year: 2026, slug: 'q2' }) // '/reports/2026/q2' (typed params in)
124
+ reportPath.parse('/reports/2026/q2') // { year: 2026, slug: 'q2' } | null (typed, coerced out)
125
+ ```
126
+
127
+ Each interpolation is a single `{ name: schema }` object, and **each schema must accept
128
+ string input** (path segments arrive as strings):
129
+
130
+ ```ts
131
+ path`/x/${{ n: z.number() }}` // ❌ compile error — z.number() rejects string input
132
+ path`/x/${{ n: z.coerce.number() }}` // ✅ ok — input widens to include strings
133
+ ```
134
+
135
+ `PathTemplate` also throws at definition time if a param doesn't occupy a whole segment, an
136
+ interpolation isn't a single-key object, or a key is declared twice. Other exported path
137
+ helpers: `splitPattern`, `joinPattern`, `matchParts`, `buildParts` (the segment-walking
138
+ primitives), and the `AnyPathTemplate` / `PathTemplate` / `PathPart` types.
139
+
140
+ ### `body` and `bodyEncoding`
141
+
142
+ - An **object** body merges its keys into `data`.
143
+ - A **non-object** body (`z.string()`, `z.array(...)`, etc.) *is* the data.
144
+ - `bodyEncoding` defaults to `'json'`. `'urlencoded'` serves
145
+ `application/x-www-form-urlencoded` (plain HTML form posts) and **requires** a `z.object`
146
+ body (enforced at `spec()` time).
147
+
148
+ ```ts
149
+ submitForm: endpoint({
150
+ body: z.object({ title: z.string(), count: z.coerce.number().int() }),
151
+ bodyEncoding: 'urlencoded',
152
+ response: z.object({ title: z.string(), count: z.number() }),
153
+ })
154
+ ```
155
+
156
+ ### `files` (multipart)
157
+
158
+ `files` is a record of form-field name → schema. Declaring files makes the endpoint
159
+ **httpOnly** (multipart is HTTP-only). File fields merge into `data` like any other kind.
160
+
161
+ ```ts
162
+ uploadDoc: endpoint({
163
+ files: { doc: z.file() },
164
+ body: z.object({ title: z.string() }), // JSON body fields ride the `body` multipart field
165
+ response: z.object({ size: z.number(), title: z.string() }),
166
+ })
167
+ // sdk.call('uploadDoc', { doc: new File(['…'], 'd.txt'), title: 'Doc' })
168
+ ```
169
+
170
+ Wire details: files go under their declared keys; the JSON body is sent under a form field
171
+ literally named **`body`** — so `'body'` is rejected as a files key. A file schema whose
172
+ *input* accepts `undefined` (e.g. `z.file().optional()`) becomes an optional `data` key. An
173
+ array schema (`z.array(z.file())`) collects all parts for that field.
174
+
175
+ ### `headers` and `cookies`
176
+
177
+ Typed request `headers` (lowercase keys) and `cookies` are **separate kinds**, parsed
178
+ server-side and surfaced as their own payload props — never merged into `data`. The client
179
+ delivers them via `opts.headers` (cookies via the `cookie` header).
180
+
181
+ ```ts
182
+ whoami: endpoint({
183
+ headers: z.object({ 'x-client-version': z.string() }),
184
+ cookies: z.object({ session: z.string() }),
185
+ response: z.object({ version: z.string(), session: z.string() }),
186
+ })
187
+ // handler: ({ headers, cookies }) => ({ version: headers['x-client-version'], session: cookies.session })
188
+ // client: sdk.call('whoami', { headers: { 'x-client-version': '1.2.3', cookie: 'session=abc' } })
189
+ ```
190
+
191
+ A missing required header → `400`. `params`/`query`/`headers`/`cookies` must each be
192
+ `z.object(...)` (enforced at `spec()` time).
193
+
194
+ ### `streamIn` / `streamOut` — raw bytes and typed items
195
+
196
+ Both accept a **string** (raw byte stream with that content-type) or a **zod schema** (a
197
+ typed item stream).
198
+
199
+ ```ts
200
+ // raw request body — handler gets stream: ReadableStream<Uint8Array>; client passes opts.stream
201
+ ingestData: endpoint({ streamIn: 'application/octet-stream', query: z.object({ tag: z.string() }), response: z.object({ bytes: z.number() }) })
202
+
203
+ // raw response stream + browser download
204
+ exportZip: endpoint({ method: 'GET', streamOut: 'application/zip', download: 'bundle.zip' })
205
+
206
+ // typed item stream out (NDJSON over http, chunk frames over ws); handler is an async generator
207
+ streamRows: endpoint({ method: 'GET', query: z.object({ n: z.coerce.number() }), streamOut: z.object({ i: z.number() }) })
208
+
209
+ // SSE (EventSource-compatible)
210
+ ticker: endpoint({ method: 'GET', streamOut: z.object({ tick: z.number() }), streamEncoding: 'sse' })
211
+
212
+ // duplex: client streams typed items IN (opts.stream), server streams typed items OUT
213
+ enrich: endpoint({ streamIn: z.object({ v: z.number() }), streamOut: z.object({ scaled: z.number() }) })
214
+ ```
215
+
216
+ - A **raw** `streamIn`/`streamOut` forces httpOnly. A **typed item** stream travels over ws
217
+ chunk frames too, so it stays ws-eligible.
218
+ - Raw `streamOut` handlers receive `out` (a `WritableStream` pipe target), `download(name, contentType?)`,
219
+ and `length(n)`. `length()` enables `Content-Length` + **HTTP Range** (206/416, resumable
220
+ downloads) plus correct `HEAD`. (See `ayepi-core-types.md` for the gated handler props.)
221
+ - `streamEncoding` (`'ndjson'` default, or `'sse'`) only applies to a **typed (schema)**
222
+ `streamOut` (enforced at `spec()` time). `download` requires a **raw (string)** `streamOut`.
223
+
224
+ ### `download`
225
+
226
+ For raw `streamOut`, sets a static `Content-Disposition: attachment; filename="…"`. The
227
+ filename can also be set dynamically per request via the handler's `download(name)`.
228
+
229
+ ### `response`, `responses`, `errors`
230
+
231
+ - **`response`** — a single success schema. The handler returns the value; the client
232
+ resolves it (`Promise<z.output<...>>`).
233
+ - **`responses`** — multi-status by code. The handler returns `{ status, data }`; the client
234
+ receives a **discriminated `{ status, data }` union**. Mutually exclusive with `response`
235
+ and `streamOut`.
236
+
237
+ ```ts
238
+ createThing: endpoint({
239
+ body: z.object({ name: z.string() }),
240
+ responses: { 200: z.object({ existing: z.string() }), 201: z.object({ id: z.string() }) },
241
+ })
242
+ // handler: () => ({ status: 201, data: { id: 'x' } } as const)
243
+ // client: const r = await sdk.call('createThing', { name }); if (r.status === 201) r.data.id
244
+ ```
245
+
246
+ - **`errors`** — declared error responses by status. They are documented in OpenAPI and they
247
+ type the handler's `fail(status, data)`. Only declared statuses are accepted, and the data
248
+ must match that status's schema; on the wire the parsed error data is returned as the body
249
+ with that status, and the client throws an `ApiError` whose `.status`/`.data` carry it.
250
+
251
+ ```ts
252
+ login: endpoint({
253
+ body: z.object({ user: z.string() }),
254
+ response: z.object({ ok: z.boolean() }),
255
+ errors: { 403: z.object({ reason: z.string() }) },
256
+ })
257
+ // handler: ({ fail }) => { if (blocked) fail(403, { reason: 'blocked' }); return { ok: true } }
258
+ ```
259
+
260
+ ### `ws` and `httpOnly`
261
+
262
+ - `httpOnly: true` forces the endpoint off WebSocket. Files and raw streams force it
263
+ implicitly.
264
+ - `ws` sets an **explicit** WebSocket channel id. The default ws identity is the
265
+ un-injected url pattern + method (e.g. the frame `type` is `/users/:id` with `method: 'PATCH'`).
266
+ With an explicit `ws`, the frame carries just `{ type: '<id>' }` and no method.
267
+
268
+ ```ts
269
+ updateUser: endpoint({ method: 'PATCH', path: '/users/:id', params: …, body: …, ws: 'user:update' })
270
+ ```
271
+
272
+ ### `doc` (per-endpoint OpenAPI metadata)
273
+
274
+ ```ts
275
+ interface EndpointDoc {
276
+ readonly summary?: string
277
+ readonly description?: string
278
+ readonly tags?: readonly string[]
279
+ readonly deprecated?: boolean
280
+ readonly operationId?: string
281
+ readonly openapi?: (op: Record<string, Json>) => Record<string, Json> // final say over the generated operation
282
+ }
283
+ ```
284
+
285
+ Spec-level `doc` (`SpecDoc`) has `openapi?` / `asyncapi?` callbacks for final patches over
286
+ the whole generated documents (e.g. injecting `servers`).
287
+
288
+ ## Events (`EventConfig`)
289
+
290
+ Events are server-pushed channels (delivered over ws). They are declared under
291
+ `spec({ events: { ... } })`:
292
+
293
+ ```ts
294
+ interface EventConfig {
295
+ readonly params?: z.ZodType // channel params (z.object); subscriptions are keyed by these
296
+ readonly data: z.ZodType // event payload schema (required)
297
+ readonly guard?: readonly AnyMiddleware[] // chain that must pass before a client may subscribe
298
+ readonly ws?: string // explicit channel id (default: the event name)
299
+ readonly doc?: EventDoc // { summary?, description?, asyncapi?(channel) }
300
+ }
301
+ ```
302
+
303
+ ```ts
304
+ events: {
305
+ jobProgress: { params: z.object({ jobId: z.string() }), data: z.object({ pct: z.number() }) },
306
+ systemNotice: { data: z.object({ msg: z.string() }), ws: 'sys:notice' }, // broadcast (no params)
307
+ }
308
+ // server/handler: emit('jobProgress', { jobId: 'job-1' }, { pct: 100 })
309
+ // client: sdk.on('jobProgress', { jobId: 'job-1' }, (d) => d.pct)
310
+ ```
311
+
312
+ ## Validation exclusivity rules (thrown at `spec()` time)
313
+
314
+ `spec()` enforces these beyond the compile-time `CheckCfg` checks:
315
+
316
+ - `streamIn` excludes `body` / `files`.
317
+ - `streamOut` excludes `response` and `responses`; `responses` excludes `response`.
318
+ - `streamEncoding` requires a typed (schema) `streamOut`.
319
+ - `bodyEncoding: 'urlencoded'` requires a `z.object` body.
320
+ - `download` requires a raw (string) `streamOut`.
321
+ - `params` / `query` / `headers` / `cookies` must each be `z.object(...)`.
322
+ - `'body'` is reserved as the multipart JSON field name (can't be a files key).
323
+ - Param keys must be declared exactly once and positioned exactly once in the path.
324
+
325
+ ## The `Manifest` types (runtime routing table)
326
+
327
+ `app.manifest()` / `manifestFromSpec(spec)` produce the zod-free `Manifest`. Every field is
328
+ part of the **frozen v0 wire contract**.
329
+
330
+ ```ts
331
+ interface Manifest {
332
+ readonly endpoints: Readonly<Record<string, ManifestEndpoint>>
333
+ readonly events: Readonly<Record<string, ManifestEvent>>
334
+ }
335
+
336
+ interface ManifestEndpoint {
337
+ readonly method: HttpMethod // default 'POST'
338
+ readonly path: string // ':key' pattern, e.g. '/users/:id'
339
+ readonly ws: string | null // explicit ws id, or null → address by method + path
340
+ readonly httpOnly: boolean // true → cannot be called over ws (raw streams / files)
341
+ readonly streamIn: string | null // streamed-request content-type (raw or NDJSON), or null
342
+ readonly itemsIn: boolean // true when streamIn is a typed NDJSON item stream
343
+ readonly streamOut: string | null // streamed-response content-type (raw / NDJSON / SSE), or null
344
+ readonly items: boolean // true when streamOut is a typed item stream
345
+ readonly p: readonly string[] // path-param keys, in path order
346
+ readonly q: readonly string[] // query-param keys
347
+ readonly b: readonly string[] | 'raw' | null // body keys, 'raw' (body IS data), or null (no body)
348
+ readonly f: readonly string[] // multipart file-field keys
349
+ readonly hasBody: boolean // whether a body is declared at all
350
+ readonly hasHeaders: boolean // whether typed request headers are declared
351
+ readonly multi: boolean // true → call() resolves a { status, data } union
352
+ readonly bodyEnc: 'json' | 'urlencoded' | null
353
+ }
354
+
355
+ interface ManifestEvent {
356
+ readonly ws: string // WebSocket channel id
357
+ readonly hasParams: boolean // parameterized (subscriptions keyed by params)
358
+ }
359
+ ```
360
+
361
+ The client splits a single `data` payload back into kinds purely from `p`/`q`/`b`/`f` —
362
+ trivial because kinds are disjoint. Obtain the manifest via `app.manifest()`,
363
+ `manifestFromSpec(spec)`, or by handing the spec to `client()`. See `ayepi-core-client.md`.