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
+1 -157
View File
@@ -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>