stitchkit 0.107.0 → 0.108.1

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.
Files changed (102) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +2 -0
  3. package/darwin-native.cjs +1 -0
  4. package/dist/agent-runtime/sandbox-process-owner.d.ts +3 -3
  5. package/dist/agent-runtime/sandbox-process-owner.d.ts.map +1 -1
  6. package/dist/agent-runtime/sandbox-process.d.ts +2 -2
  7. package/dist/agent-runtime/sandbox-process.d.ts.map +1 -1
  8. package/dist/agent-runtime/store-conformance-support.d.ts +5 -5
  9. package/dist/browser/stream.d.ts +38 -12
  10. package/dist/browser/stream.d.ts.map +1 -1
  11. package/dist/entrypoints/agent-runtime/coding-tools.js +2 -2
  12. package/dist/entrypoints/agent-runtime/sandbox.js +2 -2
  13. package/dist/entrypoints/application/diagnostic-journal.js +2 -2
  14. package/dist/entrypoints/application/directory-inbox.js +4 -4
  15. package/dist/entrypoints/bin/upgrade-cli.d.ts +32 -0
  16. package/dist/entrypoints/bin/upgrade-cli.d.ts.map +1 -1
  17. package/dist/entrypoints/bin/upgrade-cli.js +451 -8
  18. package/dist/entrypoints/cli/publish.js +3 -3
  19. package/dist/entrypoints/files/packaging.d.ts +3 -1
  20. package/dist/entrypoints/files/packaging.d.ts.map +1 -1
  21. package/dist/entrypoints/files/packaging.js +105 -41
  22. package/dist/entrypoints/files.js +3 -3
  23. package/dist/entrypoints/index.d.ts +1 -1
  24. package/dist/entrypoints/index.d.ts.map +1 -1
  25. package/dist/entrypoints/index.js +7 -1
  26. package/dist/entrypoints/process.js +2 -2
  27. package/dist/entrypoints/remote.js +1 -1
  28. package/dist/entrypoints/server.js +4 -4
  29. package/dist/entrypoints/telegram/screens.js +1 -1
  30. package/dist/entrypoints/telegram.d.ts +2 -0
  31. package/dist/entrypoints/telegram.d.ts.map +1 -1
  32. package/dist/entrypoints/telegram.js +337 -3
  33. package/dist/entrypoints/testing.d.ts +1 -0
  34. package/dist/entrypoints/testing.d.ts.map +1 -1
  35. package/dist/entrypoints/testing.js +131 -1
  36. package/dist/files/native-artifact.d.ts +14 -0
  37. package/dist/files/native-artifact.d.ts.map +1 -0
  38. package/dist/files/native-packaging-layout.d.ts +5 -0
  39. package/dist/files/native-packaging-layout.d.ts.map +1 -1
  40. package/dist/files/native-packaging-plugin.d.ts +53 -0
  41. package/dist/files/native-packaging-plugin.d.ts.map +1 -0
  42. package/dist/files/native-packaging.d.ts +1 -35
  43. package/dist/files/native-packaging.d.ts.map +1 -1
  44. package/dist/index-a5qr2rj6.js +26 -0
  45. package/dist/{index-7r74re56.js → index-bh2ht9y5.js} +12 -6
  46. package/dist/{index-dwf0gymy.js → index-e35py42g.js} +1 -1
  47. package/dist/{index-4q9jvqg7.js → index-epg514ys.js} +182 -13
  48. package/dist/{index-mbf4h8kp.js → index-nx49x0tw.js} +1 -1
  49. package/dist/{index-zsqt5q98.js → index-p5kx5vqd.js} +209 -54
  50. package/dist/{index-bnf8s6v3.js → index-t2bsd0hm.js} +1 -1
  51. package/dist/{index-4f37d3bg.js → index-w9mqgnz9.js} +1 -1
  52. package/dist/{index-e05cjgtf.js → index-xvj9h8yh.js} +28 -1
  53. package/dist/internal/bounded-lines.d.ts +46 -2
  54. package/dist/internal/bounded-lines.d.ts.map +1 -1
  55. package/dist/internal/process-identity.d.ts.map +1 -1
  56. package/dist/internal/until-aborted.d.ts +9 -0
  57. package/dist/internal/until-aborted.d.ts.map +1 -0
  58. package/dist/internal/upgrade-affects.d.ts +58 -0
  59. package/dist/internal/upgrade-affects.d.ts.map +1 -0
  60. package/dist/internal/upgrade-plan.d.ts +20 -0
  61. package/dist/internal/upgrade-plan.d.ts.map +1 -1
  62. package/dist/internal/upgrade-usage.d.ts +61 -0
  63. package/dist/internal/upgrade-usage.d.ts.map +1 -0
  64. package/dist/process/bun-child.d.ts +38 -0
  65. package/dist/process/bun-child.d.ts.map +1 -0
  66. package/dist/process/command-owner.d.ts +2 -2
  67. package/dist/process/command-owner.d.ts.map +1 -1
  68. package/dist/process/launch.d.ts +2 -2
  69. package/dist/process/launch.d.ts.map +1 -1
  70. package/dist/process/owned-child.d.ts +2 -1
  71. package/dist/process/owned-child.d.ts.map +1 -1
  72. package/dist/telegram/bot-api.d.ts +10 -4
  73. package/dist/telegram/bot-api.d.ts.map +1 -1
  74. package/dist/telegram/broadcast.d.ts +2 -1
  75. package/dist/telegram/broadcast.d.ts.map +1 -1
  76. package/dist/telegram/not-dispatched.d.ts +26 -0
  77. package/dist/telegram/not-dispatched.d.ts.map +1 -0
  78. package/dist/telegram/operator-channel.d.ts +2 -1
  79. package/dist/telegram/operator-channel.d.ts.map +1 -1
  80. package/dist/telegram/send-failure.d.ts +4 -3
  81. package/dist/telegram/send-failure.d.ts.map +1 -1
  82. package/dist/telegram/transport-connect.d.ts +38 -0
  83. package/dist/telegram/transport-connect.d.ts.map +1 -0
  84. package/dist/telegram/transport.d.ts +43 -0
  85. package/dist/telegram/transport.d.ts.map +1 -0
  86. package/dist/telegram/webhook.d.ts +2 -1
  87. package/dist/telegram/webhook.d.ts.map +1 -1
  88. package/dist/testing/manual-clock.d.ts +82 -0
  89. package/dist/testing/manual-clock.d.ts.map +1 -0
  90. package/llms/files-packaging.txt +4 -1
  91. package/llms/process.txt +19 -5
  92. package/llms/stitchkit.2.txt +211 -628
  93. package/llms/stitchkit.txt +635 -10
  94. package/llms/telegram.txt +36 -3
  95. package/llms/testing.txt +47 -0
  96. package/llms/{upgrading-0.98.0-0.107.0.txt → upgrading-0.100.0-0.108.0.txt} +59 -64
  97. package/llms/{upgrading-0.72.0-0.97.0.txt → upgrading-0.72.0-0.99.0.txt} +64 -3
  98. package/llms/upgrading.txt +34 -2
  99. package/llms-full.txt +246 -17
  100. package/llms.txt +8 -8
  101. package/package.json +1 -1
  102. package/llms/stitchkit.3.txt +0 -177
@@ -1,13 +1,11 @@
1
- # stitchkit — stitchkit docs slice, part 2 of 3
1
+ # stitchkit — stitchkit docs slice, part 2 of 2
2
2
 
3
3
  > Generated from the `docs/` tree by `bun run gen:llms`. The index of all slices is
4
4
  > `llms.txt` in this package.
5
- > Continues in `llms/stitchkit.3.txt`.
6
5
 
7
6
  Parts of this slice:
8
- - `llms/stitchkit.txt`: Guide: Getting started (docs/guide/getting-started.md)
9
- - `llms/stitchkit.2.txt`: Guide: Typed client (docs/guide/client.md)
10
- - `llms/stitchkit.3.txt`: API reference: `stitchkit` (docs/api/reference.md)
7
+ - `llms/stitchkit.txt`: Guide: Getting started (docs/guide/getting-started.md) · Guide: Typed client (docs/guide/client.md)
8
+ - `llms/stitchkit.2.txt`: Guide: Typed client (docs/guide/client.md) · API reference: `stitchkit` (docs/api/reference.md)
11
9
 
12
10
  Also read:
13
11
  - see `llms/contract.txt` for the Contracts guide
@@ -15,631 +13,9 @@ Also read:
15
13
  - see `llms/server.txt` for the Auth & errors guide
16
14
 
17
15
  ==============================================================================
18
- # Guide: Typed client (docs/guide/client.md)
16
+ # Guide: Typed client (docs/guide/client.md) (continued)
19
17
  ==============================================================================
20
18
 
21
- # Typed client
22
-
23
- From a contract, `createClient` builds a fully-typed client — one method per
24
- endpoint, arguments and result inferred from the schemas. There is no codegen
25
- step: the types come straight from the contract import.
26
-
27
- ## `createHttpClient`
28
-
29
- The HTTP client is the transport. It wraps [`ky`](https://github.com/sindresorhus/ky)
30
- and adds cookie auth, SSR cookie forwarding, error parsing into `ApiError`, a
31
- `401 → unauthorized` event stream and safe transport retry.
32
-
33
- ```ts
34
- import { contractEndpointMatchers, createHttpClient } from 'stitchkit'
35
- import { publicAuth } from '../shared/contracts'
36
-
37
- const http = createHttpClient({
38
- baseUrl: '/api',
39
- suppressUnauthorizedFor: contractEndpointMatchers(publicAuth, ['complete', 'verify']),
40
- })
41
- ```
42
-
43
- The returned `ConfiguredHttpClient` keeps that `baseUrl` as a readonly public
44
- field. Besides executing requests, it can therefore seed contract URL builders
45
- without repeating transport configuration.
46
-
47
- ### `HttpClientConfig`
48
-
49
- | Field | Default | Purpose |
50
- |-------|---------|---------|
51
- | `baseUrl` | — | URL prefix for every request |
52
- | `timeout` | `30000` | request timeout, ms |
53
- | `credentials` | `'include'` | fetch credentials mode |
54
- | `retry` | 2 retries after the initial GET; network errors only | transport retry policy |
55
- | `headers` | — | extra headers — an object, or a function re-run per request |
56
- | `suppressUnauthorizedFor` | `[]` | exact contract-derived operation matchers whose expected 401 does not emit `unauthorized` |
57
- | `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
58
- | `trace` | `false` | emit a W3C `traceparent` header on every request |
59
- | `unix` | — | dial a unix domain socket instead of TCP (Bun only, see below) |
60
-
61
- `headers` as a function is the hook for runtime tokens — a bearer token or any
62
- short-lived credential — re-evaluated on every request.
63
-
64
- Expected 401 policy is explicit and contract-driven. Name the operations you
65
- mean with `contractEndpointMatchers(contract, ['login'])`, or omit the second
66
- argument to select every HTTP operation in that contract. Pass the same
67
- `ContractClientConfig` as the typed client when routes use a static or dynamic
68
- `pathPrefix`; dynamic matchers require `stripPrefixKeys`, so the helper can
69
- compile the prefix structure without a concrete tenant id.
70
-
71
- **Matching is by PATH, not by operation.** Each named operation compiles to its
72
- path pattern, matched exactly by segments including params and trailing
73
- wildcards — so a shared *prefix* never suppresses a neighbour, but two
74
- operations on the **same path** are one matcher. If `login` is `POST /session`
75
- and `session` is `GET /session`, naming `login` suppresses the expected-401
76
- signal for both, and a real expired session stops raising `unauthorized`. Give
77
- an operation you must distinguish a path of its own, or narrow the policy with
78
- your own `(pathname) => boolean`.
79
-
80
- ### Unix domain sockets
81
-
82
- The same typed client dials a local daemon's socket file through an explicit,
83
- owned Bun/Node transport ([server side](server.md#local-daemon-over-a-unix-socket)):
84
-
85
- ```ts
86
- import { createHttpClient } from 'stitchkit'
87
- import { createUnixClientTransport } from 'stitchkit/server' // or stitchkit/node
88
-
89
- const transport = createUnixClientTransport({
90
- socketPath: '/run/my-daemon.sock',
91
- maxRequestBytes: 4 * 1024 * 1024,
92
- maxResponseBytes: 16 * 1024 * 1024,
93
- })
94
- const http = createHttpClient({
95
- baseUrl: 'http://my-daemon', // URL/Host source; never dialled as TCP
96
- fetch: transport.fetch,
97
- retry: { limit: 0 },
98
- })
99
- const daemon = createClient(daemonContract, http)
100
-
101
- // At application shutdown:
102
- await transport.close()
103
- ```
104
-
105
- `baseUrl` stays required because it supplies the URL and `Host` header. The
106
- adapter structurally owns dispatch: relative and absolute redirects use the same
107
- socket, and a missing socket cannot fall through to that host over TCP. Defaults
108
- are 16 MiB request/response bodies, 64 KiB headers, 30 s to response headers,
109
- eight connections and five redirects.
110
-
111
- `maxHeaderBytes` is enforced before exposing a response. Bun's owned parser
112
- counts the complete response head in wire bytes, including the status line and
113
- terminating blank line. Node passes the same configured integer unchanged to
114
- `http.request({ maxHeaderSize })`, so its native parser's documented header-size
115
- accounting is authoritative there. Exceeding either ceiling yields
116
- `UNIX_HEADERS_TOO_LARGE` with `delivery: 'response-received'`. A stalled body
117
- pauses its socket, and resuming preserves the exact chunked body.
118
-
119
- `UnixClientTransportError` carries a stable `code` and `delivery`:
120
- `not-dispatched`, `possibly-dispatched` or `response-received`. Only the first
121
- proves that the remote operation did not begin; Stitchkit never silently retries
122
- an ambiguous write. Response consumption/cancellation belongs to the operation,
123
- and `close()` interrupts active work and destroys owned connections.
124
-
125
- **A caller's cancellation reaches the server on both lanes.** The transport picks
126
- a raw-socket lane under Bun and `node:http` elsewhere, and both propagate
127
- `AbortSignal` the same way: aborting the request tears the connection down, and
128
- the handler's `ctx.signal` fires — measured at roughly 300 ms for a 200 ms abort
129
- over either lane, matching plain TCP. `close()` is a different thing: it ends the
130
- *transport*, not one request, and a request-level abort does not close it.
131
-
132
- The one way to see cancellation appear not to work is to pass the signal to the
133
- plain callable — `api.thing(args, { signal })` — where options are ignored in
134
- silence. See [per-call cancellation](#per-call-cancellation): `withOptions` is the
135
- only door.
136
-
137
- The response total is a unary-body policy, not a stream-buffer measurement. A
138
- long-lived NDJSON/SSE client opts into streaming explicitly instead of choosing
139
- an arbitrarily large integer:
140
-
141
- ```ts
142
- const streamTransport = createUnixClientTransport({
143
- socketPath: '/run/my-daemon.sock',
144
- responseBodyMode: 'streaming',
145
- })
146
- const streamClient = createClient(streamContract, {
147
- baseUrl: 'http://my-daemon',
148
- fetch: streamTransport.fetch,
149
- })
150
- ```
151
-
152
- `streaming` removes only the cumulative lifetime response limit. It cannot be
153
- combined with `maxResponseBytes`; request/header/connection bounds, socket
154
- pause/resume, cancellation and strict HTTP framing remain active. The stream
155
- descriptor's `maxFrameBytes` bounds each protocol frame, and the application
156
- still owns any queue it builds after consuming those frames. Use a separate
157
- default transport for finite calls that must retain the 16 MiB unary ceiling.
158
- The adapter never infers this policy from `Content-Type`. → ADR 0125.
159
-
160
- The legacy `createHttpClient({ unix: '/absolute/path' })` spelling remains a
161
- Bun-only convenience. On a non-Bun runtime it now refuses before dispatch
162
- instead of ignoring the selection and dialing TCP. `unix` and an injected
163
- `fetch` are mutually exclusive; use `createUnixClientTransport` for portable,
164
- explicit lifecycle ownership. → ADR 0116.
165
-
166
- `trace: true` mints a fresh root trace per request. The stitchkit server
167
- [continues an inbound `traceparent`](./observability.md#trace-context), so the
168
- browser call, the HTTP handler and every nested tool call share one trace id
169
- end-to-end. A `traceparent` you set yourself (via `headers`) always wins.
170
-
171
- Retry is deliberately conservative: only a connection that never landed (a
172
- network error), only on idempotent `GET`. A server that *responded* with a 5xx
173
- is the data layer's call (TanStack Query), not the transport's — retrying in
174
- both places multiplies attempts. `retry.limit` counts retries after the initial
175
- attempt, so the default `2` permits at most three total attempts. The default
176
- `statusCodes: []` does not replay HTTP responses; explicit `methods` or
177
- `statusCodes` expand that policy when a project has a proven idempotent case.
178
-
179
- Inside Next.js 16 server rendering, the first attempt still uses Next's normal
180
- request memoization. If Ky authorizes a retry after a network rejection,
181
- Stitchkit passes that retry's current `Request.signal` in the second fetch
182
- argument and materializes the current Request as URL + init. Next 16.3 otherwise
183
- merges `init` into a Request before its dedupe layer and loses the explicit
184
- signal opt-out. The retry therefore performs a new network attempt instead of
185
- returning the cached rejection. This adapter does not broaden retry policy: POST, unconfigured HTTP
186
- statuses, cancellation and exhausted budgets retain the rules above.
187
-
188
- ## `createClient`
189
-
190
- ```ts
191
- import { createClient } from 'stitchkit'
192
- import { users } from '../shared/contracts'
193
-
194
- export const api = createClient(users, http)
195
-
196
- await api.list() // GET /users
197
- await api.create({ name: 'Max' }) // POST /users body: { name }
198
- await api.get({ id: '1' }) // GET /users/1
199
- await api.update({ id: '1', name: 'M' }) // PUT /users/1 body: { name }
200
- await api.delete({ id: '1' }) // DELETE /users/1
201
- ```
202
-
203
- The client follows response presence from the contract, not from the HTTP
204
- status or a truthy runtime value. An endpoint with nullable `output` resolves
205
- JSON `null` as `null`; an endpoint without `output` resolves `undefined`,
206
- including an explicitly declared empty `200` or `205`. A missing body for a
207
- declared output, or a body for an endpoint with no output, fails loudly instead
208
- of changing the typed result.
209
-
210
- Validation runs in one direction. The response is checked against the endpoint's
211
- `output` schema before it reaches the caller; arguments are **not** checked
212
- against `input` or `params` before the request is sent. A value the contract
213
- forbids travels to the server and comes back as a `VALIDATION_ERROR` — `400`,
214
- naming every offending field in the `message` and again in `details.issues` as
215
- `{ path, code, message }`. That holds for a JSON body, a query string and a path
216
- parameter alike, so a rejected argument always costs a round trip.
217
-
218
- It is tempting to close that gap locally: the schemas are on the contract object,
219
- and `input` is the very Zod object the server validates with, so parsing it before
220
- the call looks free. **It is not, and it fails in the direction that breaks
221
- working code.** A call's arguments and the server's `input` are not the same
222
- value, for two independent reasons.
223
-
224
- An argument object is typed as the intersection of `params`, `input` and any
225
- multipart fields, so `input` covers one factor of it. Parsed over the whole
226
- object it silently strips the keys it does not own — or, being `.strict()`,
227
- refuses them:
228
-
229
- ```ts
230
- // params: { id }, input: z.object({ text }).strict()
231
- contract.endpoints.update.input.safeParse({ id: 'item-1', text: 'hello' })
232
- // → refused: unrecognized_keys: ["id"] the server accepts this call
233
- ```
234
-
235
- And on a query string or a multipart body the wire carries strings while the
236
- caller holds live values, so a coercing schema answers differently on each side:
237
-
238
- ```ts
239
- // input: z.object({ flag: z.coerce.boolean() })
240
- api.list({ flag: false })
241
- // local safeParse({ flag: false }) → false
242
- // server safeParse({ flag: 'false' }) → true z.coerce.boolean()('false') is true
243
- ```
244
-
245
- And a plain JSON body is not the safe exception it looks like, because it still
246
- crosses `JSON.stringify`. A schema holding any type that does not survive that
247
- round trip — `z.date()`, `z.map()`, `z.set()`, `z.bigint()`, `z.instanceof(...)`,
248
- or `NaN` / `Infinity` inside a `z.number()` — agrees locally and refuses on the
249
- server:
250
-
251
- ```ts
252
- // input: z.object({ when: z.date() }), POST, no path params
253
- check({ when: new Date() }) // accepted: a live Date
254
- // server // refused: expected date, received string
255
- ```
256
-
257
- This one fails in the safe direction — nothing valid is blocked — but "safe" is
258
- about traffic, not about diagnostics. The check stays silent exactly where it
259
- promised to speak: the refusal then arrives from the server, through a different
260
- path and without the field names the local gate would have given. A gate that
261
- lets through what it was added to catch is not a cheap gate, it is a quiet one.
262
-
263
- One schema, one call, opposite answers — inside a single release, with no version
264
- skew involved. Argument validation therefore stays on the server, which is the
265
- only side that sees what was actually sent. A rejected argument costs a round
266
- trip, and the refusal names every offending field.
267
-
268
- An explicit contract `HEAD` operation is exposed like any other typed method.
269
- Because HEAD endpoints are `rawResponse`, it resolves to the untouched
270
- `Response`, giving the caller direct access to status and headers without JSON
271
- parsing:
272
-
273
- ```ts
274
- const response = await assets.head({ name: 'clip.mp4' })
275
- console.log(response.headers.get('content-length'))
276
- ```
277
-
278
- Each call takes one argument object. The client routes each field by the
279
- contract:
280
-
281
- - a **path param** (`:id`) is substituted into the URL,
282
- - a named terminal wildcard (`/*filePath`) consumes that field and preserves its
283
- path segments (`{ filePath: 'a/b' }` → `/a/b`, not `/%2Fa%2Fb` or a query field),
284
- - for `GET` / `DELETE`, the remaining fields become the **query string**
285
- (arrays become repeated keys),
286
- - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
287
- - a single `multipart.files` field is a `Blob` (web / Bun) or platform
288
- `FileDescriptor` (`{ uri, name, type }`, React Native / Expo); a multiple
289
- field is an array and is appended under the same form field name in order.
290
- The exported `MultipartFile` / `FileDescriptor` types let you annotate your
291
- own upload helpers.
292
-
293
- ### Per-call cancellation
294
-
295
- Every endpoint callable exposes a `withOptions` method accepting required
296
- `ClientRequestOptions`. The ordinary callable contains only contract arguments,
297
- so it can be passed directly to callback APIs such as `react-query-kit` without
298
- mistaking their callback context for Stitchkit transport options.
299
-
300
- **That safety has a cost worth knowing before it bites you.** `api.thing(args,
301
- { signal })` — the shape everyone reaches for first — is not an error: the second
302
- argument is ignored, in silence, and the request runs to completion while the
303
- caller believes it was cancelled or bounded. TypeScript refuses the extra
304
- argument at a typed call site and says nothing at an untyped one, which is where
305
- the mistake actually happens. Nothing can catch it at runtime **on the bare
306
- callable**: probing its second argument is exactly what the callback safety above
307
- forbids, since reading a foreign object's `signal` may execute someone else's
308
- getter. So the rule is simply this — **per-call options only ever go through
309
- `withOptions`**, and a cancellation that appears to do nothing is the first thing
310
- to check.
311
-
312
- `withOptions` itself is guarded, because there the count settles it without
313
- reading anything. Its arity depends on the endpoint — one argument when there is
314
- no contract input, two when there is — and a call carrying more throws a
315
- `TypeError` naming the endpoint and the correct shape. That guard exists because
316
- the silent version of this mistake is expensive: the options are dropped, the
317
- request goes out uncancelled, the caller still receives `REQUEST_ABORTED`
318
- (cancellation is decided from the signal it was handed, not from what the
319
- transport did), and the server runs the operation to its own deadline. Every
320
- symptom then points at the transport, and the investigation goes there.
321
-
322
- ```ts
323
- const controller = new AbortController()
324
-
325
- const pending = api.upload.withOptions(
326
- { file: selectedFile, title: 'Draft' },
327
- { signal: controller.signal },
328
- )
329
-
330
- controller.abort()
331
- await pending
332
- ```
333
-
334
- Caller cancellation and the endpoint/client timeout are composed; whichever
335
- fires first owns the result. Both the bare-fetch and Ky-backed clients expose
336
- the same client-only errors:
337
-
338
- | Failure | `ApiError.code` | `status` |
339
- |---------|-----------------|----------|
340
- | arguments refused before sending | `VALIDATION_ERROR` | `0` |
341
- | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
342
- | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
343
- | other transport failure | `UNKNOWN_ERROR` | `0` |
344
-
345
- `status: 0` is what separates a refusal made here from one made by the server:
346
- the same `VALIDATION_ERROR` arrives with `400` when the server refused, and
347
- `details.issues` is the same `{ path, code, message }` array either way, so one
348
- rendering serves both. A refusal raised here is always a **rejection** — on both
349
- transports, for every call shape — and it never reaches the server. The client
350
- refuses this way for a missing path param, a missing scoped prefix key, a
351
- non-flat field in a `GET` input, and a missing or invalid multipart file. → ADR
352
- 0148.
353
-
354
- Abort and timeout do not emit `network_error` and are not retried. The same
355
- options work for query, JSON, multipart and raw-response calls. A body call
356
- also takes `onUploadProgress` — see [Upload progress](#upload-progress).
357
-
358
- An injected transport failure is normalized to `UNKNOWN_ERROR` while its exact
359
- object remains available as `ApiError.cause`. This is how a bounded adapter can
360
- preserve facts the generic client cannot invent — for example a Unix transport's
361
- `not-dispatched`, `possibly-dispatched` or `response-received` state. Never replay
362
- an effect from `UNKNOWN_ERROR` alone; inspect the owned adapter's cause and retry
363
- only when it proves that dispatch did not happen.
364
-
365
- For an endpoint without contract arguments, pass only the options object:
366
-
367
- ```ts
368
- await api.health.withOptions({ signal: controller.signal })
369
- ```
370
-
371
- ### Upload progress
372
-
373
- A call with a body can report it leaving:
374
-
375
- > **In a browser or React Native, do not give the client its own `fetch` for
376
- > a call you want counted.** Without one the count comes from
377
- > `XMLHttpRequest` upload events; with one the body is streamed to it, which a
378
- > browser may buffer (and report at once) and React Native refuses.
379
-
380
- ```ts
381
- await api.recordings.upload.withOptions(
382
- { file, title: 'Take 3' },
383
- {
384
- signal,
385
- onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
386
- bar.value = sentBytes / totalBytes
387
- },
388
- },
389
- )
390
- ```
391
-
392
- - `sentBytes` counts bytes of the encoded body handed to the network — not
393
- bytes the server accepted. `totalBytes` is the encoded length, multipart
394
- boundaries included. The first report is `0`, values only grow, and the last
395
- one of an attempt equals `totalBytes`.
396
- - A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
397
- next `attempt`.
398
- - Without the option nothing changes: the request takes the path it always did.
399
- - A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
400
- `TypeError` at the call — it has no body to count, or a live response the
401
- browser route would buffer. A listener that throws does not fail the upload.
402
-
403
- Fetch has no upload event, so the count comes from one of three routes, chosen
404
- per call:
405
-
406
- | Where the client runs | Route | What counts |
407
- |---|---|---|
408
- | Bun, Node, SSR, an injected `fetch`, a unix socket | the body is encoded once and sent as a stream of 64 KiB pieces with an exact `content-length` | a piece counts when the transport pulls it |
409
- | a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
410
- | React Native / Expo on its own fetch | the platform's `XMLHttpRequest`, handed the body as the client built it — the `FormData` itself, so a `{ uri, name, type }` file streams from disk; the same errors, cancellation and timeout | `xhr.upload` progress events; a `FormData`'s `totalBytes` comes with the first one |
411
-
412
- The browser route exists because a streaming request body is not portable:
413
- Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
414
- XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
415
- and the response is read whole before the call resolves. An injected `fetch`
416
- always takes the stream route — it owns the I/O, and a transport that buffers
417
- the stream before sending reports the whole body at once.
418
-
419
- React Native is recognised by `navigator.product === 'ReactNative'`. Its
420
- credentials follow its own fetch: `include` and `omit` set `withCredentials`,
421
- anything else keeps the platform default. An injected `fetch` there is refused
422
- with a `TypeError` at the call: React Native cannot stream a request body, so
423
- there is nothing to count.
424
-
425
- Off React Native a counted body is encoded in memory once. A file larger than one request
426
- belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
427
-
428
- ### Injected delivery adapters
429
-
430
- `ClientFetch` is the narrow composition seam for application-owned delivery.
431
- The contract still owns operation identity, request serialization and response
432
- validation; the adapter owns I/O, bounds and dispatch certainty:
433
-
434
- ```ts
435
- import { type ClientFetch, createClient } from 'stitchkit'
436
-
437
- const delivery: ClientFetch = async (input, init) => {
438
- // Fixed deployment destination and policy are chosen here, not by caller data.
439
- return boundedFetch(input, { ...init, signal: init?.signal })
440
- }
441
-
442
- const query = createClient(queryContract, {
443
- baseUrl: 'https://service.internal',
444
- fetch: delivery,
445
- })
446
- const work = createClient(requestWorkContract, {
447
- baseUrl: 'https://service.internal',
448
- fetch: delivery,
449
- })
450
- ```
451
-
452
- The same adapter accepts unrelated contract shapes without learning their DTOs
453
- or operation inventory. `createClient` chooses method/path from the contract,
454
- forwards `.withOptions(..., { signal })` and validates the response through the
455
- endpoint's output schema. A caller payload cannot replace the configured base URL
456
- or reserved operation path.
457
-
458
- It does **not** filter your arguments down to the declared ones. Keys the contract
459
- does not declare are consumed for the path where they belong there and otherwise
460
- sent as they are — `api.list({ q: 'hello', note: 'internal' })` puts
461
- `?q=hello&note=internal` on the wire, and the server drops `note` only because its
462
- schema is not `.strict()`. Nothing was validated away on this side: pass the
463
- object the contract declares, not a wider one it happens to contain.
464
-
465
- Contract metadata describes an effect; it does not authorize one. Authentication,
466
- scope and destination policy remain in the application/server boundary. A schema
467
- mismatch is an explicit validation failure and is not retried automatically. A
468
- timeout means the caller stopped waiting, not that the remote effect did not run.
469
-
470
- Published declarations are checked from a clean tarball under both bundler and
471
- NodeNext resolution. The HTTP-only NodeNext proof uses `skipLibCheck: false` and
472
- installs no Socket.IO peer. → ADR 0120.
473
-
474
- ### Many contracts at once
475
-
476
- ```ts
477
- import { createClients } from 'stitchkit'
478
-
479
- export const api = createClients({ users, posts, billing }, http)
480
- await api.users.list()
481
- await api.posts.create({ title: 'Hi' })
482
- ```
483
-
484
- When contracts use different path-prefix rules, route the same registry by the
485
- scope already declared in each contract. An array composes contracts with
486
- different scopes into one logical namespace:
487
-
488
- ```ts
489
- const api = createScopedClients(
490
- { auth: [publicAuth, authenticatedAuth], widgets },
491
- http,
492
- {
493
- public: {},
494
- user: {},
495
- tenant: {
496
- stripPrefixKeys: ['tenantId'],
497
- pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
498
- },
499
- },
500
- )
501
-
502
- await api.auth.login()
503
- await api.auth.me()
504
- await api.widgets.list({ tenantId: 't1' })
505
- ```
506
-
507
- Every scope present in the registry needs a config. Unknown/missing scopes and
508
- duplicate method names inside a composed namespace fail before a request runs.
509
-
510
- `createClients` builds one typed client per contract from a registry — list the
511
- contracts once, get the whole API typed. It accepts the same optional scoped
512
- config as `createClient`, so a whole registry can share one resource prefix:
513
-
514
- ```ts
515
- const tenantApi = createClients({ users, posts }, http, {
516
- stripPrefixKeys: ['tenantId'],
517
- pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
518
- })
519
- ```
520
-
521
- Every method now requires `tenantId`, and the callback sees it as a `string`.
522
- The batch form delegates to the same single-contract client runtime, including
523
- HTTP exposure filtering, multipart, raw responses and output validation.
524
-
525
- ## Chunked uploads
526
-
527
- A file larger than one request goes as three calls of the application's own
528
- contract — open, parts, finish — driven by `uploadInChunks` on the client and
529
- held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
530
- the application's: its fields, its limits, what happens to the finished file.
531
-
532
- ```ts
533
- // shared: one part size for both halves
534
- export const CHUNK_BYTES = 4 * 1024 * 1024
535
-
536
- // contract
537
- init: {
538
- method: 'POST', path: '/uploads', desc: 'Open an upload',
539
- input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
540
- output: z.object({ finished: Accepted.optional() }),
541
- },
542
- chunk: {
543
- method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
544
- params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
545
- multipart: { files: { bytes: {} } },
546
- output: z.object({ state: z.enum(['stored', 'repeated']) }),
547
- },
548
- finalize: {
549
- method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
550
- params: z.object({ uploadId: z.string() }),
551
- output: Accepted,
552
- },
553
- ```
554
-
555
- ```ts
556
- // server
557
- import { createChunkSpool } from 'stitchkit/files'
558
-
559
- const spool = createChunkSpool({
560
- directory: env.UPLOAD_SPOOL_DIR,
561
- chunkBytes: CHUNK_BYTES,
562
- maxFileBytes: 1024 * 1024 * 1024,
563
- meta: z.object({ name: z.string() }),
564
- })
565
-
566
- implement(contract, {
567
- init: async ({ input, ctx }) => {
568
- const done = await findFinished(input.uploadId) // the app's own record
569
- if (done) return { finished: done }
570
- await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
571
- return {}
572
- },
573
- chunk: async ({ params, files, ctx }) => ({
574
- state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
575
- }),
576
- finalize: async ({ params, ctx }) => {
577
- const key = { owner: ctx.user.id, uploadId: params.uploadId }
578
- const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
579
- const accepted = await store(file)
580
- await spool.discard(key)
581
- return accepted
582
- },
583
- })
584
- ```
585
-
586
- ```ts
587
- // client — Bun, Node, a browser or React Native
588
- import { uploadInChunks } from 'stitchkit'
589
-
590
- const accepted = await uploadInChunks({
591
- file,
592
- chunkBytes: CHUNK_BYTES,
593
- signal,
594
- onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
595
- init: async (start, options) => {
596
- const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
597
- return opened.finished ? { finished: opened.finished } : undefined
598
- },
599
- chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
600
- finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
601
- })
602
- ```
603
-
604
- The rules the two halves share:
605
-
606
- - **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
607
- call), so a repeated `init` is the same upload. The spool keys it by `owner`
608
- too — the same id from another owner is another upload. To resume after the
609
- app restarts instead of sending the file again, pass a stable `uploadId` —
610
- the id of the app's own job or attempt.
611
- - **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
612
- A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
613
- Expo `File`, passed without a cast. A part's `bytes` is what `slice`
614
- returns, so the part call hands it to the client as the multipart file.
615
- - **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
616
- the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
617
- (409). So the driver repeats `init` and a part on a failure that may pass — no
618
- answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
619
- with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
620
- - **`finalize` is never repeated.** After it the application owns the file and
621
- the parts are gone; a lost answer is recovered by `init` returning
622
- `{ finished }`, which the driver hands back without sending a byte.
623
- - **Cancellation is checked between parts**, so a cancelled upload never
624
- reaches `finalize`.
625
- - **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
626
- up; the spool refuses anything else with a 4xx `AppError`
627
- (`ChunkSpoolErrorCode`), which a handler can let through as the client's
628
- typed error. An application that maps error codes onto its own must map
629
- these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
630
- `stitchkit/files` — or a refusal reaches its client as an unknown 500.
631
- - **Progress counts file bytes**, inside a part too — the driver passes
632
- `onUploadProgress` into the part call, so the [upload
633
- progress](#upload-progress) route of the client applies. A repeated part
634
- counts again from its start.
635
- - The first writer of a part wins across processes: its data file is named by
636
- its hash and its receipt is published by an exclusive link. `sweep()` removes
637
- uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
638
-
639
- Part size is a latency decision: every part costs a round trip, so on a
640
- 200 ms link a smaller part buys smoother progress at the price of a slower
641
- upload.
642
-
643
19
  ## Contract URL builders
644
20
 
645
21
  Browser-native consumers such as `<img src>`, downloads and navigation need a
@@ -981,6 +357,47 @@ Set `finalLine: 'require-newline'` when the final newline is part of the
981
357
  protocol's truncation proof. The default `allow` continues to accept one valid
982
358
  final JSON document without a newline.
983
359
 
360
+ ### Any byte source
361
+
362
+ `parseNDJSON` (and `parseSSE`) read more than a `Response`: a `ReadableStream<Uint8Array>`
363
+ (a fetch body, a Bun child's `stdout`) or any `AsyncIterable<Uint8Array>` (a Node child's
364
+ `stdout`). `signal` stops reading and cancels or returns the source:
365
+
366
+ ```ts
367
+ import { parseNDJSON } from 'stitchkit'
368
+
369
+ const child = Bun.spawn(['worker'], { stdout: 'pipe' })
370
+ for await (const progress of parseNDJSON(child.stdout, {
371
+ maxLineBytes: 64 * 1024,
372
+ finalLine: 'require-newline',
373
+ })) report(progress)
374
+ ```
375
+
376
+ A source that hands over bytes through a callback — a `Bun.listen` or `Bun.connect` socket's
377
+ `data` handler — uses `createNDJSONDecoder`, the same reader driven by pushes:
378
+
379
+ ```ts
380
+ import { createNDJSONDecoder } from 'stitchkit'
381
+
382
+ const decoder = createNDJSONDecoder<ControlRequest>({ maxLineBytes: 16 * 1024 })
383
+ Bun.listen({
384
+ unix: socketPath,
385
+ socket: {
386
+ data(socket, bytes) {
387
+ for (const request of decoder.push(bytes)) handle(socket, request)
388
+ },
389
+ },
390
+ })
391
+ ```
392
+
393
+ Use one decoder per connection. Lines are split in bytes and decoded only once complete, so a
394
+ character split across chunks is never torn. A line that crosses `maxLineBytes` throws
395
+ `StreamLineLimitError` (`limitBytes`, `lineBytes`) at the chunk that crossed it: the reader
396
+ retains at most the limit, however long the line. With `finalLine: 'require-newline'`, a source
397
+ that ends inside a line throws `StreamTruncatedLineError` (`lineBytes`) instead of yielding the
398
+ fragment as a value — the right policy for a socket or a child process, where a missing newline
399
+ means the writer died. Both errors extend the `RangeError` / `SyntaxError` they replace.
400
+
984
401
  ## Resumable streams
985
402
 
986
403
  A stream that survives a dropped connection needs four things beyond opening it:
@@ -1064,3 +481,169 @@ may be retried once; do not replay a multi-step callback or treat any `403` as e
1064
481
  This is per-instance coordination. Route all writes through it; shared tabs/processes
1065
482
  need storage-level locking. Replace the local session counter and refresh queue when
1066
483
  adopting these primitives instead of retaining two independent lifetimes.
484
+
485
+ ==============================================================================
486
+ # API reference: `stitchkit` (docs/api/reference.md)
487
+ ==============================================================================
488
+
489
+ ## `stitchkit`
490
+
491
+ The browser-and-server entrypoint. Re-exports everything from
492
+ [`stitchkit/contract`](#stitchkitcontract), plus the client and browser realtime.
493
+
494
+ ### Client
495
+
496
+ | Export | Kind | Summary |
497
+ |--------|------|---------|
498
+ | `createClient` | function | build a typed client from a contract — [guide](../guide/client.md#createclient) |
499
+ | `createClients` | function | build one exact typed client per contract from a registry; accepts the same scoped config and transports as `createClient` |
500
+ | `createScopedClients` | function | build one registry routed by contract scope; arrays compose contracts into one namespace |
501
+ | `createScopedUrlBuilders` | function | build one URL registry routed by contract scope; arrays compose contracts into one namespace |
502
+ | `ScopeClientConfigs` | _type_ | per-scope routing configuration consumed by scoped clients and URL builders |
503
+ | `ScopedClientRegistry` | _type_ | exact composed registry returned by `createScopedClients` |
504
+ | `ScopedUrlBuilderRegistry` | _type_ | exact composed registry returned by `createScopedUrlBuilders` |
505
+ | `ClientRegistryValue` | _type_ | one contract or a contract array composing one client namespace |
506
+ | `ClientContract` | _type_ | HTTP-client-compatible contract value used by scoped registries |
507
+ | `RegistryScope` | _type_ | contract-scope union inferred from a scoped client registry value |
508
+ | `createUrlBuilder` | function | build synchronous browser-native URLs for all HTTP endpoints; body methods accept URL-bound args only — [guide](../guide/client.md#contract-url-builders) |
509
+ | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
510
+ | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
511
+ | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
512
+ | `ClientFetch` | _type_ | injectable Fetch-compatible transport used by framework and application-owned adapters |
513
+ | `ClientRequestOptions` | _type_ | per-call `{ signal?, onUploadProgress? }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
514
+ | `UploadProgress` | _type_ | `{ sentBytes, totalBytes, attempt }` heard by `onUploadProgress`: bytes of the encoded body handed to the network, first `0`, last `totalBytes`, counted again on each transport retry — [guide](../guide/client.md#upload-progress) |
515
+ | `uploadInChunks` | function | send a file larger than one request as `init` → parts → `finalize` through three calls the app owns; client-minted id, repeats of a part on a failure that may pass, `finalize` never repeated, cancel checked between parts, progress in file bytes — [guide](../guide/client.md#chunked-uploads) |
516
+ | `ChunkedUploadConfig` / `ChunkedUploadStart` / `ChunkedUploadPart` / `ChunkedUploadProgress` | _type_ | the driver's file, part size, retries and three calls; what `init` declares; one part; progress `{ sentBytes, totalBytes, index, chunkCount, attempt }` |
517
+ | `ChunkSource` | _type_ | what the driver reads a file through — `size` and `slice(start, end)`; a `Blob` is one, so is a platform file that is not (an Expo `File`), and a part's `bytes` is what its `slice` returns |
518
+ | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
519
+ | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
520
+ | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
521
+ | `createHttpClient` | function | the Ky-based HTTP transport; on Next.js SSR its first attempt stays request-memoizable while every retry is a distinct transport attempt — [guide](../guide/client.md#createhttpclient) |
522
+ | `ApiError` | class | a non-2xx or client failure, with `code` / `status` / `details` / `hint`, optional readonly `traceId` from `x-request-id`, and standard `cause` preserving an injected transport failure |
523
+ | `zodIssues` | function | a `ZodError` → structured `{ path, code, message }[]` — the exact projection a `VALIDATION_ERROR` carries in `ApiError.details.issues`, so a caller renders one shape. Also on `stitchkit/server`; same name, one definition |
524
+ | `ZodIssueSummary` | _type_ | one structured validation issue (`{ path, code, message }`) — the element type of `details.issues`. Also on `stitchkit/server` |
525
+ | `formatZodError` | function | a `ZodError` → a readable, field-summarised string. Also on `stitchkit/server` |
526
+ | `HttpClient` | _type_ | the transport interface `createClient` builds on |
527
+ | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
528
+ | `HttpClientConfig` | _type_ | config for `createHttpClient`; retry `limit` counts retries after the initial attempt (default 2 = at most 3 GET attempts), with `statusCodes: []` by default; `fetch` installs an explicit transport and is mutually exclusive with the legacy Bun-only `unix` option — [details](../guide/client.md#createhttpclient) |
529
+ | `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
530
+ | `RequestOptions` | _type_ | per-call options — params, timeout, response type, `onUploadProgress` |
531
+ | `HeaderProvider` | _type_ | static or per-request headers |
532
+ | `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
533
+ | `ApiEventListener` | _type_ | an `ApiEvent` handler |
534
+
535
+ ### Realtime (client) & streaming
536
+
537
+ | Export | Kind | Summary |
538
+ |--------|------|---------|
539
+ | `createSocketIOClient` | function | low-level typed Socket.IO transport primitive — [guide](../guide/realtime.md#low-level-transport) |
540
+ | `defineRealtimeContract` | function | Zod-first shared Socket.IO event contract — [guide](../guide/realtime.md#zod-first-event-contract) |
541
+ | `createRealtimeClient` | function | inferred, runtime-validated Socket.IO client — [guide](../guide/realtime.md#client--createrealtimeclient) |
542
+ | `bindRealtimeClient` | function | bind contract validation and typed acknowledgements to an existing Stitchkit client transport without owning its lifecycle |
543
+ | `createLiveStateController` | function | keep typed application state current across one source-owned snapshot/event generation with finite pre-snapshot buffering, generation fencing and explicit resync — [guide](../guide/realtime.md#snapshot--event-state-synchronization) |
544
+ | `LiveStateController` | _type_ | renderer-neutral `start` / `resync` / `getSnapshot` / `subscribe` / `close` handle |
545
+ | `LiveStateControllerConfig` | _type_ | typed source, reducer, explicit event/byte bounds, event sizing and isolated error hooks |
546
+ | `LiveStateControllerSnapshot` / `LiveStateControllerStatus` | _types_ | current value plus phase, generation, buffer and application/duplicate/gap/refusal counters |
547
+ | `LiveStateControllerStatusSchema` | schema | strict runtime validation for controller status metadata |
548
+ | `LiveStatePhaseSchema` / `LiveStatePhase` | schema / _type_ | `idle`, `opening`, `live`, `resync-required`, `unavailable` or `closed` |
549
+ | `LiveStateStopReasonSchema` / `LiveStateStopReason` | schema / _type_ | explicit gap, overflow, source loss, controller failure and bounded `controller-capacity` reasons |
550
+ | `LiveStateEventDecision` | _type_ | provider-owned reducer result: applied state, duplicate or gap |
551
+ | `LiveStateSource` / `LiveStateSourceOpenInput` / `LiveStateSourceOpenResult` | _types_ | host binding for one continuous snapshot/event boundary; transport retry and cursor semantics remain host-owned |
552
+ | `LiveStateControllerError` / `LiveStateSubscriberError` | _types_ | isolated observer failure payloads that do not change source or subscriber truth |
553
+ | `createRetainedTopics` | function | retained last-value store for sticky events — [guide](../guide/realtime.md#sticky-events) |
554
+ | `parseSSE` | function | parse SSE from a `Response`, a web stream or an async byte iterable into an async generator — [guide](../guide/client.md#sse) |
555
+ | `parseNDJSON` | function | parse bounded fatal-UTF-8 NDJSON from a `Response`, a `ReadableStream<Uint8Array>` or an `AsyncIterable<Uint8Array>` (a child's stdout); blank keep-alives are skipped, `finalLine: 'require-newline'` makes the delimiter mandatory and `signal` cancels the source — [guide](../guide/client.md#any-byte-source) |
556
+ | `createNDJSONDecoder` / `NDJSONDecoder` | function / _type_ | the same bounded NDJSON reading driven by `push(chunk)` / `end()`, for a socket `data` callback |
557
+ | `StreamByteSource` | _type_ | `Response \| ReadableStream<Uint8Array> \| AsyncIterable<Uint8Array>`, what the NDJSON and SSE readers accept |
558
+ | `StreamLineLimitError` / `StreamTruncatedLineError` | class | a line past `maxLineBytes` (`limitBytes`, `lineBytes`; a `RangeError`) and a source ended inside a line under `require-newline` (`lineBytes`; a `SyntaxError`) |
559
+ | `resumableIterator` | function | re-open a long-lived stream from the last delivered cursor, with jittered backoff, a caller-owned terminal item and prompt abort — [guide](../guide/client.md#resumable-streams) |
560
+ | `createBackoff` | function | exponential backoff with subtractive jitter as a value: `next()` / `reset()` |
561
+ | `ContractStreamFrameSchema` / `ContractStreamFrame` | schema / _type_ | default on-the-wire `data` / safe `error` / `end` envelope of a contract-first stream |
562
+ | `ContractStreamFraming` / `ContractStreamCompletion` | _types_ | opt-in item-vs-envelope framing and terminal-vs-stream-end completion policies |
563
+ | `StreamFinalLinePolicy` | _type_ | permissive or newline-required final NDJSON line policy |
564
+ | `DEFAULT_CONTRACT_STREAM_FRAME_BYTES` | const | default maximum encoded contract-stream frame: 256 KiB |
565
+ | `SocketIOClient` | _type_ | low-level client handle; `emit` reports disconnected drops and `emitWithAck` exposes the native Promise primitive used by validated `request()` |
566
+ | `SocketIOClientPeerLoaders` | _type_ | inject `socket.io-client` so a bundler can put it in a self-contained artifact |
567
+ | `SocketIOClientConfig` | _type_ | config for `createSocketIOClient` (incl. `retain`, `onConnectError`, `onDroppedEmit`) |
568
+ | `SocketEventMap` | _type_ | the shape of an event map |
569
+ | `RealtimeClient` | _type_ | validated client inferred from a realtime contract |
570
+ | `RealtimeClientOptions` | _type_ | transport options plus rejected-event and metadata-only acknowledged-request phase hooks for `createRealtimeClient` |
571
+ | `BoundRealtimeClient` | _type_ | validated non-owning `on`/`emit`/`request` client with no `connect`/`disconnect` |
572
+ | `RealtimeClientTransport` | _type_ | minimal existing transport capability accepted by `bindRealtimeClient` |
573
+ | `BindRealtimeClientOptions` | _type_ | rejection/logger options for a bound existing transport |
574
+ | `RealtimeAcknowledgedEvent` | _type_ | event-name union restricted to definitions with an `ack` schema |
575
+ | `RealtimeAcknowledgement` | _type_ | validated acknowledgement output inferred from an event definition |
576
+ | `RealtimeRequestArguments` | _type_ | request arguments inferred from an acknowledged event tuple |
577
+ | `RealtimeRequestOptions` | _type_ | finite positive native acknowledgement `timeoutMs` plus an optional invocation-scoped `onPhase` observer |
578
+ | `RealtimeRequestPhaseSchema` / `RealtimeRequestPhase` | schema / _type_ | closed `engine-handoff` / `engine-ack-received` / `settled` / `timeout` / `disconnected` lifecycle |
579
+ | `RealtimeRequestPhaseEventSchema` / `RealtimeRequestPhaseEvent` | schema / _type_ | strict metadata-only `{ requestId, event, phase, elapsedMs }` observation |
580
+ | `RealtimeRequestPhaseHook` | _type_ | isolated sync/async observer accepted globally by `RealtimeClientOptions.onRequestPhase` or per invocation by `RealtimeRequestOptions.onPhase` |
581
+ | `RealtimeRequestTimeoutError` | class | stable `REALTIME_REQUEST_TIMEOUT` rejection |
582
+ | `RealtimeRequestDisconnectedError` | class | stable `REALTIME_REQUEST_DISCONNECTED` rejection, including an immediate disconnected call |
583
+ | `RealtimeRequestInvalidAcknowledgementError` | class | invalid ack was reported through `onRejected` and the request rejected |
584
+ | `RealtimeRequestRejectedError` | class | the peer refused the frame against its own contract and said so — `reason`, `issues` — instead of leaving the sender to time out ([ADR 0106](../decisions/0106-a-refused-frame-answers-its-sender.md)) |
585
+ | `REALTIME_REJECTION_KEY` | const | the reserved acknowledgement key a refusal travels under |
586
+ | `RealtimeRejectionEnvelope` | _type_ | the wire shape of a refusal |
587
+ | `RealtimeRejectionReport` | _type_ | what the sender is told: event, reason, message, issues |
588
+ | `RealtimeRejectionIssue` | _type_ | one refused field, already flattened (`path: '0.v'`) |
589
+ | `asRealtimeRejection` | function | recognise a refusal in an acknowledgement value, validating it |
590
+ | `RealtimeContract` | _type_ | shared server-to-client and client-to-server event registries |
591
+ | `RealtimeEventRegistry` | _type_ | string-keyed registry of event definitions |
592
+ | `RealtimeEventDefinition` | _type_ | one tuple-shaped event and optional acknowledgement schema |
593
+ | `RealtimeEventArguments` | _type_ | tuple inferred from an event definition |
594
+ | `RealtimeEmitArguments` | _type_ | emit arguments including an inferred acknowledgement callback |
595
+ | `RealtimeEventHandler` | _type_ | handler inferred from an event definition |
596
+ | `InferRealtimeEventMap` | _type_ | inferred Socket.IO-compatible event map |
597
+ | `RealtimeRejectDirection` / `RealtimeRejectPhase` / `RealtimeRejectReason` / `RealtimeRejectFault` | _types_ | canonical inferred rejection direction, validation phase, reason and fault classification |
598
+ | `RealtimeRejectedEvent` | _type_ | structured rejected event with event, direction, phase, reason and fault |
599
+ | `RealtimeRejectedEventHook` | _type_ | sync/async observer for structured realtime rejections |
600
+ | `ValidatedRealtimeSocket` | _type_ | runtime-validating `on`/`emit` surface inferred from registries; `emit` returns "accepted by the transport" (`false` only for a client-side disconnected drop) |
601
+ | `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
602
+ | `ParseSSEOptions` | _type_ | options for `parseSSE` |
603
+ | `ParseNDJSONOptions` | _type_ | options for `parseNDJSON` |
604
+ | `BackoffPolicySchema` / `BackoffPolicy` | schema / _type_ | `minDelayMs`, `maxDelayMs` and a `0`–`1` jitter fraction; a ceiling below the floor is refused |
605
+ | `Backoff` | _type_ | the backoff handle returned by `createBackoff` |
606
+ | `ResumableIteratorConfig` | _type_ | caller-owned `open` / `advance` / `isTerminal`, retry policy, abort signal and `onAttempt` observer |
607
+ | `ResumableAttempt` | _type_ | attempt number, the delay about to be waited and the error that caused it |
608
+
609
+ ### Trace (client)
610
+
611
+ Browser-safe W3C trace helpers — the same functions as on
612
+ [`stitchkit/observability`](#stitchkitobservability), re-exported so a client
613
+ can emit / propagate a `traceparent` (see `HttpClientConfig.trace`).
614
+
615
+ | Export | Kind | Summary |
616
+ |--------|------|---------|
617
+ | `createTraceContext` | function | a fresh root trace |
618
+ | `formatTraceparent` | function | render a `traceparent` header value |
619
+ | `parseTraceparent` | function | parse a `traceparent` header |
620
+ | `childSpan` | function | a child span of a parent trace |
621
+ | `TraceContext` | _type_ | `{ traceId, spanId, parentSpanId?, tracestate?, baggage? }` |
622
+
623
+ ---
624
+
625
+ ### Session lifetime and credential coordination
626
+
627
+ `createSessionScope` (`stitchkit`) returns the generic `SessionScope`: `replace(context)`
628
+ creates a new login lifetime (including same-account relogin), `capture()` returns
629
+ its `SessionOperation<T>`, `clear()` invalidates it, `stop()` permanently closes
630
+ the host, and `owns(operation)` answers whether an operation is this scope's current
631
+ one — `current()` alone cannot tell an operation of another scope. Operations expose `id`, `generation`, `context`, `signal`, `current()` and
632
+ `assertCurrent()`. `run(callback)` checks both success and failure across an await;
633
+ `deliver(callback)` admits a synchronous final send/state change or returns false.
634
+ `bindFetch(fetch, headers?)` guards every attempt and scopes its abort signal. Wrap
635
+ the complete client call in `run` too: headers are not the response body boundary.
636
+ Expired work throws `SessionExpiredError`, not a refresh instruction; when the work
637
+ itself failed, that failure is its `cause`.
638
+
639
+ `createSessionCredentials` accepts `SessionCredentialsConfig`:
640
+ `scope`, `refresh(credentials, signal)`, `storage.write(session, credentials)`,
641
+ `storage.clear()`, `maxPendingWrites` (128), `refreshTimeoutMs` (30000, at most one
642
+ hour) and `storageTimeoutMs` (30000, at most one hour): a storage call past it
643
+ rejects and frees the queue, so `logout()` still clears; when the late call settles,
644
+ storage is written again from memory, or cleared when nobody is logged in. `SessionCredentials` provides `login`, `logout`, `read(operation)` and
645
+ `refresh(operation)`. Concurrent refresh calls in one generation share the same
646
+ promise. Logout invalidates memory before its returned persistence promise settles.
647
+ Storage errors and queue-capacity refusals reject; callers must handle them. All
648
+ writes must pass through the same instance. There is no cross-process/tab lock,
649
+ provider error classifier or automatic retry of arbitrary application callbacks.