ComfyUI custom nodepack background¶
How vanilla ComfyUI installs, loads and uses a nodepack. This is the contract comfy-env has to honour.
A nodepack is a directory under custom_nodes/ whose __init__.py exports
NODE_CLASS_MAPPINGS.
Vanilla ComfyUI loads every custom nodepack into one shared Python process with one shared environment.
Packages can be installed either manually or through ComfyUI-Manager, itself a nodepack which is nowadays bundled with Desktop ComfyUI.
At install time, the sanctioned installation flow is:
pip install -r requirements.txt, if therequirements.txtfile is presentpython install.py, ifinstall.pyis present
When starting ComfyUI, a per-nodepack pre-server-startup hook is also ran: each
pack's prestartup_script.py is executed (if present).
Anatomy of a nodepack¶
Using ComfyUI-KJNodes (a popular real-world pack) as the example:
ComfyUI/custom_nodes/
`-- ComfyUI-KJNodes/
+-- __init__.py <- THE contract: exports NODE_CLASS_MAPPINGS,
| NODE_DISPLAY_NAME_MAPPINGS, WEB_DIRECTORY
+-- requirements.txt <- PyPI deps, pip-installed into the ONE shared env
+-- pyproject.toml <- Comfy Registry metadata (name, version, publisher, ...)
+-- nodes/ <- the node classes, grouped by topic
| +-- nodes.py (constants, scheduling, utils ...)
| +-- image_nodes.py (ColorMatch, ImageResizeKJ, ...)
| +-- curve_nodes.py, mask_nodes.py, batchcrop_nodes.py, ...
+-- web/ <- JS extensions served to the browser UI
| (pointed at by WEB_DIRECTORY = "./web")
+-- example_workflows/ <- .json workflows the UI offers as templates
+-- subgraphs/ <- .json reusable node groups
+-- locales/<lang>/ <- UI translations merged into /i18n
| (all three scanned off disk -- see below)
+-- fonts/, docs/, kjweb_async/ <- misc
What ComfyUI reads from installed custom nodes¶
The four __init__.py attributes¶
At startup ComfyUI imports each pack's __init__.py:
# __init__.py (KJNodes, condensed)
from .nodes.nodes import INTConstant, Sleep, WidgetToString, ...
from .nodes.image_nodes import ColorMatch, ImageResizeKJ, ...
NODE_CLASS_MAPPINGS = {"INTConstant": INTConstant, ...} # id -> class
NODE_DISPLAY_NAME_MAPPINGS = {"INTConstant": "INT Constant", ...}
WEB_DIRECTORY = "./web" # optional JS for the UI
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
| Attribute | Required | What it is |
|---|---|---|
NODE_CLASS_MAPPINGS |
one of these two | id -> class |
comfy_entrypoint |
one of these two | V3 alternative. A genuine elif (nodes.py), so it is called only when NODE_CLASS_MAPPINGS is absent or None; a pack that defines both is loaded from the dict and the entrypoint never runs |
NODE_DISPLAY_NAME_MAPPINGS |
no | id -> pretty name |
WEB_DIRECTORY |
no | frontend JS directory |
Everything else¶
The ComfyUI loader takes several more things from the custom nodepack as a "side effect" of the import:
-
Whatever the import did: importing
__init__.pyfires every side effect it contains. The most common one: API route registration, where the pack hangs its own HTTP endpoints off ComfyUI's shared server:from server import PromptServer @PromptServer.instance.routes.post("/geompack/upload") async def upload_mesh(request): ... # now GET/POST http://127.0.0.1:8188/geompack/upload hits thisplus any monkeypatching or global setup the pack does at import time: import-time "side" effects in the wild.
-
Workflow templates and subgraphs, found by folder name.
-
Optional node-name translations in different languages (Spanish, Japanese...), one folder per locale.
-
pyproject.toml: the project name.
Lifecycle hooks and who runs them¶
Every file besides __init__.py in a nodepack is optional, and different actors run them
at different times:
| File | Run by | When | Logic |
|---|---|---|---|
requirements.txt |
ComfyUI-Manager (not core) | install / update | pip-installed line by line |
install.py |
ComfyUI-Manager (not core) | install / update, after requirements | run with sys.executable |
prestartup_script.py |
ComfyUI core | every launch, before the server boots | imported and executed (main.py:execute_prestartup_script) |
__init__.py |
ComfyUI core | every launch | imported; NODE_CLASS_MAPPINGS read, or comfy_entrypoint called instead when that dict is absent or None |
ComfyUI core never runs pip install -r requirements.txt nor
python install.py, and if the user installs a custom nodepack by plain git clone into custom_nodes/, they are
expected to run them manually.
Frontend JavaScript¶
Registration is Python-side; execution is browser-side. Both halves matter.
Registration happens in load_custom_node (nodes.py), and there are
two independent paths to register JS code -- not mutually exclusive, both can fire for one pack -- which write to the same dict under different keys:
| Declared as | Key used | Line |
|---|---|---|
[tool.comfy] web in pyproject.toml |
the Registry project name (project.name) |
nodes.py |
WEB_DIRECTORY in __init__.py |
the module/directory name | nodes.py |
Both are guarded by os.path.isdir(), and they are separate if blocks,
not a fallback chain.
Serving the JS code is a static route per registered directory,
/extensions/<node_pack_name> → the nodepack's web folder.
Auto-import is driven by GET /extensions (server.py), which
returns a flat JSON list of URLs the browser then imports. For each registered
directory:
files = glob.glob(os.path.join(glob.escape(dir), '**/*.js'), recursive=True)
Two properties of that one line govern everything:
recursive=True: the scan reaches every depth of the web folder. There is no way to keep a.jsfile out of the shared realm by burying it in a subfolder;web/js/vendor/three/build/three.jsis imported exactly like a top-level widget..jsonly:.mjs,.json,.css,.htmlare still served statically, they are simply never listed for auto-import.
That second property is the only lever a pack has. A viewer bundle renamed
viewer-bundle.js → viewer-bundle.mjs disappears from the auto-import list
while remaining fetchable, so an <iframe> or an explicit
import "./viewer-bundle.mjs" still loads it, inside the iframe's realm
rather than ComfyUI's. Everything left as .js under the web dir shares one
global scope with every other installed pack and might come into conflict with other nodepack's javascript code, as there can only be one window, document or viewer among all the auto imported .js files.
Data types¶
A socket type in ComfyUI is a string, and nothing more. RETURN_TYPES =
("INT",) and INPUT_TYPES returning {"required": {"mesh": ("TRIMESH",)}}
declare the same kind of thing.
ComfyUI never inspects the Python object flowing along an edge: it compares the two declared strings and, if they
match, passes the object through untouched.
There are ~85 built-in types (comfy_api/latest/_io.py), and the Python
objects behind them are ordinary:
| Socket | Python object |
|---|---|
IMAGE, MASK |
torch.Tensor |
LATENT |
dict with a samples tensor, plus optional noise_mask, batch_index |
CONDITIONING |
list of [tensor, dict] pairs |
MODEL, CLIP, VAE |
ComfyUI wrapper objects (ModelPatcher and friends) |
INT, FLOAT, STRING, BOOLEAN |
Python primitives |
There is no type registry. No central list of valid types exists, and no
declaration step: a pack makes a type exist by simply using its name.
TRIMESH, POINTCLOUD, SKELETON are strings a pack made up, and ComfyUI
wires them as happily as IMAGE -- even the ~85 built-ins are not
privileged, just strings core's own nodes happen to use. The flip side: two
packs that independently pick MESH are, to ComfyUI, the same type -- it
will wire one pack's MESH output straight into the other's MESH input,
and if they disagree about the Python object behind the string, that
surfaces as a crash inside the node at execution time, not as a wiring
error. The string is the whole contract.
Why this matters for comfy-env
In vanilla ComfyUI the object never leaves the process, so "the type is
just a string" costs nothing: the same Trimesh instance is handed from
one node to the next.
Under comfy-env the object may have to cross a process boundary, and a string does not say how to move bytes. That is the gap custom wire types fills.