UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

675 lines • 35.1 kB
import { getComfyUIAuthHeaders } from "../config.js"; import { describeFetchFailure, isBareFetchFailure } from "../utils/errors.js"; import { sameOrigin } from "../utils/origin.js"; import { readPublishedPanelOrigins } from "../services/panel-origin-channel.js"; import { formatComfyUIUrl } from "../transport/comfyui-url.js"; /** The request target, for the diagnostic. Never throws on an odd input. */ export function targetOf(input) { try { if (typeof input === "string") return input; if (input instanceof URL) return input.href; return input.url ?? String(input); } catch { return String(input); } } /** The request's HTTP method, uppercased. `init.method` wins over a Request's own, * matching what fetch itself does when both are supplied. */ function methodOf(input, init) { return String(init.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); } /** * What the CONNECTED panels front, for the drift comparison below (#952). * * Injected by the orchestrator at startup rather than imported: this module is * the bottom of the stack and must not depend on the bridge. Unset (the plain * MCP server, tests, any process with no bridge) simply means the comparison is * skipped — never that there is no drift. * * Origins here are diagnostic data. They must be the SERVER-OBSERVED handshake * Origin (UiBridge's `tabServerOrigin`), not the client-supplied * `hello.comfyui_url`, because the latter is page-controlled. This source is * deliberately separate from the direct /view fallback authorization below: * a tokenless local WebSocket client can forge HTTP headers, so neither this * source nor the published channel is permission to contact an origin. */ let connectedPanelOrigins = null; /** * Direct /view fallback authorization. This is intentionally a separate, * fail-closed seam from the diagnostic origin source and the published channel. * Production installs no source: a tokenless loopback WebSocket has no * browser-only provenance that could authorize MCP to make a new HTTP request. * Keep this injectable only for an explicitly authenticated integration in the * future and for focused tests of the already-hardened fallback mechanics. */ let connectedPanelFallbackOrigins = null; /** * #1415 — WHERE THESE CALLS ACTUALLY RUN. * * Injection covers the orchestrator process, and almost nothing that fails this * way lives there: every headless comfyui tool runs in the SPAWNED stdio child, * which never loads orchestrator/index.js and has no bridge to inject from. So * the comparison #952 built was silently skipped in exactly the sessions it was * written for — the reporter's panel worked, `list_packs (action:"list_templates")` * failed against a dead COMFYUI_URL, and the error made no comparison at all. * * The child reads the set the orchestrator publishes into the progress dir it * already shares with it (services/panel-origin-channel.ts). Still a leaf * dependency — a file read, not the bridge. An INJECTED source always wins: where * the bridge is in the same process it is both fresher and authoritative. */ function panelOrigins() { return connectedPanelOrigins ? connectedPanelOrigins() : readPublishedPanelOrigins(); } /** Install the panel-origin source. Pass null to clear (tests, shutdown). */ export function setConnectedPanelOrigins(fn) { connectedPanelOrigins = fn; } /** Install the direct-fallback source. Null is the production-safe default. */ export function setConnectedPanelFallbackOrigins(fn) { connectedPanelFallbackOrigins = fn; } /** Read the current panel origins without allowing a stale channel read to abort a request. */ export function connectedPanelOriginsNow() { try { return panelOrigins(); } catch { return []; } } /** Read direct-fallback candidates. Never falls back to diagnostic/channel data. */ export function connectedPanelFallbackOriginsNow() { try { return connectedPanelFallbackOrigins ? connectedPanelFallbackOrigins() : []; } catch { return []; } } /** True when the request failed at the network layer, rather than returning an HTTP status. */ export function isComfyTransportFailure(err) { const seen = new Set(); let current = err; while (current instanceof Error && !seen.has(current)) { if (isTimeoutAbort(current)) return false; if (isBareFetchFailure(current)) return true; seen.add(current); current = current.cause; } return false; } /** Origin (scheme://host:port) of a request target, or undefined if unparsable. * Exported because a message that names the target should name the ORIGIN: * `URL.origin` drops userinfo, path and query, so a COMFYUI_URL carrying a * credential cannot leak through a diagnostic string. */ export function originOf(target) { try { const origin = new URL(target).origin; // An OPAQUE origin (file:, data:, blob:) serialises to the literal string // "null", which is truthy and would sail through every caller as if it were // an address — printing "a connected panel is on null" (gate, round 4). It is // not an address, so it is not an origin as far as this module is concerned. return origin === "null" ? undefined : origin; } catch { return undefined; } } /** * Name the drift instead of asking the reader to go and check for it (#952). * * The shipped message told the user a connected panel "does not imply this * address is reachable" and pointed them at install_comfyui(action:"environment") * to compare the two by hand. We can usually just do the comparison — the bridge * knows what each connected tab fronts — and saying which of the three cases * holds is worth far more than the instruction to go and find out: * * - DIFFERENT → that is very likely the whole answer. * - THE SAME → drift is RULED OUT. Worth stating plainly, because it stops * the reader chasing the explanation the old text volunteered. * The panel reaches it from the browser; this process does not * (a firewall, a container boundary, a bound interface). * - UNKNOWN → no panel connected, no origin source installed AND nothing * published on the channel, or the handshake carried no usable * Origin. Say nothing about drift; an absent comparison is not * a negative result. */ export function describeTargetDrift(target) { return classifyTargetDrift(target).text; } function classifyTargetDrift(target) { const origins = (() => { try { return panelOrigins(); } catch { return []; // a broken source must never replace the real network error } })(); if (origins.length === 0) return { verdict: "unknown", text: "", origins: [], alias: "", others: [] }; const want = originOf(target); if (!want) return { verdict: "unknown", text: "", origins: [], alias: "", others: [] }; // WHY THESE ARE NOT PUT THROUGH #1191's SCRUB, asked three times by the gate. // // `scrubSecretShapedText`'s third pass redacts any unbroken run of >=24 // credential-alphabet characters. A cloudflared quick-tunnel hostname IS one — // `http://abcdef0123456789abcdef0123456789.trycloudflare.com` has a 32-char // label — and that is the most common remote-ComfyUI setup this project sees. // Scrubbing would therefore redact exactly the origins a reader most needs // named, on the one message whose entire job is to say WHICH server. A // hostname is public routing information, not a credential; the credential // risk in a URL lives in userinfo, path and query, and `originOf` drops all // three. So the protection here is NORMALISATION, not redaction: // // Only a well-formed `scheme://host[:port]` may ever reach a message (gate, // round 3). These values are server-observed handshake Origins, not client // prose — but "not attacker prose today" is a property of a call site three // modules away, and this string lands in an agent's context. Normalising // through `originOf` cannot mangle a legitimate origin (it is idempotent on // one) and cannot echo anything that is not one. An unparsable entry is // dropped rather than scrubbed: it could not have matched the target anyway, // and a scrub-by-shape here would redact real long hostnames. const distinct = [...new Set(origins)].flatMap((o) => { const normalised = originOf(o); return normalised ? [normalised] : []; }); if (distinct.length === 0) return { verdict: "unknown", text: "", origins: [], alias: "", others: [] }; // #1175 — `includes` compares spellings, not servers. A panel on // http://127.0.0.1:8188 against a target of http://localhost:8188 is ONE // ComfyUI, and reporting it as "a DIFFERENT address" sent a reporter to point // COMFYUI_URL at the origin it was already pointed at. const match = distinct.find((o) => sameOrigin(o, want)); if (match !== undefined) { // Name BOTH spellings when they differ textually. "This same origin" reads // as a contradiction next to two visibly different strings, and the reader // has no way to know the comparison was alias-aware — which is what sent the // #1175 reporter looking for a target mismatch that did not exist. const alias = match === want ? "" : ` (spelled ${match} there and ${want} here — the same host, so this is not a mismatch)`; return { verdict: "same", origins: distinct, others: distinct.filter((o) => !sameOrigin(o, want)), alias, text: ` A connected panel is on this same origin${alias}, so this is NOT a wrong-address problem: ` + `the browser can reach ${want} and this process cannot (a firewall, a container ` + `boundary, or a server bound to one interface).`, }; } return { verdict: "different", origins: distinct, others: distinct, alias: "", text: ` A connected panel is on ${distinct.join(", ")} — a DIFFERENT address, which is why ` + `the panel works while this call does not. Point COMFYUI_URL at that origin if it is the ` + `server you meant.`, }; } /** * The same comparison, for a loader input naming a media file the server does * not have (#2673). * * A chat attachment is uploaded by the BROWSER, to whichever ComfyUI the panel * tab is on; `enqueue_workflow` posts to COMFYUI_URL. When those are two servers * the file lands on one and the render reads the other, and ComfyUI answers * "Invalid image file: <name>" — a rejection that says nothing about the split * and that re-submitting the same workflow can never clear. This is the drift * #952 already detects, reached through a 400 instead of a socket error, and it * is the case the detector was NOT wired into. * * The SAME-origin answer earns its place too: it rules the split out, so the * reader stops hunting for a second server and treats the file as genuinely * absent from the one they have. */ export function describeMissingInputMediaDrift(target) { const { verdict, origins, alias, others } = classifyTargetDrift(target); if (verdict === "unknown") return ""; if (verdict === "same") { // ONE matching tab does not rule the split out when OTHER tabs are open // elsewhere: the attachment could have come from any of them, and "RULED // OUT" would then suppress the very guidance the reader needs (gate finding). if (others.length > 0) { return (` A connected panel is on this same ComfyUI${alias} — but others are on ` + `${others.join(", ")}, so a file attached in one of THOSE tabs is on that server and ` + `not on this one. Rule that out before treating the file as simply missing.`); } return (` A connected panel is on this same ComfyUI${alias}, and no OTHER tab is open on a ` + `different one, so that split is RULED OUT for every panel visible right now — a tab that ` + `has since closed or navigated away would not appear here.`); } return (` A connected panel is on ${origins.join(", ")} — a DIFFERENT ComfyUI from this target, so ` + `that is very likely where the file is, and very likely the whole answer: run the graph on ` + `the panel instead (panel_run), or point COMFYUI_URL at that origin.`); } /** * The "now go and check" tail, chosen by what the comparison established (#1896). * * On a SAME verdict the standing advice asks a question that is already * answered: "confirm the server is up" invites precisely the conclusion the * comparison just ruled out, since a browser is connected to that origin right * now. The reporter of #1896 was told a panel was connected and then sent to * find out whether the server was running. * * The health check is therefore RE-AIMED rather than dropped — see the note at * the SAME branch for why suppressing it would have been an overclaim. * * The generic and direct-health paths keep `install_comfyui (action:"environment")` * explicit and do NOT describe it as failing: its `/system_stats` read sits * inside a try (see services/workspace-env.ts getEnvironment), so it still * reports the resolved target on an unreachable server. The environment probe * itself uses the separate context below, where naming that action again would * recurse through its stored `running_instance.error`. * * `budget` is the timeout path's extra option. It stays offered even on a SAME * verdict: a connected browser proves something answers that origin, never that * it would answer THIS process quickly, so "the server is simply slow" is still * live and ruling it out here would be an overclaim. */ function nextStepsFor(verdict, target, budget, context) { if (context === "install_comfyui_environment") { if (verdict === "same") { const want = originOf(target) ?? target; return (` The connected panel is already on ${want}, so the browser can reach this ` + `origin while this process cannot. Check the route or firewall between them, ` + `the server's bound interface, and any COMFYUI_AUTH_* credentials required ` + `from this process.`); } if (verdict === "different") { return (` Check COMFYUI_URL and point it at the intended server. If this target is ` + `the intended one, check its route or firewall and any COMFYUI_AUTH_* ` + `credentials required from this process.`); } return (` Check COMFYUI_URL from this process, the route or firewall, and any ` + `COMFYUI_AUTH_* credentials required by the target. The transport details ` + `above are the available readiness evidence.`); } if (context === "get_system_stats_health") { const environment = 'install_comfyui (action:"environment")'; const independentProbe = ` Run ${environment} as an independent readiness probe from this MCP process; ` + `inspect its running_instance.reachable and running_instance.error instead of ` + `repeating this failing request.`; if (verdict === "same") { const want = originOf(target) ?? target; return (independentProbe + ` The connected panel is already on ${want}, so if the probe is unreachable, ` + `fix the route or firewall between this process and that origin. If it is ` + `reachable, keep the transport and any COMFYUI_AUTH_* details above in view; ` + `the browser session may have different access.`); } if (verdict === "different") { return (independentProbe + ` The panel is on a different origin, so point COMFYUI_URL at the server you ` + `meant; if the probe still cannot reach that target, check its route/firewall ` + `and any required COMFYUI_AUTH_* credentials.`); } return (independentProbe + ` If the probe is unreachable, check COMFYUI_URL, the route/firewall, and any ` + `required COMFYUI_AUTH_* credentials; if it is reachable, retry the health ` + `check once while keeping the transport details above.`); } if (verdict !== "same") { return budget ? ` Confirm it is up with get_system_stats (action:"health"), and raise ` + `COMFYUI_MCP_HTTP_TIMEOUT_S if this server is simply slow.` : ` Check the target with install_comfyui (action:"environment"), and confirm the server ` + `is up with get_system_stats (action:"health").`; } const want = originOf(target) ?? target; // REPURPOSED, not suppressed. An earlier draft of this said the health check // "will NOT add anything here: it runs in this same process against this same // address, so it fails the same way". That is an overclaim: a SAME verdict // compares ORIGINS, and an origin is not an endpoint. A ComfyUI that is up and // answering the browser can still stall one route — `/object_info` mid-decode // is the documented case (comfyui/client.ts) — in which case /system_stats // answers perfectly well and is the single most informative call available. // So the health check keeps its place; what changes is the QUESTION it is sent // to settle. "Is the server up" is already answered, and answered yes. return (` The address itself is not what is wrong — a browser is connected to that origin. Use ` + `get_system_stats (action:"health") to tell the two remaining cases apart: if it fails from ` + `here too, the route from this process to ${want} is what needs fixing; if it succeeds, the ` + `problem is specific to this request rather than to the target` + (budget ? `, and COMFYUI_MCP_HTTP_TIMEOUT_S may simply be too low for it.` : `. install_comfyui (action:"environment") reports the resolved target either way.`)); } /** * Turn a network-layer throw into a diagnostic that says WHAT was attempted. * * `TypeError: fetch failed` was reaching tool results verbatim: #954 saw it from * the workflow-templates listing (now `list_packs` action:"list_templates") and * could not tell which host was tried, and #952 saw it from the readonly tools * while the panel bridge was working fine — reading it, reasonably, as "the tool * is broken" when the headless target was simply a different (unreachable) * address than the one the panel is bound to. * * The two ARE separate targets by design: the panel talks to whichever ComfyUI * the browser is on, while these calls go to the configured COMFYUI_URL. That is * not a bug, but it is invisible unless the failure names the address — and, * where the bridge can tell us, says whether the two actually differ. * * Header for the three pieces below: the never-delivered code set, the delivery * doubt it gates, and describeComfyFetchFailure itself. */ /** * Failure codes that prove the request was NEVER DELIVERED. * * Each one fires before any byte of the request could reach the application: the * connection was refused, the name never resolved, the TLS handshake failed, or * the URL was rejected locally. On these the server cannot have acted, so saying * so is accurate and a retry is safe. * * Everything else is deliberately NOT listed. ECONNRESET, EPIPE, "socket hang up" * and UND_ERR_SOCKET all fire on an ESTABLISHED connection, which is precisely the * case where ComfyUI may have received and acted on the request before the socket * died. The default for an unrecognised code is therefore "may have been * delivered": a spurious "verify first" costs one extra read, while a spurious * "it never arrived" costs a duplicate render. */ const NEVER_DELIVERED_CODES = new Set([ "ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ERR_INVALID_URL", "ERR_UNSUPPORTED_PROTOCOL", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "CERT_HAS_EXPIRED", "ERR_TLS_CERT_ALTNAME_INVALID", ]); /** * The transport-failure twin of describeComfyTimeout's OUTCOME UNKNOWN. * * A timeout on a POST already warned that /prompt may have queued the render * before the reply was lost. A CONNECTION ERROR on that same POST carries the * identical risk — an ECONNRESET after the server accepted and enqueued the * prompt is indistinguishable, at this layer, from one before it — yet the * message said only "confirm the server is up", which reads as "it never got * there". The caller retries and the render is queued twice, on the single most * expensive operation this client makes. * * Only suppressed when the code POSITIVELY proves non-delivery, or the method is * a read. See NEVER_DELIVERED_CODES for why the default runs the other way. */ export function deliveryDoubt(code, method) { if (method === "GET" || method === "HEAD") return ""; if (code !== undefined && NEVER_DELIVERED_CODES.has(code)) return ""; // Says only what is known. Claiming "the connection was established" would be an // overclaim for an unrecognised code — the same folding this function exists to // undo, merely pointed the other way. return (` This ${method} may already have been received and acted on: a transport failure ` + `does not establish that the request never arrived. Do NOT blindly re-issue it; ` + `check the server's state first (for a queued prompt, queue (action:"list") and ` + `get_history (action:"list")).`); } function describeComfyFetchFailure(err, target, method, context) { const { message, code } = describeFetchFailure(err); // ONE classification, used for both the sentence and the tail it governs // (#1896) — two calls would read the channel twice and could, across a // republish, disagree with each other inside a single message. const drift = classifyTargetDrift(target); const wrapped = new Error(`${message} — while requesting ${target} (${method}).` + deliveryDoubt(code, method) + ` ` + `That is the headless ComfyUI target (COMFYUI_URL); a CONNECTED sidebar panel does not imply this address is reachable, ` + `because the panel talks to whichever ComfyUI its browser tab is on.` + drift.text + nextStepsFor(drift.verdict, target, false, context), { cause: err }); if (code) wrapped.code = code; return wrapped; } /** * A last-resort ceiling on a ComfyUI HTTP call that brought no budget of its own. * * `comfyuiFetch` passed `init` straight to `fetch`, so a call with no signal had * NO time limit at all: a host that accepts the connection and then never * answers — a stalled reverse proxy, a black-holed route, a ComfyUI wedged * mid-request — left it pending forever. Thirteen of the twenty-five call sites * were in that state, including `/prompt`, `/queue`, `/object_info` and the * Manager API. This is the same defect #1026 hit in the skill generator; that * fix bounded three calls, and this bounds the rest. * * DELIBERATELY GENEROUS. Every caller that knows its own budget already passes a * signal and keeps it — `init.signal` always wins. This value only has to be * shorter than "forever" and longer than * any healthy request: `/object_info` on a large custom-node install is * legitimately slow, and a ceiling that fires on a working server would be a * worse bug than the one being fixed. * * SAFE TO APPLY BROADLY because of what does NOT come through here: uploads and * `/view` go through the client library's own `fetchApi`, and model downloads * have their own streaming path. Every comfyuiFetch site is a small JSON API * request. */ const DEFAULT_COMFY_HTTP_TIMEOUT_S = 120; export function comfyHttpTimeoutSeconds() { const raw = Number(process.env.COMFYUI_MCP_HTTP_TIMEOUT_S); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_COMFY_HTTP_TIMEOUT_S; } export function defaultComfyTimeoutSignal() { return AbortSignal.timeout(Math.round(comfyHttpTimeoutSeconds() * 1000)); } /** True for an abort raised by AbortSignal.timeout (not a caller's own abort). */ export function isTimeoutAbort(err) { return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError"); } function abortReasonOf(signal) { return (signal.reason ?? Object.assign(new Error("The operation was aborted"), { name: "AbortError" })); } /** * Reject as soon as `signal` aborts, even if `work` is still running. * * Bounds an enclosing promise whose inner work may not settle. On supported * Node (`engines` is >=22) undici errors the response body stream when the * `fetch` signal fires, so a pending `Response.text()` rejects at the deadline * — the quoted "headers-only" story is false here. `raceAbort` still earns its * place by bounding everything ELSE in `work`: a test double that ignores abort, * a consumer that does not pass the signal through, or the enclosing `call_tool` * cell. #1672's inner read DID reject ("The operation was aborted due to * timeout"); the outer cell is what stayed pending. `JSON.parse` is genuinely * unabortable, and racing it cannot preempt it because it blocks the same thread. * * The inner promise is attached so a late rejection cannot become an * unhandledRejection after we already settled. */ export function raceAbort(signal, work) { if (!signal) return work(); if (signal.aborted) return Promise.reject(abortReasonOf(signal)); const workPromise = work(); workPromise.catch(() => { /* settled via abort, or the then-path below already observed this */ }); return new Promise((resolve, reject) => { const onAbort = () => { cleanup(); reject(abortReasonOf(signal)); }; const cleanup = () => signal.removeEventListener("abort", onAbort); signal.addEventListener("abort", onAbort, { once: true }); workPromise.then((value) => { cleanup(); resolve(value); }, (err) => { cleanup(); reject(err); }); }); } /** GET/HEAD learned nothing; a mutation that timed out is OUTCOME UNKNOWN. */ function timeoutRetryClause(method) { if (method === "GET" || method === "HEAD") { return (`Nothing was learned about the server from this — a timeout is not a refusal and not a ` + `"not found". `); } return (`OUTCOME UNKNOWN: this request may already have been received and acted on, so do NOT ` + `blindly re-issue it — check the server's state first (for a queued prompt, queue ` + `(action:"list") and get_history (action:"list")). `); } /** * Named diagnosis for a stall AFTER headers: `comfyuiFetch` has already returned, * so its transport rewrite never runs and a raw `TimeoutError` would escape with * no URL and no method (#2773). Distinct from the transport-timeout formatter: * the connection DID happen; only the body failed to finish. */ export function describeComfyBodyTimeout(target, method) { const seconds = comfyHttpTimeoutSeconds(); const err = new Error(`No reply from ComfyUI within ${seconds}s — while reading the response body of ${target} (${method}). ` + `Headers had already arrived; the body did not finish. ` + timeoutRetryClause(method)); err.code = "COMFYUI_HTTP_BODY_TIMEOUT"; return err; } /** * Say what the ceiling did and did NOT establish. * * The critical distinction is the METHOD. A GET that times out learned nothing * about the server's state. A POST that times out is OUTCOME UNKNOWN — `/prompt` * may well have queued the render before the reply was lost — and reporting that * as a failure would invite a retry that queues it twice. Neither may be * described as "the server is down". */ function describeComfyTimeout(input, init) { const target = targetOf(input); const method = methodOf(input, init); const seconds = comfyHttpTimeoutSeconds(); // #1896 — the comparison the TRANSPORT path has made since #1553, on the path // that shares its cause. // // The defect is simply that THIS FORMATTER NEVER ASKED. Same child, same // readable channel, same connected panel as the refusal path, and it said // nothing at all — `list_packs (action:"list_templates")` sits on this ceiling // since #1795 removed its hard-coded 8s signal, so it is a first-class // consumer of a message that was silent about a working panel. // // How a call GETS here is deliberately not overstated (review, finding 1). A // target this process cannot route to may expire on the ceiling — a DROPped // SYN black-holes it — or fail fast with EHOSTUNREACH/ENETUNREACH, which is a // bare fetch failure and takes describeComfyFetchFailure instead. Both were // measured; only the first reaches this line. The fix does not depend on which // is more common, and a slow server that never answers lands here too. const drift = classifyTargetDrift(target); const err = new Error(`No reply from ComfyUI within ${seconds}s — while requesting ${target} (${method}). ` + timeoutRetryClause(method) + // #1896 — this asserted "The connection was accepted but no answer arrived // in time". The ceiling covers the WHOLE exchange, connecting included, so // a black-holed SYN times out here having established no connection at all // — and on the read path the sentence immediately before already said // nothing was learned. Say only what a timeout actually proves. `Whether a connection was ever established is NOT known from this: the budget covers ` + `connecting, the request and the reply alike, so a black-holed or filtered route expires ` + `here exactly as a slow server does. That points at the server, or at something between it ` + `and here, rather than at the request.` + drift.text + nextStepsFor(drift.verdict, target, true)); err.code = "COMFYUI_HTTP_TIMEOUT"; return err; } /** * `fetch` wrapper for ComfyUI HTTP requests that injects the configured generic * auth header(s) (COMFYUI_AUTH_* — for self-hosted ComfyUI behind a reverse * proxy / API gateway). A no-op when no auth is configured. Explicit headers on * the call always win, so it never clobbers a per-request `Content-Type`. * * Use this instead of the global `fetch` for every call to the user's ComfyUI * server. Non-ComfyUI requests (HuggingFace, Civitai, Comfy Cloud) keep using * plain `fetch` — Comfy Cloud has its own X-API-Key path in cloud-client.ts. */ export async function comfyuiFetch(input, init = {}, context) { const auth = getComfyUIAuthHeaders(); // A CEILING, not a policy. Callers that know their own budget pass a signal // and it always wins — this only covers the ones that passed none, which // otherwise wait forever. const signal = init.signal ?? defaultComfyTimeoutSignal(); // Keep a pristine body for a Request input. A failed network fetch may have // disturbed the original Request body before the refusal is surfaced, while // ECONNREFUSED still permits the one safe retry. let retrySource; if (input instanceof Request) { try { retrySource = input.clone(); } catch { // A caller may pass an already-disturbed Request. The literal attempt // still runs; without a replayable body there is no safe retry. } } let firstInit = { ...init, signal }; let retryInit = firstInit; if (typeof ReadableStream !== "undefined" && init.body instanceof ReadableStream) { const [firstBody, retryBody] = init.body.tee(); firstInit = { ...firstInit, body: firstBody }; retryInit = { ...firstInit, body: retryBody }; } const request = (target, requestInit) => { if (Object.keys(auth).length === 0) return fetch(target, requestInit); const headers = new Headers(requestInit.headers); for (const [name, value] of Object.entries(auth)) { if (!headers.has(name)) headers.set(name, value); } return fetch(target, { ...requestInit, headers }); }; try { return await request(input, firstInit); } catch (err) { // Preserve the configured literal as the primary target. Only a refused // exact IPv4 loopback connection is safe to retry: ECONNREFUSED proves no // request reached the server, while reset/timeout errors can follow a // delivered mutation. `localhost` lets Node select an IPv6-only listener // without changing the configured target or any identity comparison. const inputUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const retryUrl = typeof input === "string" ? formatComfyUIUrl(input) : input instanceof URL ? new URL(formatComfyUIUrl(input.href)) : input instanceof Request ? formatComfyUIUrl(input.url) : undefined; const retryInput = input instanceof Request && retryUrl && retrySource ? new Request(retryUrl, retrySource) : retryUrl; if (retryInput && String(retryUrl) !== inputUrl && isBareFetchFailure(err) && describeFetchFailure(err).code === "ECONNREFUSED") { try { return await request(retryInput, retryInit); } catch (retryError) { err = retryError; } } // Our own ceiling firing is not the same event as a caller's abort, and it // must not be reported as one. Only rewrite when WE supplied the signal. if (init.signal === undefined && isTimeoutAbort(err)) { throw describeComfyTimeout(input, init); } // ONLY the opaque undici failure is rewritten. An AbortError, a timeout with // its own message, or anything else already says what happened, and // replacing that text would be a downgrade. if (!isBareFetchFailure(err)) throw err; throw describeComfyFetchFailure(err, targetOf(input), methodOf(input, init), context); } } //# sourceMappingURL=fetch.js.map