stitchkit 0.101.0 → 0.101.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 +29 -0
- package/dist/browser/chunked-upload.d.ts +80 -0
- package/dist/browser/chunked-upload.d.ts.map +1 -0
- package/dist/browser/client-executors.d.ts.map +1 -1
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/http.d.ts +3 -0
- package/dist/browser/http.d.ts.map +1 -1
- package/dist/browser/upload-progress.d.ts +26 -0
- package/dist/browser/upload-progress.d.ts.map +1 -0
- package/dist/contract/client-types.d.ts +19 -0
- package/dist/contract/client-types.d.ts.map +1 -1
- package/dist/entrypoints/contract.d.ts +1 -1
- package/dist/entrypoints/contract.d.ts.map +1 -1
- package/dist/entrypoints/files.d.ts +1 -0
- package/dist/entrypoints/files.d.ts.map +1 -1
- package/dist/entrypoints/files.js +230 -0
- package/dist/entrypoints/index.d.ts +1 -0
- package/dist/entrypoints/index.d.ts.map +1 -1
- package/dist/entrypoints/index.js +90 -2
- package/dist/entrypoints/react.js +1 -1
- package/dist/entrypoints/remote.js +2 -2
- package/dist/entrypoints/testing.js +2 -2
- package/dist/entrypoints/tracking.js +1 -1
- package/dist/files/chunk-spool.d.ts +66 -0
- package/dist/files/chunk-spool.d.ts.map +1 -0
- package/dist/{index-aaj97r4y.js → index-65jwqhcf.js} +27 -10
- package/dist/{index-qjeggp8w.js → index-h43cepag.js} +139 -11
- package/llms/files.txt +3 -0
- package/llms/stitchkit.2.txt +157 -2
- package/llms/stitchkit.3.txt +5 -2
- package/llms-full.txt +165 -4
- package/llms.txt +2 -2
- package/package.json +1 -1
package/llms/stitchkit.2.txt
CHANGED
|
@@ -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.
|
|
356
|
-
|
|
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,51 @@ 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
|
+
```ts
|
|
376
|
+
await api.recordings.upload.withOptions(
|
|
377
|
+
{ file, title: 'Take 3' },
|
|
378
|
+
{
|
|
379
|
+
signal,
|
|
380
|
+
onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
|
|
381
|
+
bar.value = sentBytes / totalBytes
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
)
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
- `sentBytes` counts bytes of the encoded body handed to the network — not
|
|
388
|
+
bytes the server accepted. `totalBytes` is the encoded length, multipart
|
|
389
|
+
boundaries included. The first report is `0`, values only grow, and the last
|
|
390
|
+
one of an attempt equals `totalBytes`.
|
|
391
|
+
- A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
|
|
392
|
+
next `attempt`.
|
|
393
|
+
- Without the option nothing changes: the request takes the path it always did.
|
|
394
|
+
- A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
|
|
395
|
+
`TypeError` at the call — it has no body to count, or a live response the
|
|
396
|
+
browser route would buffer. A listener that throws does not fail the upload.
|
|
397
|
+
|
|
398
|
+
Fetch has no upload event, so the count comes from one of two routes, chosen
|
|
399
|
+
per call:
|
|
400
|
+
|
|
401
|
+
| Where the client runs | Route | What counts |
|
|
402
|
+
|---|---|---|
|
|
403
|
+
| 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 |
|
|
404
|
+
| a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
|
|
405
|
+
|
|
406
|
+
The browser route exists because a streaming request body is not portable:
|
|
407
|
+
Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
|
|
408
|
+
XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
|
|
409
|
+
and the response is read whole before the call resolves. An injected `fetch`
|
|
410
|
+
always takes the stream route — it owns the I/O, and a transport that buffers
|
|
411
|
+
the stream before sending reports the whole body at once.
|
|
412
|
+
|
|
413
|
+
A counted body is encoded in memory once. A file larger than one request
|
|
414
|
+
belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
|
|
415
|
+
|
|
371
416
|
### Injected delivery adapters
|
|
372
417
|
|
|
373
418
|
`ClientFetch` is the narrow composition seam for application-owned delivery.
|
|
@@ -465,6 +510,116 @@ Every method now requires `tenantId`, and the callback sees it as a `string`.
|
|
|
465
510
|
The batch form delegates to the same single-contract client runtime, including
|
|
466
511
|
HTTP exposure filtering, multipart, raw responses and output validation.
|
|
467
512
|
|
|
513
|
+
## Chunked uploads
|
|
514
|
+
|
|
515
|
+
A file larger than one request goes as three calls of the application's own
|
|
516
|
+
contract — open, parts, finish — driven by `uploadInChunks` on the client and
|
|
517
|
+
held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
|
|
518
|
+
the application's: its fields, its limits, what happens to the finished file.
|
|
519
|
+
|
|
520
|
+
```ts
|
|
521
|
+
// shared: one part size for both halves
|
|
522
|
+
export const CHUNK_BYTES = 4 * 1024 * 1024
|
|
523
|
+
|
|
524
|
+
// contract
|
|
525
|
+
init: {
|
|
526
|
+
method: 'POST', path: '/uploads', desc: 'Open an upload',
|
|
527
|
+
input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
|
|
528
|
+
output: z.object({ finished: Accepted.optional() }),
|
|
529
|
+
},
|
|
530
|
+
chunk: {
|
|
531
|
+
method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
|
|
532
|
+
params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
|
|
533
|
+
multipart: { files: { bytes: {} } },
|
|
534
|
+
output: z.object({ state: z.enum(['stored', 'repeated']) }),
|
|
535
|
+
},
|
|
536
|
+
finalize: {
|
|
537
|
+
method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
|
|
538
|
+
params: z.object({ uploadId: z.string() }),
|
|
539
|
+
output: Accepted,
|
|
540
|
+
},
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
// server
|
|
545
|
+
import { createChunkSpool } from 'stitchkit/files'
|
|
546
|
+
|
|
547
|
+
const spool = createChunkSpool({
|
|
548
|
+
directory: env.UPLOAD_SPOOL_DIR,
|
|
549
|
+
chunkBytes: CHUNK_BYTES,
|
|
550
|
+
maxFileBytes: 1024 * 1024 * 1024,
|
|
551
|
+
meta: z.object({ name: z.string() }),
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
implement(contract, {
|
|
555
|
+
init: async ({ input, ctx }) => {
|
|
556
|
+
const done = await findFinished(input.uploadId) // the app's own record
|
|
557
|
+
if (done) return { finished: done }
|
|
558
|
+
await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
|
|
559
|
+
return {}
|
|
560
|
+
},
|
|
561
|
+
chunk: async ({ params, files, ctx }) => ({
|
|
562
|
+
state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
|
|
563
|
+
}),
|
|
564
|
+
finalize: async ({ params, ctx }) => {
|
|
565
|
+
const key = { owner: ctx.user.id, uploadId: params.uploadId }
|
|
566
|
+
const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
|
|
567
|
+
const accepted = await store(file)
|
|
568
|
+
await spool.discard(key)
|
|
569
|
+
return accepted
|
|
570
|
+
},
|
|
571
|
+
})
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
```ts
|
|
575
|
+
// client — Bun, Node or a browser
|
|
576
|
+
import { uploadInChunks } from 'stitchkit'
|
|
577
|
+
|
|
578
|
+
const accepted = await uploadInChunks({
|
|
579
|
+
file,
|
|
580
|
+
chunkBytes: CHUNK_BYTES,
|
|
581
|
+
signal,
|
|
582
|
+
onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
|
|
583
|
+
init: async (start, options) => {
|
|
584
|
+
const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
|
|
585
|
+
return opened.finished ? { finished: opened.finished } : undefined
|
|
586
|
+
},
|
|
587
|
+
chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
|
|
588
|
+
finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
|
|
589
|
+
})
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
The rules the two halves share:
|
|
593
|
+
|
|
594
|
+
- **The client mints the upload id** (default `crypto.randomUUID()`), so a
|
|
595
|
+
repeated `init` is the same upload. The spool keys it by `owner` too — the
|
|
596
|
+
same id from another owner is another upload.
|
|
597
|
+
- **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
|
|
598
|
+
the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
|
|
599
|
+
(409). So the driver repeats `init` and a part on a failure that may pass — no
|
|
600
|
+
answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
|
|
601
|
+
with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
|
|
602
|
+
- **`finalize` is never repeated.** After it the application owns the file and
|
|
603
|
+
the parts are gone; a lost answer is recovered by `init` returning
|
|
604
|
+
`{ finished }`, which the driver hands back without sending a byte.
|
|
605
|
+
- **Cancellation is checked between parts**, so a cancelled upload never
|
|
606
|
+
reaches `finalize`.
|
|
607
|
+
- **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
|
|
608
|
+
up; the spool refuses anything else with a 4xx `AppError`
|
|
609
|
+
(`ChunkSpoolErrorCode`), which a handler can let through as the client's
|
|
610
|
+
typed error.
|
|
611
|
+
- **Progress counts file bytes**, inside a part too — the driver passes
|
|
612
|
+
`onUploadProgress` into the part call, so the [upload
|
|
613
|
+
progress](#upload-progress) route of the client applies. A repeated part
|
|
614
|
+
counts again from its start.
|
|
615
|
+
- The first writer of a part wins across processes: its data file is named by
|
|
616
|
+
its hash and its receipt is published by an exclusive link. `sweep()` removes
|
|
617
|
+
uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
|
|
618
|
+
|
|
619
|
+
Part size is a latency decision: every part costs a round trip, so on a
|
|
620
|
+
200 ms link a smaller part buys smoother progress at the price of a slower
|
|
621
|
+
upload.
|
|
622
|
+
|
|
468
623
|
## Contract URL builders
|
|
469
624
|
|
|
470
625
|
Browser-native consumers such as `<img src>`, downloads and navigation need a
|
package/llms/stitchkit.3.txt
CHANGED
|
@@ -41,7 +41,10 @@ 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
|
|
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 }` |
|
|
45
48
|
| `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
|
|
46
49
|
| `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
|
|
47
50
|
| `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
|
|
@@ -54,7 +57,7 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
54
57
|
| `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
|
|
55
58
|
| `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
59
|
| `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
|
|
57
|
-
| `RequestOptions` | _type_ | per-call options — params, timeout, response type |
|
|
60
|
+
| `RequestOptions` | _type_ | per-call options — params, timeout, response type, `onUploadProgress` |
|
|
58
61
|
| `HeaderProvider` | _type_ | static or per-request headers |
|
|
59
62
|
| `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
|
|
60
63
|
| `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.
|
|
2772
|
-
|
|
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,51 @@ 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
|
+
```ts
|
|
2792
|
+
await api.recordings.upload.withOptions(
|
|
2793
|
+
{ file, title: 'Take 3' },
|
|
2794
|
+
{
|
|
2795
|
+
signal,
|
|
2796
|
+
onUploadProgress: ({ sentBytes, totalBytes, attempt }) => {
|
|
2797
|
+
bar.value = sentBytes / totalBytes
|
|
2798
|
+
},
|
|
2799
|
+
},
|
|
2800
|
+
)
|
|
2801
|
+
```
|
|
2802
|
+
|
|
2803
|
+
- `sentBytes` counts bytes of the encoded body handed to the network — not
|
|
2804
|
+
bytes the server accepted. `totalBytes` is the encoded length, multipart
|
|
2805
|
+
boundaries included. The first report is `0`, values only grow, and the last
|
|
2806
|
+
one of an attempt equals `totalBytes`.
|
|
2807
|
+
- A transport retry (`HttpClientConfig.retry`) counts again from `0` under the
|
|
2808
|
+
next `attempt`.
|
|
2809
|
+
- Without the option nothing changes: the request takes the path it always did.
|
|
2810
|
+
- A `GET`, `HEAD`, `DELETE` or streaming endpoint refuses the option with a
|
|
2811
|
+
`TypeError` at the call — it has no body to count, or a live response the
|
|
2812
|
+
browser route would buffer. A listener that throws does not fail the upload.
|
|
2813
|
+
|
|
2814
|
+
Fetch has no upload event, so the count comes from one of two routes, chosen
|
|
2815
|
+
per call:
|
|
2816
|
+
|
|
2817
|
+
| Where the client runs | Route | What counts |
|
|
2818
|
+
|---|---|---|
|
|
2819
|
+
| 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 |
|
|
2820
|
+
| a browser on its own fetch | `XMLHttpRequest`, with the same headers, credentials, errors, cancellation and timeout | `xhr.upload` progress events |
|
|
2821
|
+
|
|
2822
|
+
The browser route exists because a streaming request body is not portable:
|
|
2823
|
+
Chromium sends one only over HTTP/2 and Safari not at all. Two consequences of
|
|
2824
|
+
XHR: `credentials: 'omit'` is refused (XHR cannot drop same-origin cookies),
|
|
2825
|
+
and the response is read whole before the call resolves. An injected `fetch`
|
|
2826
|
+
always takes the stream route — it owns the I/O, and a transport that buffers
|
|
2827
|
+
the stream before sending reports the whole body at once.
|
|
2828
|
+
|
|
2829
|
+
A counted body is encoded in memory once. A file larger than one request
|
|
2830
|
+
belongs in [chunked uploads](#chunked-uploads), whose parts are sized for it.
|
|
2831
|
+
|
|
2787
2832
|
### Injected delivery adapters
|
|
2788
2833
|
|
|
2789
2834
|
`ClientFetch` is the narrow composition seam for application-owned delivery.
|
|
@@ -2881,6 +2926,116 @@ Every method now requires `tenantId`, and the callback sees it as a `string`.
|
|
|
2881
2926
|
The batch form delegates to the same single-contract client runtime, including
|
|
2882
2927
|
HTTP exposure filtering, multipart, raw responses and output validation.
|
|
2883
2928
|
|
|
2929
|
+
## Chunked uploads
|
|
2930
|
+
|
|
2931
|
+
A file larger than one request goes as three calls of the application's own
|
|
2932
|
+
contract — open, parts, finish — driven by `uploadInChunks` on the client and
|
|
2933
|
+
held by `createChunkSpool` (`stitchkit/files`) on the server. The contract stays
|
|
2934
|
+
the application's: its fields, its limits, what happens to the finished file.
|
|
2935
|
+
|
|
2936
|
+
```ts
|
|
2937
|
+
// shared: one part size for both halves
|
|
2938
|
+
export const CHUNK_BYTES = 4 * 1024 * 1024
|
|
2939
|
+
|
|
2940
|
+
// contract
|
|
2941
|
+
init: {
|
|
2942
|
+
method: 'POST', path: '/uploads', desc: 'Open an upload',
|
|
2943
|
+
input: z.object({ uploadId: z.string(), totalBytes: z.number(), chunkCount: z.number(), name: z.string() }),
|
|
2944
|
+
output: z.object({ finished: Accepted.optional() }),
|
|
2945
|
+
},
|
|
2946
|
+
chunk: {
|
|
2947
|
+
method: 'PUT', path: '/uploads/:uploadId/chunks/:index', desc: 'Send one part',
|
|
2948
|
+
params: z.object({ uploadId: z.string(), index: z.coerce.number() }),
|
|
2949
|
+
multipart: { files: { bytes: {} } },
|
|
2950
|
+
output: z.object({ state: z.enum(['stored', 'repeated']) }),
|
|
2951
|
+
},
|
|
2952
|
+
finalize: {
|
|
2953
|
+
method: 'POST', path: '/uploads/:uploadId/finalize', desc: 'Assemble the file',
|
|
2954
|
+
params: z.object({ uploadId: z.string() }),
|
|
2955
|
+
output: Accepted,
|
|
2956
|
+
},
|
|
2957
|
+
```
|
|
2958
|
+
|
|
2959
|
+
```ts
|
|
2960
|
+
// server
|
|
2961
|
+
import { createChunkSpool } from 'stitchkit/files'
|
|
2962
|
+
|
|
2963
|
+
const spool = createChunkSpool({
|
|
2964
|
+
directory: env.UPLOAD_SPOOL_DIR,
|
|
2965
|
+
chunkBytes: CHUNK_BYTES,
|
|
2966
|
+
maxFileBytes: 1024 * 1024 * 1024,
|
|
2967
|
+
meta: z.object({ name: z.string() }),
|
|
2968
|
+
})
|
|
2969
|
+
|
|
2970
|
+
implement(contract, {
|
|
2971
|
+
init: async ({ input, ctx }) => {
|
|
2972
|
+
const done = await findFinished(input.uploadId) // the app's own record
|
|
2973
|
+
if (done) return { finished: done }
|
|
2974
|
+
await spool.open({ owner: ctx.user.id, ...input, meta: { name: input.name } })
|
|
2975
|
+
return {}
|
|
2976
|
+
},
|
|
2977
|
+
chunk: async ({ params, files, ctx }) => ({
|
|
2978
|
+
state: await spool.put({ owner: ctx.user.id, ...params, bytes: files.bytes }),
|
|
2979
|
+
}),
|
|
2980
|
+
finalize: async ({ params, ctx }) => {
|
|
2981
|
+
const key = { owner: ctx.user.id, uploadId: params.uploadId }
|
|
2982
|
+
const file = await spool.assemble(key) // file.stream(), file.chunkPaths, file.meta
|
|
2983
|
+
const accepted = await store(file)
|
|
2984
|
+
await spool.discard(key)
|
|
2985
|
+
return accepted
|
|
2986
|
+
},
|
|
2987
|
+
})
|
|
2988
|
+
```
|
|
2989
|
+
|
|
2990
|
+
```ts
|
|
2991
|
+
// client — Bun, Node or a browser
|
|
2992
|
+
import { uploadInChunks } from 'stitchkit'
|
|
2993
|
+
|
|
2994
|
+
const accepted = await uploadInChunks({
|
|
2995
|
+
file,
|
|
2996
|
+
chunkBytes: CHUNK_BYTES,
|
|
2997
|
+
signal,
|
|
2998
|
+
onProgress: ({ sentBytes, totalBytes }) => draw(sentBytes / totalBytes),
|
|
2999
|
+
init: async (start, options) => {
|
|
3000
|
+
const opened = await api.uploads.init.withOptions({ ...start, name: file.name }, options)
|
|
3001
|
+
return opened.finished ? { finished: opened.finished } : undefined
|
|
3002
|
+
},
|
|
3003
|
+
chunk: (part, options) => api.uploads.chunk.withOptions(part, options),
|
|
3004
|
+
finalize: (upload, options) => api.uploads.finalize.withOptions(upload, options),
|
|
3005
|
+
})
|
|
3006
|
+
```
|
|
3007
|
+
|
|
3008
|
+
The rules the two halves share:
|
|
3009
|
+
|
|
3010
|
+
- **The client mints the upload id** (default `crypto.randomUUID()`), so a
|
|
3011
|
+
repeated `init` is the same upload. The spool keys it by `owner` too — the
|
|
3012
|
+
same id from another owner is another upload.
|
|
3013
|
+
- **A part is idempotent.** The spool keeps a size+sha256 receipt per index:
|
|
3014
|
+
the same bytes again answer `'repeated'`, other bytes `UPLOAD_CONFLICT`
|
|
3015
|
+
(409). So the driver repeats `init` and a part on a failure that may pass — no
|
|
3016
|
+
answer (network, timeout), `429`, `5xx` — up to `retries` times (default 3)
|
|
3017
|
+
with a doubling pause from `retryDelayMs` (default 1 s). A refusal is final.
|
|
3018
|
+
- **`finalize` is never repeated.** After it the application owns the file and
|
|
3019
|
+
the parts are gone; a lost answer is recovered by `init` returning
|
|
3020
|
+
`{ finished }`, which the driver hands back without sending a byte.
|
|
3021
|
+
- **Cancellation is checked between parts**, so a cancelled upload never
|
|
3022
|
+
reaches `finalize`.
|
|
3023
|
+
- **Every part but the last is exactly `chunkBytes`** and `chunkCount` must add
|
|
3024
|
+
up; the spool refuses anything else with a 4xx `AppError`
|
|
3025
|
+
(`ChunkSpoolErrorCode`), which a handler can let through as the client's
|
|
3026
|
+
typed error.
|
|
3027
|
+
- **Progress counts file bytes**, inside a part too — the driver passes
|
|
3028
|
+
`onUploadProgress` into the part call, so the [upload
|
|
3029
|
+
progress](#upload-progress) route of the client applies. A repeated part
|
|
3030
|
+
counts again from its start.
|
|
3031
|
+
- The first writer of a part wins across processes: its data file is named by
|
|
3032
|
+
its hash and its receipt is published by an exclusive link. `sweep()` removes
|
|
3033
|
+
uploads untouched for `staleAfterMs` (default 24 h); run it on a schedule.
|
|
3034
|
+
|
|
3035
|
+
Part size is a latency decision: every part costs a round trip, so on a
|
|
3036
|
+
200 ms link a smaller part buys smoother progress at the price of a slower
|
|
3037
|
+
upload.
|
|
3038
|
+
|
|
2884
3039
|
## Contract URL builders
|
|
2885
3040
|
|
|
2886
3041
|
Browser-native consumers such as `<img src>`, downloads and navigation need a
|
|
@@ -18024,7 +18179,10 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
18024
18179
|
| `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
|
|
18025
18180
|
| `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
|
|
18026
18181
|
| `ClientFetch` | _type_ | injectable Fetch-compatible transport used by framework and application-owned adapters |
|
|
18027
|
-
| `ClientRequestOptions` | _type_ | per-call `{ signal
|
|
18182
|
+
| `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) |
|
|
18183
|
+
| `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) |
|
|
18184
|
+
| `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) |
|
|
18185
|
+
| `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 }` |
|
|
18028
18186
|
| `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
|
|
18029
18187
|
| `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
|
|
18030
18188
|
| `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
|
|
@@ -18037,7 +18195,7 @@ The browser-and-server entrypoint. Re-exports everything from
|
|
|
18037
18195
|
| `ConfiguredHttpClient` | _type_ | a framework-created `HttpClient` carrying its readonly `baseUrl` for URL builders |
|
|
18038
18196
|
| `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
18197
|
| `UnauthorizedMatcher` | _type_ | exact `(pathname) => boolean` policy accepted by `suppressUnauthorizedFor` |
|
|
18040
|
-
| `RequestOptions` | _type_ | per-call options — params, timeout, response type |
|
|
18198
|
+
| `RequestOptions` | _type_ | per-call options — params, timeout, response type, `onUploadProgress` |
|
|
18041
18199
|
| `HeaderProvider` | _type_ | static or per-request headers |
|
|
18042
18200
|
| `ApiEvent` | _type_ | a client event — `unauthorized` / `network_error` / `logout` |
|
|
18043
18201
|
| `ApiEventListener` | _type_ | an `ApiEvent` handler |
|
|
@@ -19881,6 +20039,9 @@ available from `stitchkit/contract`.
|
|
|
19881
20039
|
| `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
20040
|
| `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
20041
|
| `ExclusiveLockError` | class | `LOCK_TIMEOUT` naming the resource and its holder, or `LOCK_ABORTED` with the signal's reason |
|
|
20042
|
+
| `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) |
|
|
20043
|
+
| `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()` |
|
|
20044
|
+
| `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` |
|
|
19884
20045
|
|
|
19885
20046
|
---
|
|
19886
20047
|
|
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)
|
|
12
|
+
- **stitchkit**: [llms/stitchkit.txt](llms/stitchkit.txt) 16.5 KB → [llms/stitchkit.2.txt](llms/stitchkit.2.txt) 43.6 KB → [llms/stitchkit.3.txt](llms/stitchkit.3.txt) 16.9 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)
|
|
42
|
+
- **stitchkit/files**: [llms/files.txt](llms/files.txt) 3.8 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