comfyui-node
Version:
ComfyUI Node.js Client
1,173 lines (1,172 loc) • 50.2 kB
JavaScript
import { WebSocket } from "ws";
import { TypedEventTarget } from "./typed-event-target.js";
import { delay } from "./tools.js";
import { ManagerFeature } from "./features/manager.js";
import { MonitoringFeature } from "./features/monitoring.js";
import { QueueFeature } from "./features/queue.js";
import { HistoryFeature } from "./features/history.js";
import { SystemFeature } from "./features/system.js";
import { NodeFeature } from "./features/node.js";
import { UserFeature } from "./features/user.js";
import { FileFeature } from "./features/file.js";
import { ModelFeature } from "./features/model.js";
import { TerminalFeature } from "./features/terminal.js";
import { MiscFeature } from "./features/misc.js";
import { FeatureFlagsFeature } from "./features/feature-flags.js";
import { runWebSocketReconnect } from "./utils/ws-reconnect.js";
import { Workflow } from "./workflow.js";
/**
* Primary client for interacting with a ComfyUI server.
*
* Responsibilities:
* - Connection lifecycle (WebSocket + polling fallback)
* - Authentication header injection
* - Capability probing / feature support detection
* - High‑level event fan‑out (progress, status, terminal, etc.)
* - Aggregation of modular feature namespaces under `ext.*`
*
* This class purposefully keeps business logic for specific domains inside feature modules
* (see files in `src/features/`). Only generic transport & coordination logic lives here.
*/
export class ComfyApi extends TypedEventTarget {
/** Base host (including protocol) e.g. http://localhost:8188 */
apiHost;
/** OS type as reported by the server (resolved during init) */
osType; // assigned during init()
/** Indicates feature probing + socket establishment completed */
isReady = false;
/** Internal ready promise (resolved once). */
readyPromise;
resolveReady;
/** Whether to subscribe to terminal log streaming on init */
listenTerminal = false;
/** Monotonic timestamp of last socket activity (used for timeout detection) */
lastActivity = Date.now();
/** WebSocket inactivity timeout (ms) before attempting reconnection */
wsTimeout = 60000;
wsTimer = null;
_pollingTimer = null;
/** Current connection state */
_connectionState = "connecting";
/** Auto-reconnect flag (when enabled, reconnection happens automatically on disconnect) */
_autoReconnect = false;
/** Callback invoked when reconnection fails after all attempts */
_onReconnectionFailed;
/** Host sans protocol (used to compose ws:// / wss:// URL) */
apiBase;
clientId;
socket = null;
listeners = [];
credentials = null;
comfyOrgApiKey;
/** Debug flag to emit verbose console logs for instrumentation */
_debug = false;
headers = {};
/** Feature flags we announce to the server upon socket open */
announcedFeatureFlags = {
supports_preview_metadata: true,
max_upload_size: 50 * 1024 * 1024
};
/** Modular feature namespaces (tree intentionally flat & dependency‑free) */
ext = {
/** ComfyUI-Manager extension integration */
manager: new ManagerFeature(this),
/** Crystools monitor / system resource streaming */
monitor: new MonitoringFeature(this),
/** Prompt queue submission / control */
queue: new QueueFeature(this),
/** Execution history lookups */
history: new HistoryFeature(this),
/** System stats & memory free */
system: new SystemFeature(this),
/** Node defs + sampler / checkpoint / lora helpers */
node: new NodeFeature(this),
/** User CRUD & settings */
user: new UserFeature(this),
/** File uploads, image helpers & user data file operations */
file: new FileFeature(this),
/** Experimental model browsing / preview */
model: new ModelFeature(this),
/** Terminal log retrieval & streaming toggle */
terminal: new TerminalFeature(this),
/** Misc endpoints (extensions list, embeddings) */
misc: new MiscFeature(this),
/** Server advertised feature flags */
featureFlags: new FeatureFlagsFeature(this)
};
/** Helper type guard shaping expected feature API */
asFeature(obj) {
return obj;
}
static generateId() {
return "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
on(type, callback, options) {
this.log("on", "Add listener", { type, callback, options });
super.on(type, callback, options);
this.listeners.push({ event: type, handler: callback, options });
return () => this.off(type, callback, options);
}
off(type, callback, options) {
this.log("off", "Remove listener", { type, callback, options });
this.listeners = this.listeners.filter((l) => !(l.event === type && l.handler === callback));
super.off(type, callback, options);
}
removeAllListeners() {
this.log("removeAllListeners", "Triggered");
this.listeners.forEach((listener) => {
super.off(listener.event, listener.handler, listener.options);
});
this.listeners = [];
}
get id() {
return this.clientId ?? this.apiBase;
}
/**
* Get the current connection state of the client.
*/
get connectionState() {
return this._connectionState;
}
/**
* Retrieves the available features of the client.
*
* @returns An object containing the available features, where each feature is a key-value pair.
*/
get availableFeatures() {
return Object.keys(this.ext).reduce((acc, key) => {
const feat = this.asFeature(this.ext[key]);
return { ...acc, [key]: !!feat.isSupported };
}, {});
}
constructor(host, clientId = ComfyApi.generateId(), opts) {
super();
this.apiHost = host;
this.apiBase = host.split("://")[1];
this.clientId = clientId;
this.readyPromise = new Promise((res) => {
this.resolveReady = res;
});
if (opts?.credentials) {
this.credentials = opts?.credentials;
this.testCredentials();
}
if (opts?.wsTimeout) {
this.wsTimeout = opts.wsTimeout;
}
if (opts?.listenTerminal) {
this.listenTerminal = opts.listenTerminal;
}
if (opts?.reconnect) {
this._reconnect = { ...opts.reconnect };
}
if (opts?.headers) {
this.headers = opts.headers;
}
if (opts?.comfyOrgApiKey) {
this.comfyOrgApiKey = opts.comfyOrgApiKey;
}
// Debug flag (env COMFY_DEBUG=1 also enables it)
try {
const envDebug = typeof process !== "undefined" && process?.env?.COMFY_DEBUG;
this._debug = Boolean(opts?.debug ?? (envDebug === "1" || envDebug === "true"));
}
catch {
/* ignore env access in non-node runtimes */
}
// Merge announced feature flags overrides
if (opts?.announceFeatureFlags) {
this.announcedFeatureFlags = {
...this.announcedFeatureFlags,
...opts.announceFeatureFlags
};
}
// Auto-reconnect configuration
if (opts?.autoReconnect !== undefined) {
this._autoReconnect = opts.autoReconnect;
}
if (opts?.onReconnectionFailed) {
this._onReconnectionFailed = opts.onReconnectionFailed;
}
// Listen for reconnection_failed event to invoke callback
this.on("reconnection_failed", async () => {
this._connectionState = "failed";
if (this._onReconnectionFailed) {
try {
await this._onReconnectionFailed();
}
catch (error) {
this.log("reconnection", "onReconnectionFailed callback error", error);
}
}
});
this.log("constructor", "Initialized", {
host,
clientId,
opts
});
return this;
}
/**
* Destroys the client instance.
* Ensures all connections, timers and event listeners are properly closed.
*/
destroy() {
this.log("destroy", "Destroying client...");
// Cleanup flag to prevent re-entry
if (this._destroyed) {
this.log("destroy", "Client already destroyed");
return;
}
this._destroyed = true;
// Clean up WebSocket timer
if (this.wsTimer) {
clearInterval(this.wsTimer);
this.wsTimer = null;
}
// Clean up polling timer if exists
if (this._pollingTimer) {
clearInterval(this._pollingTimer);
this._pollingTimer = null;
}
// Clean up socket event handlers and force close WebSocket
if (this.socket) {
try {
// Remove all event handlers
this.socket.onclose = null;
this.socket.onerror = null;
this.socket.onmessage = null;
this.socket.onopen = null;
// Forcefully close the WebSocket
if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) {
this.socket.close();
}
// Terminate the WebSocket connection
this.socket.terminate();
}
catch (e) {
this.log("destroy", "Error while closing WebSocket", e);
}
}
// Destroy all extensions
for (const ext in this.ext) {
try {
const feat = this.asFeature(this.ext[ext]);
feat.destroy?.();
}
catch (e) {
this.log("destroy", `Error destroying extension ${ext}`, e);
}
}
// Make sure socket is closed
try {
this.socket?.close();
this.socket = null;
}
catch (e) {
this.log("destroy", "Error closing socket", e);
}
// Remove all event listeners
this.removeAllListeners();
this.log("destroy", "Client destroyed completely");
}
log(fnName, message, data) {
this.dispatchEvent(new CustomEvent("log", { detail: { fnName, message, data } }));
if (this._debug) {
try {
const ts = new Date().toISOString();
const id = this.clientId || this.apiBase;
// Avoid noisy large binary/object logs
const safeData = data && typeof data === "object" ? sanitizeForLog(data) : data;
// eslint-disable-next-line no-console
console.debug(`[ComfyApi ${id}] ${ts} :: ${fnName} -> ${message}`, safeData ?? "");
}
catch {
/* no-op */
}
}
}
/**
* Build full API URL (made public for feature modules)
*/
apiURL(route) {
return `${this.apiHost}${route}`;
}
getCredentialHeaders() {
if (!this.credentials)
return {};
switch (this.credentials?.type) {
case "basic":
return {
Authorization: `Basic ${btoa(`${this.credentials.username}:${this.credentials.password}`)}`
};
case "bearer_token":
return {
Authorization: `Bearer ${this.credentials.token}`
};
case "custom":
return this.credentials.headers;
default:
return {};
}
}
async testCredentials() {
try {
if (!this.credentials)
return false;
await this.pollStatus(2000);
this.dispatchEvent(new CustomEvent("auth_success"));
return true;
}
catch (e) {
this.log("testCredentials", "Failed", e);
if (e instanceof Response) {
if (e.status === 401) {
this.dispatchEvent(new CustomEvent("auth_error", { detail: e }));
return;
}
}
this.dispatchEvent(new CustomEvent("connection_error", { detail: e }));
return false;
}
}
async testFeatures() {
const extensions = Object.values(this.ext).map((e) => this.asFeature(e));
await Promise.all(extensions.map((ext) => ext.checkSupported?.()));
/**
* Mark the client is ready to use the API.
*/
this.isReady = true;
}
/**
* Fetches data from the API.
*
* @param route - The route to fetch data from.
* @param options - The options for the fetch request.
* @returns A promise that resolves to the response from the API.
*/
async fetchApi(route, options) {
if (!options) {
options = {};
}
options.headers = {
...this.headers,
...this.getCredentialHeaders()
};
options.mode = "cors";
// Update last activity timestamp to keep WebSocket alive during HTTP requests
this.resetLastActivity();
return fetch(this.apiURL(route), options);
}
/**
* Polls the status for colab and other things that don't support websockets.
* @returns {Promise<QueueStatus>} The status information.
*/
async pollStatus(timeout = 1000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await this.fetchApi("/prompt", {
signal: controller.signal
});
if (response.status === 200) {
return response.json();
}
else {
throw response;
}
}
catch (error) {
this.log("pollStatus", "Failed", error);
if (error.name === "AbortError") {
throw new Error("Request timed out");
}
throw error;
}
finally {
clearTimeout(timeoutId);
}
}
/**
* Queues a prompt for processing.
* @param {number} number The index at which to queue the prompt. using NULL will append to the end of the queue.
* @param {object} workflow Additional workflow data.
* @returns {Promise<QueuePromptResponse>} The response from the API.
*/
// Deprecated queuePrompt / appendPrompt wrappers removed. Use feature: api.ext.queue.*
/**
* Fetch raw queue status snapshot (lightweight helper not yet moved into a feature wrapper).
*/
async getQueue() {
// Direct call (no feature wrapper yet for queue status)
const response = await this.fetchApi("/queue");
return response.json();
}
/**
* Hint the server to unload models / free memory (maps to `/free`).
* Returns false if request fails (does not throw to simplify caller ergonomics).
*/
async freeMemory(unloadModels, freeMemory) {
const payload = {
unload_models: unloadModels,
free_memory: freeMemory
};
try {
const response = await this.fetchApi("/free", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
// Check if the response is successful
if (!response.ok) {
this.log("freeMemory", "Free memory failed", response);
return false;
}
// Return the response object
return true;
}
catch (error) {
this.log("freeMemory", "Free memory failed", error);
return false;
}
}
/**
* Initialize: ping server with retries, probe features, establish WebSocket, optionally subscribe to terminal logs.
* Resolves with the client instance when ready; throws on unrecoverable connection failure.
*/
async init(maxTries = 10, delayTime = 1000) {
try {
// Wait for ping to succeed
await this.pingSuccess(maxTries, delayTime);
// Get system OS type on initialization
// Use feature namespace directly to avoid triggering deprecated shim
try {
const sys = await this.ext.system.getSystemStats();
this.osType = sys.system.os;
}
catch (e) {
console.warn("Failed to get OS type during init:", e);
this.osType = "Unknown";
}
// Test features on initialization
await this.testFeatures();
// Create WebSocket connection on initialization
this.createSocket();
// Set terminal subscription on initialization (use feature namespace to avoid deprecated shim)
if (this.listenTerminal) {
try {
await this.ext.terminal.setTerminalSubscription(true);
}
catch (e) {
console.warn("Failed to set terminal subscription during init:", e);
}
}
// Mark as ready
this.isReady = true;
// Resolve ready promise exactly once
try {
this.resolveReady?.(this);
}
catch {
/* no-op */
}
return this;
}
catch (e) {
this.log("init", "Failed", e);
this.dispatchEvent(new CustomEvent("connection_error", { detail: e }));
throw e; // Propagate the error
}
}
async pingSuccess(maxTries = 10, delayTime = 1000) {
let tries = 0;
let ping = await this.ping();
while (!ping.status) {
if (tries > maxTries) {
throw new Error("Can't connect to the server");
}
await delay(delayTime); // Wait for 1s before trying again
ping = await this.ping();
tries++;
}
}
/** Await until feature probing + socket creation finished. */
async waitForReady() {
return this.readyPromise;
}
/**
* Sends a ping request to the server and returns a boolean indicating whether the server is reachable.
* @returns A promise that resolves to `true` if the server is reachable, or `false` otherwise.
*/
async ping() {
const start = performance.now();
return this.pollStatus(5000)
.then(() => {
return { status: true, time: performance.now() - start };
})
.catch((error) => {
this.log("ping", "Can't connect to the server", error);
return { status: false };
});
}
/**
* Attempt WebSocket reconnection with exponential backoff + jitter.
* Falls back to a bounded number of attempts then emits `reconnection_failed`.
*/
async reconnectWs(triggerEvent) {
if (this._reconnectController) {
// Avoid stacking multiple controllers concurrently
try {
this._reconnectController.abort();
}
catch { }
}
this._connectionState = "reconnecting";
this._reconnectController = runWebSocketReconnect(this, () => this.createSocket(true), {
triggerEvents: !!triggerEvent,
maxAttempts: this._reconnect?.maxAttempts,
baseDelayMs: this._reconnect?.baseDelayMs,
maxDelayMs: this._reconnect?.maxDelayMs,
strategy: this._reconnect?.strategy,
jitterPercent: this._reconnect?.jitterPercent,
customDelayFn: this._reconnect?.customDelayFn
});
}
/** Abort any in-flight reconnection loop (no-op if none active). */
abortReconnect() {
try {
this._reconnectController?.abort();
}
catch { }
}
resetLastActivity() {
this.lastActivity = Date.now();
}
/**
* Check if WebSocket is currently connected and open.
*/
isConnected() {
return this.socket?.readyState === WebSocket.OPEN;
}
/**
* Actively validate connection by making a lightweight API call.
* @returns true if connection is healthy, false otherwise
*/
async validateConnection() {
try {
await this.getQueue();
return true;
}
catch {
return false;
}
}
/** Convenience: init + waitForReady (idempotent). */
async ready() {
if (!this.isReady) {
await this.init();
await this.waitForReady();
}
return this;
}
/**
* Decode a preview-with-metadata binary frame.
* Layout after the 4-byte event type header:
* [0..3] eventType (already consumed by caller)
* [4..7] big-endian uint32: metadata JSON byte length (N)
* [8..8+N) metadata JSON (utf-8)
* [8+N..] image bytes (png/jpeg as declared in metadata.image_type)
* Returns null if parsing fails.
*/
_decodePreviewWithMetadata(u8, payloadOffset) {
try {
if (u8.byteLength < payloadOffset + 4)
return null;
}
catch { }
// Re-parse with explicit big-endian
try {
const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
const metaLen = view.getUint32(payloadOffset, false /* big-endian */);
const metaStart = payloadOffset + 4;
const metaEnd = metaStart + metaLen;
if (metaEnd > u8.byteLength)
return null;
const metaBytes = u8.slice(metaStart, metaEnd);
const metaText = new TextDecoder("utf-8").decode(metaBytes);
let metadata;
try {
metadata = JSON.parse(metaText);
}
catch (e) {
metadata = { parse_error: String(e) };
}
const imageBytes = u8.slice(metaEnd);
const type = (metadata && metadata.image_type) || "image/jpeg";
const blob = new Blob([imageBytes], { type });
return { blob, metadata };
}
catch (e) {
this.log("_decodePreviewWithMetadata", "Failed to decode", e);
return null;
}
}
/**
* High-level sugar: run a Workflow or PromptBuilder directly.
* Accepts experimental Workflow abstraction or a raw PromptBuilder-like object with setInputNode/output mappings already applied.
*/
async run(wf, opts) {
if (wf instanceof Workflow) {
await this.ready();
const job = await wf.run(this, { pool: opts?.pool, includeOutputs: opts?.includeOutputs });
const ensured = this._ensureWorkflowJob(job);
if (opts?.autoDestroy) {
if (ensured && typeof ensured.on === "function") {
ensured.on("finished", () => this.destroy()).on("failed", () => this.destroy());
}
else if (ensured && typeof ensured.finally === "function") {
ensured.finally(() => this.destroy());
}
}
return ensured;
}
// Assume raw JSON -> wrap
if (typeof wf === "object" && !wf.run) {
const w = Workflow.from(wf);
await this.ready();
const job = await w.run(this, { pool: opts?.pool, includeOutputs: opts?.includeOutputs });
const ensured = this._ensureWorkflowJob(job);
if (opts?.autoDestroy) {
if (ensured && typeof ensured.on === "function") {
ensured.on("finished", () => this.destroy()).on("failed", () => this.destroy());
}
else if (ensured && typeof ensured.finally === "function") {
ensured.finally(() => this.destroy());
}
}
return ensured;
}
throw new Error("Unsupported workflow object passed to api.run");
}
/** Backwards compatibility: ensure returned value has minimal WorkflowJob surface (.on/.done). */
_ensureWorkflowJob(job) {
if (!job)
return job;
const hasOn = typeof job.on === "function";
const hasDone = typeof job.done === "function";
if (hasOn && hasDone)
return job; // already a WorkflowJob
// Wrap plain promise-like
if (typeof job.then === "function") {
const listeners = {};
const emit = (evt, ...args) => (listeners[evt] || []).forEach((fn) => {
try {
fn(...args);
}
catch { }
});
// Attempt to tap into resolution
job.then((val) => {
emit("finished", val, (val && val._promptId) || undefined);
return val;
}, (err) => {
emit("failed", err);
throw err;
});
return Object.assign(job, {
on(evt, fn) {
(listeners[evt] = listeners[evt] || []).push(fn);
return this;
},
off(evt, fn) {
listeners[evt] = (listeners[evt] || []).filter((f) => f !== fn);
return this;
},
done() {
return job;
}
});
}
return job;
}
/** Alias for clarity when passing explicit Workflow objects */
async runWorkflow(wf, opts) {
return this.run(wf, opts);
}
/** Convenience helper: run + wait for completion results in one call. */
async runAndWait(wf, opts) {
const job = await this.run(wf, { pool: opts?.pool, includeOutputs: opts?.includeOutputs });
return job.done();
}
/**
* Establish a WebSocket connection for real‑time events; installs polling fallback on failure.
* @param isReconnect internal flag indicating this creation follows a reconnect attempt
*/
createSocket(isReconnect = false) {
let reconnecting = false;
let usePolling = false;
let opened = false;
// Update connection state
if (!isReconnect) {
this._connectionState = "connecting";
}
const stopHeartbeat = () => {
if (this.wsTimer) {
clearInterval(this.wsTimer);
this.wsTimer = null;
}
};
const startHeartbeat = () => {
stopHeartbeat();
if (!Number.isFinite(this.wsTimeout) || this.wsTimeout <= 0) {
return;
}
const interval = Math.max(1000, Math.floor(this.wsTimeout / 2));
this.wsTimer = setInterval(() => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return;
}
const idleFor = Date.now() - this.lastActivity;
if (idleFor >= this.wsTimeout) {
this.log("socket", "Heartbeat ping after inactivity", { idleMs: idleFor, wsTimeout: this.wsTimeout });
try {
const wsAny = this.socket;
if (typeof wsAny.ping === "function") {
wsAny.ping();
this.resetLastActivity();
}
else {
this.log("socket", "Heartbeat ping skipped - unsupported by WebSocket implementation");
}
}
catch (error) {
this.log("socket", "Heartbeat ping failed", error);
}
}
}, interval);
};
// Track last seen executing node + prompt id for correlation
let lastExecutingNode = null;
let lastPromptId = null;
if (this.socket) {
this.log("socket", "Socket already exists, skipping creation.");
return;
}
const headers = {
...this.headers,
...this.getCredentialHeaders()
};
const existingSession = `?clientId=${this.clientId}`;
const wsUrl = `ws${this.apiHost.includes("https:") ? "s" : ""}://${this.apiBase}/ws${existingSession}`;
this.log("socket", "Preparing to open WebSocket", {
url: wsUrl,
// Only include header keys to avoid leaking secrets in logs
header_keys: Object.keys(headers)
});
// Try to create WebSocket connection
try {
this.socket = new WebSocket(wsUrl, {
headers: headers
});
const wsEventSource = this.socket;
if (typeof wsEventSource.on === "function") {
wsEventSource.on("pong", () => this.resetLastActivity());
wsEventSource.on("ping", () => this.resetLastActivity());
}
else {
this.socket.addEventListener?.("pong", () => this.resetLastActivity());
this.socket.addEventListener?.("ping", () => this.resetLastActivity());
}
const activeSocket = this.socket;
this.socket.onclose = (_event) => {
const closeEvent = _event;
const code = closeEvent?.code ?? undefined;
const reason = closeEvent?.reason ?? undefined;
const wasClean = closeEvent?.wasClean ?? undefined;
stopHeartbeat();
if (this.socket === activeSocket) {
this.socket = null;
}
if (reconnecting || isReconnect) {
return;
}
reconnecting = true;
const shouldEmit = opened;
opened = false;
this.log("socket", "Socket closed", { code, reason, wasClean, shouldEmit, isReconnect });
// Update connection state
this._connectionState = "disconnected";
if (shouldEmit) {
this.dispatchEvent(new CustomEvent("status", { detail: null }));
}
// Handle reconnection based on autoReconnect flag
if (this._autoReconnect || shouldEmit) {
this.reconnectWs(shouldEmit);
}
if (!opened && !isReconnect && !usePolling) {
usePolling = true;
this.log("socket", "Socket failed to open, enabling polling fallback");
this.setupPollingFallback();
}
};
this.socket.onopen = () => {
this.resetLastActivity();
reconnecting = false;
opened = true;
usePolling = false; // Reset polling flag if we have an open connection
this.log("socket", "Socket opened");
stopHeartbeat();
startHeartbeat();
// Update connection state
this._connectionState = "connected";
if (isReconnect) {
this.dispatchEvent(new CustomEvent("reconnected"));
}
else {
this.dispatchEvent(new CustomEvent("connected"));
}
if (this._pollingTimer) {
clearInterval(this._pollingTimer);
this._pollingTimer = null;
}
// Announce feature flags (configurable via constructor option)
this.socket?.send(JSON.stringify({
type: "feature_flags",
data: this.announcedFeatureFlags
}));
};
}
catch (error) {
this.log("socket", "WebSocket creation failed, falling back to polling", error);
this.socket = null;
usePolling = true;
this._connectionState = "failed";
this.dispatchEvent(new CustomEvent("websocket_unavailable", { detail: error }));
// Set up polling mechanism
this.setupPollingFallback();
}
// Only continue with WebSocket setup if creation was successful
if (this.socket) {
this.socket.onmessage = (event) => {
this.resetLastActivity();
try {
// Unified binary handling: Buffer (ws), ArrayBuffer (WHATWG / Node >= 22), or typed array view
let u8 = null;
if (event.data instanceof Buffer) {
u8 = event.data;
}
else if (event.data instanceof ArrayBuffer) {
u8 = new Uint8Array(event.data);
}
else if (ArrayBuffer.isView(event.data)) {
const viewAny = event.data;
u8 = new Uint8Array(viewAny.buffer, viewAny.byteOffset, viewAny.byteLength);
}
if (u8) {
if (u8.byteLength < 8) {
this.log("socket", "Binary frame too small for preview header", { size: u8.byteLength });
return;
}
const view = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
const eventType = view.getUint32(0); // protocol: first 4 bytes event kind
switch (eventType) {
case 1: {
// Legacy: preview image without metadata
const imageType = view.getUint32(4); // 1=jpeg, 2=png
const imageMime = imageType === 2 ? "image/png" : "image/jpeg";
const imageBlob = new Blob([u8.slice(8)], { type: imageMime });
this.log("socket", "b_preview (binary) received", { size: u8.byteLength, mime: imageMime });
this.dispatchEvent(new CustomEvent("b_preview", { detail: imageBlob }));
break;
}
case 2: {
// Unencoded preview image (raw). Forward bytes to consumers.
const bytes = u8.slice(4);
this.log("socket", "b_preview_raw (binary) received", { size: bytes.byteLength });
this.dispatchEvent(new CustomEvent("b_preview_raw", { detail: bytes }));
break;
}
case 3: {
// Text payload (utf-8) with 4-byte channel preceding text
try {
if (u8.byteLength < 8) {
this.log("socket", "b_text frame too small", { size: u8.byteLength });
break;
}
const view2 = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
const channel = view2.getUint32(4, false /* big-endian */);
const text = new TextDecoder("utf-8").decode(u8.slice(8));
this.log("socket", "b_text (binary) received", {
size: u8.byteLength,
channel,
preview: text.slice(0, 120)
});
this.dispatchEvent(new CustomEvent("b_text", { detail: text }));
this.dispatchEvent(new CustomEvent("b_text_meta", { detail: { channel, text } }));
// Emit normalized node_text_update for consumers
const norm = {
channel,
text,
kind: "message",
executingNode: lastExecutingNode,
promptIdHint: lastPromptId
};
// Simplify: find the first occurrence of a known phrase and drop everything before it (prefix agnostic)
// This covers prefixes like "LUMA", numeric IDs ("2"), mixed case, etc.
const lower = text.toLowerCase();
const phrases = ["task in progress:", "result url:"];
let start = -1;
for (const p of phrases) {
const idx = lower.indexOf(p);
if (idx !== -1)
start = start === -1 ? idx : Math.min(start, idx);
}
let body = start !== -1 ? text.slice(start).trimStart() : text;
norm.cleanText = body;
const mProg = body.match(/^(?:([A-Z0-9_\-]+))?Task in progress: ([0-9]+(?:\.[0-9]+)?)s/i);
if (mProg) {
norm.kind = "progress";
norm.nodeHint = mProg[1] || undefined;
norm.progressSeconds = Number(mProg[2]);
}
const mUrl = body.match(/^(?:([A-Z0-9_\-]+))?Result URL:\s*(https?:[^\s]+)\s*$/i);
if (mUrl) {
norm.kind = "result";
norm.nodeHint = mUrl[1] || undefined;
norm.resultUrl = mUrl[2];
}
// Fallback: if we couldn't extract a node hint from the text, use the last executing node
if (!norm.nodeHint && lastExecutingNode) {
norm.nodeHint = lastExecutingNode;
}
this.dispatchEvent(new CustomEvent("node_text_update", { detail: norm }));
}
catch (e) {
this.log("socket", "Failed to decode b_text", e);
}
break;
}
case 4: {
// Preview image WITH metadata (supports_preview_metadata)
try {
const decoded = this._decodePreviewWithMetadata(u8, 4 /*payloadOffset*/);
if (decoded) {
this.log("socket", "b_preview_meta (binary) received", { size: u8.byteLength });
this.dispatchEvent(new CustomEvent("b_preview", { detail: decoded.blob }));
this.dispatchEvent(new CustomEvent("b_preview_meta", { detail: { blob: decoded.blob, metadata: decoded.metadata } }));
}
}
catch (e) {
this.log("socket", "Failed to decode preview with metadata", e);
}
break;
}
default:
// Unknown binary type – ignore but log once (could extend protocol later)
this.log("socket", "Unknown binary websocket message", { eventType, size: u8.byteLength });
break;
}
return; // handled binary branch
}
if (typeof event.data === "string") {
const msg = JSON.parse(event.data);
if (!msg.data || !msg.type)
return;
this.log("socket-msg", `type=${msg.type}`, {
prompt_id: msg.data?.prompt_id,
node: msg.data?.node,
keys: Object.keys(msg.data || {})
});
this.dispatchEvent(new CustomEvent("all", { detail: msg }));
if (msg.type === "logs") {
this.dispatchEvent(new CustomEvent("terminal", { detail: msg.data.entries?.[0] || null }));
}
else {
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data }));
}
if (msg.data.sid) {
this.clientId = msg.data.sid;
}
// Correlate execution context for text parsing later
if (msg.type === "executing") {
lastExecutingNode = msg.data?.node ?? null;
lastPromptId = msg.data?.prompt_id ?? null;
}
}
else {
this.log("socket", "Unhandled message", { kind: typeof event.data });
}
}
catch (error) {
this.log("socket", "Unhandled message", { event, error });
}
};
this.socket.onerror = (e) => {
this.log("socket", "Socket error", e);
if (!opened && !isReconnect && !usePolling) {
usePolling = true;
this.log("socket", "WebSocket error before open, enabling polling fallback");
this.setupPollingFallback();
}
};
}
}
/**
* Install a 2s interval polling loop to replicate essential status events when WebSocket is unavailable.
* Stops automatically once a socket connection is restored.
*/
setupPollingFallback() {
this.log("socket", "Setting up polling fallback mechanism");
// Clear any existing polling timer
if (this._pollingTimer) {
try {
clearInterval(this._pollingTimer);
this._pollingTimer = null;
}
catch (e) {
this.log("socket", "Error clearing polling timer", e);
}
}
// Poll every 2 seconds
const POLLING_INTERVAL = 2000;
const pollFn = async () => {
try {
// Poll execution status
const status = await this.pollStatus();
const anyStatus = status;
const queueRem = anyStatus?.status?.exec_info?.queue_remaining ?? anyStatus?.exec_info?.queue_remaining;
this.log("polling", "status snapshot", { queue_remaining: queueRem });
// Simulate an event dispatch similar to WebSocket
this.dispatchEvent(new CustomEvent("status", { detail: status }));
// Reset activity timestamp to prevent timeout
this.resetLastActivity();
// Try to re-establish WebSocket connection periodically
if (!this.socket || !this.socket || this.socket.readyState !== WebSocket.OPEN) {
this.log("socket", "Attempting to restore WebSocket connection");
try {
this.createSocket(true);
}
catch (error) {
// Continue with polling if WebSocket creation fails
this.log("socket", "WebSocket still unavailable, continuing with polling", error);
}
}
else {
// WebSocket is back, we can stop polling
this.log("socket", "WebSocket connection restored, stopping polling");
if (this._pollingTimer) {
clearInterval(this._pollingTimer);
this._pollingTimer = null;
}
}
}
catch (error) {
this.log("socket", "Polling error", error);
}
};
// Using setInterval and casting to the expected type
this._pollingTimer = setInterval(pollFn, POLLING_INTERVAL);
this.log("socket", `Polling started with interval of ${POLLING_INTERVAL}ms`);
}
/**
* Retrieves a list of all available model folders.
* @experimental API that may change in future versions
* @returns A promise that resolves to an array of ModelFolder objects.
*/
async getModelFolders() {
try {
const response = await this.fetchApi("/experiment/models");
if (!response.ok) {
this.log("getModelFolders", "Failed to fetch model folders", response);
throw new Error(`Failed to fetch model folders: ${response.status} ${response.statusText}`);
}
return response.json();
}
catch (error) {
this.log("getModelFolders", "Error fetching model folders", error);
throw error;
}
}
/**
* Retrieves a list of all model files in a specific folder.
* @experimental API that may change in future versions
* @param folder - The name of the model folder.
* @returns A promise that resolves to an array of ModelFile objects.
*/
async getModelFiles(folder) {
try {
const response = await this.fetchApi(`/experiment/models/${encodeURIComponent(folder)}`);
if (!response.ok) {
this.log("getModelFiles", "Failed to fetch model files", { folder, response });
throw new Error(`Failed to fetch model files: ${response.status} ${response.statusText}`);
}
return response.json();
}
catch (error) {
this.log("getModelFiles", "Error fetching model files", { folder, error });
throw error;
}
}
/**
* Retrieves a preview image for a specific model file.
* @experimental API that may change in future versions
* @param folder - The name of the model folder.
* @param pathIndex - The index of the folder path where the file is stored.
* @param filename - The name of the model file.
* @returns A promise that resolves to a ModelPreviewResponse object containing the preview image data.
*/
async getModelPreview(folder, pathIndex, filename) {
try {
const response = await this.fetchApi(`/experiment/models/preview/${encodeURIComponent(folder)}/${pathIndex}/${encodeURIComponent(filename)}`);
if (!response.ok) {
this.log("getModelPreview", "Failed to fetch model preview", { folder, pathIndex, filename, response });
throw new Error(`Failed to fetch model preview: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get("content-type") || "image/webp";
const body = await response.arrayBuffer();
return {
body,
contentType
};
}
catch (error) {
this.log("getModelPreview", "Error fetching model preview", { folder, pathIndex, filename, error });
throw error;
}
}
/**
* Creates a URL for a model preview image.
* @experimental API that may change in future versions
* @param folder - The name of the model folder.
* @param pathIndex - The index of the folder path where the file is stored.
* @param filename - The name of the model file.
* @returns The URL string for the model preview.
*/
getModelPreviewUrl(folder, pathIndex, filename) {
return this.apiURL(`/experiment/models/preview/${encodeURIComponent(folder)}/${pathIndex}/${encodeURIComponent(filename)}`);
}
}
/**
* Remove large / sensitive fields before logging objects to console in debug mode.
*/
function sanitizeForLog(input) {
try {
if (!input || typeof input !== "object")
return input;
const clone = Array.isArray(input) ? [] : {};
const SENSITIVE_KEYS = new Set(["api_key", "api_key_comfy_org", "Authorization", "headers"]);
for (const [k, v] of Object.entries(input)) {
if (SENSITIVE_KEYS.has(k)) {
clone[k] = "<redacted>";
continue;
}
if (v && typeof v === "object") {
clone[k] = sanitizeForLog(v);
}
else if (typeof v === "string" && v.length > 500) {