From aecb42479c7531562ab014636c4f66a464923d4e Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 09:53:51 -0500 Subject: [PATCH 01/21] prompt.failed event --- src/server.ts | 137 +++++++++++++++++++++++++++++++------------------- 1 file changed, 86 insertions(+), 51 deletions(-) diff --git a/src/server.ts b/src/server.ts index 8446053..d169abf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -282,69 +282,104 @@ server.after(() => { /** * Send the prompt to ComfyUI, and return a 202 response to the user. */ - runPromptAndGetOutputs(prompt, app.log).then( - /** - * This function does not block returning the 202 response to the user. - */ - async (outputs: Record) => { - for (const originalFilename in outputs) { - let filename = originalFilename; - let fileBuffer = outputs[filename]; - if (convert_output) { - try { - fileBuffer = await convertImageBuffer( - fileBuffer, - convert_output - ); - - /** - * If the user has provided an output format, we need to update the filename - */ - filename = originalFilename.replace( - /\.[^/.]+$/, - `.${convert_output.format}` - ); - } catch (e: any) { - app.log.warn(`Failed to convert image: ${e.message}`); + runPromptAndGetOutputs(prompt, app.log) + .then( + /** + * This function does not block returning the 202 response to the user. + */ + async (outputs: Record) => { + for (const originalFilename in outputs) { + let filename = originalFilename; + let fileBuffer = outputs[filename]; + if (convert_output) { + try { + fileBuffer = await convertImageBuffer( + fileBuffer, + convert_output + ); + + /** + * If the user has provided an output format, we need to update the filename + */ + filename = originalFilename.replace( + /\.[^/.]+$/, + `.${convert_output.format}` + ); + } catch (e: any) { + app.log.warn(`Failed to convert image: ${e.message}`); + } } + const base64File = fileBuffer.toString("base64"); + app.log.info( + `Sending image ${filename} to webhook: ${webhook}` + ); + fetch(webhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + event: "output.complete", + image: base64File, + id, + filename, + prompt, + }), + }) + .catch((e: any) => { + app.log.error( + `Failed to send image to webhook: ${e.message}` + ); + }) + .then(async (resp) => { + if (!resp) { + app.log.error("No response from webhook"); + } else if (!resp.ok) { + app.log.error( + `Failed to send image ${filename}: ${await resp.text()}` + ); + } else { + app.log.info(`Sent image ${filename}`); + } + }); + + // Remove the file after sending + fsPromises.unlink( + path.join(config.outputDir, originalFilename) + ); } - const base64File = fileBuffer.toString("base64"); - app.log.info(`Sending image ${filename} to webhook: ${webhook}`); - fetch(webhook, { + } + ) + .catch(async (e: any) => { + /** + * Send a webhook reporting that the generation failed. + */ + app.log.error(`Failed to generate images: ${e.message}`); + try { + const resp = await fetch(webhook, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - event: "output.complete", - image: base64File, + event: "prompt.failed", id, - filename, prompt, + error: e.message, }), - }) - .catch((e: any) => { - app.log.error( - `Failed to send image to webhook: ${e.message}` - ); - }) - .then(async (resp) => { - if (!resp) { - app.log.error("No response from webhook"); - } else if (!resp.ok) { - app.log.error( - `Failed to send image ${filename}: ${await resp.text()}` - ); - } else { - app.log.info(`Sent image ${filename}`); - } - }); + }); - // Remove the file after sending - fsPromises.unlink(path.join(config.outputDir, originalFilename)); + if (!resp.ok) { + app.log.error( + `Failed to send failure message to webhook: ${await resp.text()}` + ); + } + } catch (e: any) { + app.log.error( + `Failed to send failure message to webhook: ${e.message}` + ); } - } - ); + }); return reply.code(202).send({ status: "ok", id, webhook, prompt }); } else { /** From b580bc6ce9be0007065d5fdaff3348f3557a93b2 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 10:15:33 -0500 Subject: [PATCH 02/21] split out comfy utils --- src/comfy.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 155 +------------------------------------------------ 2 files changed, 160 insertions(+), 154 deletions(-) create mode 100644 src/comfy.ts diff --git a/src/comfy.ts b/src/comfy.ts new file mode 100644 index 0000000..5e2503c --- /dev/null +++ b/src/comfy.ts @@ -0,0 +1,159 @@ +import { sleep } from "./utils"; +import config from "./config"; +import { CommandExecutor } from "./commands"; +import { FastifyBaseLogger } from "fastify"; +import { ComfyPrompt } from "./types"; +import path from "path"; +import fsPromises from "fs/promises"; + +const commandExecutor = new CommandExecutor(); + +export function launchComfyUI() { + const cmdAndArgs = config.comfyLaunchCmd.split(" "); + const cmd = cmdAndArgs[0]; + const args = cmdAndArgs.slice(1); + return commandExecutor.execute(cmd, args, { + DIRECT_ADDRESS: config.comfyHost, + COMFYUI_PORT_HOST: config.comfyPort, + WEB_ENABLE_AUTH: "false", + CF_QUICK_TUNNELS: "false", + }); +} + +export function shutdownComfyUI() { + commandExecutor.interrupt(); +} + +export async function pingComfyUI(): Promise { + const res = await fetch(config.comfyURL); + if (!res.ok) { + throw new Error(`Failed to ping Comfy UI: ${await res.text()}`); + } +} + +export async function waitForComfyUIToStart( + log: FastifyBaseLogger +): Promise { + let retries = 0; + while (retries < config.startupCheckMaxTries) { + try { + await pingComfyUI(); + log.info("Comfy UI started"); + return; + } catch (e) { + // Ignore + } + retries++; + await sleep(config.startupCheckInterval); + } + + throw new Error( + `Comfy UI did not start after ${ + (config.startupCheckInterval / 1000) * config.startupCheckMaxTries + } seconds` + ); +} + +export async function warmupComfyUI(): Promise { + if (config.warmupPrompt) { + const resp = await fetch(`http://localhost:${config.wrapperPort}/prompt`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ prompt: config.warmupPrompt }), + }); + if (!resp.ok) { + throw new Error(`Failed to warmup Comfy UI: ${await resp.text()}`); + } + } +} + +export async function queuePrompt(prompt: ComfyPrompt): Promise { + const resp = await fetch(`${config.comfyURL}/prompt`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ prompt }), + }); + if (!resp.ok) { + throw new Error(`Failed to queue prompt: ${await resp.text()}`); + } + const { prompt_id } = await resp.json(); + return prompt_id; +} + +export async function getPromptOutputs( + promptId: string, + log: FastifyBaseLogger +): Promise | null> { + const resp = await fetch(`${config.comfyURL}/history/${promptId}`); + if (!resp.ok) { + throw new Error(`Failed to get prompt outputs: ${await resp.text()}`); + } + const body = await resp.json(); + const allOutputs: Record = {}; + const fileLoadPromises: Promise[] = []; + if (!body[promptId]) { + return null; + } + const { status, outputs } = body[promptId]; + if (status.completed) { + for (const nodeId in outputs) { + const node = outputs[nodeId]; + for (const outputType in node) { + for (let outputFile of node[outputType]) { + const filename = outputFile.filename; + if (!filename) { + /** + * Some nodes have fields in the outputs that are not actual files. + * For example, the SaveAnimatedWebP node has a field called "animated" + * that only container boolean values mapping to the files present in + * .images. We can safely ignore these. + */ + continue; + } + const filepath = path.join(config.outputDir, filename); + fileLoadPromises.push( + fsPromises + .readFile(filepath) + .then((data) => { + allOutputs[filename] = data; + }) + .catch((e: any) => { + /** + * The most likely reason for this is a node that has an optonal + * output. If the node doesn't produce that output, the file won't + * exist. + */ + log.warn(`Failed to read file ${filepath}: ${e.message}`); + }) + ); + } + } + } + } else if (status.status_str === "error") { + throw new Error("Prompt execution failed"); + } else { + console.log(JSON.stringify(status, null, 2)); + throw new Error("Prompt is not completed"); + } + await Promise.all(fileLoadPromises); + return allOutputs; +} + +export async function runPromptAndGetOutputs( + prompt: ComfyPrompt, + log: FastifyBaseLogger +): Promise> { + const promptId = await queuePrompt(prompt); + log.info(`Prompt queued with ID: ${promptId}`); + while (true) { + const outputs = await getPromptOutputs(promptId, log); + if (outputs) { + return outputs; + } + await sleep(50); + } +} diff --git a/src/utils.ts b/src/utils.ts index 9789dc3..4162eb2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,5 @@ import config from "./config"; import { FastifyBaseLogger } from "fastify"; -import { CommandExecutor } from "./commands"; import fs from "fs"; import fsPromises from "fs/promises"; import { Readable } from "stream"; @@ -8,75 +7,12 @@ import path from "path"; import { randomUUID } from "crypto"; import { ZodObject, ZodRawShape, ZodTypeAny, ZodDefault } from "zod"; import sharp from "sharp"; -import { ComfyPrompt, OutputConversionOptions } from "./types"; - -const commandExecutor = new CommandExecutor(); +import { OutputConversionOptions } from "./types"; export async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function launchComfyUI() { - const cmdAndArgs = config.comfyLaunchCmd.split(" "); - const cmd = cmdAndArgs[0]; - const args = cmdAndArgs.slice(1); - return commandExecutor.execute(cmd, args, { - DIRECT_ADDRESS: config.comfyHost, - COMFYUI_PORT_HOST: config.comfyPort, - WEB_ENABLE_AUTH: "false", - CF_QUICK_TUNNELS: "false", - }); -} - -export function shutdownComfyUI() { - commandExecutor.interrupt(); -} - -export async function pingComfyUI(): Promise { - const res = await fetch(config.comfyURL); - if (!res.ok) { - throw new Error(`Failed to ping Comfy UI: ${await res.text()}`); - } -} - -export async function waitForComfyUIToStart( - log: FastifyBaseLogger -): Promise { - let retries = 0; - while (retries < config.startupCheckMaxTries) { - try { - await pingComfyUI(); - log.info("Comfy UI started"); - return; - } catch (e) { - // Ignore - } - retries++; - await sleep(config.startupCheckInterval); - } - - throw new Error( - `Comfy UI did not start after ${ - (config.startupCheckInterval / 1000) * config.startupCheckMaxTries - } seconds` - ); -} - -export async function warmupComfyUI(): Promise { - if (config.warmupPrompt) { - const resp = await fetch(`http://localhost:${config.wrapperPort}/prompt`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ prompt: config.warmupPrompt }), - }); - if (!resp.ok) { - throw new Error(`Failed to warmup Comfy UI: ${await resp.text()}`); - } - } -} - export async function downloadImage( imageUrl: string, outputPath: string, @@ -268,92 +204,3 @@ export async function convertImageBuffer( return image.toBuffer(); } - -export async function queuePrompt(prompt: ComfyPrompt): Promise { - const resp = await fetch(`${config.comfyURL}/prompt`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ prompt }), - }); - if (!resp.ok) { - throw new Error(`Failed to queue prompt: ${await resp.text()}`); - } - const { prompt_id } = await resp.json(); - return prompt_id; -} - -export async function getPromptOutputs( - promptId: string, - log: FastifyBaseLogger -): Promise | null> { - const resp = await fetch(`${config.comfyURL}/history/${promptId}`); - if (!resp.ok) { - throw new Error(`Failed to get prompt outputs: ${await resp.text()}`); - } - const body = await resp.json(); - const allOutputs: Record = {}; - const fileLoadPromises: Promise[] = []; - if (!body[promptId]) { - return null; - } - const { status, outputs } = body[promptId]; - if (status.completed) { - for (const nodeId in outputs) { - const node = outputs[nodeId]; - for (const outputType in node) { - for (let outputFile of node[outputType]) { - const filename = outputFile.filename; - if (!filename) { - /** - * Some nodes have fields in the outputs that are not actual files. - * For example, the SaveAnimatedWebP node has a field called "animated" - * that only container boolean values mapping to the files present in - * .images. We can safely ignore these. - */ - continue; - } - const filepath = path.join(config.outputDir, filename); - fileLoadPromises.push( - fsPromises - .readFile(filepath) - .then((data) => { - allOutputs[filename] = data; - }) - .catch((e: any) => { - /** - * The most likely reason for this is a node that has an optonal - * output. If the node doesn't produce that output, the file won't - * exist. - */ - log.warn(`Failed to read file ${filepath}: ${e.message}`); - }) - ); - } - } - } - } else if (status.status_str === "error") { - throw new Error("Prompt execution failed"); - } else { - console.log(JSON.stringify(status, null, 2)); - throw new Error("Prompt is not completed"); - } - await Promise.all(fileLoadPromises); - return allOutputs; -} - -export async function runPromptAndGetOutputs( - prompt: ComfyPrompt, - log: FastifyBaseLogger -): Promise> { - const promptId = await queuePrompt(prompt); - log.info(`Prompt queued with ID: ${promptId}`); - while (true) { - const outputs = await getPromptOutputs(promptId, log); - if (outputs) { - return outputs; - } - await sleep(50); - } -} From c56702c342492c07cf85a068a5d10419cebc0f15 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 10:15:45 -0500 Subject: [PATCH 03/21] configurable log levels --- src/config.ts | 70 ++++++++++++++++++++++++++++++--------------------- src/server.ts | 10 +++----- 2 files changed, 45 insertions(+), 35 deletions(-) diff --git a/src/config.ts b/src/config.ts index c820f38..78acf56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,24 +5,25 @@ import { randomUUID } from "node:crypto"; import { execSync } from "child_process"; import { z } from "zod"; const { + ALWAYS_RESTART_COMFYUI = "false", + BASE = "ai-dock", CMD = "init.sh", + COMFY_HOME = "/opt/ComfyUI", + COMFYUI_PORT_HOST = "8188", + DIRECT_ADDRESS = "127.0.0.1", HOST = "::", + INPUT_DIR, + LOG_LEVEL = "info", + MARKDOWN_SCHEMA_DESCRIPTIONS = "true", + MAX_BODY_SIZE_MB = "100", + MODEL_DIR, + OUTPUT_DIR, PORT = "3000", - DIRECT_ADDRESS = "127.0.0.1", - COMFYUI_PORT_HOST = "8188", STARTUP_CHECK_INTERVAL_S = "1", STARTUP_CHECK_MAX_TRIES = "10", - COMFY_HOME = "/opt/ComfyUI", - OUTPUT_DIR, - INPUT_DIR, - MODEL_DIR, + SYSTEM_WEBHOOK, WARMUP_PROMPT_FILE, - WORKFLOW_MODELS = "all", WORKFLOW_DIR = "/workflows", - MARKDOWN_SCHEMA_DESCRIPTIONS = "true", - BASE = "ai-dock", - MAX_BODY_SIZE_MB = "100", - ALWAYS_RESTART_COMFYUI = "false", } = process.env; fs.mkdirSync(WORKFLOW_DIR, { recursive: true }); @@ -36,6 +37,16 @@ const startupCheckInterval = parseInt(STARTUP_CHECK_INTERVAL_S, 10) * 1000; const startupCheckMaxTries = parseInt(STARTUP_CHECK_MAX_TRIES, 10); const maxBodySize = parseInt(MAX_BODY_SIZE_MB, 10) * 1024 * 1024; const alwaysRestartComfyUI = ALWAYS_RESTART_COMFYUI.toLowerCase() === "true"; +const systemWebhook = SYSTEM_WEBHOOK ?? ""; + +if (systemWebhook) { + try { + const webhook = new URL(systemWebhook); + assert(webhook.protocol === "http:" || webhook.protocol === "https:"); + } catch (e: any) { + throw new Error(`Invalid system webhook: ${e.message}`); + } +} const loadEnvCommand: Record = { "ai-dock": `source /opt/ai-dock/etc/environment.sh \ @@ -128,27 +139,17 @@ with open("${temptComfyFilePath}", "w") as f: const comfyDescription = getComfyUIDescription(); const config = { - comfyLaunchCmd: CMD, - wrapperHost: HOST, - wrapperPort: port, - selfURL, - maxBodySize, + alwaysRestartComfyUI, + comfyDir, comfyHost: DIRECT_ADDRESS, + comfyLaunchCmd: CMD, comfyPort: COMFYUI_PORT_HOST, comfyURL, - alwaysRestartComfyUI, - wsClientId, comfyWSURL, - startupCheckInterval, - startupCheckMaxTries, - comfyDir, - outputDir: OUTPUT_DIR ?? path.join(comfyDir, "output"), inputDir: INPUT_DIR ?? path.join(comfyDir, "input"), - workflowDir: WORKFLOW_DIR, - warmupPrompt, - warmupCkpt, - samplers: z.enum(comfyDescription.samplers as [string, ...string[]]), - schedulers: z.enum(comfyDescription.schedulers as [string, ...string[]]), + logLevel: LOG_LEVEL.toLowerCase(), + markdownSchemaDescriptions: MARKDOWN_SCHEMA_DESCRIPTIONS === "true", + maxBodySize, models: {} as Record< string, { @@ -157,8 +158,19 @@ const config = { enum: z.ZodEnum<[string, ...string[]]>; } >, - workflowModels: WORKFLOW_MODELS, - markdownSchemaDescriptions: MARKDOWN_SCHEMA_DESCRIPTIONS === "true", + outputDir: OUTPUT_DIR ?? path.join(comfyDir, "output"), + samplers: z.enum(comfyDescription.samplers as [string, ...string[]]), + schedulers: z.enum(comfyDescription.schedulers as [string, ...string[]]), + selfURL, + startupCheckInterval, + startupCheckMaxTries, + systemWebhook, + warmupCkpt, + warmupPrompt, + workflowDir: WORKFLOW_DIR, + wrapperHost: HOST, + wrapperPort: port, + wsClientId, }; const modelDir = MODEL_DIR ?? path.join(comfyDir, "models"); diff --git a/src/server.ts b/src/server.ts index d169abf..76f3b8f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,16 +11,14 @@ import fsPromises from "fs/promises"; import path from "path"; import { version } from "../package.json"; import config from "./config"; +import { processImage, zodToMarkdownTable, convertImageBuffer } from "./utils"; import { warmupComfyUI, waitForComfyUIToStart, launchComfyUI, shutdownComfyUI, - processImage, - zodToMarkdownTable, - convertImageBuffer, runPromptAndGetOutputs, -} from "./utils"; +} from "./comfy"; import { PromptRequestSchema, PromptErrorResponseSchema, @@ -37,7 +35,7 @@ import { randomUUID } from "crypto"; const server = Fastify({ bodyLimit: config.maxBodySize, - logger: true, + logger: { level: config.logLevel }, }); server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); @@ -456,7 +454,7 @@ server.after(() => { /** * Workflow endpoints expose a simpler API to users, and then perform the transformation - * to a ComfyUI prompt behind the scenes. These endpoints behind-the-scenes just call the /prompt + * to a ComfyUI prompt behind the scenes. These endpoints under the hood just call the /prompt * endpoint with the appropriate parameters. */ app.post<{ From f579ce2e6fa96fe1db096bc2abf7698f166d272b Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 10:43:14 -0500 Subject: [PATCH 04/21] log websocket events --- src/comfy.ts | 20 ++++++++++++++++++++ src/server.ts | 9 ++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/comfy.ts b/src/comfy.ts index 5e2503c..9013bc4 100644 --- a/src/comfy.ts +++ b/src/comfy.ts @@ -5,6 +5,7 @@ import { FastifyBaseLogger } from "fastify"; import { ComfyPrompt } from "./types"; import path from "path"; import fsPromises from "fs/promises"; +import { Message, client as WebSocketClient } from "websocket"; const commandExecutor = new CommandExecutor(); @@ -157,3 +158,22 @@ export async function runPromptAndGetOutputs( await sleep(50); } } + +export function getComfyUIWebsocketStream( + onMessage: (msg: Message) => Promise, + log: FastifyBaseLogger +): Promise { + const client = new WebSocketClient(); + client.connect(config.comfyWSURL); + return new Promise((resolve, reject) => { + client.on("connect", (connection) => { + log.info("Connected to Comfy UI websocket"); + connection.on("message", onMessage); + resolve(); + }); + client.on("connectFailed", (error) => { + log.error(`Failed to connect to Comfy UI websocket: ${error}`); + reject(error); + }); + }); +} diff --git a/src/server.ts b/src/server.ts index 76f3b8f..48a8e8c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { launchComfyUI, shutdownComfyUI, runPromptAndGetOutputs, + getComfyUIWebsocketStream, } from "./comfy"; import { PromptRequestSchema, @@ -32,6 +33,7 @@ import { import workflows from "./workflows"; import { z } from "zod"; import { randomUUID } from "crypto"; +import { Message } from "websocket"; const server = Fastify({ bodyLimit: config.maxBodySize, @@ -543,9 +545,14 @@ export async function start() { const start = Date.now(); // Start ComfyUI await launchComfyUIAndAPIServerAndWaitForWarmup(); - const warmupTime = Date.now() - start; server.log.info(`Warmup took ${warmupTime / 1000}s`); + await getComfyUIWebsocketStream(async (data: Message) => { + server.log.info(`Received ${data.type} message`); + if (data.type === "utf8") { + console.log(data.utf8Data); + } + }, server.log); } catch (err: any) { server.log.error(`Failed to start server: ${err.message}`); process.exit(1); From 085e9daeb0b097dc38101bde861ec959e2ed893b Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 10:43:22 -0500 Subject: [PATCH 05/21] update dependencies --- package-lock.json | 491 ++++++++++++++++++++++++++++++++-------------- package.json | 2 + 2 files changed, 351 insertions(+), 142 deletions(-) diff --git a/package-lock.json b/package-lock.json index 526a529..cfe18ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "fastify-type-provider-zod": "^2.0.0", "sharp": "^0.33.5", "typescript": "^5.4.5", + "websocket": "^1.0.35", "zod": "^3.23.8" }, "bin": { @@ -27,6 +28,7 @@ "@types/chokidar": "^2.1.3", "@types/mocha": "^10.0.10", "@types/node": "^20.12.7", + "@types/websocket": "^1.0.10", "@yao-pkg/pkg": "^6.1.0", "earl": "^1.3.0", "minimist": "^1.2.8", @@ -50,9 +52,9 @@ } }, "node_modules/@anthropic-ai/sdk/node_modules/@types/node": { - "version": "18.19.63", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.63.tgz", - "integrity": "sha512-hcUB7THvrGmaEcPcvUZCZtQ2Z3C+UR/aOcraBLCvTsFMh916Gc1kCCYcfcMuB76HM2pSerxl1PoP3KnmHzd9Lw==", + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -65,13 +67,13 @@ "dev": true }, "node_modules/@babel/generator": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", - "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", + "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", "dev": true, "dependencies": { - "@babel/parser": "^7.26.2", - "@babel/types": "^7.26.0", + "@babel/parser": "^7.26.5", + "@babel/types": "^7.26.5", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -99,12 +101,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", - "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.5.tgz", + "integrity": "sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==", "dev": true, "dependencies": { - "@babel/types": "^7.26.0" + "@babel/types": "^7.26.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -114,9 +116,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", - "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "version": "7.26.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.5.tgz", + "integrity": "sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -616,9 +618,9 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -721,34 +723,43 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.17.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.5.tgz", - "integrity": "sha512-n8FYY/pRxu496441gIcAQFZPKXbhsd6VZygcq+PTSZ75eMh/Ke0hCAROdUa21qiFqKNsPPYic46yXDO1JGiPBQ==", + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", "dev": true, "dependencies": { "undici-types": "~6.19.2" } }, "node_modules/@types/node-fetch": { - "version": "2.6.11", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", - "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==", "dev": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.0" } }, + "node_modules/@types/websocket": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.10.tgz", + "integrity": "sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@yao-pkg/pkg": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.1.0.tgz", - "integrity": "sha512-77wFdRj3fWlWdCsF+vCYYzTHClvSEZvFJ+yZ98/Jr3D0VTsrd1tIpxJRnhS6xvbV9UEPM+IXPSC1qXVD6mOH+w==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-6.2.0.tgz", + "integrity": "sha512-kq1aDs9aa+fEtKQQ2AsxcL4Z82LsYw9ZQIwD3Q/wDq8ZPN69wCf2+OQp271lnqMybYInXwwBJ3swIb/nvaXS/g==", "dev": true, "dependencies": { "@babel/generator": "^7.23.0", "@babel/parser": "^7.23.0", "@babel/types": "^7.23.0", - "@yao-pkg/pkg-fetch": "3.5.17", + "@yao-pkg/pkg-fetch": "3.5.18", "into-stream": "^6.0.0", "minimist": "^1.2.6", "multistream": "^4.1.0", @@ -769,9 +780,9 @@ } }, "node_modules/@yao-pkg/pkg-fetch": { - "version": "3.5.17", - "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.17.tgz", - "integrity": "sha512-2gD2K8JUwHwvFFZbwVXwmm90P0U3s8Kqiym4w7t2enTajH28LMhpXaqh/x+SzKeNwvPGoaRUhV0h2nPtWTDoDA==", + "version": "3.5.18", + "resolved": "https://registry.npmjs.org/@yao-pkg/pkg-fetch/-/pkg-fetch-3.5.18.tgz", + "integrity": "sha512-tdUT7zS2lyXeJwkA8lDI4aVxHwauAc5lKj6Xui3/BtDe6vDsQ8KP+f66u07AI28DuTzKxjRJKNNXVdyGv2Ndsg==", "dev": true, "dependencies": { "https-proxy-agent": "^5.0.0", @@ -786,18 +797,6 @@ "pkg-fetch": "lib-es5/bin.js" } }, - "node_modules/@yao-pkg/pkg/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -852,9 +851,9 @@ } }, "node_modules/agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", "dev": true, "dependencies": { "humanize-ms": "^1.2.1" @@ -895,9 +894,19 @@ } }, "node_modules/ajv/node_modules/fast-uri": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", - "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", + "integrity": "sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -945,6 +954,17 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -1096,6 +1116,18 @@ "ieee754": "^1.1.13" } }, + "node_modules/bufferutil": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", + "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -1330,10 +1362,22 @@ "node": ">= 8" } }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "dependencies": { "ms": "^2.1.3" }, @@ -1462,6 +1506,43 @@ "stackframe": "^1.3.4" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1488,6 +1569,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1506,6 +1610,14 @@ "node": ">=6" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -1573,9 +1685,9 @@ "integrity": "sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==" }, "node_modules/fastify": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.28.1.tgz", - "integrity": "sha512-kFWUtpNr4i7t5vY2EJPCN2KgMVpuqfU4NjnJNCgiNB900oiDeYqaNDRcAfeBbOF5hGixixxcKnOU4KN9z6QncQ==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.29.0.tgz", + "integrity": "sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==", "funding": [ { "type": "github", @@ -1623,13 +1735,27 @@ } }, "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz", + "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==", "dependencies": { "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1752,9 +1878,9 @@ "dev": true }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", "dev": true, "dependencies": { "graceful-fs": "^4.2.0", @@ -1977,9 +2103,9 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "dependencies": { "hasown": "^2.0.2" @@ -2035,6 +2161,11 @@ "node": ">=8" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -2085,9 +2216,9 @@ } }, "node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "bin": { "jsesc": "bin/jsesc" @@ -2345,18 +2476,6 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2406,10 +2525,15 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "dev": true }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, "node_modules/node-abi": { - "version": "3.71.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.71.0.tgz", - "integrity": "sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==", + "version": "3.73.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.73.0.tgz", + "integrity": "sha512-z8iYzQGBu35ZkTQ9mtR8RqugJZ9RCLn8fv3d7LsgDBzOijGQP3RdKTX4LA7LXw03ZhU5z0l4xfhIMgSES31+cg==", "dev": true, "dependencies": { "semver": "^7.3.5" @@ -2457,6 +2581,16 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -2582,20 +2716,21 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pino": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.5.0.tgz", - "integrity": "sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", + "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -2627,9 +2762,19 @@ "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" }, "node_modules/pino/node_modules/process-warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.0.tgz", - "integrity": "sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==" + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] }, "node_modules/prebuild-install": { "version": "7.1.2", @@ -2736,6 +2881,15 @@ "rc": "cli.js" } }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -2768,6 +2922,17 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2794,18 +2959,21 @@ } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", "dev": true, "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3181,12 +3349,15 @@ } }, "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/supports-color": { @@ -3234,9 +3405,9 @@ } }, "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", "dev": true, "dependencies": { "chownr": "^1.1.1", @@ -3302,32 +3473,6 @@ "node": ">=12.0.0" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", - "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", - "dev": true, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3431,10 +3576,23 @@ "node": "*" } }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3479,6 +3637,18 @@ "punycode": "^2.1.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3506,6 +3676,35 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3632,6 +3831,14 @@ "node": ">=10" } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -3642,9 +3849,9 @@ } }, "node_modules/yaml": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", - "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", + "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", "bin": { "yaml": "bin.mjs" }, @@ -3757,19 +3964,19 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-to-json-schema": { - "version": "3.23.5", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.5.tgz", - "integrity": "sha512-5wlSS0bXfF/BrL4jPAbz9da5hDlDptdEppYfe+x4eIJ7jioqKG9uUxOwPzqof09u/XeVdrgFu29lZi+8XNDJtA==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.1.tgz", + "integrity": "sha512-3h08nf3Vw3Wl3PK+q3ow/lIil81IT2Oa7YpQyUUDsEWbXveMesdfK1xBd2RhCkynwZndAxixji/7SYJJowr62w==", "peerDependencies": { - "zod": "^3.23.3" + "zod": "^3.24.1" } } } diff --git a/package.json b/package.json index 34e5f09..9a48aaf 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@types/chokidar": "^2.1.3", "@types/mocha": "^10.0.10", "@types/node": "^20.12.7", + "@types/websocket": "^1.0.10", "@yao-pkg/pkg": "^6.1.0", "earl": "^1.3.0", "minimist": "^1.2.8", @@ -33,6 +34,7 @@ "fastify-type-provider-zod": "^2.0.0", "sharp": "^0.33.5", "typescript": "^5.4.5", + "websocket": "^1.0.35", "zod": "^3.23.8" }, "pkg": { From 9c26c699b66fe61fcc6785c04c97c4cca824918d Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 13:37:14 -0500 Subject: [PATCH 06/21] wire up comfy events to emit webhooks --- src/comfy.ts | 86 ++++++++++++++++++--- src/config.ts | 34 ++++++++ src/server.ts | 26 ++++--- src/types.ts | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 65 +++++++++++++++- 5 files changed, 398 insertions(+), 22 deletions(-) diff --git a/src/comfy.ts b/src/comfy.ts index 9013bc4..5640bf4 100644 --- a/src/comfy.ts +++ b/src/comfy.ts @@ -2,7 +2,20 @@ import { sleep } from "./utils"; import config from "./config"; import { CommandExecutor } from "./commands"; import { FastifyBaseLogger } from "fastify"; -import { ComfyPrompt } from "./types"; +import { + ComfyPrompt, + ComfyWSMessage, + isStatusMessage, + isProgressMessage, + isExecutionStartMessage, + isExecutionCachedMessage, + isExecutedMessage, + isExecutionSuccessMessage, + isExecutingMessage, + isExecutionInterruptedMessage, + isExecutionErrorMessage, + WebhookHandlers, +} from "./types"; import path from "path"; import fsPromises from "fs/promises"; import { Message, client as WebSocketClient } from "websocket"; @@ -76,7 +89,7 @@ export async function queuePrompt(prompt: ComfyPrompt): Promise { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ prompt }), + body: JSON.stringify({ prompt, client_id: config.wsClientId }), }); if (!resp.ok) { throw new Error(`Failed to queue prompt: ${await resp.text()}`); @@ -144,12 +157,16 @@ export async function getPromptOutputs( return allOutputs; } +export const comfyIDToApiID: Record = {}; + export async function runPromptAndGetOutputs( + id: string, prompt: ComfyPrompt, log: FastifyBaseLogger ): Promise> { const promptId = await queuePrompt(prompt); - log.info(`Prompt queued with ID: ${promptId}`); + comfyIDToApiID[promptId] = id; + log.info(`Prompt ${id} queued as comfy prompt id: ${promptId}`); while (true) { const outputs = await getPromptOutputs(promptId, log); if (outputs) { @@ -159,16 +176,67 @@ export async function runPromptAndGetOutputs( } } -export function getComfyUIWebsocketStream( - onMessage: (msg: Message) => Promise, - log: FastifyBaseLogger +export function connectToComfyUIWebsocketStream( + hooks: WebhookHandlers, + log: FastifyBaseLogger, + useApiIDs: boolean = true ): Promise { - const client = new WebSocketClient(); - client.connect(config.comfyWSURL); return new Promise((resolve, reject) => { + const client = new WebSocketClient(); + client.connect(config.comfyWSURL); client.on("connect", (connection) => { log.info("Connected to Comfy UI websocket"); - connection.on("message", onMessage); + connection.on("message", (data) => { + if (hooks.onMessage) { + hooks.onMessage(data); + } + if (data.type === "utf8") { + const message = JSON.parse(data.utf8Data) as ComfyWSMessage; + if ( + useApiIDs && + message.data.prompt_id && + comfyIDToApiID[message.data.prompt_id] + ) { + message.data.prompt_id = comfyIDToApiID[message.data.prompt_id]; + } + if (isStatusMessage(message) && hooks.onStatus) { + hooks.onStatus(message); + } else if (isProgressMessage(message) && hooks.onProgress) { + hooks.onProgress(message); + } else if ( + isExecutionStartMessage(message) && + hooks.onExecutionStart + ) { + hooks.onExecutionStart(message); + } else if ( + isExecutionCachedMessage(message) && + hooks.onExecutionCached + ) { + hooks.onExecutionCached(message); + } else if (isExecutingMessage(message) && hooks.onExecuting) { + hooks.onExecuting(message); + } else if (isExecutedMessage(message) && hooks.onExecuted) { + hooks.onExecuted(message); + } else if ( + isExecutionSuccessMessage(message) && + hooks.onExecutionSuccess + ) { + hooks.onExecutionSuccess(message); + } else if ( + isExecutionInterruptedMessage(message) && + hooks.onExecutionInterrupted + ) { + hooks.onExecutionInterrupted(message); + } else if ( + isExecutionErrorMessage(message) && + hooks.onExecutionError + ) { + hooks.onExecutionError(message); + } + } else { + log.info(`Received ${data.type} message`); + } + }); resolve(); }); client.on("connectFailed", (error) => { diff --git a/src/config.ts b/src/config.ts index 78acf56..c9ad1f3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -19,9 +19,12 @@ const { MODEL_DIR, OUTPUT_DIR, PORT = "3000", + SALAD_MACHINE_ID, + SALAD_CONTAINER_GROUP_ID, STARTUP_CHECK_INTERVAL_S = "1", STARTUP_CHECK_MAX_TRIES = "10", SYSTEM_WEBHOOK, + SYSTEM_WEBHOOK_EVENTS, WARMUP_PROMPT_FILE, WORKFLOW_DIR = "/workflows", } = process.env; @@ -48,6 +51,26 @@ if (systemWebhook) { } } +const allEvents = new Set([ + "message", + "status", + "progress", + "executing", + "execution_start", + "execution_cached", + "executed", + "execution_success", + "execution_interrupted", + "execution_error", +]); +const systemWebhookEvents = SYSTEM_WEBHOOK_EVENTS?.split(",") ?? []; +assert( + systemWebhookEvents.every((e) => allEvents.has(e)), + `Invalid system webhook events. Supported options: ${Array.from( + allEvents + ).join(", ")}` +); + const loadEnvCommand: Record = { "ai-dock": `source /opt/ai-dock/etc/environment.sh \ && source /opt/ai-dock/bin/venv-set.sh comfyui \ @@ -159,12 +182,16 @@ const config = { } >, outputDir: OUTPUT_DIR ?? path.join(comfyDir, "output"), + saladContainerGroupId: SALAD_CONTAINER_GROUP_ID, + saladMachineId: SALAD_MACHINE_ID, samplers: z.enum(comfyDescription.samplers as [string, ...string[]]), schedulers: z.enum(comfyDescription.schedulers as [string, ...string[]]), selfURL, startupCheckInterval, startupCheckMaxTries, + systemMetaData: {} as Record, systemWebhook, + systemWebhookEvents, warmupCkpt, warmupPrompt, workflowDir: WORKFLOW_DIR, @@ -189,4 +216,11 @@ for (const modelType of modelSubDirs) { } } +for (const varName of Object.keys(process.env)) { + if (varName.startsWith("SYSTEM_META_")) { + const key = varName.substring("SYSTEM_META_".length); + config.systemMetaData[key] = process.env[varName] ?? ""; + } +} + export default config; diff --git a/src/server.ts b/src/server.ts index 48a8e8c..139b745 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,14 +11,19 @@ import fsPromises from "fs/promises"; import path from "path"; import { version } from "../package.json"; import config from "./config"; -import { processImage, zodToMarkdownTable, convertImageBuffer } from "./utils"; +import { + processImage, + zodToMarkdownTable, + convertImageBuffer, + getConfiguredWebhookHandlers, +} from "./utils"; import { warmupComfyUI, waitForComfyUIToStart, launchComfyUI, shutdownComfyUI, runPromptAndGetOutputs, - getComfyUIWebsocketStream, + connectToComfyUIWebsocketStream, } from "./comfy"; import { PromptRequestSchema, @@ -33,7 +38,6 @@ import { import workflows from "./workflows"; import { z } from "zod"; import { randomUUID } from "crypto"; -import { Message } from "websocket"; const server = Fastify({ bodyLimit: config.maxBodySize, @@ -57,6 +61,7 @@ for (const modelType in config.models) { let warm = false; let wasEverWarm = false; +let queueDepth = 0; server.register(fastifySwagger, { openapi: { @@ -282,7 +287,7 @@ server.after(() => { /** * Send the prompt to ComfyUI, and return a 202 response to the user. */ - runPromptAndGetOutputs(prompt, app.log) + runPromptAndGetOutputs(id, prompt, app.log) .then( /** * This function does not block returning the 202 response to the user. @@ -392,7 +397,7 @@ server.after(() => { /** * Send the prompt to ComfyUI, and wait for the images to be generated. */ - const allOutputs = await runPromptAndGetOutputs(prompt, app.log); + const allOutputs = await runPromptAndGetOutputs(id, prompt, app.log); for (const originalFilename in allOutputs) { let fileBuffer = allOutputs[originalFilename]; let filename = originalFilename; @@ -547,12 +552,11 @@ export async function start() { await launchComfyUIAndAPIServerAndWaitForWarmup(); const warmupTime = Date.now() - start; server.log.info(`Warmup took ${warmupTime / 1000}s`); - await getComfyUIWebsocketStream(async (data: Message) => { - server.log.info(`Received ${data.type} message`); - if (data.type === "utf8") { - console.log(data.utf8Data); - } - }, server.log); + await connectToComfyUIWebsocketStream( + getConfiguredWebhookHandlers(server.log), + server.log, + true + ); } catch (err: any) { server.log.error(`Failed to start server: ${err.message}`); process.exit(1); diff --git a/src/types.ts b/src/types.ts index 5743bc5..6316d9c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { randomUUID } from "crypto"; +import { Message } from "websocket"; export const ComfyNodeSchema = z.object({ inputs: z.any(), @@ -190,3 +191,211 @@ export const WorkflowResponseSchema = z.object({ convert_output: OutputConversionOptionsSchema.optional(), status: z.enum(["ok"]).optional(), }); + +export interface ComfyWSMessage { + type: + | "status" + | "progress" + | "executing" + | "execution_start" + | "execution_cached" + | "executed" + | "execution_success" + | "execution_interrupted" + | "execution_error"; + data: any; + sid: string | null; +} + +export interface ComfyWSStatusMessage extends ComfyWSMessage { + type: "status"; + data: { + status: { + exec_info: { + queue_remaining: number; + }; + }; + }; +} + +export interface ComfyWSProgressMessage extends ComfyWSMessage { + type: "progress"; + data: { + value: number; + max: number; + prompt_id: string; + node: string | null; + }; +} + +export interface ComfyWSExecutingMessage extends ComfyWSMessage { + type: "executing"; + data: { + node: string | null; + display_node: string; + prompt_id: string; + }; +} + +export interface ComfyWSExecutionStartMessage extends ComfyWSMessage { + type: "execution_start"; + data: { + prompt_id: string; + timestamp: number; + }; +} + +export interface ComfyWSExecutionCachedMessage extends ComfyWSMessage { + type: "execution_cached"; + data: { + nodes: string[]; + prompt_id: string; + timestamp: number; + }; +} + +export interface ComfyWSExecutedMessage extends ComfyWSMessage { + type: "executed"; + data: { + node: string; + display_node: string; + output: any; + prompt_id: string; + }; +} + +export interface ComfyWSExecutionSuccessMessage extends ComfyWSMessage { + type: "execution_success"; + data: { + prompt_id: string; + timestamp: number; + }; +} + +export interface ComfyWSExecutionInterruptedMessage extends ComfyWSMessage { + type: "execution_interrupted"; + data: { + prompt_id: string; + node_id: string; + node_type: string; + executed: any[]; + }; +} + +export interface ComfyWSExecutionErrorMessage extends ComfyWSMessage { + type: "execution_error"; + data: { + prompt_id: string; + node_id: string; + node_type: string; + executed: any[]; + exception_message: string; + exception_type: string; + traceback: string; + current_inputs: any; + current_outputs: any[]; + }; +} + +export function isStatusMessage( + msg: ComfyWSMessage +): msg is ComfyWSStatusMessage { + return msg.type === "status"; +} + +export function isProgressMessage( + msg: ComfyWSMessage +): msg is ComfyWSProgressMessage { + return msg.type === "progress"; +} + +export function isExecutingMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutingMessage { + return msg.type === "executing"; +} + +export function isExecutionStartMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutionStartMessage { + return msg.type === "execution_start"; +} + +export function isExecutionCachedMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutionCachedMessage { + return msg.type === "execution_cached"; +} + +export function isExecutedMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutedMessage { + return msg.type === "executed"; +} + +export function isExecutionSuccessMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutionSuccessMessage { + return msg.type === "execution_success"; +} + +export function isExecutionInterruptedMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutionInterruptedMessage { + return msg.type === "execution_interrupted"; +} + +export function isExecutionErrorMessage( + msg: ComfyWSMessage +): msg is ComfyWSExecutionErrorMessage { + return msg.type === "execution_error"; +} + +export type WebhookHandlers = { + onMessage?: (msg: Message) => Promise | void; + onStatus?: (data: ComfyWSStatusMessage) => Promise | void; + onProgress?: (data: ComfyWSProgressMessage) => Promise | void; + onExecuting?: (data: ComfyWSExecutingMessage) => Promise | void; + onExecutionStart?: ( + data: ComfyWSExecutionStartMessage + ) => Promise | void; + onExecutionCached?: ( + data: ComfyWSExecutionCachedMessage + ) => Promise | void; + onExecuted?: (data: ComfyWSExecutedMessage) => Promise | void; + onExecutionSuccess?: (data: ComfyWSExecutionSuccessMessage) => Promise; + onExecutionError?: ( + data: ComfyWSExecutionErrorMessage + ) => Promise | void; + onExecutionInterrupted?: ( + data: ComfyWSExecutionInterruptedMessage + ) => Promise | void; +}; + +export const SystemWebhookEvents = [ + "message", + "status", + "progress", + "executing", + "execution_start", + "execution_cached", + "executed", + "execution_success", + "execution_interrupted", + "execution_error", +] as const; + +type Required = { + [P in keyof T]-?: T[P]; +}; + +type ValidWebhookHandlerName = keyof WebhookHandlers; + +const handler: Required = {} as Required; +const webhookHandlerKeys = Object.keys(handler) as ValidWebhookHandlerName[]; + +export function isValidWebhookHandlerName( + key: string +): key is ValidWebhookHandlerName { + return webhookHandlerKeys.includes(key as ValidWebhookHandlerName); +} diff --git a/src/utils.ts b/src/utils.ts index 4162eb2..7d70e23 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,7 +7,11 @@ import path from "path"; import { randomUUID } from "crypto"; import { ZodObject, ZodRawShape, ZodTypeAny, ZodDefault } from "zod"; import sharp from "sharp"; -import { OutputConversionOptions } from "./types"; +import { + isValidWebhookHandlerName, + OutputConversionOptions, + WebhookHandlers, +} from "./types"; export async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -39,7 +43,7 @@ export async function downloadImage( await new Promise((resolve, reject) => { Readable.fromWeb(body as any) .pipe(fileStream) - .on("finish", resolve) + .on("finish", () => resolve) .on("error", reject); }); @@ -204,3 +208,60 @@ export async function convertImageBuffer( return image.toBuffer(); } + +export async function sendSystemWebhook( + eventName: string, + data: any, + log: FastifyBaseLogger +): Promise { + const metadata: Record = { ...config.systemMetaData }; + if (config.saladContainerGroupId) { + metadata["salad_container_group_id"] = config.saladContainerGroupId; + } + if (config.saladMachineId) { + metadata["salad_machine_id"] = config.saladMachineId; + } + if (config.systemWebhook) { + try { + const response = await fetch(config.systemWebhook, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ event: eventName, data, metadata }), + }); + + if (!response.ok) { + log.error(`Failed to send system webhook: ${await response.text()}`); + } + } catch (error) { + log.error("Error sending system webhook:", error); + } + } +} + +function snakeCaseToCamelCase(str: string) { + return str.replace(/([-_]\w)/g, (g) => g[1].toUpperCase()); +} + +export function getConfiguredWebhookHandlers( + log: FastifyBaseLogger +): WebhookHandlers { + const handlers: Record void> = {}; + + if (config.systemWebhook) { + const systemWebhookEvents = config.systemWebhookEvents; + for (const eventName of systemWebhookEvents) { + const handlerName = `on${snakeCaseToCamelCase(eventName)}`; + if (!isValidWebhookHandlerName(handlerName)) { + log.error(`Invalid webhook handler name: ${handlerName}`); + continue; + } + handlers[handlerName] = (data: any) => { + sendSystemWebhook(`comfy.${eventName}`, data, log); + }; + } + } + + return handlers as WebhookHandlers; +} From d226620c405c7b52f4893551c7880b759c9a97bd Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 13:58:17 -0500 Subject: [PATCH 07/21] make sure to clear id map to avoid memory leak --- docker-compose.yml | 2 ++ src/comfy.ts | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7117a23..1124dc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: ports: - "3000:3000" - "8188:8188" + environment: + ALWAYS_RESTART_COMFYUI: "true" deploy: resources: reservations: diff --git a/src/comfy.ts b/src/comfy.ts index 5640bf4..5809351 100644 --- a/src/comfy.ts +++ b/src/comfy.ts @@ -18,7 +18,7 @@ import { } from "./types"; import path from "path"; import fsPromises from "fs/promises"; -import { Message, client as WebSocketClient } from "websocket"; +import { client as WebSocketClient } from "websocket"; const commandExecutor = new CommandExecutor(); @@ -170,6 +170,9 @@ export async function runPromptAndGetOutputs( while (true) { const outputs = await getPromptOutputs(promptId, log); if (outputs) { + sleep(1000).then(() => { + delete comfyIDToApiID[promptId]; + }); return outputs; } await sleep(50); From fff1fc7f0e954b2ec6289981bd6c747b7cb8e331 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 16 Jan 2025 14:37:14 -0500 Subject: [PATCH 08/21] version bump --- README.md | 2 +- docker/api.dockerfile | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e46f73a..fd022e6 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If you have your own ComfyUI dockerfile, you can add the comfyui-api server to i ```dockerfile # Change this to the version you want to use -ARG api_version=1.7.2 +ARG api_version=1.8.0 # Download the comfyui-api binary, and make it executable diff --git a/docker/api.dockerfile b/docker/api.dockerfile index bccb8e1..e67888a 100644 --- a/docker/api.dockerfile +++ b/docker/api.dockerfile @@ -7,7 +7,7 @@ FROM ghcr.io/saladtechnologies/comfyui-api:comfy${comfy_version}-torch${pytorch_ ENV WORKFLOW_DIR=/workflows ENV STARTUP_CHECK_MAX_TRIES=30 -ARG api_version=1.7.2 +ARG api_version=1.8.0 ADD https://github.com/SaladTechnologies/comfyui-api/releases/download/${api_version}/comfyui-api . RUN chmod +x comfyui-api diff --git a/package-lock.json b/package-lock.json index cfe18ba..a9de776 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "comfyui-api", - "version": "1.7.2", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "comfyui-api", - "version": "1.7.2", + "version": "1.8.0", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 9a48aaf..9ca38c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "comfyui-api", - "version": "1.7.2", + "version": "1.8.0", "description": "Wraps comfyui to make it easier to use as a stateless web service", "main": "dist/src/index.js", "scripts": { From 88b6cac98e72716613e30a5279315d5451c37ffe Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 10:25:28 -0500 Subject: [PATCH 09/21] use websocket to wait for prompt completion --- package-lock.json | 206 ++++++---------------------------------------- package.json | 4 +- src/comfy.ts | 161 +++++++++++++++++++++--------------- src/config.ts | 3 + src/server.ts | 27 +++++- src/types.ts | 4 +- 6 files changed, 150 insertions(+), 255 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9de776..57c88e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "fastify-type-provider-zod": "^2.0.0", "sharp": "^0.33.5", "typescript": "^5.4.5", - "websocket": "^1.0.35", + "ws": "^8.18.0", "zod": "^3.23.8" }, "bin": { @@ -28,7 +28,7 @@ "@types/chokidar": "^2.1.3", "@types/mocha": "^10.0.10", "@types/node": "^20.12.7", - "@types/websocket": "^1.0.10", + "@types/ws": "^8.5.13", "@yao-pkg/pkg": "^6.1.0", "earl": "^1.3.0", "minimist": "^1.2.8", @@ -741,10 +741,10 @@ "form-data": "^4.0.0" } }, - "node_modules/@types/websocket": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.10.tgz", - "integrity": "sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==", + "node_modules/@types/ws": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", + "integrity": "sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==", "dev": true, "dependencies": { "@types/node": "*" @@ -1116,18 +1116,6 @@ "ieee754": "^1.1.13" } }, - "node_modules/bufferutil": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.9.tgz", - "integrity": "sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -1362,18 +1350,6 @@ "node": ">= 8" } }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1506,43 +1482,6 @@ "stackframe": "^1.3.4" } }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1569,29 +1508,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1610,14 +1526,6 @@ "node": ">=6" } }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "dependencies": { - "type": "^2.7.2" - } - }, "node_modules/fast-content-type-parse": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz", @@ -2161,11 +2069,6 @@ "node": ">=8" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -2525,11 +2428,6 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", "dev": true }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" - }, "node_modules/node-abi": { "version": "3.73.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.73.0.tgz", @@ -2581,16 +2479,6 @@ } } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -3576,19 +3464,6 @@ "node": "*" } }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -3637,18 +3512,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3676,35 +3539,6 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, - "node_modules/websocket": { - "version": "1.0.35", - "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", - "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", - "dependencies": { - "bufferutil": "^4.0.1", - "debug": "^2.2.0", - "es5-ext": "^0.10.63", - "typedarray-to-buffer": "^3.1.5", - "utf-8-validate": "^5.0.2", - "yaeti": "^0.0.6" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/websocket/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/websocket/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -3822,6 +3656,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -3831,14 +3685,6 @@ "node": ">=10" } }, - "node_modules/yaeti": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", - "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", - "engines": { - "node": ">=0.10.32" - } - }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 9ca38c0..146ccb9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@types/chokidar": "^2.1.3", "@types/mocha": "^10.0.10", "@types/node": "^20.12.7", - "@types/websocket": "^1.0.10", + "@types/ws": "^8.5.13", "@yao-pkg/pkg": "^6.1.0", "earl": "^1.3.0", "minimist": "^1.2.8", @@ -34,7 +34,7 @@ "fastify-type-provider-zod": "^2.0.0", "sharp": "^0.33.5", "typescript": "^5.4.5", - "websocket": "^1.0.35", + "ws": "^8.18.0", "zod": "^3.23.8" }, "pkg": { diff --git a/src/comfy.ts b/src/comfy.ts index 5809351..50a5445 100644 --- a/src/comfy.ts +++ b/src/comfy.ts @@ -18,7 +18,7 @@ import { } from "./types"; import path from "path"; import fsPromises from "fs/promises"; -import { client as WebSocketClient } from "websocket"; +import WebSocket, { MessageEvent } from "ws"; const commandExecutor = new CommandExecutor(); @@ -157,6 +157,35 @@ export async function getPromptOutputs( return allOutputs; } +export async function waitForPromptToComplete(promptId: string): Promise { + return new Promise((resolve, reject) => { + const func = (event: MessageEvent) => { + const { data } = event; + if (typeof data === "string") { + const message = JSON.parse(data) as ComfyWSMessage; + if ( + isExecutionSuccessMessage(message) && + message.data.prompt_id === promptId + ) { + wsClient?.removeEventListener("message", func); + return resolve(); + } else if ( + isExecutionErrorMessage(message) && + message.data.prompt_id === promptId + ) { + return reject(new Error("Prompt execution failed")); + } else if ( + isExecutionInterruptedMessage(message) && + message.data.prompt_id === promptId + ) { + return reject(new Error("Prompt execution interrupted")); + } + } + }; + wsClient?.addEventListener("message", func); + }); +} + export const comfyIDToApiID: Record = {}; export async function runPromptAndGetOutputs( @@ -166,85 +195,81 @@ export async function runPromptAndGetOutputs( ): Promise> { const promptId = await queuePrompt(prompt); comfyIDToApiID[promptId] = id; - log.info(`Prompt ${id} queued as comfy prompt id: ${promptId}`); - while (true) { - const outputs = await getPromptOutputs(promptId, log); - if (outputs) { - sleep(1000).then(() => { - delete comfyIDToApiID[promptId]; - }); - return outputs; - } - await sleep(50); + log.debug(`Prompt ${id} queued as comfy prompt id: ${promptId}`); + await waitForPromptToComplete(promptId); + const outputs = await getPromptOutputs(promptId, log); + if (outputs) { + sleep(1000).then(() => { + delete comfyIDToApiID[promptId]; + }); + return outputs; } + throw new Error("Failed to get prompt outputs"); } +let wsClient: WebSocket | null = null; + export function connectToComfyUIWebsocketStream( hooks: WebhookHandlers, log: FastifyBaseLogger, useApiIDs: boolean = true -): Promise { +): Promise { return new Promise((resolve, reject) => { - const client = new WebSocketClient(); - client.connect(config.comfyWSURL); - client.on("connect", (connection) => { - log.info("Connected to Comfy UI websocket"); - connection.on("message", (data) => { - if (hooks.onMessage) { - hooks.onMessage(data); + wsClient = new WebSocket(config.comfyWSURL); + wsClient.on("message", (data, isBinary) => { + if (hooks.onMessage) { + hooks.onMessage(data); + } + if (!isBinary) { + const message = JSON.parse(data.toString("utf8")) as ComfyWSMessage; + if ( + useApiIDs && + message.data.prompt_id && + comfyIDToApiID[message.data.prompt_id] + ) { + message.data.prompt_id = comfyIDToApiID[message.data.prompt_id]; } - if (data.type === "utf8") { - const message = JSON.parse(data.utf8Data) as ComfyWSMessage; - if ( - useApiIDs && - message.data.prompt_id && - comfyIDToApiID[message.data.prompt_id] - ) { - message.data.prompt_id = comfyIDToApiID[message.data.prompt_id]; - } - if (isStatusMessage(message) && hooks.onStatus) { - hooks.onStatus(message); - } else if (isProgressMessage(message) && hooks.onProgress) { - hooks.onProgress(message); - } else if ( - isExecutionStartMessage(message) && - hooks.onExecutionStart - ) { - hooks.onExecutionStart(message); - } else if ( - isExecutionCachedMessage(message) && - hooks.onExecutionCached - ) { - hooks.onExecutionCached(message); - } else if (isExecutingMessage(message) && hooks.onExecuting) { - hooks.onExecuting(message); - } else if (isExecutedMessage(message) && hooks.onExecuted) { - hooks.onExecuted(message); - } else if ( - isExecutionSuccessMessage(message) && - hooks.onExecutionSuccess - ) { - hooks.onExecutionSuccess(message); - } else if ( - isExecutionInterruptedMessage(message) && - hooks.onExecutionInterrupted - ) { - hooks.onExecutionInterrupted(message); - } else if ( - isExecutionErrorMessage(message) && - hooks.onExecutionError - ) { - hooks.onExecutionError(message); - } - } else { - log.info(`Received ${data.type} message`); + if (isStatusMessage(message) && hooks.onStatus) { + hooks.onStatus(message); + } else if (isProgressMessage(message) && hooks.onProgress) { + hooks.onProgress(message); + } else if (isExecutionStartMessage(message) && hooks.onExecutionStart) { + hooks.onExecutionStart(message); + } else if ( + isExecutionCachedMessage(message) && + hooks.onExecutionCached + ) { + hooks.onExecutionCached(message); + } else if (isExecutingMessage(message) && hooks.onExecuting) { + hooks.onExecuting(message); + } else if (isExecutedMessage(message) && hooks.onExecuted) { + hooks.onExecuted(message); + } else if ( + isExecutionSuccessMessage(message) && + hooks.onExecutionSuccess + ) { + hooks.onExecutionSuccess(message); + } else if ( + isExecutionInterruptedMessage(message) && + hooks.onExecutionInterrupted + ) { + hooks.onExecutionInterrupted(message); + } else if (isExecutionErrorMessage(message) && hooks.onExecutionError) { + hooks.onExecutionError(message); } - }); - resolve(); + } else { + log.info(`Received binary message`); + } + }); + + wsClient.on("open", () => { + log.info("Connected to Comfy UI websocket"); + + return resolve(wsClient as WebSocket); }); - client.on("connectFailed", (error) => { + wsClient.on("error", (error) => { log.error(`Failed to connect to Comfy UI websocket: ${error}`); - reject(error); + return reject(error); }); }); } diff --git a/src/config.ts b/src/config.ts index c9ad1f3..4a43ed7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ const { LOG_LEVEL = "info", MARKDOWN_SCHEMA_DESCRIPTIONS = "true", MAX_BODY_SIZE_MB = "100", + MAX_QUEUE_DEPTH = "0", MODEL_DIR, OUTPUT_DIR, PORT = "3000", @@ -39,6 +40,7 @@ const port = parseInt(PORT, 10); const startupCheckInterval = parseInt(STARTUP_CHECK_INTERVAL_S, 10) * 1000; const startupCheckMaxTries = parseInt(STARTUP_CHECK_MAX_TRIES, 10); const maxBodySize = parseInt(MAX_BODY_SIZE_MB, 10) * 1024 * 1024; +const maxQueueDepth = parseInt(MAX_QUEUE_DEPTH, 10); const alwaysRestartComfyUI = ALWAYS_RESTART_COMFYUI.toLowerCase() === "true"; const systemWebhook = SYSTEM_WEBHOOK ?? ""; @@ -173,6 +175,7 @@ const config = { logLevel: LOG_LEVEL.toLowerCase(), markdownSchemaDescriptions: MARKDOWN_SCHEMA_DESCRIPTIONS === "true", maxBodySize, + maxQueueDepth, models: {} as Record< string, { diff --git a/src/server.ts b/src/server.ts index 139b745..d302be7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,7 @@ import { import workflows from "./workflows"; import { z } from "zod"; import { randomUUID } from "crypto"; +import { WebSocket } from "ws"; const server = Fastify({ bodyLimit: config.maxBodySize, @@ -141,7 +142,10 @@ server.after(() => { }, }, async (request, reply) => { - if (warm) { + if ( + warm && + (!config.maxQueueDepth || queueDepth < config.maxQueueDepth) + ) { return reply.code(200).send({ version, status: "ready" }); } return reply.code(503).send({ version, status: "not ready" }); @@ -514,9 +518,14 @@ server.after(() => { walk(workflows); }); +let comfyWebsocketClient: WebSocket | null = null; + process.on("SIGINT", async () => { server.log.info("Received SIGINT, interrupting process"); shutdownComfyUI(); + if (comfyWebsocketClient) { + comfyWebsocketClient.terminate(); + } process.exit(0); }); @@ -552,8 +561,20 @@ export async function start() { await launchComfyUIAndAPIServerAndWaitForWarmup(); const warmupTime = Date.now() - start; server.log.info(`Warmup took ${warmupTime / 1000}s`); - await connectToComfyUIWebsocketStream( - getConfiguredWebhookHandlers(server.log), + const handlers = getConfiguredWebhookHandlers(server.log); + if (handlers.onStatus) { + const originalHandler = handlers.onStatus; + handlers.onStatus = (msg) => { + queueDepth = msg.data.status.exec_info.queue_remaining; + originalHandler(msg); + }; + } else { + handlers.onStatus = (msg) => { + queueDepth = msg.data.status.exec_info.queue_remaining; + }; + } + comfyWebsocketClient = await connectToComfyUIWebsocketStream( + handlers, server.log, true ); diff --git a/src/types.ts b/src/types.ts index 6316d9c..7bcb319 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { randomUUID } from "crypto"; -import { Message } from "websocket"; +import { RawData } from "ws"; export const ComfyNodeSchema = z.object({ inputs: z.any(), @@ -352,7 +352,7 @@ export function isExecutionErrorMessage( } export type WebhookHandlers = { - onMessage?: (msg: Message) => Promise | void; + onMessage?: (msg: RawData) => Promise | void; onStatus?: (data: ComfyWSStatusMessage) => Promise | void; onProgress?: (data: ComfyWSProgressMessage) => Promise | void; onExecuting?: (data: ComfyWSExecutingMessage) => Promise | void; From 40ff878f95b716ce7fca04e88da65690e5376c19 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 11:20:53 -0500 Subject: [PATCH 10/21] document webhooks --- README.md | 286 ++++++++++++++++++++++++++++++++++++++++++++++---- src/config.ts | 19 +++- 2 files changed, 283 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fd022e6..4362a28 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,19 @@ A simple wrapper that facilitates using ComfyUI as a stateless API, either by re - [Environment Variables](#environment-variables) - [Configuration Details](#configuration-details) - [Additional Notes](#additional-notes) + - [Webhooks](#webhooks) + - [output.complete](#outputcomplete) + - [prompt.failed](#promptfailed) + - [System Events](#system-events) + - [status](#status) + - [progress](#progress) + - [executing](#executing) + - [execution\_start](#execution_start) + - [execution\_cached](#execution_cached) + - [executed](#executed) + - [execution\_success](#execution_success) + - [execution\_interrupted](#execution_interrupted) + - [execution\_error](#execution_error) - [Generating New Workflow Endpoints](#generating-new-workflow-endpoints) - [Automating with Claude 3.5 Sonnet](#automating-with-claude-35-sonnet) - [Prebuilt Docker Images](#prebuilt-docker-images) @@ -69,7 +82,7 @@ The server hosts swagger docs at `/docs`, which can be used to interact with the The server has two probes, `/health` and `/ready`. - The `/health` probe will return a 200 status code once the warmup workflow has completed. It will stay healthy as long as the server is running, even if ComfyUI crashes. -- The `/ready` probe will also return a 200 status code once the warmup workflow has completed. It will return a 503 status code if ComfyUI is not running, such as in the case it has crashed, but is being automatically restarted. +- The `/ready` probe will also return a 200 status code once the warmup workflow has completed. It will return a 503 status code if ComfyUI is not running, such as in the case it has crashed, but is being automatically restarted. If you have set `MAX_QUEUE_DEPTH` to a non-zero value, it will return a 503 status code if ComfyUI's queue has reached the maximum depth. Here's a markdown guide to configuring the application based on the provided config.ts file: @@ -82,24 +95,31 @@ This guide provides an overview of how to configure the application using enviro The following table lists the available environment variables and their default values. The default values mostly assume this will run on top of an [ai-dock](https://github.com/ai-dock/comfyui) image, but can be customized as needed. -| Variable | Default Value | Description | -| ------------------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| CMD | "init.sh" | Command to launch ComfyUI | -| HOST | "::" | Wrapper host address | -| PORT | "3000" | Wrapper port number | -| MAX_BODY_SIZE_MB | "100" | Maximum body size in MB | -| DIRECT_ADDRESS | "127.0.0.1" | Direct address for ComfyUI | -| COMFYUI_PORT_HOST | "8188" | ComfyUI port number | -| STARTUP_CHECK_INTERVAL_S | "1" | Interval in seconds between startup checks | -| STARTUP_CHECK_MAX_TRIES | "10" | Maximum number of startup check attempts | -| COMFY_HOME | "/opt/ComfyUI" | ComfyUI home directory | -| OUTPUT_DIR | "/opt/ComfyUI/output" | Directory for output files | -| INPUT_DIR | "/opt/ComfyUI/input" | Directory for input files | -| MODEL_DIR | "/opt/ComfyUI/models" | Directory for model files | -| WARMUP_PROMPT_FILE | (not set) | Path to warmup prompt file (optional) | -| WORKFLOW_DIR | "/workflows" | Directory for workflow files | -| BASE | "ai-dock" | There are different ways to load the comfyui environment for determining config values that vary with the base image. Currently only "ai-dock" has preset values. Set to empty string to not use this. | -| ALWAYS_RESTART_COMFYUI | "false" | If set to "true", the ComfyUI process will be automatically restarted if it exits. Otherwise, the API server will exit when ComfyUI exits. | +| Variable | Default Value | Description | +| ---------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ALWAYS_RESTART_COMFYUI | "false" | If set to "true", the ComfyUI process will be automatically restarted if it exits. Otherwise, the API server will exit when ComfyUI exits. | +| BASE | "ai-dock" | There are different ways to load the comfyui environment for determining config values that vary with the base image. Currently only "ai-dock" has preset values. Set to empty string to not use this. | +| CMD | "init.sh" | Command to launch ComfyUI | +| COMFY_HOME | "/opt/ComfyUI" | ComfyUI home directory | +| COMFYUI_PORT_HOST | "8188" | ComfyUI port number | +| DIRECT_ADDRESS | "127.0.0.1" | Direct address for ComfyUI | +| HOST | "::" | Wrapper host address | +| INPUT_DIR | "/opt/ComfyUI/input" | Directory for input files | +| LOG_LEVEL | "info" | Log level for the application. One of "trace", "debug", "info", "warn", "error", "fatal". | +| MARKDOWN_SCHEMA_DESCRIPTIONS | "true" | If set to "true", the server will use the descriptions in the zod schemas to generate markdown tables in the swagger docs. | +| MAX_BODY_SIZE_MB | "100" | Maximum body size in MB | +| MAX_BODY_SIZE_MB | "100" | Maximum request body size in MB | +| MAX_QUEUE_DEPTH | "0" | Maximum number of queued requests before the readiness probe will return 503. 0 indicates no limit. | +| MODEL_DIR | "/opt/ComfyUI/models" | Directory for model files | +| OUTPUT_DIR | "/opt/ComfyUI/output" | Directory for output files | +| PORT | "3000" | Wrapper port number | +| STARTUP_CHECK_INTERVAL_S | "1" | Interval in seconds between startup checks | +| STARTUP_CHECK_MAX_TRIES | "10" | Maximum number of startup check attempts | +| SYSTEM_META_* | (not set) | Any environment variable starting with SYSTEM_META_ will be sent to the system webhook as metadata. i.e. `SYSTEM_META_batch=abc` will add `{"batch": "abc"}` to the `.metadata` field on system webhooks. | +| SYSTEM_WEBHOOK_EVENTS | (not set) | Comma separated list of events to send to the webhook. Only selected events will be sent. If not set, no events will be sent. See [System Events](#system-events) | +| SYSTEM_WEBHOOK_URL | (not set) | Optionally receive via webhook the events that ComfyUI emits on websocket. This includes progress events. | +| WARMUP_PROMPT_FILE | (not set) | Path to warmup prompt file (optional) | +| WORKFLOW_DIR | "/workflows" | Directory for workflow files | ### Configuration Details @@ -133,7 +153,7 @@ The default values mostly assume this will run on top of an [ai-dock](https://gi - The model names are exposed via the `GET /models` endpoint, and via the config object throughout the application. 7. **ComfyUI Description**: - - The application retrieves available samplers and schedulers from ComfyUI. + - The application retrieves available samplers and schedulers from ComfyUI itself. - This information is used to create Zod enums for validation. ### Additional Notes @@ -143,6 +163,232 @@ The default values mostly assume this will run on top of an [ai-dock](https://gi Remember to set these environment variables according to your specific deployment needs before running the application. +## Webhooks + +ComfyUI API sends two types of webhooks: System Events, which are emitted by ComfyUI itself, and Workflow Events, which are emitted by the API server. See [System Events](#system-events) for more information on System Events. + +If a user includes the `.webhook` field in a request to `/prompt` or any of the workflow endpoints, the server will send any completed outputs to the webhook URL provided in the request. It will also send a webhook if the request fails. + +For successful requests, every output from the workflow will be sent as individual webhook requests. That means if your request generates 4 images, you will receive 4 webhook requests, each with a single image. + +### output.complete + +The webhook event name for a completed output is `output.complete`. The webhook will have the following schema: + +```json +{ + "event": "output.complete", + "image": "base64-encoded-image", + "id": "request-id", + "filename": "output-filename.png", + "prompt": {} +} +``` + +### prompt.failed + +The webhook event name for a failed request is `prompt.failed`. The webhook will have the following schema: + +```json +{ + "event": "prompt.failed", + "error": "error-message", + "id": "request-id", + "prompt": {} +} +``` + +## System Events + +ComfyUI emits a number of events over websocket during the course of a workflow. These can be configured to be sent to a webhook using the `SYSTEM_WEBHOOK_URL` and `SYSTEM_WEBHOOK_EVENTS` environment variables. Additionally, any environment variable starting with `SYSTEM_META_` will be sent as metadata with the event. + +All webhooks have the same format, which is as follows: + +```json +{ + "event": "event_name", + "data": {}, + "metadata": {} +} +``` + +When running on SaladCloud, `.metadata` will always include `salad_container_group_id` and `salad_machine_id`. + +The following events are available: + +- "status" +- "progress" +- "executing" +- "execution_start" +- "execution_cached" +- "executed" +- "execution_success" +- "execution_interrupted" +- "execution_error" + +The `SYSTEM_WEBHOOK_EVENTS` environment variable should be a comma-separated list of the events you want to send to the webhook. If not set, no events will be sent. + +The event name received in the webhook will be `comfy.${event_name}`, i.e. `comfy.progress`. + +**Example**: + +```shell +export SYSTEM_WEBHOOK_EVENTS="progress,execution_start,execution_success,execution_error" +``` + +This will cause the API to send the `progress`, `execution_start`, `execution_success`, and `execution_error` events to the webhook. + +The `SYSTEM_META_*` environment variables can be used to add metadata to the webhook events. For example: + +```shell +export SYSTEM_META_batch=abc +export SYSTEM_META_purpose=testing +``` + +Will add `{"batch": "abc", "purpose": "testing"}` to the `.metadata` field on system webhooks. + +The following are the schemas for the event data that will be sent to the webhook. This will populate the `.data` field on the webhook. + +### status + +```json +{ + "type": "status", + "data": { + "status": { + "exec_info": { + "queue_remaining": 3 + } + } + }, + "sid": "abc123" +} +``` + +### progress + +```json +{ + "type": "progress", + "data": { + "value": 45, + "max": 100, + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "node": "42" + }, + "sid": "xyz789" +} +``` + +### executing + +```json +{ + "type": "executing", + "data": { + "node": "42", + "display_node": "42", + "prompt_id": "123e4567-e89b-12d3-a456-426614174000" + }, + "sid": "xyz789" +} +``` + +### execution_start + +```json +{ + "type": "execution_start", + "data": { + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "timestamp": 1705505423000 + }, + "sid": "xyz789" +} +``` + +### execution_cached + +```json +{ + "type": "execution_cached", + "data": { + "nodes": ["42", "7", "13"], + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "timestamp": 1705505423000 + }, + "sid": "xyz789" +} +``` + +### executed + +```json +{ + "type": "executed", + "data": { + "node": "42", + "display_node": "42", + "output": {}, + "prompt_id": "123e4567-e89b-12d3-a456-426614174000" + }, + "sid": "xyz789" +} +``` + +### execution_success + +```json +{ + "type": "execution_success", + "data": { + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "timestamp": 1705505423000 + }, + "sid": "xyz789" +} +``` + +### execution_interrupted + +```json +{ + "type": "execution_interrupted", + "data": { + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "node_id": "42", + "node_type": "KSampler", + "executed": [] + }, + "sid": "xyz789" +} +``` + +### execution_error + +```json +{ + "type": "execution_error", + "data": { + "prompt_id": "123e4567-e89b-12d3-a456-426614174000", + "node_id": "42", + "node_type": "KSampler", + "executed": [], + "exception_message": "CUDA out of memory. Tried to allocate 2.20 GiB", + "exception_type": "RuntimeError", + "traceback": "Traceback (most recent call last):\n File \"nodes.py\", line 245, in sample\n samples = sampler.sample(model, noise, steps)", + "current_inputs": { + "seed": 42, + "steps": 20, + "cfg": 7.5, + "sampler_name": "euler" + }, + "current_outputs": [] + }, + "sid": "xyz789" +} +``` + ## Generating New Workflow Endpoints Since the ComfyUI prompt format is a little obtuse, it's common to wrap the workflow endpoints with a more user-friendly interface. diff --git a/src/config.ts b/src/config.ts index 4a43ed7..b114a53 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,7 +24,7 @@ const { SALAD_CONTAINER_GROUP_ID, STARTUP_CHECK_INTERVAL_S = "1", STARTUP_CHECK_MAX_TRIES = "10", - SYSTEM_WEBHOOK, + SYSTEM_WEBHOOK_URL, SYSTEM_WEBHOOK_EVENTS, WARMUP_PROMPT_FILE, WORKFLOW_DIR = "/workflows", @@ -37,12 +37,27 @@ const wsClientId = randomUUID(); const comfyWSURL = `ws://${DIRECT_ADDRESS}:${COMFYUI_PORT_HOST}/ws?clientId=${wsClientId}`; const selfURL = `http://localhost:${PORT}`; const port = parseInt(PORT, 10); + const startupCheckInterval = parseInt(STARTUP_CHECK_INTERVAL_S, 10) * 1000; +assert( + startupCheckInterval > 0, + "STARTUP_CHECK_INTERVAL_S must be a positive integer" +); + const startupCheckMaxTries = parseInt(STARTUP_CHECK_MAX_TRIES, 10); +assert( + startupCheckMaxTries > 0, + "STARTUP_CHECK_MAX_TRIES must be a positive integer" +); + const maxBodySize = parseInt(MAX_BODY_SIZE_MB, 10) * 1024 * 1024; +assert(maxBodySize > 0, "MAX_BODY_SIZE_MB must be a positive integer"); + const maxQueueDepth = parseInt(MAX_QUEUE_DEPTH, 10); +assert(maxQueueDepth >= 0, "MAX_QUEUE_DEPTH must be a non-negative integer"); + const alwaysRestartComfyUI = ALWAYS_RESTART_COMFYUI.toLowerCase() === "true"; -const systemWebhook = SYSTEM_WEBHOOK ?? ""; +const systemWebhook = SYSTEM_WEBHOOK_URL ?? ""; if (systemWebhook) { try { From 70f083a0330a3112bf07ba206ae7e692d50d863e Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 12:32:12 -0500 Subject: [PATCH 11/21] document new docker image home --- README.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4362a28..a7484b5 100644 --- a/README.md +++ b/README.md @@ -623,14 +623,27 @@ As with all AI-generated code, it is strongly recommended to review the generate ## Prebuilt Docker Images -There are several prebuilt Docker images using this server. -They are built from the [SaladCloud Recipes Repo](https://github.com/SaladTechnologies/salad-recipes/), and can be found on [Docker Hub](https://hub.docker.com/r/saladtechnologies/comfyui/tags). +You can find ready-to-go docker images under [Packages](https://github.com/orgs/SaladTechnologies/packages?repo_name=comfyui-api) in this repository. -The tag pattern is `saladtechnologies/comfyui:comfy-api-` where: +The images are tagged with the comfyui-api version they are built with, and the comfyui version they are built for, along with their pytorch version and CUDA version. There are versions for both CUDA runtime and CUDA devel, so you can choose the one that best fits your needs. + +The tag pattern is `ghcr.io/saladtechnologies/comfyui-api:comfy-api-torch-cuda-` where: - `` is the version of ComfyUI used - `` is the version of the comfyui-api server -- `` is the model used. There is a `base` tag for an image that contains ComfyUI and the comfyui-api server, but no models. There are also tags for specific models, like `sdxl-with-refiner` or `flux-schnell-fp8`. +- `` is the version of PyTorch used +- `` is the version of CUDA used +- `` is whether the image is built with the CUDA runtime or the CUDA devel image. The devel image is much larger, but includes the full CUDA toolkit, which is required for some custom nodes. + +Included in the API images are the following utilities: +- `git` +- `curl` +- `wget` +- `unzip` +- `ComfyUI` +- `comfy` cli + +All of SaladCloud's image and video generation [recipes](https://docs.salad.com/products/recipes/overview) are built on top of these images, so you can use them as a base for your own workflows. For examples of using this with custom models and nodes, check out the [Salad Recipes](https://github.com/SaladTechnologies/salad-recipes/tree/master/src) repository on GitHub. ## Considerations for Running on SaladCloud From 74e0ede74e976b72a4e97510cbb568dbb43c296c Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 12:42:24 -0500 Subject: [PATCH 12/21] markdown format --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a7484b5..67f6952 100644 --- a/README.md +++ b/README.md @@ -636,6 +636,7 @@ The tag pattern is `ghcr.io/saladtechnologies/comfyui-api:comfy-a - `` is whether the image is built with the CUDA runtime or the CUDA devel image. The devel image is much larger, but includes the full CUDA toolkit, which is required for some custom nodes. Included in the API images are the following utilities: + - `git` - `curl` - `wget` From 33302b0beab2189d4ae4231e6c39a75213549492 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 13:22:08 -0500 Subject: [PATCH 13/21] update to comfy 0.3.12 --- docker-compose.yml | 4 ++-- docker/api.dockerfile | 2 +- docker/build-api-images | 2 +- docker/build-comfy-base-images | 2 +- docker/comfyui.dockerfile | 2 +- docker/push-api-images | 2 +- docker/push-comfy-base-images | 2 +- test/docker-image/Dockerfile | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1124dc4..14fc4d5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: comfyui: - image: ghcr.io/saladtechnologies/comfyui-api:comfy0.3.10-test-image + image: ghcr.io/saladtechnologies/comfyui-api:comfy0.3.12-test-image volumes: - ./bin:/app/bin - ./test/docker-image/models:/opt/ComfyUI/models @@ -9,7 +9,7 @@ services: context: ./test/docker-image dockerfile: Dockerfile args: - - comfy_version=0.3.10 + - comfy_version=0.3.12 ports: - "3000:3000" - "8188:8188" diff --git a/docker/api.dockerfile b/docker/api.dockerfile index e67888a..a543461 100644 --- a/docker/api.dockerfile +++ b/docker/api.dockerfile @@ -1,5 +1,5 @@ ARG base=runtime -ARG comfy_version=0.3.10 +ARG comfy_version=0.3.12 ARG pytorch_version=2.5.0 ARG cuda_version=12.1 FROM ghcr.io/saladtechnologies/comfyui-api:comfy${comfy_version}-torch${pytorch_version}-cuda${cuda_version}-${base} diff --git a/docker/build-api-images b/docker/build-api-images index 6135c4c..03d3ed5 100755 --- a/docker/build-api-images +++ b/docker/build-api-images @@ -2,7 +2,7 @@ usage="Usage: $0 [comfy_version] [torch_version] [cuda_version] [api_version]" -comfy_version=${1:-0.3.10} +comfy_version=${1:-0.3.12} torch_version=${2:-2.5.0} cuda_version=${3:-12.1} diff --git a/docker/build-comfy-base-images b/docker/build-comfy-base-images index 8a9715d..07acc32 100755 --- a/docker/build-comfy-base-images +++ b/docker/build-comfy-base-images @@ -1,6 +1,6 @@ #! /usr/bin/bash -comfy_version=${1:-0.3.10} +comfy_version=${1:-0.3.12} torch_version=${2:-2.5.0} cuda_version=${3:-12.1} bases=("runtime" "devel") diff --git a/docker/comfyui.dockerfile b/docker/comfyui.dockerfile index b6944f4..d305099 100644 --- a/docker/comfyui.dockerfile +++ b/docker/comfyui.dockerfile @@ -15,7 +15,7 @@ RUN apt-get update && apt-get install -y \ RUN pip install --upgrade pip RUN pip install comfy-cli WORKDIR /opt -ARG comfy_version=0.3.10 +ARG comfy_version=0.3.12 RUN git clone --depth 1 --branch v${comfy_version} https://github.com/comfyanonymous/ComfyUI.git WORKDIR /opt/ComfyUI RUN pip install torch torchvision torchaudio --extra-index-url https://download.pytorch.org/whl/cu121 diff --git a/docker/push-api-images b/docker/push-api-images index b40a26a..17bf72a 100755 --- a/docker/push-api-images +++ b/docker/push-api-images @@ -2,7 +2,7 @@ usage="Usage: $0 [comfy_version] [torch_version] [cuda_version] [api_version]" -comfy_version=${1:-0.3.10} +comfy_version=${1:-0.3.12} torch_version=${2:-2.5.0} cuda_version=${3:-12.1} diff --git a/docker/push-comfy-base-images b/docker/push-comfy-base-images index e87aac5..98e4599 100755 --- a/docker/push-comfy-base-images +++ b/docker/push-comfy-base-images @@ -2,7 +2,7 @@ usage="Usage: $0 [comfy_version] [torch_version] [cuda_version]" -comfy_version=${1:-0.3.10} +comfy_version=${1:-0.3.12} torch_version=${2:-2.5.0} cuda_version=${3:-12.1} diff --git a/test/docker-image/Dockerfile b/test/docker-image/Dockerfile index 57a303c..50377a2 100644 --- a/test/docker-image/Dockerfile +++ b/test/docker-image/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/saladtechnologies/comfyui-api:comfy0.3.10-torch2.5.0-cuda12.1-devel +FROM ghcr.io/saladtechnologies/comfyui-api:comfy0.3.12-torch2.5.0-cuda12.1-devel RUN apt-get update && apt-get install -y \ libgl1 \ From 17202d68b97fd7d6bee278bdc954f588c26a2a30 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 13:29:43 -0500 Subject: [PATCH 14/21] more detail in readme --- README.md | 2 ++ test/docker-image/Dockerfile | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 67f6952..c06f039 100644 --- a/README.md +++ b/README.md @@ -635,6 +635,8 @@ The tag pattern is `ghcr.io/saladtechnologies/comfyui-api:comfy-a - `` is the version of CUDA used - `` is whether the image is built with the CUDA runtime or the CUDA devel image. The devel image is much larger, but includes the full CUDA toolkit, which is required for some custom nodes. +**If the tag doesn't have `api`, it does not include the api, and is just the ComfyUI base image.** + Included in the API images are the following utilities: - `git` diff --git a/test/docker-image/Dockerfile b/test/docker-image/Dockerfile index 50377a2..949743a 100644 --- a/test/docker-image/Dockerfile +++ b/test/docker-image/Dockerfile @@ -1,4 +1,5 @@ -FROM ghcr.io/saladtechnologies/comfyui-api:comfy0.3.12-torch2.5.0-cuda12.1-devel +ARG comfy_version=0.3.12 +FROM ghcr.io/saladtechnologies/comfyui-api:comfy${comfy_version}-torch2.5.0-cuda12.1-devel RUN apt-get update && apt-get install -y \ libgl1 \ From 694b07ac0bf6b955cf2741b98ac0ad2b38d00b42 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 13:47:25 -0500 Subject: [PATCH 15/21] allow different endpoint names for webhook --- test/test-utils.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/test-utils.ts b/test/test-utils.ts index ef77a3a..a99bac9 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -7,12 +7,13 @@ export async function sleep(ms: number): Promise { } export async function createWebhookListener( - onReceive: (body: any) => void | Promise + onReceive: (body: any) => void | Promise, + endpoint: string = "/webhook" ): Promise { const app = fastify({ bodyLimit: 1024 * 1024 * 1024, // 1 GB }); - app.post("/webhook", (req, res) => { + app.post(endpoint, (req, res) => { if (req.body) { onReceive(req.body); } From 8d61224054e66e2c87f3f1bd40017ccd248bf140 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 13:47:57 -0500 Subject: [PATCH 16/21] debug logging for events --- src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils.ts b/src/utils.ts index 7d70e23..40b5ae2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -258,6 +258,7 @@ export function getConfiguredWebhookHandlers( continue; } handlers[handlerName] = (data: any) => { + log.debug(`Sending system webhook for event: ${eventName}`); sendSystemWebhook(`comfy.${eventName}`, data, log); }; } From af918edc81c0a35eea3d368b1f58c087361f127a Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 13:48:11 -0500 Subject: [PATCH 17/21] handle close events while waiting for image --- src/comfy.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/comfy.ts b/src/comfy.ts index 50a5445..27798f3 100644 --- a/src/comfy.ts +++ b/src/comfy.ts @@ -159,7 +159,7 @@ export async function getPromptOutputs( export async function waitForPromptToComplete(promptId: string): Promise { return new Promise((resolve, reject) => { - const func = (event: MessageEvent) => { + const handleMessage = (event: MessageEvent) => { const { data } = event; if (typeof data === "string") { const message = JSON.parse(data) as ComfyWSMessage; @@ -167,7 +167,7 @@ export async function waitForPromptToComplete(promptId: string): Promise { isExecutionSuccessMessage(message) && message.data.prompt_id === promptId ) { - wsClient?.removeEventListener("message", func); + wsClient?.removeEventListener("message", handleMessage); return resolve(); } else if ( isExecutionErrorMessage(message) && @@ -182,7 +182,14 @@ export async function waitForPromptToComplete(promptId: string): Promise { } } }; - wsClient?.addEventListener("message", func); + wsClient?.addEventListener("message", handleMessage); + + const onClose = () => { + wsClient?.removeEventListener("message", handleMessage); + wsClient?.removeEventListener("close", onClose); + return reject(new Error("Websocket closed")); + }; + wsClient?.addEventListener("close", onClose); }); } @@ -271,5 +278,9 @@ export function connectToComfyUIWebsocketStream( log.error(`Failed to connect to Comfy UI websocket: ${error}`); return reject(error); }); + + wsClient.on("close", () => { + log.info("Disconnected from Comfy UI websocket"); + }); }); } From f44314be47c1f9449ff3a1119d5a122ac5fe1ec6 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 14:20:58 -0500 Subject: [PATCH 18/21] test new system event hooks --- docker-compose.yml | 2 ++ src/config.ts | 20 ++++++++++++-------- src/server.ts | 2 ++ src/types.ts | 15 --------------- src/utils.ts | 22 +++++++++------------- test/animatediff.spec.ts | 4 ++-- test/cogvideox.spec.ts | 4 ++-- test/flux.spec.ts | 4 ++-- test/hunyuanvideo.spec.ts | 4 ++-- test/ltxvideo.spec.ts | 4 ++-- test/mochi.spec.ts | 4 ++-- test/sd1.5.spec.ts | 4 ++-- test/sd3.5.spec.ts | 4 ++-- test/sdxl.spec.ts | 4 ++-- test/system-events.spec.ts | 38 ++++++++++++++++++++++++++++++++++++++ test/test-utils.ts | 4 ++-- 16 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 test/system-events.spec.ts diff --git a/docker-compose.yml b/docker-compose.yml index 14fc4d5..4edf053 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: - "8188:8188" environment: ALWAYS_RESTART_COMFYUI: "true" + SYSTEM_WEBHOOK_URL: "http://host.docker.internal:1234/system" + SYSTEM_WEBHOOK_EVENTS: all deploy: resources: reservations: diff --git a/src/config.ts b/src/config.ts index b114a53..14a6ab2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -69,7 +69,6 @@ if (systemWebhook) { } const allEvents = new Set([ - "message", "status", "progress", "executing", @@ -80,13 +79,18 @@ const allEvents = new Set([ "execution_interrupted", "execution_error", ]); -const systemWebhookEvents = SYSTEM_WEBHOOK_EVENTS?.split(",") ?? []; -assert( - systemWebhookEvents.every((e) => allEvents.has(e)), - `Invalid system webhook events. Supported options: ${Array.from( - allEvents - ).join(", ")}` -); +let systemWebhookEvents: string[] = []; +if (SYSTEM_WEBHOOK_EVENTS === "all") { + systemWebhookEvents = Array.from(allEvents); +} else { + systemWebhookEvents = SYSTEM_WEBHOOK_EVENTS?.split(",") ?? []; + assert( + systemWebhookEvents.every((e) => allEvents.has(e)), + `Invalid system webhook events. Supported options: ${Array.from( + allEvents + ).join(", ")}` + ); +} const loadEnvCommand: Record = { "ai-dock": `source /opt/ai-dock/etc/environment.sh \ diff --git a/src/server.ts b/src/server.ts index d302be7..0849666 100644 --- a/src/server.ts +++ b/src/server.ts @@ -566,11 +566,13 @@ export async function start() { const originalHandler = handlers.onStatus; handlers.onStatus = (msg) => { queueDepth = msg.data.status.exec_info.queue_remaining; + server.log.debug(`Queue depth: ${queueDepth}`); originalHandler(msg); }; } else { handlers.onStatus = (msg) => { queueDepth = msg.data.status.exec_info.queue_remaining; + server.log.debug(`Queue depth: ${queueDepth}`); }; } comfyWebsocketClient = await connectToComfyUIWebsocketStream( diff --git a/src/types.ts b/src/types.ts index 7bcb319..a15afcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -384,18 +384,3 @@ export const SystemWebhookEvents = [ "execution_interrupted", "execution_error", ] as const; - -type Required = { - [P in keyof T]-?: T[P]; -}; - -type ValidWebhookHandlerName = keyof WebhookHandlers; - -const handler: Required = {} as Required; -const webhookHandlerKeys = Object.keys(handler) as ValidWebhookHandlerName[]; - -export function isValidWebhookHandlerName( - key: string -): key is ValidWebhookHandlerName { - return webhookHandlerKeys.includes(key as ValidWebhookHandlerName); -} diff --git a/src/utils.ts b/src/utils.ts index 40b5ae2..12717b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,11 +7,7 @@ import path from "path"; import { randomUUID } from "crypto"; import { ZodObject, ZodRawShape, ZodTypeAny, ZodDefault } from "zod"; import sharp from "sharp"; -import { - isValidWebhookHandlerName, - OutputConversionOptions, - WebhookHandlers, -} from "./types"; +import { OutputConversionOptions, WebhookHandlers } from "./types"; export async function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -240,23 +236,23 @@ export async function sendSystemWebhook( } } -function snakeCaseToCamelCase(str: string) { - return str.replace(/([-_]\w)/g, (g) => g[1].toUpperCase()); +/** + * Converts a snake_case string to UpperCamelCase + */ +function snakeCaseToUpperCamelCase(str: string): string { + const camel = str.replace(/(_\w)/g, (match) => match[1].toUpperCase()); + const upperCamel = camel.charAt(0).toUpperCase() + camel.slice(1); + return upperCamel; } export function getConfiguredWebhookHandlers( log: FastifyBaseLogger ): WebhookHandlers { const handlers: Record void> = {}; - if (config.systemWebhook) { const systemWebhookEvents = config.systemWebhookEvents; for (const eventName of systemWebhookEvents) { - const handlerName = `on${snakeCaseToCamelCase(eventName)}`; - if (!isValidWebhookHandlerName(handlerName)) { - log.error(`Invalid webhook handler name: ${handlerName}`); - continue; - } + const handlerName = `on${snakeCaseToUpperCamelCase(eventName)}`; handlers[handlerName] = (data: any) => { log.debug(`Sending system webhook for event: ${eventName}`); sendSystemWebhook(`comfy.${eventName}`, data, log); diff --git a/test/animatediff.spec.ts b/test/animatediff.spec.ts index 8460da4..6e2d4da 100644 --- a/test/animatediff.spec.ts +++ b/test/animatediff.spec.ts @@ -10,7 +10,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import { before } from "mocha"; @@ -28,7 +28,7 @@ const largeOpts = { describe("AnimateDiff", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("returns still frames and a video", async () => { diff --git a/test/cogvideox.spec.ts b/test/cogvideox.spec.ts index 4dd389c..26f4321 100644 --- a/test/cogvideox.spec.ts +++ b/test/cogvideox.spec.ts @@ -6,7 +6,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/cogvideox-txt2video.json"; @@ -18,7 +18,7 @@ const text2VideoOptions = { describe("CogVideoX", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2video works", async () => { diff --git a/test/flux.spec.ts b/test/flux.spec.ts index 19b9080..681eb2a 100644 --- a/test/flux.spec.ts +++ b/test/flux.spec.ts @@ -4,7 +4,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import fluxTxt2Img from "./workflows/flux-txt2img.json"; @@ -15,7 +15,7 @@ const fluxOpts = { describe("Flux", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2image works with 1 image", async () => { diff --git a/test/hunyuanvideo.spec.ts b/test/hunyuanvideo.spec.ts index 3826dcb..61e836a 100644 --- a/test/hunyuanvideo.spec.ts +++ b/test/hunyuanvideo.spec.ts @@ -4,7 +4,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/hunyuanvideo-txt2video.json"; @@ -16,7 +16,7 @@ const text2VideoOptions = { describe("Hunyuan Video", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2video works", async () => { diff --git a/test/ltxvideo.spec.ts b/test/ltxvideo.spec.ts index 3eed6e1..7c8b08b 100644 --- a/test/ltxvideo.spec.ts +++ b/test/ltxvideo.spec.ts @@ -6,7 +6,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/ltxv_text_to_video.json"; import img2Video from "./workflows/ltxv_image_to_video.json"; @@ -29,7 +29,7 @@ const img2VideoOptions = { describe("LTX Video", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2video works", async () => { diff --git a/test/mochi.spec.ts b/test/mochi.spec.ts index c7ed190..24bf521 100644 --- a/test/mochi.spec.ts +++ b/test/mochi.spec.ts @@ -6,7 +6,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/mochi.json"; @@ -18,7 +18,7 @@ const text2VideoOptions = { describe("Mochi Video", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2video works", async () => { diff --git a/test/sd1.5.spec.ts b/test/sd1.5.spec.ts index a9f3978..35e09a8 100644 --- a/test/sd1.5.spec.ts +++ b/test/sd1.5.spec.ts @@ -6,7 +6,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import sd15Txt2Img from "./workflows/sd1.5-txt2img.json"; import sd15Img2Img from "./workflows/sd1.5-img2img.json"; @@ -21,7 +21,7 @@ sd15Img2Img["10"].inputs.image = inputImage; describe("Stable Diffusion 1.5", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2image works with 1 image", async () => { diff --git a/test/sd3.5.spec.ts b/test/sd3.5.spec.ts index 3e6b168..b3cce0c 100644 --- a/test/sd3.5.spec.ts +++ b/test/sd3.5.spec.ts @@ -5,7 +5,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import sd35Txt2Image from "./workflows/sd3.5-txt2img.json"; @@ -16,7 +16,7 @@ const txt2imgOpts = { describe("Stable Diffusion 3.5", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2image works with 1 image", async () => { diff --git a/test/sdxl.spec.ts b/test/sdxl.spec.ts index 7ffafac..19043c3 100644 --- a/test/sdxl.spec.ts +++ b/test/sdxl.spec.ts @@ -4,7 +4,7 @@ import { createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import sdxlWithRefinerTxt2Img from "./workflows/sdxl-with-refiner.json"; @@ -15,7 +15,7 @@ const txt2imgOpts = { describe("Stable Diffusion XL", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2image works with 1 image", async () => { diff --git a/test/system-events.spec.ts b/test/system-events.spec.ts new file mode 100644 index 0000000..014bdf3 --- /dev/null +++ b/test/system-events.spec.ts @@ -0,0 +1,38 @@ +import { expect } from "earl"; +import { + sleep, + createWebhookListener, + submitPrompt, + checkImage, + waitForServerToBeReady, +} from "./test-utils"; +import sd15Txt2Img from "./workflows/sd1.5-txt2img.json"; +import exp from "constants"; + +describe("System Events", () => { + before(async () => { + await waitForServerToBeReady(); + }); + + it("works", async () => { + const uniquePrompt = JSON.parse(JSON.stringify(sd15Txt2Img)); + uniquePrompt["3"].inputs.seed = Math.floor(Math.random() * 1000000); + const eventsReceived: { [key: string]: number } = {}; + const webhook = await createWebhookListener(async (body) => { + if (!eventsReceived[body.event]) { + eventsReceived[body.event] = 0; + } + eventsReceived[body.event]++; + }, "/system"); + + await submitPrompt(uniquePrompt); + + expect(eventsReceived).toHaveSubset({ + "comfy.progress": uniquePrompt["3"].inputs.steps, + "comfy.executed": 1, + "comfy.execution_success": 1, + }); + + await webhook.close(); + }); +}); diff --git a/test/test-utils.ts b/test/test-utils.ts index a99bac9..d0018f5 100644 --- a/test/test-utils.ts +++ b/test/test-utils.ts @@ -77,10 +77,10 @@ export async function checkImage( } } -export async function waitForServerToStart(): Promise { +export async function waitForServerToBeReady(): Promise { while (true) { try { - const resp = await fetch(`http://localhost:3000/health`); + const resp = await fetch(`http://localhost:3000/ready`); if (resp.ok) { break; } From 3c3e50ac0aca180bca5e0baf14015134bcc344a9 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 14:25:58 -0500 Subject: [PATCH 19/21] remove unused imports --- test/system-events.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/system-events.spec.ts b/test/system-events.spec.ts index 014bdf3..f02914a 100644 --- a/test/system-events.spec.ts +++ b/test/system-events.spec.ts @@ -1,13 +1,10 @@ import { expect } from "earl"; import { - sleep, createWebhookListener, submitPrompt, - checkImage, waitForServerToBeReady, } from "./test-utils"; import sd15Txt2Img from "./workflows/sd1.5-txt2img.json"; -import exp from "constants"; describe("System Events", () => { before(async () => { From e40378dd3832d18e843fd875d91ecdfc6ef9acf3 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 14:28:12 -0500 Subject: [PATCH 20/21] document the "all" option for events --- README.md | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index c06f039..cc84e83 100644 --- a/README.md +++ b/README.md @@ -95,31 +95,31 @@ This guide provides an overview of how to configure the application using enviro The following table lists the available environment variables and their default values. The default values mostly assume this will run on top of an [ai-dock](https://github.com/ai-dock/comfyui) image, but can be customized as needed. -| Variable | Default Value | Description | -| ---------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ALWAYS_RESTART_COMFYUI | "false" | If set to "true", the ComfyUI process will be automatically restarted if it exits. Otherwise, the API server will exit when ComfyUI exits. | -| BASE | "ai-dock" | There are different ways to load the comfyui environment for determining config values that vary with the base image. Currently only "ai-dock" has preset values. Set to empty string to not use this. | -| CMD | "init.sh" | Command to launch ComfyUI | -| COMFY_HOME | "/opt/ComfyUI" | ComfyUI home directory | -| COMFYUI_PORT_HOST | "8188" | ComfyUI port number | -| DIRECT_ADDRESS | "127.0.0.1" | Direct address for ComfyUI | -| HOST | "::" | Wrapper host address | -| INPUT_DIR | "/opt/ComfyUI/input" | Directory for input files | -| LOG_LEVEL | "info" | Log level for the application. One of "trace", "debug", "info", "warn", "error", "fatal". | -| MARKDOWN_SCHEMA_DESCRIPTIONS | "true" | If set to "true", the server will use the descriptions in the zod schemas to generate markdown tables in the swagger docs. | -| MAX_BODY_SIZE_MB | "100" | Maximum body size in MB | -| MAX_BODY_SIZE_MB | "100" | Maximum request body size in MB | -| MAX_QUEUE_DEPTH | "0" | Maximum number of queued requests before the readiness probe will return 503. 0 indicates no limit. | -| MODEL_DIR | "/opt/ComfyUI/models" | Directory for model files | -| OUTPUT_DIR | "/opt/ComfyUI/output" | Directory for output files | -| PORT | "3000" | Wrapper port number | -| STARTUP_CHECK_INTERVAL_S | "1" | Interval in seconds between startup checks | -| STARTUP_CHECK_MAX_TRIES | "10" | Maximum number of startup check attempts | -| SYSTEM_META_* | (not set) | Any environment variable starting with SYSTEM_META_ will be sent to the system webhook as metadata. i.e. `SYSTEM_META_batch=abc` will add `{"batch": "abc"}` to the `.metadata` field on system webhooks. | -| SYSTEM_WEBHOOK_EVENTS | (not set) | Comma separated list of events to send to the webhook. Only selected events will be sent. If not set, no events will be sent. See [System Events](#system-events) | -| SYSTEM_WEBHOOK_URL | (not set) | Optionally receive via webhook the events that ComfyUI emits on websocket. This includes progress events. | -| WARMUP_PROMPT_FILE | (not set) | Path to warmup prompt file (optional) | -| WORKFLOW_DIR | "/workflows" | Directory for workflow files | +| Variable | Default Value | Description | +| ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ALWAYS_RESTART_COMFYUI | "false" | If set to "true", the ComfyUI process will be automatically restarted if it exits. Otherwise, the API server will exit when ComfyUI exits. | +| BASE | "ai-dock" | There are different ways to load the comfyui environment for determining config values that vary with the base image. Currently only "ai-dock" has preset values. Set to empty string to not use this. | +| CMD | "init.sh" | Command to launch ComfyUI | +| COMFY_HOME | "/opt/ComfyUI" | ComfyUI home directory | +| COMFYUI_PORT_HOST | "8188" | ComfyUI port number | +| DIRECT_ADDRESS | "127.0.0.1" | Direct address for ComfyUI | +| HOST | "::" | Wrapper host address | +| INPUT_DIR | "/opt/ComfyUI/input" | Directory for input files | +| LOG_LEVEL | "info" | Log level for the application. One of "trace", "debug", "info", "warn", "error", "fatal". | +| MARKDOWN_SCHEMA_DESCRIPTIONS | "true" | If set to "true", the server will use the descriptions in the zod schemas to generate markdown tables in the swagger docs. | +| MAX_BODY_SIZE_MB | "100" | Maximum body size in MB | +| MAX_BODY_SIZE_MB | "100" | Maximum request body size in MB | +| MAX_QUEUE_DEPTH | "0" | Maximum number of queued requests before the readiness probe will return 503. 0 indicates no limit. | +| MODEL_DIR | "/opt/ComfyUI/models" | Directory for model files | +| OUTPUT_DIR | "/opt/ComfyUI/output" | Directory for output files | +| PORT | "3000" | Wrapper port number | +| STARTUP_CHECK_INTERVAL_S | "1" | Interval in seconds between startup checks | +| STARTUP_CHECK_MAX_TRIES | "10" | Maximum number of startup check attempts | +| SYSTEM_META_* | (not set) | Any environment variable starting with SYSTEM_META_ will be sent to the system webhook as metadata. i.e. `SYSTEM_META_batch=abc` will add `{"batch": "abc"}` to the `.metadata` field on system webhooks. | +| SYSTEM_WEBHOOK_EVENTS | (not set) | Comma separated list of events to send to the webhook. Only selected events will be sent. If not set, no events will be sent. See [System Events](#system-events). You may also use the special value `all` to subscribe to all event types. | +| SYSTEM_WEBHOOK_URL | (not set) | Optionally receive via webhook the events that ComfyUI emits on websocket. This includes progress events. | +| WARMUP_PROMPT_FILE | (not set) | Path to warmup prompt file (optional) | +| WORKFLOW_DIR | "/workflows" | Directory for workflow files | ### Configuration Details From 344de6d99ce89ec0d3670e63b230adc5678e336d Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 17 Jan 2025 14:42:21 -0500 Subject: [PATCH 21/21] remove unused imports. run system events test separately, different config --- docker-compose.yml | 8 ++++---- test/cogvideox.spec.ts | 2 -- test/mochi.spec.ts | 2 -- test/system-events.spec.ts | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4edf053..6990295 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,10 +13,10 @@ services: ports: - "3000:3000" - "8188:8188" - environment: - ALWAYS_RESTART_COMFYUI: "true" - SYSTEM_WEBHOOK_URL: "http://host.docker.internal:1234/system" - SYSTEM_WEBHOOK_EVENTS: all + # environment: + # ALWAYS_RESTART_COMFYUI: "true" + # SYSTEM_WEBHOOK_URL: "http://host.docker.internal:1234/system" + # SYSTEM_WEBHOOK_EVENTS: all deploy: resources: reservations: diff --git a/test/cogvideox.spec.ts b/test/cogvideox.spec.ts index 26f4321..99fca27 100644 --- a/test/cogvideox.spec.ts +++ b/test/cogvideox.spec.ts @@ -1,6 +1,4 @@ import { expect } from "earl"; -import path from "path"; -import fs from "fs"; import { sleep, createWebhookListener, diff --git a/test/mochi.spec.ts b/test/mochi.spec.ts index 24bf521..ae34a64 100644 --- a/test/mochi.spec.ts +++ b/test/mochi.spec.ts @@ -1,6 +1,4 @@ import { expect } from "earl"; -import path from "path"; -import fs from "fs"; import { sleep, createWebhookListener, diff --git a/test/system-events.spec.ts b/test/system-events.spec.ts index f02914a..3f1b100 100644 --- a/test/system-events.spec.ts +++ b/test/system-events.spec.ts @@ -6,7 +6,7 @@ import { } from "./test-utils"; import sd15Txt2Img from "./workflows/sd1.5-txt2img.json"; -describe("System Events", () => { +describe.skip("System Events", () => { before(async () => { await waitForServerToBeReady(); });