# Keep Generated Files (Advanced)

Copy generated media into your store before the provider URL expires. A Sora clip, an image batch, or a long audio track otherwise stops loading.

To keep the output, save the generated bytes to your own storage and serve them from your own origin.

## Quick setup

This adds to [Generation persistence](./generation-persistence). Set that up first.

### 1. Give the backend byte storage

Byte storage needs two stores, and you must provide both:

- `artifacts`: the metadata of each file.
- `blobs`: the bytes.

`memoryPersistence()` has both, plus `generationRuns`. Any backend that implements `ArtifactStore` and `BlobStore` works the same way. See [Build a generation adapter](./build-your-own-generation-adapter).

### 2. Stamp a durable URL in the generation route

Pass `artifactUrl` to `withGenerationPersistence`. It returns the URL of the serve route from step 3:

```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.')
  }


  const stream = generateImage({
    adapter: openaiImage('gpt-image-2.5-flare'),
    prompt: input.prompt,
    threadId,
    stream: true,
    middleware: [
      withGenerationPersistence(persistence, {
        // Stamp the serve URL (the artifact route below) on every stored
        // artifact ref. The live result and the restored result then both
        // load from your own origin, not from the expiring provider link.
        artifactUrl: (ref) =>
          `/api/generate/image/artifact?id=${ref.artifactId}`,
      }),
    ],
  })

  return toServerSentEventsResponse(stream)
}
```

### 3. Add a route that serves the bytes

Use a **separate** route. The `GET` on the generation route already answers hydration and resume for [Generation persistence](./generation-persistence):

```ts group=generation-bytes
// routes/api.generate.image.artifact.ts, serves stored bytes by id.
//
// Security: the id comes from the caller, so this route MUST authorize before
// it serves. `ArtifactRecord` has the `threadId` and `runId` that the file was
// generated under. Compare them with an identity from the server session,
// never from the query string. Without this check, any caller who learns or
// guesses an artifact id can read the media of another user.
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 and ownership check, for example:
  // 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 separate "exists but forbidden" answer 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),
    },
  })
}
```

### 4. Use the result in the client

You do not change the client. `result.images[i].url` already points at your route. Generate an image, reload the page, and the image still loads after the provider link expires.

## How it works

With both stores present, `withGenerationPersistence` does this for each generated file:

1. It writes the bytes to the blob store, under the key `artifacts/<runId>/<artifactId>` by default.
2. It records an `ArtifactRecord`.
3. It attaches durable references to the result and to the run record.

Without the two stores, only the run record is kept.

`artifactUrl` makes the stored bytes reachable from the client. For each stored ref, it returns the URL on your origin that serves the bytes. Then `withGenerationPersistence` does two things with it:

- It stamps the URL on the ref, as `ref.url`.
- It rewrites the media field of the live result to the same URL: `result.images[i].url` for images, `result.url` for a video, and `result.audio.url` for audio.

The durable refs travel on `result.artifacts`. On mount, the generation hook rebuilds `result` from these refs and sets each media field to its `ref.url`. So the restored result shows the same media as the live run. `result.artifacts` is the full artifact surface on the hook. There are no other top-level artifact fields, live or restored.

`memoryPersistence` keeps everything in process memory. That is correct for development and tests. For production, point `generationRuns`, `artifacts`, and `blobs` at a durable backend.

## Advanced

### Serve video: honor `Range`

The route above is enough for images, but not for video. Seeking in a `<video>` uses HTTP range requests. If a source answers every `Range` with the whole file, the user cannot seek. Safari does not play it at all, and other browsers download the full clip before it starts.

Pass `range` to `retrieveBlob` and answer with `206`:

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

// The same route, with seek support. `parseRangeHeader` resolves the header
// against the size on the record. It handles suffix ranges (`bytes=-500` is the
// LAST 500 bytes) and the unsatisfiable case. `blob.range` then reports the
// slice that was served, and `artifact.size` the whole file: the two numbers
// that `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` goes on every response, also the whole-file one. It tells
  // the player that it can seek.
  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 needs nothing special. When the route answers ranges, the browser does the rest:

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

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

With `preload="metadata"`, the player fetches the header bytes with a range request. It shows the duration and the seek bar without a download of the full clip. The store must support ranged reads for this to work. The [conformance testkit](./store-reference#blobstore) checks it.

### Choose what gets captured

Two options of `withGenerationPersistence` control the capture:

- `extractArtifacts`: return your own descriptors. A custom `extractArtifacts` replaces the built-in rule.
- `nameArtifact`: give each file a name.

If you use a video provider with [no public URL](../media/video-generation#videos-with-no-public-url) and a custom `extractArtifacts`, return a descriptor with `bytes: result.body` for the video. If you do not, the stream is not stored, and `url` is a base64 `data:` URL.

### Choose where the bytes land

By default, the bytes go under `artifacts/<runId>/<artifactId>`. Pass `storageKey` to use your own folder structure. This helps when your app shares the bucket, or when you want media grouped by the thing it belongs to, not by the run:

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

- **The resolved key is saved on the artifact.** A custom path cannot be computed again from the record. So it is stored as `ArtifactRecord.blobKey`, and reads use it. Every record must have `blobKey`. A record without it does not load.
- **A key that is not unique overwrites.** Include `artifactId`, or another unique value, unless you want the overwrite.
- **This is server-side only.** A key from the browser can cause path traversal and writes into the data of another tenant.

### Prompt media referenced by URL

The middleware stores the **generated output**. When a provider returns a link that expires, the middleware downloads it and keeps the bytes.

Prompt media is different. It depends on how you sent it:

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

The middleware leaves a caller URL alone for two reasons:

- A server-side download lets anyone name an address that your server can reach, for example a cloud metadata endpoint or a `localhost` admin service. The caller can then read the response through the artifact `GET` route.
- The copy is not necessary. The caller already had the media.

If you need a durable copy of caller media (for example a "paste an image URL" input), opt in with `allowInputUrl`. It is a predicate, not a flag, so you must write the check:

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

Every artifact fetch, input or output, has three limits:

- The scheme must be `http:` or `https:`.
- The fetch stops after `artifactFetchTimeoutMs` (default 30s).
- The body is capped at `maxArtifactBytes` (default 1 GiB) while it drains. Pass `false` for no cap. See [Nothing is buffered](#nothing-is-buffered).

Input fetches add two more limits:

- A block on loopback, private, and link-local hosts.
- No redirects, so a `302` cannot go to an address that the check did not see.

These limits are a backstop, not the main control. A hostname that *resolves* to a private address still passes a literal-IP check. Keep `allowInputUrl` narrow. For stronger isolation, inject `artifactFetch` to send downloads through an egress-restricted proxy. That proxy can check the address that the connection actually reached.

### Nothing is buffered

The middleware **streams** a provider URL into the blob store. It never holds the artifact in memory, and it counts `size` on the record 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, because it keeps the bytes in process by design. It is a store for development and tests, not for production.

So `maxArtifactBytes` limits **transfer**, not memory. It puts a ceiling on what a runaway or hostile origin can make you download and store. `content-length` is only a claim: an origin can declare 1 KB and send data without end. That is the only reason for the default.

#### A provider stream is stored the same way

Some video providers return the finished video as a stream, with no URL to fetch. See [Videos with no public URL](../media/video-generation#videos-with-no-public-url). The middleware passes that stream to `BlobStore.put` as it is, so memory stays flat here too.

There are two differences from a fetched URL:

- **No declared length.** The stream has no `content-length`, and the middleware sends no `BlobPutOptions.expectedLength`. Your store must accept a stream of unknown length. On R2, that means a multipart upload.
- **No size cap.** `maxArtifactBytes` applies to URL fetches only.

#### The body reaches your store untouched when possible

To enforce the cap during the drain, the middleware wraps the body in a `TransformStream` that counts. The readable side of a transform has **no declared length**, and the workerd `R2Bucket.put` needs that length for a single-shot upload. So the middleware adds the wrapper only where it is necessary:

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

In the first row, the counter is not necessary. The declared length was already checked against the cap, and HTTP framing holds the origin to it. That is the common case for a provider CDN. So on Cloudflare, the default configuration 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 need the counter:

- A chunked reply declares no length.
- A compressed reply can decode to much more than it declared (a decompression bomb).

For those rows, `BlobPutOptions.expectedLength` is also absent. A store that needs a length then uses a multipart upload. That upload buffers one 8 MiB part at a time, so memory stays flat for any artifact size. The `ai-persistence/build-cloudflare-artifact-store` skill has that recipe.

To remove the ceiling (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,
})
```

Use this when you trust the origins that you fetch from. The limits of your storage backend still apply. For example, R2 caps a single-shot upload at 5 GiB and a multipart upload at 10,000 parts.

Keep the cap when `allowInputUrl` lets callers name the URL. Then you do not control the origin.
