# Keep Generated Files

# Keep Generated Files

Provider URLs for generated media expire. A Sora clip, a batch of images, a long
audio track: the model hands you a URL that stops working after a while, and
once it does the output is gone. To keep the output, save the generated bytes to
your own storage and serve them from your own origin, where they outlive the
provider's link.

This is a server-side opt-in that layers on **top of** the `generationRuns` store
[Generation persistence](./generation-persistence) already requires. Byte storage
adds two more stores, which must be provided together:

- an `artifacts` store: the metadata.
- a `blobs` store: the bytes.

What each choice gets you:

- **Both provided**: `withGenerationPersistence` writes each generated file's
  bytes to the blob store, records an `ArtifactRecord`, and attaches durable
  references to the result and the run record.
- **Neither provided**: only the run record is kept.

`memoryPersistence()` ships all three stores (`generationRuns`, `artifacts`,
`blobs`), so it works out of the box. Any backend that implements `ArtifactStore`
and `BlobStore` (see
[Build a generation adapter](./build-your-own-generation-adapter))
works the same way.

## Serve the stored bytes

The bytes land under the blob key `artifacts/<runId>/<artifactId>`. To fetch a
generated file later (to render it, download it, or hand it to another request),
add a `GET` route that reads the artifact back with the `retrieveArtifact` /
`retrieveBlob` helpers and streams it from your own origin:

```ts group=generation-bytes
// routes/api.generate.image.ts, runs the generation.
import {
  generateImage,
  generationParamsFromRequest,
  toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
  memoryPersistence,
  retrieveArtifact,
  retrieveBlob,
  withGenerationPersistence,
} from '@tanstack/ai-persistence'

const persistence = memoryPersistence()

export async function POST(request: Request) {
  const { input, threadId } = await generationParamsFromRequest(
    'image',
    request,
  )

  if (typeof input.prompt !== 'string') {
    throw new Error('This endpoint accepts text image prompts only.')
  }

  // Persistence requires the scope these runs are filed under.
  if (threadId === undefined) {
    return new Response('`threadId` is required', { status: 400 })
  }

  const stream = generateImage({
    adapter: openaiImage('gpt-image-2'),
    prompt: input.prompt,
    threadId,
    stream: true,
    middleware: [
      withGenerationPersistence(persistence, {
        // Stamp the durable serve URL (the artifact route below) onto every
        // persisted artifact ref, and rewrite the live result's media field to
        // it. Both the live and the restored result then render from your own
        // origin instead of the provider's expiring link.
        artifactUrl: (ref) =>
          `/api/generate/image/artifact?id=${ref.artifactId}`,
      }),
    ],
  })

  return toServerSentEventsResponse(stream)
}
```

The serve route is a **separate** route from the generation endpoint. A `GET` on
the generation route is already spoken for by
[Generation persistence](./generation-persistence), which is where a reloading
client hydrates and where an in-flight run resumes, so the bytes get their own
path, the one `artifactUrl` stamps above:

```ts group=generation-bytes
// routes/api.generate.image.artifact.ts, serves stored bytes by id.
//
// This is a plain file endpoint: it serves one stored file, it does not resume
// a run or rebuild a conversation.
//
// Security: the id comes from the caller, so this route MUST authorize before
// it serves. `ArtifactRecord` carries the `threadId` / `runId` the file was
// generated under. Check that against an identity you derive server-side from
// the session, never from the query string. Without this check, any caller who
// learns or guesses an artifact id can read another user's media.
export async function GET(request: Request) {
  const artifactId = new URL(request.url).searchParams.get('id')
  if (!artifactId) return new Response('missing id', { status: 400 })

  const artifact = await retrieveArtifact(persistence, artifactId)
  if (!artifact) return new Response('not found', { status: 404 })

  // Replace with your session + ownership check, e.g.:
  // const user = await auth(request)
  // const owned = user != null && (await db.threadOwnedBy(user.id, artifact.threadId))
  const owned = true
  void request
  // 404, not 403. A distinguishable "exists but forbidden" confirms valid ids.
  if (!owned) return new Response('not found', { status: 404 })

  const blob = await retrieveBlob(persistence, artifact)
  if (!blob) return new Response('not found', { status: 404 })

  return new Response(blob.body ?? (await blob.arrayBuffer()), {
    headers: {
      'content-type': artifact.mimeType,
      'content-length': String(artifact.size),
    },
  })
}
```

### Serve video: honour `Range`

The route above is enough for images. Video is not: seeking a `<video>` is
built on HTTP range requests, and a source that answers every `Range` with the
whole file cannot be scrubbed: Safari refuses to play it at all, and every
other browser downloads the entire clip before it starts. Pass `range` to
`retrieveBlob` and answer `206`:

```ts group=generation-bytes
import { parseRangeHeader } from '@tanstack/ai-persistence'
import type { ArtifactRecord } from '@tanstack/ai-persistence'

// The same route, seek-aware. `parseRangeHeader` resolves the header against
// the size on the record, including suffix ranges (`bytes=-500` is the LAST
// 500 bytes) and the unsatisfiable case. `blob.range` then reports the slice
// actually served, and `artifact.size` the whole file: the two numbers
// `Content-Range` needs.
export async function serveArtifactBytes(
  request: Request,
  artifact: ArtifactRecord,
) {
  const range = parseRangeHeader(request.headers.get('range'), artifact.size)
  if (range === 'unsatisfiable') {
    return new Response('range not satisfiable', {
      status: 416,
      headers: { 'content-range': `bytes */${artifact.size}` },
    })
  }

  const blob = await retrieveBlob(
    persistence,
    artifact,
    range ? { range } : undefined,
  )
  if (!blob) return new Response('not found', { status: 404 })

  const body = blob.body ?? (await blob.arrayBuffer())
  // `accept-ranges` on every response, including the whole-file one: it is how
  // the player learns it may seek at all.
  const headers = {
    'content-type': artifact.mimeType,
    'accept-ranges': 'bytes',
  }
  if (!blob.range) {
    return new Response(body, {
      headers: { ...headers, 'content-length': String(artifact.size) },
    })
  }
  const { offset, length } = blob.range
  return new Response(body, {
    status: 206,
    headers: {
      ...headers,
      'content-length': String(length),
      'content-range': `bytes ${offset}-${offset + length - 1}/${artifact.size}`,
    },
  })
}
```

The client side needs nothing special, which is the point. Given a route that
answers ranges, the browser drives the rest:

```tsx
import type { PersistedArtifactRef } from '@tanstack/ai'

export function GeneratedVideo({ artifact }: { artifact: PersistedArtifactRef }) {
  // `artifact.url` is the app-origin URL `artifactUrl` stamped on the ref.
  return <video src={artifact.url} controls preload="metadata" />
}
```

`preload="metadata"` has the player fetch the header bytes with a range request
and show the duration and scrubber without pulling the clip down. A store must
support ranged reads for any of this to work; the
[conformance testkit](./store-reference#blobstore) asserts it.

`memoryPersistence` keeps everything in process memory, which is right for
development and tests; point `generationRuns` / `artifacts` / `blobs` at a durable backend
for production. Control what gets captured with `withGenerationPersistence`'s
`extractArtifacts` (return your own descriptors) and `nameArtifact` (name each
file) options.

## Choose where the bytes land

By default an artifact's bytes are written under
`artifacts/<runId>/<artifactId>`. Pass `storageKey` to put them in your own
folder structure instead, which helps when the bucket is shared with the rest of
your app, or when you want media grouped by the thing it belongs to rather than
by the run that produced it:

```ts group=generation-bytes
const storageKeyOptions = withGenerationPersistence(persistence, {
  storageKey: ({ runId, artifactId, role, name }) =>
    `products/${role}/${runId}-${artifactId}-${name}`,
})
```

Two things worth knowing:

**The resolved key is recorded on the artifact.** Once the path is arbitrary it
can no longer be recomputed from the record, so it is stored as
`ArtifactRecord.blobKey` and reads resolve through it. Records written before
this existed fall back to the default convention, so adding `storageKey` to an
app with existing artifacts does not orphan them. It does mean the default
convention can never be changed retroactively.

**Returning a non-unique key overwrites.** Include `artifactId`, or something
equally unique, unless overwriting is what you want.

This is server-side only, deliberately. A key supplied by the browser would be a
path-traversal and cross-tenant-write vector.

## Prompt media referenced by URL

What gets stored is the **generated output**. When a provider returns an
expiring link, the middleware downloads it and keeps the bytes, which is the
whole point of this page.

Prompt media is different, and it splits by how you sent it:

- **base64** (`source: { type: 'data' }`): stored alongside the output, because
  the bytes are already in hand.
- **a URL** (`source: { type: 'url' }`): **not fetched**, and no artifact is
  recorded for it.

Two reasons a caller-supplied URL is left alone. Downloading it server-side
would let anyone name an address your server can reach (cloud metadata
endpoints, `localhost` admin services) and then read the response back through
the artifact `GET` route. The copy is also redundant: whoever supplied the URL
already had the media.

If you do need a durable copy of caller-supplied media (a "paste an image URL"
input box, say), opt in with `allowInputUrl`, which is a predicate rather than a
flag precisely so the check is not optional:

```ts group=generation-bytes
const inputUrlOptions = withGenerationPersistence(persistence, {
  allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})
```

Every artifact fetch, input or output, is bounded three ways:

- The scheme must be `http:` or `https:`.
- It is aborted after `artifactFetchTimeoutMs` (default 30s).
- It is capped at `maxArtifactBytes` (default 1 GiB) as the body drains, or not
  at all when you pass `false`; see [Nothing is buffered](#nothing-is-buffered).

Input fetches add two more: a loopback / private / link-local host block, and a
refusal to follow redirects, so a `302` cannot hop somewhere the check never
saw.

Treat those as a backstop, not the control: a hostname that *resolves* to a
private address still passes a literal-IP check. Keep `allowInputUrl` narrow,
and for stronger isolation inject `artifactFetch` to route downloads through an
egress-restricted proxy that can check the address actually connected to.

## Nothing is buffered

A provider URL is **streamed** into the blob store: the middleware never holds
the artifact in memory, and `size` on the record is counted as the bytes drain.
A 2 GB video costs a streaming store (R2, S3, a filesystem) the same memory as
a 2 KB icon. `memoryPersistence` is the exception, and only because holding the
bytes in process *is* what it does: it is a dev/test store, not a production
one.

`maxArtifactBytes` is therefore a bound on **transfer**, not on memory. What it
buys is a ceiling on what a runaway or hostile origin can make you pull and
store: `content-length` is advisory, so an origin can declare 1 KB and send
forever. That is the only reason there is a default at all.

### The body reaches your store untouched when it can

Enforcing the cap during the drain means wrapping the body in a
`TransformStream` that counts, and a transform's readable side carries **no
declared length**, which is exactly what workerd's `R2Bucket.put` needs for a
single-shot upload. So the wrapper is applied only where it is load-bearing:

| The response                                            | What your store gets                        |
| ------------------------------------------------------- | ------------------------------------------- |
| declares a `content-length`, no `content-encoding`       | the `fetch` body **untouched**, length intact |
| chunked, no declared length                              | the counting wrapper                        |
| `content-encoding: gzip` (declared length is compressed) | the counting wrapper                        |

In the first row nothing is lost by skipping the counter: the declared length
was already checked against the cap, and HTTP framing holds the origin to it,
a body cannot exceed a length it declared. That is the common case for a
provider CDN, so on Cloudflare the default configuration already streams
straight through:

```ts ignore
// Inside your BlobStore.put on workerd: nothing buffered, no multipart, no
// hint needed. (`R2Bucket` comes from @cloudflare/workers-types.)
const putStraightToR2 = (bucket: R2Bucket, key: string, body: BlobBody) =>
  bucket.put(key, body)
```

The other two rows genuinely need the counter, because a chunked reply declares
nothing, and a compressed one can decode to arbitrarily more than it declared
(a decompression bomb). For those, `BlobPutOptions.expectedLength` is absent
too, and a length-strict store falls back to a multipart upload that buffers
one 8 MiB part at a time, so memory stays flat whatever the artifact's size. The
`ai-persistence/build-cloudflare-artifact-store` skill ships that recipe.

To drop the ceiling entirely (no counter on any response, no limit on what an
origin can stream into your bucket) pass `false`:

```ts group=generation-bytes
const uncappedOptions = withGenerationPersistence(persistence, {
  maxArtifactBytes: false,
})
```

Reach for that when you trust the origins you fetch from and would rather have
no ceiling than a generous one. Your storage backend's own limits still apply,
R2, for instance, caps a single-shot upload at 5 GiB and a multipart one at
10,000 parts. Keep the cap when `allowInputUrl` lets callers name the URL:
there the origin is by definition not one you control.

## Wire the durable URL through to the client

`artifactUrl` is what makes the stored bytes reachable from the client without
any extra plumbing. For each persisted ref it returns the app-origin URL that
serves those bytes (the `GET` route above), and `withGenerationPersistence` does
two things with it:

- Stamps the URL onto the ref, as `ref.url`.
- Rewrites the live result's media field to the same URL: `result.images[i].url`
  for images, `result.url` for a video, `result.audio.url` for audio.

So the live result already points at your origin, not the provider's expiring
link.

Those durable refs ride along on `result.artifacts`, and they are what a reload
restores from. In [Generation persistence](./generation-persistence), the
generation hook rebuilds `result` from the persisted refs on mount, resolving
each media field to its durable `ref.url`, so the restored result renders the
same media the live run showed. `result.artifacts` is the whole artifact surface
on the hook: there are no separate top-level artifact fields to read, live or
restored.

## Where to go next

- [Generation persistence](./generation-persistence): the run record that
  survives a reload or a dropped connection, and the `generationRuns` store that
  byte storage builds on.
- [Build a generation adapter](./build-your-own-generation-adapter): a
  custom `ArtifactStore` / `BlobStore` on your own database.
