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,12 @@
1
- # stitchkit — stitchkit docs slice, part 1 of 3
1
+ # stitchkit — stitchkit docs slice, part 1 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
5
  > Continues in `llms/stitchkit.2.txt`.
6
6
 
7
7
  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)
8
+ - `llms/stitchkit.txt`: Guide: Getting started (docs/guide/getting-started.md) · Guide: Typed client (docs/guide/client.md)
9
+ - `llms/stitchkit.2.txt`: Guide: Typed client (docs/guide/client.md) · API reference: `stitchkit` (docs/api/reference.md)
11
10
 
12
11
  Also read:
13
12
  - see `llms/contract.txt` for the Contracts guide
@@ -71,9 +70,9 @@ own, recorded as an ADR.
71
70
  | `stitchkit/cli/publish` | build and release tooling (Bun or Node, POSIX) | evolving | `publishCli` — commit a verified CLI distribution and move its public manifest last; see [CLI guide](cli.md#publishing-a-complete-version) |
72
71
  | `stitchkit/remote` | browser **and** server | stable | peer-free `implementRemote` for thin HTTP proxy processes |
73
72
  | `stitchkit/process` | server (Bun or Node, POSIX) | evolving | finite native commands, byte sinks, bounded capture and process-group cancellation |
74
- | `stitchkit/files/packaging` | build time (Bun or Node) | evolving | installed native asset graph, integrity and Bun packaging plugin; no runtime install policy |
73
+ | `stitchkit/files/packaging` | build time (Bun or Node) | evolving | installed native asset graph, integrity, Bun packaging plugin and the artifact loader check; no runtime install policy |
75
74
  | `stitchkit/files` | server (Bun or Node) | stable | peer-free managed local-file boundary |
76
- | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, local Bot API files, webhook ownership and durable webhook update intake |
75
+ | `stitchkit/telegram` | server (Bun or Node) | evolving | peer-free Telegram platform primitives — Mini App `initData` verification, send-failure classification, resumable broadcasts, the operator channel with dedupe, a Bot API transport that separates a request that never left from an unknown outcome, local Bot API files, webhook ownership and durable webhook update intake |
77
76
  | `stitchkit/telegram/html` | browser and server | evolving | Telegram's HTML as a tree — clean any markup to what Telegram accepts, cut long text into valid parts by what the reader sees, check markup before it is sent |
78
77
  | `stitchkit/telegram/screens` | server (Bun or Node) | evolving | a bot's menus as declared screens over grammY — typed paths, actions and input, message reconciliation, state in any `StorageAdapter`, a test chat |
79
78
  | `stitchkit/tracking` | browser **and** server | evolving | visitor-tracking mechanics — `createTrackingClient`, the tab-shared outbox, the page-leave beacon, attribution, the contract factory; no event vocabulary, no React |
@@ -83,12 +82,12 @@ own, recorded as an ADR.
83
82
  | `stitchkit/geo` | server (Bun or Node) | evolving | managed GeoIP reader generations, last-known-good reload and the optional MaxMind adapter |
84
83
  | `stitchkit/oauth` | browser **and** server | evolving | provider-neutral Authorization Code + PKCE request and one-shot callback transaction mechanics |
85
84
  | `stitchkit/google` | server (Bun or Node) | evolving | optional Google code exchange and verified OIDC identity adapter |
86
- | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 52 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
87
- | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, plus the managed-resource conformance kit |
85
+ | `stitchkit/observability` | server | stable<br>_redefined in 2 of the 53 minors since 0.56.2, most recently 0.92.0_ | request/tool event projections — `createObservability`, trace context, sanitisation |
86
+ | `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler, the managed-resource conformance kit and manual time for schedules and retries |
88
87
  | `stitchkit/declaration` | browser + build and deployment tooling (Bun or Node) | evolving | `ProjectDeclarationSchema` — the one machine-readable statement a repository makes about itself |
89
88
  | `stitchkit/react` | browser + server rendering | stable | `createCursorQuery`, `createCacheBridge`, QueryClient and `ApiError` retry policy |
90
89
  | `stitchkit/react/keyboard` | browser | evolving | which part of a screen gets a key — ordered layers, one Escape per level, list zones with real focus; see [keyboard guide](keyboard.md) |
91
- | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 23 of the 52 minors since 0.56.2, most recently 0.105.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
90
+ | `stitchkit/agent-runtime` | server | evolving<br>_redefined in 23 of the 53 minors since 0.56.2, most recently 0.105.0_ | optional durable conversation/run loop, history, models, prompts, fencing and events |
92
91
  | `stitchkit/agent-runtime/sandbox` | server | evolving | optional Linux Bubblewrap sessions, durable workspaces and a host HTTP credential gateway; see [sandbox guide](sandbox.md) |
93
92
  | `stitchkit/agent-runtime/testing` | tests on Bun or Node | evolving | credential-free replay, scripted provider faults, deterministic race controls and the agent-store conformance kit |
94
93
  | `stitchkit/agent-runtime/harness` | server | evolving | resource-aware process-local facade over the canonical Agent runtime; supervision stays outside |
@@ -100,7 +99,7 @@ own, recorded as an ADR.
100
99
  | `stitchkit/agent-runtime/sqlite/bun` | server (Bun) | evolving | durable built-in SQLite store for the agent runtime |
101
100
  | `stitchkit/agent-runtime/sqlite/node` | server (Node ≥ 22.5) | evolving | durable built-in SQLite store for the agent runtime |
102
101
  | `stitchkit-tui` | terminal (Bun) | evolving | optional official OpenTUI host over a caller-composed headless runtime |
103
- | `stitchkit/application` | browser + server | evolving<br>_redefined in 13 of the 52 minors since 0.56.2, most recently 0.105.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
102
+ | `stitchkit/application` | browser + server | evolving<br>_redefined in 13 of the 53 minors since 0.56.2, most recently 0.105.0_ | managed resource graph, readiness, admission, schedules, subtree restart and bounded shutdown |
104
103
  | `stitchkit/application/grammy` | server | evolving | a grammY bot as application resources — configuration, then polling with updates admitted by batch — and the webhook adapter |
105
104
  | `stitchkit/application/opentelemetry` | server | evolving | maps application snapshots onto an injected OpenTelemetry `Meter` |
106
105
  | `stitchkit/application/schemas` | browser + server | evolving | the application's snapshot, health and shutdown schemas alone, without the kernel |
@@ -272,3 +271,629 @@ optional preferred row. That profile is a thin `stitchkit.agent.ts` composition
272
271
  durable SQLite history, lazy skills, direct coding tools, approval continuations
273
272
  and recovery stay framework-owned primitives, while model choice, permissions,
274
273
  executables and OS isolation remain application policy.
274
+
275
+ ==============================================================================
276
+ # Guide: Typed client (docs/guide/client.md)
277
+ ==============================================================================
278
+
279
+ # Typed client
280
+
281
+ From a contract, `createClient` builds a fully-typed client — one method per
282
+ endpoint, arguments and result inferred from the schemas. There is no codegen
283
+ step: the types come straight from the contract import.
284
+
285
+ ## `createHttpClient`
286
+
287
+ The HTTP client is the transport. It wraps [`ky`](https://github.com/sindresorhus/ky)
288
+ and adds cookie auth, SSR cookie forwarding, error parsing into `ApiError`, a
289
+ `401 → unauthorized` event stream and safe transport retry.
290
+
291
+ ```ts
292
+ import { contractEndpointMatchers, createHttpClient } from 'stitchkit'
293
+ import { publicAuth } from '../shared/contracts'
294
+
295
+ const http = createHttpClient({
296
+ baseUrl: '/api',
297
+ suppressUnauthorizedFor: contractEndpointMatchers(publicAuth, ['complete', 'verify']),
298
+ })
299
+ ```
300
+
301
+ The returned `ConfiguredHttpClient` keeps that `baseUrl` as a readonly public
302
+ field. Besides executing requests, it can therefore seed contract URL builders
303
+ without repeating transport configuration.
304
+
305
+ ### `HttpClientConfig`
306
+
307
+ | Field | Default | Purpose |
308
+ |-------|---------|---------|
309
+ | `baseUrl` | — | URL prefix for every request |
310
+ | `timeout` | `30000` | request timeout, ms |
311
+ | `credentials` | `'include'` | fetch credentials mode |
312
+ | `retry` | 2 retries after the initial GET; network errors only | transport retry policy |
313
+ | `headers` | — | extra headers — an object, or a function re-run per request |
314
+ | `suppressUnauthorizedFor` | `[]` | exact contract-derived operation matchers whose expected 401 does not emit `unauthorized` |
315
+ | `parseError` | built-in | map an error body to `{ code, message, details, hint }` |
316
+ | `trace` | `false` | emit a W3C `traceparent` header on every request |
317
+ | `unix` | — | dial a unix domain socket instead of TCP (Bun only, see below) |
318
+
319
+ `headers` as a function is the hook for runtime tokens — a bearer token or any
320
+ short-lived credential — re-evaluated on every request.
321
+
322
+ Expected 401 policy is explicit and contract-driven. Name the operations you
323
+ mean with `contractEndpointMatchers(contract, ['login'])`, or omit the second
324
+ argument to select every HTTP operation in that contract. Pass the same
325
+ `ContractClientConfig` as the typed client when routes use a static or dynamic
326
+ `pathPrefix`; dynamic matchers require `stripPrefixKeys`, so the helper can
327
+ compile the prefix structure without a concrete tenant id.
328
+
329
+ **Matching is by PATH, not by operation.** Each named operation compiles to its
330
+ path pattern, matched exactly by segments including params and trailing
331
+ wildcards — so a shared *prefix* never suppresses a neighbour, but two
332
+ operations on the **same path** are one matcher. If `login` is `POST /session`
333
+ and `session` is `GET /session`, naming `login` suppresses the expected-401
334
+ signal for both, and a real expired session stops raising `unauthorized`. Give
335
+ an operation you must distinguish a path of its own, or narrow the policy with
336
+ your own `(pathname) => boolean`.
337
+
338
+ ### Unix domain sockets
339
+
340
+ The same typed client dials a local daemon's socket file through an explicit,
341
+ owned Bun/Node transport ([server side](server.md#local-daemon-over-a-unix-socket)):
342
+
343
+ ```ts
344
+ import { createHttpClient } from 'stitchkit'
345
+ import { createUnixClientTransport } from 'stitchkit/server' // or stitchkit/node
346
+
347
+ const transport = createUnixClientTransport({
348
+ socketPath: '/run/my-daemon.sock',
349
+ maxRequestBytes: 4 * 1024 * 1024,
350
+ maxResponseBytes: 16 * 1024 * 1024,
351
+ })
352
+ const http = createHttpClient({
353
+ baseUrl: 'http://my-daemon', // URL/Host source; never dialled as TCP
354
+ fetch: transport.fetch,
355
+ retry: { limit: 0 },
356
+ })
357
+ const daemon = createClient(daemonContract, http)
358
+
359
+ // At application shutdown:
360
+ await transport.close()
361
+ ```
362
+
363
+ `baseUrl` stays required because it supplies the URL and `Host` header. The
364
+ adapter structurally owns dispatch: relative and absolute redirects use the same
365
+ socket, and a missing socket cannot fall through to that host over TCP. Defaults
366
+ are 16 MiB request/response bodies, 64 KiB headers, 30 s to response headers,
367
+ eight connections and five redirects.
368
+
369
+ `maxHeaderBytes` is enforced before exposing a response. Bun's owned parser
370
+ counts the complete response head in wire bytes, including the status line and
371
+ terminating blank line. Node passes the same configured integer unchanged to
372
+ `http.request({ maxHeaderSize })`, so its native parser's documented header-size
373
+ accounting is authoritative there. Exceeding either ceiling yields
374
+ `UNIX_HEADERS_TOO_LARGE` with `delivery: 'response-received'`. A stalled body
375
+ pauses its socket, and resuming preserves the exact chunked body.
376
+
377
+ `UnixClientTransportError` carries a stable `code` and `delivery`:
378
+ `not-dispatched`, `possibly-dispatched` or `response-received`. Only the first
379
+ proves that the remote operation did not begin; Stitchkit never silently retries
380
+ an ambiguous write. Response consumption/cancellation belongs to the operation,
381
+ and `close()` interrupts active work and destroys owned connections.
382
+
383
+ **A caller's cancellation reaches the server on both lanes.** The transport picks
384
+ a raw-socket lane under Bun and `node:http` elsewhere, and both propagate
385
+ `AbortSignal` the same way: aborting the request tears the connection down, and
386
+ the handler's `ctx.signal` fires — measured at roughly 300 ms for a 200 ms abort
387
+ over either lane, matching plain TCP. `close()` is a different thing: it ends the
388
+ *transport*, not one request, and a request-level abort does not close it.
389
+
390
+ The one way to see cancellation appear not to work is to pass the signal to the
391
+ plain callable — `api.thing(args, { signal })` — where options are ignored in
392
+ silence. See [per-call cancellation](#per-call-cancellation): `withOptions` is the
393
+ only door.
394
+
395
+ The response total is a unary-body policy, not a stream-buffer measurement. A
396
+ long-lived NDJSON/SSE client opts into streaming explicitly instead of choosing
397
+ an arbitrarily large integer:
398
+
399
+ ```ts
400
+ const streamTransport = createUnixClientTransport({
401
+ socketPath: '/run/my-daemon.sock',
402
+ responseBodyMode: 'streaming',
403
+ })
404
+ const streamClient = createClient(streamContract, {
405
+ baseUrl: 'http://my-daemon',
406
+ fetch: streamTransport.fetch,
407
+ })
408
+ ```
409
+
410
+ `streaming` removes only the cumulative lifetime response limit. It cannot be
411
+ combined with `maxResponseBytes`; request/header/connection bounds, socket
412
+ pause/resume, cancellation and strict HTTP framing remain active. The stream
413
+ descriptor's `maxFrameBytes` bounds each protocol frame, and the application
414
+ still owns any queue it builds after consuming those frames. Use a separate
415
+ default transport for finite calls that must retain the 16 MiB unary ceiling.
416
+ The adapter never infers this policy from `Content-Type`. → ADR 0125.
417
+
418
+ The legacy `createHttpClient({ unix: '/absolute/path' })` spelling remains a
419
+ Bun-only convenience. On a non-Bun runtime it now refuses before dispatch
420
+ instead of ignoring the selection and dialing TCP. `unix` and an injected
421
+ `fetch` are mutually exclusive; use `createUnixClientTransport` for portable,
422
+ explicit lifecycle ownership. → ADR 0116.
423
+
424
+ `trace: true` mints a fresh root trace per request. The stitchkit server
425
+ [continues an inbound `traceparent`](./observability.md#trace-context), so the
426
+ browser call, the HTTP handler and every nested tool call share one trace id
427
+ end-to-end. A `traceparent` you set yourself (via `headers`) always wins.
428
+
429
+ Retry is deliberately conservative: only a connection that never landed (a
430
+ network error), only on idempotent `GET`. A server that *responded* with a 5xx
431
+ is the data layer's call (TanStack Query), not the transport's — retrying in
432
+ both places multiplies attempts. `retry.limit` counts retries after the initial
433
+ attempt, so the default `2` permits at most three total attempts. The default
434
+ `statusCodes: []` does not replay HTTP responses; explicit `methods` or
435
+ `statusCodes` expand that policy when a project has a proven idempotent case.
436
+
437
+ Inside Next.js 16 server rendering, the first attempt still uses Next's normal
438
+ request memoization. If Ky authorizes a retry after a network rejection,
439
+ Stitchkit passes that retry's current `Request.signal` in the second fetch
440
+ argument and materializes the current Request as URL + init. Next 16.3 otherwise
441
+ merges `init` into a Request before its dedupe layer and loses the explicit
442
+ signal opt-out. The retry therefore performs a new network attempt instead of
443
+ returning the cached rejection. This adapter does not broaden retry policy: POST, unconfigured HTTP
444
+ statuses, cancellation and exhausted budgets retain the rules above.
445
+
446
+ ## `createClient`
447
+
448
+ ```ts
449
+ import { createClient } from 'stitchkit'
450
+ import { users } from '../shared/contracts'
451
+
452
+ export const api = createClient(users, http)
453
+
454
+ await api.list() // GET /users
455
+ await api.create({ name: 'Max' }) // POST /users body: { name }
456
+ await api.get({ id: '1' }) // GET /users/1
457
+ await api.update({ id: '1', name: 'M' }) // PUT /users/1 body: { name }
458
+ await api.delete({ id: '1' }) // DELETE /users/1
459
+ ```
460
+
461
+ The client follows response presence from the contract, not from the HTTP
462
+ status or a truthy runtime value. An endpoint with nullable `output` resolves
463
+ JSON `null` as `null`; an endpoint without `output` resolves `undefined`,
464
+ including an explicitly declared empty `200` or `205`. A missing body for a
465
+ declared output, or a body for an endpoint with no output, fails loudly instead
466
+ of changing the typed result.
467
+
468
+ Validation runs in one direction. The response is checked against the endpoint's
469
+ `output` schema before it reaches the caller; arguments are **not** checked
470
+ against `input` or `params` before the request is sent. A value the contract
471
+ forbids travels to the server and comes back as a `VALIDATION_ERROR` — `400`,
472
+ naming every offending field in the `message` and again in `details.issues` as
473
+ `{ path, code, message }`. That holds for a JSON body, a query string and a path
474
+ parameter alike, so a rejected argument always costs a round trip.
475
+
476
+ It is tempting to close that gap locally: the schemas are on the contract object,
477
+ and `input` is the very Zod object the server validates with, so parsing it before
478
+ the call looks free. **It is not, and it fails in the direction that breaks
479
+ working code.** A call's arguments and the server's `input` are not the same
480
+ value, for two independent reasons.
481
+
482
+ An argument object is typed as the intersection of `params`, `input` and any
483
+ multipart fields, so `input` covers one factor of it. Parsed over the whole
484
+ object it silently strips the keys it does not own — or, being `.strict()`,
485
+ refuses them:
486
+
487
+ ```ts
488
+ // params: { id }, input: z.object({ text }).strict()
489
+ contract.endpoints.update.input.safeParse({ id: 'item-1', text: 'hello' })
490
+ // → refused: unrecognized_keys: ["id"] the server accepts this call
491
+ ```
492
+
493
+ And on a query string or a multipart body the wire carries strings while the
494
+ caller holds live values, so a coercing schema answers differently on each side:
495
+
496
+ ```ts
497
+ // input: z.object({ flag: z.coerce.boolean() })
498
+ api.list({ flag: false })
499
+ // local safeParse({ flag: false }) → false
500
+ // server safeParse({ flag: 'false' }) → true z.coerce.boolean()('false') is true
501
+ ```
502
+
503
+ And a plain JSON body is not the safe exception it looks like, because it still
504
+ crosses `JSON.stringify`. A schema holding any type that does not survive that
505
+ round trip — `z.date()`, `z.map()`, `z.set()`, `z.bigint()`, `z.instanceof(...)`,
506
+ or `NaN` / `Infinity` inside a `z.number()` — agrees locally and refuses on the
507
+ server:
508
+
509
+ ```ts
510
+ // input: z.object({ when: z.date() }), POST, no path params
511
+ check({ when: new Date() }) // accepted: a live Date
512
+ // server // refused: expected date, received string
513
+ ```
514
+
515
+ This one fails in the safe direction — nothing valid is blocked — but "safe" is
516
+ about traffic, not about diagnostics. The check stays silent exactly where it
517
+ promised to speak: the refusal then arrives from the server, through a different
518
+ path and without the field names the local gate would have given. A gate that
519
+ lets through what it was added to catch is not a cheap gate, it is a quiet one.
520
+
521
+ One schema, one call, opposite answers — inside a single release, with no version
522
+ skew involved. Argument validation therefore stays on the server, which is the
523
+ only side that sees what was actually sent. A rejected argument costs a round
524
+ trip, and the refusal names every offending field.
525
+
526
+ An explicit contract `HEAD` operation is exposed like any other typed method.
527
+ Because HEAD endpoints are `rawResponse`, it resolves to the untouched
528
+ `Response`, giving the caller direct access to status and headers without JSON
529
+ parsing:
530
+
531
+ ```ts
532
+ const response = await assets.head({ name: 'clip.mp4' })
533
+ console.log(response.headers.get('content-length'))
534
+ ```
535
+
536
+ Each call takes one argument object. The client routes each field by the
537
+ contract:
538
+
539
+ - a **path param** (`:id`) is substituted into the URL,
540
+ - a named terminal wildcard (`/*filePath`) consumes that field and preserves its
541
+ path segments (`{ filePath: 'a/b' }` → `/a/b`, not `/%2Fa%2Fb` or a query field),
542
+ - for `GET` / `DELETE`, the remaining fields become the **query string**
543
+ (arrays become repeated keys),
544
+ - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
545
+ - a single `multipart.files` field is a `Blob` (web / Bun) or platform
546
+ `FileDescriptor` (`{ uri, name, type }`, React Native / Expo); a multiple
547
+ field is an array and is appended under the same form field name in order.
548
+ The exported `MultipartFile` / `FileDescriptor` types let you annotate your
549
+ own upload helpers.
550
+
551
+ ### Per-call cancellation
552
+
553
+ Every endpoint callable exposes a `withOptions` method accepting required
554
+ `ClientRequestOptions`. The ordinary callable contains only contract arguments,
555
+ so it can be passed directly to callback APIs such as `react-query-kit` without
556
+ mistaking their callback context for Stitchkit transport options.
557
+
558
+ **That safety has a cost worth knowing before it bites you.** `api.thing(args,
559
+ { signal })` — the shape everyone reaches for first — is not an error: the second
560
+ argument is ignored, in silence, and the request runs to completion while the
561
+ caller believes it was cancelled or bounded. TypeScript refuses the extra
562
+ argument at a typed call site and says nothing at an untyped one, which is where
563
+ the mistake actually happens. Nothing can catch it at runtime **on the bare
564
+ callable**: probing its second argument is exactly what the callback safety above
565
+ forbids, since reading a foreign object's `signal` may execute someone else's
566
+ getter. So the rule is simply this — **per-call options only ever go through
567
+ `withOptions`**, and a cancellation that appears to do nothing is the first thing
568
+ to check.
569
+
570
+ `withOptions` itself is guarded, because there the count settles it without
571
+ reading anything. Its arity depends on the endpoint — one argument when there is
572
+ no contract input, two when there is — and a call carrying more throws a
573
+ `TypeError` naming the endpoint and the correct shape. That guard exists because
574
+ the silent version of this mistake is expensive: the options are dropped, the
575
+ request goes out uncancelled, the caller still receives `REQUEST_ABORTED`
576
+ (cancellation is decided from the signal it was handed, not from what the
577
+ transport did), and the server runs the operation to its own deadline. Every
578
+ symptom then points at the transport, and the investigation goes there.
579
+
580
+ ```ts
581
+ const controller = new AbortController()
582
+
583
+ const pending = api.upload.withOptions(
584
+ { file: selectedFile, title: 'Draft' },
585
+ { signal: controller.signal },
586
+ )
587
+
588
+ controller.abort()
589
+ await pending
590
+ ```
591
+
592
+ Caller cancellation and the endpoint/client timeout are composed; whichever
593
+ fires first owns the result. Both the bare-fetch and Ky-backed clients expose
594
+ the same client-only errors:
595
+
596
+ | Failure | `ApiError.code` | `status` |
597
+ |---------|-----------------|----------|
598
+ | arguments refused before sending | `VALIDATION_ERROR` | `0` |
599
+ | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
600
+ | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
601
+ | other transport failure | `UNKNOWN_ERROR` | `0` |
602
+
603
+ `status: 0` is what separates a refusal made here from one made by the server:
604
+ the same `VALIDATION_ERROR` arrives with `400` when the server refused, and
605
+ `details.issues` is the same `{ path, code, message }` array either way, so one
606
+ rendering serves both. A refusal raised here is always a **rejection** — on both
607
+ transports, for every call shape — and it never reaches the server. The client
608
+ refuses this way for a missing path param, a missing scoped prefix key, a
609
+ non-flat field in a `GET` input, and a missing or invalid multipart file. → ADR
610
+ 0148.
611
+
612
+ Abort and timeout do not emit `network_error` and are not retried. The same
613
+ options work for query, JSON, multipart and raw-response calls. A body call
614
+ also takes `onUploadProgress` — see [Upload progress](#upload-progress).
615
+
616
+ An injected transport failure is normalized to `UNKNOWN_ERROR` while its exact
617
+ object remains available as `ApiError.cause`. This is how a bounded adapter can
618
+ preserve facts the generic client cannot invent — for example a Unix transport's
619
+ `not-dispatched`, `possibly-dispatched` or `response-received` state. Never replay
620
+ an effect from `UNKNOWN_ERROR` alone; inspect the owned adapter's cause and retry
621
+ only when it proves that dispatch did not happen.
622
+
623
+ For an endpoint without contract arguments, pass only the options object:
624
+
625
+ ```ts
626
+ await api.health.withOptions({ signal: controller.signal })
627
+ ```
628
+
629
+ ### Upload progress
630
+
631
+ A call with a body can report it leaving:
632
+
633
+ > **In a browser or React Native, do not give the client its own `fetch` for
634
+ > a call you want counted.** Without one the count comes from
635
+ > `XMLHttpRequest` upload events; with one the body is streamed to it, which a
636
+ > browser may buffer (and report at once) and React Native refuses.
637
+
638
+ ```ts
639
+ await api.recordings.upload.withOptions(
640
+ { file, title: 'Take 3' },
641
+ {
642
+ signal,
643
+ onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
644
+ bar.value = sentBytes / totalBytes
645
+ },
646
+ },
647
+ )
648
+ ```
649
+
650
+ - `sentBytes` counts bytes of the encoded body handed to the network — not
651
+ bytes the server accepted. `totalBytes` is the encoded length, multipart
652
+ boundaries included. The first report is `0`, values only grow, and the last
653
+ one of an attempt equals `totalBytes`.
654
+ - A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
655
+ next `attempt`.
656
+ - Without the option nothing changes: the request takes the path it always did.
657
+ - A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
658
+ `TypeError` at the call — it has no body to count, or a live response the
659
+ browser route would buffer. A listener that throws does not fail the upload.
660
+
661
+ Fetch has no upload event, so the count comes from one of three routes, chosen
662
+ per call:
663
+
664
+ | Where the client runs | Route | What counts |
665
+ |---|---|---|
666
+ | 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 |
667
+ | a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
668
+ | 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 |
669
+
670
+ The browser route exists because a streaming request body is not portable:
671
+ Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
672
+ XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
673
+ and the response is read whole before the call resolves. An injected `fetch`
674
+ always takes the stream route — it owns the I/O, and a transport that buffers
675
+ the stream before sending reports the whole body at once.
676
+
677
+ React Native is recognised by `navigator.product === 'ReactNative'`. Its
678
+ credentials follow its own fetch: `include` and `omit` set `withCredentials`,
679
+ anything else keeps the platform default. An injected `fetch` there is refused
680
+ with a `TypeError` at the call: React Native cannot stream a request body, so
681
+ there is nothing to count.
682
+
683
+ Off React Native a counted body is encoded in memory once. A file larger than one request
684
+ belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
685
+
686
+ ### Injected delivery adapters
687
+
688
+ `ClientFetch` is the narrow composition seam for application-owned delivery.
689
+ The contract still owns operation identity, request serialization and response
690
+ validation; the adapter owns I/O, bounds and dispatch certainty:
691
+
692
+ ```ts
693
+ import { type ClientFetch, createClient } from 'stitchkit'
694
+
695
+ const delivery: ClientFetch = async (input, init) => {
696
+ // Fixed deployment destination and policy are chosen here, not by caller data.
697
+ return boundedFetch(input, { ...init, signal: init?.signal })
698
+ }
699
+
700
+ const query = createClient(queryContract, {
701
+ baseUrl: 'https://service.internal',
702
+ fetch: delivery,
703
+ })
704
+ const work = createClient(requestWorkContract, {
705
+ baseUrl: 'https://service.internal',
706
+ fetch: delivery,
707
+ })
708
+ ```
709
+
710
+ The same adapter accepts unrelated contract shapes without learning their DTOs
711
+ or operation inventory. `createClient` chooses method/path from the contract,
712
+ forwards `.withOptions(..., { signal })` and validates the response through the
713
+ endpoint's output schema. A caller payload cannot replace the configured base URL
714
+ or reserved operation path.
715
+
716
+ It does **not** filter your arguments down to the declared ones. Keys the contract
717
+ does not declare are consumed for the path where they belong there and otherwise
718
+ sent as they are — `api.list({ q: 'hello', note: 'internal' })` puts
719
+ `?q=hello&note=internal` on the wire, and the server drops `note` only because its
720
+ schema is not `.strict()`. Nothing was validated away on this side: pass the
721
+ object the contract declares, not a wider one it happens to contain.
722
+
723
+ Contract metadata describes an effect; it does not authorize one. Authentication,
724
+ scope and destination policy remain in the application/server boundary. A schema
725
+ mismatch is an explicit validation failure and is not retried automatically. A
726
+ timeout means the caller stopped waiting, not that the remote effect did not run.
727
+
728
+ Published declarations are checked from a clean tarball under both bundler and
729
+ NodeNext resolution. The HTTP-only NodeNext proof uses `skipLibCheck: false` and
730
+ installs no Socket.IO peer. → ADR 0120.
731
+
732
+ ### Many contracts at once
733
+
734
+ ```ts
735
+ import { createClients } from 'stitchkit'
736
+
737
+ export const api = createClients({ users, posts, billing }, http)
738
+ await api.users.list()
739
+ await api.posts.create({ title: 'Hi' })
740
+ ```
741
+
742
+ When contracts use different path-prefix rules, route the same registry by the
743
+ scope already declared in each contract. An array composes contracts with
744
+ different scopes into one logical namespace:
745
+
746
+ ```ts
747
+ const api = createScopedClients(
748
+ { auth: [publicAuth, authenticatedAuth], widgets },
749
+ http,
750
+ {
751
+ public: {},
752
+ user: {},
753
+ tenant: {
754
+ stripPrefixKeys: ['tenantId'],
755
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
756
+ },
757
+ },
758
+ )
759
+
760
+ await api.auth.login()
761
+ await api.auth.me()
762
+ await api.widgets.list({ tenantId: 't1' })
763
+ ```
764
+
765
+ Every scope present in the registry needs a config. Unknown/missing scopes and
766
+ duplicate method names inside a composed namespace fail before a request runs.
767
+
768
+ `createClients` builds one typed client per contract from a registry — list the
769
+ contracts once, get the whole API typed. It accepts the same optional scoped
770
+ config as `createClient`, so a whole registry can share one resource prefix:
771
+
772
+ ```ts
773
+ const tenantApi = createClients({ users, posts }, http, {
774
+ stripPrefixKeys: ['tenantId'],
775
+ pathPrefix: ({ tenantId }) => `tenants/${tenantId}`,
776
+ })
777
+ ```
778
+
779
+ Every method now requires `tenantId`, and the callback sees it as a `string`.
780
+ The batch form delegates to the same single-contract client runtime, including
781
+ HTTP exposure filtering, multipart, raw responses and output validation.
782
+
783
+ ## Chunked uploads
784
+
785
+ A file larger than one request goes as three calls of the application's own
786
+ contract — open, parts, finish — driven by `uploadInChunks` on the client and
787
+ held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
788
+ the application's: its fields, its limits, what happens to the finished file.
789
+
790
+ ```ts
791
+ // shared: one part size for both halves
792
+ export const CHUNK_BYTES = 4 * 1024 * 1024
793
+
794
+ // contract
795
+ init: {
796
+ method: 'POST', path: '/uploads', desc: 'Open an upload',
797
+ input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
798
+ output: z.object({ finished: Accepted.optional() }),
799
+ },
800
+ chunk: {
801
+ method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
802
+ params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
803
+ multipart: { files: { bytes: {} } },
804
+ output: z.object({ state: z.enum(['stored', 'repeated']) }),
805
+ },
806
+ finalize: {
807
+ method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
808
+ params: z.object({ uploadId: z.string() }),
809
+ output: Accepted,
810
+ },
811
+ ```
812
+
813
+ ```ts
814
+ // server
815
+ import { createChunkSpool } from 'stitchkit/files'
816
+
817
+ const spool = createChunkSpool({
818
+ directory: env.UPLOAD_SPOOL_DIR,
819
+ chunkBytes: CHUNK_BYTES,
820
+ maxFileBytes: 1024 * 1024 * 1024,
821
+ meta: z.object({ name: z.string() }),
822
+ })
823
+
824
+ implement(contract, {
825
+ init: async ({ input, ctx }) => {
826
+ const done = await findFinished(input.uploadId) // the app's own record
827
+ if (done) return { finished: done }
828
+ await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
829
+ return {}
830
+ },
831
+ chunk: async ({ params, files, ctx }) => ({
832
+ state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
833
+ }),
834
+ finalize: async ({ params, ctx }) => {
835
+ const key = { owner: ctx.user.id, uploadId: params.uploadId }
836
+ const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
837
+ const accepted = await store(file)
838
+ await spool.discard(key)
839
+ return accepted
840
+ },
841
+ })
842
+ ```
843
+
844
+ ```ts
845
+ // client — Bun, Node, a browser or React Native
846
+ import { uploadInChunks } from 'stitchkit'
847
+
848
+ const accepted = await uploadInChunks({
849
+ file,
850
+ chunkBytes: CHUNK_BYTES,
851
+ signal,
852
+ onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
853
+ init: async (start, options) => {
854
+ const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
855
+ return opened.finished ? { finished: opened.finished } : undefined
856
+ },
857
+ chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
858
+ finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
859
+ })
860
+ ```
861
+
862
+ The rules the two halves share:
863
+
864
+ - **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
865
+ call), so a repeated `init` is the same upload. The spool keys it by `owner`
866
+ too — the same id from another owner is another upload. To resume after the
867
+ app restarts instead of sending the file again, pass a stable `uploadId` —
868
+ the id of the app's own job or attempt.
869
+ - **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
870
+ A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
871
+ Expo `File`, passed without a cast. A part's `bytes` is what `slice`
872
+ returns, so the part call hands it to the client as the multipart file.
873
+ - **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
874
+ the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
875
+ (409). So the driver repeats `init` and a part on a failure that may pass — no
876
+ answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
877
+ with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
878
+ - **`finalize` is never repeated.** After it the application owns the file and
879
+ the parts are gone; a lost answer is recovered by `init` returning
880
+ `{ finished }`, which the driver hands back without sending a byte.
881
+ - **Cancellation is checked between parts**, so a cancelled upload never
882
+ reaches `finalize`.
883
+ - **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
884
+ up; the spool refuses anything else with a 4xx `AppError`
885
+ (`ChunkSpoolErrorCode`), which a handler can let through as the client's
886
+ typed error. An application that maps error codes onto its own must map
887
+ these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
888
+ `stitchkit/files` — or a refusal reaches its client as an unknown 500.
889
+ - **Progress counts file bytes**, inside a part too — the driver passes
890
+ `onUploadProgress` into the part call, so the [upload
891
+ progress](#upload-progress) route of the client applies. A repeated part
892
+ counts again from its start.
893
+ - The first writer of a part wins across processes: its data file is named by
894
+ its hash and its receipt is published by an exclusive link. `sweep()` removes
895
+ uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
896
+
897
+ Part size is a latency decision: every part costs a round trip, so on a
898
+ 200 ms link a smaller part buys smoother progress at the price of a slower
899
+ upload.