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.
- package/CHANGELOG.md +111 -0
- package/README.md +2 -0
- package/darwin-native.cjs +1 -0
- package/dist/agent-runtime/sandbox-process-owner.d.ts +3 -3
- package/dist/agent-runtime/sandbox-process-owner.d.ts.map +1 -1
- package/dist/agent-runtime/sandbox-process.d.ts +2 -2
- package/dist/agent-runtime/sandbox-process.d.ts.map +1 -1
- package/dist/agent-runtime/store-conformance-support.d.ts +5 -5
- package/dist/browser/stream.d.ts +38 -12
- package/dist/browser/stream.d.ts.map +1 -1
- package/dist/entrypoints/agent-runtime/coding-tools.js +2 -2
- package/dist/entrypoints/agent-runtime/sandbox.js +2 -2
- 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 +2 -2
- 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-dwf0gymy.js → index-e35py42g.js} +1 -1
- package/dist/{index-4q9jvqg7.js → index-epg514ys.js} +182 -13
- 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/process/bun-child.d.ts +38 -0
- package/dist/process/bun-child.d.ts.map +1 -0
- package/dist/process/command-owner.d.ts +2 -2
- package/dist/process/command-owner.d.ts.map +1 -1
- package/dist/process/launch.d.ts +2 -2
- package/dist/process/launch.d.ts.map +1 -1
- package/dist/process/owned-child.d.ts +2 -1
- package/dist/process/owned-child.d.ts.map +1 -1
- 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.txt
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
# stitchkit — stitchkit docs slice, part 1 of
|
|
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
|
|
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
|
|
87
|
-
| `stitchkit/testing` | tests on Bun or Node | stable | in-process generated clients over a real Fetch handler,
|
|
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
|
|
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
|
|
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¬e=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.
|