5af05b2141
- /api/generate now responds with text/event-stream end-to-end - forwards upstream image_generation.* / image_edit.* partial+completed events - 20s keepalive comments survive Cloudflare's 120s proxy-read timeout - falls back to non-streaming when upstream rejects stream/partial_images - drops @ai-sdk/openai-compatible, @ai-sdk/react, ai (unused) - frontend consumes SSE via fetch+ReadableStream, shows progressive preview
4.6 KiB
4.6 KiB
AGENTS.md
Bun + TypeScript single-file server that proxies an OpenAI-compatible image endpoint and serves a small vanilla HTML/JS playground. The whole pipeline is SSE end-to-end so it survives Cloudflare's 120s proxy-read timeout.
Runtime
- Bun, not Node. See
CLAUDE.mdfor the full Bun-vs-Node cheatsheet (preferBun.serve,Bun.file,bun:test,Bun.sql, etc.). Do not adddotenv— Bun loads.envautomatically. - Bun version baseline:
1.3.13(perREADME.md).
Commands
- Install:
bun install - Dev (HMR):
bun run dev→bun --hot ./index.ts - Start:
bun run start→bun ./index.ts - Typecheck: no script defined. Use
bunx tsc --noEmit(tsconfig already setsnoEmit: true, so plainbunx tscworks too). - Tests / lint / formatter: none configured. If adding tests, use
bun test.
The server binds 0.0.0.0 (see index.ts:175), so it is reachable from other
hosts on the network when running locally — be mindful when entering API keys.
Architecture
index.ts— the entire backend. OneBun.serveinstance with:/servesindex.htmlvia Bun's HTML import (import index from "./index.html").POST /api/generateaccepts{ baseURL, apiKey, model, prompt, size, referenceImages? }and always responds withtext/event-stream. Emitted events:event: partial—{ image: dataUrl, index }for eachpartial_imageevent: final—{ image: dataUrl }for the completed imageevent: done— empty payload, sent right before closeevent: error—{ message }for any failure- SSE comments
: keepaliveevery 20s while waiting for upstream, so Cloudflare's 120s proxy-read timeout never fires.
- Upstream dispatch:
referenceImagespresent →POST {baseURL}/images/editsasmultipart/form-data(image blobs decoded from data URLs).- Otherwise →
POST {baseURL}/images/generationsas JSON. - Both calls send
stream: true, partial_images: 2first. If upstream returns a 400 mentioningstreamorpartial_images,isStreamingUnsupportedErrortriggers a single retry withstream: falseand the response is replayed as onefinalevent viaforwardUpstreamJSON. Any other 4xx/5xx propagates aserror.
- Targets the gpt-image series only (gpt-image-2 is the default). Do
not reintroduce DALL·E-only fields like
response_format— gpt-image always returnsb64_json.
index.html— self-contained UI: inline CSS, plain DOM JS, no build step. Reads the SSE response viafetch+ReadableStream(notEventSource, because the API isPOST). Partials overwrite a single<img>so the preview animates in place. Text fields (baseURL,apiKey,model,size,prompt) persist inlocalStorageunder theaip:<field>prefix. Reference images are kept in an in-memoryrefImagesarray as base64 data URLs and are not persisted. There is no React code despitereact/react-dom/@types/react*being inpackage.json— treat those deps as latent. Do not invent a React frontend unless asked.- No router, no DB, no auth, no AI SDK. API key is supplied per-request by the browser and never stored server-side.
TypeScript conventions
tsconfig.json is strict with bundler-mode resolution:
strict,noUncheckedIndexedAccess,noImplicitOverride,noFallthroughCasesInSwitchare on — array/object index access isT | undefinedand must be narrowed.verbatimModuleSyntax+moduleDetection: "force"— useimport typefor type-only imports; every file is a module.allowImportingTsExtensionsis on;.tsextensions in imports are fine.jsx: "react-jsx"is set but unused (see frontend note above).
When extending the API
- Add routes inside the
routesobject inindex.ts; keep the{ POST: async (req) => … }shape used by/api/generate. - For any long-running upstream call, mirror the SSE-with-keepalive pattern:
build a
ReadableStream<Uint8Array>, start a 20s: keepalivecomment timer instart(), do work insidetry, alwaysclearIntervalandcontroller.close()infinally. HelperssseEvent/sseCommentalready exist. - Stay defensive about upstream capabilities: many OpenAI-compatible
providers reject unknown params. Send the optimistic request first, then
detect the specific 400 (see
isStreamingUnsupportedError) and retry with a degraded body rather than feature-detecting up front. - Decode incoming data URLs with
decodeDataUrl(returnsBuffer+ mime) and pass them asBlobparts toFormData— same pattern as the edits path.