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:
2026-05-18 23:13:06 +08:00
parent 5af05b2141
commit 600f574b5c
7 changed files with 381 additions and 361 deletions
+85 -50
View File
@@ -1,8 +1,9 @@
# AGENTS.md # AGENTS.md
Bun + TypeScript single-file server that proxies an OpenAI-compatible image Bun + Hono server that proxies an OpenAI-compatible image endpoint and serves
endpoint and serves a small vanilla HTML/JS playground. The whole pipeline is a small vanilla TS playground. SSE end-to-end: streams gpt-image partial
SSE end-to-end so it survives Cloudflare's 120s proxy-read timeout. previews through, with keepalive comments that survive Cloudflare's 120s
proxy-read timeout.
## Runtime ## 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). `noEmit: true`, so plain `bunx tsc` works too).
- Tests / lint / formatter: none configured. If adding tests, use `bun test`. - 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 The server binds `0.0.0.0` (see `index.ts`), so it is reachable from other
hosts on the network when running locally — be mindful when entering API keys. 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 ## Architecture
- `index.ts` — the entire backend. One `Bun.serve` instance with: Three files do everything:
- `/` serves `index.html` via Bun's HTML import (`import index from "./index.html"`).
- `POST /api/generate` accepts - `index.ts` — Hono app mounted under `Bun.serve` (`fetch: app.fetch`).
`{ baseURL, apiKey, model, prompt, size, referenceImages? }` and **always - `routes: { "/": index }` serves `index.html` via Bun's HTML bundler;
responds with `text/event-stream`**. Emitted events: everything else falls through to Hono.
- `event: partial``{ image: dataUrl, index }` for each `partial_image` - `idleTimeout: 255` (max) — `Bun.serve`'s 10s default kills SSE
- `event: final``{ image: dataUrl }` for the completed image connections before the first keepalive can fire. The symptom is an
- `event: done` — empty payload, sent right before close empty EventStream in DevTools and `request timed out after 10 seconds`
- `event: error``{ message }` for any failure in the log.
- SSE comments `: keepalive` every 20s while waiting for upstream, so - `POST /api/generate` uses `streamSSE` from `hono/streaming`. Emits:
Cloudflare's 120s proxy-read timeout never fires. - `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: - Upstream dispatch:
- `referenceImages` present → `POST {baseURL}/images/edits` as - `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. - Otherwise → `POST {baseURL}/images/generations` as JSON.
- Both calls send `stream: true, partial_images: 2` first. If upstream - Always sends `stream: true, partial_images: 2` first. On a 400 that
returns a 400 mentioning `stream` or `partial_images`, mentions `stream` or `partial_images` (see
`isStreamingUnsupportedError` triggers a single retry with `isStreamingUnsupportedError`), retries once with `stream: false`
`stream: false` and the response is replayed as one `final` event via and replays the JSON response as a single `final` event via
`forwardUpstreamJSON`. Any other 4xx/5xx propagates as `error`. `forwardUpstreamJSON`. Any other 4xx/5xx becomes an `error` event.
- Targets the **gpt-image series only** (gpt-image-2 is the default). Do - `AbortController` wired to `stream.onAbort()` and threaded as `signal`
not reintroduce DALL·E-only fields like `response_format` — gpt-image 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`. always returns `b64_json`.
- `index.html` — self-contained UI: inline CSS, plain DOM JS, no build step. - `client.ts` — browser entry, loaded via `<script type="module"
Reads the SSE response via `fetch` + `ReadableStream` (not `EventSource`, src="./client.ts">` in `index.html`. Bun's bundler resolves the import,
because the API is `POST`). Partials overwrite a single `<img>` so the inlines `@microsoft/fetch-event-source`, and serves the bundle from
preview animates in place. Text fields (`baseURL`, `apiKey`, `model`, `/_bun/client/index-*.js`. **Inline `<script type="module">` blocks are
`size`, `prompt`) persist in `localStorage` under the `aip:<field>` prefix. not bundled by Bun** — any client JS that imports from `node_modules`
Reference images are kept in an in-memory `refImages` array as base64 data must live in a separate file.
URLs and are **not** persisted. There is no React code despite - Uses `fetchEventSource` instead of hand-rolled `fetch` +
`react` / `react-dom` / `@types/react*` being in `package.json` — treat `ReadableStream` SSE parsing. It supports POST + body, custom headers,
those deps as latent. Do not invent a React frontend unless asked. `signal`, and the `onopen` / `onmessage` / `onerror` callbacks.
- No router, no DB, no auth, no AI SDK. API key is supplied per-request by - On `done`, the client calls `abort.abort()` to terminate the
the browser and never stored server-side. `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 ## 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 - `verbatimModuleSyntax` + `moduleDetection: "force"` — use `import type` for
type-only imports; every file is a module. type-only imports; every file is a module.
- `allowImportingTsExtensions` is on; `.ts` extensions in imports are fine. - `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 ## When extending the API
- Add routes inside the `routes` object in `index.ts`; keep the - Routes live on the Hono `app`. For long-running upstream calls, mirror
`{ POST: async (req) => … }` shape used by `/api/generate`. the existing pattern:
- For any long-running upstream call, mirror the SSE-with-keepalive pattern: - `return streamSSE(c, async (stream) => { … })`
build a `ReadableStream<Uint8Array>`, start a 20s `: keepalive` comment - `stream.onAbort(() => abortController.abort())` at the top
timer in `start()`, do work inside `try`, always `clearInterval` and - `await stream.write(": connected\n\n")` to flush headers immediately
`controller.close()` in `finally`. Helpers `sseEvent` / `sseComment` - `setInterval(() => stream.write(": keepalive\n\n").catch(() => {}),
already exist. 15_000)` and `clearInterval` in `finally`
- Stay defensive about upstream capabilities: many OpenAI-compatible - `stream.writeSSE({ event, data: JSON.stringify(payload) })` for
providers reject unknown params. Send the optimistic request first, then application events
detect the specific 400 (see `isStreamingUnsupportedError`) and retry with - Catch errors and check `signal.aborted` before emitting `error` —
a degraded body rather than feature-detecting up front. otherwise every closed tab logs noise.
- Decode incoming data URLs with `decodeDataUrl` (returns `Buffer` + mime) - Send the optimistic request to upstream first; detect the specific 400
and pass them as `Blob` parts to `FormData` — same pattern as the edits via `isStreamingUnsupportedError` and retry with a degraded body rather
path. than feature-detecting up front.
- Decode incoming data URLs with `decodeDataUrl` and pass the typed
`Uint8Array<ArrayBuffer>` directly as a `Blob` part in `FormData`.
+5 -15
View File
@@ -5,35 +5,25 @@
"": { "": {
"name": "ai-playground", "name": "ai-playground",
"dependencies": { "dependencies": {
"react": "^19.2.6", "@microsoft/fetch-event-source": "^2.0.1",
"react-dom": "^19.2.6", "hono": "^4.12.19",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3", "typescript": "^6.0.3",
}, },
}, },
}, },
"packages": { "packages": {
"@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="], "@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+147
View File
@@ -0,0 +1,147 @@
import { fetchEventSource } from "@microsoft/fetch-event-source";
const byId = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const input = (id: string) => byId<HTMLInputElement>(id);
const select = (id: string) => byId<HTMLSelectElement>(id);
const textarea = (id: string) => byId<HTMLTextAreaElement>(id);
const persistedFields = ["baseURL", "apiKey", "model", "size", "prompt"] as const;
for (const f of persistedFields) {
const el = byId<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(f);
const saved = localStorage.getItem("aip:" + f);
if (saved) el.value = saved;
const save = () => localStorage.setItem("aip:" + f, el.value);
el.addEventListener("input", save);
el.addEventListener("change", save);
}
const refImages: string[] = [];
const refPreview = byId<HTMLDivElement>("refPreview");
function renderRefPreview() {
refPreview.innerHTML = "";
refImages.forEach((src, i) => {
const thumb = document.createElement("div");
thumb.className = "thumb";
const img = document.createElement("img");
img.src = src;
img.alt = "reference " + (i + 1);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "remove";
btn.textContent = "\u00d7";
btn.title = "Remove";
btn.onclick = () => {
refImages.splice(i, 1);
renderRefPreview();
};
thumb.appendChild(img);
thumb.appendChild(btn);
refPreview.appendChild(thumb);
});
}
input("refImages").addEventListener("change", async (e) => {
const el = e.target as HTMLInputElement;
const files = Array.from(el.files ?? []);
for (const file of files) {
if (!file.type.startsWith("image/")) continue;
const dataUrl = await new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result as string);
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
refImages.push(dataUrl);
}
renderRefPreview();
el.value = "";
});
class FatalError extends Error {}
byId<HTMLButtonElement>("generate").addEventListener("click", async () => {
const btn = byId<HTMLButtonElement>("generate");
const status = byId<HTMLSpanElement>("status");
const result = byId<HTMLDivElement>("result");
const body = {
baseURL: input("baseURL").value.trim(),
apiKey: input("apiKey").value.trim(),
model: input("model").value.trim(),
size: select("size").value,
prompt: textarea("prompt").value.trim(),
referenceImages: refImages,
};
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) {
status.textContent = "Please fill in Base URL, API Key, Model and Prompt.";
status.className = "status error";
return;
}
btn.disabled = true;
status.className = "status";
status.textContent = "Generating...";
result.innerHTML = "";
const preview = document.createElement("img");
preview.alt = body.prompt;
let appended = false;
const showPreview = (src: string) => {
preview.src = src;
if (!appended) {
result.appendChild(preview);
appended = true;
}
};
const abort = new AbortController();
try {
await fetchEventSource("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: abort.signal,
openWhenHidden: true,
async onopen(res) {
const ct = res.headers.get("content-type") ?? "";
if (res.ok && ct.includes("event-stream")) return;
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new FatalError(data.error ?? "HTTP " + res.status);
},
onmessage(ev) {
if (!ev.data) return;
const payload = JSON.parse(ev.data) as {
image?: string;
index?: number;
message?: string;
};
if (ev.event === "partial" && payload.image) {
showPreview(payload.image);
status.textContent =
"Receiving preview " + ((payload.index ?? 0) + 1) + "...";
} else if (ev.event === "final" && payload.image) {
showPreview(payload.image);
status.textContent = "Done.";
} else if (ev.event === "error") {
throw new FatalError(payload.message ?? "Unknown error");
} else if (ev.event === "done") {
abort.abort();
}
},
onerror(err) {
throw err;
},
});
} catch (err) {
if (!abort.signal.aborted) {
const message = err instanceof Error ? err.message : String(err);
status.textContent = "Error: " + message;
status.className = "status error";
}
} finally {
btn.disabled = false;
}
});
+1 -157
View File
@@ -252,162 +252,6 @@
<div id="result" class="result"></div> <div id="result" class="result"></div>
</div> </div>
<script> <script type="module" src="./client.ts"></script>
const $ = (id) => document.getElementById(id);
const fields = ["baseURL", "apiKey", "model", "size", "prompt"];
for (const f of fields) {
const saved = localStorage.getItem("aip:" + f);
if (saved) $(f).value = saved;
$(f).addEventListener("input", () => {
localStorage.setItem("aip:" + f, $(f).value);
});
$(f).addEventListener("change", () => {
localStorage.setItem("aip:" + f, $(f).value);
});
}
const refImages = [];
function renderRefPreview() {
const c = $("refPreview");
c.innerHTML = "";
refImages.forEach((src, i) => {
const thumb = document.createElement("div");
thumb.className = "thumb";
const img = document.createElement("img");
img.src = src;
img.alt = "reference " + (i + 1);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "remove";
btn.textContent = "\u00d7";
btn.title = "Remove";
btn.onclick = () => {
refImages.splice(i, 1);
renderRefPreview();
};
thumb.appendChild(img);
thumb.appendChild(btn);
c.appendChild(thumb);
});
}
$("refImages").addEventListener("change", async (e) => {
const input = e.target;
const files = Array.from(input.files || []);
for (const file of files) {
if (!file.type.startsWith("image/")) continue;
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = () => reject(r.error);
r.readAsDataURL(file);
});
refImages.push(dataUrl);
}
renderRefPreview();
input.value = "";
});
$("generate").addEventListener("click", async () => {
const btn = $("generate");
const status = $("status");
const result = $("result");
const body = {
baseURL: $("baseURL").value.trim(),
apiKey: $("apiKey").value.trim(),
model: $("model").value.trim(),
size: $("size").value,
prompt: $("prompt").value.trim(),
referenceImages: refImages,
};
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) {
status.textContent = "Please fill in Base URL, API Key, Model and Prompt.";
status.className = "status error";
return;
}
btn.disabled = true;
status.className = "status";
status.textContent = "Generating...";
result.innerHTML = "";
let preview = null;
const ensurePreview = () => {
if (!preview) {
preview = document.createElement("img");
preview.alt = body.prompt;
result.appendChild(preview);
}
return preview;
};
try {
const res = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("event-stream")) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || ("HTTP " + res.status));
}
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const handleBlock = (raw) => {
let event = "message";
const dataLines = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return;
let payload;
try {
payload = JSON.parse(dataLines.join("\n"));
} catch {
return;
}
if (event === "partial") {
ensurePreview().src = payload.image;
status.textContent = "Receiving preview " + ((payload.index ?? 0) + 1) + "...";
} else if (event === "final") {
ensurePreview().src = payload.image;
status.textContent = "Done.";
} else if (event === "error") {
throw new Error(payload.message || "Unknown error");
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
handleBlock(buffer.slice(0, idx));
buffer = buffer.slice(idx + 2);
}
}
if (buffer.trim()) handleBlock(buffer);
} catch (err) {
status.textContent = "Error: " + (err.message || String(err));
status.className = "status error";
if (preview && !preview.src) preview.remove();
} finally {
btn.disabled = false;
}
});
</script>
</body> </body>
</html> </html>
+80 -74
View File
@@ -1,3 +1,6 @@
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import type { SSEStreamingApi } from "hono/streaming";
import index from "./index.html"; import index from "./index.html";
type Size = `${number}x${number}`; type Size = `${number}x${number}`;
@@ -11,26 +14,16 @@ type GenerateRequest = {
referenceImages?: string[]; referenceImages?: string[];
}; };
type SSEController = ReadableStreamDefaultController<Uint8Array>; function decodeDataUrl(
dataUrl: string,
const encoder = new TextEncoder(); ): { bytes: Uint8Array<ArrayBuffer>; mime: string } | null {
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 {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!match) return null; if (!match) return null;
const mime = match[1]!; const mime = match[1]!;
const b64 = match[2]!; const binary = atob(match[2]!);
return { bytes: Buffer.from(b64, "base64"), mime }; 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: { async function callUpstream(args: {
@@ -41,8 +34,9 @@ async function callUpstream(args: {
size: Size; size: Size;
referenceImages: string[]; referenceImages: string[];
stream: boolean; stream: boolean;
signal?: AbortSignal;
}): Promise<Response> { }): Promise<Response> {
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 isEdit = referenceImages.length > 0;
const url = `${baseURL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`; const url = `${baseURL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`;
@@ -71,6 +65,7 @@ async function callUpstream(args: {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${apiKey}` }, headers: { Authorization: `Bearer ${apiKey}` },
body: form, body: form,
signal,
}); });
} }
@@ -86,6 +81,7 @@ async function callUpstream(args: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal,
}); });
} }
@@ -101,19 +97,12 @@ function parseSSEBlock(raw: string): { event: string; data: string } | null {
return { event: eventName, data: dataLines.join("\n") }; return { event: eventName, data: dataLines.join("\n") };
} }
async function forwardUpstreamSSE( async function emitUpstreamBlock(
upstream: Response, raw: string,
controller: SSEController, stream: SSEStreamingApi,
): Promise<void> { ): Promise<void> {
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); const block = parseSSEBlock(raw);
if (!block) return; if (!block || block.data === "[DONE]") return;
if (block.data === "[DONE]") return;
let parsed: { let parsed: {
type?: string; type?: string;
b64_json?: string; b64_json?: string;
@@ -128,41 +117,56 @@ async function forwardUpstreamSSE(
const b64 = parsed.b64_json; const b64 = parsed.b64_json;
if (!b64) return; if (!b64) return;
if (type.endsWith(".partial_image")) { if (type.endsWith(".partial_image")) {
sseEvent(controller, "partial", { await stream.writeSSE({
event: "partial",
data: JSON.stringify({
image: `data:image/png;base64,${b64}`, image: `data:image/png;base64,${b64}`,
index: parsed.partial_image_index ?? 0, index: parsed.partial_image_index ?? 0,
}),
}); });
} else if (type.endsWith(".completed")) { } else if (type.endsWith(".completed")) {
sseEvent(controller, "final", { await stream.writeSSE({
image: `data:image/png;base64,${b64}`, event: "final",
data: JSON.stringify({ image: `data:image/png;base64,${b64}` }),
}); });
} }
}; }
async function forwardUpstreamSSE(
upstream: Response,
stream: SSEStreamingApi,
): Promise<void> {
if (!upstream.body) throw new Error("Upstream returned no body");
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) { while (true) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
buffer += decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
let idx: number; let idx: number;
while ((idx = buffer.indexOf("\n\n")) !== -1) { while ((idx = buffer.indexOf("\n\n")) !== -1) {
handle(buffer.slice(0, idx)); await emitUpstreamBlock(buffer.slice(0, idx), stream);
buffer = buffer.slice(idx + 2); buffer = buffer.slice(idx + 2);
} }
} }
if (buffer.trim().length > 0) handle(buffer); if (buffer.trim().length > 0) await emitUpstreamBlock(buffer, stream);
} }
async function forwardUpstreamJSON( async function forwardUpstreamJSON(
upstream: Response, upstream: Response,
controller: SSEController, stream: SSEStreamingApi,
): Promise<void> { ): Promise<void> {
const data = (await upstream.json()) as { const data = (await upstream.json()) as {
data?: Array<{ b64_json?: string }>; data?: Array<{ b64_json?: string }>;
}; };
for (const item of data.data ?? []) { for (const item of data.data ?? []) {
if (!item.b64_json) continue; if (!item.b64_json) continue;
sseEvent(controller, "final", { await stream.writeSSE({
event: "final",
data: JSON.stringify({
image: `data:image/png;base64,${item.b64_json}`, image: `data:image/png;base64,${item.b64_json}`,
}),
}); });
} }
} }
@@ -171,18 +175,15 @@ function isStreamingUnsupportedError(errText: string): boolean {
return /\b(stream|partial_images)\b/i.test(errText); return /\b(stream|partial_images)\b/i.test(errText);
} }
const server = Bun.serve({ const app = new Hono();
hostname: "0.0.0.0",
routes: { app.post("/api/generate", async (c) => {
"/": index, const body = (await c.req.json()) as GenerateRequest;
"/api/generate": {
POST: async (req) => {
const body = (await req.json()) as GenerateRequest;
const { baseURL, apiKey, model, prompt, size, referenceImages } = body; const { baseURL, apiKey, model, prompt, size, referenceImages } = body;
if (!baseURL || !apiKey || !model || !prompt) { if (!baseURL || !apiKey || !model || !prompt) {
return Response.json( return c.json(
{ error: "baseURL, apiKey, model, prompt are required" }, { error: "baseURL, apiKey, model, prompt are required" },
{ status: 400 }, 400,
); );
} }
const refs = Array.isArray(referenceImages) ? referenceImages : []; const refs = Array.isArray(referenceImages) ? referenceImages : [];
@@ -195,21 +196,30 @@ const server = Bun.serve({
referenceImages: refs, referenceImages: refs,
}; };
const stream = new ReadableStream<Uint8Array>({ return streamSSE(c, async (stream) => {
async start(controller) { const abort = new AbortController();
stream.onAbort(() => abort.abort());
await stream.write(": connected\n\n");
const keepalive = setInterval(() => { const keepalive = setInterval(() => {
try { stream.write(": keepalive\n\n").catch(() => {});
sseComment(controller, "keepalive"); }, 15_000);
} catch {}
}, 20_000);
try { try {
let upstream = await callUpstream({ ...args, stream: true }); let upstream = await callUpstream({
...args,
stream: true,
signal: abort.signal,
});
if (!upstream.ok && upstream.status === 400) { if (!upstream.ok && upstream.status === 400) {
const errText = await upstream.text().catch(() => ""); const errText = await upstream.text().catch(() => "");
if (isStreamingUnsupportedError(errText)) { if (isStreamingUnsupportedError(errText)) {
upstream = await callUpstream({ ...args, stream: false }); upstream = await callUpstream({
...args,
stream: false,
signal: abort.signal,
});
} else { } else {
throw new Error(`Upstream 400: ${errText || upstream.statusText}`); throw new Error(`Upstream 400: ${errText || upstream.statusText}`);
} }
@@ -224,41 +234,37 @@ const server = Bun.serve({
const contentType = upstream.headers.get("content-type") ?? ""; const contentType = upstream.headers.get("content-type") ?? "";
if (contentType.includes("event-stream")) { if (contentType.includes("event-stream")) {
await forwardUpstreamSSE(upstream, controller); await forwardUpstreamSSE(upstream, stream);
} else { } else {
await forwardUpstreamJSON(upstream, controller); await forwardUpstreamJSON(upstream, stream);
} }
sseEvent(controller, "done", {}); await stream.writeSSE({ event: "done", data: "" });
} catch (err) { } catch (err) {
if (abort.signal.aborted) return;
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
console.error("[generate] error:", err); console.error("[generate] error:", err);
try { await stream.writeSSE({
sseEvent(controller, "error", { message }); event: "error",
} catch {} data: JSON.stringify({ message }),
});
} finally { } finally {
clearInterval(keepalive); clearInterval(keepalive);
try {
controller.close();
} catch {}
} }
}, });
}); });
return new Response(stream, { const server = Bun.serve({
headers: { hostname: "0.0.0.0",
"Content-Type": "text/event-stream", idleTimeout: 255,
"Cache-Control": "no-cache, no-transform", routes: {
"X-Accel-Buffering": "no", "/": index,
Connection: "keep-alive",
},
});
},
},
}, },
fetch: app.fetch,
development: { development: {
hmr: true, hmr: true,
console: true, console: true,
chromeDevToolsAutomaticWorkspaceFolders: false,
}, },
}); });
+2 -4
View File
@@ -9,12 +9,10 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "^1.3.14", "@types/bun": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3" "typescript": "^6.0.3"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.6", "@microsoft/fetch-event-source": "^2.0.1",
"react-dom": "^19.2.6" "hono": "^4.12.19"
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",