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`.
|
||||
@@ -5,35 +5,25 @@
|
||||
"": {
|
||||
"name": "ai-playground",
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"hono": "^4.12.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"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/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=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"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=="],
|
||||
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
|
||||
@@ -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
@@ -252,162 +252,6 @@
|
||||
<div id="result" class="result"></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script type="module" src="./client.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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<Uint8Array>;
|
||||
|
||||
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<ArrayBuffer>; 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<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 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<void> {
|
||||
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<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);
|
||||
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<void> {
|
||||
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<Uint8Array>({
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user