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

124 lines
6.3 KiB
Markdown

# 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 dev``bun --hot ./index.ts`
- Start: `bun run start``bun ./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
`decodeDataUrl``Uint8Array<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`.