Files
imagen/AGENTS.md
T
imbytecat 600f574b5c refactor: replace hand-rolled SSE with Hono + fetch-event-source
Server (index.ts):
- migrate to Hono streamSSE, mounted under Bun.serve fetch handler
- idleTimeout: 255 fixes the silent Bun 10s timeout that killed SSE
  responses before the first keepalive could fire (root cause of the
  empty EventStream tab)
- stream.onAbort wires an AbortController into upstream fetch signal
- 15s : keepalive raw SSE comments for Cloudflare 120s headroom
- decodeDataUrl returns Uint8Array<ArrayBuffer> for DOM Blob types
- chromeDevToolsAutomaticWorkspaceFolders: false silences the
  'Unable to add filesystem' warning in sandboxed browsers

Client (client.ts new):
- extracted from inline <script> — Bun only bundles external script src,
  not inline module imports, so node_modules bare specifiers must live
  in their own file
- @microsoft/fetch-event-source replaces hand-rolled fetch +
  ReadableStream parsing; supports POST + body + signal natively
- client aborts the loop on event:done so fetchEventSource doesn't retry

Build:
- drop unused react/react-dom/@types/react* deps (KISS)
- add 'DOM', 'DOM.Iterable' to tsconfig lib for client.ts
2026-05-18 23:13:06 +08:00

6.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).

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. 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>).
      • 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 (baseURL, apiKey, model, 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.