@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
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
82
82
|
- [`ayepi-core-types.md`](./ayepi-core-types.md)
|
|
83
83
|
- [`ayepi-core.md`](./ayepi-core.md)
|
|
84
84
|
|
|
85
|
-
They
|
|
85
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/core).
|
|
86
86
|
|
|
87
87
|
## License
|
|
88
88
|
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-core-client.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` — client
|
|
11
|
+
|
|
12
|
+
The typed client exposes `call` / `url` / `on` whose argument and return types are derived
|
|
13
|
+
per endpoint from the spec **type** (used type-only). It speaks both HTTP and the ws frame
|
|
14
|
+
protocol, splitting the single `data` payload back into kinds via the manifest key tables.
|
|
15
|
+
|
|
16
|
+
## The zod-free entry — `@ayepi/core/client`
|
|
17
|
+
|
|
18
|
+
Import the client from **`@ayepi/core/client`** in browser/frontend code. That entry
|
|
19
|
+
contains **zero zod runtime code** (verified in CI). The client module imports zod
|
|
20
|
+
**type-only** — it never references `z` as a value — so nothing in the request/response path
|
|
21
|
+
pulls zod into your bundle.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { client, wsTransport } from '@ayepi/core/client' // zod-free
|
|
25
|
+
import type { api } from './api' // type-only — erased at build
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Why the type-only spec import matters:** `client<typeof api>(...)` uses the spec purely for
|
|
29
|
+
inference. Because `import type` is erased at build, your bundle gets the exact typed surface
|
|
30
|
+
(`sdk.call('getUser', { id })` is fully checked) **without** shipping the schemas. The
|
|
31
|
+
runtime routing comes from the `Manifest`, which is plain data.
|
|
32
|
+
|
|
33
|
+
## `client()`
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
function client<S extends AnySpec>(opts: ClientOptions): ApiClient<S>
|
|
37
|
+
|
|
38
|
+
interface ClientOptions {
|
|
39
|
+
readonly baseUrl: string // HTTP base (trailing slash optional)
|
|
40
|
+
readonly manifest: Manifest | AnySpec // routing table — Manifest OR the spec
|
|
41
|
+
readonly headers?: Record<string,string> | (() => Record<string,string>) // static or computed per request
|
|
42
|
+
readonly fetchImpl?: (req: Request) => Promise<Response> // override fetch (tests / in-memory)
|
|
43
|
+
readonly ws?: ClientWs // ws transport — required for ws calls + events
|
|
44
|
+
readonly prefer?: 'http' | 'ws' // preferred transport for dual endpoints (default 'http')
|
|
45
|
+
readonly validate?: AnySpec // opt-in: parse responses/items with their schemas
|
|
46
|
+
readonly cache?: ClientCacheOptions // defaults for sdk.caller(...) caches (max/ttl/store) — see Callers
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `manifest`: a `Manifest` OR the spec
|
|
51
|
+
|
|
52
|
+
`client()` accepts **either**:
|
|
53
|
+
|
|
54
|
+
- a zod-free **`Manifest`** — keeps the frontend bundle schema-free. This is the recommended
|
|
55
|
+
path. The slim path stays zod-free purely by tree-shaking: a manifest value carries no
|
|
56
|
+
derivation code.
|
|
57
|
+
- the **spec itself** — convenient when bundle size isn't a concern; the client derives the
|
|
58
|
+
manifest from it. Because the spec holds zod, **this pulls zod into the bundle**. (The
|
|
59
|
+
spec is read for its stamped manifest-builder under `Symbol.for('ayepi.manifest')`, not by
|
|
60
|
+
importing the deriver.)
|
|
61
|
+
|
|
62
|
+
### Acquiring the manifest
|
|
63
|
+
|
|
64
|
+
Get the manifest one of three ways:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// 1. from the running server
|
|
68
|
+
const manifest = app.manifest()
|
|
69
|
+
|
|
70
|
+
// 2. from the spec (importing manifestFromSpec pulls zod into the bundle)
|
|
71
|
+
import { manifestFromSpec } from '@ayepi/core'
|
|
72
|
+
const manifest = manifestFromSpec(api)
|
|
73
|
+
|
|
74
|
+
// 3. hand the spec straight to the client (ships zod)
|
|
75
|
+
const sdk = client<typeof api>({ baseUrl, manifest: api })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The recommended frontend pattern: build the manifest once (it's plain JSON), commit it (or
|
|
79
|
+
write it to a file the frontend imports), and pass that value:
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import manifest from './manifest.gen' // prebuilt zod-free manifest (plain data)
|
|
83
|
+
const sdk = client<typeof api>({ baseUrl: 'https://api.example.dev', manifest })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Headers (static / computed)
|
|
87
|
+
|
|
88
|
+
`headers` may be a static record or a **function** called per request (e.g. to inject a
|
|
89
|
+
fresh auth token). Per-call `opts.headers` merge on top and also deliver typed request
|
|
90
|
+
headers/cookies.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const sdk = client<typeof api>({ baseUrl, manifest, headers: () => ({ authorization: `Bearer ${getToken()}` }) })
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## `sdk.call` / `sdk.url` / `sdk.on`
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
interface ApiClient<S extends AnySpec> {
|
|
100
|
+
call<K>(name: K, ...args: CallArgs<S['endpoints'][K]>): CallReturn<S['endpoints'][K]>
|
|
101
|
+
url<K extends GetUrlKeys<S>>(name: K, ...args: …): string // GET endpoints only
|
|
102
|
+
on<K>(name: K, ...args: …): () => void // subscribe; returns unsubscribe
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `call(name, data?, opts?)`
|
|
107
|
+
|
|
108
|
+
Arguments are computed per endpoint (see `ayepi-core-types.md` for `CallArgs`):
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
await sdk.call('health') // no data → opts-only
|
|
112
|
+
await sdk.call('getUser', { id: 'u1' }) // merged data
|
|
113
|
+
await sdk.call('searchDocs', { q: 'x', filters: ['a'] }) // query + body in one data
|
|
114
|
+
await sdk.call('echoText', 'hello') // non-object body IS the data (required positional)
|
|
115
|
+
await sdk.call('createThing', { name }) // → { status, data } discriminated union
|
|
116
|
+
await sdk.call('ingestData', { tag: 't' }, { stream }) // streamIn → opts.stream required
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Per-call `opts` (`CallOpts`):
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
interface CallOptsBase {
|
|
123
|
+
readonly signal?: AbortSignal // cancels the in-flight request (and, over ws, the call)
|
|
124
|
+
readonly headers?: Record<string,string> // extra headers; also delivers typed headers/cookies
|
|
125
|
+
readonly onUploadProgress?: (p: { loaded: number; total: number }) => void // request-upload progress
|
|
126
|
+
}
|
|
127
|
+
// plus, per endpoint:
|
|
128
|
+
// transport?: 'http' | 'ws' (narrowed to 'http' for httpOnly endpoints)
|
|
129
|
+
// stream: required for streamIn endpoints (StreamBody for raw, AsyncIterable for typed items)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**Upload progress.** Pass `onUploadProgress` to watch bytes sent for a file/multipart or body
|
|
133
|
+
upload — the natural fit for a progress bar:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
await sdk.call('upload', { doc: file, title }, {
|
|
137
|
+
onUploadProgress: ({ loaded, total }) => setPct(Math.round((loaded / total) * 100)),
|
|
138
|
+
})
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`fetch` exposes no upload-progress events, so when this is set the request goes via
|
|
142
|
+
`XMLHttpRequest` instead (the only transport that reports them). Consequences: it **bypasses a
|
|
143
|
+
custom `fetchImpl`**, applies only to **non-streaming** requests/responses (ignored for
|
|
144
|
+
`streamIn`/`streamOut` endpoints), and is a **browser** feature — where `XMLHttpRequest` is absent
|
|
145
|
+
(e.g. server-side) the call still completes via `fetch`, just without progress. The response and
|
|
146
|
+
errors are identical to the `fetch` path (`ApiError`, `AbortError` on `signal`).
|
|
147
|
+
|
|
148
|
+
### `url(name, data?)` — GET-only
|
|
149
|
+
|
|
150
|
+
Builds a plain GET URL (typed against the endpoint's data). Hand it to the browser
|
|
151
|
+
(`location`, `<a href>`, `window.open`, `EventSource`) for native streamed downloads / SSE.
|
|
152
|
+
Only `GET` endpoints are accepted (`GetUrlKeys<S>`); calling it on a non-GET endpoint is a
|
|
153
|
+
type error and throws at runtime.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const zipUrl = sdk.url('downloadZip', { name: 'report' }) // 'https://…/downloadZip?name=report'
|
|
157
|
+
window.open(zipUrl) // browser streams the download (Content-Disposition)
|
|
158
|
+
|
|
159
|
+
const es = new EventSource(sdk.url('ticker', { n: 100 })) // SSE endpoint
|
|
160
|
+
es.onmessage = (e) => console.log(JSON.parse(e.data))
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### `on(name, params?, cb)` — event subscriptions
|
|
164
|
+
|
|
165
|
+
Subscribe to a server-pushed event; returns an unsubscribe function. Parameterized channels
|
|
166
|
+
**require** the params object (and it keys delivery); broadcast channels omit it.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const off = sdk.on('jobProgress', { jobId: 'job-7' }, (d) => console.log(d.pct)) // typed, param-keyed
|
|
170
|
+
sdk.on('systemNotice', (d) => console.log(d.msg)) // broadcast, no params
|
|
171
|
+
off() // unsubscribe (sends an unsub frame when the last listener for a key leaves)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`on()` requires a configured `ws` transport (throws otherwise).
|
|
175
|
+
|
|
176
|
+
## Callers — client-side call policy
|
|
177
|
+
|
|
178
|
+
`sdk.caller(name, options)` returns a **typed `Caller`** whose `call(...args)` is the same as
|
|
179
|
+
`sdk.call(name, ...args)` but wrapped with stateful policy. Each feature is its own wrapper
|
|
180
|
+
function layered around the base call (so their state never tangles), composed in a fixed order:
|
|
181
|
+
|
|
182
|
+
```
|
|
183
|
+
hooks → cache(read/write + SWR) → dedupe → lastOnly → debounce → rateLimit → retry → call
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
interface Caller<E> {
|
|
188
|
+
call(...args): Promise<…> // same arguments/return as sdk.call
|
|
189
|
+
cancel(): void // abort in-flight calls + drop pending debounced calls
|
|
190
|
+
invalidate(): void // clear this caller's own cached entries
|
|
191
|
+
readonly pending: number // calls currently in flight
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A read with caching + dedupe, and a search box that debounces and only keeps the latest:
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
const getUser = sdk.caller('getUser', { cache: { ttl: 30_000, tags: ['user'] }, dedupe: true })
|
|
199
|
+
await getUser.call({ id: 'u1' }) // network; cached 30s, shared with concurrent identical calls
|
|
200
|
+
|
|
201
|
+
const search = sdk.caller('search', { debounce: 250, lastOnly: true })
|
|
202
|
+
search.call({ q }) // coalesces keystrokes; supersedes older requests (they reject AbortError)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A mutation that **invalidates** other callers' caches by tag (shared across the client's callers):
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const createUser = sdk.caller('createUser', { invalidates: ['user'], retry: { attempts: 3 } })
|
|
209
|
+
await createUser.call({ name }) // on success, clears every cached entry tagged 'user' (e.g. getUser above)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
`CallerOptions` (each is independent; omit to skip that layer):
|
|
213
|
+
|
|
214
|
+
| Option | Shape | Effect |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `cache` | `true` \| `{ ttl?, staleWhileRevalidate?, key?, tags?, store? }` | Cache by the call's data. `key` defaults to stable JSON; `store` is `'memory'` (default) / `'session'` / `'local'` / a custom `KVStore`. `staleWhileRevalidate` serves a stale value while refetching. |
|
|
217
|
+
| `debounce` | `number` \| `{ wait, maxWait?, leading?, accumulate?, spread? }` | Trailing debounce. `accumulate(dataList)` merges queued calls into one; `spread(result, dataList)` fans the result back. |
|
|
218
|
+
| `rateLimit` | `{ limit, window, onLimit? }` | Token bucket. `onLimit`: `'wait'` (default) / `'drop'` / `'throw'` (rejects `CallerRateLimited`). |
|
|
219
|
+
| `retry` | `{ attempts?, base?, factor?, max?, jitter? }` | Exponential-backoff retry on failure. |
|
|
220
|
+
| `lastOnly` | `boolean` | Only the most recent call resolves; older in-flight calls are aborted (reject `AbortError`). |
|
|
221
|
+
| `dedupe` | `boolean` | Concurrent identical calls share one request. |
|
|
222
|
+
| `invalidates` | `string[]` \| `(data, result) => string[]` | Tags to clear (in **all** the client's caches) when this caller runs. `invalidateOn`: `'success'` (default) / `'start'` / `'both'`. |
|
|
223
|
+
| `onStart`/`onSuccess`/`onError`/`onSettled` | callbacks | Lifecycle hooks; pair with `caller.pending`. |
|
|
224
|
+
|
|
225
|
+
Caches are **shared across a client's callers** (one cache per distinct `store`), so a mutating
|
|
226
|
+
caller's tag invalidation reaches the cached reads of other callers. Set `client({ cache: { max,
|
|
227
|
+
ttl, store } })` for cache defaults. **Streaming** endpoints (item/raw streams) bypass every layer
|
|
228
|
+
— policies apply to unary request/response calls. The cache primitive is also exported standalone
|
|
229
|
+
as `createClientCache(...)`.
|
|
230
|
+
|
|
231
|
+
## HTTP vs ws transport selection
|
|
232
|
+
|
|
233
|
+
- Default transport is `'http'`, unless `prefer: 'ws'` is set (and the endpoint is
|
|
234
|
+
ws-eligible and a `ws` transport is configured).
|
|
235
|
+
- Per call, `opts.transport: 'http' | 'ws'` overrides. For **httpOnly** endpoints
|
|
236
|
+
(files / raw streams), `transport` is narrowed to `'http'` at the type level; passing
|
|
237
|
+
`'ws'` is a compile error and rejects/throws at runtime.
|
|
238
|
+
- Typed item streams travel over **either** transport (NDJSON/SSE over HTTP, chunk frames
|
|
239
|
+
over ws). Raw byte streams and file uploads are HTTP-only.
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
await sdk.call('getUser', { id: 'u1' }, { transport: 'ws' }) // dual endpoint over ws
|
|
243
|
+
for await (const r of sdk.call('streamRows', { n: 3 }, { transport: 'ws' })) … // item stream over ws
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Typed item streams
|
|
247
|
+
|
|
248
|
+
`call()` on a `streamOut`-schema endpoint returns an `AsyncIterable` — `for await` it:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
for await (const row of sdk.call('streamRows', { n: 4 })) console.log(row.i) // typed items
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
- **Over HTTP**, items decode lazily from NDJSON (`application/x-ndjson`) or SSE
|
|
255
|
+
(`text/event-stream`); the request fires on first pull.
|
|
256
|
+
- **Over ws**, items arrive as chunk frames until an `end` frame.
|
|
257
|
+
- **Streaming IN** (`streamIn` schema): pass `opts.stream` as an `AsyncIterable` (or a
|
|
258
|
+
generator function). Over HTTP it's sent as an NDJSON request body (`duplex: 'half'`); over
|
|
259
|
+
ws it's pumped as chunk frames. Duplex endpoints stream items both directions.
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
for await (const r of sdk.call('enrich', { factor: 10 }, {
|
|
263
|
+
stream: async function* () { yield { id: 1, v: 1 }; yield { id: 2, v: 2 } },
|
|
264
|
+
})) console.log(r.scaled)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Raw `streamOut` (string content-type) resolves a `Promise<ReadableStream<Uint8Array>>`
|
|
268
|
+
instead.
|
|
269
|
+
|
|
270
|
+
## Opt-in response validation (`validate`)
|
|
271
|
+
|
|
272
|
+
By default the client does **not** validate responses (types assert shapes statically; no zod
|
|
273
|
+
at runtime). Pass `validate: spec` to parse responses/items with their zod schemas as they
|
|
274
|
+
arrive — this only pulls zod in because *you* supplied the schema-bearing spec:
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
const sdk = client<typeof api>({ baseUrl, manifest, validate: api })
|
|
278
|
+
const u = await sdk.call('getUser', { id: 'u1' }) // response .parse()'d
|
|
279
|
+
for await (const r of sdk.call('streamRows', { n: 2 })) … // each item .parse()'d
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Errors — `ApiError`
|
|
283
|
+
|
|
284
|
+
Failed calls reject with an `ApiError`, reconstructed identically from an HTTP error
|
|
285
|
+
envelope or a ws error frame:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
class ApiError extends Error {
|
|
289
|
+
readonly status: number // HTTP (or ws-mapped) status
|
|
290
|
+
readonly code: string // stable machine code, e.g. 'UNAUTHORIZED'
|
|
291
|
+
readonly data?: unknown // declared-error body, or the raw envelope
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```ts
|
|
296
|
+
try {
|
|
297
|
+
await sdk.call('login', { user: 'blocked' })
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (err instanceof ApiError && err.status === 403) {
|
|
300
|
+
const { reason } = err.data as { reason: string } // declared typed error body
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
`reject(status, code, message?)` constructs an `ApiError` for throwing from
|
|
306
|
+
handlers/middleware (see `ayepi-core-middleware.md`).
|
|
307
|
+
|
|
308
|
+
### The ws wire protocol (errors + status)
|
|
309
|
+
|
|
310
|
+
Every ws **call response** carries a reserved `$status` (the `$`-prefix avoids colliding
|
|
311
|
+
with your payload, which lives under `data`). The client throws whenever `$status` is **not
|
|
312
|
+
2xx**, mirroring HTTP:
|
|
313
|
+
|
|
314
|
+
```jsonc
|
|
315
|
+
// success: { "id": "c1", "$status": 200, "data": <result> } // multi-status: data = { status, data }
|
|
316
|
+
// error: { "id": "c1", "$status": 404, "$error": "Not Found", "$code": "NOT_FOUND", "data": <typed body?> }
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- `$status` — the status code (always present on a call response).
|
|
320
|
+
- `$error` — a human-readable message. If omitted, the client derives one from `$status`
|
|
321
|
+
(a known status text, else `Request failed with status <n>`).
|
|
322
|
+
- `$code` — the machine code → `ApiError.code` (defaults to `'ERROR'` when omitted, e.g. for
|
|
323
|
+
declared errors, matching HTTP).
|
|
324
|
+
- `data` — the typed error body for **declared** errors (`errors: { 404: … }`); read it via
|
|
325
|
+
`err.data` exactly as over HTTP.
|
|
326
|
+
|
|
327
|
+
A non-2xx `$status` becomes `new ApiError($status, $code, $error, data)`. The transport also
|
|
328
|
+
synthesizes `$status: 0` `DISCONNECTED` frames so awaited ws calls reject instead of hanging
|
|
329
|
+
when the socket drops. The generated **AsyncAPI** document models both the success and error
|
|
330
|
+
reply frames per endpoint (see `app.asyncapi()`).
|
|
331
|
+
|
|
332
|
+
## Cancellation
|
|
333
|
+
|
|
334
|
+
Every `call()` accepts `opts.signal`. Over HTTP it aborts the `fetch`; **over ws it sends an
|
|
335
|
+
`{ id, abort: true }` frame** so the server aborts the per-call signal and stops streaming,
|
|
336
|
+
and the client rejects the pending / fails the item stream.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
const ac = new AbortController()
|
|
340
|
+
const rows = sdk.call('streamRows', { n: 1_000_000 }, { transport: 'ws', signal: ac.signal })
|
|
341
|
+
setTimeout(() => ac.abort(), 100) // stops the server mid-stream
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## `wsTransport()` — resilient browser WebSocket
|
|
345
|
+
|
|
346
|
+
`client`'s `ws` option accepts any `ClientWs` (`{ send, onMessage }`). `wsTransport` is a
|
|
347
|
+
production-ready one with lazy connect, reconnect (exponential backoff + jitter, capped),
|
|
348
|
+
**resubscribe** of live channels after reconnect, in-flight call failure on drop, and an
|
|
349
|
+
optional heartbeat. It speaks only `WebSocket` + JSON, so it stays zod-free and ships in the
|
|
350
|
+
`@ayepi/core/client` entry.
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
function wsTransport(url: string | (() => string), opts?: WsTransportOptions): WsTransport
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
> **Option names have no `Ms` suffix.** Durations are plain numbers in **milliseconds**
|
|
357
|
+
> (`interval`, `timeout`, `initial`, `max`) — there is no `intervalMs` etc.
|
|
358
|
+
|
|
359
|
+
### Authenticating the connection
|
|
360
|
+
|
|
361
|
+
Browsers **can't set headers** on a WebSocket handshake, so a bearer token can't ride an
|
|
362
|
+
`Authorization` header the way HTTP requests do (where `client({ headers })` handles it).
|
|
363
|
+
Instead, pass `url` (or `protocols`) as a **function** — it's re-resolved at each (re)connect,
|
|
364
|
+
so you can carry a token that isn't known until after login (as a query param or subprotocol):
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
const token = ref('') // set after a REST login call
|
|
368
|
+
const sdk = client<typeof api>({
|
|
369
|
+
baseUrl: location.origin,
|
|
370
|
+
manifest,
|
|
371
|
+
headers: () => (token.value ? { authorization: `Bearer ${token.value}` } : {}), // HTTP auth
|
|
372
|
+
ws: wsTransport(() => `wss://api.example.dev/ws?access_token=${token.value}`), // ws auth
|
|
373
|
+
})
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
The transport connects **lazily** (on the first `sdk.on(...)` / ws call), so if you only
|
|
377
|
+
subscribe after login the token is always present. On the server, `@ayepi/auth`'s `bearerAuth`
|
|
378
|
+
reads that `?access_token=` query param over ws by default (it's on the upgrade request) — see
|
|
379
|
+
`ayepi-auth.md`. Never sign tokens on the client; mint them server-side at login and pass the
|
|
380
|
+
result.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
interface WsTransportOptions {
|
|
384
|
+
readonly protocols?: string | string[] | (() => string | string[] | undefined) // value, or resolved per (re)connect
|
|
385
|
+
readonly WebSocket?: WebSocketCtor // ctor override (defaults to global; pass `ws` in Node)
|
|
386
|
+
readonly whileDisconnected?: 'queue' | 'fail' // non-sub frames while down (default 'fail' = reject immediately)
|
|
387
|
+
readonly backoff?: BackoffOptions // reconnect backoff tuning
|
|
388
|
+
readonly heartbeat?: HeartbeatOptions | false // heartbeat tuning, or false to disable (default enabled)
|
|
389
|
+
readonly maxRetries?: number // give up after N consecutive failed reconnects (default Infinity)
|
|
390
|
+
readonly onStateChange?: (state: WsState) => void // 'closed' | 'connecting' | 'open'
|
|
391
|
+
readonly onError?: (error: unknown) => void
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
interface BackoffOptions {
|
|
395
|
+
readonly initial?: number // first retry delay, ms (default 500)
|
|
396
|
+
readonly max?: number // max retry delay, ms (default 30_000)
|
|
397
|
+
readonly factor?: number // growth factor per attempt (default 2)
|
|
398
|
+
readonly jitter?: boolean // random jitter in [delay/2, delay] (default true)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface HeartbeatOptions {
|
|
402
|
+
readonly interval?: number // ms between { ping: true } (default 30_000)
|
|
403
|
+
readonly timeout?: number // ms to await { pong: true } before force-reconnect (default 10_000)
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
`WsTransport` extends `ClientWs` with explicit lifecycle control:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
interface WsTransport extends ClientWs {
|
|
411
|
+
connect(): void // open now (otherwise lazy on first send)
|
|
412
|
+
close(): void // close permanently, stop reconnecting
|
|
413
|
+
readonly state: WsState // 'closed' | 'connecting' | 'open'
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const sdk = client<typeof api>({
|
|
419
|
+
baseUrl: 'https://api.example.dev',
|
|
420
|
+
manifest,
|
|
421
|
+
ws: wsTransport('wss://api.example.dev/ws', {
|
|
422
|
+
heartbeat: { interval: 30_000, timeout: 10_000 },
|
|
423
|
+
backoff: { initial: 500, max: 30_000 },
|
|
424
|
+
whileDisconnected: 'queue',
|
|
425
|
+
}),
|
|
426
|
+
})
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
## Exported client symbols
|
|
430
|
+
|
|
431
|
+
`client`, `wsTransport`, and the types `ApiClient`, `ClientOptions`, `ClientWs`,
|
|
432
|
+
`GetUrlKeys`, `WsTransport`, `WsTransportOptions`, `WsState`, `BackoffOptions`,
|
|
433
|
+
`HeartbeatOptions`, `WebSocketLike`, `WebSocketCtor`, `WsMessageEvent`. Plus `ApiError` /
|
|
434
|
+
`reject` from errors, and the payload types in `ayepi-core-types.md` (incl. `UploadProgress`,
|
|
435
|
+
the `onUploadProgress` shape).
|
|
436
|
+
|
|
437
|
+
**Callers** (this doc's [Callers](#callers--client-side-call-policy) section): `createClientCache`,
|
|
438
|
+
`createCallerContext`, `stableStringify`, `CallerRateLimited`, and the types `Caller`,
|
|
439
|
+
`CallerOptions`, `CallerCacheConfig`, `CallerDebounceConfig`, `CallerRateLimitConfig`,
|
|
440
|
+
`CallerRetryConfig`, `Tagger`, `ClientCache`, `ClientCacheOptions`, `CallerContext`,
|
|
441
|
+
`CacheStoreSpec`, `CacheHit`, `KVStore`.
|