# 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; params?: Record; callbackUrl?: string; metadata?: Record; } 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 | null; } export class ComfyUIGateway { private baseUrl: string; private headers: Record; 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( method: string, path: string, body?: unknown, options: { timeout?: number; params?: Record } = {}, ): Promise { 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> { const data = await this.request<{ workflows: Array<{ id: string; name: string }> }>( "GET", "/workflows", ); return data.workflows; } async getWorkflow(id: string): Promise> { const data = await this.request<{ workflow: Record }>( "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 { 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 = {}; 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> { 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 { 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, options: { pollIntervalMs?: number; maxWaitMs?: number; onProgress?: (progress: number | null, status: string) => void; } = {}, ): Promise { 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= * 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= S3_SECRET_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 ```