1797 lines
47 KiB
Markdown
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
|
|
```
|