@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,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`.
|