Files
imagen/AGENTS.md
T
imbytecat d5bbc14c8d feat: move base URL, API key, and model to server .env
- 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
2026-05-18 23:28:12 +08:00

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.md for the full Bun-vs-Node cheatsheet (prefer Bun.serve, Bun.file, bun:test, Bun.sql, etc.). Do not add dotenv — Bun loads .env automatically.
  • Bun version baseline: 1.3.13 (per README.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 devbun --hot ./index.ts
  • Start: bun run startbun ./index.ts
  • Typecheck: no script defined. Use bunx tsc --noEmit (tsconfig already sets noEmit: true, so plain bunx tsc works 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 under Bun.serve (fetch: app.fetch).
    • routes: { "/": index } serves index.html via 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 and request timed out after 10 seconds in the log.
    • POST /api/generate uses streamSSE from hono/streaming. Accepts { prompt, size, referenceImages? }BASE_URL, API_KEY, and MODEL come from env, not the request. Emits:
      • event: partial{ image: dataUrl, index } for each image_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\n so the browser/EventStream tab becomes responsive immediately; then a : keepalive\n\n raw comment every 15s.
    • Upstream dispatch:
      • referenceImages present → POST {baseURL}/images/edits as multipart/form-data (blobs decoded from data URLs via decodeDataUrlUint8Array<ArrayBuffer>). Single reference uses field name image; two or more references use image[] to match OpenAI's documented array syntax — strict gateways reject repeated image parts with a duplicate_parameter 400.
      • Otherwise → POST {baseURL}/images/generations as JSON.
      • Always sends stream: true, partial_images: 2 first. On a 400 that mentions stream or partial_images (see isStreamingUnsupportedError), retries once with stream: false and replays the JSON response as a single final event via forwardUpstreamJSON. Any other 4xx/5xx becomes an error event.
    • AbortController wired to stream.onAbort() and threaded as signal into every upstream fetch. The catch branch is suppressed when signal.aborted so 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 returns b64_json.
  • client.ts — browser entry, loaded via <script type="module" src="./client.ts"> in index.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 from node_modules must live in a separate file.
    • Uses fetchEventSource instead of hand-rolled fetch + ReadableStream SSE parsing. It supports POST + body, custom headers, signal, and the onopen / onmessage / onerror callbacks.
    • On done, the client calls abort.abort() to terminate the fetchEventSource loop cleanly — otherwise it would retry forever.
    • Text fields (size, prompt) persist in localStorage under the aip:<field> prefix. Reference images stay in-memory only.
  • 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, noFallthroughCasesInSwitch are on — array/object index access is T | undefined and must be narrowed.
  • verbatimModuleSyntax + moduleDetection: "force" — use import type for type-only imports; every file is a module.
  • allowImportingTsExtensions is on; .ts extensions in imports are fine.
  • lib: ["ESNext", "DOM", "DOM.Iterable"] — DOM globals are in scope for client.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 Uint8Array into Uint8Array<ArrayBuffer> vs Uint8Array<SharedArrayBuffer>. DOM's Blob/BufferSource requires the former. Allocate via new Uint8Array(new ArrayBuffer(n)) (see decodeDataUrl) rather than new Uint8Array(n) — the latter widens to ArrayBufferLike and fails to satisfy BlobPart.

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 top
    • await stream.write(": connected\n\n") to flush headers immediately
    • setInterval(() => stream.write(": keepalive\n\n").catch(() => {}), 15_000) and clearInterval in finally
    • stream.writeSSE({ event, data: JSON.stringify(payload) }) for application events
    • Catch errors and check signal.aborted before emitting error — otherwise every closed tab logs noise.
  • Send the optimistic request to upstream first; detect the specific 400 via isStreamingUnsupportedError and retry with a degraded body rather than feature-detecting up front.
  • Decode incoming data URLs with decodeDataUrl and pass the typed Uint8Array<ArrayBuffer> directly as a Blob part in FormData.