- BASE_URL, API_KEY, MODEL now read from process.env (Bun auto-loads .env)
- requireEnv() fails fast at startup if any is missing
- request body simplifies to { prompt, size, referenceImages? }
- client drops the three fields from form and localStorage
- add .env.example as the variable-name source of truth
- AGENTS.md notes the 0.0.0.0 bind now exposes the upstream quota to
anyone reachable on the network
7.3 KiB
AGENTS.md
Bun + Hono server that proxies an OpenAI-compatible image endpoint and serves a small vanilla TS playground. SSE end-to-end: streams gpt-image partial previews through, with keepalive comments that survive 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).
Config
Required env vars (validated at startup via requireEnv; the process exits
if any is missing):
| Var | Example | Purpose |
|---|---|---|
BASE_URL |
https://api.openai.com/v1 |
OpenAI-compatible base URL |
API_KEY |
sk-… |
Bearer token sent to upstream |
MODEL |
gpt-image-2 |
Model name forwarded to upstream |
.env.example is the source of truth for variable names. The real .env
is gitignored. Restart the server after changing env vars — they are read
once at module load.
These secrets stay server-side. The browser only sends prompt,
size, and referenceImages. Combined with the 0.0.0.0 bind, this
means anyone reachable on the network can spend your upstream quota —
bind to 127.0.0.1 or put auth in front if that matters.
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), so it is reachable from other
hosts on the network — be mindful when entering API keys.
Bun's dev server auto-serves /.well-known/appspecific/com.chrome.devtools.json
advertising the project root to Chrome DevTools' "Automatic Workspace Folders".
Sandboxed browsers (Flatpak/Snap) reject the path with
Unable to add filesystem: <illegal path>. Disabled via
development.chromeDevToolsAutomaticWorkspaceFolders: false.
Architecture
Three files do everything:
index.ts— Hono app mounted underBun.serve(fetch: app.fetch).routes: { "/": index }servesindex.htmlvia Bun's HTML bundler; everything else falls through to Hono.idleTimeout: 255(max) —Bun.serve's 10s default kills SSE connections before the first keepalive can fire. The symptom is an empty EventStream in DevTools andrequest timed out after 10 secondsin the log.POST /api/generateusesstreamSSEfromhono/streaming. Accepts{ prompt, size, referenceImages? }—BASE_URL,API_KEY, andMODELcome from env, not the request. Emits:event: partial—{ image: dataUrl, index }for eachimage_generation.partial_image/image_edit.partial_image.event: final—{ image: dataUrl }for*.completed.event: done— empty payload, sent before stream ends.event: error—{ message }for any failure.- First write is
: connected\n\nso the browser/EventStream tab becomes responsive immediately; then a: keepalive\n\nraw comment every 15s.
- Upstream dispatch:
referenceImagespresent →POST {baseURL}/images/editsasmultipart/form-data(blobs decoded from data URLs viadecodeDataUrl→Uint8Array<ArrayBuffer>). Single reference uses field nameimage; two or more references useimage[]to match OpenAI's documented array syntax — strict gateways reject repeatedimageparts with aduplicate_parameter400.- Otherwise →
POST {baseURL}/images/generationsas JSON. - Always sends
stream: true, partial_images: 2first. On a 400 that mentionsstreamorpartial_images(seeisStreamingUnsupportedError), retries once withstream: falseand replays the JSON response as a singlefinalevent viaforwardUpstreamJSON. Any other 4xx/5xx becomes anerrorevent.
AbortControllerwired tostream.onAbort()and threaded assignalinto every upstreamfetch. Thecatchbranch is suppressed whensignal.abortedso closed tabs don't spam the log.- Targets the gpt-image series only (gpt-image-2 default). Do not
reintroduce DALL·E-only fields like
response_format— gpt-image always returnsb64_json.
client.ts— browser entry, loaded via<script type="module" src="./client.ts">inindex.html. Bun's bundler resolves the import, inlines@microsoft/fetch-event-source, and serves the bundle from/_bun/client/index-*.js. Inline<script type="module">blocks are not bundled by Bun — any client JS that imports fromnode_modulesmust live in a separate file.- Uses
fetchEventSourceinstead of hand-rolledfetch+ReadableStreamSSE parsing. It supports POST + body, custom headers,signal, and theonopen/onmessage/onerrorcallbacks. - On
done, the client callsabort.abort()to terminate thefetchEventSourceloop cleanly — otherwise it would retry forever. - Text fields (
size,prompt) persist inlocalStorageunder theaip:<field>prefix. Reference images stay in-memory only.
- Uses
index.html— markup + inline CSS only. No JS lives here.
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.lib: ["ESNext", "DOM", "DOM.Iterable"]— DOM globals are in scope forclient.ts. The server file uses Bun globals from@types/bun; the overlap (fetch,Response,Blob,FormData) resolves to Web standards, which is what we want.- TS 5.7+ split
Uint8ArrayintoUint8Array<ArrayBuffer>vsUint8Array<SharedArrayBuffer>. DOM'sBlob/BufferSourcerequires the former. Allocate vianew Uint8Array(new ArrayBuffer(n))(seedecodeDataUrl) rather thannew Uint8Array(n)— the latter widens toArrayBufferLikeand fails to satisfyBlobPart.
When extending the API
- Routes live on the Hono
app. For long-running upstream calls, mirror the existing pattern:return streamSSE(c, async (stream) => { … })stream.onAbort(() => abortController.abort())at the topawait stream.write(": connected\n\n")to flush headers immediatelysetInterval(() => stream.write(": keepalive\n\n").catch(() => {}), 15_000)andclearIntervalinfinallystream.writeSSE({ event, data: JSON.stringify(payload) })for application events- Catch errors and check
signal.abortedbefore emittingerror— otherwise every closed tab logs noise.
- Send the optimistic request to upstream first; detect the specific 400
via
isStreamingUnsupportedErrorand retry with a degraded body rather than feature-detecting up front. - Decode incoming data URLs with
decodeDataUrland pass the typedUint8Array<ArrayBuffer>directly as aBlobpart inFormData.