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
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# 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.
|
||||
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
|
||||
|
||||
@@ -20,45 +21,69 @@ SSE end-to-end so it survives Cloudflare's 120s proxy-read timeout.
|
||||
`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:175`), so it is reachable from other
|
||||
hosts on the network when running locally — be mindful when entering API keys.
|
||||
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
|
||||
|
||||
- `index.ts` — the entire backend. One `Bun.serve` instance with:
|
||||
- `/` serves `index.html` via Bun's HTML import (`import index from "./index.html"`).
|
||||
- `POST /api/generate` accepts
|
||||
`{ baseURL, apiKey, model, prompt, size, referenceImages? }` and **always
|
||||
responds with `text/event-stream`**. Emitted events:
|
||||
- `event: partial` — `{ image: dataUrl, index }` for each `partial_image`
|
||||
- `event: final` — `{ image: dataUrl }` for the completed image
|
||||
- `event: done` — empty payload, sent right before close
|
||||
- `event: error` — `{ message }` for any failure
|
||||
- SSE comments `: keepalive` every 20s while waiting for upstream, so
|
||||
Cloudflare's 120s proxy-read timeout never fires.
|
||||
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` (image blobs decoded from data URLs).
|
||||
`multipart/form-data` (blobs decoded from data URLs via
|
||||
`decodeDataUrl` → `Uint8Array<ArrayBuffer>`).
|
||||
- Otherwise → `POST {baseURL}/images/generations` as JSON.
|
||||
- Both calls send `stream: true, partial_images: 2` first. If upstream
|
||||
returns a 400 mentioning `stream` or `partial_images`,
|
||||
`isStreamingUnsupportedError` triggers a single retry with
|
||||
`stream: false` and the response is replayed as one `final` event via
|
||||
`forwardUpstreamJSON`. Any other 4xx/5xx propagates as `error`.
|
||||
- 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 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`.
|
||||
- `index.html` — self-contained UI: inline CSS, plain DOM JS, no build step.
|
||||
Reads the SSE response via `fetch` + `ReadableStream` (not `EventSource`,
|
||||
because the API is `POST`). Partials overwrite a single `<img>` so the
|
||||
preview animates in place. Text fields (`baseURL`, `apiKey`, `model`,
|
||||
`size`, `prompt`) persist in `localStorage` under the `aip:<field>` prefix.
|
||||
Reference images are kept in an in-memory `refImages` array as base64 data
|
||||
URLs and are **not** persisted. There is no React code despite
|
||||
`react` / `react-dom` / `@types/react*` being in `package.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.
|
||||
- `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
|
||||
|
||||
@@ -69,21 +94,31 @@ hosts on the network when running locally — be mindful when entering API keys.
|
||||
- `verbatimModuleSyntax` + `moduleDetection: "force"` — use `import type` for
|
||||
type-only imports; every file is a module.
|
||||
- `allowImportingTsExtensions` is on; `.ts` extensions in imports are fine.
|
||||
- `jsx: "react-jsx"` is set but unused (see frontend note above).
|
||||
- `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
|
||||
|
||||
- Add routes inside the `routes` object in `index.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 `: keepalive` comment
|
||||
timer in `start()`, do work inside `try`, always `clearInterval` and
|
||||
`controller.close()` in `finally`. Helpers `sseEvent` / `sseComment`
|
||||
already 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` (returns `Buffer` + mime)
|
||||
and pass them as `Blob` parts to `FormData` — same pattern as the edits
|
||||
path.
|
||||
- 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`.
|
||||
Reference in New Issue
Block a user