diff --git a/README.md b/README.md index a6a3161..fcbc35a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# comfy-api-addons -A custom node for ComfyUI that offers additional API functionality not provided by the internal API or existing custom nodes. +# ComfyUI Mobile UI – Workflow Endpoints + +This is a small ComfyUI custom node that adds HTTP endpoints for a standalone client (like the mobile UI in this repo) to: + +- **List** workflow `.json` files stored on the ComfyUI machine +- **Fetch** a workflow JSON +- **Fetch an API-compatible prompt JSON** for a workflow (either already API prompt, or converted via an optional converter endpoint) +- **Convert posted workflow JSON** into an API prompt JSON + +## Endpoints + +- `GET /mobileui/info` + - Returns version + detected workflow directories + endpoint hints. + +- `GET /mobileui/workflows` + - Returns an array of objects: + - `name` (string): workflow name / relative path without `.json` + - `bytes` (number|null): file size + - `mtime` (number|null): modified time (unix seconds) + +- `GET /mobileui/workflows/{name}` + - Returns the raw workflow JSON file. + - `{name}` may include subfolders and may optionally include `.json`. + +- `GET /mobileui/workflows_api/{name}` + - Returns an **API prompt JSON**. + - Behavior: + 1) If the JSON already looks like an API prompt (contains `class_type` entries), it is returned as-is. + 2) Otherwise, the node tries to POST the workflow JSON to `POST /workflow/convert`. + - This requires the optional converter extension: + https://github.com/SethRobinson/comfyui-workflow-to-api-converter-endpoint + +- `POST /mobileui/convert` + - Accepts any workflow JSON payload in the request body and returns an API prompt JSON. + - Uses the same conversion rules as `workflows_api`. + +- `OPTIONS /mobileui/{tail:.*}` + - CORS preflight helper. + +## Install + +Copy the folder `comfyui_mobileui_workflow_list_endpoint` into: + +`ComfyUI/custom_nodes/` + +Then restart ComfyUI. + +## CORS + +This node can add CORS headers itself if you set: + +- `MOBILEUI_CORS_ORIGIN=http://localhost:5173` (or `*`) + +You can also use ComfyUI's built-in CORS flag if you prefer. + +## Configuration + +By default, it scans common locations relative to the ComfyUI root: + +- `/workflows` +- `/user/workflows` +- `/examples` +- `/examples/workflows` +- `/pysssss-workflows` (used by ComfyUI-Custom-Scripts) + +You can override/add workflow directories with environment variables: + +- `COMFY_WORKFLOWS_DIR=/path/to/workflows` +- `COMFY_WORKFLOWS_DIRS=/path/one,/path/two` + +## Notes + +- Includes basic path traversal protection. +- Only serves files ending in `.json`. +- Conversion requires `POST /workflow/convert` to exist. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9811141 --- /dev/null +++ b/__init__.py @@ -0,0 +1,295 @@ +from server import PromptServer +from aiohttp import web, ClientSession +import os +import inspect +import json + +"""ComfyUI custom node: Mobile UI Workflow Endpoints + +This custom node registers HTTP routes that allow a standalone client to: +- List workflow JSON files on the ComfyUI machine +- Fetch the raw workflow JSON +- Fetch an API-compatible prompt JSON (if already API prompt OR if a converter endpoint is available) +- Convert a posted workflow JSON payload to an API prompt JSON + +Routes: +- GET /mobileui/info +- GET /mobileui/workflows +- GET /mobileui/workflows/{name:.+} +- GET /mobileui/workflows_api/{name:.+} +- POST /mobileui/convert +- OPTIONS /mobileui/{tail:.*} + +Config: +- COMFY_WORKFLOWS_DIR : single directory +- COMFY_WORKFLOWS_DIRS : comma-separated list of directories +- MOBILEUI_CORS_ORIGIN : if set, adds Access-Control-Allow-Origin for these routes + +Notes: +- This node only serves files ending in .json. +- Includes path traversal protection. +""" + +NODE_VERSION = "0.3.0" + + +def _comfy_root_dir() -> str: + # server.py lives under the ComfyUI root in typical installs + server_py = inspect.getfile(PromptServer) + return os.path.dirname(os.path.abspath(server_py)) + + +def _resolve_workflow_dirs() -> list[str]: + dirs: list[str] = [] + + env_single = os.environ.get("COMFY_WORKFLOWS_DIR") + env_multi = os.environ.get("COMFY_WORKFLOWS_DIRS") + + if env_multi: + dirs.extend([d.strip() for d in env_multi.split(",") if d.strip()]) + if env_single: + dirs.append(env_single) + + root = _comfy_root_dir() + candidates = [ + os.path.join(root, "workflows"), + os.path.join(root, "user", "workflows"), + os.path.join(root, "examples"), + os.path.join(root, "examples", "workflows"), + # popular location used by ComfyUI-Custom-Scripts + os.path.join(root, "pysssss-workflows"), + ] + dirs.extend(candidates) + + normalized: list[str] = [] + seen: set[str] = set() + for d in dirs: + if not d: + continue + absd = os.path.abspath(d) + if os.path.isdir(absd) and absd not in seen: + seen.add(absd) + normalized.append(absd) + + return normalized + + +_BASES = _resolve_workflow_dirs() + + +def _cors_origin() -> str | None: + # If you start ComfyUI with --enable-cors-header this may be redundant, + # but having per-route CORS makes the node easier to drop-in. + return os.environ.get("MOBILEUI_CORS_ORIGIN") + + +def _add_cors_headers(resp: web.StreamResponse): + origin = _cors_origin() + if not origin: + return resp + + resp.headers["Access-Control-Allow-Origin"] = origin + resp.headers["Vary"] = "Origin" + resp.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS" + resp.headers["Access-Control-Allow-Headers"] = "Content-Type" + resp.headers["Access-Control-Allow-Credentials"] = "true" + return resp + + +def _list_workflows_with_meta() -> list[dict]: + items: list[dict] = [] + seen: set[str] = set() + + for base in _BASES: + for dirpath, _dirnames, filenames in os.walk(base): + for fname in filenames: + if not fname.lower().endswith(".json"): + continue + full = os.path.join(dirpath, fname) + rel = os.path.relpath(full, base) + rel_no_ext = os.path.splitext(rel)[0] + rel_norm = rel_no_ext.replace(os.sep, "/") + if rel_norm in seen: + continue + + seen.add(rel_norm) + + try: + st = os.stat(full) + items.append( + { + "name": rel_norm, + "bytes": int(st.st_size), + "mtime": int(st.st_mtime), + } + ) + except Exception: + items.append({"name": rel_norm, "bytes": None, "mtime": None}) + + items.sort(key=lambda x: x.get("name") or "") + return items + + +def _safe_join_and_validate(base: str, rel: str) -> str | None: + # Prevent path traversal attacks. + full = os.path.abspath(os.path.join(base, rel)) + base_abs = os.path.abspath(base) + try: + if os.path.commonpath([full, base_abs]) != base_abs: + return None + except Exception: + return None + return full + + +def _find_workflow_file(name: str) -> str | None: + filename = name if name.lower().endswith(".json") else f"{name}.json" + for base in _BASES: + full = _safe_join_and_validate(base, filename) + if not full: + continue + if os.path.isfile(full): + return full + return None + + +def _looks_like_api_prompt(payload) -> bool: + # Heuristic: a ComfyUI API prompt is usually an object keyed by node-id strings, + # with values containing at least class_type. + if not isinstance(payload, dict) or not payload: + return False + sample_key = next(iter(payload.keys())) + if not isinstance(sample_key, str): + return False + sample_val = payload.get(sample_key) + return isinstance(sample_val, dict) and ("class_type" in sample_val) + + +def _converter_url(request) -> str: + # Use the same host/scheme the client used. + return f"{request.scheme}://{request.host}/workflow/convert" + + +async def _convert_via_converter_endpoint(request, workflow_json): + url = _converter_url(request) + async with ClientSession() as session: + async with session.post(url, json=workflow_json) as r: + if r.status != 200: + text = await r.text() + raise RuntimeError(f"conversion failed ({r.status}): {text}") + return await r.json() + + +@PromptServer.instance.routes.get("/mobileui/info") +async def _mobileui_info(_request): + payload = { + "name": "comfyui_mobileui_workflow_endpoints", + "version": NODE_VERSION, + "workflow_dirs": _BASES, + "endpoints": { + "workflows": "/mobileui/workflows", + "workflow": "/mobileui/workflows/{name}", + "workflow_api": "/mobileui/workflows_api/{name}", + "convert": "/mobileui/convert", + }, + } + resp = web.json_response(payload) + return _add_cors_headers(resp) + + +@PromptServer.instance.routes.get("/mobileui/workflows") +async def _mobileui_list_workflows(_request): + # Returns [{ name, bytes, mtime }, ...] + items = _list_workflows_with_meta() + resp = web.json_response(items) + return _add_cors_headers(resp) + + +@PromptServer.instance.routes.get("/mobileui/workflows/") +async def _mobileui_list_workflows_slash(request): + return await _mobileui_list_workflows(request) + + +@PromptServer.instance.routes.get("/mobileui/workflows/{name:.+}") +async def _mobileui_get_workflow(request): + name = request.match_info["name"].lstrip("/\\") + path = _find_workflow_file(name) + if not path: + resp = web.Response(status=404, text="workflow not found") + return _add_cors_headers(resp) + + resp = web.FileResponse(path) + return _add_cors_headers(resp) + + +@PromptServer.instance.routes.get("/mobileui/workflows_api/{name:.+}") +async def _mobileui_get_workflow_api(request): + name = request.match_info["name"].lstrip("/\\") + path = _find_workflow_file(name) + if not path: + resp = web.Response(status=404, text="workflow not found") + return _add_cors_headers(resp) + + # Load JSON + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + resp = web.Response(status=400, text="invalid json") + return _add_cors_headers(resp) + + # Already API prompt + if _looks_like_api_prompt(data): + resp = web.json_response(data) + return _add_cors_headers(resp) + + # Attempt conversion via SethRobinson's /workflow/convert endpoint if installed. + try: + converted = await _convert_via_converter_endpoint(request, data) + resp = web.json_response(converted) + return _add_cors_headers(resp) + except Exception as e: + resp = web.Response( + status=501, + text=( + "converter endpoint not available or conversion failed. " + "Install comfyui-workflow-to-api-converter-endpoint to enable /workflow/convert. " + f"Details: {e}" + ), + ) + return _add_cors_headers(resp) + + +@PromptServer.instance.routes.post("/mobileui/convert") +async def _mobileui_convert(request): + # Accepts posted JSON body and returns an API prompt JSON. + try: + data = await request.json() + except Exception: + resp = web.Response(status=400, text="invalid json") + return _add_cors_headers(resp) + + if _looks_like_api_prompt(data): + resp = web.json_response(data) + return _add_cors_headers(resp) + + try: + converted = await _convert_via_converter_endpoint(request, data) + resp = web.json_response(converted) + return _add_cors_headers(resp) + except Exception as e: + resp = web.Response( + status=501, + text=( + "converter endpoint not available or conversion failed. " + "Install comfyui-workflow-to-api-converter-endpoint to enable /workflow/convert. " + f"Details: {e}" + ), + ) + return _add_cors_headers(resp) + + +@PromptServer.instance.routes.options("/mobileui/{tail:.*}") +async def _mobileui_options(_request): + resp = web.Response(status=204) + return _add_cors_headers(resp) \ No newline at end of file