stitchkit 0.101.0 → 0.101.2

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.
@@ -352,8 +352,8 @@ non-flat field in a `GET` input, and a missing or invalid multipart file. → AD
352
352
  0148.
353
353
 
354
354
  Abort and timeout do not emit `network_error` and are not retried. The same
355
- options work for query, JSON, multipart and raw-response calls. Stitchkit does
356
- not expose upload progress: Fetch has no portable upload-progress primitive.
355
+ options work for query, JSON, multipart and raw-response calls. A body call
356
+ also takes `onUploadProgress` — see [Upload progress](#upload-progress).
357
357
 
358
358
  An injected transport failure is normalized to `UNKNOWN_ERROR` while its exact
359
359
  object remains available as `ApiError.cause`. This is how a bounded adapter can
@@ -368,6 +368,63 @@ For an endpoint without contract arguments, pass only the options object:
368
368
  await api.health.withOptions({ signal: controller.signal })
369
369
  ```
370
370
 
371
+ ### Upload progress
372
+
373
+ A call with a body can report it leaving:
374
+
375
+ > **In a browser or React Native, do not give the client its own `fetch` for
376
+ > a call you want counted.** Without one the count comes from
377
+ > `XMLHttpRequest` upload events; with one the body is streamed to it, which a
378
+ > browser may buffer (and report at once) and React Native refuses.
379
+
380
+ ```ts
381
+ await api.recordings.upload.withOptions(
382
+ { file, title: 'Take 3' },
383
+ {
384
+ signal,
385
+ onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
386
+ bar.value = sentBytes / totalBytes
387
+ },
388
+ },
389
+ )
390
+ ```
391
+
392
+ - `sentBytes` counts bytes of the encoded body handed to the network — not
393
+ bytes the server accepted. `totalBytes` is the encoded length, multipart
394
+ boundaries included. The first report is `0`, values only grow, and the last
395
+ one of an attempt equals `totalBytes`.
396
+ - A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
397
+ next `attempt`.
398
+ - Without the option nothing changes: the request takes the path it always did.
399
+ - A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
400
+ `TypeError` at the call — it has no body to count, or a live response the
401
+ browser route would buffer. A listener that throws does not fail the upload.
402
+
403
+ Fetch has no upload event, so the count comes from one of three routes, chosen
404
+ per call:
405
+
406
+ | Where the client runs | Route | What counts |
407
+ |---|---|---|
408
+ | Bun, Node, SSR, an injected `fetch`, a unix socket | the body is encoded once and sent as a stream of 64 KiB pieces with an exact `content-length` | a piece counts when the transport pulls it |
409
+ | a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
410
+ | React Native / Expo on its own fetch | the platform's `XMLHttpRequest`, handed the body as the client built it — the `FormData` itself, so a `{ uri, name, type }` file streams from disk; the same errors, cancellation and timeout | `xhr.upload` progress events; a `FormData`'s `totalBytes` comes with the first one |
411
+
412
+ The browser route exists because a streaming request body is not portable:
413
+ Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
414
+ XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
415
+ and the response is read whole before the call resolves. An injected `fetch`
416
+ always takes the stream route — it owns the I/O, and a transport that buffers
417
+ the stream before sending reports the whole body at once.
418
+
419
+ React Native is recognised by `navigator.product === 'ReactNative'`. Its
420
+ credentials follow its own fetch: `include` and `omit` set `withCredentials`,
421
+ anything else keeps the platform default. An injected `fetch` there is refused
422
+ with a `TypeError` at the call: React Native cannot stream a request body, so
423
+ there is nothing to count.
424
+
425
+ Off React Native a counted body is encoded in memory once. A file larger than one request
426
+ belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
427
+
371
428
  ### Injected delivery adapters
372
429
 
373
430
  `ClientFetch` is the narrow composition seam for application-owned delivery.
@@ -465,6 +522,124 @@ Every method now requires `tenantId`, and the callback sees it as a `string`.
465
522
  The batch form delegates to the same single-contract client runtime, including
466
523
  HTTP exposure filtering, multipart, raw responses and output validation.
467
524
 
525
+ ## Chunked uploads
526
+
527
+ A file larger than one request goes as three calls of the application's own
528
+ contract — open, parts, finish — driven by `uploadInChunks` on the client and
529
+ held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
530
+ the application's: its fields, its limits, what happens to the finished file.
531
+
532
+ ```ts
533
+ // shared: one part size for both halves
534
+ export const CHUNK_BYTES = 4 * 1024 * 1024
535
+
536
+ // contract
537
+ init: {
538
+ method: 'POST', path: '/uploads', desc: 'Open an upload',
539
+ input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
540
+ output: z.object({ finished: Accepted.optional() }),
541
+ },
542
+ chunk: {
543
+ method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
544
+ params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
545
+ multipart: { files: { bytes: {} } },
546
+ output: z.object({ state: z.enum(['stored', 'repeated']) }),
547
+ },
548
+ finalize: {
549
+ method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
550
+ params: z.object({ uploadId: z.string() }),
551
+ output: Accepted,
552
+ },
553
+ ```
554
+
555
+ ```ts
556
+ // server
557
+ import { createChunkSpool } from 'stitchkit/files'
558
+
559
+ const spool = createChunkSpool({
560
+ directory: env.UPLOAD_SPOOL_DIR,
561
+ chunkBytes: CHUNK_BYTES,
562
+ maxFileBytes: 1024 * 1024 * 1024,
563
+ meta: z.object({ name: z.string() }),
564
+ })
565
+
566
+ implement(contract, {
567
+ init: async ({ input, ctx }) => {
568
+ const done = await findFinished(input.uploadId) // the app's own record
569
+ if (done) return { finished: done }
570
+ await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
571
+ return {}
572
+ },
573
+ chunk: async ({ params, files, ctx }) => ({
574
+ state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
575
+ }),
576
+ finalize: async ({ params, ctx }) => {
577
+ const key = { owner: ctx.user.id, uploadId: params.uploadId }
578
+ const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
579
+ const accepted = await store(file)
580
+ await spool.discard(key)
581
+ return accepted
582
+ },
583
+ })
584
+ ```
585
+
586
+ ```ts
587
+ // client — Bun, Node, a browser or React Native
588
+ import { uploadInChunks } from 'stitchkit'
589
+
590
+ const accepted = await uploadInChunks({
591
+ file,
592
+ chunkBytes: CHUNK_BYTES,
593
+ signal,
594
+ onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
595
+ init: async (start, options) => {
596
+ const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
597
+ return opened.finished ? { finished: opened.finished } : undefined
598
+ },
599
+ chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
600
+ finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
601
+ })
602
+ ```
603
+
604
+ The rules the two halves share:
605
+
606
+ - **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
607
+ call), so a repeated `init` is the same upload. The spool keys it by `owner`
608
+ too — the same id from another owner is another upload. To resume after the
609
+ app restarts instead of sending the file again, pass a stable `uploadId` —
610
+ the id of the app's own job or attempt.
611
+ - **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
612
+ A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
613
+ Expo `File`, passed without a cast. A part's `bytes` is what `slice`
614
+ returns, so the part call hands it to the client as the multipart file.
615
+ - **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
616
+ the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
617
+ (409). So the driver repeats `init` and a part on a failure that may pass — no
618
+ answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
619
+ with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
620
+ - **`finalize` is never repeated.** After it the application owns the file and
621
+ the parts are gone; a lost answer is recovered by `init` returning
622
+ `{ finished }`, which the driver hands back without sending a byte.
623
+ - **Cancellation is checked between parts**, so a cancelled upload never
624
+ reaches `finalize`.
625
+ - **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
626
+ up; the spool refuses anything else with a 4xx `AppError`
627
+ (`ChunkSpoolErrorCode`), which a handler can let through as the client's
628
+ typed error. An application that maps error codes onto its own must map
629
+ these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
630
+ `stitchkit/files` — or a refusal reaches its client as an unknown 500.
631
+ - **Progress counts file bytes**, inside a part too — the driver passes
632
+ `onUploadProgress` into the part call, so the [upload
633
+ progress](#upload-progress) route of the client applies. A repeated part
634
+ counts again from its start.
635
+ - The first writer of a part wins across processes: its data file is named by
636
+ its hash and its receipt is published by an exclusive link. `sweep()` removes
637
+ uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
638
+
639
+ Part size is a latency decision: every part costs a round trip, so on a
640
+ 200 ms link a smaller part buys smoother progress at the price of a slower
641
+ upload.
642
+
468
643
  ## Contract URL builders
469
644
 
470
645
  Browser-native consumers such as `<img src>`, downloads and navigation need a
@@ -41,7 +41,11 @@ The browser-and-server entrypoint. Re-exports everything from
41
41
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
42
42
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
43
43
  | `ClientFetch` | _type_ | injectable Fetch-compatible transport used by framework and application-owned adapters |
44
- | `ClientRequestOptions` | _type_ | per-call `{ signal?: AbortSignal }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
44
+ | `ClientRequestOptions` | _type_ | per-call `{ signal?, onUploadProgress? }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
45
+ | `UploadProgress` | _type_ | `{ sentBytes, totalBytes, attempt }` heard by `onUploadProgress`: bytes of the encoded body handed to the network, first `0`, last `totalBytes`, counted again on each transport retry — [guide](../guide/client.md#upload-progress) |
46
+ | `uploadInChunks` | function | send a file larger than one request as `init` → parts → `finalize` through three calls the app owns; client-minted id, repeats of a part on a failure that may pass, `finalize` never repeated, cancel checked between parts, progress in file bytes — [guide](../guide/client.md#chunked-uploads) |
47
+ | `ChunkedUploadConfig` / `ChunkedUploadStart` / `ChunkedUploadPart` / `ChunkedUploadProgress` | _type_ | the driver's file, part size, retries and three calls; what `init` declares; one part; progress `{ sentBytes, totalBytes, index, chunkCount, attempt }` |
48
+ | `ChunkSource` | _type_ | what the driver reads a file through — `size` and `slice(start, end)`; a `Blob` is one, so is a platform file that is not (an Expo `File`), and a part's `bytes` is what its `slice` returns |
45
49
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
46
50
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
47
51
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
@@ -54,7 +58,7 @@ The browser-and-server entrypoint. Re-exports everything from
54
58
  | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
55
59
  | `HttpClientConfig` | _type_ | config for `createHttpClient`; retry `limit` counts retries after the initial attempt (default 2 = at most 3 GET attempts), with `statusCodes: []` by default; `fetch` installs an explicit transport and is mutually exclusive with the legacy Bun-only `unix` option — [details](../guide/client.md#createhttpclient) |
56
60
  | `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
57
- | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
61
+ | `RequestOptions` | _type_ | per-call options — params, timeout, response type, `onUploadProgress` |
58
62
  | `HeaderProvider` | _type_ | static or per-request headers |
59
63
  | `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
60
64
  | `ApiEventListener` | _type_ | an `ApiEvent` handler |
package/llms-full.txt CHANGED
@@ -2768,8 +2768,8 @@ non-flat field in a `GET` input, and a missing or invalid multipart file. → AD
2768
2768
  0148.
2769
2769
 
2770
2770
  Abort and timeout do not emit `network_error` and are not retried. The same
2771
- options work for query, JSON, multipart and raw-response calls. Stitchkit does
2772
- not expose upload progress: Fetch has no portable upload-progress primitive.
2771
+ options work for query, JSON, multipart and raw-response calls. A body call
2772
+ also takes `onUploadProgress` — see [Upload progress](#upload-progress).
2773
2773
 
2774
2774
  An injected transport failure is normalized to `UNKNOWN_ERROR` while its exact
2775
2775
  object remains available as `ApiError.cause`. This is how a bounded adapter can
@@ -2784,6 +2784,63 @@ For an endpoint without contract arguments, pass only the options object:
2784
2784
  await api.health.withOptions({ signal: controller.signal })
2785
2785
  ```
2786
2786
 
2787
+ ### Upload progress
2788
+
2789
+ A call with a body can report it leaving:
2790
+
2791
+ > **In a browser or React Native, do not give the client its own `fetch` for
2792
+ > a call you want counted.** Without one the count comes from
2793
+ > `XMLHttpRequest` upload events; with one the body is streamed to it, which a
2794
+ > browser may buffer (and report at once) and React Native refuses.
2795
+
2796
+ ```ts
2797
+ await api.recordings.upload.withOptions(
2798
+ { file, title: 'Take 3' },
2799
+ {
2800
+ signal,
2801
+ onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
2802
+ bar.value = sentBytes / totalBytes
2803
+ },
2804
+ },
2805
+ )
2806
+ ```
2807
+
2808
+ - `sentBytes` counts bytes of the encoded body handed to the network — not
2809
+ bytes the server accepted. `totalBytes` is the encoded length, multipart
2810
+ boundaries included. The first report is `0`, values only grow, and the last
2811
+ one of an attempt equals `totalBytes`.
2812
+ - A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
2813
+ next `attempt`.
2814
+ - Without the option nothing changes: the request takes the path it always did.
2815
+ - A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
2816
+ `TypeError` at the call — it has no body to count, or a live response the
2817
+ browser route would buffer. A listener that throws does not fail the upload.
2818
+
2819
+ Fetch has no upload event, so the count comes from one of three routes, chosen
2820
+ per call:
2821
+
2822
+ | Where the client runs | Route | What counts |
2823
+ |---|---|---|
2824
+ | 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 |
2825
+ | a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
2826
+ | 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 |
2827
+
2828
+ The browser route exists because a streaming request body is not portable:
2829
+ Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
2830
+ XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
2831
+ and the response is read whole before the call resolves. An injected `fetch`
2832
+ always takes the stream route — it owns the I/O, and a transport that buffers
2833
+ the stream before sending reports the whole body at once.
2834
+
2835
+ React Native is recognised by `navigator.product === 'ReactNative'`. Its
2836
+ credentials follow its own fetch: `include` and `omit` set `withCredentials`,
2837
+ anything else keeps the platform default. An injected `fetch` there is refused
2838
+ with a `TypeError` at the call: React Native cannot stream a request body, so
2839
+ there is nothing to count.
2840
+
2841
+ Off React Native a counted body is encoded in memory once. A file larger than one request
2842
+ belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
2843
+
2787
2844
  ### Injected delivery adapters
2788
2845
 
2789
2846
  `ClientFetch` is the narrow composition seam for application-owned delivery.
@@ -2881,6 +2938,124 @@ Every method now requires `tenantId`, and the callback sees it as a `string`.
2881
2938
  The batch form delegates to the same single-contract client runtime, including
2882
2939
  HTTP exposure filtering, multipart, raw responses and output validation.
2883
2940
 
2941
+ ## Chunked uploads
2942
+
2943
+ A file larger than one request goes as three calls of the application's own
2944
+ contract — open, parts, finish — driven by `uploadInChunks` on the client and
2945
+ held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
2946
+ the application's: its fields, its limits, what happens to the finished file.
2947
+
2948
+ ```ts
2949
+ // shared: one part size for both halves
2950
+ export const CHUNK_BYTES = 4 * 1024 * 1024
2951
+
2952
+ // contract
2953
+ init: {
2954
+ method: 'POST', path: '/uploads', desc: 'Open an upload',
2955
+ input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
2956
+ output: z.object({ finished: Accepted.optional() }),
2957
+ },
2958
+ chunk: {
2959
+ method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
2960
+ params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
2961
+ multipart: { files: { bytes: {} } },
2962
+ output: z.object({ state: z.enum(['stored', 'repeated']) }),
2963
+ },
2964
+ finalize: {
2965
+ method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
2966
+ params: z.object({ uploadId: z.string() }),
2967
+ output: Accepted,
2968
+ },
2969
+ ```
2970
+
2971
+ ```ts
2972
+ // server
2973
+ import { createChunkSpool } from 'stitchkit/files'
2974
+
2975
+ const spool = createChunkSpool({
2976
+ directory: env.UPLOAD_SPOOL_DIR,
2977
+ chunkBytes: CHUNK_BYTES,
2978
+ maxFileBytes: 1024 * 1024 * 1024,
2979
+ meta: z.object({ name: z.string() }),
2980
+ })
2981
+
2982
+ implement(contract, {
2983
+ init: async ({ input, ctx }) => {
2984
+ const done = await findFinished(input.uploadId) // the app's own record
2985
+ if (done) return { finished: done }
2986
+ await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
2987
+ return {}
2988
+ },
2989
+ chunk: async ({ params, files, ctx }) => ({
2990
+ state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
2991
+ }),
2992
+ finalize: async ({ params, ctx }) => {
2993
+ const key = { owner: ctx.user.id, uploadId: params.uploadId }
2994
+ const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
2995
+ const accepted = await store(file)
2996
+ await spool.discard(key)
2997
+ return accepted
2998
+ },
2999
+ })
3000
+ ```
3001
+
3002
+ ```ts
3003
+ // client — Bun, Node, a browser or React Native
3004
+ import { uploadInChunks } from 'stitchkit'
3005
+
3006
+ const accepted = await uploadInChunks({
3007
+ file,
3008
+ chunkBytes: CHUNK_BYTES,
3009
+ signal,
3010
+ onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
3011
+ init: async (start, options) => {
3012
+ const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
3013
+ return opened.finished ? { finished: opened.finished } : undefined
3014
+ },
3015
+ chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
3016
+ finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
3017
+ })
3018
+ ```
3019
+
3020
+ The rules the two halves share:
3021
+
3022
+ - **The client mints the upload id** (default `crypto.randomUUID()`, fresh per
3023
+ call), so a repeated `init` is the same upload. The spool keys it by `owner`
3024
+ too — the same id from another owner is another upload. To resume after the
3025
+ app restarts instead of sending the file again, pass a stable `uploadId` —
3026
+ the id of the app's own job or attempt.
3027
+ - **The file is anything with `size` and `slice(start, end)`** (`ChunkSource`).
3028
+ A `Blob` is one; so is a platform file that is not a DOM `Blob`, such as an
3029
+ Expo `File`, passed without a cast. A part's `bytes` is what `slice`
3030
+ returns, so the part call hands it to the client as the multipart file.
3031
+ - **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
3032
+ the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
3033
+ (409). So the driver repeats `init` and a part on a failure that may pass — no
3034
+ answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
3035
+ with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
3036
+ - **`finalize` is never repeated.** After it the application owns the file and
3037
+ the parts are gone; a lost answer is recovered by `init` returning
3038
+ `{ finished }`, which the driver hands back without sending a byte.
3039
+ - **Cancellation is checked between parts**, so a cancelled upload never
3040
+ reaches `finalize`.
3041
+ - **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
3042
+ up; the spool refuses anything else with a 4xx `AppError`
3043
+ (`ChunkSpoolErrorCode`), which a handler can let through as the client's
3044
+ typed error. An application that maps error codes onto its own must map
3045
+ these too — `isChunkSpoolErrorCode` / `CHUNK_SPOOL_ERROR_CODES` from
3046
+ `stitchkit/files` — or a refusal reaches its client as an unknown 500.
3047
+ - **Progress counts file bytes**, inside a part too — the driver passes
3048
+ `onUploadProgress` into the part call, so the [upload
3049
+ progress](#upload-progress) route of the client applies. A repeated part
3050
+ counts again from its start.
3051
+ - The first writer of a part wins across processes: its data file is named by
3052
+ its hash and its receipt is published by an exclusive link. `sweep()` removes
3053
+ uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
3054
+
3055
+ Part size is a latency decision: every part costs a round trip, so on a
3056
+ 200 ms link a smaller part buys smoother progress at the price of a slower
3057
+ upload.
3058
+
2884
3059
  ## Contract URL builders
2885
3060
 
2886
3061
  Browser-native consumers such as `<img src>`, downloads and navigation need a
@@ -18024,7 +18199,11 @@ The browser-and-server entrypoint. Re-exports everything from
18024
18199
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
18025
18200
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
18026
18201
  | `ClientFetch` | _type_ | injectable Fetch-compatible transport used by framework and application-owned adapters |
18027
- | `ClientRequestOptions` | _type_ | per-call `{ signal?: AbortSignal }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
18202
+ | `ClientRequestOptions` | _type_ | per-call `{ signal?, onUploadProgress? }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
18203
+ | `UploadProgress` | _type_ | `{ sentBytes, totalBytes, attempt }` heard by `onUploadProgress`: bytes of the encoded body handed to the network, first `0`, last `totalBytes`, counted again on each transport retry — [guide](../guide/client.md#upload-progress) |
18204
+ | `uploadInChunks` | function | send a file larger than one request as `init` → parts → `finalize` through three calls the app owns; client-minted id, repeats of a part on a failure that may pass, `finalize` never repeated, cancel checked between parts, progress in file bytes — [guide](../guide/client.md#chunked-uploads) |
18205
+ | `ChunkedUploadConfig` / `ChunkedUploadStart` / `ChunkedUploadPart` / `ChunkedUploadProgress` | _type_ | the driver's file, part size, retries and three calls; what `init` declares; one part; progress `{ sentBytes, totalBytes, index, chunkCount, attempt }` |
18206
+ | `ChunkSource` | _type_ | what the driver reads a file through — `size` and `slice(start, end)`; a `Blob` is one, so is a platform file that is not (an Expo `File`), and a part's `bytes` is what its `slice` returns |
18028
18207
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
18029
18208
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
18030
18209
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
@@ -18037,7 +18216,7 @@ The browser-and-server entrypoint. Re-exports everything from
18037
18216
  | `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
18038
18217
  | `HttpClientConfig` | _type_ | config for `createHttpClient`; retry `limit` counts retries after the initial attempt (default 2 = at most 3 GET attempts), with `statusCodes: []` by default; `fetch` installs an explicit transport and is mutually exclusive with the legacy Bun-only `unix` option — [details](../guide/client.md#createhttpclient) |
18039
18218
  | `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
18040
- | `RequestOptions` | _type_ | per-call options — params, timeout, response type |
18219
+ | `RequestOptions` | _type_ | per-call options — params, timeout, response type, `onUploadProgress` |
18041
18220
  | `HeaderProvider` | _type_ | static or per-request headers |
18042
18221
  | `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
18043
18222
  | `ApiEventListener` | _type_ | an `ApiEvent` handler |
@@ -19881,6 +20060,10 @@ available from `stitchkit/contract`.
19881
20060
  | `writeFileAtomic` / `writeFileAtomicSync` / `WriteFileAtomicOptions` | function / _type_ | replace a file atomically: a random staging name created exclusively (never through a planted link), the mode set on the descriptor before the file is visible (default `0o600`, not masked by the umask), `fsync`, rename; a failure leaves the target and no staging file. The asynchronous form keeps the event loop running |
19882
20061
  | `withExclusiveLock` / `ExclusiveLockOptions` / `ExclusiveLock` / `ExclusiveLockOwner` | function / _type_ | run work under an exclusive lock between processes — a file recording its owner (pid, host, machine identity, time and nullable kernel process identity); waits up to `timeoutMs` (default 10 s) and stops on `signal`; a dead owner on this machine is taken over, a live, slow or foreign one never is; an ownerless lock only after `ownerlessGraceMs` |
19883
20062
  | `ExclusiveLockError` | class | `LOCK_TIMEOUT` naming the resource and its holder, or `LOCK_ABORTED` with the signal's reason |
20063
+ | `createChunkSpool` | function | the server half of a chunked upload: parts on disk under `owner` + client-minted `uploadId`, each with a size+sha256 receipt; idempotent `open` and `put`, the first writer of a part wins across processes, `assemble` in order once every part is in, `sweep` of untouched uploads — [guide](../guide/client.md#chunked-uploads) |
20064
+ | `ChunkSpool` / `ChunkSpoolConfig` / `ChunkSpoolKey` / `ChunkSpoolOpen` / `ChunkSpoolPart` / `ChunkSpoolAssembly` | _type_ | the spool, its directory, part size, file limit, meta schema and age; an upload's key; its declaration; one part; the assembled parts with a concatenating `stream()` |
20065
+ | `ChunkSpoolErrorCode` | _type_ | the `AppError` codes a spool refuses with, each with its 4xx status: `UPLOAD_INVALID`, `UPLOAD_TOO_LARGE`, `UPLOAD_NOT_FOUND`, `UPLOAD_CONFLICT`, `UPLOAD_CHUNK_OUT_OF_RANGE`, `UPLOAD_CHUNK_SIZE`, `UPLOAD_INCOMPLETE` |
20066
+ | `CHUNK_SPOOL_ERROR_CODES` / `isChunkSpoolErrorCode` | const / function | every `ChunkSpoolErrorCode` as a frozen list, and the guard for one — map them onto the app's own error codes, or a spool refusal reaches its client as an unknown 500 |
19884
20067
 
19885
20068
  ---
19886
20069
 
package/llms.txt CHANGED
@@ -9,7 +9,7 @@ Build with stitchkit: define a contract once, then `implement` it and serve it (
9
9
  Load the slice for each entrypoint your code imports — `import … from 'stitchkit/server'` → `llms/server.txt`; the root `stitchkit` import → `llms/stitchkit.txt`. A slice holds that entrypoint's API reference section and the guide pages whose home it is; every guide lives in exactly one slice, and a slice that needs another's guide names it under "Also read". Each file is at most 50.0 KB; a longer one continues in numbered parts, each naming the next at its top. Moving across versions: load `llms/upgrading.txt` and the range slices it lists between your version and the target. `llms-full.txt` inlines everything; it is far larger than a context window, so do not read it whole.
10
10
 
11
11
  ## Entrypoint slices
12
- - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 16.5 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 37.2 KB → [llms/stitchkit.3.txt](llms/stitchkit.3.txt) 16.1 KB
12
+ - **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 16.5 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 45.2 KB → [llms/stitchkit.3.txt](llms/stitchkit.3.txt) 17.1 KB
13
13
  - **stitchkit/live**: [llms/live.txt](llms/live.txt) 21.6 KB
14
14
  - **stitchkit/react**: [llms/react.txt](llms/react.txt) 9.0 KB
15
15
  - **stitchkit/react/keyboard**: [llms/react-keyboard.txt](llms/react-keyboard.txt) 9.4 KB
@@ -39,7 +39,7 @@ Load the slice for each entrypoint your code imports — `import … from 'stitc
39
39
  - **stitchkit/application/schemas**: [llms/application-schemas.txt](llms/application-schemas.txt) 1.8 KB
40
40
  - **stitchkit/testing**: [llms/testing.txt](llms/testing.txt) 30.8 KB
41
41
  - **stitchkit/agent-runtime/sandbox**: [llms/agent-runtime-sandbox.txt](llms/agent-runtime-sandbox.txt) 8.1 KB
42
- - **stitchkit/files**: [llms/files.txt](llms/files.txt) 2.9 KB
42
+ - **stitchkit/files**: [llms/files.txt](llms/files.txt) 4.1 KB
43
43
  - **stitchkit/telegram**: [llms/telegram.txt](llms/telegram.txt) 26.9 KB
44
44
  - **stitchkit/telegram/html**: [llms/telegram-html.txt](llms/telegram-html.txt) 2.7 KB
45
45
  - **stitchkit/telegram/screens**: [llms/telegram-screens.txt](llms/telegram-screens.txt) 21.2 KB
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.101.0",
3
+ "version": "0.101.2",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",