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
|
# 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,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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -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 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>
|
||||||
|
|||||||
@@ -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,68 +97,76 @@ 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 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(
|
async function forwardUpstreamSSE(
|
||||||
upstream: Response,
|
upstream: Response,
|
||||||
controller: SSEController,
|
stream: SSEStreamingApi,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!upstream.body) throw new Error("Upstream returned no body");
|
if (!upstream.body) throw new Error("Upstream returned no body");
|
||||||
const reader = upstream.body.getReader();
|
const reader = upstream.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
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) {
|
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({
|
||||||
image: `data:image/png;base64,${item.b64_json}`,
|
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);
|
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({
|
const server = Bun.serve({
|
||||||
hostname: "0.0.0.0",
|
hostname: "0.0.0.0",
|
||||||
|
idleTimeout: 255,
|
||||||
routes: {
|
routes: {
|
||||||
"/": index,
|
"/": 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: {
|
development: {
|
||||||
hmr: true,
|
hmr: true,
|
||||||
console: true,
|
console: true,
|
||||||
|
chromeDevToolsAutomaticWorkspaceFolders: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user