From d60bcb99a07a84aebaa2225427941d306ee7f4f5 Mon Sep 17 00:00:00 2001 From: Edward Niewold Date: Thu, 23 Apr 2026 08:53:56 +0200 Subject: [PATCH] added URL fetch error handling --- README.md | 9 ++- js/subworkflow.js | 163 ++++++++++++++++++++++++++++++++++++++++++---- server_routes.py | 35 +++++++++- workflow_node.py | 40 ++++++++++-- workflow_utils.py | 45 +++++++++++-- 5 files changed, 264 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d6769ac..9912ef7 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,11 @@ Restart ComfyUI after installation or after Python changes. Browser-side updates Use `at execution` set to `reload` when the inner workflow file should be read fresh every run. Use `keep loaded` when the inner workflow should keep its in-memory state between runs. +## Load from URL + +Use the `Subworkflow (from URL)` node to load an inner workflow from a URL instead of a local file. The URL must point to a raw JSON workflow file. +Since the SSL certificate handling of ComfyUI's Python environment may not support all HTTPS endpoints, there is an option to ignore SSL errors when loading from URL. Use this option with caution and only for trusted sources. + ## Use Cases - Reuse a prompt, sampler, or decode chain across multiple workflows. @@ -101,6 +106,4 @@ Use `at execution` set to `reload` when the inner workflow file should be read f - [x] When linked nodes have a mismatch in expected input type and the value provided by Subworkflow Output, no error is given but passed to the next node. Implement type checking and error handling? ### Wish List -- [ ] For each Subworkflow Input node, add an input field in the Subworkflow node for directly setting its value. This also allows for linking of nodes with same type. Similar as subgraphs. -- [ ] Node with similar functionality as `Subworkflow` but where the inner workflow is loaded from a URL instead of a local file. This allows for sharing of inner workflows without sharing the entire outer workflow, and also allows for easier updating of inner workflows since they can be updated at the URL without needing to update the outer workflow. -- [ ] \ No newline at end of file +- [ ] For each Subworkflow Input node, add an input field in the Subworkflow node for directly setting its value. This also allows for linking of nodes with same type. Similar as subgraphs. Check if this conflicts with unlinked inputs. diff --git a/js/subworkflow.js b/js/subworkflow.js index b1f369a..e936c69 100644 --- a/js/subworkflow.js +++ b/js/subworkflow.js @@ -12,10 +12,12 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +import { $el, ComfyDialog } from "../../scripts/ui.js"; const NODE_CONFIGS = { SWF_Subworkflow: { widgetName: "workflow", + refreshWidgetNames: ["workflow"], describe: "workflow", infoPath(value) { return `/subworkflow/info?source=file&workflow=${encodeURIComponent(value)}`; @@ -23,14 +25,22 @@ const NODE_CONFIGS = { }, SWF_SubworkflowFromURL: { widgetName: "url", + refreshWidgetNames: ["url", "verify_ssl"], describe: "workflow URL", - infoPath(value) { - return `/subworkflow/info?source=url&url=${encodeURIComponent(value)}`; + infoPath(value, node) { + const verifySsl = node?.widgets?.find(w => w.name === "verify_ssl")?.value !== false; + return `/subworkflow/info?source=url&url=${encodeURIComponent(value)}&verify_ssl=${verifySsl ? "true" : "false"}`; }, }, }; const MAX_SLOTS = 8; const LOG = "[SWF]"; +let workflowErrorDialog = null; +let lastWorkflowErrorKey = null; + +console.log(LOG, "extension module loaded", { + configuredNodeTypes: Object.keys(NODE_CONFIGS), +}); function slotSummary(slots) { return (slots || []).map((slot, index) => ({ @@ -60,29 +70,118 @@ function setNodeSizeAtLeast(node, minSize) { ]); } -async function fetchWorkflowInfo(config, value) { +function clearWorkflowError() { + lastWorkflowErrorKey = null; + if ( + workflowErrorDialog && + workflowErrorDialog.element && + workflowErrorDialog.element.style.display !== "none" && + typeof workflowErrorDialog.close === "function" + ) { + workflowErrorDialog.close(); + } +} + +function showWorkflowError(config, value, message) { + const text = String(message || `Unable to load ${config.describe}`); + const key = `${config.describe}\n${value || ""}\n${text}`; + if (key === lastWorkflowErrorKey) return; + lastWorkflowErrorKey = key; + + if (!workflowErrorDialog) { + workflowErrorDialog = new ComfyDialog(); + } + + workflowErrorDialog.show( + $el("div", { + style: { + display: "flex", + flexDirection: "column", + gap: "10px", + maxWidth: "720px", + }, + }, [ + $el("h3", { + textContent: "Subworkflow load failed", + style: { + margin: "0", + color: "#fff", + fontFamily: "sans-serif", + }, + }), + $el("div", { + textContent: `Could not load ${config.describe}${value ? `: ${value}` : ""}`, + style: { + color: "#ddd", + fontFamily: "sans-serif", + overflowWrap: "anywhere", + }, + }), + $el("pre", { + textContent: text, + style: { + margin: "0", + padding: "10px", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + maxHeight: "320px", + overflow: "auto", + color: "#fff", + background: "#221111", + border: "1px solid #6f2b2b", + borderRadius: "4px", + }, + }), + ]) + ); +} + +async function fetchWorkflowInfo(config, value, node) { if (!value) { console.log(LOG, `fetchWorkflowInfo: skipping empty ${config.describe} value`); + clearWorkflowError(); return null; } - console.log(LOG, `fetchWorkflowInfo: fetching info for ${config.describe}`, value); + const path = config.infoPath(value, node); + console.log(LOG, `fetchWorkflowInfo: fetching info for ${config.describe}`, { + value, + path, + }); try { - const resp = await api.fetchApi(config.infoPath(value)); - console.log(LOG, `fetchWorkflowInfo: response status ${resp.status} for "${value}"`); + const resp = await api.fetchApi(path); + console.log(LOG, `fetchWorkflowInfo: response status ${resp.status} for "${value}"`, { + url: resp.url, + redirected: resp.redirected, + }); + let data = null; + try { + data = await resp.json(); + console.log(LOG, "fetchWorkflowInfo: response payload", data); + } catch (e) { + console.warn(LOG, `fetchWorkflowInfo: failed to parse JSON response for "${value}"`, e); + } if (!resp.ok) { console.warn(LOG, `fetchWorkflowInfo: HTTP ${resp.status} for "${value}"`); + showWorkflowError(config, value, data?.error || `HTTP ${resp.status} while loading ${config.describe}`); + return null; + } + if (!data) { + const message = `Invalid response while loading ${config.describe}: expected JSON`; + console.warn(LOG, `fetchWorkflowInfo: ${message}`); + showWorkflowError(config, value, message); return null; } - const data = await resp.json(); - console.log(LOG, "fetchWorkflowInfo: response payload", data); if (data.error) { console.warn(LOG, `fetchWorkflowInfo: server error for "${value}":`, data.error); + showWorkflowError(config, value, data.error); return null; } + clearWorkflowError(); console.log(LOG, `fetchWorkflowInfo: got ${data.inputs.length} input(s), ${data.outputs.length} output(s) for "${value}"`); return data; } catch (e) { console.error(LOG, "fetchWorkflowInfo: fetch failed:", e); + showWorkflowError(config, value, e); return null; } } @@ -193,13 +292,25 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType, nodeData) { const config = NODE_CONFIGS[nodeData.name]; - if (!config) return; + if (!config) { + if (String(nodeData.name || "").startsWith("SWF_")) { + console.log(LOG, "beforeRegisterNodeDef: ignoring unconfigured SWF node", nodeData.name); + } + return; + } console.log(LOG, "beforeRegisterNodeDef: patching", nodeData.name, nodeData); const origOnConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (serializedNode) { - console.log(LOG, "onConfigure: restoring node from saved workflow"); + console.log(LOG, "onConfigure: restoring node from saved workflow", { + nodeType: nodeData.name, + nodeId: this.id, + serializedWidgetValues: serializedNode?.widgets_values, + serializedProperties: serializedNode?.properties, + serializedInputCount: serializedNode?.inputs?.length, + serializedOutputCount: serializedNode?.outputs?.length, + }); const savedInputCount = (serializedNode.inputs || []) .filter(i => i.name?.startsWith("swf_in_")).length; @@ -207,10 +318,21 @@ app.registerExtension({ console.log(LOG, `onConfigure: serialized node has ${savedInputCount} dynamic input(s)`); if (origOnConfigure) origOnConfigure.call(this, serializedNode); + console.log(LOG, "onConfigure: after original handler", { + nodeType: nodeData.name, + nodeId: this.id, + widgets: (this.widgets || []).map(w => ({ + name: w.name, + value: w.value, + type: w.type, + hasCallback: !!w.callback, + })), + }); const widget = this.widgets?.find(w => w.name === config.widgetName); const sourceValue = widget?.value; console.log(LOG, `onConfigure: ${config.widgetName} widget value =`, sourceValue, { + nodeType: nodeData.name, nodeId: this.id, widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })), inputs: slotSummary(this.inputs), @@ -218,10 +340,11 @@ app.registerExtension({ }); if (sourceValue) { - fetchWorkflowInfo(config, sourceValue).then(info => { + fetchWorkflowInfo(config, sourceValue, this).then(info => { applyWorkflowInfoOnLoad(this, info, savedInputCount, savedSize); }); } else { + clearWorkflowError(); console.warn(LOG, `onConfigure: ${config.widgetName} widget not found or empty`, { nodeId: this.id }); } }; @@ -229,13 +352,23 @@ app.registerExtension({ const origOnWidgetChanged = nodeType.prototype.onWidgetChanged; nodeType.prototype.onWidgetChanged = function (name, value, oldValue, widget) { if (origOnWidgetChanged) origOnWidgetChanged.call(this, name, value, oldValue, widget); - if (name === config.widgetName) { + console.log(LOG, "onWidgetChanged: observed widget change", { + nodeType: nodeData.name, + nodeId: this.id, + name, + value, + oldValue, + watchedWidget: config.widgetName, + widgetType: widget?.type, + }); + if (config.refreshWidgetNames.includes(name)) { + const sourceValue = this.widgets?.find(w => w.name === config.widgetName)?.value; console.log(LOG, `onWidgetChanged: ${config.widgetName} changed from "${oldValue}" to "${value}"`, { nodeId: this.id, inputs: slotSummary(this.inputs), outputs: slotSummary(this.outputs), }); - fetchWorkflowInfo(config, value).then(info => applyWorkflowInfo(this, info)); + fetchWorkflowInfo(config, sourceValue, this).then(info => applyWorkflowInfo(this, info)); } }; @@ -245,14 +378,16 @@ app.registerExtension({ const widget = this.widgets?.find(w => w.name === config.widgetName); const val = widget?.value; console.log(LOG, `onAdded: node placed on canvas, ${config.widgetName} =`, val, { + nodeType: nodeData.name, nodeId: this.id, widgets: (this.widgets || []).map(w => ({ name: w.name, value: w.value, type: w.type })), inputs: slotSummary(this.inputs), outputs: slotSummary(this.outputs), }); if (val) { - fetchWorkflowInfo(config, val).then(info => applyWorkflowInfo(this, info)); + fetchWorkflowInfo(config, val, this).then(info => applyWorkflowInfo(this, info)); } else { + clearWorkflowError(); console.warn(LOG, `onAdded: ${config.widgetName} widget not found or empty`, { nodeId: this.id }); } }; diff --git a/server_routes.py b/server_routes.py index f11e29a..2e154f1 100644 --- a/server_routes.py +++ b/server_routes.py @@ -17,12 +17,20 @@ from .workflow_utils import ( log = logging.getLogger("ComfyUI-Subworkflow") +def _query_bool(value: str | None, default: bool = True) -> bool: + if value is None: + return default + return str(value).strip().lower() not in {"0", "false", "no", "off"} + + def setup_routes(): try: from server import PromptServer except ImportError: + log.warning("Subworkflow route setup skipped: PromptServer import failed") return routes = PromptServer.instance.routes + log.info("Subworkflow route setup: registering /subworkflow/info and /subworkflow/list") async def get_workflow_info(request: web.Request): """ @@ -32,17 +40,27 @@ def setup_routes(): source = request.rel_url.query.get("source", "file") workflow_name = request.rel_url.query.get("workflow", "") url = request.rel_url.query.get("url", "") + verify_ssl = _query_bool(request.rel_url.query.get("verify_ssl"), True) source_value = url if source == "url" else workflow_name - log.info("Subworkflow route: info requested for source=%r value=%r", source, source_value) + log.info( + "Subworkflow route: info requested path=%r query=%s source=%r value=%r verify_ssl=%s", + str(request.rel_url), + dict(request.rel_url.query), + source, + source_value, + verify_ssl, + ) if source == "url": if not url: log.warning("Subworkflow route: URL info request missing URL") return web.json_response({"inputs": [], "outputs": [], "error": "No workflow URL specified"}) + log.info("Subworkflow route: selected URL loader for %r verify_ssl=%s", url, verify_ssl) loader = load_workflow_url elif source == "file": if not workflow_name: log.warning("Subworkflow route: info request missing workflow name") return web.json_response({"inputs": [], "outputs": [], "error": "No workflow specified"}) + log.info("Subworkflow route: selected file loader for %r", workflow_name) loader = load_workflow_file else: log.warning("Subworkflow route: unsupported source=%r", source) @@ -50,9 +68,22 @@ def setup_routes(): try: if source == "url": - data = await asyncio.to_thread(loader, source_value) + log.info( + "Subworkflow route: dispatching URL load to worker thread for %r verify_ssl=%s", + source_value, + verify_ssl, + ) + data = await asyncio.to_thread(loader, source_value, verify_ssl) else: + log.info("Subworkflow route: loading file workflow synchronously for %r", source_value) data = loader(source_value) + log.info( + "Subworkflow route: loader returned source=%r value=%r type=%s keys=%s", + source, + source_value, + type(data).__name__, + sorted(data.keys())[:12] if isinstance(data, dict) else None, + ) except FileNotFoundError: log.warning("Subworkflow route: workflow file not found: %r", source_value) return web.json_response({"inputs": [], "outputs": [], "error": f"File not found: {source_value}"}) diff --git a/workflow_node.py b/workflow_node.py index 6fa9fe9..39441b7 100644 --- a/workflow_node.py +++ b/workflow_node.py @@ -169,6 +169,7 @@ class SubworkflowFromURL(BaseSubworkflow): @classmethod def define_schema(cls): + log.info("SubworkflowFromURL: define_schema called") return io.Schema( node_id="SWF_SubworkflowFromURL", display_name="Subworkflow (from URL)", @@ -180,6 +181,13 @@ class SubworkflowFromURL(BaseSubworkflow): ), inputs=[ io.String.Input("url", multiline=False, default=""), + io.Boolean.Input( + "verify_ssl", + display_name="verify SSL", + default=True, + label_on="verify", + label_off="skip", + ), _reload_input(), ], outputs=_subworkflow_outputs(), @@ -189,22 +197,44 @@ class SubworkflowFromURL(BaseSubworkflow): @classmethod def _source_label(cls, url: str, **kwargs) -> str: + log.info("SubworkflowFromURL: _source_label url=%r", url) return url @classmethod - def _source_cache_key(cls, url: str, **kwargs) -> str: - return f"url:{url.strip()}" + def _source_cache_key(cls, url: str, verify_ssl: bool = True, **kwargs) -> str: + log.info( + "SubworkflowFromURL: _source_cache_key url=%r stripped=%r verify_ssl=%s", + url, + url.strip(), + verify_ssl, + ) + return f"url:{url.strip()}:verify_ssl:{bool(verify_ssl)}" @classmethod - def _load_source(cls, url: str, **kwargs) -> dict: + def _load_source(cls, url: str, verify_ssl: bool = True, **kwargs) -> dict: + log.info( + "SubworkflowFromURL: _load_source url=%r verify_ssl=%s kwargs_keys=%s", + url, + verify_ssl, + sorted(kwargs.keys()), + ) if not url: + log.warning("SubworkflowFromURL: _load_source missing URL") raise ValueError("No workflow URL specified.") - return load_workflow_url(url.strip()) + return load_workflow_url(url.strip(), verify_ssl=bool(verify_ssl)) @classmethod - def execute(cls, url: str, reload_each_execution=True, **kwargs): + def execute(cls, url: str, verify_ssl=True, reload_each_execution=True, **kwargs): + log.info( + "SubworkflowFromURL: execute called url=%r verify_ssl=%s reload_each_execution=%s dynamic_inputs=%s", + url, + verify_ssl, + reload_each_execution, + sorted(k for k in kwargs if k.startswith("swf_in_")), + ) return cls._execute_source( url=url, + verify_ssl=verify_ssl, reload_each_execution=reload_each_execution, **kwargs, ) diff --git a/workflow_utils.py b/workflow_utils.py index 092137c..2833a94 100644 --- a/workflow_utils.py +++ b/workflow_utils.py @@ -7,6 +7,7 @@ import logging import os import random import re +import ssl import urllib.error import urllib.parse import urllib.request @@ -59,44 +60,80 @@ def load_workflow_file(filename: str) -> dict: return data -def load_workflow_url(url: str) -> dict: +def load_workflow_url(url: str, verify_ssl: bool = True) -> dict: + log.info("Subworkflow URL loader: entered with url=%r verify_ssl=%s", url, verify_ssl) parsed = urllib.parse.urlparse(url) + log.info( + "Subworkflow URL loader: parsed scheme=%r netloc=%r path=%r query_present=%s", + parsed.scheme, + parsed.netloc, + parsed.path, + bool(parsed.query), + ) if parsed.scheme not in {"http", "https"} or not parsed.netloc: + log.warning("Subworkflow URL loader: rejecting invalid absolute URL %r", url) raise ValueError("Workflow URL must be an absolute http:// or https:// URL.") - log.info("Subworkflow: loading workflow URL %r", url) + log.info( + "Subworkflow URL loader: opening URL %r timeout=%s max_bytes=%s verify_ssl=%s", + url, + URL_WORKFLOW_TIMEOUT, + MAX_URL_WORKFLOW_BYTES, + verify_ssl, + ) request = urllib.request.Request( url, headers={"User-Agent": "ComfyUI-Subworkflow/1.0"}, ) + context = None if verify_ssl else ssl._create_unverified_context() + if not verify_ssl: + log.warning("Subworkflow URL loader: SSL certificate verification disabled for %r", url) try: - with urllib.request.urlopen(request, timeout=URL_WORKFLOW_TIMEOUT) as response: + with urllib.request.urlopen(request, timeout=URL_WORKFLOW_TIMEOUT, context=context) as response: status = getattr(response, "status", 200) + log.info( + "Subworkflow URL loader: response opened status=%s content_type=%r content_length=%r final_url=%r", + status, + response.headers.get("Content-Type"), + response.headers.get("Content-Length"), + response.geturl(), + ) if status < 200 or status >= 300: + log.warning("Subworkflow URL loader: rejecting HTTP status %s for %r", status, url) raise ValueError(f"Workflow URL returned HTTP {status}.") content_length = response.headers.get("Content-Length") if content_length and int(content_length) > MAX_URL_WORKFLOW_BYTES: + log.warning( + "Subworkflow URL loader: rejecting oversized Content-Length=%s for %r", + content_length, + url, + ) raise ValueError( f"Workflow URL response is too large " f"({content_length} bytes; limit {MAX_URL_WORKFLOW_BYTES})." ) raw = response.read(MAX_URL_WORKFLOW_BYTES + 1) + log.info("Subworkflow URL loader: read %d byte(s) from %r", len(raw), url) except urllib.error.HTTPError as e: + log.warning("Subworkflow URL loader: HTTPError for %r: %s", url, e) raise ValueError(f"Workflow URL returned HTTP {e.code}.") from e except urllib.error.URLError as e: + log.warning("Subworkflow URL loader: URLError for %r: %s", url, e) raise ValueError(f"Failed to load workflow URL: {e.reason}") from e if len(raw) > MAX_URL_WORKFLOW_BYTES: + log.warning("Subworkflow URL loader: rejecting oversized body for %r", url) raise ValueError( f"Workflow URL response is too large " f"(limit {MAX_URL_WORKFLOW_BYTES} bytes)." ) + log.info("Subworkflow URL loader: decoding JSON from %r", url) data = json.loads(raw.decode("utf-8")) log.info( - "Subworkflow: loaded workflow URL %r format=%s top_level_keys=%s", + "Subworkflow URL loader: loaded workflow URL %r format=%s top_level_keys=%s", url, "UI" if is_ui_format(data) else "API", sorted(data.keys())[:12],