diff --git a/README.md b/README.md index e46f73a..cc84e83 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) @@ -31,7 +44,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 @@ -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). 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 @@ -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. @@ -377,14 +623,30 @@ 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 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 `saladtechnologies/comfyui:comfy-api-` where: +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. + +**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` +- `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 diff --git a/docker-compose.yml b/docker-compose.yml index 7117a23..6990295 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,10 +9,14 @@ services: context: ./test/docker-image dockerfile: Dockerfile args: - - comfy_version=0.3.10 + - comfy_version=0.3.12 ports: - "3000:3000" - "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/docker/api.dockerfile b/docker/api.dockerfile index bccb8e1..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} @@ -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/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/package-lock.json b/package-lock.json index 526a529..57c88e1 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": { @@ -17,6 +17,7 @@ "fastify-type-provider-zod": "^2.0.0", "sharp": "^0.33.5", "typescript": "^5.4.5", + "ws": "^8.18.0", "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/ws": "^8.5.13", "@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/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": "*" + } + }, "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", @@ -1331,9 +1351,9 @@ } }, "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" }, @@ -1573,9 +1593,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 +1643,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 +1786,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 +2011,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" @@ -2085,9 +2119,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 +2379,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", @@ -2407,9 +2429,9 @@ "dev": true }, "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" @@ -2582,20 +2604,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 +2650,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 +2769,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 +2810,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 +2847,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 +3237,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 +3293,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 +3361,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", @@ -3432,9 +3465,9 @@ } }, "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" @@ -3623,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", @@ -3642,9 +3695,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 +3810,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..146ccb9 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": { @@ -16,6 +16,7 @@ "@types/chokidar": "^2.1.3", "@types/mocha": "^10.0.10", "@types/node": "^20.12.7", + "@types/ws": "^8.5.13", "@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", + "ws": "^8.18.0", "zod": "^3.23.8" }, "pkg": { diff --git a/src/comfy.ts b/src/comfy.ts new file mode 100644 index 0000000..27798f3 --- /dev/null +++ b/src/comfy.ts @@ -0,0 +1,286 @@ +import { sleep } from "./utils"; +import config from "./config"; +import { CommandExecutor } from "./commands"; +import { FastifyBaseLogger } from "fastify"; +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 WebSocket, { MessageEvent } from "ws"; + +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, client_id: config.wsClientId }), + }); + 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 waitForPromptToComplete(promptId: string): Promise { + return new Promise((resolve, reject) => { + const handleMessage = (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", handleMessage); + 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", handleMessage); + + const onClose = () => { + wsClient?.removeEventListener("message", handleMessage); + wsClient?.removeEventListener("close", onClose); + return reject(new Error("Websocket closed")); + }; + wsClient?.addEventListener("close", onClose); + }); +} + +export const comfyIDToApiID: Record = {}; + +export async function runPromptAndGetOutputs( + id: string, + prompt: ComfyPrompt, + log: FastifyBaseLogger +): Promise> { + const promptId = await queuePrompt(prompt); + comfyIDToApiID[promptId] = id; + 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 { + return new Promise((resolve, reject) => { + 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 (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 binary message`); + } + }); + + wsClient.on("open", () => { + log.info("Connected to Comfy UI websocket"); + + return resolve(wsClient as WebSocket); + }); + wsClient.on("error", (error) => { + log.error(`Failed to connect to Comfy UI websocket: ${error}`); + return reject(error); + }); + + wsClient.on("close", () => { + log.info("Disconnected from Comfy UI websocket"); + }); + }); +} diff --git a/src/config.ts b/src/config.ts index c820f38..14a6ab2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,24 +5,29 @@ 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", + MAX_QUEUE_DEPTH = "0", + MODEL_DIR, + OUTPUT_DIR, PORT = "3000", - DIRECT_ADDRESS = "127.0.0.1", - COMFYUI_PORT_HOST = "8188", + SALAD_MACHINE_ID, + SALAD_CONTAINER_GROUP_ID, STARTUP_CHECK_INTERVAL_S = "1", STARTUP_CHECK_MAX_TRIES = "10", - COMFY_HOME = "/opt/ComfyUI", - OUTPUT_DIR, - INPUT_DIR, - MODEL_DIR, + SYSTEM_WEBHOOK_URL, + SYSTEM_WEBHOOK_EVENTS, 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 }); @@ -32,10 +37,60 @@ 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_URL ?? ""; + +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 allEvents = new Set([ + "status", + "progress", + "executing", + "execution_start", + "execution_cached", + "executed", + "execution_success", + "execution_interrupted", + "execution_error", +]); +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 \ @@ -128,27 +183,18 @@ 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, + maxQueueDepth, models: {} as Record< string, { @@ -157,8 +203,23 @@ const config = { enum: z.ZodEnum<[string, ...string[]]>; } >, - workflowModels: WORKFLOW_MODELS, - markdownSchemaDescriptions: MARKDOWN_SCHEMA_DESCRIPTIONS === "true", + 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, + wrapperHost: HOST, + wrapperPort: port, + wsClientId, }; const modelDir = MODEL_DIR ?? path.join(comfyDir, "models"); @@ -177,4 +238,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 8446053..0849666 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,16 +11,20 @@ import fsPromises from "fs/promises"; import path from "path"; import { version } from "../package.json"; import config from "./config"; +import { + processImage, + zodToMarkdownTable, + convertImageBuffer, + getConfiguredWebhookHandlers, +} from "./utils"; import { warmupComfyUI, waitForComfyUIToStart, launchComfyUI, shutdownComfyUI, - processImage, - zodToMarkdownTable, - convertImageBuffer, runPromptAndGetOutputs, -} from "./utils"; + connectToComfyUIWebsocketStream, +} from "./comfy"; import { PromptRequestSchema, PromptErrorResponseSchema, @@ -34,10 +38,11 @@ import { import workflows from "./workflows"; import { z } from "zod"; import { randomUUID } from "crypto"; +import { WebSocket } from "ws"; const server = Fastify({ bodyLimit: config.maxBodySize, - logger: true, + logger: { level: config.logLevel }, }); server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); @@ -57,6 +62,7 @@ for (const modelType in config.models) { let warm = false; let wasEverWarm = false; +let queueDepth = 0; server.register(fastifySwagger, { openapi: { @@ -136,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" }); @@ -282,69 +291,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(id, 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 { /** @@ -357,7 +401,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; @@ -421,7 +465,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<{ @@ -474,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); }); @@ -510,9 +559,27 @@ 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`); + const handlers = getConfiguredWebhookHandlers(server.log); + if (handlers.onStatus) { + 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( + handlers, + 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..a15afcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { randomUUID } from "crypto"; +import { RawData } from "ws"; export const ComfyNodeSchema = z.object({ inputs: z.any(), @@ -190,3 +191,196 @@ 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: RawData) => 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; diff --git a/src/utils.ts b/src/utils.ts index 9789dc3..12717b1 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, WebhookHandlers } 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, @@ -103,7 +39,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); }); @@ -269,91 +205,60 @@ 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, +export async function sendSystemWebhook( + eventName: string, + data: any, 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()}`); +): Promise { + const metadata: Record = { ...config.systemMetaData }; + if (config.saladContainerGroupId) { + metadata["salad_container_group_id"] = config.saladContainerGroupId; } - const body = await resp.json(); - const allOutputs: Record = {}; - const fileLoadPromises: Promise[] = []; - if (!body[promptId]) { - return null; + if (config.saladMachineId) { + metadata["salad_machine_id"] = config.saladMachineId; } - 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}`); - }) - ); - } + 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); } - } 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, +/** + * 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 -): 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; +): WebhookHandlers { + const handlers: Record void> = {}; + if (config.systemWebhook) { + const systemWebhookEvents = config.systemWebhookEvents; + for (const eventName of systemWebhookEvents) { + const handlerName = `on${snakeCaseToUpperCamelCase(eventName)}`; + handlers[handlerName] = (data: any) => { + log.debug(`Sending system webhook for event: ${eventName}`); + sendSystemWebhook(`comfy.${eventName}`, data, log); + }; } - await sleep(50); } + + return handlers as WebhookHandlers; } 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..99fca27 100644 --- a/test/cogvideox.spec.ts +++ b/test/cogvideox.spec.ts @@ -1,12 +1,10 @@ import { expect } from "earl"; -import path from "path"; -import fs from "fs"; import { sleep, createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/cogvideox-txt2video.json"; @@ -18,7 +16,7 @@ const text2VideoOptions = { describe("CogVideoX", () => { before(async () => { - await waitForServerToStart(); + await waitForServerToBeReady(); }); describe("Return content in response", () => { it("text2video works", async () => { diff --git a/test/docker-image/Dockerfile b/test/docker-image/Dockerfile index 57a303c..949743a 100644 --- a/test/docker-image/Dockerfile +++ b/test/docker-image/Dockerfile @@ -1,4 +1,5 @@ -FROM ghcr.io/saladtechnologies/comfyui-api:comfy0.3.10-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 \ 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..ae34a64 100644 --- a/test/mochi.spec.ts +++ b/test/mochi.spec.ts @@ -1,12 +1,10 @@ import { expect } from "earl"; -import path from "path"; -import fs from "fs"; import { sleep, createWebhookListener, submitPrompt, checkImage, - waitForServerToStart, + waitForServerToBeReady, } from "./test-utils"; import txt2Video from "./workflows/mochi.json"; @@ -18,7 +16,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..3f1b100 --- /dev/null +++ b/test/system-events.spec.ts @@ -0,0 +1,35 @@ +import { expect } from "earl"; +import { + createWebhookListener, + submitPrompt, + waitForServerToBeReady, +} from "./test-utils"; +import sd15Txt2Img from "./workflows/sd1.5-txt2img.json"; + +describe.skip("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 ef77a3a..d0018f5 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); } @@ -76,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; }