본문으로 건너뛰기

생성된 파일 유지

생성된 미디어의 Provider URL은 만료됩니다. Sora 클립, 이미지 묶음, 긴 오디오 트랙처럼 모델이 제공하는 URL은 시간이 지나면 작동하지 않으며, 그렇게 되면 출력이 사라집니다. 출력을 유지하려면 생성된 바이트를 자체 스토리지에 저장하고 자체 오리진에서 제공해야 합니다. 그러면 Provider 링크보다 오래 유지할 수 있습니다.

이는 서버 측에서 선택적으로 활성화하는 기능이며, 생성 영속성에 이미 필요한 generationRuns 스토어 위에 추가됩니다. 바이트 스토리지를 사용하려면 두 개의 스토어를 더 함께 제공해야 합니다.

  • artifacts 스토어: 메타데이터를 저장합니다.
  • blobs 스토어: 바이트를 저장합니다.

각 선택의 결과는 다음과 같습니다.

  • 둘 다 제공: withGenerationPersistence가 각 생성 파일의 바이트를 blob 스토어에 쓰고, ArtifactRecord를 기록하며, 결과와 실행 레코드에 영속적인 참조를 연결합니다.
  • 둘 다 제공하지 않음: 실행 레코드만 유지됩니다.

memoryPersistence()는 세 스토어(generationRuns, artifacts, blobs)를 모두 제공하므로 바로 사용할 수 있습니다. ArtifactStoreBlobStore를 구현하는 모든 백엔드(생성 어댑터 빌드 참고)도 같은 방식으로 작동합니다.

저장된 바이트 제공

바이트는 blob 키 artifacts/<runId>/<artifactId> 아래에 저장됩니다. 나중에 생성 파일을 가져오려면(렌더링, 다운로드 또는 다른 요청에 전달하기 위해) retrieveArtifact / retrieveBlob 헬퍼로 아티팩트를 다시 읽고 자체 오리진에서 스트리밍하는 GET 경로를 추가합니다.

// 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)
}

제공 경로는 생성 엔드포인트와 별도의 경로입니다. 생성 경로의 GET은 이미 생성 영속성이 사용합니다. 이 경로에서 새로 고침한 클라이언트가 하이드레이션되고 진행 중인 실행이 재개되므로, 바이트에는 위의 artifactUrl이 지정하는 자체 경로가 필요합니다.

// 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),
},
})
}

비디오 제공: Range 준수

위 경로는 이미지에는 충분하지만 비디오에는 충분하지 않습니다. <video>의 탐색은 HTTP 범위 요청을 기반으로 하므로 모든 Range 요청에 전체 파일로 응답하는 소스에서는 탐색할 수 없습니다. Safari는 아예 재생을 거부하고, 다른 브라우저는 재생을 시작하기 전에 전체 클립을 다운로드합니다. retrieveBlobrange를 전달하고 206으로 응답합니다.

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}`,
},
})
}

클라이언트 측에는 별도의 처리가 필요하지 않습니다. 범위 요청에 응답하는 경로가 있으면 브라우저가 나머지를 처리합니다.

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"를 사용하면 플레이어가 범위 요청으로 헤더 바이트를 가져와 클립 전체를 다운로드하지 않고 재생 시간과 탐색 막대를 표시합니다. 이 기능이 작동하려면 스토어가 범위 읽기를 지원해야 하며, 적합성 테스트킷이 이를 검증합니다.

memoryPersistence는 모든 것을 프로세스 메모리에 유지하므로 개발 및 테스트에 적합합니다. 프로덕션에서는 generationRuns / artifacts / blobs를 영속적인 백엔드에 연결합니다. withGenerationPersistenceextractArtifacts(자체 디스크립터 반환) 및 nameArtifact(각 파일 이름 지정) 옵션으로 캡처할 항목을 제어합니다.

바이트 저장 위치 선택

기본적으로 아티팩트의 바이트는 artifacts/<runId>/<artifactId> 아래에 기록됩니다. 대신 자체 폴더 구조에 저장하려면 storageKey를 전달합니다. 버킷을 앱의 다른 부분과 공유하거나, 미디어를 생성한 실행이 아니라 미디어가 속한 대상별로 그룹화하려는 경우 유용합니다.

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

알아둘 사항은 다음 두 가지입니다.

해결된 키는 아티팩트에 기록됩니다. 경로는 임의적이므로 기록에서 다시 계산할 수 없으므로 ArtifactRecord.blobKey 로 저장되고 이를 통해 해결합니다. 이전에 작성된 기록은 기본 관례로 되돌아갑니다. 따라서 storageKey 를 기존 아티팩트가 있는 앱에 추가하면 이를 고아화하지 않습니다. 이는 기본 관례를 사후적으로 변경할 수 없음을 의미합니다.

고유하지 않은 키를 반환하면 덮어씁니다. 덮어쓰려는 것이 아니라면 artifactId 또는 그에 준하는 고유 값을 포함합니다.

이는 의도적으로 서버 측 전용입니다. 브라우저에서 제공하는 키는 경로 탐색 및 교차 테넌트 작성 벡터가 됩니다.

URL로 참조되는 프롬프트 미디어

저장되는 것은 생성된 출력입니다. 공급자가 만료 링크를 반환하면 미들웨어가 이를 다운로드하고 바이트를 보관하며 이것이 이 페이지의 전체 목적입니다.

프롬프트 미디어는 다르며, 전송 방식에 따라 나뉩니다.

  • base64 (source: { type: 'data' }): 출력과 함께 저장되며 바이트는 이미 손에 있습니다.
  • URL (source: { type: 'url' }): fetch되지 않으며 이에 대한 아티팩트가 기록되지 않습니다.

호출자가 제공한 URL을 그대로 두는 이유는 두 가지입니다. 서버 측에서 다운로드하면 서버가 접근할 수 있는 주소(클라우드 메타데이터 엔드포인트, localhost 관리 서비스 등)를 누구나 지정한 뒤 아티팩트 GET 경로를 통해 응답을 읽을 수 있게 됩니다. 또한 URL을 제공한 호출자는 이미 미디어를 가지고 있으므로 복사도 불필요합니다.

호출자가 제공한 미디어의 영속적인 복사본이 필요하다면(예: "이미지 URL 붙여넣기" 입력 상자) allowInputUrl로 옵트인합니다. 검사를 선택 사항으로 만들지 않기 위해 이 옵션은 플래그가 아니라 조건자입니다.

const inputUrlOptions = withGenerationPersistence(persistence, {
allowInputUrl: ({ url }) => url.hostname.endsWith('.cdn.example.com'),
})

입력이든 출력이든 모든 아티팩트 가져오기는 다음 세 가지 제한을 받습니다.

  • 스킴은 http: 또는 https:여야 합니다.
  • artifactFetchTimeoutMs 후에 중단됩니다(기본값 30초).
  • 본문을 읽는 동안 maxArtifactBytes(기본값 1GiB)로 제한되며, false를 전달하면 제한되지 않습니다. 버퍼링하지 않음을 참고합니다.

입력 가져오기에는 루프백 / 비공개 / 링크 로컬 호스트 차단과 리디렉션 추적 거부라는 두 가지 제한이 추가되므로, 302가 검사하지 않은 곳으로 이동할 수 없습니다.

이는 통제 수단이 아니라 보완책으로 취급합니다. 비공개 주소로 해석되는 호스트 이름도 리터럴 IP 검사는 통과합니다. allowInputUrl의 범위를 좁게 유지하고, 더 강한 격리가 필요하면 artifactFetch를 주입하여 실제 연결된 주소를 검사할 수 있는 송신 제한 프록시를 통해 다운로드를 라우팅합니다.

버퍼링하지 않음

Provider URL은 blob 스토어로 스트리밍됩니다. 미들웨어는 아티팩트를 메모리에 보관하지 않으며 레코드의 size는 바이트가 소진될 때 계산됩니다. 2GB 비디오는 스트리밍 스토어(R2, S3, 파일 시스템)에서 2KB 아이콘과 같은 메모리를 사용합니다. memoryPersistence는 프로세스에서 바이트를 보관하는 것이 기능 자체이므로 예외이며, 개발/테스트용 스토어이지 프로덕션용이 아닙니다.

따라서 maxArtifactBytes는 메모리가 아니라 전송량의 한도입니다. 이 옵션은 제어 불능이거나 악의적인 오리진이 가져와 저장하게 만들 수 있는 양을 제한합니다. content-length는 참고 정보일 뿐이므로 오리진이 1KB라고 선언한 뒤 계속 전송할 수 있습니다. 기본값이 존재하는 이유는 이것뿐입니다.

가능한 경우 본문은 변경 없이 스토어에 도달함

읽는 동안 한도를 적용하려면 본문을 TransformStream으로 감쌉니다. 변환의 읽기 측에는 선언된 길이가 없으며, 이는 workerd의 R2Bucket.put 단일 업로드에 정확히 필요한 조건입니다. 따라서 래퍼는 실제로 필요한 경우에만 적용됩니다.

응답스토어가 받는 값
content-length를 선언하고 content-encoding이 없음길이가 유지된 변경되지 않은 fetch 본문
청크 방식이고 길이를 선언하지 않음카운팅 래퍼
content-encoding: gzip(선언된 길이는 압축됨)카운팅 래퍼

첫 번째 행에서는 카운터를 건너뛰어도 손실이 없습니다. 선언된 길이는 이미 한도와 비교되었고 HTTP 프레이밍이 오리진에 그 길이를 적용하므로 본문은 선언된 길이를 초과할 수 없습니다. Provider CDN에서는 이것이 일반적인 경우이므로 Cloudflare에서는 기본 구성만으로도 바로 스트리밍됩니다.

// 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)

나머지 두 행에는 실제로 카운터가 필요합니다. 청크 응답은 아무것도 선언하지 않고, 압축된 응답은 선언된 값보다 임의로 크게 디코딩될 수 있습니다(압축 해제 폭탄). 이러한 경우 BlobPutOptions.expectedLength도 없으며, 길이에 엄격한 스토어는 한 번에 8MiB 파트 하나만 버퍼링하는 멀티파트 업로드로 대체되므로 아티팩트 크기와 관계없이 메모리 사용량이 일정합니다. ai-persistence/build-cloudflare-artifact-store 스킬이 해당 방식을 제공합니다. 한도를 완전히 제거하려면(어떤 응답에도 카운터를 적용하지 않고 오리진이 버킷으로 스트리밍할 양을 제한하지 않으려면) false를 전달합니다.

const uncappedOptions = withGenerationPersistence(persistence, {
maxArtifactBytes: false,
})

가져오는 오리진을 신뢰하고 넉넉한 한도보다 한도 자체를 두지 않기를 원할 때 사용합니다. 스토리지 백엔드 자체의 제한은 여전히 적용됩니다. 예를 들어 R2는 단일 업로드를 5GiB, 멀티파트 업로드를 10,000개 파트로 제한합니다. allowInputUrl로 호출자가 URL을 지정할 수 있다면 오리진을 제어할 수 없으므로 한도를 유지합니다.

영속 URL을 클라이언트까지 전달

artifactUrl을 사용하면 별도의 연결 작업 없이 클라이언트가 저장된 바이트에 접근할 수 있습니다. 각 영속 ref에 대해 바이트를 제공하는 앱 오리진 URL(위의 GET 경로)을 반환하며, withGenerationPersistence는 이를 다음 두 가지 방식으로 사용합니다.

  • URL을 ref.url로 ref에 기록합니다.
  • 라이브 결과의 미디어 필드를 같은 URL로 다시 씁니다. 이미지에는 result.images[i].url, 비디오에는 result.url, 오디오에는 result.audio.url을 사용합니다.

따라서 라이브 결과는 Provider의 만료되는 링크가 아니라 이미 자체 오리진을 가리킵니다.

이러한 영속 ref는 result.artifacts에 함께 포함되며 새로 고침 시 이를 통해 복원됩니다. 생성 영속성에서는 생성 훅이 마운트될 때 영속 ref에서 result를 재구성하고 각 미디어 필드를 영속적인 ref.url로 확인하므로, 복원된 결과에도 라이브 실행과 같은 미디어가 렌더링됩니다. 훅에서 아티팩트에 접근하는 표면은 result.artifacts 하나뿐이며, 라이브 결과와 복원된 결과 모두 별도의 최상위 아티팩트 필드를 읽지 않습니다.

다음 단계

  • 생성 영속성: 새로 고침이나 연결 끊김 이후에도 유지되는 실행 레코드와 바이트 스토리지가 기반으로 사용하는 generationRuns 스토어입니다.
  • 생성 어댑터 빌드: 자체 데이터베이스에 맞춘 ArtifactStore / BlobStore입니다.