stitchkit 0.107.0 → 0.108.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/CHANGELOG.md +99 -0
- package/README.md +2 -0
- package/darwin-native.cjs +1 -0
- package/dist/browser/stream.d.ts +38 -12
- package/dist/browser/stream.d.ts.map +1 -1
- package/dist/entrypoints/application/diagnostic-journal.js +2 -2
- package/dist/entrypoints/application/directory-inbox.js +4 -4
- package/dist/entrypoints/bin/upgrade-cli.d.ts +32 -0
- package/dist/entrypoints/bin/upgrade-cli.d.ts.map +1 -1
- package/dist/entrypoints/bin/upgrade-cli.js +451 -8
- package/dist/entrypoints/cli/publish.js +3 -3
- package/dist/entrypoints/files/packaging.d.ts +3 -1
- package/dist/entrypoints/files/packaging.d.ts.map +1 -1
- package/dist/entrypoints/files/packaging.js +105 -41
- package/dist/entrypoints/files.js +3 -3
- package/dist/entrypoints/index.d.ts +1 -1
- package/dist/entrypoints/index.d.ts.map +1 -1
- package/dist/entrypoints/index.js +7 -1
- package/dist/entrypoints/process.js +1 -1
- package/dist/entrypoints/remote.js +1 -1
- package/dist/entrypoints/server.js +4 -4
- package/dist/entrypoints/telegram/screens.js +1 -1
- package/dist/entrypoints/telegram.d.ts +2 -0
- package/dist/entrypoints/telegram.d.ts.map +1 -1
- package/dist/entrypoints/telegram.js +337 -3
- package/dist/entrypoints/testing.d.ts +1 -0
- package/dist/entrypoints/testing.d.ts.map +1 -1
- package/dist/entrypoints/testing.js +131 -1
- package/dist/files/native-artifact.d.ts +14 -0
- package/dist/files/native-artifact.d.ts.map +1 -0
- package/dist/files/native-packaging-layout.d.ts +5 -0
- package/dist/files/native-packaging-layout.d.ts.map +1 -1
- package/dist/files/native-packaging-plugin.d.ts +53 -0
- package/dist/files/native-packaging-plugin.d.ts.map +1 -0
- package/dist/files/native-packaging.d.ts +1 -35
- package/dist/files/native-packaging.d.ts.map +1 -1
- package/dist/index-a5qr2rj6.js +26 -0
- package/dist/{index-7r74re56.js → index-bh2ht9y5.js} +12 -6
- package/dist/{index-mbf4h8kp.js → index-nx49x0tw.js} +1 -1
- package/dist/{index-zsqt5q98.js → index-p5kx5vqd.js} +209 -54
- package/dist/{index-bnf8s6v3.js → index-t2bsd0hm.js} +1 -1
- package/dist/{index-4f37d3bg.js → index-w9mqgnz9.js} +1 -1
- package/dist/{index-e05cjgtf.js → index-xvj9h8yh.js} +28 -1
- package/dist/internal/bounded-lines.d.ts +46 -2
- package/dist/internal/bounded-lines.d.ts.map +1 -1
- package/dist/internal/process-identity.d.ts.map +1 -1
- package/dist/internal/until-aborted.d.ts +9 -0
- package/dist/internal/until-aborted.d.ts.map +1 -0
- package/dist/internal/upgrade-affects.d.ts +58 -0
- package/dist/internal/upgrade-affects.d.ts.map +1 -0
- package/dist/internal/upgrade-plan.d.ts +20 -0
- package/dist/internal/upgrade-plan.d.ts.map +1 -1
- package/dist/internal/upgrade-usage.d.ts +61 -0
- package/dist/internal/upgrade-usage.d.ts.map +1 -0
- package/dist/telegram/bot-api.d.ts +10 -4
- package/dist/telegram/bot-api.d.ts.map +1 -1
- package/dist/telegram/broadcast.d.ts +2 -1
- package/dist/telegram/broadcast.d.ts.map +1 -1
- package/dist/telegram/not-dispatched.d.ts +26 -0
- package/dist/telegram/not-dispatched.d.ts.map +1 -0
- package/dist/telegram/operator-channel.d.ts +2 -1
- package/dist/telegram/operator-channel.d.ts.map +1 -1
- package/dist/telegram/send-failure.d.ts +4 -3
- package/dist/telegram/send-failure.d.ts.map +1 -1
- package/dist/telegram/transport-connect.d.ts +38 -0
- package/dist/telegram/transport-connect.d.ts.map +1 -0
- package/dist/telegram/transport.d.ts +43 -0
- package/dist/telegram/transport.d.ts.map +1 -0
- package/dist/telegram/webhook.d.ts +2 -1
- package/dist/telegram/webhook.d.ts.map +1 -1
- package/dist/testing/manual-clock.d.ts +82 -0
- package/dist/testing/manual-clock.d.ts.map +1 -0
- package/llms/files-packaging.txt +4 -1
- package/llms/process.txt +19 -5
- package/llms/stitchkit.2.txt +211 -628
- package/llms/stitchkit.txt +635 -10
- package/llms/telegram.txt +36 -3
- package/llms/testing.txt +47 -0
- package/llms/{upgrading-0.98.0-0.107.0.txt → upgrading-0.100.0-0.108.0.txt} +59 -64
- package/llms/{upgrading-0.72.0-0.97.0.txt → upgrading-0.72.0-0.99.0.txt} +64 -3
- package/llms/upgrading.txt +34 -2
- package/llms-full.txt +246 -17
- package/llms.txt +8 -8
- package/package.json +1 -1
- package/llms/stitchkit.3.txt +0 -177
package/llms/stitchkit.2.txt
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
# stitchkit — stitchkit docs slice, part 2 of
|
|
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¬e=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.
|