Files
ComfyAssets-ComfyUI_PromptM…/web/js/sse-stream.js
T
Vito Sansevero 7b6b7ecf3d fix: stale responses, double submits and leaked listeners
web/js/api-client.js wraps fetch with one failure path (ApiError for non-2xx,
success:false and non-JSON bodies) and latestOnly(), which aborts the previous
in-flight request and resolves superseded calls to STALE. The admin search and
recent-page loads share one guard, the gallery page load has its own, so fast
typing or clicking can no longer paint an older result over a newer one.

In-place prompt editing used a blur listener with {once:true} plus a keydown
listener added on every edit, which sent the PUT twice and stacked handlers.
The list now has one keydown/focusout pair bound at construction and
finishEdit() clears the edit state before touching the element, so a save is
sent exactly once. Mutations re-fetch the page currently shown (refreshList)
instead of calling search(), and delete, bulk delete, edit, restore and scan no
longer reload the whole page.

Pagination state is committed only after a successful fetch via the pure
ListState.nextPageState(current, response) in both admin and gallery, so a
failed or aborted page request leaves the current page intact. The gallery
lightbox removed its keydown handlers on a non-existent 'remove' event; a
MutationObserver now drops them when the modal leaves the document.

web/js/sse-stream.js reads server-sent events over fetch so POST-only progress
endpoints can stream.
2026-10-08 18:01:34 -07:00

118 lines
4.5 KiB
JavaScript

/**
* 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().
*
* Loadable in the browser (window.SseStream) and in Node for tests.
*/
(function (root) {
"use strict";
const CONNECTING = 0;
const OPEN = 1;
const CLOSED = 2;
/**
* Parse complete events out of a buffer of SSE text.
* @param {string} buffer
* @returns {{events: Array<{data: string, event?: string}>, rest: string}} parsed
* events and the trailing, still incomplete block
*/
function parseSseChunk(buffer) {
const normalized = buffer.replace(/\r\n/g, "\n");
const blocks = normalized.split("\n\n");
const rest = blocks.pop();
const events = [];
for (const block of blocks) {
const event = parseBlock(block);
if (event) events.push(event);
}
return { events, rest };
}
function parseBlock(block) {
const data = [];
let name;
for (const line of block.split("\n")) {
if (line === "" || line.startsWith(":")) continue;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let value = colon === -1 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") data.push(value);
else if (field === "event") name = value;
}
if (data.length === 0) return null;
const event = { data: data.join("\n") };
if (name !== undefined) event.event = name;
return event;
}
/**
* @param {string} url
* @param {{method?: string, body?: string|FormData, fetchImpl?: typeof fetch}} [options]
* @returns {{onmessage: Function|null, onerror: Function|null, onclose: Function|null,
* readyState: number, close: Function, CONNECTING: number, OPEN: number, CLOSED: number}}
*/
function connect(url, { method = "GET", body, fetchImpl } = {}) {
const doFetch = fetchImpl || ((...args) => globalThis.fetch(...args));
const controller = new AbortController();
const source = {
onmessage: null,
onerror: null,
onclose: null,
readyState: CONNECTING,
CONNECTING,
OPEN,
CLOSED,
close() {
if (source.readyState === CLOSED) return;
source.readyState = CLOSED;
controller.abort();
},
};
(async () => {
try {
const init = { method, headers: { Accept: "text/event-stream" }, signal: controller.signal };
if (body !== undefined) init.body = body;
const response = await doFetch(url, init);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (source.readyState === CLOSED) return;
source.readyState = OPEN;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (source.readyState === CLOSED) return;
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const { events, rest } = parseSseChunk(buffer);
buffer = rest;
for (const event of events) {
if (source.readyState === CLOSED) return;
if (source.onmessage) source.onmessage(event);
}
if (done) break;
}
source.readyState = CLOSED;
if (source.onclose) source.onclose();
} catch (error) {
if (source.readyState === CLOSED) return; // closed by the caller
source.readyState = CLOSED;
if (source.onerror) source.onerror(error);
}
})();
return source;
}
const api = { parseSseChunk, connect, CONNECTING, OPEN, CLOSED };
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.SseStream = api;
})(typeof window !== "undefined" ? window : globalThis);