import axios from 'axios' import type { EmbeddingsResponse, ExecutedWsMessage, ExecutingWsMessage, ExecutionCachedWsMessage, ExecutionErrorWsMessage, ExecutionInterruptedWsMessage, ExecutionStartWsMessage, ExecutionSuccessWsMessage, ExtensionsResponse, HistoryTaskItem, LogsRawResponse, LogsWsMessage, PendingTaskItem, ProgressWsMessage, PromptResponse, RunningTaskItem, Settings, StatusWsMessage, StatusWsMessageStatus, SystemStats, User, UserDataFullInfo } from '@/schemas/apiSchema' import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema' import { type ComfyNodeDef, validateComfyNodeDef } from '@/schemas/nodeDefSchema' import { WorkflowTemplates } from '@/types/workflowTemplateTypes' interface QueuePromptRequestBody { client_id: string // Mapping from node id to node info + input values // TODO: Type this. prompt: Record extra_data: { extra_pnginfo: { workflow: ComfyWorkflowJSON } } front?: boolean number?: number } /** Dictionary of Frontend-generated API calls */ interface FrontendApiCalls { graphChanged: ComfyWorkflowJSON promptQueued: { number: number; batchCount: number } graphCleared: never reconnecting: never reconnected: never } /** Dictionary of calls originating from ComfyUI core */ interface BackendApiCalls { progress: ProgressWsMessage executing: ExecutingWsMessage executed: ExecutedWsMessage status: StatusWsMessage execution_start: ExecutionStartWsMessage execution_success: ExecutionSuccessWsMessage execution_error: ExecutionErrorWsMessage execution_interrupted: ExecutionInterruptedWsMessage execution_cached: ExecutionCachedWsMessage logs: LogsWsMessage /** Mr Blob Preview, I presume? */ b_preview: Blob } /** Dictionary of all api calls */ interface ApiCalls extends BackendApiCalls, FrontendApiCalls {} /** Used to create a discriminating union on type value. */ interface ApiMessage { type: T data: ApiCalls[T] } /** Ensures workers get a fair shake. */ type Unionize = T[keyof T] /** * Discriminated union of generic, i.e.: * ```ts * // Convert * type ApiMessageUnion = ApiMessage<'status' | 'executing' | ...> * // To * type ApiMessageUnion = ApiMessage<'status'> | ApiMessage<'executing'> | ... * ``` */ type ApiMessageUnion = Unionize<{ [Key in keyof ApiCalls]: ApiMessage }> /** Wraps all properties in {@link CustomEvent}. */ type AsCustomEvents = { readonly [K in keyof T]: CustomEvent } /** Handles differing event and API signatures. */ type ApiToEventType = { [K in keyof T]: K extends 'status' ? StatusWsMessageStatus : K extends 'executing' ? NodeId : T[K] } /** Dictionary of types used in the detail for a custom event */ type ApiEventTypes = ApiToEventType /** Dictionary of API events: `[name]: CustomEvent` */ type ApiEvents = AsCustomEvents /** {@link Omit} all properties that evaluate to `never`. */ type NeverNever = { [K in keyof T as T[K] extends never ? never : K]: T[K] } /** {@link Pick} only properties that evaluate to `never`. */ type PickNevers = { [K in keyof T as T[K] extends never ? K : never]: T[K] } /** Keys (names) of API events that _do not_ pass a {@link CustomEvent} `detail` object. */ type SimpleApiEvents = keyof PickNevers /** Keys (names) of API events that pass a {@link CustomEvent} `detail` object. */ type ComplexApiEvents = keyof NeverNever /** EventTarget typing has no generic capability. This interface enables tsc strict. */ export interface ComfyApi extends EventTarget { addEventListener( type: TEvent, callback: ((event: ApiEvents[TEvent]) => void) | null, options?: AddEventListenerOptions | boolean ): void removeEventListener( type: TEvent, callback: ((event: ApiEvents[TEvent]) => void) | null, options?: EventListenerOptions | boolean ): void } export class ComfyApi extends EventTarget { #registered = new Set() api_host: string api_base: string /** * The client id from the initial session storage. */ initialClientId: string | null /** * The current client id from websocket status updates. */ clientId?: string /** * The current user id. */ user: string socket: WebSocket | null = null reportedUnknownMessageTypes = new Set() constructor() { super() this.user = '' this.api_host = location.host this.api_base = location.pathname.split('/').slice(0, -1).join('/') console.log('Running on', this.api_host) this.initialClientId = sessionStorage.getItem('clientId') } internalURL(route: string): string { return this.api_base + '/internal' + route } apiURL(route: string): string { return this.api_base + '/api' + route } fileURL(route: string): string { return this.api_base + route } fetchApi(route: string, options?: RequestInit) { if (!options) { options = {} } if (!options.headers) { options.headers = {} } if (!options.cache) { options.cache = 'no-cache' } if (Array.isArray(options.headers)) { options.headers.push(['Comfy-User', this.user]) } else if (options.headers instanceof Headers) { options.headers.set('Comfy-User', this.user) } else { options.headers['Comfy-User'] = this.user } return fetch(this.apiURL(route), options) } addEventListener( type: TEvent, callback: ((event: ApiEvents[TEvent]) => void) | null, options?: AddEventListenerOptions | boolean ) { // Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe. super.addEventListener(type, callback as EventListener, options) this.#registered.add(type) } removeEventListener( type: TEvent, callback: ((event: ApiEvents[TEvent]) => void) | null, options?: EventListenerOptions | boolean ): void { super.removeEventListener(type, callback as EventListener, options) } /** * Dispatches a custom event. * Provides type safety for the contravariance issue with EventTarget (last checked TS 5.6). * @param type The type of event to emit * @param detail The detail property used for a custom event ({@link CustomEventInit.detail}) */ dispatchCustomEvent(type: T): boolean dispatchCustomEvent( type: T, detail: ApiEventTypes[T] | null ): boolean dispatchCustomEvent( type: T, detail?: ApiEventTypes[T] ): boolean { const event = detail === undefined ? new CustomEvent(type) : new CustomEvent(type, { detail }) return super.dispatchEvent(event) } /** @deprecated Use {@link dispatchCustomEvent}. */ dispatchEvent(event: never): boolean { return super.dispatchEvent(event) } /** * Poll status for colab and other things that don't support websockets. */ #pollQueue() { setInterval(async () => { try { const resp = await this.fetchApi('/prompt') const status = (await resp.json()) as StatusWsMessageStatus this.dispatchCustomEvent('status', status) } catch (error) { this.dispatchCustomEvent('status', null) } }, 1000) } /** * Creates and connects a WebSocket for realtime updates * @param {boolean} isReconnect If the socket is connection is a reconnect attempt */ #createSocket(isReconnect?: boolean) { if (this.socket) { return } let opened = false let existingSession = window.name if (existingSession) { existingSession = '?clientId=' + existingSession } this.socket = new WebSocket( `ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}` ) this.socket.binaryType = 'arraybuffer' this.socket.addEventListener('open', () => { opened = true if (isReconnect) { this.dispatchCustomEvent('reconnected') } }) this.socket.addEventListener('error', () => { if (this.socket) this.socket.close() if (!isReconnect && !opened) { this.#pollQueue() } }) this.socket.addEventListener('close', () => { setTimeout(() => { this.socket = null this.#createSocket(true) }, 300) if (opened) { this.dispatchCustomEvent('status', null) this.dispatchCustomEvent('reconnecting') } }) this.socket.addEventListener('message', (event) => { try { if (event.data instanceof ArrayBuffer) { const view = new DataView(event.data) const eventType = view.getUint32(0) const buffer = event.data.slice(4) switch (eventType) { case 1: const view2 = new DataView(event.data) const imageType = view2.getUint32(0) let imageMime switch (imageType) { case 1: default: imageMime = 'image/jpeg' break case 2: imageMime = 'image/png' } const imageBlob = new Blob([buffer.slice(4)], { type: imageMime }) this.dispatchCustomEvent('b_preview', imageBlob) break default: throw new Error( `Unknown binary websocket message of type ${eventType}` ) } } else { const msg = JSON.parse(event.data) as ApiMessageUnion switch (msg.type) { case 'status': if (msg.data.sid) { const clientId = msg.data.sid this.clientId = clientId window.name = clientId // use window name so it isnt reused when duplicating tabs sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow } this.dispatchCustomEvent('status', msg.data.status ?? null) break case 'executing': this.dispatchCustomEvent( 'executing', msg.data.display_node || msg.data.node ) break case 'execution_start': case 'execution_error': case 'execution_interrupted': case 'execution_cached': case 'execution_success': case 'progress': case 'executed': case 'graphChanged': case 'promptQueued': case 'logs': case 'b_preview': this.dispatchCustomEvent(msg.type, msg.data) break default: if (this.#registered.has(msg.type)) { // Fallback for custom types - calls super direct. super.dispatchEvent( new CustomEvent(msg.type, { detail: msg.data }) ) } else if (!this.reportedUnknownMessageTypes.has(msg.type)) { this.reportedUnknownMessageTypes.add(msg.type) throw new Error(`Unknown message type ${msg.type}`) } } } } catch (error) { console.warn('Unhandled message:', event.data, error) } }) } /** * Initialises sockets and realtime updates */ init() { this.#createSocket() } /** * Gets a list of extension urls */ async getExtensions(): Promise { const resp = await this.fetchApi('/extensions', { cache: 'no-store' }) return await resp.json() } /** * Gets the available workflow templates from custom nodes. * @returns A map of custom_node names and associated template workflow names. */ async getWorkflowTemplates(): Promise<{ [customNodesName: string]: string[] }> { const res = await this.fetchApi('/workflow_templates') return await res.json() } /** * Gets the index of core workflow templates. */ async getCoreWorkflowTemplates(): Promise { const res = await axios.get(this.fileURL('/templates/index.json')) const contentType = res.headers['content-type'] return contentType?.includes('application/json') ? res.data : [] } /** * Gets a list of embedding names */ async getEmbeddings(): Promise { const resp = await this.fetchApi('/embeddings', { cache: 'no-store' }) return await resp.json() } /** * Loads node object definitions for the graph * @returns The node definitions */ async getNodeDefs({ validate = false }: { validate?: boolean } = {}): Promise< Record > { const resp = await this.fetchApi('/object_info', { cache: 'no-store' }) const objectInfoUnsafe = await resp.json() if (!validate) { return objectInfoUnsafe } // Validate node definitions against zod schema. (slow) const objectInfo: Record = {} for (const key in objectInfoUnsafe) { const validatedDef = validateComfyNodeDef( objectInfoUnsafe[key], /* onError=*/ (errorMessage: string) => { console.warn( `Skipping invalid node definition: ${key}. See debug log for more information.` ) console.debug(errorMessage) } ) if (validatedDef !== null) { objectInfo[key] = validatedDef } } return objectInfo } /** * * @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue * @param {object} prompt The prompt data to queue */ async queuePrompt( number: number, { output, workflow }: { output: Record; workflow: ComfyWorkflowJSON } ): Promise { const body: QueuePromptRequestBody = { client_id: this.clientId ?? '', // TODO: Unify clientId access prompt: output, extra_data: { extra_pnginfo: { workflow } } } if (number === -1) { body.front = true } else if (number != 0) { body.number = number } const res = await this.fetchApi('/prompt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) if (res.status !== 200) { throw { response: await res.json() } } return await res.json() } /** * Gets a list of model folder keys (eg ['checkpoints', 'loras', ...]) * @returns The list of model folder keys */ async getModelFolders(): Promise<{ name: string; folders: string[] }[]> { const res = await this.fetchApi(`/experiment/models`) if (res.status === 404) { return [] } const folderBlacklist = ['configs', 'custom_nodes'] return (await res.json()).filter( (folder: string) => !folderBlacklist.includes(folder) ) } /** * Gets a list of models in the specified folder * @param {string} folder The folder to list models from, such as 'checkpoints' * @returns The list of model filenames within the specified folder */ async getModels( folder: string ): Promise<{ name: string; pathIndex: number }[]> { const res = await this.fetchApi(`/experiment/models/${folder}`) if (res.status === 404) { return [] } return await res.json() } /** * Gets the metadata for a model * @param {string} folder The folder containing the model * @param {string} model The model to get metadata for * @returns The metadata for the model */ async viewMetadata(folder: string, model: string) { const res = await this.fetchApi( `/view_metadata/${folder}?filename=${encodeURIComponent(model)}` ) const rawResponse = await res.text() if (!rawResponse) { return null } try { return JSON.parse(rawResponse) } catch (error) { console.error( 'Error viewing metadata', res.status, res.statusText, rawResponse, error ) return null } } /** * Loads a list of items (queue or history) * @param {string} type The type of items to load, queue or history * @returns The items of the specified type grouped by their status */ async getItems(type: 'queue' | 'history') { if (type === 'queue') { return this.getQueue() } return this.getHistory() } /** * Gets the current state of the queue * @returns The currently running and queued items */ async getQueue(): Promise<{ Running: RunningTaskItem[] Pending: PendingTaskItem[] }> { try { const res = await this.fetchApi('/queue') const data = await res.json() return { // Running action uses a different endpoint for cancelling Running: data.queue_running.map((prompt: Record) => ({ taskType: 'Running', prompt, remove: { name: 'Cancel', cb: () => api.interrupt() } })), Pending: data.queue_pending.map((prompt: Record) => ({ taskType: 'Pending', prompt })) } } catch (error) { console.error(error) return { Running: [], Pending: [] } } } /** * Gets the prompt execution history * @returns Prompt history including node outputs */ async getHistory( max_items: number = 200 ): Promise<{ History: HistoryTaskItem[] }> { try { const res = await this.fetchApi(`/history?max_items=${max_items}`) const json: Promise = await res.json() return { History: Object.values(json).map((item) => ({ ...item, taskType: 'History' })) } } catch (error) { console.error(error) return { History: [] } } } /** * Gets system & device stats * @returns System stats such as python version, OS, per device info */ async getSystemStats(): Promise { const res = await this.fetchApi('/system_stats') return await res.json() } /** * Sends a POST request to the API * @param {*} type The endpoint to post to * @param {*} body Optional POST data */ async #postItem(type: string, body: any) { try { await this.fetchApi('/' + type, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }) } catch (error) { console.error(error) } } /** * Deletes an item from the specified list * @param {string} type The type of item to delete, queue or history * @param {number} id The id of the item to delete */ async deleteItem(type: string, id: string) { await this.#postItem(type, { delete: [id] }) } /** * Clears the specified list * @param {string} type The type of list to clear, queue or history */ async clearItems(type: string) { await this.#postItem(type, { clear: true }) } /** * Interrupts the execution of the running prompt */ async interrupt() { await this.#postItem('interrupt', null) } /** * Gets user configuration data and where data should be stored */ async getUserConfig(): Promise { return (await this.fetchApi('/users')).json() } /** * Creates a new user * @param { string } username * @returns The fetch response */ createUser(username: string) { return this.fetchApi('/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) } /** * Gets all setting values for the current user * @returns { Promise } A dictionary of id -> value */ async getSettings(): Promise { return (await this.fetchApi('/settings')).json() } /** * Gets a setting for the current user * @param { string } id The id of the setting to fetch * @returns { Promise } The setting value */ async getSetting(id: keyof Settings): Promise { return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json() } /** * Stores a dictionary of settings for the current user */ async storeSettings(settings: Settings) { return this.fetchApi(`/settings`, { method: 'POST', body: JSON.stringify(settings) }) } /** * Stores a setting for the current user */ async storeSetting(id: keyof Settings, value: Settings[keyof Settings]) { return this.fetchApi(`/settings/${encodeURIComponent(id)}`, { method: 'POST', body: JSON.stringify(value) }) } /** * Gets a user data file for the current user */ async getUserData(file: string, options?: RequestInit) { return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options) } /** * Stores a user data file for the current user * @param { string } file The name of the userdata file to save * @param { unknown } data The data to save to the file * @param { RequestInit & { stringify?: boolean, throwOnError?: boolean } } [options] * @returns { Promise } */ async storeUserData( file: string, data: any, options: RequestInit & { overwrite?: boolean stringify?: boolean throwOnError?: boolean full_info?: boolean } = { overwrite: true, stringify: true, throwOnError: true, full_info: false } ): Promise { const resp = await this.fetchApi( `/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}&full_info=${options.full_info}`, { method: 'POST', body: options?.stringify ? JSON.stringify(data) : data, ...options } ) if (resp.status !== 200 && options.throwOnError !== false) { throw new Error( `Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}` ) } return resp } /** * Deletes a user data file for the current user * @param { string } file The name of the userdata file to delete */ async deleteUserData(file: string) { const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, { method: 'DELETE' }) return resp } /** * Move a user data file for the current user * @param { string } source The userdata file to move * @param { string } dest The destination for the file */ async moveUserData( source: string, dest: string, options = { overwrite: false } ) { const resp = await this.fetchApi( `/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`, { method: 'POST' } ) return resp } /** * @overload * Lists user data files for the current user * @param { string } dir The directory in which to list files * @param { boolean } [recurse] If the listing should be recursive * @param { true } [split] If the paths should be split based on the os path separator * @returns { Promise } The list of split file paths in the format [fullPath, ...splitPath] */ /** * @overload * Lists user data files for the current user * @param { string } dir The directory in which to list files * @param { boolean } [recurse] If the listing should be recursive * @param { false | undefined } [split] If the paths should be split based on the os path separator * @returns { Promise } The list of files */ async listUserData( dir: string, recurse: boolean, split?: true ): Promise async listUserData( dir: string, recurse: boolean, split?: false ): Promise /** * @deprecated Use `listUserDataFullInfo` instead. */ async listUserData(dir: string, recurse: boolean, split?: boolean) { const resp = await this.fetchApi( `/userdata?${new URLSearchParams({ recurse: recurse ? 'true' : 'false', dir, split: split ? 'true' : 'false' })}` ) if (resp.status === 404) return [] if (resp.status !== 200) { throw new Error( `Error getting user data list '${dir}': ${resp.status} ${resp.statusText}` ) } return resp.json() } async listUserDataFullInfo(dir: string): Promise { const resp = await this.fetchApi( `/userdata?dir=${encodeURIComponent(dir)}&recurse=true&split=false&full_info=true` ) if (resp.status === 404) return [] if (resp.status !== 200) { throw new Error( `Error getting user data list '${dir}': ${resp.status} ${resp.statusText}` ) } return resp.json() } async getLogs(): Promise { return (await axios.get(this.internalURL('/logs'))).data } async getRawLogs(): Promise { return (await axios.get(this.internalURL('/logs/raw'))).data } async subscribeLogs(enabled: boolean): Promise { return await axios.patch(this.internalURL('/logs/subscribe'), { enabled, clientId: this.clientId }) } async getFolderPaths(): Promise> { return (await axios.get(this.internalURL('/folder_paths'))).data } /** * Gets the custom nodes i18n data from the server. * * @returns The custom nodes i18n data */ async getCustomNodesI18n(): Promise> { return (await axios.get(this.apiURL('/i18n'))).data } } export const api = new ComfyApi()