playbook/antigravity-awesome-skills/skills/comfyui-gateway/references/integration.md

1797 lines
47 KiB
Markdown

# ComfyUI Gateway -- Integration Guide
Complete integration reference with ready-to-use code examples for every endpoint
and common platforms. All examples assume the gateway is running at
`http://localhost:3000` with API key authentication enabled.
---
## Table of Contents
1. [curl Examples (Every Endpoint)](#1-curl-examples)
2. [n8n Webhook Workflow](#2-n8n-webhook-workflow)
3. [Supabase Edge Function](#3-supabase-edge-function)
4. [Claude Code Integration](#4-claude-code-integration)
5. [Python Requests Client](#5-python-requests-client)
6. [JavaScript/TypeScript Fetch Client](#6-javascripttypescript-fetch-client)
7. [Webhook Receiver (Express.js + HMAC)](#7-webhook-receiver-expressjs--hmac)
8. [Docker Compose](#8-docker-compose)
9. [Environment Configuration Examples](#9-environment-configuration-examples)
---
## 1. curl Examples
### Health Check
```bash
curl -s http://localhost:3000/health | jq .
```
Response:
```json
{
"ok": true,
"version": null,
"comfyui": {
"reachable": true,
"url": "http://127.0.0.1:8188"
},
"uptime": 1234.567
}
```
### Capabilities
```bash
curl -s http://localhost:3000/capabilities \
-H "X-API-Key: your-api-key" | jq .
```
Response:
```json
{
"workflows": [
{ "id": "sdxl_realism_v1", "name": "SDXL Realism v1", "description": "..." },
{ "id": "sprite_transparent_bg", "name": "Sprite Transparent BG", "description": "..." }
],
"maxSize": 2048,
"maxBatch": 4,
"formats": ["png", "jpg", "webp"],
"storageProvider": "local"
}
```
### List Workflows
```bash
curl -s http://localhost:3000/workflows \
-H "X-API-Key: your-api-key" | jq .
```
### Get Workflow Details
```bash
curl -s http://localhost:3000/workflows/sdxl_realism_v1 \
-H "X-API-Key: your-api-key" | jq .
```
### Create Workflow (Admin)
```bash
curl -X POST http://localhost:3000/workflows \
-H "Content-Type: application/json" \
-H "X-API-Key: your-admin-key" \
-d '{
"id": "my_custom_workflow",
"name": "My Custom Workflow",
"description": "A custom txt2img workflow",
"workflowJson": {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": "{{seed}}",
"steps": "{{steps}}",
"cfg": "{{cfg}}",
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
}
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {
"text": "{{prompt}}",
"clip": ["4", 1]
}
}
},
"inputSchema": {
"type": "object",
"fields": {
"prompt": { "type": "string", "required": true, "description": "Text prompt" },
"seed": { "type": "number", "default": -1, "description": "Random seed" },
"steps": { "type": "number", "default": 30, "min": 1, "max": 100 },
"cfg": { "type": "number", "default": 7.0, "min": 1, "max": 20 }
}
},
"defaultParams": {
"seed": -1,
"steps": 30,
"cfg": 7.0
}
}' | jq .
```
### Update Workflow (Admin)
```bash
curl -X PUT http://localhost:3000/workflows/my_custom_workflow \
-H "Content-Type: application/json" \
-H "X-API-Key: your-admin-key" \
-d '{
"name": "My Custom Workflow v2",
"description": "Updated description"
}' | jq .
```
### Delete Workflow (Admin)
```bash
curl -X DELETE http://localhost:3000/workflows/my_custom_workflow \
-H "X-API-Key: your-admin-key" -v
# Returns HTTP 204 No Content
```
### Create Job
```bash
curl -X POST http://localhost:3000/jobs \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"workflowId": "sdxl_realism_v1",
"inputs": {
"prompt": "a photorealistic mountain landscape at sunset, 8k, detailed",
"negative_prompt": "blurry, low quality",
"width": 1024,
"height": 1024,
"steps": 30,
"cfg": 7.0,
"seed": 42
},
"callbackUrl": "https://your-app.com/webhook/comfyui",
"metadata": {
"requestId": "req_abc123",
"userId": "user_456"
}
}' | jq .
```
Response (HTTP 202):
```json
{
"jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "queued",
"etaSeconds": 0,
"pollUrl": "/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```
### Poll Job Status
```bash
curl -s http://localhost:3000/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "X-API-Key: your-api-key" | jq .
```
Response (completed):
```json
{
"jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "succeeded",
"workflowId": "sdxl_realism_v1",
"progress": 100,
"outputs": [
{
"filename": "ComfyUI_00001_.png",
"storagePath": "/data/outputs/a1b2.../uuid.png",
"url": "/outputs/a1b2.../uuid.png",
"size": 1542890,
"sha256": "abc123..."
}
],
"error": null,
"timing": {
"createdAt": "2025-01-15T10:30:00.000Z",
"startedAt": "2025-01-15T10:30:01.000Z",
"completedAt": "2025-01-15T10:30:15.000Z",
"executionTimeMs": 14000
},
"metadata": { "requestId": "req_abc123", "userId": "user_456" }
}
```
### List Jobs (with Filters)
```bash
# All jobs
curl -s "http://localhost:3000/jobs" \
-H "X-API-Key: your-api-key" | jq .
# Filter by status
curl -s "http://localhost:3000/jobs?status=succeeded&limit=10" \
-H "X-API-Key: your-api-key" | jq .
# Filter by workflow and date range
curl -s "http://localhost:3000/jobs?workflowId=sdxl_realism_v1&after=2025-01-01T00:00:00Z&limit=50" \
-H "X-API-Key: your-api-key" | jq .
```
### Get Job Logs
```bash
curl -s http://localhost:3000/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/logs \
-H "X-API-Key: your-api-key" | jq .
```
### Cancel Job
```bash
curl -X POST http://localhost:3000/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/cancel \
-H "X-API-Key: your-api-key" | jq .
```
Response:
```json
{
"jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "cancelled",
"message": "Cancellation requested"
}
```
### List Outputs
```bash
curl -s http://localhost:3000/outputs/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "X-API-Key: your-api-key" | jq .
```
### Download Output (Binary)
```bash
# Stream to file
curl -s http://localhost:3000/outputs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/uuid.png \
-H "X-API-Key: your-api-key" \
-o output.png
# View content type and size
curl -sI http://localhost:3000/outputs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/uuid.png \
-H "X-API-Key: your-api-key"
```
### Download Output (Base64)
```bash
curl -s "http://localhost:3000/outputs/a1b2c3d4-e5f6-7890-abcd-ef1234567890/uuid.png?format=base64" \
-H "X-API-Key: your-api-key" | jq .
```
Response:
```json
{
"filename": "uuid.png",
"contentType": "image/png",
"size": 1542890,
"data": "iVBORw0KGgoAAAANSUhEUgAA..."
}
```
---
## 2. n8n Webhook Workflow
Step-by-step setup for using n8n to trigger image generation and receive results
via webhook.
### Step 1: Create a Webhook Trigger Node
1. Add a **Webhook** node in n8n.
2. Set HTTP Method to `POST`.
3. Set Path to `/comfyui-result`.
4. Under Authentication, select **Header Auth** and configure:
- Name: `X-Signature`
- Value: leave blank (we will verify in code).
5. Copy the **Production URL** (e.g., `https://n8n.your-domain.com/webhook/comfyui-result`).
### Step 2: Create an HTTP Request Node to Submit a Job
1. Add an **HTTP Request** node.
2. Configure:
- Method: `POST`
- URL: `http://your-gateway:3000/jobs`
- Authentication: select **Generic Credential Type** > **Header Auth**
- Name: `X-API-Key`
- Value: `your-api-key`
- Body Content Type: JSON
- Body:
```json
{
"workflowId": "sdxl_realism_v1",
"inputs": {
"prompt": "{{ $json.prompt }}",
"width": 1024,
"height": 1024,
"steps": 30
},
"callbackUrl": "https://n8n.your-domain.com/webhook/comfyui-result",
"metadata": {
"requestId": "{{ $json.requestId }}"
}
}
```
### Step 3: Process the Webhook Callback
Back in the Webhook node, add downstream nodes:
1. **IF** node: Check `{{ $json.status }}` equals `succeeded`.
2. On true branch, **HTTP Request** node to download the image:
- URL: `http://your-gateway:3000{{ $json.result.outputs[0].url }}`
- Headers: `X-API-Key: your-api-key`
- Response Format: File
3. Continue with your pipeline (save to disk, upload to S3, send to Slack, etc.).
### Step 4: HMAC Verification (Optional)
Add a **Code** node before the IF to verify the webhook signature:
```javascript
const crypto = require('crypto');
const secret = 'your-webhook-secret';
const body = JSON.stringify($json);
const expected = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('hex');
const received = $headers['x-signature']?.replace('sha256=', '');
if (received !== expected) {
throw new Error('Invalid webhook signature');
}
return $json;
```
### Step 5: Add WEBHOOK_ALLOWED_DOMAINS
In your gateway `.env`:
```
WEBHOOK_ALLOWED_DOMAINS=n8n.your-domain.com
WEBHOOK_SECRET=your-webhook-secret
```
---
## 3. Supabase Edge Function
A Supabase Edge Function that submits a job to the gateway and returns the job
ID for client-side polling.
### File: `supabase/functions/generate-image/index.ts`
```typescript
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
const GATEWAY_URL = Deno.env.get("COMFYUI_GATEWAY_URL") ?? "http://localhost:3000";
const GATEWAY_KEY = Deno.env.get("COMFYUI_GATEWAY_KEY") ?? "";
interface GenerateRequest {
prompt: string;
negative_prompt?: string;
width?: number;
height?: number;
steps?: number;
workflow_id?: string;
callback_url?: string;
}
serve(async (req: Request) => {
// CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { "Content-Type": "application/json" },
});
}
try {
const body: GenerateRequest = await req.json();
if (!body.prompt || body.prompt.trim().length === 0) {
return new Response(JSON.stringify({ error: "prompt is required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Submit job to ComfyUI Gateway
const jobResponse = await fetch(`${GATEWAY_URL}/jobs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": GATEWAY_KEY,
},
body: JSON.stringify({
workflowId: body.workflow_id ?? "sdxl_realism_v1",
inputs: {
prompt: body.prompt,
negative_prompt: body.negative_prompt ?? "",
width: body.width ?? 1024,
height: body.height ?? 1024,
steps: body.steps ?? 30,
},
callbackUrl: body.callback_url,
metadata: {
requestId: crypto.randomUUID(),
source: "supabase-edge-function",
},
}),
});
if (!jobResponse.ok) {
const errorData = await jobResponse.json();
return new Response(JSON.stringify(errorData), {
status: jobResponse.status,
headers: { "Content-Type": "application/json" },
});
}
const jobData = await jobResponse.json();
return new Response(
JSON.stringify({
job_id: jobData.jobId,
status: jobData.status,
poll_url: `${GATEWAY_URL}${jobData.pollUrl}`,
}),
{
status: 202,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
},
);
} catch (err) {
return new Response(
JSON.stringify({ error: "Internal error", message: String(err) }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
});
```
### Deploy
```bash
# Set secrets in Supabase
supabase secrets set COMFYUI_GATEWAY_URL=https://your-gateway.com
supabase secrets set COMFYUI_GATEWAY_KEY=your-api-key
# Deploy the function
supabase functions deploy generate-image
# Test
curl -X POST https://your-project.supabase.co/functions/v1/generate-image \
-H "Authorization: Bearer YOUR_SUPABASE_ANON_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "a photorealistic cat"}'
```
---
## 4. Claude Code Integration
How to use the ComfyUI Gateway from within a Claude Code session or any
environment where Claude has access to shell tools.
### Generating an Image from Claude Code
When Claude Code has access to `bash` or `curl`, you can generate images directly:
```bash
# 1. Submit a generation job
JOB_RESPONSE=$(curl -s -X POST http://localhost:3000/jobs \
-H "Content-Type: application/json" \
-H "X-API-Key: your-api-key" \
-d '{
"workflowId": "sdxl_realism_v1",
"inputs": {
"prompt": "a professional headshot photo, studio lighting, neutral background",
"width": 1024,
"height": 1024,
"steps": 30
},
"metadata": { "requestId": "claude-session-001" }
}')
JOB_ID=$(echo "$JOB_RESPONSE" | jq -r '.jobId')
echo "Job submitted: $JOB_ID"
# 2. Poll until complete (simple loop)
while true; do
STATUS=$(curl -s "http://localhost:3000/jobs/$JOB_ID" \
-H "X-API-Key: your-api-key" | jq -r '.status')
echo "Status: $STATUS"
if [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] || [ "$STATUS" = "cancelled" ]; then
break
fi
sleep 3
done
# 3. Get the output URL
OUTPUT_URL=$(curl -s "http://localhost:3000/jobs/$JOB_ID" \
-H "X-API-Key: your-api-key" | jq -r '.outputs[0].url')
echo "Output: http://localhost:3000$OUTPUT_URL"
# 4. Download the image
curl -s "http://localhost:3000$OUTPUT_URL" \
-H "X-API-Key: your-api-key" -o generated_image.png
```
### Using Base64 Output in Claude Code
If you need the image as base64 (for inline display or further processing):
```bash
# Get base64 encoded output
B64_DATA=$(curl -s "http://localhost:3000${OUTPUT_URL}?format=base64" \
-H "X-API-Key: your-api-key" | jq -r '.data')
# Save the base64 to a file (can be read by Claude's image viewer)
echo "$B64_DATA" | base64 -d > generated_image.png
```
### Helper Script for Repeated Use
Save as `generate.sh` in your project:
```bash
#!/usr/bin/env bash
set -euo pipefail
GATEWAY_URL="${COMFYUI_GATEWAY_URL:-http://localhost:3000}"
API_KEY="${COMFYUI_API_KEY:-}"
WORKFLOW="${1:-sdxl_realism_v1}"
PROMPT="${2:-a test image}"
OUTPUT="${3:-output.png}"
# Submit
JOB=$(curl -sf -X POST "$GATEWAY_URL/jobs" \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
-d "{
\"workflowId\": \"$WORKFLOW\",
\"inputs\": { \"prompt\": \"$PROMPT\", \"width\": 1024, \"height\": 1024 }
}")
JOB_ID=$(echo "$JOB" | jq -r '.jobId')
echo "Job: $JOB_ID"
# Poll
for i in $(seq 1 120); do
RESULT=$(curl -sf "$GATEWAY_URL/jobs/$JOB_ID" -H "X-API-Key: $API_KEY")
STATUS=$(echo "$RESULT" | jq -r '.status')
if [ "$STATUS" = "succeeded" ]; then
URL=$(echo "$RESULT" | jq -r '.outputs[0].url')
curl -sf "$GATEWAY_URL$URL" -H "X-API-Key: $API_KEY" -o "$OUTPUT"
echo "Saved to $OUTPUT"
exit 0
elif [ "$STATUS" = "failed" ]; then
echo "FAILED: $(echo "$RESULT" | jq '.error')"
exit 1
fi
sleep 2
done
echo "TIMEOUT"
exit 1
```
Usage:
```bash
chmod +x generate.sh
export COMFYUI_API_KEY=your-api-key
./generate.sh sdxl_realism_v1 "a sunset over the ocean" sunset.png
```
---
## 5. Python Requests Client
Full-featured Python client with job submission, polling, and download.
### File: `comfyui_client.py`
```python
"""
ComfyUI Gateway Python Client
Usage:
from comfyui_client import ComfyUIGateway
gw = ComfyUIGateway("http://localhost:3000", api_key="your-key")
result = gw.generate("sdxl_realism_v1", prompt="a mountain landscape")
gw.download(result["outputs"][0]["url"], "output.png")
"""
import time
import uuid
import hashlib
import hmac
import requests
from typing import Any, Optional
class ComfyUIGatewayError(Exception):
"""Base exception for gateway errors."""
def __init__(self, message: str, status_code: int = 0, details: Any = None):
super().__init__(message)
self.status_code = status_code
self.details = details
class ComfyUIGateway:
"""Client for the ComfyUI Gateway REST API."""
def __init__(self, base_url: str, api_key: str = "", timeout: int = 30):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.session = requests.Session()
if api_key:
self.session.headers["X-API-Key"] = api_key
# ── Health & Capabilities ──────────────────────────────────────────────
def health(self) -> dict:
"""Check gateway and ComfyUI health."""
resp = self.session.get(f"{self.base_url}/health", timeout=self.timeout)
resp.raise_for_status()
return resp.json()
def capabilities(self) -> dict:
"""Get available workflows and server capabilities."""
resp = self.session.get(
f"{self.base_url}/capabilities", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
# ── Workflows ──────────────────────────────────────────────────────────
def list_workflows(self) -> list[dict]:
"""List all registered workflows."""
resp = self.session.get(
f"{self.base_url}/workflows", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()["workflows"]
def get_workflow(self, workflow_id: str) -> dict:
"""Get details of a specific workflow."""
resp = self.session.get(
f"{self.base_url}/workflows/{workflow_id}", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()["workflow"]
def create_workflow(
self,
workflow_id: str,
name: str,
workflow_json: dict,
input_schema: Optional[dict] = None,
description: str = "",
default_params: Optional[dict] = None,
) -> dict:
"""Register a new workflow (admin only)."""
body: dict[str, Any] = {
"id": workflow_id,
"name": name,
"workflowJson": workflow_json,
}
if description:
body["description"] = description
if input_schema:
body["inputSchema"] = input_schema
if default_params:
body["defaultParams"] = default_params
resp = self.session.post(
f"{self.base_url}/workflows",
json=body,
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json()["workflow"]
def delete_workflow(self, workflow_id: str) -> bool:
"""Delete a workflow (admin only). Returns True on success."""
resp = self.session.delete(
f"{self.base_url}/workflows/{workflow_id}", timeout=self.timeout
)
return resp.status_code == 204
# ── Jobs ───────────────────────────────────────────────────────────────
def submit_job(
self,
workflow_id: str,
inputs: dict,
params: Optional[dict] = None,
callback_url: Optional[str] = None,
request_id: Optional[str] = None,
metadata: Optional[dict] = None,
) -> dict:
"""Submit a new generation job. Returns immediately with jobId."""
body: dict[str, Any] = {
"workflowId": workflow_id,
"inputs": inputs,
}
if params:
body["params"] = params
if callback_url:
body["callbackUrl"] = callback_url
meta = metadata or {}
if request_id:
meta["requestId"] = request_id
if meta:
body["metadata"] = meta
resp = self.session.post(
f"{self.base_url}/jobs", json=body, timeout=self.timeout
)
if not resp.ok:
data = resp.json()
raise ComfyUIGatewayError(
data.get("message", "Job submission failed"),
status_code=resp.status_code,
details=data,
)
return resp.json()
def get_job(self, job_id: str) -> dict:
"""Get the status and details of a job."""
resp = self.session.get(
f"{self.base_url}/jobs/{job_id}", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
def list_jobs(
self,
status: Optional[str] = None,
workflow_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
) -> dict:
"""List jobs with optional filters."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if workflow_id:
params["workflowId"] = workflow_id
resp = self.session.get(
f"{self.base_url}/jobs", params=params, timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
def cancel_job(self, job_id: str) -> dict:
"""Cancel a queued or running job."""
resp = self.session.post(
f"{self.base_url}/jobs/{job_id}/cancel", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
def get_job_logs(self, job_id: str) -> dict:
"""Get processing logs for a job."""
resp = self.session.get(
f"{self.base_url}/jobs/{job_id}/logs", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()
# ── Outputs ────────────────────────────────────────────────────────────
def list_outputs(self, job_id: str) -> list[dict]:
"""List output files for a completed job."""
resp = self.session.get(
f"{self.base_url}/outputs/{job_id}", timeout=self.timeout
)
resp.raise_for_status()
return resp.json()["files"]
def get_output_base64(self, job_id: str, filename: str) -> dict:
"""Get a single output file as base64."""
resp = self.session.get(
f"{self.base_url}/outputs/{job_id}/{filename}",
params={"format": "base64"},
timeout=self.timeout,
)
resp.raise_for_status()
return resp.json()
def download(self, url_path: str, output_path: str) -> str:
"""Download an output file to a local path. Returns the path."""
full_url = f"{self.base_url}{url_path}" if url_path.startswith("/") else url_path
resp = self.session.get(full_url, timeout=120)
resp.raise_for_status()
with open(output_path, "wb") as f:
f.write(resp.content)
return output_path
# ── High-Level: Generate & Wait ────────────────────────────────────────
def generate(
self,
workflow_id: str,
poll_interval: float = 2.0,
max_wait: float = 300.0,
**inputs: Any,
) -> dict:
"""
Submit a job, poll until complete, and return the full result.
Usage:
result = gw.generate("sdxl_realism_v1", prompt="a sunset", steps=30)
print(result["outputs"])
"""
job = self.submit_job(
workflow_id=workflow_id,
inputs=inputs,
request_id=str(uuid.uuid4()),
)
job_id = job["jobId"]
start = time.time()
while time.time() - start < max_wait:
result = self.get_job(job_id)
status = result["status"]
if status == "succeeded":
return result
elif status in ("failed", "cancelled"):
raise ComfyUIGatewayError(
f"Job {status}: {result.get('error')}",
details=result,
)
time.sleep(poll_interval)
raise ComfyUIGatewayError(f"Job {job_id} timed out after {max_wait}s")
# ── Usage Example ──────────────────────────────────────────────────────────
if __name__ == "__main__":
gw = ComfyUIGateway("http://localhost:3000", api_key="your-api-key")
# Check health
print("Health:", gw.health())
# Generate an image (blocking)
result = gw.generate(
"sdxl_realism_v1",
prompt="a photorealistic golden retriever in a park",
width=1024,
height=1024,
steps=30,
)
# Download the first output
if result["outputs"]:
output_url = result["outputs"][0]["url"]
gw.download(output_url, "generated.png")
print(f"Image saved to generated.png ({result['outputs'][0]['size']} bytes)")
else:
print("No outputs produced")
```
---
## 6. JavaScript/TypeScript Fetch Client
A full client using native `fetch` (Node.js 18+, Deno, Bun, or browsers).
### File: `comfyui-client.ts`
```typescript
/**
* ComfyUI Gateway TypeScript Client
*
* Works with Node.js 18+ (native fetch), Deno, Bun, and browsers.
*/
export interface GatewayConfig {
baseUrl: string;
apiKey?: string;
timeout?: number;
}
export interface JobSubmission {
workflowId: string;
inputs: Record<string, unknown>;
params?: Record<string, unknown>;
callbackUrl?: string;
metadata?: Record<string, unknown>;
}
export interface JobResult {
jobId: string;
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
workflowId: string;
progress: number | null;
outputs: Array<{
filename: string;
storagePath: string;
url: string;
size: number;
sha256: string;
}> | null;
error: unknown;
timing: {
createdAt: string;
startedAt: string | null;
completedAt: string | null;
executionTimeMs: number | null;
};
metadata: Record<string, unknown> | null;
}
export class ComfyUIGateway {
private baseUrl: string;
private headers: Record<string, string>;
private timeout: number;
constructor(config: GatewayConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
this.timeout = config.timeout ?? 30_000;
this.headers = {
"Content-Type": "application/json",
};
if (config.apiKey) {
this.headers["X-API-Key"] = config.apiKey;
}
}
// ── Internal fetch wrapper ──────────────────────────────────────────────
private async request<T>(
method: string,
path: string,
body?: unknown,
options: { timeout?: number; params?: Record<string, string> } = {},
): Promise<T> {
let url = `${this.baseUrl}${path}`;
if (options.params) {
const searchParams = new URLSearchParams(options.params);
url += `?${searchParams.toString()}`;
}
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
options.timeout ?? this.timeout,
);
try {
const resp = await fetch(url, {
method,
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!resp.ok) {
const errorBody = await resp.json().catch(() => ({}));
throw new Error(
`Gateway error ${resp.status}: ${(errorBody as { message?: string }).message ?? resp.statusText}`,
);
}
// 204 No Content
if (resp.status === 204) {
return undefined as T;
}
return (await resp.json()) as T;
} finally {
clearTimeout(timer);
}
}
// ── Health ──────────────────────────────────────────────────────────────
async health(): Promise<{
ok: boolean;
version: string | null;
comfyui: { reachable: boolean; url: string };
uptime: number;
}> {
return this.request("GET", "/health");
}
async capabilities(): Promise<{
workflows: Array<{ id: string; name: string }>;
maxSize: number;
maxBatch: number;
formats: string[];
storageProvider: string;
}> {
return this.request("GET", "/capabilities");
}
// ── Workflows ───────────────────────────────────────────────────────────
async listWorkflows(): Promise<Array<{ id: string; name: string }>> {
const data = await this.request<{ workflows: Array<{ id: string; name: string }> }>(
"GET",
"/workflows",
);
return data.workflows;
}
async getWorkflow(id: string): Promise<Record<string, unknown>> {
const data = await this.request<{ workflow: Record<string, unknown> }>(
"GET",
`/workflows/${encodeURIComponent(id)}`,
);
return data.workflow;
}
// ── Jobs ────────────────────────────────────────────────────────────────
async submitJob(
submission: JobSubmission,
): Promise<{ jobId: string; status: string; pollUrl: string }> {
return this.request("POST", "/jobs", submission);
}
async getJob(jobId: string): Promise<JobResult> {
return this.request("GET", `/jobs/${encodeURIComponent(jobId)}`);
}
async cancelJob(
jobId: string,
): Promise<{ jobId: string; status: string; message: string }> {
return this.request("POST", `/jobs/${encodeURIComponent(jobId)}/cancel`);
}
async listJobs(filters?: {
status?: string;
workflowId?: string;
limit?: number;
offset?: number;
}): Promise<{ jobs: JobResult[]; count: number }> {
const params: Record<string, string> = {};
if (filters?.status) params.status = filters.status;
if (filters?.workflowId) params.workflowId = filters.workflowId;
if (filters?.limit) params.limit = String(filters.limit);
if (filters?.offset) params.offset = String(filters.offset);
return this.request("GET", "/jobs", undefined, { params });
}
// ── Outputs ─────────────────────────────────────────────────────────────
async listOutputs(
jobId: string,
): Promise<Array<{ filename: string; size: number; sha256: string; url: string }>> {
const data = await this.request<{
files: Array<{ filename: string; size: number; sha256: string; url: string }>;
}>("GET", `/outputs/${encodeURIComponent(jobId)}`);
return data.files;
}
async getOutputBase64(
jobId: string,
filename: string,
): Promise<{ filename: string; contentType: string; size: number; data: string }> {
return this.request(
"GET",
`/outputs/${encodeURIComponent(jobId)}/${encodeURIComponent(filename)}`,
undefined,
{ params: { format: "base64" } },
);
}
async downloadOutput(jobId: string, filename: string): Promise<Blob> {
const url = `${this.baseUrl}/outputs/${encodeURIComponent(jobId)}/${encodeURIComponent(filename)}`;
const resp = await fetch(url, { headers: this.headers });
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`);
return resp.blob();
}
// ── High-Level: Generate & Wait ─────────────────────────────────────────
async generate(
workflowId: string,
inputs: Record<string, unknown>,
options: {
pollIntervalMs?: number;
maxWaitMs?: number;
onProgress?: (progress: number | null, status: string) => void;
} = {},
): Promise<JobResult> {
const { pollIntervalMs = 2000, maxWaitMs = 300_000, onProgress } = options;
const job = await this.submitJob({
workflowId,
inputs,
metadata: { requestId: crypto.randomUUID() },
});
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
const result = await this.getJob(job.jobId);
onProgress?.(result.progress, result.status);
if (result.status === "succeeded") {
return result;
}
if (result.status === "failed" || result.status === "cancelled") {
throw new Error(
`Job ${result.status}: ${JSON.stringify(result.error)}`,
);
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(`Job ${job.jobId} timed out after ${maxWaitMs}ms`);
}
}
// ── Usage Example ─────────────────────────────────────────────────────────
async function main() {
const gw = new ComfyUIGateway({
baseUrl: "http://localhost:3000",
apiKey: "your-api-key",
});
// Health check
const health = await gw.health();
console.log("ComfyUI reachable:", health.comfyui.reachable);
// Generate (blocking)
const result = await gw.generate(
"sdxl_realism_v1",
{
prompt: "a photorealistic golden retriever in a park",
width: 1024,
height: 1024,
steps: 30,
},
{
onProgress: (progress, status) =>
console.log(`Status: ${status}, Progress: ${progress ?? "N/A"}%`),
},
);
console.log("Outputs:", result.outputs);
// Download first output as base64
if (result.outputs && result.outputs.length > 0) {
const firstOutput = result.outputs[0];
const b64 = await gw.getOutputBase64(result.jobId, firstOutput.filename);
console.log(`Image: ${b64.contentType}, ${b64.size} bytes`);
}
}
// Uncomment to run:
// main().catch(console.error);
```
---
## 7. Webhook Receiver (Express.js + HMAC)
A standalone Express.js server that receives webhook callbacks from the gateway,
verifies HMAC-SHA256 signatures, and processes results.
### File: `webhook-receiver.js`
```javascript
const express = require("express");
const crypto = require("crypto");
const app = express();
const PORT = process.env.WEBHOOK_PORT || 4000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "your-webhook-secret";
// IMPORTANT: Must use raw body for HMAC computation
app.use(
express.json({
verify: (req, _res, buf) => {
// Store raw body buffer for signature verification
req.rawBody = buf;
},
}),
);
/**
* Verify HMAC-SHA256 signature from the gateway.
*
* The gateway sends: X-Signature: sha256=<hex_digest>
* Computed as: HMAC-SHA256(secret, raw_json_body)
*/
function verifySignature(req) {
const signatureHeader = req.headers["x-signature"];
if (!signatureHeader) {
return { valid: false, reason: "Missing X-Signature header" };
}
// Extract hex digest (format: "sha256=abcdef...")
const receivedSig = signatureHeader.replace("sha256=", "");
// Compute expected signature using raw body bytes
const expectedSig = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(req.rawBody, "utf8")
.digest("hex");
// Constant-time comparison to prevent timing attacks
const valid = crypto.timingSafeEqual(
Buffer.from(receivedSig, "hex"),
Buffer.from(expectedSig, "hex"),
);
return { valid, reason: valid ? null : "Signature mismatch" };
}
/**
* POST /webhook/comfyui
*
* Receives job completion/failure callbacks from the ComfyUI Gateway.
*/
app.post("/webhook/comfyui", (req, res) => {
// 1. Verify HMAC signature
if (WEBHOOK_SECRET) {
const { valid, reason } = verifySignature(req);
if (!valid) {
console.error("Webhook signature verification FAILED:", reason);
return res.status(401).json({ error: "Invalid signature" });
}
}
// 2. Respond immediately (gateway has a 10s timeout)
res.status(200).json({ received: true });
// 3. Process the payload asynchronously
const payload = req.body;
console.log("Webhook received:", {
event: payload.event,
jobId: payload.jobId,
status: payload.status,
});
if (payload.status === "succeeded") {
handleSuccess(payload);
} else if (payload.status === "failed") {
handleFailure(payload);
}
});
async function handleSuccess(payload) {
console.log(`Job ${payload.jobId} succeeded!`);
console.log(`Outputs: ${payload.result?.outputs?.length ?? 0} files`);
// Example: Download the first output
if (payload.result?.outputs?.length > 0) {
const output = payload.result.outputs[0];
console.log(` - ${output.filename}: ${output.size} bytes, URL: ${output.url}`);
// You could download the file here:
// const resp = await fetch(`http://gateway:3000${output.url}`,
// { headers: { "X-API-Key": "your-key" } });
// const buffer = await resp.arrayBuffer();
// fs.writeFileSync(`./downloads/${output.filename}`, Buffer.from(buffer));
}
}
function handleFailure(payload) {
console.error(`Job ${payload.jobId} FAILED:`, payload.error);
// Implement your error handling: retry, notify, log, etc.
}
app.listen(PORT, () => {
console.log(`Webhook receiver listening on port ${PORT}`);
console.log(`Endpoint: POST http://localhost:${PORT}/webhook/comfyui`);
console.log(`HMAC verification: ${WEBHOOK_SECRET ? "ENABLED" : "DISABLED"}`);
});
```
### Run
```bash
npm install express
WEBHOOK_SECRET=your-webhook-secret node webhook-receiver.js
```
---
## 8. Docker Compose
Production-ready Docker Compose configuration with the gateway, ComfyUI, Redis,
and MinIO (S3-compatible storage).
### File: `docker-compose.yml`
```yaml
version: "3.9"
services:
# ── ComfyUI (GPU) ────────────────────────────────────────────────────────
comfyui:
image: ghcr.io/ai-dock/comfyui:latest
container_name: comfyui
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
ports:
- "8188:8188"
volumes:
- comfyui-data:/workspace/ComfyUI
- ./models:/workspace/ComfyUI/models
environment:
- CLI_ARGS=--listen 0.0.0.0 --port 8188
networks:
- comfy-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8188/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# ── ComfyUI Gateway ──────────────────────────────────────────────────────
gateway:
build:
context: .
dockerfile: Dockerfile
container_name: comfyui-gateway
restart: unless-stopped
ports:
- "3000:3000"
environment:
- PORT=3000
- HOST=0.0.0.0
- NODE_ENV=production
- LOG_LEVEL=info
- PRIVACY_MODE=true
# ComfyUI connection (use Docker service name)
- COMFYUI_URL=http://comfyui:8188
- COMFYUI_TIMEOUT_MS=300000
# Authentication
- API_KEYS=sk-admin-key:admin,sk-user-key:user
- JWT_SECRET=change-this-to-a-random-secret
# Redis queue
- REDIS_URL=redis://redis:6379/0
# Database (SQLite inside the container)
- DATABASE_URL=./data/gateway.db
# S3 storage (MinIO)
- STORAGE_PROVIDER=s3
- S3_ENDPOINT=http://minio:9000
- S3_BUCKET=comfyui-outputs
- S3_ACCESS_KEY=minioadmin
- S3_SECRET_KEY=minioadmin
- S3_REGION=us-east-1
# Rate limiting
- RATE_LIMIT_MAX=200
- RATE_LIMIT_WINDOW_MS=60000
# Job limits
- MAX_CONCURRENCY=1
- MAX_IMAGE_SIZE=2048
- MAX_BATCH_SIZE=4
# Cache
- CACHE_ENABLED=true
- CACHE_TTL_SECONDS=86400
# Webhooks
- WEBHOOK_SECRET=your-webhook-hmac-secret
- WEBHOOK_ALLOWED_DOMAINS=*
# CORS
- CORS_ORIGINS=*
volumes:
- gateway-data:/app/data
depends_on:
redis:
condition: service_healthy
comfyui:
condition: service_healthy
minio:
condition: service_healthy
networks:
- comfy-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 15s
timeout: 5s
retries: 3
# ── Redis ────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
container_name: comfyui-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
networks:
- comfy-net
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
# ── MinIO (S3-compatible storage) ────────────────────────────────────────
minio:
image: minio/minio:latest
container_name: comfyui-minio
restart: unless-stopped
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio-data:/data
command: server /data --console-address ":9001"
networks:
- comfy-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 15s
timeout: 5s
retries: 3
# ── MinIO Bucket Init ────────────────────────────────────────────────────
minio-init:
image: minio/mc:latest
container_name: comfyui-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing local/comfyui-outputs;
mc anonymous set download local/comfyui-outputs;
echo 'Bucket created and configured';
"
networks:
- comfy-net
volumes:
comfyui-data:
gateway-data:
redis-data:
minio-data:
networks:
comfy-net:
driver: bridge
```
### Gateway Dockerfile
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --include=optional
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev --include=optional
COPY --from=builder /app/dist/ ./dist/
COPY config/ ./config/
RUN mkdir -p data/outputs data/workflows data/cache
EXPOSE 3000
CMD ["node", "dist/index.js"]
```
### Usage
```bash
# Start everything
docker compose up -d
# Watch logs
docker compose logs -f gateway
# Test health
curl http://localhost:3000/health | jq .
# Generate an image
curl -X POST http://localhost:3000/jobs \
-H "Content-Type: application/json" \
-H "X-API-Key: sk-admin-key" \
-d '{
"workflowId": "sdxl_realism_v1",
"inputs": { "prompt": "a sunset over the ocean" }
}'
# Stop everything
docker compose down
# Stop and remove volumes (full reset)
docker compose down -v
```
---
## 9. Environment Configuration Examples
### Local Development (Minimal)
```bash
# .env for local development
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=debug
COMFYUI_URL=http://127.0.0.1:8188
COMFYUI_TIMEOUT_MS=300000
# No auth in development (all requests treated as admin)
API_KEYS=
JWT_SECRET=
# In-memory queue (no Redis needed)
REDIS_URL=
# SQLite database
DATABASE_URL=./data/gateway.db
# Local file storage
STORAGE_PROVIDER=local
STORAGE_LOCAL_PATH=./data/outputs
# Cache enabled
CACHE_ENABLED=true
CACHE_TTL_SECONDS=3600
# Lenient rate limits
RATE_LIMIT_MAX=1000
RATE_LIMIT_WINDOW_MS=60000
# No webhook restrictions
WEBHOOK_SECRET=
WEBHOOK_ALLOWED_DOMAINS=*
# Allow all CORS
CORS_ORIGINS=*
```
### Production (Full Security)
```bash
# .env for production
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
LOG_LEVEL=info
PRIVACY_MODE=true
COMFYUI_URL=http://comfyui-internal:8188
COMFYUI_TIMEOUT_MS=300000
# API keys with roles
API_KEYS=sk-prod-admin-a1b2c3d4:admin,sk-prod-user-e5f6g7h8:user,sk-prod-service-i9j0k1l2:user
JWT_SECRET=a-very-long-random-secret-at-least-32-chars
# Redis for durable job queue
REDIS_URL=redis://:redis-password@redis-host:6379/0
# Postgres for production database
DATABASE_URL=postgresql://gateway_user:strong_password@postgres-host:5432/comfyui_gateway?sslmode=require
# S3 storage
STORAGE_PROVIDER=s3
S3_ENDPOINT=
S3_BUCKET=my-comfyui-outputs
S3_ACCESS_KEY=<AWS_ACCESS_KEY_ID>
S3_SECRET_KEY=<AWS_SECRET_ACCESS_KEY>
S3_REGION=us-east-1
# Cache
CACHE_ENABLED=true
CACHE_TTL_SECONDS=86400
# Strict rate limits
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW_MS=60000
# Concurrency
MAX_CONCURRENCY=1
MAX_IMAGE_SIZE=2048
MAX_BATCH_SIZE=4
# Webhook security
WEBHOOK_SECRET=webhook-hmac-secret-at-least-32-chars
WEBHOOK_ALLOWED_DOMAINS=api.your-app.com,n8n.your-app.com
# Restricted CORS
CORS_ORIGINS=https://your-app.com,https://admin.your-app.com
```
### Docker (Internal Network)
```bash
# .env for Docker Compose (services communicate via Docker DNS)
PORT=3000
HOST=0.0.0.0
NODE_ENV=production
LOG_LEVEL=info
PRIVACY_MODE=true
# Docker service name instead of localhost
COMFYUI_URL=http://comfyui:8188
COMFYUI_TIMEOUT_MS=300000
API_KEYS=sk-docker-admin:admin,sk-docker-user:user
JWT_SECRET=docker-jwt-secret-change-me
# Redis via Docker service name
REDIS_URL=redis://redis:6379/0
# SQLite (mounted volume)
DATABASE_URL=./data/gateway.db
# MinIO via Docker service name
STORAGE_PROVIDER=s3
S3_ENDPOINT=http://minio:9000
S3_BUCKET=comfyui-outputs
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_REGION=us-east-1
CACHE_ENABLED=true
CACHE_TTL_SECONDS=86400
RATE_LIMIT_MAX=200
RATE_LIMIT_WINDOW_MS=60000
MAX_CONCURRENCY=1
MAX_IMAGE_SIZE=2048
MAX_BATCH_SIZE=4
WEBHOOK_SECRET=docker-webhook-secret
WEBHOOK_ALLOWED_DOMAINS=*
CORS_ORIGINS=*
```
### WSL2 (Gateway in WSL, ComfyUI on Windows)
```bash
# .env for WSL2 setup
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
LOG_LEVEL=debug
# Use Windows host IP from WSL2 perspective
# Get this with: cat /etc/resolv.conf | grep nameserver | awk '{print $2}'
COMFYUI_URL=http://172.25.192.1:8188
COMFYUI_TIMEOUT_MS=300000
API_KEYS=
JWT_SECRET=
REDIS_URL=
DATABASE_URL=./data/gateway.db
STORAGE_PROVIDER=local
STORAGE_LOCAL_PATH=./data/outputs
CACHE_ENABLED=true
CACHE_TTL_SECONDS=3600
RATE_LIMIT_MAX=500
RATE_LIMIT_WINDOW_MS=60000
WEBHOOK_SECRET=
WEBHOOK_ALLOWED_DOMAINS=*
CORS_ORIGINS=*
```
### Multi-GPU (Separate Workers)
```bash
# .env.shared (common settings)
NODE_ENV=production
LOG_LEVEL=info
COMFYUI_URL=http://comfyui:8188
REDIS_URL=redis://redis:6379/0
DATABASE_URL=postgresql://user:pass@postgres:5432/gateway
STORAGE_PROVIDER=s3
S3_ENDPOINT=http://minio:9000
S3_BUCKET=outputs
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
API_KEYS=sk-admin:admin
# Worker 1 (GPU 0) -- start with: CUDA_VISIBLE_DEVICES=0 npm run start:worker
MAX_CONCURRENCY=1
# Worker 2 (GPU 1) -- start with: CUDA_VISIBLE_DEVICES=1 npm run start:worker
# Uses the same .env, same Redis queue -- BullMQ distributes jobs automatically
# API server (no GPU needed) -- start with: npm run start:api
# Serves the REST API; workers handle ComfyUI execution
```