diff --git a/AGENTS.md b/AGENTS.md index 7fd2986..e969a51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: `. 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`). - 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 `` so the - preview animates in place. Text fields (`baseURL`, `apiKey`, `model`, - `size`, `prompt`) persist in `localStorage` under the `aip:` 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 ` + diff --git a/index.ts b/index.ts index e03f58b..c384f60 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,6 @@ +import { Hono } from "hono"; +import { streamSSE } from "hono/streaming"; +import type { SSEStreamingApi } from "hono/streaming"; import index from "./index.html"; type Size = `${number}x${number}`; @@ -11,26 +14,16 @@ type GenerateRequest = { referenceImages?: string[]; }; -type SSEController = ReadableStreamDefaultController; - -const encoder = new TextEncoder(); - -function sseEvent(controller: SSEController, event: string, data: unknown): void { - controller.enqueue( - encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), - ); -} - -function sseComment(controller: SSEController, text: string): void { - controller.enqueue(encoder.encode(`: ${text}\n\n`)); -} - -function decodeDataUrl(dataUrl: string): { bytes: Buffer; mime: string } | null { +function decodeDataUrl( + dataUrl: string, +): { bytes: Uint8Array; mime: string } | null { const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!match) return null; const mime = match[1]!; - const b64 = match[2]!; - return { bytes: Buffer.from(b64, "base64"), mime }; + const binary = atob(match[2]!); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, mime }; } async function callUpstream(args: { @@ -41,8 +34,9 @@ async function callUpstream(args: { size: Size; referenceImages: string[]; stream: boolean; + signal?: AbortSignal; }): Promise { - const { baseURL, apiKey, model, prompt, size, referenceImages, stream } = args; + const { baseURL, apiKey, model, prompt, size, referenceImages, stream, signal } = args; const isEdit = referenceImages.length > 0; const url = `${baseURL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`; @@ -71,6 +65,7 @@ async function callUpstream(args: { method: "POST", headers: { Authorization: `Bearer ${apiKey}` }, body: form, + signal, }); } @@ -86,6 +81,7 @@ async function callUpstream(args: { "Content-Type": "application/json", }, body: JSON.stringify(body), + signal, }); } @@ -101,68 +97,76 @@ function parseSSEBlock(raw: string): { event: string; data: string } | null { return { event: eventName, data: dataLines.join("\n") }; } +async function emitUpstreamBlock( + raw: string, + stream: SSEStreamingApi, +): Promise { + const block = parseSSEBlock(raw); + if (!block || block.data === "[DONE]") return; + let parsed: { + type?: string; + b64_json?: string; + partial_image_index?: number; + }; + try { + parsed = JSON.parse(block.data); + } catch { + return; + } + const type = parsed.type ?? block.event; + const b64 = parsed.b64_json; + if (!b64) return; + if (type.endsWith(".partial_image")) { + await stream.writeSSE({ + event: "partial", + data: JSON.stringify({ + image: `data:image/png;base64,${b64}`, + index: parsed.partial_image_index ?? 0, + }), + }); + } else if (type.endsWith(".completed")) { + await stream.writeSSE({ + event: "final", + data: JSON.stringify({ image: `data:image/png;base64,${b64}` }), + }); + } +} + async function forwardUpstreamSSE( upstream: Response, - controller: SSEController, + stream: SSEStreamingApi, ): Promise { if (!upstream.body) throw new Error("Upstream returned no body"); const reader = upstream.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; - - const handle = (raw: string) => { - const block = parseSSEBlock(raw); - if (!block) return; - if (block.data === "[DONE]") return; - let parsed: { - type?: string; - b64_json?: string; - partial_image_index?: number; - }; - try { - parsed = JSON.parse(block.data); - } catch { - return; - } - const type = parsed.type ?? block.event; - const b64 = parsed.b64_json; - if (!b64) return; - if (type.endsWith(".partial_image")) { - sseEvent(controller, "partial", { - image: `data:image/png;base64,${b64}`, - index: parsed.partial_image_index ?? 0, - }); - } else if (type.endsWith(".completed")) { - sseEvent(controller, "final", { - image: `data:image/png;base64,${b64}`, - }); - } - }; - while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx: number; while ((idx = buffer.indexOf("\n\n")) !== -1) { - handle(buffer.slice(0, idx)); + await emitUpstreamBlock(buffer.slice(0, idx), stream); buffer = buffer.slice(idx + 2); } } - if (buffer.trim().length > 0) handle(buffer); + if (buffer.trim().length > 0) await emitUpstreamBlock(buffer, stream); } async function forwardUpstreamJSON( upstream: Response, - controller: SSEController, + stream: SSEStreamingApi, ): Promise { const data = (await upstream.json()) as { data?: Array<{ b64_json?: string }>; }; for (const item of data.data ?? []) { if (!item.b64_json) continue; - sseEvent(controller, "final", { - image: `data:image/png;base64,${item.b64_json}`, + await stream.writeSSE({ + event: "final", + data: JSON.stringify({ + image: `data:image/png;base64,${item.b64_json}`, + }), }); } } @@ -171,94 +175,96 @@ function isStreamingUnsupportedError(errText: string): boolean { return /\b(stream|partial_images)\b/i.test(errText); } +const app = new Hono(); + +app.post("/api/generate", async (c) => { + const body = (await c.req.json()) as GenerateRequest; + const { baseURL, apiKey, model, prompt, size, referenceImages } = body; + if (!baseURL || !apiKey || !model || !prompt) { + return c.json( + { error: "baseURL, apiKey, model, prompt are required" }, + 400, + ); + } + const refs = Array.isArray(referenceImages) ? referenceImages : []; + const args = { + baseURL, + apiKey, + model, + prompt, + size: size ?? ("1024x1024" as Size), + referenceImages: refs, + }; + + return streamSSE(c, async (stream) => { + const abort = new AbortController(); + stream.onAbort(() => abort.abort()); + + await stream.write(": connected\n\n"); + const keepalive = setInterval(() => { + stream.write(": keepalive\n\n").catch(() => {}); + }, 15_000); + + try { + let upstream = await callUpstream({ + ...args, + stream: true, + signal: abort.signal, + }); + + if (!upstream.ok && upstream.status === 400) { + const errText = await upstream.text().catch(() => ""); + if (isStreamingUnsupportedError(errText)) { + upstream = await callUpstream({ + ...args, + stream: false, + signal: abort.signal, + }); + } else { + throw new Error(`Upstream 400: ${errText || upstream.statusText}`); + } + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + throw new Error( + `Upstream ${upstream.status}: ${errText || upstream.statusText}`, + ); + } + + const contentType = upstream.headers.get("content-type") ?? ""; + if (contentType.includes("event-stream")) { + await forwardUpstreamSSE(upstream, stream); + } else { + await forwardUpstreamJSON(upstream, stream); + } + + await stream.writeSSE({ event: "done", data: "" }); + } catch (err) { + if (abort.signal.aborted) return; + const message = err instanceof Error ? err.message : String(err); + console.error("[generate] error:", err); + await stream.writeSSE({ + event: "error", + data: JSON.stringify({ message }), + }); + } finally { + clearInterval(keepalive); + } + }); +}); + const server = Bun.serve({ hostname: "0.0.0.0", + idleTimeout: 255, routes: { "/": index, - "/api/generate": { - POST: async (req) => { - const body = (await req.json()) as GenerateRequest; - const { baseURL, apiKey, model, prompt, size, referenceImages } = body; - if (!baseURL || !apiKey || !model || !prompt) { - return Response.json( - { error: "baseURL, apiKey, model, prompt are required" }, - { status: 400 }, - ); - } - const refs = Array.isArray(referenceImages) ? referenceImages : []; - const args = { - baseURL, - apiKey, - model, - prompt, - size: size ?? ("1024x1024" as Size), - referenceImages: refs, - }; - - const stream = new ReadableStream({ - async start(controller) { - const keepalive = setInterval(() => { - try { - sseComment(controller, "keepalive"); - } catch {} - }, 20_000); - - try { - let upstream = await callUpstream({ ...args, stream: true }); - - if (!upstream.ok && upstream.status === 400) { - const errText = await upstream.text().catch(() => ""); - if (isStreamingUnsupportedError(errText)) { - upstream = await callUpstream({ ...args, stream: false }); - } else { - throw new Error(`Upstream 400: ${errText || upstream.statusText}`); - } - } - - if (!upstream.ok) { - const errText = await upstream.text().catch(() => ""); - throw new Error( - `Upstream ${upstream.status}: ${errText || upstream.statusText}`, - ); - } - - const contentType = upstream.headers.get("content-type") ?? ""; - if (contentType.includes("event-stream")) { - await forwardUpstreamSSE(upstream, controller); - } else { - await forwardUpstreamJSON(upstream, controller); - } - - sseEvent(controller, "done", {}); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - console.error("[generate] error:", err); - try { - sseEvent(controller, "error", { message }); - } catch {} - } finally { - clearInterval(keepalive); - try { - controller.close(); - } catch {} - } - }, - }); - - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - "X-Accel-Buffering": "no", - Connection: "keep-alive", - }, - }); - }, - }, }, + fetch: app.fetch, development: { hmr: true, console: true, + chromeDevToolsAutomaticWorkspaceFolders: false, }, }); diff --git a/package.json b/package.json index e74896a..8633e78 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,10 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", "typescript": "^6.0.3" }, "dependencies": { - "react": "^19.2.6", - "react-dom": "^19.2.6" + "@microsoft/fetch-event-source": "^2.0.1", + "hono": "^4.12.19" } } diff --git a/tsconfig.json b/tsconfig.json index b2e7497..3fdec7f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { // Environment setup & latest features - "lib": ["ESNext"], + "lib": ["ESNext", "DOM", "DOM.Iterable"], "target": "ESNext", "module": "Preserve", "moduleDetection": "force",