- Add ComfyUI-PreviewBridgeExtended as new submodule in nodes/
- Update __init__.py to load preview-bridge-extended
- Add local path resolution to dev_mode.py (dev_mode_local.yaml
support, --local flag, get_resolved_mappings)
- Add dev_mode_local.yaml.example template
- Add docs/ directory:
- dynamic-node-loading.md: aggregator architecture guide for
building multi-node ComfyUI collections
- git-submodule-workaround.md: workaround for silent git
submodule add failure on Git for Windows
- developer-tools.md: guide to all scripts/ tooling
- Update README with new node, architecture details, dev mode
section, and doc links
- Fix Buy Me A Coffee badge URL
- Remove stale fit-mask-to-image warning in publish workflow
- Update version.py, pyproject.toml, CHANGELOG.md
15 KiB
Dynamic Node Loading Architecture
This document describes how DazzleNodes aggregates independently developed ComfyUI custom nodes into a single installable package using git submodules and dynamic module loading. This approach can serve as a template for anyone building their own multi-node ComfyUI collection.
Overview
DazzleNodes solves a packaging problem: each custom node is developed in its own git repository with its own release cycle, but users want a single install. The solution has three layers:
- Git submodules — each node lives in
nodes/<name>/as an independent repo - Dynamic module loading — the root
__init__.pydiscovers and imports each node at startup usingimportlib - Web resource syncing — JavaScript/CSS files from each submodule are synced to a unified
web/directory for ComfyUI to serve
Directory Structure
DazzleNodes/
├── __init__.py # Aggregator: loads all nodes dynamically
├── version.py # Collection version (auto-updated by hooks)
├── pyproject.toml # ComfyUI Registry metadata
├── web/ # Generated (gitignored) - synced JS/CSS
│ ├── core/ # Shared libraries
│ ├── smart-resolution-calc/ # Synced from nodes/smart-resolution-calc/web/
│ └── ...
├── web_src/
│ └── core/ # Shared JS libraries (e.g., dazzle_core.js)
├── nodes/ # Git submodules
│ ├── smart-resolution-calc/ # Each is an independent git repo
│ ├── fit-mask-to-image/
│ ├── dazzle-comfy-plasma-fast/
│ └── preview-bridge-extended/
├── scripts/
│ ├── sync_web_files.py # Web resource sync script
│ ├── dev_mode.py # Dev/publish mode toggle
│ └── update-version.sh # Version management
└── docs/ # Documentation
Layer 1: Submodule Contract
Each node submodule must expose a standard ComfyUI interface in its __init__.py:
# Required: ComfyUI discovers nodes through these dictionaries
NODE_CLASS_MAPPINGS = {
"MyNodeName": MyNodeClass,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MyNodeName": "My Node (Display Name)",
}
Beyond this, each submodule is free to organize its internals however it wants. Common patterns seen across DazzleNodes submodules:
nodes/my-node/
├── __init__.py # Exports NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
├── py/ # Python source (node logic)
│ ├── __init__.py # May re-export mappings
│ └── my_node.py # Node implementation
├── web/ # JavaScript for custom widgets (optional)
│ └── my_node.js
├── version.py # Node-level version tracking (optional)
└── pyproject.toml # Standalone registry metadata (optional)
Optional: WEB_DIRECTORY
If the node has JavaScript files (custom widgets, UI extensions), it should declare:
WEB_DIRECTORY = "./web"
This tells ComfyUI where to find client-side code. The DazzleNodes sync system also reads from this location.
Layer 2: Dynamic Module Loading
The root __init__.py uses importlib to load each submodule at startup. This is necessary because Python module names can't contain hyphens, but the submodule directories use them (e.g., smart-resolution-calc).
The Loader
import importlib.util
import sys
from pathlib import Path
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_nodes_dir = Path(__file__).parent / "nodes"
def load_node_module(node_dir_name, display_name):
"""Load a node module from a directory (even with hyphens in name)."""
node_path = _nodes_dir / node_dir_name
init_file = node_path / "__init__.py"
if not init_file.exists():
raise FileNotFoundError(f"__init__.py not found in {node_path}")
# Load using importlib — converts hyphens to underscores for module name
spec = importlib.util.spec_from_file_location(
node_dir_name.replace("-", "_"),
init_file
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
# Merge node mappings into the collection
if hasattr(module, 'NODE_CLASS_MAPPINGS'):
NODE_CLASS_MAPPINGS.update(module.NODE_CLASS_MAPPINGS)
else:
raise AttributeError(f"Module {node_dir_name} has no NODE_CLASS_MAPPINGS")
if hasattr(module, 'NODE_DISPLAY_NAME_MAPPINGS'):
NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS)
return len(module.NODE_CLASS_MAPPINGS)
Registering a Node
Each node is loaded with error isolation so one failing node doesn't break the collection:
try:
num_nodes = load_node_module("my-node-name", "My Node Display Name")
_loaded_nodes.append(f"My Node ({num_nodes} nodes)")
except Exception as e:
_failed_nodes.append(("My Node", str(e)))
print(f"[DazzleNodes] [WARN] Could not load My Node: {e}")
Why This Works
importlib.util.spec_from_file_locationloads a module from an arbitrary file path, bypassing Python's normal import resolution that chokes on hyphens- Each submodule is registered in
sys.modulesso its internal relative imports work correctly - The
NODE_CLASS_MAPPINGSdictionaries are merged, so ComfyUI sees all nodes as coming from one package
Layer 3: JavaScript — The Tricky Part
JavaScript is the most complex layer because ES6 import paths are relative to the URL the script is served from, and that URL changes depending on whether a node is installed standalone or as part of DazzleNodes.
The Problem
When ComfyUI loads a custom node's JavaScript, the URL path depends on the installation:
| Mode | URL Path |
|---|---|
| Standalone | /extensions/smart-resolution-calc/smart_resolution_calc.js |
| DazzleNodes | /extensions/DazzleNodes/smart-resolution-calc/smart_resolution_calc.js |
Static ES6 imports break because they assume a fixed nesting depth:
// This works standalone (3 path segments):
import { app } from "../../scripts/app.js";
// /extensions/smart-resolution-calc/file.js → ../../ → /scripts/app.js ✓
// But fails inside DazzleNodes (4 path segments):
// /extensions/DazzleNodes/smart-resolution-calc/file.js → ../../ → /extensions/scripts/app.js ✗ (404)
The Solution: Dynamic Import Depth Detection
DazzleNodes uses import.meta.url to calculate the correct path prefix at runtime. The core helper lives in web_src/core/dazzle_core.js:
function calculateImportDepth() {
const currentPath = import.meta.url;
const urlParts = new URL(currentPath).pathname.split('/').filter(p => p);
// Each part (including filename) requires one ../ to traverse up to root
const depth = urlParts.length;
return '../'.repeat(depth);
}
This works at any nesting depth:
| URL | Parts | Prefix | Resolves To |
|---|---|---|---|
/extensions/my-node/file.js |
3 | ../../../ |
/scripts/app.js ✓ |
/extensions/DazzleNodes/my-node/file.js |
4 | ../../../../ |
/scripts/app.js ✓ |
/extensions/A/B/my-node/file.js |
5 | ../../../../../ |
/scripts/app.js ✓ |
Implementation Pattern: Inline Helper
Each node that needs ComfyUI imports wraps its code in an async IIFE with a local depth calculator. This is the pattern used by smart-resolution-calc and fit-mask-to-image:
// Dynamic import helper for standalone vs DazzleNodes compatibility
async function importComfyCore() {
const currentPath = import.meta.url;
const urlParts = new URL(currentPath).pathname.split('/').filter(p => p);
const depth = urlParts.length;
const prefix = '../'.repeat(depth);
const [appModule, tooltipModule] = await Promise.all([
import(`${prefix}scripts/app.js`),
import('./tooltip_content.js') // Local imports use relative paths
]);
return {
app: appModule.app,
TOOLTIP_CONTENT: tooltipModule.TOOLTIP_CONTENT
};
}
// Wrap entire extension in async IIFE
(async () => {
const { app, TOOLTIP_CONTENT } = await importComfyCore();
app.registerExtension({
name: "MyNode.Extension",
// ... extension code using app, ComfyWidgets, etc.
});
})().catch(error => {
console.error("[MyNode] Failed to load extension:", error);
});
Implementation Pattern: Shared Core Library
For new nodes, dazzle_core.js exports helpers via window.DazzleNodesCore:
// In your node's JavaScript:
(async () => {
const { app, ComfyWidgets } = await window.DazzleNodesCore.importComfyCore();
app.registerExtension({
name: "MyNode.Extension",
// ...
});
})();
The shared library provides:
calculateImportDepth()— returns the../prefix stringimportComfyModules('app', 'widgets', ...)— imports multiple ComfyUI modulesimportComfyModule('app')— imports a single moduleimportComfyCore()— shortcut forapp+widgets
Standalone Compatibility
A common concern: does switching from static import statements to dynamic import() break the node when installed standalone (outside of DazzleNodes)?
No. The depth calculation is based entirely on the runtime URL, so the same code works in both contexts without any configuration or mode detection:
- Standalone install (
/extensions/my-node/file.js): 3 segments →../../../scripts/app.js→/scripts/app.js✓ - DazzleNodes install (
/extensions/DazzleNodes/my-node/file.js): 4 segments →../../../../scripts/app.js→/scripts/app.js✓
The node doesn't need to know which mode it's in. There's no flag, no environment variable, and no build step. The same .js file works identically in both scenarios.
The only behavioral difference is that dynamic import() is asynchronous (returns a Promise), which is why the entire extension must be wrapped in an async IIFE. This is a one-time structural change when adopting the pattern — after that, the node works everywhere.
Gotcha: Depth Calculation Bug
An earlier version used depth = urlParts.length - 2, reasoning that the filename and root shouldn't count. This was wrong — every path segment (including the filename) requires one ../ to reach root. The correct formula is simply depth = urlParts.length.
Web Resource Syncing
Since DazzleNodes aggregates multiple nodes, their JavaScript files need to be collected into one web/ directory that ComfyUI can serve.
The scripts/sync_web_files.py script handles this automatically at startup (called from __init__.py):
- Shared libraries from
web_src/core/are synced first (highest priority) - Submodule web files from each
nodes/*/web/are synced intoweb/<node-name>/ - Files are always copied (ComfyUI's web server doesn't reliably follow symlinks)
- An MD5 cache hash (
web/.sync_hash) avoids unnecessary re-syncing on every startup - Sync uses an atomic temp-directory swap to prevent partial states
The web/ directory is gitignored since it's generated at runtime.
File discovery is recursive (rglob("**/*.js")), so nodes can organize JavaScript in subdirectories (e.g., web/managers/, web/utils/) and the directory structure is preserved in the synced output.
Building Your Own Node Collection
To create a similar multi-node package:
1. Create the collection repo
mkdir MyNodeCollection
cd MyNodeCollection
git init
2. Add nodes as submodules
git submodule add https://github.com/you/node-one.git nodes/node-one
git submodule add https://github.com/you/node-two.git nodes/node-two
Note: See git-submodule-workaround.md if
git submodule addsilently fails.
3. Create the aggregator __init__.py
Use the load_node_module pattern shown above. Key points:
- Replace hyphens with underscores in module names
- Wrap each load in try/except for fault isolation
- Merge
NODE_CLASS_MAPPINGSfrom each submodule into the collection's dict - Report load status so users can diagnose issues
4. Ensure each node exports the contract
Each submodule's __init__.py must export NODE_CLASS_MAPPINGS (required) and NODE_DISPLAY_NAME_MAPPINGS (recommended).
5. Handle JavaScript (if applicable)
If any nodes have custom widgets or UI, you need to solve two problems:
Problem 1: Collecting JS files from multiple submodules into one web/ directory.
Set WEB_DIRECTORY = "./web" in the root __init__.py, then either:
- Copy DazzleNodes'
sync_web_files.pyapproach (auto-syncsnodes/*/web/→web/*/at startup) - Or manually maintain a
web/directory
Problem 2: ES6 import paths break at different URL depths.
Each node's JavaScript must use dynamic imports with depth detection instead of static import statements. Without this, a node that works standalone will produce 404 errors when loaded inside your collection (or vice versa). See Layer 3: JavaScript above for the full pattern.
The inline helper approach (async IIFE with import.meta.url depth calculation) is recommended because it makes each node self-contained — it works regardless of how the node is installed.
6. Register with ComfyUI Registry
Add a pyproject.toml with [tool.comfy] section for the ComfyUI Registry:
[project]
name = "my-node-collection"
version = "1.0.0"
[tool.comfy]
PublisherId = "your-id"
DisplayName = "My Node Collection"
Key Design Decisions
| Decision | Rationale |
|---|---|
| Git submodules over monorepo | Each node has independent history, releases, and can be installed standalone |
importlib over standard imports |
Python can't import from directories with hyphens; importlib bypasses this |
| Fault-isolated loading | One broken node shouldn't prevent the others from loading |
| Web sync at startup | Avoids committing generated files; adapts to dev/prod mode automatically |
Dynamic JS imports via import.meta.url |
Static ES6 imports break at different URL depths; runtime detection works everywhere |
| Always copy (never symlink) web files | ComfyUI's web server doesn't reliably follow symlinks |
| Merged NODE_CLASS_MAPPINGS | ComfyUI sees one flat namespace — no nesting required |
Limitations
- Namespace collisions: If two submodules define the same node class name, the last one loaded wins. Use unique prefixes.
- Startup cost: Each submodule is imported at ComfyUI startup. Keep
__init__.pylightweight. - Submodule complexity: Git submodules have a learning curve. The
dev_mode.pyscript helps by toggling between symlinks (dev) and submodules (publish).