생성 영속성
미디어 생성에는 시간이 걸리며, 동영상은 몇 분이 걸릴 수 있습니다. 사용자가
페이지를 새로 고치거나 실행 중 연결이 끊기면 해당 실행을 쉽게 잃을 수 있습니다. 생성
영속성은 각 실행의 작은 기록을 유지하고 이를 훅의 일반적인 status / result / error
필드로 복원하므로, 새로 고친 후에도 새로 실행한 것과 정확히 같이 읽힙니다.
동영상, 일괄 이미지, 긴 오디오, 대용량 전사처럼 실행 시간이 길어 새로 고침이 중요한 경우에 사용합니다. 표시한 뒤 잊어도 되는 빠른 단일 이미지에는 사용하지 않습니다.
모든 것이 여기에 의존하는 id
이 모든 기능은 threadId를 키로 사용하며, 생성에서 이는 대화가 아니라 슬롯입니다.
즉, 연속 실행이 채워지는 위치에 앱이 정한 안정적인 이름을 지정합니다
(product-7-hero, video-9-start-frame). 각 실행에는 고유한 runId와 기록이
할당되며, 복원할 때 슬롯에서 가장 최신 실행을 반환합니다.
훅과 activity에 같은 문자열을 전달하고 새로 고침 사이에도 이를 안정적으로 유지합니다. 미들웨어가 activity에서 이 값을 읽으므로 activity에서 반복해서 전달할 필요가 없습니다.
Id map에서는 값을 선택하는 방법과 값이 달라질 때 발생하는 문제를 설명합니다.
켜기
persistence 은 boolean 입니다:
persistence: true: 서버가 기록을 유지합니다. 마운트할 때 훅은 해당threadId의 마지막 실행을 하이드레이션하고 이를 다시 표시합니다.- 생략 /
false: 꺼집니다. 실행은 메모리에만 존재하며, 새로 고치면 빈 상태로 시작합니다.
기록은 서버에 존재하며 withGenerationPersistence가 기록합니다. 이 기능에는
generationRuns 저장소가 필요합니다(실행 자체의 runId를 키로 사용하고, 실행이
속한 슬롯을 threadId로 기록하는 GenerationRunStore).
memoryPersistence()가 기본 제공되며, 자체 백엔드를 사용하려면
생성 어댑터 빌드를 참고합니다.
브라우저는 아무것도 캐시하지 않으므로 생성 기록이 로컬 저장소에 중복되지 않으며, 두 번째 기기에서도 같은 실행을 확인할 수 있습니다.
기록에는 생성된 바이트가 저장되지 않으므로, 기록만으로 새로 고치면 status와
error만 복원되고 result는 null로 유지됩니다. 미디어도 복원하려면 서버 바이트
저장소를 추가합니다. 생성된 파일 유지를 참고합니다.
기록의 생명 주기는 간단하며, 미들웨어가 하나의 상태 필드를 다음 단계로 진행합니다:
stateDiagram-v2
[*] --> running : generation starts (idempotent createOrResume)
running --> completed : finish, result metadata saved
running --> failed : error
running --> interrupted : abort
completed --> [*]
failed --> [*]
interrupted --> [*]
복원된 interrupted 실행은 훅에서 오류로 나타납니다. 중단된 생성은 재개할 수
없으며 다시 실행해야 합니다.
라우트 연결
생성을 실행하는 하나의 POST와 같은 경로에서 마운트 시 하이드레이션에
reconstructGeneration으로 응답하는 GET을 구성합니다:
import {
generateImage,
generationParamsFromRequest,
memoryStream,
resumeServerSentEventsResponse,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
memoryPersistence,
reconstructGeneration,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
export async function POST(request: Request) {
const durability = memoryStream(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, so a request without one cannot be served:
// the run would be filed nowhere the client could hydrate from.
if (threadId === undefined) {
return new Response('`threadId` is required', { status: 400 })
}
const stream = generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
// The same scope the GET below finds the last run for.
threadId,
stream: true,
// `artifactUrl` makes the restored media render from your own origin. It is
// optional; see Keep generated files for the serve route it points at.
middleware: [
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
}),
],
})
return toServerSentEventsResponse(stream, {
durability: { adapter: durability },
})
}
export function GET(request: Request): Response | Promise<Response> {
const durability = memoryStream(request)
// A reconnecting client carries a resume cursor; replay the live stream.
if (durability.resumeFrom() !== null) {
return resumeServerSentEventsResponse({ adapter: durability })
}
// Otherwise this is the mount-time hydration (?threadId). In multi-user apps
// pass reconstructGeneration's `authorize` option so a guessed threadId can't
// read another user's generation.
return reconstructGeneration(persistence, request)
}
클라이언트에서는 연결, 안정적인 threadId, persistence: true를 전달합니다.
훅은 투명하게 동작합니다. 새로 고치면 새 실행에서 사용하는 것과 같은 필드에
마지막 실행을 다시 표시하며, useChat이 messages로 복원하는 방식과 같습니다:
import { fetchServerSentEvents, useGenerateImage } from '@tanstack/ai-react'
const connection = fetchServerSentEvents('/api/generate/image')
export function HeroImageGenerator({ threadId }: { threadId: string }) {
const image = useGenerateImage({
threadId,
connection,
persistence: true,
})
return (
<section>
<button
type="button"
disabled={image.isLoading}
onClick={() =>
void image.generate({ prompt: 'A glass cabin in a pine forest' })
}
>
Generate
</button>
{image.status === 'success' ? (
<p>Last run finished{image.result?.id ? ` (${image.result.id})` : ''}.</p>
) : null}
{image.error ? <p>Last run failed: {image.error.message}</p> : null}
{image.result?.images.map((img, index) =>
img.url ? <img key={index} src={img.url} alt="" /> : null,
)}
</section>
)
}
서버가 영속적인 artifactUrl을 기록하므로 복원된
image.result.images[i].url은 자체 오리진에서 제공되며, 새로 고친 후에도 이미지가
실행 중과 동일하게 렌더링됩니다. 별도로 가져오거나 시드할 필요가 없습니다.
thread id가 안정적인 키이므로 새로 고치거나 다른 기기에서 같은 스레드를 사용해도
동일한 경로를 따릅니다.
연결이 끊기거나 페이지가 새로고침되는 동안 실행이 여전히 생성 중이면 클라이언트가 해당 실행에 다시 연결하여 useChat와 정확히 동일하게 그 자리에서 완료합니다.
durability 어댑터와 toServerSentEventsResponse, 그리고 GET의 재개 분기만 있으면 충분합니다: 마운트 시 reconstructGeneration는 라이브 실행을 보고하고 클라이언트는 내구성 로그를 통해 추적합니다. 프로덕션에서는 memoryStream를 durableStream로 교체하여 @tanstack/ai-durable-stream에서 요청이 프로세스 간에 확장되도록 합니다. Resumable Streams을 참조하세요.
sequenceDiagram
participant Hook as useGenerateImage (persistence: true)
participant Route as GET /api/generate/image
participant Runs as generationRuns store
participant Log as Delivery log
Note over Hook: mount (or reload) with a threadId
Hook->>Route: ?threadId=…
Route->>Runs: reconstructGeneration, latest run for the thread
Runs-->>Route: run record (status, result metadata, artifact refs)
Route-->>Hook: status / error / result repainted
alt run still generating
Hook->>Route: ?runId=…&offset=-1
Route->>Log: resumeServerSentEventsResponse
Log-->>Hook: replay + live tail, run finishes in place
end
서버 함수 / 직접 호출
위의 HTTP 어댑터는 하이드레이션과 재참여를 대신 구현합니다.
TanStack Start 서버 함수(또는 직접적인 프로세스 내
호출)에는 연결할 GET 경로가 없으므로 두 핸들러(마운트 시 하이드레이션용 하나,
진행 중인 실행 재생용 하나)를 직접 제공하고 fetcher(또는 stream() /
rpcStream())와 함께 옵션으로 전달합니다.
서버 함수 세 개로 처리합니다. 하나는 생성을 실행하고, 하나는
getGenerationHydration으로 하이드레이션에 응답하며, 하나는
replayRunStream으로 실행의 내구성 로그를 재생합니다. 두 스트리밍 함수는 모두
toServerSentEventsResponse에서 반환하는 SSE Response를 반환하므로 클라이언트는
같은 방식으로 디코딩합니다:
// server/image.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import {
generateImage,
memoryStream,
replayRunStream,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
import {
getGenerationHydration,
memoryPersistence,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
import type { ImageGenerateInput } from '@tanstack/ai-client'
const persistence = memoryPersistence()
export const generateImageFn = createServerFn({ method: 'POST' })
.inputValidator((data: ImageGenerateInput & { threadId: string }) => data)
.handler(({ data: { threadId, ...input } }) => {
if (typeof input.prompt !== 'string') {
throw new Error('This endpoint accepts text image prompts only.')
}
// One run id for both the durability log and the run record, so the
// rejoin below replays exactly the run hydration reports.
const runId = crypto.randomUUID()
const stream = generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: input.prompt,
threadId,
runId,
stream: true,
// `artifactUrl` is optional; see Keep generated files.
middleware: [
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
}),
],
})
return toServerSentEventsResponse(stream, {
durability: { adapter: memoryStream({ runId }) },
})
})
/**
* The wire shape for mount hydration, parsed with Zod so that:
* - TanStack Start gets a JSON-serializable return type (the run record's
* `result` is loosely typed, and Start's serializer rejects `unknown`)
* - the stored record is validated before it crosses the wire
*/
const hydrationSchema = z.object({
resumeSnapshot: z
.object({
schemaVersion: z.literal(1),
resumeState: z
.object({ threadId: z.string(), runId: z.string() })
.nullable(),
status: z.enum(['idle', 'running', 'complete', 'error']),
// `z.any()` (not `z.unknown()`) so Start's serializer accepts the values.
result: z.record(z.string(), z.any()).optional(),
error: z
.object({ message: z.string(), code: z.string().optional() })
.optional(),
activity: z.string().optional(),
})
.nullable(),
activeRun: z.object({ runId: z.string() }).nullable(),
})
export const getImageHydrationFn = createServerFn({ method: 'GET' })
.inputValidator(z.string().min(1))
.handler(async ({ data: threadId }) => {
// `getGenerationHydration` does no auth, so gate on your session here, the
// way you would pass `authorize` to `reconstructGeneration`.
return hydrationSchema.parse(
await getGenerationHydration(persistence, threadId),
)
})
export const joinImageRunFn = createServerFn({ method: 'GET' })
.inputValidator(z.string().min(1))
.handler(({ data: runId }) =>
// Same SSE envelope `generateImageFn` returns, so one client-side decoder
// covers both.
toServerSentEventsResponse(replayRunStream(memoryStream({ runId }))),
)
클라이언트에서는 fetcher와 함께 두 핸들러를 전달합니다. 이제 새로 고치면
getImageHydrationFn을 통해 마지막 실행을 하이드레이션하고, 아직 생성 중인 실행은
joinImageRunFn을 통해 완료될 때까지 수신합니다. joinRun은 Response가 아니라
StreamChunk를 생성하므로 SSE 본문을 직접 디코딩하고 그곳에 signal을 적용합니다.
서버 함수 자체는 data만 받습니다:
import { useGenerateImage } from '@tanstack/ai-react'
import type { StreamChunk } from '@tanstack/ai'
import {
generateImageFn,
getImageHydrationFn,
joinImageRunFn,
} from './server/image'
/** Decode an SSE `Response` from a server function into `StreamChunk`s. */
async function* chunksFromSseResponse(
response: Response,
signal?: AbortSignal,
): AsyncGenerator<StreamChunk> {
if (!response.ok) throw new Error(`Join failed: ${response.status}`)
const reader = response.body?.getReader()
if (!reader) return
const decoder = new TextDecoder()
let buffer = ''
try {
while (!signal?.aborted) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data:')) continue
const data = line.slice(5).trimStart()
// `JSON.parse` returns the chunk the server encoded.
if (data) yield JSON.parse(data)
}
}
} finally {
reader.releaseLock()
}
}
export function HeroImageGenerator({ threadId }: { threadId: string }) {
const image = useGenerateImage({
threadId,
fetcher: (input) => generateImageFn({ data: { ...input, threadId } }),
hydrateGeneration: (id) => getImageHydrationFn({ data: id }),
joinRun: async function* (runId, signal) {
const response = await joinImageRunFn({ data: runId })
if (!(response instanceof Response)) {
throw new Error('joinImageRunFn should return an SSE Response')
}
yield* chunksFromSseResponse(response, signal)
},
persistence: true,
})
// The render is identical to the HTTP example above.
return <p>{image.status}</p>
}
스트리밍하지 않는 fetcher(SSE Response가 아닌 일반
Promise<ImageGenerationResult>)에는 다시 참여할 진행 중인 스트림이 없으므로
hydrateGeneration만 필요합니다. joinRun과 디코더를 함께 제거합니다.
아직 생성 중인 실행을 수신할 joinRun 핸들러가 없는 상태로 복원하면
generating에 영원히 멈추지 않고 인터럽트 오류로 나타납니다(재개할 수 없으며
다시 실행해야 합니다).
동일한 핸들러는 가벼운 연결 어댑터인
stream(factory, { hydrateGeneration, joinRun }) 및
rpcStream(call, { hydrateGeneration, joinRun })에도 직접 사용할 수 있어
프로세스 내 또는 RPC 전송을 지원합니다. 또한 useChat의 서버 기반 영속성을 위한
chat hydrate 핸들러도 허용합니다.
새로 고칠 때 복원되는 항목
어떤 구성을 사용했든 다음 두 가지를 기억해야 합니다:
- 훅은 투명하게 동작합니다. 새로 고치면
status('idle'/'generating'/'success'/'error'), error와 result를 실행 중과 같이error와result를 다시 표시합니다. 직접 렌더링할 스냅샷 필드는 없습니다. result를 복원하려면 바이트 저장소가 필요합니다. 실행 기록에는 미디어 바이트가 아니라 결과 메타데이터만 저장되므로, 기록만으로 새로 고치면status와error만 복원되고result는null로 유지됩니다. 바이트 저장소와artifactUrl을 추가하면 (생성된 파일 유지)result가 다시 구성되고 미디어는 자체 오리진에서 제공됩니다.
스트리밍하지 않는 동영상은 두 번 호출합니다
generateVideo({ stream: true })는 생성 → 폴링 → 완료 생명 주기 전체를 한 번의
호출에서 실행하므로 영속성에서는 하나의 실행으로 인식되며 여기의 내용은 적용되지 않습니다.
stream: true가 없으면 두 번 호출하며 첫 번째 호출이 반환될 때는 동영상이 아직
존재하지 않습니다:
import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
import {
memoryPersistence,
withGenerationPersistence,
} from '@tanstack/ai-persistence'
import { openaiVideo } from '@tanstack/ai-openai'
const persistence = memoryPersistence()
const adapter = openaiVideo('sora-2')
const middleware = [withGenerationPersistence(persistence)]
const threadId = 'product-7-launch-clip'
// Opens the run. Status `running`, and there is no video yet.
const { jobId } = await generateVideo({
adapter,
prompt: 'A cat chasing a dog in a sunny park',
threadId,
middleware,
})
// Completes that same run. This is what writes the video and its artifacts.
const status = await getVideoJobStatus({ adapter, jobId, threadId, middleware })
두 호출 모두에 같은 threadId와 middleware를 전달합니다. jobId가 전체
상관관계입니다. 실행 ID가 여기서 파생되므로 별도로 저장하지 않아도 다른 요청이나
프로세스에서 폴링할 때 제출 단계가 연 실행을 찾습니다. 전달할 실행 ID는 없습니다.
폴링에서 작업의 최종 상태를 확인할 때까지 기록은 running으로 유지됩니다. 해당
슬롯을 하이드레이션하는 클라이언트는 아직 진행 중인 생성을 확인합니다. 제출에
실패하면 최종 error 실행을 기록하므로 새로 고칠 때 빈 슬롯이 아니라 실패를 표시합니다.
더 알아보기
- 생성된 파일 유지: 생성된 바이트를 저장하여 기록뿐 아니라 미디어 자체도 유지합니다.