fix: read thumbnail generation progress over POST with SseStream

images/generate-thumbnails/progress is becoming POST-only, which the
browser EventSource cannot request. Both thumbnail progress consumers in
the gallery now use SseStream.connect(url, {method: 'POST'}) exactly as
the autotag streams do. SseStream gains addEventListener/removeEventListener
for named event: frames (tested) so the status, start, progress,
file_error, complete and error listeners stay as they were, and an onclose
guard rejects when the stream ends without a terminal frame.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 4ab0214f09
commit b792da53ba
3 changed files with 98 additions and 9 deletions
+50
View File
@@ -160,3 +160,53 @@ test("connect defaults to GET and the global fetch", async () => {
globalThis.fetch = original;
}
});
test("addEventListener receives named events and unnamed ones go to 'message'", async () => {
const named = [];
const plain = [];
let closed = false;
const fetchImpl = async () =>
new Response(
streamOf(['event: start\ndata: {"total":3}\n\n', 'data: {"n":1}\n\n', 'event: complete\ndata: {"ok":true}\n\n']),
{ status: 200 },
);
const source = connect("/prompt_manager/images/generate-thumbnails/progress", { method: "POST", fetchImpl });
source.addEventListener("start", (e) => named.push(["start", JSON.parse(e.data).total]));
source.addEventListener("complete", (e) => named.push(["complete", JSON.parse(e.data).ok]));
source.addEventListener("message", (e) => plain.push(JSON.parse(e.data).n));
source.onclose = () => { closed = true; };
await until(() => closed);
assert.deepEqual(named, [["start", 3], ["complete", true]]);
assert.deepEqual(plain, [1]);
});
test("removeEventListener stops delivery and onmessage still sees every event", async () => {
const all = [];
const progress = [];
let closed = false;
const fetchImpl = async () =>
new Response(streamOf(["event: progress\ndata: 1\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 });
const source = connect("/x", { fetchImpl });
const listener = (e) => {
progress.push(e.data);
source.removeEventListener("progress", listener);
};
source.addEventListener("progress", listener);
source.onmessage = (e) => all.push(`${e.event}:${e.data}`);
source.onclose = () => { closed = true; };
await until(() => closed);
assert.deepEqual(progress, ["1"]);
assert.deepEqual(all, ["progress:1", "progress:2"]);
});
test("a listener that closes the source stops later events", async () => {
const seen = [];
const fetchImpl = async () =>
new Response(streamOf(["event: error\ndata: boom\n\n", "event: progress\ndata: 2\n\n"]), { status: 200 });
const source = connect("/x", { fetchImpl });
source.addEventListener("error", (e) => { seen.push(e.data); source.close(); });
source.addEventListener("progress", (e) => seen.push(e.data));
await until(() => source.readyState === source.CLOSED);
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(seen, ["boom"]);
});
+21 -4
View File
@@ -1840,7 +1840,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Set up Server-Sent Events
const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${options.quality}`);
// The progress route is POST (it generates files), so read it with SseStream like the autotag streams
const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(options.quality)}`, { method: 'POST' });
// Log connection
@@ -1993,7 +1994,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
console.error('EventSource readyState:', eventSource.readyState);
// Check if connection is closing normally
if (eventSource.readyState === EventSource.CLOSED) {
if (eventSource.readyState === eventSource.CLOSED) {
} else {
console.error('EventSource connection failed');
}
@@ -2008,6 +2009,14 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
// Stream ended without a complete/error frame (close() does not trigger this)
eventSource.onclose = () => {
if (cancelled || resultData) return;
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
reject(new Error('Connection to server closed before thumbnail generation finished'));
};
});
}
@@ -2580,7 +2589,8 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
// Set up Server-Sent Events (same as the settings modal)
const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${quality}`);
// The progress route is POST (it generates files), so read it with SseStream like the autotag streams
const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(quality)}`, { method: 'POST' });
let resultData = null;
@@ -2750,7 +2760,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
console.error('EventSource connection error in modal:', error);
// Check if connection is closing normally (after complete event)
if (eventSource.readyState === EventSource.CLOSED && resultData) {
if (eventSource.readyState === eventSource.CLOSED && resultData) {
return;
}
@@ -2765,6 +2775,13 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
// Stream ended without a complete/error frame (close() does not trigger this)
eventSource.onclose = () => {
if (isCancelled() || resultData) return;
addStatusMessage('Connection to server closed before thumbnail generation finished', 'error');
reject(new Error('Connection to server closed before thumbnail generation finished'));
};
});
}
+27 -5
View File
@@ -1,10 +1,12 @@
/**
* Server-sent events over fetch.
*
* EventSource can only GET. The auto-tag download/start endpoints are POST
* (they have side effects), so their progress streams are read with fetch and
* a small parser. `connect()` returns an object with the EventSource surface
* the pages already use: onmessage, onerror, onclose, readyState, close().
* EventSource can only GET. The auto-tag download/start and thumbnail
* generation endpoints are POST (they have side effects), so their progress
* streams are read with fetch and a small parser. `connect()` returns an
* object with the EventSource surface the pages already use: onmessage,
* onerror, onclose, readyState, close(), and addEventListener(name, fn) for
* named `event:` frames.
*
* Loadable in the browser (window.SseStream) and in Node for tests.
*/
@@ -60,6 +62,7 @@
function connect(url, { method = "GET", body, fetchImpl } = {}) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
const controller = new AbortController();
const listeners = new Map();
const source = {
onmessage: null,
onerror: null,
@@ -73,8 +76,27 @@
source.readyState = CLOSED;
controller.abort();
},
/** Named `event:` frames reach listeners for that name; unnamed ones reach "message". */
addEventListener(name, listener) {
if (!listeners.has(name)) listeners.set(name, new Set());
listeners.get(name).add(listener);
},
removeEventListener(name, listener) {
const set = listeners.get(name);
if (set) set.delete(listener);
},
};
function deliver(event) {
if (source.onmessage) source.onmessage(event);
const set = listeners.get(event.event || "message");
if (!set) return;
for (const listener of Array.from(set)) {
if (source.readyState === CLOSED) return;
listener(event);
}
}
(async () => {
try {
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
@@ -95,7 +117,7 @@
buffer = rest;
for (const event of events) {
if (source.readyState === CLOSED) return;
if (source.onmessage) source.onmessage(event);
deliver(event);
}
if (done) break;
}