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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user