Repository navigation
Description
Summary
withGenerationPersistence cannot persist artifacts to a Cloudflare R2 BlobStore. Every generation that produces media fails with:
TypeError: Provided readable stream must have a known length
(request/response body or readable half of FixedLengthStream)
at Object.put (src/lib/persistence.server.ts)
at persistGenerationArtifacts (@tanstack/ai-persistence/dist/esm/middleware.js)
at applyGenerationResultTransforms (@tanstack/ai/dist/esm/activities/middleware/run.js)
at runGenerateImage (@tanstack/ai/dist/esm/activities/generateImage/index.js)
This affects any Worker following the shipped ai-persistence/build-cloudflare-artifact-store skill, whose recipe is incorrect on this point (details in "3." below).
Environment: @tanstack/ai-persistence (pkg.pr.new build for #1011) + @tanstack/ai, TanStack Start on Cloudflare Workers via @cloudflare/vite-plugin, workerd 1.20260730.1, wrangler 4.116.0. Artifacts sourced from fal / Gemini / xAI result URLs.
Root cause
descriptorBody() resolves a URL-backed descriptor by wrapping the response body:
body: capBodySize(response.body, maxBytes, descriptor.url),
size: 0,and capBodySize enforces the cap with a TransformStream:
function capBodySize(body, maxBytes, url) {
let seen = 0
return body.pipeThrough(new TransformStream({ transform(chunk, controller) {
seen += chunk.byteLength
if (seen > maxBytes) { controller.error(...); return }
controller.enqueue(chunk)
}}))
}The counting itself is fine — it streams, nothing is buffered. The problem is that a TransformStream's readable side carries no declared length, because a transform may emit any number of bytes. Response.body had a length; after pipeThrough it does not.
R2Bucket.put requires a known length for a single-shot upload (workerd will not implicitly buffer an arbitrary stream to discover it). So the BlobStore receives a body it cannot hand to R2.
There is no way to opt out from the caller side: the wrap is unconditional, and maxArtifactBytes: Infinity disables the threshold but not the wrapping.
Why the obvious fixes are wrong
Worth stating, since both look reasonable:
- Buffer the stream in the store.
DEFAULT_MAX_ARTIFACT_BYTESis 100 MB against a 128 MB Worker memory limit. This works for images and becomes an OOM risk for video — it fails late and under load rather than immediately. - Skip
capBodySizewhenevercontent-lengthis present. This is what I first assumed, but the code comment is right to reject it:fetchtransparently decompresses, so on acontent-encoding: gzipresponsecontent-lengthis the compressed size and the decoded body can be arbitrarily larger. Trusting it would open a decompression-bomb hole.
I worked around it by buffering one 8 MiB part and switching to an R2 multipart upload beyond that, which bounds memory regardless of artifact size. That works, but it is a fair amount of machinery for every Workers user to reinvent, and it costs the download/upload overlap that streaming straight to put would give.
Suggestions
Roughly in order of leverage:
1. Expose the known length to the store (additive, runtime-agnostic).
The middleware already knows declaredLength, and descriptorBody computes exact sizes for the bytes / json branches. Adding an optional hint to BlobPutOptions — say expectedLength?: number — would let a store choose single-shot vs multipart knowingly, or on workerd re-wrap with new FixedLengthStream(n). It changes no cap semantics and no existing behaviour.
2. Add a length-less stream to the conformance testkit.
runPersistenceConformance appears to exercise blobs.put with byte bodies. A case that puts a TransformStream-wrapped stream would have caught both this and the broken recipe as a test failure, instead of surfacing as a runtime error in an app. This is probably the highest-value change — a Uint8Array takes a different branch in most stores and proves nothing about the streaming path.
3. Fix ai-persistence/build-cloudflare-artifact-store.
The skill states:
BlobBodyisReadableStream<Uint8Array> | ArrayBuffer | ArrayBufferView | string | Blob— which is exactly whatR2Bucket.putaccepts, so the body flows straight through with no conversion.
That holds only for bodies that already carry a length, which is never true for the URL path — i.e. essentially every generated artifact. Anyone following this recipe gets a store that passes the conformance suite and fails on first real use.
4. Nit, same function.
const declaredLength = Number(response.headers.get('content-length'))
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { ... }headers.get returns null when absent, and Number(null) === 0, which is finite — so a missing content-length reads as a declared length of 0 and always passes. Harmless today because the drain-time check is the real enforcement, but the early-reject silently never fires for chunked responses.
Reproduction
Any generateImage / generateVideo through withGenerationPersistence with an R2-backed blobs store, where the provider returns a result URL. Minimal shape of the failing call:
// R2 BlobStore.put — body is the capBodySize-wrapped stream
await bucket.put(key, body, { httpMetadata: { contentType } })
// TypeError: Provided readable stream must have a known lengthStandalone, without any provider or this library — run inside a Worker with an R2 binding. The first put is a positive control isolating the pipeThrough as the sole cause:
// 1. Response.body straight through — succeeds, length preserved
await bucket.put('probe-direct', new Response('x'.repeat(1024)).body!)
// 2. Identical bytes via a pass-through TransformStream — throws
const lengthless = new Response('x'.repeat(1024)).body!.pipeThrough(new TransformStream())
await bucket.put('probe-wrapped', lengthless)Observed on workerd 1.20260730.1:
Response.body direct -> OK (length preserved)
pipeThrough(TransformStream) -> THREW: Provided readable stream must have a known length
(request/response body or readable half of FixedLengthStream)
Happy to send a PR for (1) and (2) if that direction seems right.
Activity
tombeckenham commentedon Jul 31, 2026
Some follow-up data that sharpens the severity, plus a related gap in the same recipe.
DEFAULT_MAX_ARTIFACT_BYTES(100 MB) is below realistic video outputgenerateVideois a first-class activity, and generated clips routinely run to a few hundred MB — a 4K or longer clip well past 100 MB is ordinary, not pathological. At the default, those runs fail outright withexceeds maxArtifactBytes.The part relevant to this issue is the interaction between that default and the length-stripping, because I think it's hiding how much the stripping costs:
So the low default makes a store that cannot stream look correct. Anyone who follows the Cloudflare recipe, hits the
TypeError, and reaches for the obvious fix ends up with something that works on images, works in CI, and dies on the first large video. That's the case for suggestion (1) in the issue — surfacing the known length (or a size hint onBlobPutOptions) so a store can stream rather than buffer.Streaming is sufficient — measurement
To check that a streaming store actually handles realistic sizes, I ran a 1 GB artifact through an R2
BlobStorethat buffers one 8 MiB part and uses multipart upload beyond that:Peak memory stays flat at one part regardless of artifact size. So there's no need to cap artifacts low on Workers provided the store can stream — which is exactly what the missing length prevents it from doing by default.
For reference I've since raised
maxArtifactBytesto 1 GB in my app, which is only safe because the store streams.Same recipe: the artifact-serving example has no
RangesupportMinor, but the same shape of problem as the
putadvice — fine for images, wrong for video. The skill's "4. Serve the bytes back" example returns:For a library that generates video this is incomplete:
<video>seeking requires206/Content-Range, and Safari will not play a video at all from a source that doesn't honourRange. At image sizes nobody notices; at a few hundred MB it means the artifact can't be played back or seeked, only fully downloaded. R2'sgettakes arangeoption, so the example could show it.fix(ai-persistence): stream artifacts to length-strict stores, serve …
fix(ai-persistence): stream artifacts to length-strict stores, serve …