From 70087e40242e24a27d082dffc363b3d07ab25817 Mon Sep 17 00:00:00 2001 From: Ben Cooley Date: Wed, 2 Sep 2026 13:20:53 -0700 Subject: [PATCH 1/4] docs(custom-nodes): add the Custom Nodes SDK V2 JavaScript documentation Documents the published V2 frontend node API: registration and lifecycle, definitions, graphs and nodes, slots and links, widgets and mounted UI, execution and resolution, application services, legacy migration recipes, and the generated reference for each of the eight declaration sections. Twenty-four pages, placed beside the existing "JavaScript (UI)" section rather than replacing it: V1 packs keep working and their documentation stays where authors expect it. Scope is deliberately the JavaScript half. The V2 Python API, and anything about hosted or isolated execution, is not documented here. The section is generated from a shared source by `prepare-publication.mjs`, which materialises one variant as committable MDX and refuses to hand over a tree that still carries a review banner, Python SDK surfaces, or working notes. `public/custom-nodes-sdk/v2/comfy-api.d.ts` is the declaration the reference pages are generated from and link to, so the download links resolve once this merges. --- custom-nodes/v2/index.mdx | 109 + custom-nodes/v2/javascript/concepts.mdx | 221 + custom-nodes/v2/javascript/definitions.mdx | 264 ++ custom-nodes/v2/javascript/example-packs.mdx | 248 ++ .../v2/javascript/execution-services.mdx | 317 ++ custom-nodes/v2/javascript/execution.mdx | 293 ++ custom-nodes/v2/javascript/graphs-nodes.mdx | 247 ++ .../v2/javascript/migration-recipes.mdx | 1246 ++++++ custom-nodes/v2/javascript/registration.mdx | 250 ++ custom-nodes/v2/javascript/slots-links.mdx | 234 ++ custom-nodes/v2/javascript/tutorial.mdx | 184 + custom-nodes/v2/javascript/widgets-ui.mdx | 345 ++ custom-nodes/v2/reference-overview.mdx | 65 + custom-nodes/v2/reference/javascript-core.mdx | 445 +++ .../v2/reference/javascript-definitions.mdx | 602 +++ .../reference/javascript-documents-graphs.mdx | 672 ++++ .../v2/reference/javascript-execution.mdx | 404 ++ .../reference/javascript-settings-storage.mdx | 151 + .../v2/reference/javascript-slots.mdx | 311 ++ .../v2/reference/javascript-ui-widgets.mdx | 862 ++++ .../v2/reference/javascript-workflow.mdx | 88 + custom-nodes/v2/testing.mdx | 81 + custom-nodes/v2/troubleshooting.mdx | 47 + custom-nodes/v2/versioning-capabilities.mdx | 88 + docs.json | 62 +- public/custom-nodes-sdk/v2/comfy-api.d.ts | 3548 +++++++++++++++++ 26 files changed, 11383 insertions(+), 1 deletion(-) create mode 100644 custom-nodes/v2/index.mdx create mode 100644 custom-nodes/v2/javascript/concepts.mdx create mode 100644 custom-nodes/v2/javascript/definitions.mdx create mode 100644 custom-nodes/v2/javascript/example-packs.mdx create mode 100644 custom-nodes/v2/javascript/execution-services.mdx create mode 100644 custom-nodes/v2/javascript/execution.mdx create mode 100644 custom-nodes/v2/javascript/graphs-nodes.mdx create mode 100644 custom-nodes/v2/javascript/migration-recipes.mdx create mode 100644 custom-nodes/v2/javascript/registration.mdx create mode 100644 custom-nodes/v2/javascript/slots-links.mdx create mode 100644 custom-nodes/v2/javascript/tutorial.mdx create mode 100644 custom-nodes/v2/javascript/widgets-ui.mdx create mode 100644 custom-nodes/v2/reference-overview.mdx create mode 100644 custom-nodes/v2/reference/javascript-core.mdx create mode 100644 custom-nodes/v2/reference/javascript-definitions.mdx create mode 100644 custom-nodes/v2/reference/javascript-documents-graphs.mdx create mode 100644 custom-nodes/v2/reference/javascript-execution.mdx create mode 100644 custom-nodes/v2/reference/javascript-settings-storage.mdx create mode 100644 custom-nodes/v2/reference/javascript-slots.mdx create mode 100644 custom-nodes/v2/reference/javascript-ui-widgets.mdx create mode 100644 custom-nodes/v2/reference/javascript-workflow.mdx create mode 100644 custom-nodes/v2/testing.mdx create mode 100644 custom-nodes/v2/troubleshooting.mdx create mode 100644 custom-nodes/v2/versioning-capabilities.mdx create mode 100644 public/custom-nodes-sdk/v2/comfy-api.d.ts diff --git a/custom-nodes/v2/index.mdx b/custom-nodes/v2/index.mdx new file mode 100644 index 000000000..ef3e1434a --- /dev/null +++ b/custom-nodes/v2/index.mdx @@ -0,0 +1,109 @@ +--- +title: "Getting started" +description: "Build JavaScript extensions for ComfyUI nodes with the published frontend API." +--- + +The Custom Nodes 2.0 frontend SDK is the published JavaScript API for extending ComfyUI node behavior, widgets, graphs, and interface features. + +A pack's frontend extension can: + +- extend node definitions with custom behavior, badges, previews, and lifecycle hooks; +- add widgets, panels, menus, commands, and other interface features; +- read and edit graphs, nodes, slots, and links through documented handles; +- drive queueing, execution feedback, settings, storage, and authenticated backend requests. + +This section documents the JavaScript API only. Python node authoring is documented separately. + + + + Learn handles, lifecycle, snapshots, capabilities, and how the frontend API is structured. + + + Follow a complete tutorial that adds a badge, a menu action, and lifecycle behavior to an existing node. + + + Find focused guides for registration, definitions, graphs, slots, widgets, UI, execution, and services. + + + Look up imports, common patterns, rules, versioning, and exact API signatures. + + + +## What you can build + +Anyone who can write JavaScript can change how a node looks and behaves in the ComfyUI editor: custom widgets, live previews, node badges, context menus, commands, panels, and graph automation. + +Almost any frontend behavior built against `app`, `LiteGraph`, or prototype patching has a path to V2. Keep the behavior users rely on while replacing private implementation hooks with documented handles, events, and UI contributions. + + + You bring the user experience. Comfy provides the editor, the graph model, and a stable extension API. + + +## How the API fits together + +| Part | Import | Use it for | +| --- | --- | --- | +| JavaScript `comfy` | `import { comfy } from '/comfy/api/v2.js'` | Node definitions, graphs, nodes, slots, widgets, queueing, commands, settings, storage, backend calls, and UI | + +The normal authoring flow is: + +1. Ship the extension module inside the pack's `v2/web/` distribution. +2. Import `comfy` from `/comfy/api/v2.js`. +3. Declare hard dependencies with `comfy.require()` and probe optional ones with `comfy.supports()`. +4. Extend a node definition and attach lifecycle, widget, and UI behavior. +5. Read and mutate graph state through documented handles instead of private objects. + +## Extend a node through `comfy` + +The JavaScript API represents ComfyUI nodes, graphs, widgets, and services through documented handles. Use those handles instead of patching private application objects. + +```javascript +import { comfy } from '/comfy/api/v2.js' + +comfy.defs.extend('ScaleImage', (definition) => { + definition.onCreated((node) => { + node.addBadge(() => ({ text: 'V2' })) + }) +}) +``` + +This keeps node behavior consistent as ComfyUI evolves and across supported frontend renderers. + +## V1 and V2 pack layout + +A converted pack keeps its existing V1 distribution at the top level and adds a complete V2 replacement under `v2/`. + +```text +my_pack/ +├── __init__.py # V1 +├── nodes/ # V1 +├── web/ # V1 +└── v2/ + ├── __init__.py # V2 + ├── nodes/ # V2 + └── web/ # V2 +``` + +The host selects either the top level as the V1 pack root or `v2/` as the V2 pack root. It does not merge the two trees or fall back to a V1 file that is missing from `v2/`. + +V2 frontend modules and their assets live under `v2/web/`. A similarly named file in the root `web/` tree is not a V2 asset. + +For an existing pack, the recommended migration path is MAGIC PATCH, which uses Claude Code or Codex installed on your computer with the same conversion skills Comfy uses for its pack catalog. See [Migrate legacy frontend code](/custom-nodes/v2/javascript/migration-recipes) for the frontend mappings it produces. + +## Build durable node code + +Use documented `comfy` handles, events, and UI contributions instead of depending on `window.app`, LiteGraph internals, application stores, or host DOM. Published extension points can be versioned, tested, and kept compatible as ComfyUI evolves. + + + Avoid monkeypatching frontend prototypes, application stores, or the web page. Those changes depend on private implementation details and can break without notice. Prefer a published `comfy` capability. If the API cannot express a legitimate use case, tell us what extension point is missing. We expect to add capabilities as node authors find new needs. Keep any unavoidable private integration optional and separate from the main V2 path. + + +## Where to go next + +1. Read [Use JavaScript handles and lifecycle](/custom-nodes/v2/javascript/concepts) for the frontend mental model. +2. Follow the [Extend a node with JavaScript](/custom-nodes/v2/javascript/tutorial) tutorial. +3. Study the [example packs](/custom-nodes/v2/javascript/example-packs) for tested, runnable patterns. +4. Use the focused [JavaScript how-to guides](/custom-nodes/v2/javascript/registration) while building a real pack. +5. Open the [Reference overview](/custom-nodes/v2/reference-overview) when you need a rule or exact signature. + +The generated API reference comes from `comfy-api.d.ts`. If prose and a generated signature disagree, follow the declaration and report the documentation mismatch. diff --git a/custom-nodes/v2/javascript/concepts.mdx b/custom-nodes/v2/javascript/concepts.mdx new file mode 100644 index 000000000..3c1d4b60a --- /dev/null +++ b/custom-nodes/v2/javascript/concepts.mdx @@ -0,0 +1,221 @@ +--- +title: "Use JavaScript handles and lifecycle" +description: "Understand definitions, handles, snapshots, graph scopes, mutations, lifecycle, serialization, resolution, and API errors." +--- + +The published API is organized around definitions, handles, snapshots, scopes, +and host-owned behavior. Understanding those five ideas prevents most migration +mistakes. + +## Definitions and instances are different surfaces + +A node definition describes a type. A node handle addresses one node in one +graph. + +| Surface | Purpose | Typical entry point | +| ---------------- | ----------------------------------------------------------- | ---------------------------------------------- | +| `NodeDef` | Frozen metadata read from a backend or frontend definition. | `comfy.defs.get(type)` | +| `NodeDefBuilder` | Register behavior for every matching instance. | `comfy.defs.extend(selector, apply)` | +| `NodeDefinition` | Declare a frontend-owned node type as plain data. | `comfy.defs.define(definition)` | +| `NodeHandle` | Read or edit one live node. | A lifecycle callback or `comfy.graph.node(id)` | + +Register type behavior at module load. Use an instance handle only after the +node has joined a graph. This is why `onCreated` runs later than a legacy +constructor hook: an ID-backed handle cannot address a node that has no graph or +ID yet. + +## Handles are closed, ID-backed capabilities + +`NodeHandle` and `WidgetHandle` are closed proxy objects. A handle stores public +identity and resolves the current entity on each access; it does not expose the +live internal object. + +Consequences: + +- unknown members are not an escape hatch to internal data; +- a handle does not keep a deleted entity alive; +- mutations pass through host-owned behavior; +- no handle exposes a constructor, prototype, store, renderer, or Vue proxy; +- `isDeleted` is available on every entity handle. + +Keep handles when convenient, but check `isDeleted` before writing through a +long-lived one. Identity reads remain useful after deletion. Other reads return +no value, while a write to a deleted entity throws `ComfyDeletedError` instead +of being silently discarded. + +### Handle identity + +Do not assume object identity across API instances, majors, events, or graph +scopes: + +```js +first === second // only reliable inside one handle cache +comfy.sameEntity(first, second) // supported identity comparison +``` + +`comfy.adopt(handle)` re-resolves a foreign node handle into the current API +instance. It returns `undefined` for a non-handle, a deleted node, or a handle +kind that cannot be adopted independently. + +## Collection reads are snapshots + +List-shaped reads return frozen array snapshots: + +```js +const nodes = comfy.graph.nodes() +const links = node.outputs.get('IMAGE')?.links() ?? [] + +for (const link of links) { + // Safe even though disconnecting changes the live graph. + node.outputs.get('IMAGE')?.disconnect(link.targetNodeId) +} +``` + +The array does not update after it is returned. Ask again when you need current +state. Objects such as `NodeSnapshot`, `SlotSnapshot`, `LinkInfo`, definition +metadata, execution results, and resolver views are also inert, read-only data. + +Use collection operations to mutate live state; never mutate an array returned +by `all()`, `nodes()`, `links()`, `names()`, or similar methods. + +## Graph scope is part of identity + +`comfy.graph` means the graph currently shown by the editor. It can be the root +graph or a subgraph the user entered. + +Use: + +- `comfy.graph.root()` for the document root even while another graph is shown; +- `comfy.graph.subgraphs()` for subgraph definitions; +- `GraphScopeHandle.node(id)` to resolve an ID inside its owning graph; +- `node.graphId` when recording a node outside the callback that supplied it. + +Do not flatten a document into a map keyed only by node ID. Independently +authored subgraph definitions may contain the same IDs. A durable in-memory key +must include graph and node identity. + +Supply and frontend-node resolution also run per graph scope. A supplier inside +a subgraph may feed unconnected inputs in that subgraph, but resolution does not +cross the boundary and create invisible dependencies on outside state. + +## Mutations use host behavior + +Set values through methods such as `setTitle`, `setValue`, `modify`, +`connectTo`, and `replace`. These methods preserve the host behavior associated +with the edit: callbacks, property synchronization, link identity, layout, +redraw, serialization, and undo boundaries as applicable. + +`WidgetHandle.setValue()` is a full programmatic commit. It behaves like a user +edit for the value protocol: + +- writes the value; +- synchronizes a property-backed widget; +- runs the widget callback chain and node widget-change behavior; +- notifies `change` listeners; +- advances graph change state. + +It does not fire `activate`, because activation reports a user act. Writing the +current value again is a no-op. + +Use `graph.batch()` for a synchronous compound edit that should be one undo +step: + +```js +comfy.graph.batch(() => { + const first = comfy.graph.add('CheckpointLoaderSimple') + const second = comfy.graph.add('KSampler') + first.outputs.get('MODEL')?.connectTo(second.id, 'model') +}) +``` + +Do not hold a batch across `await`; unrelated user actions during the wait would +be folded into the pack's edit. + +## Lifecycle is explicit + +An extension module can register definitions, settings, commands, widget types, +and listeners immediately. Use lifecycle signals for state that is not ready at +module evaluation: + +| Signal | Meaning | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `comfy.onReady` | The application, definitions, settings, and graph have finished initial setup. Fires on the next microtask if already ready. | +| `comfy.onWorkflowLoaded` | A new workflow has finished loading. Fires for every load. | +| `NodeDefBuilder.onCreated` | One matching node has joined a graph and can be addressed. | +| `NodeDefBuilder.onConfigured` | Saved node data was applied. | +| `NodeDefBuilder.onRemoved` | One matching node left its graph. | + +Most registrations and subscriptions return `Unsubscribe`. Retain it when the +registration has a shorter lifetime than the module or owning node. + +## Observe the narrowest behavior + +Prefer a semantic event to polling, repaint hooks, or broad document scans: + +1. a widget's `change`, `activate`, `textInteraction`, or `beforeSerialize`; +2. a definition lifecycle hook such as `onConnectionsChanged` or `onResized`; +3. root observers such as `onNodeChanged`, `onNodeMoved`, or + `onViewportChanged`; +4. `graph.version` only as an opaque structural-change token. + +`graph.version` includes widget values committed through the host protocol, but +it is still an opaque change token rather than a universal edit log. Never +subtract versions or assume consecutive increments. Data a pack keeps outside +the graph and widget model does not affect it. + +## Saved workflow and queued prompt are separate destinations + +The API distinguishes: + +- graph state in the saved workflow; +- input values sent in the API prompt; +- the workflow embedded into an output generated by that prompt. + +Mounted widgets expose separate `serialize` and `sendToPrompt` flags. Existing +widgets can replace their value for one serialization destination with a +synchronous `beforeSerialize` listener: + +```js +widget.on('beforeSerialize', (event) => { + if (event.context === 'prompt') { + event.setSerializedValue(expandReferences(String(event.value))) + } +}) +``` + +The live widget value is unchanged. Ignoring `event.context` changes all three +destinations. Serialization handlers are synchronous; starting asynchronous +work inside one does not delay the prompt or workflow write. + +## Frontend execution is pure resolution + +A frontend-only node is ordinary editor state that does not reach the backend. +At prompt time it either: + +- is omitted; +- forwards an output to one of its inputs; +- supplies a literal value. + +Resolvers and suppliers receive frozen read views and return data. They do not +mutate the graph or a prompt draft. This keeps prompt construction deterministic +and prevents a failed resolver from leaving the document half-edited. + +Use ordinary node and graph methods for permanent editor actions. Use resolution +only to describe what execution means. + +## Errors are part of the contract + +API failures are plain `Error` subclasses with no internal object attached: + +| Error | Cause | +| ------------------------- | ----------------------------------------------------- | +| `ComfyApiError` | Base class for API failures. | +| `ComfyDeletedError` | A mutation targeted a deleted handle. | +| `ComfyReadonlyError` | Code attempted to assign a read-only public property. | +| `ComfyAmbiguousSlotError` | A name matched more than one slot. | +| `ComfyUnsupportedError` | `require()` named a capability the host lacks. | + +The constructors are not currently exported from `/comfy/api/v2.js`. Prefer +capability checks, `isDeleted`, and `undefined` handling for expected absence; +use ordinary `Error` fields when reporting an unexpected failure. + diff --git a/custom-nodes/v2/javascript/definitions.mdx b/custom-nodes/v2/javascript/definitions.mdx new file mode 100644 index 000000000..3e61f3faf --- /dev/null +++ b/custom-nodes/v2/javascript/definitions.mdx @@ -0,0 +1,264 @@ +--- +title: "Extend node definitions" +description: "Read definitions, register composable type behavior, define frontend-owned nodes, and work with live node handles." +--- + +Node type registration and live node editing are separate APIs. Use the +definition registry to install behavior by type, and a `NodeHandle` to work with +one node that already belongs to a graph. + +## Reading definitions + +```js +const definition = comfy.defs.get('KSampler') + +if (definition) { + console.info(definition.title, definition.category) + console.info(definition.inputs) + console.info(definition.outputs) +} +``` + +`NodeDef` is frozen metadata: + +| Field | Meaning | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| `type`, `title`, `category`, `description` | Stable definition identity and presentation. | +| `inputs` | Declared inputs, including name, type, localized name, combo values, and a frozen `options` passthrough. | +| `outputs` | Declared output names and types. | +| `hidden` | Backend hidden-input declarations. These are not connectable slots. | +| `isOutputNode` | Whether the backend marks the node as an output. | +| `source` | The backend-reported pack that supplied the type, if known. | + +`inputs[].options` and `hidden` deliberately preserve pack-owned backend data. +They are declarations, not live widget values or hidden-input execution values. + +Use `defs.all()` to take a snapshot of every definition and `defs.has(type)` for +an existence check. `defs.refresh()` asks the backend to reload definitions; +`defs.onRefreshed()` observes completion. + +## Extending definitions + +```js +const stop = comfy.defs.extend('KSampler', (definition) => { + definition.setTitle('KSampler with tools') + definition.addWidget({ + type: 'button', + name: 'reset_seed' + }) + + definition.onCreated((node, event) => { + if (!event.restored) node.setColor('#334155') + node.widgets.get('reset_seed')?.on('activate', () => { + node.widgets.get('seed')?.setValue(0) + }) + }) +}) +``` + +Builder registrations compose. The host applies every matching extension rather +than making each pack capture and call a previous prototype method. + +### Builder configuration + +| Method | Purpose | +| ------------------------------------------------- | ----------------------------------------------------------------------- | +| `setTitle(title)` | Change the displayed type title. | +| `setCategory(category)` | Change where the type appears in node search. | +| `setExecution('backend' \| 'frontend', resolve?)` | Mark a backend-defined type frontend-only or restore backend execution. | +| `setSupply(supplier)` | Declare broadcast-style edges into other nodes' unconnected inputs. | +| `addWidget(def)` | Add a declared widget to every instance. | +| `hideWidget(name)` | Hide a declared widget while retaining its value. | +| `addMenuItem(item)` | Add a host-rendered context-menu entry. | + +Structural changes to a live node - dynamic slots, values, ordering, or +connections - belong on the instance handles supplied by lifecycle callbacks. + +### Lifecycle and behavior hooks + +| Hook | When it runs | +| ----------------------------------- | ---------------------------------------------------------- | +| `onCreated(node, event)` | The node joined a graph and is addressable. | +| `onConfigured(node, data)` | Saved node data was applied. | +| `onRemoved(node)` | The node left its graph. | +| `onExecuted(node, result)` | The backend returned the node's execution result. | +| `onPreview(node, frame)` | A preview frame was correlated with this executing node. | +| `onConnectionsChanged(node, event)` | A slot connected or disconnected. | +| `onBeforeConnect(node, event)` | A proposed connection may be accepted or vetoed. | +| `onUnplacedLink(node, event)` | A link dropped on the node had no unique destination slot. | +| `onResized(node, size)` | A user or layout operation resized the node. | +| `onHover(node, hovering)` | The pointer entered or left the node. | +| `onDoubleClick(node)` | The node was double-clicked. | +| `onPropertyChanged(node, event)` | A user edited a node property. | +| `onDragOver(node, event)` | Decide whether the node accepts a browser drag. | +| `onDrop(node, event)` | Handle a browser drop the node accepted. | +| `onSerialize(node)` | Return pack-owned fields to merge into the saved node. | + +`onBeforeConnect` returns `false` to veto. `onUnplacedLink` returns `true` after +the callback wires the link itself. `onDragOver` returns `true` to route the +drop; `onDrop` returns `true` to claim it. + +`onSerialize` must return deterministic, synchronous, pack-owned data. Its data +comes back through `onConfigured`. Do not return core workflow fields or mutate +a serialization object supplied by the host. + +## Defining a frontend-owned node + +```js +const unregister = comfy.defs.define({ + type: 'MyPack/ConstantText', + title: 'Constant Text', + category: 'My Pack', + outputs: [{ name: 'text', type: 'STRING' }], + widgets: [{ type: 'text', name: 'value', value: '', serialize: true }], + execution: 'frontend', + resolve: ({ self }) => ({ + text: { literal: self.widgetValue('value') ?? '' } + }), + onCreated(node) { + node.setSizeConstraints({ minWidth: 220 }) + } +}) +``` + +`NodeDefinition` is plain data, not a class. It supports inputs, outputs, +widgets, frontend execution, supply, and the common lifecycle callbacks. + +For builder-only behavior such as menu entries, preview frames, resize, hover, +double-click, connect veto, or unplaced links, define the type and extend the +same type separately. + +## Working with a live node + +Node handles can come from lifecycle callbacks, graph lookups, selection, +groups, execution events, or subgraph scopes. + +```js +const node = comfy.graph.node(nodeId) +if (node) { + node.setTitle('Primary sampler') + node.setMode('always') + node.setPosition({ x: 320, y: 180 }) + node.setSize({ width: 280, height: 420 }) + node.setProperty('role', 'primary') +} +``` + +### Identity and presentation + +- `id` is identity inside the owning graph. +- `graphId` identifies that graph. +- `type` is immutable definition identity. +- `comfyClass` is the backend class identifier when it differs from `type`. +- title, mode, collapsed state, pinned state, colors, and shape use explicit + getter/setter pairs. + +Do not assign `type`. Use `comfy.graph.replace(node.id, newType)` to rebuild the +node and preserve compatible state and links. + +### Properties + +Use `getProperty`, `getProperties`, and `setProperty`. `getProperties()` returns +an inert object rather than a mutable reference. + +`onPropertyChanged` can normalize or reject a user edit: + +```js +definition.onPropertyChanged((_node, event) => { + if (event.name !== 'strength') return + const value = Number(event.value) + if (!Number.isFinite(value)) event.reject() + else event.setValue(Math.min(1, Math.max(0, value))) +}) +``` + +`setValue()` replaces the pending property value without re-entering the +property callback. `reject()` restores the previous value. + +### Geometry + +| Method | Coordinate space or behavior | +| --------------------------------- | -------------------------------------------------------- | +| `getPosition()` / `setPosition()` | Node body position in graph space. | +| `getSize()` / `setSize()` | Size through the host resize protocol. | +| `getBounds()` | Full node bounds in graph space, including title layout. | +| `getSlotPosition(side, index)` | Renderer-computed slot center in graph space. | +| `getScreenRect()` | Client-coordinate screen rectangle, if rendered. | +| `setSizeConstraints()` | Declarative min/max dimensions and auto-height. | + +Do not reconstruct title height, slot spacing, pan, zoom, or device-pixel-ratio +math from renderer constants. + +### Output images + +`getOutputImages()` returns URLs for the images or previews the node currently +exposes. It never returns renderer-owned `HTMLImageElement` objects. +`getDisplayedImageIndex()` identifies the image selected or hovered by the user, +or returns `undefined` when there is no such choice. + +### Collections and snapshots + +Each node exposes: + +```js +node.inputs +node.outputs +node.widgets +``` + +These are operation-oriented collections, not arrays. See +[Slots and links](/custom-nodes/v2/javascript/slots-links) and [Widgets](/custom-nodes/v2/javascript/widgets-ui). + +`node.snapshot()` returns a frozen `NodeSnapshot` with identity, presentation, +position, and size. It can return `undefined` when the entity is gone. + +## Menus and badges + +Node menu entries are declarative and host-rendered: + +```js +definition.addMenuItem({ + label: (node) => + node.getMode() === 'never' ? 'Enable node' : 'Disable node', + when: (node) => !node.isDeleted, + run(node) { + node.setMode(node.getMode() === 'never' ? 'always' : 'never') + }, + order: 20 +}) +``` + +An item may instead provide one level of `items`, either a fixed array or a +function of the current node. A submenu parent omits `run`. + +Badges are small labels rendered in node chrome under both renderers: + +```js +const removeBadge = node.addBadge({ + text: 'cached', + onClick() { + clearCache(node) + } +}) +``` + +Pass a function for a dynamic badge. It runs during drawing, so it must be fast. +The return value removes the badge. + +## Pack-owned instance state + +Do not add fields to a node or handle. Keep state outside the entity and include +graph scope in the key: + +```js +const state = new Map() +const keyOf = (node) => `${node.graphId}:${node.id}` + +comfy.defs.extend('MyPack/Node', (definition) => { + definition.onCreated((node) => state.set(keyOf(node), { open: false })) + definition.onRemoved((node) => state.delete(keyOf(node))) +}) +``` + +When handles may come from different API instances or scopes, compare them with +`comfy.sameEntity()` rather than `===`. diff --git a/custom-nodes/v2/javascript/example-packs.mdx b/custom-nodes/v2/javascript/example-packs.mdx new file mode 100644 index 000000000..96bc2e814 --- /dev/null +++ b/custom-nodes/v2/javascript/example-packs.mdx @@ -0,0 +1,248 @@ +--- +title: "Learn from the example packs" +description: "Run focused, tested examples for frontend-only nodes, widgets, graph interaction, execution, and application services." +--- + +The ComfyUI frontend repository includes four small custom-node packs under +`examples/node-api`. Each pack demonstrates one family of published JavaScript +APIs without importing ComfyUI source files, patching generated node classes, or +using the legacy `app` global. + +| Pack | What it demonstrates | +| --- | --- | +| `how_to_frontend_nodes` | Frontend-only nodes, literals, reroutes, suppliers, and dynamic slots | +| `how_to_widgets` | Widget events, canvas and mounted widgets, custom widget types, and prompt serialization | +| `how_to_graph_interaction` | Badges, menus, lifecycle state, file drops, link rules, graph edits, and undo | +| `how_to_execution` | Queueing, execution results, the mask editor, backend routes and events, settings, commands, and storage | + +These are executable examples, not isolated fragments. The frontend repository +also contains a saved workflow and browser tests that exercise registration, +prompt resolution, widget events, backend calls, graph batching, undo, and a +real backend run. + + + The example packs keep their Python deliberately small because they teach the + published frontend API. Each pack's `v2/__init__.py` only registers the + extension and exposes `WEB_DIRECTORY`; the behavior under test is the + JavaScript module. + + +## Find and install the examples + +In a checkout of the ComfyUI frontend repository, the files are here: + +```text +examples/node-api/ +├── README.md +├── how_to_execution/ +├── how_to_frontend_nodes/ +├── how_to_graph_interaction/ +└── how_to_widgets/ +``` + +Copy one or more packs into a local ComfyUI `custom_nodes` directory, then +restart ComfyUI: + +```sh +cp -R examples/node-api/how_to_* /path/to/ComfyUI/custom_nodes/ +``` + +Search the node library for `API Examples`. Each pack README gives a short +exercise for the nodes it installs. + +These checkout examples are arranged for direct local testing. When publishing +a converted V2 pack, retain the complete V1 distribution at the pack root and +put the complete V2 replacement distribution under `v2/`. Do not flatten these +example directories into a converted pack or treat `v2/` as an overlay. + +## Start with a frontend-only node + +`how_to_frontend_nodes` is the smallest starting point. Its Constant Text node +stays in the saved workflow but resolves to a literal before the backend prompt +is sent: + +```js +import { comfy } from '/comfy/api/v2.js' + +const api = comfy.forMajor(2) + +api.require('defs.define') +api.require('node.resolve') + +api.defs.define({ + type: 'HowTo/ConstantText', + title: 'How-To: Constant Text', + category: 'API Examples/Frontend Nodes', + outputs: [{ name: 'text', type: 'STRING' }], + widgets: [ + { + type: 'text', + name: 'value', + value: 'Hello from a frontend node', + serialize: true + } + ], + execution: 'frontend', + resolve: ({ self }) => ({ + text: { literal: String(self.widgetValue('value') ?? '') } + }) +}) +``` + +Three details are worth copying into real packs: + +- `forMajor(2)` pins the contract the module expects; +- `require()` fails early with the missing capability name; +- `resolve()` describes the output without mutating the graph or a prompt draft. + +The same pack shows a reroute with `forwardTo`, a same-group text supplier, and +a First Connected node that adds a new input whenever its final input becomes +connected: + +```js +const dynamicNode = { + onConnectionsChanged(node) { + const last = node.inputs.at(node.inputs.length - 1) + if (last?.isConnected) { + node.inputs.add(`input_${node.inputs.length + 1}`, '*', { + shape: 'optional' + }) + } + } +} +``` + +Use that example when a node needs dynamic slots; use the Constant Text and +Reroute nodes when learning prompt-time resolution. + +## Choose the right widget ownership model + +`how_to_widgets` places four widget approaches next to each other: + +1. ordinary declared widgets with additive event listeners; +2. a host-rendered canvas widget; +3. a mounted, pack-owned DOM control; +4. a custom renderer for a Python-declared input type. + +For an ordinary button, listen for `activate` and update another widget through +its handle: + +```js +const widgetEvents = { + onCreated(node) { + const count = node.widgets.get('count') + node.widgets.get('increment')?.on('activate', () => { + count?.setValue(Number(count.getValue()) + 1) + }) + } +} +``` + +For a custom input type, register the renderer with +`defs.defineWidgetType()`. The example returns a cleanup function that removes +DOM listeners and API subscriptions when the mounted widget is destroyed: + +```js +api.defs.defineWidgetType('HOW_TO_RATING', { + defaultValue: 3, + minWidth: 160, + serialize: true, + render(container, value, name, context) { + // Create controls inside the supplied container. + // Subscribe through value and context handles. + return () => { + // Remove every retained listener and subscription. + } + } +}) +``` + +The pack also shows how to change a widget's prompt value without changing the +value stored in the workflow. Its Prompt Serialization node expands ComfyUI +text tokens only when `event.context === 'prompt'`. + +## Add graph behavior without internal objects + +`how_to_graph_interaction` demonstrates behavior that legacy extensions often +implemented through LiteGraph instances or canvas hooks. + +The Lifecycle Badge node stores pack-owned state in a `Map`, returns that state +from `onSerialize`, restores it in `onConfigured`, and releases the badge in +`onRemoved`. This makes ownership and cleanup visible in the code. + +The Graph Builder node uses a synchronous batch so adding, connecting, and +selecting two nodes becomes one undoable edit: + +```js +api.graph.batch(() => { + const source = api.graph.add('HowTo/GraphSource', { + position: { x: x + 320, y } + }) + const target = api.graph.add('HowTo/GraphTarget', { + position: { x: x + 640, y } + }) + source.outputs.get('text')?.connectTo(target.id, 'text') + api.graph.select([source, target]) + api.graph.centerOn(target) +}) +``` + +The same pack contains focused patterns for connection vetoes, dropped browser +files, duplication, and same-type node replacement. + +## Connect execution to application services + +`how_to_execution` combines small backend nodes and routes with supported +frontend services. Its Text Output node adds a context-menu action that queues +only that node and updates a badge from the correlated result: + +```js +api.defs.extend('HowToTextOutput', (definition) => { + definition.onExecuted((node, result) => { + textResults.set(`${node.graphId}:${node.id}`, result.text[0] ?? 'complete') + }) + definition.addMenuItem({ + label: 'Run This Node', + run: (node) => { + void api.queue.run({ nodes: [node] }) + } + }) +}) +``` + +Other nodes in the pack show: + +- `commands.has()` and `commands.run()` for the host mask editor; +- `backend.fetch()` for a pack-owned route; +- `backend.on()` for a validated custom backend event; +- `settings.declare()` for a preference; +- `commands.register()` for a command and keybinding; +- `storage.set()` and `storage.get()` for named per-user content. + +Prefer these services to importing application stores, constructing private +URLs, or reaching into host UI objects. + +## Use the examples as a pattern library + +Do not copy an entire pack when one focused pattern is enough. Start with the +example closest to the behavior you need, copy its capability requirements and +lifecycle structure, then rename its types, settings, commands, storage keys, +and backend events into a namespace owned by your pack. + +Before release: + +- verify each retained `require()` names a capability the feature truly needs; +- remove demo categories and identifiers; +- keep cleanup paired with every retained subscription or mounted control; +- test save, reload, duplicate, delete, undo, and execution behavior; +- test the complete V2 replacement under the pack's `v2/` directory. + +## Continue learning + +- [JavaScript concepts](/custom-nodes/v2/javascript/concepts) +- [Nodes and definitions](/custom-nodes/v2/javascript/definitions) +- [Widgets and mounted UI](/custom-nodes/v2/javascript/widgets-ui) +- [Graphs and nodes](/custom-nodes/v2/javascript/graphs-nodes) +- [Execution and resolution](/custom-nodes/v2/javascript/execution) +- [Application services](/custom-nodes/v2/javascript/execution-services) +- [Test a V2 pack](/custom-nodes/v2/testing) diff --git a/custom-nodes/v2/javascript/execution-services.mdx b/custom-nodes/v2/javascript/execution-services.mdx new file mode 100644 index 000000000..e2cbf0d8c --- /dev/null +++ b/custom-nodes/v2/javascript/execution-services.mdx @@ -0,0 +1,317 @@ +--- +title: "Use application services" +description: "Declare settings, register commands, contribute UI, call backend routes, open workflows, and store pack data." +--- + +The root API exposes narrow services for behavior that legitimately belongs +outside one node: settings, commands, host UI contributions, backend calls, +workflow loading, and per-user storage. + +## Settings + +Declare a setting once at module load: + +```js +comfy.settings.declare({ + id: 'MyPack.previewQuality', + name: 'Preview quality', + type: 'slider', + defaultValue: 80, + category: ['My Pack', 'Preview'], + attrs: { min: 1, max: 100, step: 1 }, + onChange(value, previous) { + rebuildPreview(value, previous) + } +}) +``` + +Setting IDs share one namespace with core and every pack. Include a stable pack +prefix. Redeclaring an ID does not reset an existing user's value. + +Supported controls are: + +- `boolean`, `number`, `slider`, and `knob`; +- `combo` and `radio`; +- `text`, `password`, `color`, `image`, and `url`. + +For combo and radio choices, a string is both stored value and label. Use +`{ value, label }` when they differ so numeric values remain numeric. + +Read, write, and observe settings: + +```js +const quality = comfy.settings.get('MyPack.previewQuality') +await comfy.settings.set('MyPack.previewQuality', 90) + +const stop = comfy.settings.onChange( + 'Comfy.LinkRelease.Action', + (value, previous) => respondToCoreSetting(value, previous) +) +``` + +`onChange()` can observe a setting the pack did not declare. It fires on change, +not on registration. + +Settings are small preferences. Use `comfy.storage` for named content the user +authors. + +## Commands, keybindings, and notifications + +```js +comfy.commands.register({ + id: 'MyPack.centerPrimary', + label: () => `Center ${primaryLabel()}`, + keybinding: { key: 'm', ctrl: true, shift: true }, + scope: 'canvas', + run() { + const node = findPrimaryNode() + if (node) comfy.graph.centerOn(node) + } +}) +``` + +Command IDs must be namespaced. A label may be dynamic and should return +quickly. A keybinding is a default so a user's custom binding wins. + +`scope: 'canvas'` prevents the keybinding from firing while the user is typing +in a node widget or another field. Omit it for an application-wide command. + +Run a host or pack command without reaching into its implementation: + +```js +if (comfy.commands.has('Comfy.MaskEditor.OpenMaskEditor')) { + await comfy.commands.run('Comfy.MaskEditor.OpenMaskEditor') +} +``` + +`run()` rejects when the command does not exist. Use `has()` for an optional +entry. + +Show a notification: + +```js +comfy.commands.notify({ + severity: 'warn', + summary: 'Model unavailable', + detail: 'Refresh node definitions after installing it.', + life: 5000 +}) +``` + +Severity is `success`, `info`, `warn`, or `error` and defaults to `info`. + +## Sidebar tabs + +Hand-written modules can render into a host container: + +```js +const removeTab = comfy.ui.addSidebarTab({ + id: 'MyPack.assets', + title: 'My assets', + icon: 'icon-[lucide--folder]', + render(container) { + mountAssetBrowser(container) + }, + destroy() { + unmountAssetBrowser() + } +}) +``` + +`render()` can run each time the tab becomes visible. Treat it as a mount and +release retained resources from `destroy()`. + +A built pack can instead provide a bundled Vue component: + +```js +comfy.ui.addSidebarTab({ + id: 'MyPack.monitor', + title: 'Monitor', + component: MonitorTab +}) +``` + +Per ADR 0005, a pack bundles its own Vue. Do not import the host's internal Vue +runtime or pass host reactive objects across the boundary. + +## Top-bar badges and action buttons + +These contributions are declarative so the host retains control of layout and +style: + +```js +const badge = comfy.ui.addTopBarBadge({ + id: 'MyPack.queueState', + text: 'Idle', + variant: 'info', + tooltip: 'My Pack queue state' +}) + +badge.update({ text: 'Running', variant: 'warning' }) +badge.remove() +``` + +```js +const button = comfy.ui.addActionBarButton({ + id: 'MyPack.openPanel', + icon: 'icon-[lucide--panel-right-open]', + label: 'Open My Pack', + run(event) { + openPanel({ detached: event.shiftKey }) + } +}) +``` + +IDs must be namespaced and unique. An update changes only supplied fields. A +removed contribution cannot be updated again. + +If an action also needs a palette entry or shortcut, put behavior in a command +and have the button call `comfy.commands.run()`. + +## Dialogs, menus, and prompts + +### Dialog + +```js +const dialog = comfy.ui.showDialog({ + key: 'MyPack.modelInfo', + title: 'Model information', + render(container) { + renderModelInfo(container) + }, + destroy() { + releaseModelInfo() + } +}) + +dialog.close() +``` + +Dialogs also accept a bundled Vue `component` and optional frozen `props`. +Dialog keys must be namespaced because the host maps them into one dialog +keyspace. + +### Context menu raised by a pack + +```js +const menu = comfy.ui.showMenu({ + title: 'Output type', + event: mouseEvent, + items: [ + { label: 'Image', run: () => choose('IMAGE') }, + { + label: 'Latent', + submenu: [ + { label: 'Samples', run: () => choose('LATENT') }, + { label: 'Noise', disabled: true } + ] + } + ] +}) +``` + +The `MouseEvent` positions the menu. A submenu item is mutually exclusive with +`run`. Use `NodeDefBuilder.addMenuItem()` instead when the host is opening a +node's own context menu. + +### Prompt + +```js +const label = await comfy.ui.prompt({ + label: 'Group name', + value: group.getTitle(), + placeholder: 'Name' +}) +``` + +The result is `undefined` when the user cancels. + +## Backend URLs, requests, and events + +### Authenticated API calls + +```js +const response = await comfy.backend.fetch('/my-pack/models', { + method: 'GET' +}) +``` + +`fetch()` delegates credentials and authentication behavior to the host. Its +route is API-relative and must start with `/`. + +`backend.url(route)` builds the absolute API URL but does not attach credentials +to a later plain `fetch()`. Prefer `backend.fetch()` for API requests. + +### Static host files + +```js +const url = comfy.backend.assetUrl('/extensions/shared/icon.svg') +``` + +`assetUrl()` does not add the API prefix. For a file next to the current pack's +module, use the install-location-safe form: + +```js +const stylesheet = new URL('./panel.css', import.meta.url) +``` + +Do not guess the pack's install directory. + +### Backend messages and session identity + +```js +const stop = comfy.backend.on('my-pack-progress', (detail) => { + updateProgress(detail) +}) + +const session = comfy.backend.sessionId() +``` + +Event payloads are `unknown` because a pack owns its own event schema. Validate +before use. The session ID can be `undefined` until the backend connection is +established and must not be persisted. + +## Workflow service + +Open parsed ComfyUI workflow JSON as the active document: + +```js +const data = JSON.parse(text) +await comfy.workflow.open(data) +``` + +This replaces the current document and is therefore an explicit user-facing +action. Validate or confirm untrusted input before calling it. + +Expand the host's workflow text tokens against the active root graph: + +```js +const filename = comfy.workflow.applyTextReplacements( + '%date:yyyy-MM-dd%_%KSampler.seed%' +) +``` + +It throws when no graph is active. + +## Per-user storage + +Storage is for named text documents such as presets, templates, and saved +prompts: + +```js +await comfy.storage.set('MyPack.presets/portrait', JSON.stringify(preset)) + +const text = await comfy.storage.get('MyPack.presets/portrait') +const names = await comfy.storage.list('MyPack.presets') + +await comfy.storage.remove('MyPack.presets/portrait') +``` + +Names and namespaces must contain a pack prefix and may not contain `..`. +`get()` returns `undefined` for a missing item; `list()` returns an empty frozen +array when the namespace has no entries. + +Storage lives with the user's server-side data and follows the user between +machines. Use settings for small preferences, and storage for content the user +expects to retain and manage by name. + diff --git a/custom-nodes/v2/javascript/execution.mdx b/custom-nodes/v2/javascript/execution.mdx new file mode 100644 index 000000000..71ae21920 --- /dev/null +++ b/custom-nodes/v2/javascript/execution.mdx @@ -0,0 +1,293 @@ +--- +title: "Control execution and resolution" +description: "Queue workflows, observe execution, define frontend-only nodes, supply values, and customize prompt serialization." +--- + +The execution API separates permanent graph edits, prompt-time resolution, +queue submission, and backend results. Keeping those stages distinct prevents a +pack from mutating the live document merely to construct a prompt. + +## Queueing a run + +```js +const submitted = await comfy.queue.run() +``` + +`run()` behaves like the host Run action. It resolves when submission finishes, +not when backend execution completes. `false` means another queue call was +already in flight and this call was folded into it. + +Run part of the graph with explicit output nodes: + +```js +await comfy.queue.run({ + nodes: comfy.graph.selection(), + batch: 2 +}) +``` + +The host includes the dependencies that feed those nodes. An empty `nodes` +array is rejected rather than interpreted as “run everything.” + +## Queue lifecycle + +### Before submission + +```js +const stop = comfy.queue.onBeforeRun(() => { + const restore = preparePromptState() + return () => restore() +}) +``` + +The listener runs before the prompt is built. It must be synchronous; the +prompt builder does not await work started here. + +A returned cleanup runs when the attempt ends whether it was submitted, +rejected, canceled, or threw. This pairing exists for legacy behavior that must +temporarily change graph state and restore it, but prefer `beforeSerialize`, a +frontend resolver, or a supplier when those express the intent without graph +mutation. + +### After submission + +```js +const stop = comfy.queue.onAfterRun((event) => { + console.info(event.promptIds) + console.info(event.submissions) + console.info(event.rejected) +}) +``` + +`onAfterRun` means the submission attempt finished. It is not an execution- +complete event. Accepted submissions include prompt IDs and backend node counts; +`rejected` reports how many submissions the backend refused. + +### Validation rejection + +```js +const stop = comfy.queue.onRejected(({ status, error, nodeErrors }) => { + reportValidationFailure(status, error, nodeErrors) +}) +``` + +This event covers a prompt the backend rejects before execution begins. It +includes the top-level error and per-node input validation details. It does not +represent a transport failure or an exception raised after execution starts. + +## Guarding a run + +Use a guard when the decision may be asynchronous and must be made before the +prompt is built: + +```js +const stop = comfy.queue.guard(async () => { + const answer = await comfy.ui.prompt({ + label: 'Estimated cost is high. Type RUN to continue.' + }) + return answer === 'RUN' +}) +``` + +Every registered guard runs; any `false` cancels the attempt. A guard that +throws is treated as allowing the run. All guards share a short host timeout, +after which the run proceeds so one extension cannot make ComfyUI permanently +unrunnable. Do not place an indefinitely blocking dialog behind a guard. + +`onBeforeRun` observes and prepares. `guard` can delay and cancel. Do not use one +as an approximation of the other. + +## Queue state and interruption + +```js +const count = comfy.queue.pending() +const stopPending = comfy.queue.onPendingChanged((next) => updateCount(next)) + +await comfy.queue.interrupt() +const stopInterrupted = comfy.queue.onInterrupted(() => releaseWaiters()) +``` + +`pending()` includes the currently executing run. `interrupt()` stops that run; +it does not clear the remainder of the queue. + +The API also exposes the host's user-facing queue settings: + +```js +comfy.queue.autoQueueMode() // 'disabled' | 'change' | 'instant' +comfy.queue.setAutoQueueMode('change') +comfy.queue.batchCount() +comfy.queue.setBatchCount(4) +comfy.queue.disableAutoQueue() +``` + +Use `disableAutoQueue()` before a self-interrupting conditional workflow so the +automatic runner does not immediately submit it again. + +## Observing backend execution + +The root API resolves backend execution IDs, including nested subgraph paths: + +```js +const current = comfy.executingNode() +const node = comfy.executionNode(executionId) + +const stop = comfy.onExecutingNodeChanged((next) => { + updateRunningBadge(next) +}) +``` + +`executingNode()` is `undefined` between nodes and runs. Use +`executionNode(id)` instead of parsing nested execution IDs or looking up the +visible graph by the final numeric segment. + +For results, register behavior on the node definition: + +```js +comfy.defs.extend('MyPack/Analyzer', (definition) => { + definition.onExecuted((node, result) => { + console.info(result.images, result.text, result.raw) + }) + + definition.onPreview((node, frame) => { + showPreview(node, frame.url) + }) +}) +``` + +`ExecutionResult.raw` preserves custom output keys from the pack's own backend. +`PreviewFrame.url` is an object URL revoked when the next frame arrives; copy or +consume it before retaining a preview beyond that lifetime. + +## Frontend-only nodes + +Frontend nodes remain ordinary editor entities but do not execute on the +backend. Define one with `execution: 'frontend'`, or mark a backend-defined type +with `NodeDefBuilder.setExecution('frontend', resolver?)`. + +A resolver answers what each of its own outputs means: + +```js +comfy.defs.define({ + type: 'MyPack/Reroute', + inputs: [{ name: 'in', type: '*' }], + outputs: [{ name: 'out', type: '*' }], + execution: 'frontend', + resolve({ self }) { + const input = self.input('in') + return { + out: input ? { forwardTo: input } : { omit: true } + } + } +}) +``` + +Each output name maps to one `OutputResolution`: + +```js +{ + omit: true +} +{ + forwardTo: inputRef +} +{ + literal: value +} +``` + +The resolver receives a frozen `ResolveView`: + +- `self` gives the resolver's ID, type, properties, groups, mode, color, own + inputs and outputs, and widget values; +- `nodesOfType(type)` returns other frozen views in the same graph scope; +- `self.input(nameOrIndex)` creates the only reference a resolver may forward. + +Resolution follows chains to a physical backend output, literal, or omission, +with cycle detection. A resolver must be pure and synchronous. It cannot edit +the graph or a prompt draft. + +`InputSlotHandle.resolvedSource()` exposes the same final result for editor +behavior without changing topology. + +## Suppliers and broadcast behavior + +A supplier is the supply-side counterpart to a resolver. It answers which +unconnected inputs elsewhere in the same graph this node offers to feed: + +```js +comfy.defs.extend('MyPack/BroadcastModel', (definition) => { + definition.setSupply(({ self, unconnectedInputs }) => { + const output = self.outputs.find(({ name }) => name === 'MODEL') + if (!output) return [] + + return unconnectedInputs() + .filter((input) => input.type === 'MODEL') + .map((input) => ({ + to: { nodeId: input.nodeId, input: input.input }, + from: { output: output.index }, + priority: 10 + })) + }) +}) +``` + +A supplied source can be: + +- one of the supplier's own outputs; +- a literal; +- whatever feeds one of the supplier's own inputs (`forwardInput`). + +It cannot name an arbitrary third-party node as a source. A supplier may offer +what it owns, not rewire two bystanders. + +`unconnectedInputs()` exposes matching data needed by real broadcast packs: +slot name, translated label, type, widget-input status, owner title/mode/color, +groups, and frozen owner properties. + +When multiple suppliers claim one input, higher priority wins. Exact priority +ties feed nothing instead of making execution depend on graph order. + +Resolution runs independently in each graph scope. It never crosses a subgraph +boundary. + +## Inspecting winning supplies + +```js +for (const edge of comfy.graph.resolvedSupplies()) { + if (edge.supplierNodeId !== broadcaster.id) continue + if (edge.from.kind !== 'output') continue + + broadcaster.outputs + .at(edge.from.output) + ?.connectTo(edge.to.nodeId, { index: edge.to.input }) +} +``` + +This recomputes the same pure resolver and priority arbitration prompt execution +uses. It is the safe basis for a command such as “convert virtual broadcasts to +real links”; reimplementing matching in the pack can create links the prompt +would not use. + +The returned IDs are local to that graph scope. `GraphScopeHandle` offers the +same read for root or subgraph definitions. + +## Prompt-time widget serialization + +Frontend resolution changes topology. A widget's `beforeSerialize` changes one +value for one destination: + +```js +widget.on('beforeSerialize', (event) => { + if (event.context === 'prompt') { + event.setSerializedValue(resolveTemplate(String(event.value))) + } +}) +``` + +Use this for sentinel expansion, prompt templates, rolled seeds, or embedded +reproduction data. It is synchronous and does not mutate the live widget. + +Do not edit the built prompt or workflow snapshot. Use partial queue execution, +frontend resolution, supply, widget serialization, and ordinary graph commands +for the supported intents those internal edits previously combined. + diff --git a/custom-nodes/v2/javascript/graphs-nodes.mdx b/custom-nodes/v2/javascript/graphs-nodes.mdx new file mode 100644 index 000000000..0fe01922b --- /dev/null +++ b/custom-nodes/v2/javascript/graphs-nodes.mdx @@ -0,0 +1,247 @@ +--- +title: "Edit graphs and nodes" +description: "Work with root and subgraph scopes, create and replace nodes, batch edits, manage groups, and observe graph behavior." +--- + +`comfy.graph` is the editing surface for the graph currently shown to the user. +It provides graph-safe node, selection, viewport, group, link, and mutation +operations without exposing `LGraph` or `LGraphCanvas`. + +## Visible graph, root graph, and subgraphs + +The active view is not always the document root: + +```js +const visible = comfy.graph +const root = comfy.graph.root() +const definitions = comfy.graph.subgraphs() +``` + +| Handle | Scope | +| ---------------------------------------------- | ---------------------------------------------------------------------------------- | +| `GraphHandle` (`comfy.graph`) | The graph currently shown in the editor. Includes editing and viewport operations. | +| `GraphScopeHandle` (`root()` or `subgraphs()`) | One graph definition. Read-oriented: nodes, groups, and resolved supplies. | + +A subgraph entry represents a definition, not each placed instance. If the same +definition is placed three times, it appears once and its internal nodes appear +once. + +Node IDs must be resolved inside their owning graph. Do not collect all document +nodes into a map keyed only by ID: + +```js +function documentNodes() { + const scopes = [comfy.graph.root(), ...comfy.graph.subgraphs()].filter( + Boolean + ) + + return scopes.flatMap((scope) => + scope.nodes().map((node) => ({ graphId: scope.id, node })) + ) +} +``` + +Use `node.graphId` when persisting pack-owned in-memory state about a node. + +## Looking up nodes and links + +```js +const node = comfy.graph.node('42') +const samplers = comfy.graph.nodesOfType('KSampler') +const allNodes = comfy.graph.nodes() +const links = comfy.graph.links() +``` + +Every list is a frozen snapshot. `LinkInfo` is inert data with source and target +node IDs, slot IDs, types, and endpoint indexes at snapshot time. Indexes are +volatile; do not store them across slot mutations. + +Use `node.inputs` and `node.outputs` to edit connectivity. `graph.links()` is for +inspection, not mutation. + +`graph.nodeAt({ x, y })` returns the topmost node at a graph-space point using +the rendered layout and z-order. It can find nothing before the first render. + +## Creating and removing nodes + +```js +const node = comfy.graph.add('KSampler', { + title: 'Preview sampler', + position: { x: 360, y: 220 } +}) + +const copy = comfy.graph.duplicate(node.id, { x: 680, y: 220 }) +comfy.graph.remove(node.id) +``` + +`add()` constructs through the registered definition. It throws when the type +does not exist. `duplicate()` carries serializable widgets and properties but +does not copy links. It returns `undefined` when the source is gone or cannot be +constructed. + +`NodeHandle.remove()` removes its own node. `graph.remove(id)` is the equivalent +when only an ID is available. + +## Replacing a node + +Node type is identity and is read-only. Rebuild with `replace()`: + +```js +const replacement = comfy.graph.replace(node.id, 'KSamplerAdvanced') +``` + +The operation carries position, a user-customized title, colors, mode, +compatible properties, widget values by name, and every link that still fits. +It matches slots by name first and by index as a fallback. Incompatible links +are dropped with a warning rather than forced into the wrong slot. The complete +replacement is one undo step. + +Replacing with the same type is useful when a refreshed definition changed and +an existing node must be rebuilt without discarding its state. + +## Compound edits and undo + +Use `batch()` for one synchronous user operation: + +```js +comfy.graph.batch(() => { + const loader = comfy.graph.add('CheckpointLoaderSimple', { + position: { x: 100, y: 100 } + }) + const sampler = comfy.graph.add('KSampler', { + position: { x: 500, y: 100 } + }) + loader.outputs.get('MODEL')?.connectTo(sampler.id, 'model') + comfy.graph.select([loader, sampler]) +}) +``` + +The scope closes even when the callback throws. It is synchronous by design; +never include an `await` inside it. + +## Selection and viewport + +```js +const selected = comfy.graph.selection() +comfy.graph.select([node]) +comfy.graph.select([another], { add: true }) +comfy.graph.select([]) // clear + +comfy.graph.centerOn(node) +comfy.graph.setZoom(1.25) +const pointer = comfy.graph.pointerPosition() +``` + +Selection and viewport methods address the visible graph and active editor. +`pointerPosition()` is in graph coordinates and can return `undefined` when no +canvas is available. `centerOn()` does not change zoom. + +For a panel anchored to a node, read `node.getScreenRect()` and update it when +the viewport changes: + +```js +const stop = comfy.onViewportChanged(() => { + const rectangle = node.getScreenRect() + if (rectangle) positionPanel(rectangle) +}) +``` + +Do not read or reconstruct canvas pan, scale, offsets, title height, or device +pixel ratio. + +## Groups + +```js +for (const group of comfy.graph.groups()) { + console.info(group.id, group.getTitle(), group.nodes().length) +} +``` + +`GroupHandle` provides: + +- `getTitle()` / `setTitle()`; +- `getColor()` / `setColor()`; +- `nodes()` for the nodes geometrically contained by the group; +- `getBounds()` in graph space; +- `centerOn()` for the visible view. + +Groups are derived rectangles, not parents that own a stored child list. Ask +`nodes()` again after layout changes. A subgraph scope's `groups()` reads groups +inside that definition. + +## Structural version token + +`graph.version` changes when graph-visible state changes, including node or slot +structure, connections, node flags, and widget values committed through the +host protocol. + +Treat it as an opaque token: + +```js +const before = comfy.graph.version +await refreshExternalData() +if (comfy.graph.version !== before) rebuildIndex() +``` + +Do not subtract versions, assume increments of one, or use it as a replayable +event log. Pack state held outside the graph and widget model does not affect +it. A custom canvas widget whose external drawing data changed should call its +own `redraw()`. + +`graph.cacheSize` is diagnostics for live handle-cache slots, not application +state. + +## Node and graph observation + +There is no per-frame tick. Observe the semantic operation: + +### Node property changes + +```js +const stop = comfy.onNodeChanged( + ({ graphId, node, property, from, to }) => { + updateIndex(graphId, node.id, property, from, to) + }, + { scope: 'document' } +) +``` + +The default scope is `visible`. `scope: 'document'` includes the root and every +subgraph definition. Each event names its graph because node ID alone is not a +document-wide key. + +The tracked fields are title, mode, foreground color, background color, shape, +and advanced-widget visibility. Position uses the movement stream instead +because it changes continuously during a drag. + +### Movement and drag completion + +```js +const stopMove = comfy.onNodeMoved(({ node, position }) => { + updateGuide(node, position) +}) + +const stopEnd = comfy.onNodeDragEnd((nodes) => { + commitDropBehavior(nodes) +}) +``` + +A pack that moves nodes from its own movement handler must guard against +re-entry. `onNodeDragEnd` is available under Nodes 2.0; the legacy renderer does +not publish a drag lifecycle. + +### Editor interaction state + +`comfy.isInteracting()` reports whether the editor is already handling a link, +node, or widget gesture. A pack starting its own pointer gesture should stand +down while it is true. + +## Resolved supply inspection + +`graph.resolvedSupplies()` runs the same pure supplier and priority arbitration +used by prompt construction and returns the winning graph-local edges without +mutating the graph. It is intended for editor commands that materialize virtual +broadcasts as real links. Exact-priority ties are absent, matching execution. + +See [Execution and resolution](/custom-nodes/v2/javascript/execution) for supplier semantics. + diff --git a/custom-nodes/v2/javascript/migration-recipes.mdx b/custom-nodes/v2/javascript/migration-recipes.mdx new file mode 100644 index 000000000..fd51478ae --- /dev/null +++ b/custom-nodes/v2/javascript/migration-recipes.mdx @@ -0,0 +1,1246 @@ +--- +title: "Migrate legacy frontend code" +description: "Replace common LiteGraph, app, widget, graph, execution, and service internals with published V2 API behavior." +--- + +This guide answers “I used to do X through LiteGraph or `app`; how do I preserve +that behavior through the published API?” It is based on the custom-node +conversion corpus, including cases where the correct replacement was not the +API with the most similar name. + +The published entry point is always: + +```js +import { comfy } from '/comfy/api/v2.js' +``` + +Use this guide for live frontend behavior. If an object came from `JSON.parse`, +a workflow file, `graphToPrompt`, or a backend response, it is data rather than +a live node. Do not replace fields inside serialized data with handle methods. + +## Start with the behavior + +Before choosing a replacement, state what the old code accomplishes for the +user. One old mechanism often served several unrelated purposes: + +| Old mechanism | Possible intent | Published destination | +| ----------------------------------- | ------------------------------ | ------------------------------------------------------ | +| `onDrawForeground` | Show a small status label | `node.addBadge()` | +| `onDrawForeground` | Draw a control | `node.widgets.canvas()` | +| `onDrawForeground` | Enforce node size | `node.setSizeConstraints()` | +| `onDrawForeground` | Poll a value | A widget, node, graph, or viewport event | +| `onDrawForeground` | Keep DOM aligned | `node.widgets.mount()` and `comfy.onViewportChanged()` | +| `getCustomWidgets` | Render one backend input type | `comfy.defs.defineWidgetType()` | +| `getCustomWidgets` | Add a surface to one live node | `node.widgets.mount()` | +| `widgets.splice()` | Reorder widgets | `widgets.reorder()` or `widgets.move()` | +| Remove and reinsert the same widget | Invalidate changed options | `widget.setOption()` | +| `node.type = value` | Defensive self-assignment | Delete the write | +| `node.type = newType` | Replace the node type | `comfy.graph.replace()` | +| `widget.callback(value)` | Commit a new value | `widget.setValue(value)` | +| Capture and chain `widget.callback` | Observe changes | `widget.on('change', listener)` | +| Button `widget.callback()` | Run an action | `widget.on('activate', listener)` | + +Do not migrate by spelling alone. Trace what the callback reads, what it writes, +when it runs, and which saved or queued bytes it affects. + +## Quick lookup by legacy surface + +This index gives the usual destination. Follow the detailed recipe whenever the +row names more than one destination or changes saved workflow topology. + +| Legacy surface | Published destination | +| -------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `app.registerExtension(...)` | Split registration across `comfy.defs`, `settings`, `commands`, UI, and lifecycle APIs | +| `beforeRegisterNodeDef` | `comfy.defs.extend(selector, builder => ...)` | +| `nodeType.prototype.onNodeCreated` | `builder.onCreated()` | +| `nodeType.prototype.onConfigure` | `builder.onConfigured()` | +| `nodeType.prototype.onExecuted` | `builder.onExecuted()` | +| `nodeType.prototype.onRemoved` | `builder.onRemoved()` | +| `nodeType.prototype.onConnectionsChange` | `builder.onConnectionsChanged()` | +| `nodeType.prototype.onDragOver` / `onDragDrop` | `builder.onDragOver()` / `builder.onDrop()` | +| `nodeType.prototype.onSerialize` | `builder.onSerialize()` for pack-owned fields | +| `extends LGraphNode` / `registerNodeType` | `comfy.defs.define()` | +| `app.graph._nodes` | `comfy.graph.nodes()` | +| `graph.getNodeById(id)` | `comfy.graph.node(id)` or a scoped `graph.node(id)` | +| `graph._groups` | `comfy.graph.groups()` or a scoped `graph.groups()` | +| `canvas.selected_nodes` | `comfy.graph.selection()` | +| `canvas.selectNode(...)` | `comfy.graph.select(...)` | +| `canvas.centerOnNode(node)` | `comfy.graph.centerOn(node)` | +| `LiteGraph.createNode()` plus `graph.add()` | `comfy.graph.add()` | +| `node.clone()` plus `graph.add()` | `comfy.graph.duplicate()` | +| `graph.remove(node)` | `node.remove()` or `comfy.graph.remove()` | +| Replace or retype a live node | `comfy.graph.replace()` | +| `graph.beforeChange()` / `afterChange()` | `comfy.graph.batch()` | +| `graph._version++` | Delete the write; published mutations update `graph.version` | +| Document pointer listeners for a node gesture | `comfy.onNodeMoved()` and, in Nodes 2.0, `comfy.onNodeDragEnd()` | +| `canvas.connecting_links` / `resizing_node` | `comfy.isInteracting()` | +| `node.pos` / `node.size` | `getPosition()` / `setPosition()` and `getSize()` / `setSize()` | +| `node.getBounding()` | `node.getBounds()` | +| `node.getConnectionPos()` | `node.getSlotPosition()` | +| Canvas transform and `graph_mouse` | `node.getScreenRect()` and `comfy.graph.pointerPosition()` | +| `input.link` | `input.isConnected`, `input.link()`, `input.source()`, or `input.disconnect()` | +| `output.links` | `output.links()`, `output.connectTo()`, or `output.disconnect()` | +| Disconnect then reconnect to another output | `output.moveLinksTo()` | +| `node.addInput()` / `addOutput()` | `node.inputs.add()` / `node.outputs.add()` | +| `node.removeInput()` / `removeOutput()` | `node.inputs.remove()` / `node.outputs.remove()` | +| Direct slot name, type, label, or shape writes | `slot.modify()` | +| Mutate slot arrays to reorder | `node.inputs.reorder()` / `node.outputs.reorder()` | +| `LiteGraph.isValidConnection()` | `comfy.defs.isTypeCompatible()` | +| Read or merge a connected Primitive's config | `input.widgetConfig()` / `input.mergeWidgetConfig()` | +| Patch `connectByType` for a link dropped on a node | `builder.onUnplacedLink()` | +| `widget.value = value; widget.callback(value)` | `widget.setValue(value)` | +| Replace or chain `widget.callback` | `widget.on('change')` or `widget.on('activate')` | +| `widget.type = 'converted-widget'` | `widget.setHidden(true)` | +| `widget.options[key] = value` | `widget.setOption(key, value)` | +| `widget.disabled = value` | `widget.setDisabled(value)` | +| `widget.linkedWidgets` | `widget.linked()` / `widget.setLinked()` | +| `widget.computeSize` | `widget.setHeight()` or node size constraints | +| `widgets.push()` / `removeWidget()` | `widgets.add()` / `widgets.remove()` | +| `widgets.splice()` / replace widget array | `widgets.move()` / `widgets.reorder()` after classifying intent | +| `node.addDOMWidget()` | `node.widgets.mount()` | +| `widget.inputEl` | `textInteraction` for a host editor, or `widgets.mount()` for a pack control | +| `getCustomWidgets` for an input type | `comfy.defs.defineWidgetType()` | +| `widget.serializeValue` | Widget serialization flags or `beforeSerialize` | +| Name-keyed object written into `widgets_values` | Delete the write; use named handles and migrate old data in `onConfigured()` | +| Custom upload UI beside an upload-declared input | Delete it; use the host's built-in upload control | +| `node.imgs = ...` for pack drawing | `node.widgets.canvas()` and `CanvasHandle.redraw()` | +| Draw status text in node chrome | `node.addBadge()` | +| `onDrawForeground` / `onDrawBackground` | Canvas widget, semantic event, mount lifecycle, or size constraint based on intent | +| `canvas.setDirty()` / `setDirtyCanvas()` | Usually delete; use `CanvasHandle.redraw()` for external canvas-widget data | +| Read `node.imgs` / `imageIndex` from another node | `getOutputImages()` / `getDisplayedImageIndex()` | +| `getExtraMenuOptions` | `builder.addMenuItem()` | +| `new LiteGraph.ContextMenu(...)` | `comfy.ui.showMenu()` when the pack owns the triggering gesture | +| Inject sidebar, top-bar, action-bar, or modal DOM | `comfy.ui` contributions | +| `app.ui.settings` / extension `settings` | `comfy.settings` | +| Replace the canvas background draw hook | Write the core `Comfy.Canvas.BackgroundImage` setting | +| Extension `commands`, keybindings, and toast | `comfy.commands` | +| `api.fetchApi()` / `api.apiURL()` | `comfy.backend.fetch()` / `comfy.backend.url()` | +| `api.addEventListener()` for pack messages | `comfy.backend.on()` | +| Global preview and executing listeners | Definition `onPreview()` or root execution observers | +| `app.queuePrompt()` | `comfy.queue` plus serialization, resolution, or supply for the actual intent | +| Read or mutate queue setting stores | `queue.autoQueueMode()`, `setAutoQueueMode()`, `batchCount()`, or `setBatchCount()` | +| `graphToPrompt` wrapper used only for a sidecar | Pack backend route, partial queueing, and correlated execution events | +| `app.loadGraphData()` | `comfy.workflow.open()` | +| `app.applyTextReplacements()` | `comfy.workflow.applyTextReplacements()` | +| `localStorage` or direct user-data APIs | `comfy.storage` for user-authored pack documents | +| `LGraphCanvas.node_colors[name]` | `comfy.defs.nodeColor(name)` | +| Read or write pack link-type colors | `comfy.defs.typeColor()` / `setTypeColor()` | + +## Registration and lifecycle + +### How do I replace `app.registerExtension()`? + +Split the registration by behavior. The old extension object combined node +definition hooks, commands, settings, custom widgets, and application +lifecycle. The published API gives each one an explicit owner. + +| Legacy extension field | Published API | +| -------------------------------------- | ---------------------------------------------- | +| `beforeRegisterNodeDef` | `comfy.defs.extend()` | +| `registerCustomNodes` | `comfy.defs.define()` | +| `getCustomWidgets` | `comfy.defs.defineWidgetType()` | +| `settings` | `comfy.settings.declare()` | +| `commands` and keybindings | `comfy.commands.register()` | +| `setup` for registration | Run at module scope | +| `setup` that needs the initialized app | `comfy.onReady()` | +| Workflow setup | Definition hooks or `comfy.onWorkflowLoaded()` | + +Old: + +```js +app.registerExtension({ + name: 'MyPack.SamplerTools', + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== 'KSampler') return + + const previous = nodeType.prototype.onNodeCreated + nodeType.prototype.onNodeCreated = function () { + previous?.apply(this, arguments) + installSamplerTools(this) + } + } +}) +``` + +Published API: + +```js +comfy.defs.extend('KSampler', (definition) => { + definition.onCreated((node, event) => { + installSamplerTools(node, event) + }) +}) +``` + +Definition callbacks compose. Do not capture and invoke a previous prototype +method. + +### How do I run code after a node is restored? + +Use `onCreated` for a live, addressable node and inspect its event: + +```js +comfy.defs.extend('MyPack/Node', (definition) => { + definition.onCreated((node, event) => { + if (!event.restored) initializeFreshNode(node) + if (event.loading) restoreDocumentResources(node) + }) + + definition.onConfigured((node, savedData) => { + migratePackOwnedState(node, savedData) + }) +}) +``` + +`restored` covers nodes carrying saved state, including duplicate and paste. +`loading` distinguishes a workflow load. Use `onConfigured` when the behavior +needs the saved node record. + +### How do I keep private state on a node? + +Do not assign custom fields to `LGraphNode` or `NodeHandle`. Keep pack state in +a map and include graph scope in its key: + +```js +const state = new Map() +const keyOf = (node) => `${node.graphId}:${node.id}` + +comfy.defs.extend('MyPack/Node', (definition) => { + definition.onCreated((node) => { + state.set(keyOf(node), { expanded: false }) + }) + + definition.onRemoved((node) => { + state.delete(keyOf(node)) + }) +}) +``` + +## Nodes and graphs + +### How do I read or change node state? + +Handles use methods so reads can resolve current store state and writes can use +host mutation behavior. + +| Legacy live-node access | Published API | +| ----------------------- | ---------------------------------------------------------- | +| `node.title` | `node.getTitle()` / `node.setTitle(title)` | +| `node.mode` | `node.getMode()` / `node.setMode(mode)` | +| `node.flags.collapsed` | `node.isCollapsed()` / `node.setCollapsed(value)` | +| `node.flags.pinned` | `node.isPinned()` / `node.setPinned(value)` | +| `node.color` | `node.getColor()` / `node.setColor(color)` | +| `node.bgcolor` | `node.getBgColor()` / `node.setBgColor(color)` | +| `node.shape` | `node.getShape()` / `node.setShape(shape)` | +| `node.properties[name]` | `node.getProperty(name)` / `node.setProperty(name, value)` | +| `node.pos` | `node.getPosition()` / `node.setPosition(point)` | +| `node.size` | `node.getSize()` / `node.setSize(size)` | +| `{ ...node }` | `node.snapshot()` | + +Assigning a new property to a closed handle is not a mutation API. Use the +setter, or a pack-owned map when the value is not entity state. + +### How do I enumerate or find nodes? + +```js +const visibleNodes = comfy.graph.nodes() +const sampler = comfy.graph.node(nodeId) +const samplers = comfy.graph.nodesOfType('KSampler') +const selected = comfy.graph.selection() +``` + +These replace `app.graph._nodes`, `app.graph.getNodeById()`, +`canvas.selected_nodes`, and renderer selection stores for node selection. +Returned arrays are frozen snapshots. + +`comfy.graph` is the graph currently visible. For document-wide work, preserve +scope rather than flattening IDs: + +```js +const scopes = [comfy.graph.root(), ...comfy.graph.subgraphs()].filter(Boolean) + +for (const scope of scopes) { + for (const node of scope.nodes()) indexNode(scope.id, node) +} +``` + +### How do I add, clone, remove, or replace a node? + +| Legacy operation | Published API | +| --------------------------------------------------- | ------------------------------------------------ | +| `LiteGraph.createNode(type)` plus `graph.add(node)` | `comfy.graph.add(type, init)` | +| `graph.add(node.clone())` | `comfy.graph.duplicate(node.id, position)` | +| `graph.remove(node)` | `node.remove()` or `comfy.graph.remove(node.id)` | +| Delete and recreate to refresh a definition | `comfy.graph.replace(node.id, node.type)` | +| Change `node.type` | `comfy.graph.replace(node.id, newType)` | + +```js +const replacement = comfy.graph.replace(node.id, 'KSamplerAdvanced') +``` + +`replace()` carries compatible state and links and groups the operation into one +undo step on the visible graph. A defensive write such as +`node.type = node.type ?? undefined` has no user behavior and should be removed +instead of converted. + +### How do I make several edits one undo step? + +Replace `graph.beforeChange()` / `graph.afterChange()` pairs with a synchronous +scope: + +```js +comfy.graph.batch(() => { + const first = comfy.graph.add('CheckpointLoaderSimple') + const second = comfy.graph.add('KSampler') + first.outputs.get('MODEL')?.connectTo(second.id, 'model') + comfy.graph.select([first, second]) +}) +``` + +Do not hold a batch across `await`. + +### How do I respond to graph changes without polling? + +Choose the narrowest semantic signal: + +| Intent | Published signal | +| -------------------------------------- | ----------------------------------- | +| A widget value changed | `widget.on('change', listener)` | +| A connection changed | `definition.onConnectionsChanged()` | +| A property changed | `definition.onPropertyChanged()` | +| Any visible or document node changed | `comfy.onNodeChanged()` | +| A node moved | `comfy.onNodeMoved()` | +| A Nodes 2.0 drag ended | `comfy.onNodeDragEnd()` | +| Pan, zoom, or viewport resize | `comfy.onViewportChanged()` | +| Coarse “did graph state change?” check | Compare `comfy.graph.version` | + +Do not increment `graph._version` yourself. Published mutations and committed +widget values advance host change state. Treat `graph.version` as an opaque +token, not a counter or event log. + +### How do I accept a file dropped from the browser? + +Register the behavior on the node definition instead of replacing renderer +drop methods: + +```js +comfy.defs.extend('MyPack/ImageNode', (definition) => { + definition.onDragOver((_node, event) => + Array.from(event.dataTransfer?.types ?? []).includes('Files') + ) + + definition.onDrop(async (node, event) => { + const file = event.dataTransfer?.files?.[0] + if (!file) return false + + const uploadedName = await uploadFile(file) + node.widgets.get('image')?.setValue(uploadedName) + return true + }) +}) +``` + +Returning `true` from `onDragOver` asks both renderers to present and route the +drop. Returning `true` from `onDrop` claims it; handlers after the claimant do +not run. The host gets the first opportunity, so this extends file handling +rather than replacing behavior another pack or core already owns. + +## Slots and links + +### How do I inspect an input connection? + +| Legacy input access | Published API | +| ---------------------------------------------- | ------------------------ | +| `input.link != null` | `input.isConnected` | +| `graph.links[input.link]` | `input.link()` | +| Find the immediate source | `input.source()` | +| Follow frontend reroutes and virtual values | `input.resolvedSource()` | +| Determine the arriving type through a boundary | `input.connectedType` | + +```js +const input = node.inputs.get('model') +if (input?.isConnected) { + console.info(input.source()) + console.info(input.resolvedSource()) +} +``` + +Use the physical source for graph editing and the resolved source for reasoning +about execution. + +### How do I connect or disconnect slots? + +```js +const output = source.outputs.get('IMAGE') + +output?.connectTo(target.id, 'image') +target.inputs.get('image')?.disconnect() +output?.disconnect(target.id) +output?.disconnect() +``` + +These replace editing `input.link`, pushing into `output.links`, and mutating +the graph's link map. Normal compatibility checks and connection-veto hooks +still run. + +### How do I add or remove dynamic inputs and outputs? + +```js +comfy.defs.extend('MyPack/MultiImage', (definition) => { + definition.onConnectionsChanged((node) => { + const last = node.inputs.at(node.inputs.length - 1) + if (last?.isConnected) { + node.inputs.add(`image_${node.inputs.length + 1}`, 'IMAGE') + } + }) +}) +``` + +Use `node.inputs.add/remove` and `node.outputs.add/remove` instead of +`addInput`, `removeInput`, `addOutput`, or array mutation. Removing a slot +disconnects its links through the host. + +### How do I rename or retype a slot? + +Use one atomic patch: + +```js +node.outputs.get('value')?.modify({ + name: 'model', + label: 'MODEL', + type: 'MODEL', + shape: 'directional' +}) +``` + +This replaces direct writes to `slot.name`, `slot.type`, `slot.label`, and +renderer shape fields. Existing links remain attached. + +### How do I reorder slots safely? + +```js +node.inputs.reorder(['model', 'positive', 'negative', 'latent_image']) +``` + +The list must be a complete permutation. The host updates serialized endpoint +indexes while preserving which logical slots the links reach. + +### How do I move links to another output? + +```js +node.outputs.get('old_output')?.moveLinksTo('new_output') +``` + +Use this instead of disconnecting and reconnecting. It preserves link IDs, +which is required for workflow wire compatibility. + +### How do I veto or observe a proposed connection? + +Use `onBeforeConnect` only when the old hook can refuse: + +```js +comfy.defs.extend('MyPack/StrictInput', (definition) => { + definition.onBeforeConnect((_node, event) => { + if ( + event.side === 'input' && + event.peerType && + !comfy.defs.isTypeCompatible(event.peerType, 'MODEL') + ) { + return false + } + }) +}) +``` + +If the old `onConnectInput` or `onConnectOutput` never returned `false`, it was +an observer. Use `onConnectionsChanged` instead. + +### How do I preserve widget-backed input constraints? + +A socket converted from a widget carries the declaration a connected +Primitive node should render. Read or intersect that declaration through the +input handle: + +```js +const input = node.inputs.get('seed') +const current = input?.widgetConfig() + +const merged = input?.mergeWidgetConfig({ + type: 'INT', + options: { min: 0, max: 0xffffffffffff, step: 1 } +}) + +if (!merged) reportIncompatibleDeclarations(current) +``` + +`mergeWidgetConfig()` updates the declaration only when the two widget types +are compatible. It returns `undefined` and leaves the input unchanged when +they are not. For a dynamic socket that represents one of the node's widgets, +pass both `widget` and `widgetConfig` to `inputs.add()`, or apply them together +with `input.modify()`. The widget name must already exist on that node. + +### How do I handle a link dropped on a node when no one slot fits? + +Use `onUnplacedLink` when the node understands a bundle or pipe and can expand +one gesture into its own connections: + +```js +comfy.defs.extend('MyPack/ContextPipe', (definition) => { + definition.onUnplacedLink((node, event) => { + if (event.type !== 'CONTEXT' || event.side !== 'output') return false + + return connectContextToPeer(node, { + nodeId: event.peerNodeId, + outputIndex: event.peerIndex, + replaceExisting: event.replaceExisting + }) + }) +}) +``` + +The callback must make the connections through slot handles and return `true` +only when it placed the link. Both ends can be offered the gesture and the +first claimant wins. This replaces a global `connectByType` patch without +changing link-routing behavior for every other node in the document. + +## Widgets + +### How do I read and write a widget value? + +```js +const widget = node.widgets.get('seed') +const previous = widget?.getValue() +widget?.setValue(42) +``` + +`setValue()` replaces both a bare `widget.value = value` and the common pair +`widget.value = value; widget.callback?.(value)`. It uses the host's commit +protocol: property synchronization, host callback behavior, node change +behavior, one `change` event, and graph change tracking. + +An equal value is a no-op. A programmatic write does not fire `activate`. + +### How do I replace a chained `widget.callback`? + +Observe values additively: + +```js +const stop = widget.on('change', (value, previous) => { + refreshPreview(value, previous) +}) +``` + +For an action widget such as a button: + +```js +node.widgets.get('refresh')?.on('activate', () => { + refreshModels() +}) +``` + +Do not capture, replace, or call through another callback. If old code invoked +its own handler once to initialize state, call that handler directly after +subscribing. + +### How do I hide a converted widget? + +```js +node.widgets.get('seed')?.setHidden(true) +``` + +This replaces `widget.type = 'converted-widget'` and the bookkeeping that +restored the old type and size function. Hiding and serialization are separate: +`setHidden()` retains the widget's existing saved and prompt behavior. Audit any +old `serializeValue` override separately. + +### How do I add, remove, or reorder widgets? + +| Legacy mutation | Published API | +| --------------------------------------------------- | ---------------------------- | +| `widgets.push(widget)` | `widgets.add(definition)` | +| `widgets.splice(index, 1)` | `widgets.remove(name)` | +| Move with `splice()` | `widgets.move(name, index)` | +| Replace `node.widgets` with a sorted array | `widgets.reorder(names)` | +| Remove and insert the same widget at the same index | Usually `widget.setOption()` | + +```js +node.widgets.add({ type: 'button', name: 'refresh', value: null }) +node.widgets.move('refresh', 0) +node.widgets.remove('refresh') +``` + +`reorder()` requires every current widget name exactly once. It cannot silently +drop widgets or bypass teardown. + +### How do I change widget options? + +```js +widget.setOption('values', refreshedModels) +widget.setOption('min', 0) +widget.setOption('max', 100) +widget.setLabel('Model') +widget.setDisabled(true) +``` + +`getOptions()` returns a frozen view. Do not mutate it or remove and reinsert a +widget to make the renderer notice changes. + +### How do I replace `addDOMWidget()` or `widget.inputEl`? + +For a pack-owned control on one node, mount into a host-owned container: + +```js +function addNotes(node) { + let release + + return node.widgets.mount({ + name: 'notes', + defaultValue: '', + serialize: true, + sendToPrompt: false, + render(container, value) { + const textarea = document.createElement('textarea') + textarea.value = String(value.get()) + + const onInput = () => value.set(textarea.value) + textarea.addEventListener('input', onInput) + const stopValue = value.onChange((next) => { + textarea.value = String(next) + }) + container.append(textarea) + + release = () => { + textarea.removeEventListener('input', onInput) + stopValue() + } + }, + destroy() { + release?.() + } + }) +} +``` + +The host controls mounting, zoom visibility, and removal. Clean up listeners, +timers, and observers in `destroy()`. Keep one mount per serialized legacy +widget cell unless you have proved that consolidating them preserves +`widgets_values`. + +For behavior on a host-owned multiline editor, do not mount a duplicate merely +to access its element. Subscribe to `textInteraction`: + +```js +widget.on('textInteraction', (event) => { + if (event.kind === 'keydown' && event.key === 'Enter' && event.ctrlKey) { + event.preventDefault() + runText(event.value) + } +}) +``` + +### How do I replace `getCustomWidgets` for a backend input type? + +Register the renderer for that type before nodes are constructed: + +```js +comfy.defs.defineWidgetType('MY_PACK_COLOR', { + defaultValue: '#ffffff', + render(container, value, name, context) { + const input = document.createElement('input') + input.type = 'color' + input.value = String(value.get()) + input.ariaLabel = name + + const onInput = () => value.set(input.value) + input.addEventListener('input', onInput) + container.append(input) + + const stopReady = context.onNodeReady((node) => + installNodeBehavior(node, input) + ) + return () => { + stopReady() + input.removeEventListener('input', onInput) + input.remove() + } + } +}) +``` + +Do not add a second mounted widget for the same input. A type renderer preserves +the original positional `widgets_values` cell and keeps the input a widget +instead of turning it into a socket. + +### How do I replace `widget.serializeValue`? + +Classify the behavior: + +| Old behavior | Published mechanism | +| ------------------------------------------- | ---------------------------------------------------------- | +| Pack-owned widget never serializes | Create it with `serialize: false` | +| Mounted value saves but is not sent | `serialize: true`, `sendToPrompt: false` | +| Substitute a value for one destination | `beforeSerialize` | +| Asynchronously prepare data before queueing | `comfy.queue.guard()` followed by a normal committed value | + +```js +widget.on('beforeSerialize', (event) => { + if (event.context === 'prompt') { + event.setSerializedValue(expandReferences(String(event.value))) + } +}) +``` + +The three contexts are `workflow`, `prompt`, and `embedded`. The handler is +synchronous and changes only that write, not the live value. + +### How do I migrate a name-keyed `widgets_values` override? + +Delete the live override. Runtime widget identity is already the widget name: + +```js +node.widgets.get('seed')?.setValue(nextSeed) +``` + +The positional array remains the workflow wire format and is host-owned. Do +not replace it with a record, rewrite it during serialization, or mirror live +values into it. + +Keep compatibility with workflows the old pack already saved. The host applies +normal positional values before `onConfigured`, and that hook also receives +the original saved node data. Read the pack's old record there, translate +renamed or retired keys, and commit the recovered values through named widget +handles. That migration is user-data compatibility; the live serialization +override is not. + +```js +comfy.defs.extend('MyPack/LegacyNode', (definition) => { + definition.onConfigured((node, saved) => { + const legacy = saved.widgets_values + if (!legacy || Array.isArray(legacy) || typeof legacy !== 'object') return + + for (const [oldName, value] of Object.entries(legacy)) { + const currentName = renamedWidgets[oldName] ?? oldName + node.widgets.get(currentName)?.setValue(value) + } + }) +}) +``` + +### How do I migrate a custom upload widget? + +Inspect the Python input declaration first. If its options already include +`image_upload`, `animated_image_upload`, `video_upload`, or `audio_upload`, the +host supplies the chooser, upload, value commit, and preview. Remove the +duplicate frontend widget. + +Do not replace it with `widgets.mount()` merely because the original used DOM. +An extra widget adds a positional `widgets_values` cell and can shift every +later value. Mount only when the pack provides behavior beyond the declared +upload contract, and preserve the original serialized-cell count. + +## Drawing, geometry, and editor interaction + +### How do I replace an `onDraw*` callback? + +Inspect its body first. + +#### It draws a small status label + +Use a badge when the content belongs in node chrome rather than in an +interactive widget: + +```js +const removeBadge = node.addBadge(() => ({ + text: currentStatus(node) +})) +``` + +The function is evaluated while drawing, so it must return quickly. Keep the +returned removal function when the badge has a shorter lifetime than the node. + +#### It actually draws + +Move the drawing and hit testing to a pack-owned canvas widget: + +```js +const surface = node.widgets.canvas({ + name: 'status', + height: 32, + draw(context, [width, height], theme) { + context.fillStyle = theme.surface + context.fillRect(0, 0, width, height) + context.fillStyle = theme.text + context.fillText(currentStatus(node), 8, height / 2) + }, + onPointerDown({ event }) { + if (event.button === 0) openStatus(node) + } +}) + +surface.redraw() +``` + +This renders under both the legacy renderer and Nodes 2.0. Use the supplied +theme instead of LiteGraph color constants. + +#### It enforces size + +Declare the constraint once: + +```js +node.setSizeConstraints({ + minWidth: 280, + minHeight: 180, + autoHeight: true +}) +``` + +#### It polls for state + +Move the body to the event that changes the state: `widget.on('change')`, +`onPropertyChanged`, `onConnectionsChanged`, `onNodeChanged`, or +`onWorkflowLoaded`. + +#### It positions DOM over the graph + +Prefer a mounted widget. If the UI must remain outside the node, read +`node.getScreenRect()` and update it from `comfy.onViewportChanged()`. + +### How do I replace renderer geometry constants? + +| Legacy renderer data | Published answer | +| ----------------------------------- | -------------------------------------------------------------------------------- | +| `LiteGraph.NODE_TITLE_HEIGHT` | Difference between `node.getBounds()` and `node.getSize()` when genuinely needed | +| Slot spacing and node position math | `node.getSlotPosition(side, index)` | +| Canvas pan and scale | `node.getScreenRect()` or `comfy.graph.pointerPosition()` | +| Hit test against nodes | `comfy.graph.nodeAt(point)` | +| Theme widget colors | `CanvasTheme` passed to `widgets.canvas()` | +| Detect a concurrent editor gesture | `comfy.isInteracting()` | + +Use the answer to the operation instead of publishing another renderer +constant. + +### How do I request a repaint? + +Do not port `canvas.setDirty()` or `node.setDirtyCanvas()`. Handle mutations +invalidate their own host views. For external data behind a canvas widget, call +that surface's `redraw()`. + +### How do I rebuild a node-drag editing gesture? + +Observe semantic movement rather than document pointer events and canvas drag +state: + +```js +let gesture + +const stopMove = comfy.onNodeMoved(({ node, position }) => { + gesture = { + dragged: node, + target: findDropCandidate(node, position) + } +}) + +const stopEnd = comfy.onNodeDragEnd((nodes) => { + if ( + gesture?.target && + nodes.some((node) => comfy.sameEntity(node, gesture.dragged)) + ) { + commitGesture(gesture.dragged, gesture.target) + } + gesture = undefined +}) +``` + +`onNodeMoved` works under both renderers. It reports movement, not proof that a +person caused it, so guard mutations made by the gesture against re-entry. +`onNodeDragEnd` is Nodes 2.0 only because the legacy renderer has no published +drag-completion lifecycle. If the action must work under both renderers, design +an explicit command or button rather than pretending release is observable. + +Use `comfy.isInteracting()` when the old code read several renderer flags only +to ask whether the editor was already in the middle of any gesture. + +## Menus and application UI + +### How do I add a node context-menu item? + +```js +comfy.defs.extend('KSampler', (definition) => { + definition.addMenuItem({ + label: (node) => (node.isPinned() ? 'Unpin' : 'Pin'), + run(node) { + node.setPinned(!node.isPinned()) + } + }) +}) +``` + +This replaces `getExtraMenuOptions` and node-menu prototype patches. Entries +from multiple packs compose. Use `when`, a dynamic label, or `items` for a +submenu rather than constructing `LiteGraph.ContextMenu`. + +### How do I open a menu from my own button or surface? + +```js +comfy.ui.showMenu({ + event: mouseEvent, + title: 'Output type', + items: [ + { label: 'Image', run: () => choose('IMAGE') }, + { + label: 'Latent', + submenu: [{ label: 'Samples', run: () => choose('LATENT') }] + } + ] +}) +``` + +The triggering `MouseEvent` gives the host an anchor. This does not add a new +hook to a host-owned canvas or slot menu; use it only when the pack owns the +gesture that asks for the menu. + +### How do I replace DOM insertion into ComfyUI chrome? + +| Old DOM target | Published contribution | +| ---------------------------- | ------------------------------- | +| Sidebar markup | `comfy.ui.addSidebarTab()` | +| Top-bar status text | `comfy.ui.addTopBarBadge()` | +| Action button | `comfy.ui.addActionBarButton()` | +| Modal markup | `comfy.ui.showDialog()` | +| Toast | `comfy.commands.notify()` | +| Command palette and shortcut | `comfy.commands.register()` | + +Contributions are declarative so host layout, theme, accessibility, and +lifecycle remain host-owned. + +### How do I replace extension settings? + +```js +comfy.settings.declare({ + id: 'MyPack.previewQuality', + name: 'Preview quality', + type: 'slider', + defaultValue: 80, + attrs: { min: 1, max: 100, step: 1 } +}) + +const quality = comfy.settings.get('MyPack.previewQuality') +await comfy.settings.set('MyPack.previewQuality', 90) +``` + +Use `settings.onChange()` instead of polling, including for a core setting the +pack does not own. IDs must be namespaced. + +### How do I set a temporary graph background? + +If the old code replaced the canvas background renderer only to show an image, +write the core setting instead: + +```js +const setting = 'Comfy.Canvas.BackgroundImage' +const previous = String(comfy.settings.get(setting) ?? '') + +await comfy.settings.set(setting, imageUrl) + +async function stopBackgroundMode() { + await comfy.settings.set(setting, previous) +} +``` + +The host owns loading and drawing the image under both renderers. Preserve and +restore the user's previous value when the pack's temporary mode ends. + +### How do I use host palette colors? + +Resolve design tokens at the point of use: + +```js +const modelLink = comfy.defs.typeColor('MODEL') +const red = comfy.defs.nodeColor('red') + +if (red) { + node.setColor(red.color) + node.setBgColor(red.bgColor) +} +``` + +Use `setTypeColor(type, color)` only for a link type the pack owns; it refuses +core-owned types and returns an unsubscribe that restores the prior mapping. +`nodeColor()` returns the title, body, and group fill colors behind a palette +name. Do not cache or copy the host's internal color tables. + +## Execution, previews, and backend services + +### How do I replace `api.queuePrompt()` wrappers? + +| Intent | Published API | +| ------------------------------------------ | ------------------------------ | +| Queue like the Run button | `comfy.queue.run()` | +| Run selected output nodes and dependencies | `comfy.queue.run({ nodes })` | +| Synchronous final preparation | `comfy.queue.onBeforeRun()` | +| Asynchronous validation or confirmation | `comfy.queue.guard()` | +| Advance state after submission | `comfy.queue.onAfterRun()` | +| Observe backend validation refusal | `comfy.queue.onRejected()` | +| Replace one serialized widget value | `widget.on('beforeSerialize')` | +| Change virtual prompt topology | Frontend resolution or supply | + +Do not rebuild a prompt wrapper when only one stage is needed. + +### How do I read or change auto-queue and batch settings? + +Use the queue service rather than queue stores or settings IDs: + +```js +const mode = comfy.queue.autoQueueMode() +const batch = comfy.queue.batchCount() + +comfy.queue.setAutoQueueMode('change') +comfy.queue.setBatchCount(4) +``` + +The modes are `disabled`, `change`, and `instant`. Call +`disableAutoQueue()` before a self-interrupting conditional workflow so the +automatic runner does not immediately submit it again. It does not cancel the +run already in progress. + +### How do I define a frontend-only or virtual node? + +Replace `extends LGraphNode`, `isVirtualNode`, and prompt mutation with plain +data and pure resolution: + +```js +comfy.defs.define({ + type: 'MyPack/Reroute', + inputs: [{ name: 'in', type: '*' }], + outputs: [{ name: 'out', type: '*' }], + execution: 'frontend', + resolve({ self }) { + const input = self.input('in') + return { out: input ? { forwardTo: input } : { omit: true } } + } +}) +``` + +A resolver may omit, forward one of its own inputs, or return a literal. It is +synchronous and cannot mutate the graph or prompt. + +### How do I implement “use this value everywhere” behavior? + +Use a supplier rather than scanning and editing the built prompt: + +```js +comfy.defs.extend('MyPack/BroadcastModel', (definition) => { + definition.setSupply(({ self, unconnectedInputs }) => { + const output = self.outputs.find(({ name }) => name === 'MODEL') + if (!output) return [] + + return unconnectedInputs() + .filter(({ type }) => type === 'MODEL') + .map((input) => ({ + from: { output: output.index }, + to: { nodeId: input.nodeId, input: input.input }, + priority: 10 + })) + }) +}) +``` + +Supply resolution is graph-local and does not cross subgraph boundaries. +`graph.resolvedSupplies()` exposes the winning edges when a command needs to +materialize the same choices as real links. + +### How do I correlate execution or preview events with a node? + +```js +comfy.defs.extend('MyPack/PreviewNode', (definition) => { + definition.onExecuted((node, result) => { + updateResult(node, result) + }) + + definition.onPreview((node, frame) => { + updatePreview(node, frame.url) + }) +}) +``` + +This replaces global `b_preview`, `b_preview_with_metadata`, and module-level +“currently executing ID” correlation for a node's own frames. For global +execution UI, use `comfy.executingNode()`, `comfy.executionNode(id)`, and +`comfy.onExecutingNodeChanged()`. + +### How do I read images produced by another node? + +Definition callbacks are intentionally correlated only with the node type they +extend. For a command, panel, or overlay that inspects an arbitrary node, read +that node's output state directly: + +```js +const images = producer.getOutputImages() +const displayed = producer.getDisplayedImageIndex() +const selectedUrl = displayed === undefined ? undefined : images[displayed] +``` + +The image list is a frozen URL snapshot. The displayed index is the image the +user selected or is hovering, and can be `undefined` when no image is singled +out. + +### How do I replace a `graphToPrompt` wrapper used for a sidecar cache? + +Trace the Python first. A wrapper that only chose a cache filename does not +require access to the built prompt: + +1. Use the pack's authenticated route through `comfy.backend.fetch()` to read + or refresh the sidecar. +2. If the backend needs connected tensors, run that node and its dependencies + with `comfy.queue.run({ nodes: [node] })`. +3. Refresh correlated state from the definition's `onExecuted` callback and + `comfy.backend.on('execution_cached', ...)` when the cached-result path also + matters. +4. Use a backend hidden `UNIQUE_ID` input when simultaneous node instances need + distinct identity. + +A hidden string whose default is the same for every node is not a frontend API +gap; it is insufficient backend identity. State the simultaneous-node +limitation instead of restoring prompt mutation. + +### How do I call my Python routes or receive custom messages? + +```js +const response = await comfy.backend.fetch('/my-pack/models') +const stop = comfy.backend.on('my-pack-progress', (detail) => { + updateProgress(detail) +}) +``` + +Use `backend.fetch()` rather than plain `fetch(backend.url(...))` when the +request needs host credentials. Use `new URL('./asset.css', import.meta.url)` +for a file shipped beside the current module. + +### How do I load a workflow or store pack documents? + +```js +await comfy.workflow.open(parsedWorkflow) + +await comfy.storage.set('MyPack.presets/portrait', JSON.stringify(preset)) +const saved = await comfy.storage.get('MyPack.presets/portrait') +``` + +`workflow.open()` replaces `app.loadGraphData()` for an explicit user action. +`comfy.storage` replaces direct user-data APIs or `localStorage` for named +server-side presets, prompts, and templates. + +### How do I apply ComfyUI filename and workflow tokens? + +```js +const filename = comfy.workflow.applyTextReplacements( + '%date:yyyy-MM-dd%_%KSampler.seed%' +) +``` + +This uses the active root graph and the same token language as core. It throws +when no graph is active. Do not copy the token parser or read workflow widgets +through renderer objects. + +## Things not to translate + +Some old code should disappear rather than acquire a new spelling: + +| Legacy code | What to do | +| --------------------------------------------------------------------- | --------------------------------------------------- | +| `node.type = node.type ?? undefined` | Delete the defensive no-op. | +| `canvas.setDirty()` after a handle write | Delete it; the mutation invalidates its view. | +| Restore fields saved only to undo `converted-widget` | Delete the workaround; use `setHidden()`. | +| Duplicate upload widget for an input already declaring upload options | Delete it; let the host render the upload behavior. | +| Rewrite named widget values into `widgets_values` | Delete it; address live widgets by name. | +| Poll inside a draw callback | Subscribe to the state-changing event. | + +Other mechanisms have no sanctioned equivalent and must not be recreated with +an internal escape hatch: + +- patching `LGraph`, `LGraphNode`, `LGraphCanvas`, renderer, or widget + prototypes; +- mutating the built prompt or core workflow snapshot; +- selecting and restyling host-owned DOM with global selectors; +- publishing or consuming live internal stores and mutable link records; +- making a supplier or resolver cross a subgraph boundary; +- using a per-frame callback as a general tick. + +If a user capability remains after checking the supported alternatives, name +that capability precisely as an API gap. Do not invent a plausible member or +reintroduce the old object. + +## How do I document a refusal? + +A refusal is an architectural decision, not shorthand for “the conversion was +hard” or “I did not find a method.” It must answer **why** the old mechanism or +requested capability is outside the published contract. + +Every refusal record must include: + +1. **User behavior:** what the feature does from the user's point of view. +2. **Mechanism:** the exact live object, prototype, store, DOM, prompt, or + renderer operation the original used. +3. **Reason:** which ownership, determinism, wire-format, renderer-independence, + scope, or lifecycle guarantee that mechanism violates. +4. **Outcome:** the supported replacement and remaining loss. State explicitly + when the loss is nothing. +5. **Boundary:** the precise published capability or policy change that would + make the refused remainder supportable, or why it must remain host-owned. + +Keep those facts in one adjacent comment block. Use the existing terminal +markers for the outcome rather than inventing another marker: + +```js +// REFUSED: replacing LGraphCanvas.prototype.prompt to change the host's +// numeric-entry behavior for every node and every pack. +// Reason: a pack would own host-global editor behavior, and callback order +// would decide which pack's replacement wins. +// RESTORED: this pack's numeric editor uses comfy.ui.prompt from its own menu. +// INOPERABLE: nothing. +// Reconsider if the host publishes a scoped numeric-editor contribution. +``` + +When behavior is genuinely lost, name the loss rather than softening it: + +```js +// REFUSED: wrapping graphToPrompt to replace the built prompt with an implicit +// cross-product of widget values. +// Reason: execution would depend on hidden frontend code rather than the saved +// graph, and multiple wrappers would compose in load order. +// DROPPED: one queue action no longer expands into undeclared executions. +// This remains host-owned unless the graph gains a serializable fan-out node. +``` + +These are not valid reasons: + +- “not supported”; +- “no API”; +- “renderer internals are unavailable”; +- “cannot be converted”; +- a list of removed property names without the behavior they implemented. + +Those statements describe absence or effort, not a decision. If the behavior +is acceptable but the public surface is merely missing, classify it as an API +gap. If another published mechanism preserves it, convert through that +mechanism and record the refused technique only as localized rationale. + +## Migration safety checklist + +- Trace every `inputs`, `outputs`, `links`, `widgets`, and `properties` value to + determine whether it is live state or serialized data. +- State the user-visible behavior before selecting a replacement. +- Preserve workflow and prompt wire format, including positional widget cells + and link IDs. +- Use the narrowest semantic event instead of polling or draw-time work. +- Keep frontend resolvers and suppliers pure and graph-scoped. +- Use methods for handle reads and writes; do not attach arbitrary properties. +- Probe optional behavior with `comfy.supports()`. +- Verify both behavioral equivalence and the specific failure the migration is + intended to fix. + +For the detailed contracts behind these recipes, continue with +[Nodes](/custom-nodes/v2/javascript/definitions), [Graphs](/custom-nodes/v2/javascript/graphs-nodes), [Slots](/custom-nodes/v2/javascript/slots-links), +[Widgets](/custom-nodes/v2/javascript/widgets-ui), [Execution](/custom-nodes/v2/javascript/execution), and +[Application services](/custom-nodes/v2/javascript/execution-services). + diff --git a/custom-nodes/v2/javascript/registration.mdx b/custom-nodes/v2/javascript/registration.mdx new file mode 100644 index 000000000..525884f7b --- /dev/null +++ b/custom-nodes/v2/javascript/registration.mdx @@ -0,0 +1,250 @@ +--- +title: "Register extensions and lifecycle hooks" +description: "Import the API, declare capabilities, register behavior, define frontend nodes and widgets, and clean up owned resources." +--- + +Custom-node JavaScript is loaded as an ES module. Register published behavior +from the module body, then use explicit lifecycle signals for work that needs a +running graph or a loaded workflow. + +## Importing the API + +```js +import { comfy } from '/comfy/api/v2.js' +``` + +The frontend installs one API instance immediately before it loads custom-node +modules. `/comfy/api/v2.js` re-exports that instance rather than constructing a +new registry. + +Extension modules should import `comfy` so the dependency is explicit, the +published contract is clear, and tooling can understand it. Do not make a +global object part of your pack's runtime contract. + +These legacy globals and modules are not part of this contract: + +- `window.comfyAPI`; +- `window.app`; +- `/scripts/app.js`, `/scripts/api.js`, `/scripts/widgets.js`; +- `LiteGraph` and renderer globals. + +## Versioning and capability probes + +```js +console.info(comfy.version) +console.info(comfy.major) + +if (comfy.supports('slots.dynamic')) { + installDynamicInputs() +} + +comfy.require('workflow.open') +``` + +Use `supports()` for optional behavior and `require()` for a feature without +which the extension cannot work. Do not compare application versions, parse +`comfy.version`, or probe an internal member. + +`comfy.capabilities()` returns a frozen list of everything the host provides. +`comfy.forMajor(major)` pins a public major when a pack deliberately maintains +more than one implementation. + +## Registration at module load + +The API is ready for declarations before the graph has completed setup. These +operations normally belong at module scope: + +```js +comfy.settings.declare({ + id: 'SeedTools.showBadge', + name: 'Show seed badge', + type: 'boolean', + defaultValue: true +}) + +comfy.commands.register({ + id: 'SeedTools.resetSelected', + label: 'Reset selected sampler seeds', + scope: 'canvas', + run() { + for (const node of comfy.graph.selection()) { + node.widgets.get('seed')?.setValue(0) + } + } +}) + +comfy.defs.extend('KSampler', (definition) => { + definition.onCreated((node) => installSamplerBehavior(node)) +}) +``` + +Registration IDs share host-wide namespaces. Prefix setting, command, tab, +dialog, top-bar badge, and action-button IDs with a stable pack name. + +## Extending backend node definitions + +`defs.extend(selector, apply)` is the replacement for +`beforeRegisterNodeDef` and prototype patching: + +```js +const stop = comfy.defs.extend( + { category: /^image\/postprocessing/ }, + (definition) => { + definition.onExecuted((node, result) => { + rememberImages(node, result.images) + }) + } +) +``` + +A selector can be: + +- an exact type string; +- an array of type strings; +- a type-name regular expression; +- `{ category: string | RegExp }`; +- a predicate over `NodeDef` when the other forms cannot express the match. + +Prefer an indexable selector. A predicate must inspect every registered +definition and should be reserved for structural questions such as “any node +with a VAE input.” + +The builder's `def` is the frozen definition after earlier extensions have run. +Registered callbacks compose; there is no previous prototype callback to +capture or invoke. + +## Defining a frontend-owned node type + +Use plain data rather than subclassing `LGraphNode`: + +```js +const unregister = comfy.defs.define({ + type: 'SeedTools/Reroute', + title: 'Seed Tools Reroute', + category: 'Seed Tools', + inputs: [{ name: 'in', type: '*' }], + outputs: [{ name: 'out', type: '*' }], + execution: 'frontend', + resolve({ self }) { + const input = self.input('in') + return { out: input ? { forwardTo: input } : { omit: true } } + } +}) +``` + +The `type` must be globally unique. `define()` returns an unregister function. +`execution: 'frontend'` keeps the node out of the backend prompt. A resolver is +optional: without one, the node is simply omitted. See +[Execution and resolution](/custom-nodes/v2/javascript/execution) before defining execution behavior. + +## Defining an input widget type + +`defineWidgetType()` replaces `getCustomWidgets` for a Python input type: + +```js +const unregister = comfy.defs.defineWidgetType('SEED_TOOLS_COLOR', { + defaultValue: '#ffffff', + minWidth: 120, + render(container, value, name, context) { + const input = document.createElement('input') + input.type = 'color' + input.value = String(value.get()) + input.ariaLabel = name + input.addEventListener('input', () => value.set(input.value)) + container.append(input) + + const stopValue = value.onChange((next) => { + input.value = String(next) + }) + return () => { + stopValue() + input.remove() + } + } +}) +``` + +Type-level widget construction happens before the owner has joined a graph. The +render callback therefore receives a value accessor and a `WidgetTypeContext`, +not a node handle. Use `context.onNodeReady()` when behavior genuinely needs the +owning `NodeHandle`. + +The element belongs to the pack's mounted UI surface; it is not a supported way to change ComfyUI's host page. Use the supplied `container` and published UI contributions instead of querying or changing host-owned page elements. + +## Lifecycle signals + +### Application ready + +```js +const stop = comfy.onReady(() => { + rebuildIndex(comfy.defs.all()) +}) +``` + +At this point the canvas, settings, graph, and node definitions exist. A listener +registered after readiness still runs on the next microtask. + +### Workflow loaded + +```js +const stop = comfy.onWorkflowLoaded(() => { + restorePackStateFor(comfy.graph.root()) +}) +``` + +This fires after every workflow load. It is the replacement for a one-time setup +hook when behavior belongs to each document. + +### Definition lifecycle + +Use `NodeDefBuilder` or `NodeDefinition` callbacks for individual instances: + +- `onCreated` after the node joins a graph; +- `onConfigured` after saved data is applied; +- `onRemoved` when it leaves; +- `onExecuted` and `onPreview` for backend results; +- `onConnectionsChanged`, `onResized`, `onHover`, `onDoubleClick`, + `onPropertyChanged`, `onDragOver`, and `onDrop` for semantic editor behavior. + +`NodeCreatedEvent.restored` distinguishes fresh nodes from nodes carrying saved +state. `NodeCreatedEvent.loading` distinguishes workflow load from paste or +duplication. + +## Cleanup and ownership + +Registrations and subscriptions commonly return `Unsubscribe`: + +```js +const cleanup = [ + comfy.defs.extend('KSampler', installDefinition), + comfy.onWorkflowLoaded(rebuild), + comfy.ui.addSidebarTab(tabDefinition) +] + +function disposePack() { + for (const stop of cleanup.splice(0)) stop() +} +``` + +Match cleanup to ownership: + +- module registrations may live for the page; +- a sidebar tab or dialog releases listeners, observers, and timers from its + `destroy` callback; +- a mounted widget releases them from `MountDef.destroy` or the function + returned by a widget type's `render`; +- per-node state should be removed from `onRemoved`. + +Do not attach private fields to a node handle. Keep pack-owned state in a map +keyed by graph ID and node ID, and release it with the node lifecycle. + +## Type contract + +Generate the complete declarations from the matching frontend revision: + +```sh +node scripts/magic-patch/gen_api_dts.mjs > comfy-api.d.ts +``` + +If a type or member is absent from that file, it is not published. The runtime +global is not a substitute for missing declarations. diff --git a/custom-nodes/v2/javascript/slots-links.mdx b/custom-nodes/v2/javascript/slots-links.mdx new file mode 100644 index 000000000..f101264eb --- /dev/null +++ b/custom-nodes/v2/javascript/slots-links.mdx @@ -0,0 +1,234 @@ +--- +title: "Work with slots and links" +description: "Inspect, connect, add, reorder, modify, and resolve slots and links through stable handles." +--- + +Slots and links are exposed through stable handles and frozen snapshots. Use +them instead of `node.inputs`, `node.outputs`, `input.link`, `output.links`, or +mutable link records from LiteGraph. + +## Slot identity and references + +A slot index is a position and changes when the slot list changes. A `SlotId` is +stable for the lifetime of the slot. + +Methods accepting `SlotRef` support: + +```js +node.inputs.get('model') // exact name, preferred +node.inputs.get(slotId) // stable runtime identity +node.inputs.get({ index: 0 }) // explicit positional access +``` + +A bare number is deliberately not accepted. Use `{ index }` so a volatile +positional dependency is visible in code review and search. + +String resolution is: + +1. exact `SlotId`; +2. exact slot name; +3. while named slots are unavailable, a canonical integer string such as `'0'` + resolves positionally; +4. no match. + +`get(name)` throws `ComfyAmbiguousSlotError` when more than one slot has that +name. `byName(name)` returns `undefined` on ambiguity. Output names may +legitimately repeat, so the API never guesses. + +Slot IDs are runtime identity. Workflows still serialize link endpoints by +index, so do not persist a `SlotId` across save and reload. + +## Collections + +Every node has input and output collections: + +```js +const inputs = node.inputs +const outputs = node.outputs + +inputs.length +inputs.get('model') +inputs.byId(slotId) +inputs.byName('model') +inputs.at(0) +inputs.all() +inputs.ids() +inputs.names() +``` + +The returned arrays are frozen snapshots. Collections are iterable and their +handles remain operation-oriented. + +## Inspecting an input + +```js +const input = node.inputs.get('model') +if (input) { + console.info(input.id, input.index, input.name, input.type) + console.info(input.label, input.connectedType, input.isConnected) +} +``` + +Important input reads: + +| Method or field | Meaning | +| --------------------------- | ------------------------------------------------------------------------------- | +| `link()` | Frozen `LinkInfo` for the physical incoming link. | +| `source()` | Physical source node ID and output index. Stops at a frontend reroute. | +| `resolvedSource()` | Executable source after frontend-node resolution: output, literal, or omission. | +| `connectedType` | Type arriving through the connection, including a subgraph boundary. | +| `isWidgetInput` | Whether the slot is the socket form of a widget. | +| `widgetConfig()` | Input declaration used by a connected Primitive node. | +| `mergeWidgetConfig(config)` | Compatible intersection of this and another declaration. | +| `snapshot()` | Frozen `SlotSnapshot`. | + +Use `source()` to edit physical topology. Use `resolvedSource()` to understand +what execution ultimately receives through reroutes, Get/Set nodes, and other +frontend resolvers. + +## Inspecting an output + +```js +const output = node.outputs.get('IMAGE') +if (output) { + const links = output.links() + const targets = output.targets() + console.info(links, targets) +} +``` + +`links()` and `targets()` are snapshots, so it is safe to iterate while +disconnecting. + +## Connecting and disconnecting + +```js +const made = source.outputs.get('IMAGE')?.connectTo(target.id, 'image') + +target.inputs.get('image')?.disconnect() +source.outputs.get('IMAGE')?.disconnect(target.id) +source.outputs.get('IMAGE')?.disconnect() // all targets +``` + +`connectTo()` returns the new `LinkInfo`, or `undefined` when the endpoint does +not exist or the host rejects the connection. Normal compatibility and +definition hooks still apply. + +Do not update `LinkInfo`; it is a record of one observation. Perform the edit +through the slot handles. + +## Adding and removing dynamic slots + +```js +const input = node.inputs.add('image_3', 'IMAGE', { + shape: 'optional', + localizedName: 'Third image' +}) + +node.outputs.add('batch', 'IMAGE', { shape: 'list' }) +node.inputs.remove(input.id) +``` + +`SlotOptions` supports: + +- `shape`: `'default'`, `'optional'`, `'list'`, or `'directional'`; +- `localizedName`; +- a custom graph-space `position` and link `direction`; +- `widget`, naming the widget whose socket form this input represents; +- `widgetConfig`, the declaration a connected Primitive should render. + +Shapes, localized names, positions, directions, colors, and widget-input +metadata can affect saved workflow bytes. Reproduce the original declaration +when wire compatibility matters. + +Removing a slot disconnects its links. + +## Reordering slots safely + +```js +node.inputs.reorder(['model', 'positive', 'negative', 'latent_image']) +``` + +The names must be a complete permutation of the current slots. The host updates +every affected link endpoint as part of the operation and preserves link IDs. +Permuting an internal slot array directly would silently retarget connections, +because serialized links store endpoint indexes. + +The slot order itself is serialized, so reordering changes the workflow by +design while preserving which logical slots its links reach. + +## Modifying a slot + +Apply related changes atomically: + +```js +node.outputs.get('value')?.modify({ + name: 'model', + label: 'MODEL', + type: 'MODEL', + shape: 'directional' +}) +``` + +`SlotPatch` supports name, label, localized name, type, position, direction, +connected and unconnected colors, and shape. `InputSlotPatch` additionally +supports widget identity and widget configuration. + +A type may be a string or an array of accepted types. The host normalizes an +array to the comma-separated form used by its compatibility checks. + +Retyping keeps existing links. Dynamic wildcard-to-concrete nodes depend on +that behavior; explicitly disconnect a link when the feature requires it. + +## Moving output links without replacing them + +```js +const moved = node.outputs.get('old_output')?.moveLinksTo('new_output') +``` + +`moveLinksTo()` moves every link to another output on the same node and +preserves link IDs. Disconnecting and reconnecting would allocate new IDs and +change the serialized workflow. + +The move deliberately does not revalidate types. The observed migration pattern +moves links away and then retypes a slot; checking compatibility halfway through +would reject that valid sequence. + +## Widget-backed inputs + +A dynamic input that is the socket form of a widget must carry the relationship: + +```js +node.inputs.add('strength', 'FLOAT', { + widget: 'strength', + widgetConfig: { + type: 'FLOAT', + options: { default: 1, min: 0, max: 2, step: 0.05 } + } +}) +``` + +This is not cosmetic. Widget-backed inputs serialize differently from ordinary +sockets, and the widget keeps its position in `widgets_values`. + +`InputSlotHandle.modify({ widget, widgetConfig })` updates an existing input. +Use `null` for `widget` to clear the relationship. + +## Physical and resolved topology + +Frontend-only nodes can forward, replace, or omit execution values without +changing physical links. Keep the distinction explicit: + +```js +const physical = input.source() +const executable = input.resolvedSource() +``` + +An executable source is one of: + +- `{ kind: 'output', graphId, nodeId, outputIndex }`; +- `{ kind: 'literal', value }`; +- `{ kind: 'omitted', reason }`. + +Resolution is read-only and scoped to the input's graph. See +[Execution and resolution](/custom-nodes/v2/javascript/execution) for defining resolvers and suppliers. diff --git a/custom-nodes/v2/javascript/tutorial.mdx b/custom-nodes/v2/javascript/tutorial.mdx new file mode 100644 index 000000000..c8999f882 --- /dev/null +++ b/custom-nodes/v2/javascript/tutorial.mdx @@ -0,0 +1,184 @@ +--- +title: "Extend a node with JavaScript" +description: "Add a live badge, menu action, execution feedback, lifecycle behavior, and optional capabilities to an existing node." +--- + +This tutorial adds a live seed badge and a “Reset seed” menu item to the built-in +`KSampler` node. It demonstrates module registration, definition hooks, node and +widget handles, a user-visible mutation, and lifecycle timing without touching a +frontend global or prototype. + +## 1. Create the pack layout + +```text +seed_tools/ +├── __init__.py # existing V1 entrypoint, unchanged +├── web/ # existing V1 frontend, unchanged +│ └── seed-tools.js +└── v2/ # complete V2 replacement pack root + ├── __init__.py + └── web/ + └── seed-tools.js +``` + +This tutorial changes the V2 implementation under `v2/`. A converted pack keeps +its V1 distribution at the top level and repeats the complete distribution +under `v2/`; the V2 tree is a replacement pack root, not an overlay. + +Expose the V2 web directory from `v2/__init__.py`: + +```python +from comfy_api.v0_0_3 import ComfyExtension + +WEB_DIRECTORY = "./web" + + +class SeedToolsExtension(ComfyExtension): + pass + + +async def comfy_entrypoint() -> ComfyExtension: + return SeedToolsExtension() +``` + +ComfyUI discovers JavaScript modules in that directory when it loads the V2 +custom-node package. Create the module at `v2/web/seed-tools.js`. + +## 2. Import the API + +Create `v2/web/seed-tools.js`: + +```js +import { comfy } from '/comfy/api/v2.js' + +comfy.require('defs.extend') +``` + +The published module is installed before custom-node modules evaluate. Import +it directly; do not import `/scripts/app.js`, read `window.comfyAPI`, or wait for +a DOM element. + +`require()` makes the dependency explicit. If the host is too old, the error +names the missing capability and the API version. For an optional enhancement, +use `supports()` and skip only that enhancement instead. + +## 3. Extend `KSampler` + +Register the definition behavior at module scope: + +```js +comfy.defs.extend('KSampler', (definition) => { + definition.onCreated((node) => { + node.addBadge(() => { + const seed = node.widgets.get('seed')?.getValue() + return { + text: `Seed ${String(seed ?? 'unset')}`, + bgColor: '#334155' + } + }) + }) + + definition.addMenuItem({ + label: 'Reset seed', + run(node) { + node.widgets.get('seed')?.setValue(0) + } + }) +}) +``` + +There is no constructor and no prototype patch: + +- `defs.extend()` selects a registered node type; +- `onCreated()` receives an ID-backed `NodeHandle` after the node joins a graph; +- `node.widgets.get()` finds the seed by stable name rather than array position; +- `setValue()` commits through the same value protocol as a user edit; +- `addBadge()` asks the host to render node chrome under either renderer. + +The badge callback is evaluated when the node is drawn, so it reads the latest +seed. Keep a dynamic badge callback fast. For expensive work, update cached pack +state from a widget `change` listener and have the badge read that state. + +## 4. Add feedback after execution + +The definition builder can observe backend results without subscribing to raw +backend messages or guessing which node produced them: + +```js +comfy.defs.extend('KSampler', (definition) => { + definition.onExecuted((node, result) => { + comfy.commands.notify({ + severity: 'success', + summary: 'Sampling complete', + detail: `${node.getTitle()} produced ${result.images.length} image(s)`, + life: 2500 + }) + }) +}) +``` + +Multiple extensions of the same type compose. They do not need to capture and +call a previous callback. In a real pack, keep related hooks in one +`defs.extend()` call when that makes the behavior easier to read. + +## 5. Use application readiness only when needed + +Definition registration belongs at module scope. Work that needs the initialized +graph belongs behind `onReady`: + +```js +const stopReady = comfy.onReady(() => { + const count = comfy.graph.nodesOfType('KSampler').length + console.info(`[seed-tools] ${count} sampler(s) in the visible graph`) +}) +``` + +`onReady` fires once. If the extension must repeat work after every workflow +open, use `onWorkflowLoaded` instead. + +The returned function unsubscribes. A module-lifetime listener normally lives +as long as the page; a listener owned by a tab, dialog, widget, or node should be +released when that owner is destroyed. + +## 6. Make an optional feature degrade cleanly + +Suppose a later version anchors a panel to the node's screen rectangle: + +```js +if (comfy.supports('viewport.changed')) { + const stop = comfy.onViewportChanged(() => { + for (const node of comfy.graph.nodesOfType('KSampler')) { + const rectangle = node.getScreenRect() + if (rectangle) updatePanel(node.id, rectangle) + } + }) +} +``` + +The capability check describes the needed behavior. Do not compare frontend +application versions or test whether an internal property happens to exist. + +## 7. Verify behavior + +Exercise at least these cases in a real frontend: + +1. create a fresh KSampler and confirm the badge appears; +2. change the seed manually and confirm the badge follows it; +3. choose “Reset seed” and confirm linked seed controls and serialization react + as they would to a user edit; +4. duplicate, save, reload, and delete the node; +5. run the workflow and confirm the notification is attributed to the correct + node; +6. repeat under every renderer the pack claims to support. + +Static type and conformance checks cannot prove those interactions. They are the +final behavior contract a user experiences. + +## Next steps + +- [Runnable example packs](/custom-nodes/v2/javascript/example-packs) +- [Migration how-to](/custom-nodes/v2/javascript/migration-recipes) +- [Registration and lifecycle](/custom-nodes/v2/javascript/registration) +- [Nodes and definitions](/custom-nodes/v2/javascript/definitions) +- [Widgets](/custom-nodes/v2/javascript/widgets-ui) +- [API reference](/custom-nodes/v2/reference/javascript-core) diff --git a/custom-nodes/v2/javascript/widgets-ui.mdx b/custom-nodes/v2/javascript/widgets-ui.mdx new file mode 100644 index 000000000..449f45c8c --- /dev/null +++ b/custom-nodes/v2/javascript/widgets-ui.mdx @@ -0,0 +1,345 @@ +--- +title: "Add widgets and custom UI" +description: "Manage widget values, events, ordering, serialization, mounted controls, canvas surfaces, and custom widget types." +--- + +Widgets are addressed by name through `node.widgets`. The API separates +ordinary value widgets, per-node mounted surfaces, per-node canvas surfaces, +and type-level widget renderers. + +## Widget collections + +```js +const widgets = node.widgets + +widgets.length +widgets.get('seed') +widgets.at(0) +widgets.all() +widgets.names() +``` + +Use names whenever possible. `at(index)` is explicitly positional and becomes +stale when widgets are inserted, removed, or reordered. + +All list reads are frozen snapshots. The collection itself provides mutation +operations: + +```js +widgets.add({ + type: 'button', + name: 'refresh' +}) + +widgets.move('refresh', 0) +widgets.reorder(['refresh', 'model', 'seed']) +widgets.remove('refresh') +``` + +`reorder()` requires every current name exactly once. It updates both legacy +and Nodes 2.0 render order. `add()` rejects duplicate names. + +For behavior that belongs to every node of a type, prefer +`NodeDefBuilder.addWidget()` and install listeners from `onCreated`. + +## Values and commits + +```js +const seed = node.widgets.get('seed') +if (seed) { + const current = seed.getValue() + seed.setValue(Number(current) + 1) +} +``` + +`setValue()` is not a bare assignment. It commits exactly as the host's user +edit protocol does: + +- writes the value; +- synchronizes a property-backed widget; +- runs the host and pack callback chain; +- calls node widget-change behavior; +- fires one `change` event; +- advances graph change state. + +Writing an equal value is a no-op. A programmatic commit never fires +`activate`; that event represents a user act. + +## Widget events + +```js +const stopChange = widget.on('change', (value, previous) => { + updatePreview(value, previous) +}) + +const stopActivate = widget.on('activate', () => { + performAction() +}) + +const stopRemoved = widget.on('removed', () => { + releaseWidgetState() +}) +``` + +| Event | Use it for | +| ----------------- | -------------------------------------------------------------------------------------------- | +| `change` | A committed value changed. Receives new and old values. | +| `activate` | The user acted: clicked a button or committed a control. Programmatic writes do not fire it. | +| `removed` | The widget left the node. | +| `textInteraction` | Caret, input, keyboard, selection, and wheel behavior for a host-owned multiline editor. | +| `beforeSerialize` | Synchronously replace the value for workflow, prompt, or embedded serialization. | + +Listeners are additive. Do not capture or replace `widget.callback`. + +## Visibility, disabled state, labels, and options + +```js +widget.setHidden(true) +widget.setDisabled(true) +widget.setLabel('Resolved model') +widget.setOption('min', 0) +widget.setOption('max', 1) +``` + +`setHidden()` is the replacement for assigning the special +`'converted-widget'` type. It retains the value and cascades to linked controls. +Visibility, disabled state, and serialization are independent. + +Use: + +- `isHidden()`; +- `isDisabled()`; +- `isSerialized()`; +- `getOptions()` for a frozen options view; +- `setOption(key, value)` for one option. + +Options include common numeric bounds, combo values, read-only and multiline +flags, placeholders, presentation flags, and widget-input metadata. Do not +mutate the object returned by `getOptions()`. + +## Linked widgets + +Compound controls use an explicit relationship: + +```js +seed.setLinked(['control_after_generate']) +const controls = seed.linked() +``` + +Every name passed to `setLinked()` must identify another widget on the node. +Pass an empty array to clear the relationship. Hiding the owner automatically +hides linked controls; reading `linked()` is useful when their current values +affect behavior. + +## Height and layout + +```js +widget.setHeight(120) +const allocated = widget.getHeight() +``` + +`setHeight()` pins the widget's allocation in graph units. `getHeight()` returns +the latest host allocation, or `undefined` before layout. + +This differs from `MountDef.height`: the mount option gives the inner container +a height, while `WidgetHandle.setHeight()` changes how much node layout assigns +to the widget. Leave the height unpinned for an editor intended to fill spare +space. + +Use `node.setSizeConstraints()` for node-level minimum, maximum, or auto-height +behavior instead of reassigning a size callback. + +## Serialization + +### Declared widgets + +`WidgetDef.serialize` controls whether an added widget writes to the saved +workflow: + +```js +definition.addWidget({ + type: 'text', + name: 'status', + value: '', + disabled: true, + serialize: false +}) +``` + +### Destination-specific values + +```js +widget.on('beforeSerialize', (event) => { + if (event.context === 'prompt') { + event.setSerializedValue(expandPrompt(String(event.value))) + } +}) +``` + +Contexts are: + +- `workflow`: a workflow saved by the user; +- `prompt`: the API payload sent for execution; +- `embedded`: the workflow copy embedded into output from that prompt. + +`setSerializedValue()` changes that write only. The live widget stays unchanged. +Handlers are synchronous and the last replacement wins. + +## Mounting a DOM control on one node + +`widgets.mount()` is the replacement for `addDOMWidget`: + +```js +let stopInput + +const handle = node.widgets.mount({ + name: 'strength_editor', + defaultValue: 1, + serialize: true, + sendToPrompt: true, + height: 36, + render(container, value) { + const input = document.createElement('input') + input.type = 'range' + input.min = '0' + input.max = '2' + input.step = '0.05' + input.value = String(value.get()) + + const onInput = () => value.set(Number(input.value)) + input.addEventListener('input', onInput) + container.append(input) + + const stopValue = value.onChange((next) => { + input.value = String(next) + }) + stopInput = () => { + input.removeEventListener('input', onInput) + stopValue() + } + }, + destroy() { + stopInput?.() + } +}) +``` + +The host owns mounting and removal. Release listeners, timers, observers, and +other retained resources in `destroy()`. + +The mounted surface is pack-owned and does not make host-page elements part of the published API. Keep all DOM work inside the supplied container so the code remains renderer-independent. + +Mount behavior: + +- `defaultValue` makes the mount value-holding; omit it for decoration; +- `serialize` defaults to `true` for a value-holding control and `false` for a + decorative mount; +- `sendToPrompt` defaults to `serialize` and can differ for “saved but not sent” + readouts; +- `hideOnZoom` defaults to `true`; +- `height` reserves an inner container height; +- `hidden` controls initial visibility. + +The `MountedValue` accessor offers `get()`, `set(value)`, and `onChange()`. +Object defaults are cloned per node so instances do not share mutable data. + +## A canvas surface owned by the widget + +`widgets.canvas()` lets a pack keep canvas drawing code without drawing into the +host's shared graph canvas: + +```js +let meterWidth = 1 + +const surface = node.widgets.canvas({ + name: 'meter', + height: 48, + defaultValue: 0, + serialize: false, + draw(context, [width, height], theme, value) { + meterWidth = width + const amount = Number(value?.get() ?? 0) + context.fillStyle = theme.surface + context.fillRect(0, 0, width, height) + context.fillStyle = theme.text + context.fillRect(0, 0, width * amount, height) + }, + onPointerDown({ x, event }) { + event.preventDefault() + surface.widget.setValue(Math.max(0, Math.min(1, x / meterWidth))) + } +}) + +surface.redraw() +``` + +The surface is a pack-owned DOM canvas positioned by either renderer. The host +handles backing-store scaling and supplies design-system colors on every draw. + +Pointer coordinates are in the same CSS-pixel coordinate system as `draw()`. +The underlying `PointerEvent` carries buttons and modifiers. A context-menu +handler claims secondary-click behavior; without one, the node context menu +continues to work. + +`CanvasHandle.widget` exposes the ordinary widget handle. Call `redraw()` when +external data used by `draw()` changes. + +## Interacting with a host text editor + +Do not reach for `widget.inputEl`. Subscribe to `textInteraction`: + +```js +widget.on('textInteraction', (event) => { + if (event.kind === 'keydown' && event.key === 'Enter' && event.ctrlKey) { + event.preventDefault() + runText(event.value) + } + + if (event.kind === 'input') { + updateCompletions(event.value, event.selection, event.menuEvent) + } +}) +``` + +All variants expose the current string, selection, a `menuEvent` for positioning +a host menu, `setValue()` with optional restored selection, and `focus()`. +Keyboard and wheel variants expose their relevant modifiers and cancellation +methods. + +## Defining a widget type + +Use `defs.defineWidgetType()` when a backend input type needs a renderer on +every node: + +```js +const unregister = comfy.defs.defineWidgetType('MY_PACK_RATING', { + defaultValue: 0, + minWidth: 100, + serialize: true, + render(container, value, name, context) { + const control = buildRatingControl(container, name, value.get()) + control.onChange((rating) => value.set(rating)) + + const stopValue = value.onChange((rating) => control.set(rating)) + const stopReady = context.onNodeReady((node) => + installNodeSpecificRatingBehavior(node, control) + ) + + return () => { + stopReady() + stopValue() + control.destroy() + } + } +}) +``` + +This is different from `widgets.mount()`: + +- `defineWidgetType()` declares presentation for an input type before its nodes + join a graph; +- `mount()` adds one widget to one live node; +- a type-level renderer gets a `WidgetTypeContext`, and obtains a node later + through `onNodeReady`; +- registered input types remain widgets rather than silently becoming sockets, + which affects both workflow and prompt serialization. diff --git a/custom-nodes/v2/reference-overview.mdx b/custom-nodes/v2/reference-overview.mdx new file mode 100644 index 000000000..b15efb678 --- /dev/null +++ b/custom-nodes/v2/reference-overview.mdx @@ -0,0 +1,65 @@ +--- +title: "Reference overview" +description: "Quickly find V2 frontend imports, pack layout, common patterns, API rules, and generated JavaScript signatures." +--- + +Use this section when you already understand the V2 model and need an exact import, signature, rule, or API surface. + +## Imports + +```javascript +import { comfy } from '/comfy/api/v2.js' +``` + +`comfy.version` reports the contract as `major.minor`. Keep a released pack on a supported major with `comfy.forMajor()`. + +## Pack layout + +```text +my_pack/ +├── +└── v2/ + └── +``` + +The top level remains the V1 pack root. `v2/` is the V2 pack root, not a patch directory. V2 does not merge with or fall back to V1. + +## Minimal JavaScript pattern + +```javascript +import { comfy } from '/comfy/api/v2.js' + +comfy.require('defs.extend') + +comfy.defs.extend('Example_Node', (definition) => { + definition.onCreated((node) => { + node.addBadge(() => ({ text: 'V2' })) + }) +}) +``` + +## Core rules + +| Rule | Meaning | +| --- | --- | +| Treat returned data as snapshots | Ask the API again when current frontend state matters. | +| Check `isDeleted` before reuse | A handle can outlive the node, widget, or document session it describes. | +| Mutate through handles | Use documented methods so validation, events, undo, and serialization remain consistent. | +| Probe optional capabilities | Use `supports()` for enhancements and `require()` for hard dependencies. | +| Avoid private monkeypatches | Private server or web-page internals are not part of the versioned API contract. | + + +## JavaScript reference + +- [Core API and capabilities](/custom-nodes/v2/reference/javascript-core) +- [Definitions and lifecycle](/custom-nodes/v2/reference/javascript-definitions) +- [Documents and graphs](/custom-nodes/v2/reference/javascript-documents-graphs) +- [Execution and queueing](/custom-nodes/v2/reference/javascript-execution) +- [Settings and storage](/custom-nodes/v2/reference/javascript-settings-storage) +- [Slots and links](/custom-nodes/v2/reference/javascript-slots) +- [UI and widgets](/custom-nodes/v2/reference/javascript-ui-widgets) +- [Workflow data and serialization](/custom-nodes/v2/reference/javascript-workflow) + +## When something is missing + +Report the user-visible behavior, affected JavaScript surface, current workaround, and why existing APIs are insufficient. New capabilities and extension points can be added for legitimate author needs. Keep any temporary private integration optional and local-only. diff --git a/custom-nodes/v2/reference/javascript-core.mdx b/custom-nodes/v2/reference/javascript-core.mdx new file mode 100644 index 000000000..6c7f61949 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-core.mdx @@ -0,0 +1,445 @@ +--- +title: "JavaScript core API" +description: "The root Comfy object, capabilities, backend calls, commands, chrome contributions, and handle rules." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 13 exported declarations: `BackendHandle`, `BadgeContribution`, `ChromeItemHandle`, `ButtonContribution`, `PropSpec`, `HandleSpec`, `HandleCommon`, `HandleToken`, `Comfy`, `KeyCombo`, `CommandDef`, `NotifyDef`, `CommandsHandle`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── backendHandle.ts ──────────────────────────────────────────── + +export interface BackendHandle { + /** + * Absolute URL for a backend route, honouring however the host is served — + * a base path, a different port, a proxy. + */ + url(route: string): string + /** + * Absolute URL for a file the host serves, rather than an API route. + * + * Distinct from `url()` because that one addresses the API and prepends + * `/api`, so a static path built through it produced `/api/extensions/…`, + * which 404s. + * + * This is for a path the caller already knows absolutely. It is *not* the + * way a pack should reach its own neighbouring files: the host serves those + * from `/extensions//`, and that directory name is chosen when + * the pack is installed and can be renamed, so it is not knowable from + * source. `new URL('x.css', import.meta.url)` resolves against the module's + * real location and stays correct. One pack ships two spellings of its own + * directory with an `onerror` fallback between them, which is what guessing + * costs. + */ + assetUrl(route: string): string + /** + * Identifies this frontend connection to a pack's own backend route. + * Undefined until the backend establishes the connection; do not persist it. + */ + sessionId(): string | undefined + /** + * Fires when {@link sessionId} becomes a different value. + * + * A pack that keys ephemeral server-side work by session — a scratch + * directory, a warmed model, a subscription — needs to know its old key is + * dead. The id changes on the first connection and again whenever the socket + * reconnects under a new identity, and the work filed under the previous one + * is no longer addressable. + * + * The session is not the user, the workflow or the node. It does not survive + * a reload, and storing it in any of those is how a pack ends up reading + * another tab's scratch state. + */ + onSessionChanged( + listener: (sessionId: string | undefined) => void + ): Unsubscribe + /** + * Subscribes to a backend message. The name is whatever the backend emits; + * `detail` is its payload, unparsed. + */ + on(event: string, listener: (detail: unknown) => void): Unsubscribe + /** + * Calls a backend route with the host's own credentials attached. + * + * `url()` only builds a string, so a pack calling `fetch()` on it sends an + * unauthenticated request — fine for a public route, a 401 when host authentication is required. + * Packs ship their own Python routes and were reaching for `api.fetchApi` + * precisely to inherit the session; this is that, and nothing more. + * + * The route is API-relative and must start with `/`, as `url()` requires. + */ + fetch(route: string, init?: RequestInit): Promise +} + +// ─── chromeContributions.ts ────────────────────────────────────── + +export interface BadgeContribution { + /** Namespaced, e.g. `Crystools.monitor`. Registering the same id twice throws. */ + readonly id: string + readonly text: string + readonly label?: string + readonly variant?: 'info' | 'warning' | 'error' + /** An iconify or PrimeIcons class, e.g. `pi-chart-bar`. */ + readonly icon?: string + readonly tooltip?: string +} + +/** What a pack keeps after contributing something to the chrome. */ +export interface ChromeItemHandle { + /** Changes what is shown. Only the fields given are replaced. */ + update(changes: Partial>): void + remove(): void +} + +export interface ButtonContribution { + readonly id: string + readonly icon: string + readonly label?: string + readonly tooltip?: string + /** + * The click. The event is passed because packs branch on modifiers — one + * opens its panel in a sized window on shift-click — and without it that + * behaviour has nothing to read. + */ + run(event: MouseEvent): void +} + +// ─── closedProxy.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PropSpec { + get(target: TTarget): unknown + set?(target: TTarget, value: unknown): void + /** Appended to the error when a pack assigns to a read-only property. */ + readonlyHint?: string +} + +export interface HandleSpec { + /** Used in errors and `Symbol.toStringTag`, e.g. 'node'. */ + readonly kind: string + readonly props: Readonly>> + readonly methods?: Readonly< + Record unknown> + > + /** + * Methods that also need the handle's own id. + * + * A widget target is just the widget: it holds no reference back to its + * node, by design, so a method that has to name a sibling cannot find one + * from the target alone. Separate from `methods` so the common signature + * stays two arguments. + */ + readonly idMethods?: Readonly< + Record unknown> + > + /** + * Props that remain readable after deletion. Identity only — an id or type is + * still useful for logging and cleanup once the entity is gone. + */ + readonly identityProps?: readonly string[] +} + +/** Present on every handle. Never throws, even when the entity is gone. */ +export interface HandleCommon { + readonly isDeleted: boolean +} + +export interface HandleToken { + readonly kind: string + readonly id: string +} + +// ─── comfyApi.ts ───────────────────────────────────────────────── + +export interface Comfy { + /** + * `major.minor`. Prefer `supports()` over comparing this — a capability + * survives being backported or reordered across minors; a version comparison + * does not. + */ + readonly version: string + /** Breaking-change generation. Incremented only when something is removed. */ + readonly major: number + /** + * Cheap, never throws. The supported way to branch. + * + * Answers whether this host can do something, under the grant it is running + * with. It is not a permission request: asking does not obtain authority, and + * a pack never enumerates capabilities to be allowed to run. + */ + supports(capability: string): boolean + /** Asserts a capability, with an actionable error naming it. */ + require(capability: string): void + /** Every capability this host provides. */ + capabilities(): readonly string[] + /** + * Pins to a specific major. + * + * A major stays available until it is announced for removal and withdrawn + * through the normal phased deprecation process, so a pack written against + * one keeps working across that period rather than breaking on a release. + */ + forMajor(major: number): Comfy + + /** + * True when two handles refer to the same entity, whatever major, API + * instance or graph scope produced them. + * + * `===` is only reliable for handles from the same instance, the same major + * AND the same scope. Scope is the one most likely to catch a pack out: a + * node reached through `comfy.graph` while it is on screen and the same node + * reached through `graph.subgraphs()` or through a document-scoped + * `onNodeChanged` come from different handle caches, so they are equal here + * and not equal under `===`. Use this whenever a handle may have come from + * another pack, from an event, or from a graph other than the visible one. + */ + sameEntity(a: unknown, b: unknown): boolean + + /** + * Re-resolves a handle from any major or instance into one of this instance's + * own. Returns `undefined` if it is not a handle, or its entity is gone. + */ + adopt(handle: unknown): NodeHandle | undefined + + readonly graph: GraphHandle + /** Node definitions, and the replacement for `beforeRegisterNodeDef`. */ + readonly defs: DefRegistry + /** Declaring, reading and writing pack settings. */ + readonly settings: SettingsHandle + /** + * Per-user persistent storage for documents the pack's users author — + * templates, presets, saved prompts. Server-side, so it follows the user + * between machines. + */ + readonly storage: StorageHandle + /** Bounded, host-sampled hardware metrics. */ + readonly system: SystemHandle + /** The sanctioned slice of app chrome — sidebar tabs. */ + readonly ui: UiHandle + /** Host-owned facilities shared by widget implementations. */ + readonly widgets: WidgetsHandle + /** Bounded declarative locale catalogs rendered by host-native i18n. */ + readonly localization: LocalizationHandle + /** Commands, their keybindings, and notifications. */ + readonly commands: CommandsHandle + /** Backend URLs and messages, including a pack's own events. */ + readonly backend: BackendHandle + /** Loading a parsed workflow into a new active document. */ + readonly workflow: WorkflowHandle + /** Explicit, bounded host file selection and download. */ + readonly files: FilesHandle + /** Fixed host cryptographic primitives available to pack UI workers. */ + readonly crypto: CryptoHandle + /** Bounded vendor-specific facilities. */ + readonly integrations: IntegrationsHandle + /** + * The editor is already mid-gesture — dragging a link, resizing a node, + * dragging a widget. A pack running its own pointer gesture must stand down + * while this is true. + */ + isInteracting(): boolean + /** + * Observes nodes being moved, under either renderer. + * + * For building an editing gesture — swap, insert-on-link, shake-to-detach. + * A pack that moves nodes itself will see its own writes, so guard re-entry. + */ + onNodeMoved(listener: (event: NodeMoveEvent) => void): Unsubscribe + /** + * A drag finished; every node it moved. + * + * Where an editing gesture commits — swap the pair, insert into the link + * under the cursor. **Nodes 2.0 only**: the legacy canvas renderer publishes + * no drag lifecycle, so this never fires under it. + */ + onNodeDragEnd(listener: (nodes: readonly NodeHandle[]) => void): Unsubscribe + /** + * The view panned, zoomed or was resized. + * + * For keeping something anchored to a node in sync — ask + * `node.getScreenRect()` again when this fires. Carries no payload: where a + * node is belongs to the node, and the transform belongs to the renderer. + */ + onViewportChanged(listener: () => void): Unsubscribe + /** + * A node changed — its mode, title, colour or shape. + * + * For observing nodes the pack does not own. rgthree's relay polls every + * 500ms and installs a `defineProperty` trap on `mode` because nothing + * reports it; this is that signal. + * + * One stream rather than a subscription per node, deliberately: node + * identity does not survive undo, reload or re-entering a subgraph, so + * anything keyed by the object stops firing silently, and keying by id + * instead never gets collected. Filter by `event.node.id`. + * + * Only fields the host tracks are reported. Position is not among them — it + * changes per frame during a drag and is served by {@link onNodeMoved}. + * + * Reports the graph on screen unless `scope: 'document'` asks for the root + * graph and every subgraph definition as well. A pack that computes from + * other nodes wants `'document'`: a relay in a subgraph the user has + * navigated away from otherwise stops recomputing while still asserting its + * last answer. Each event names the graph it came from, and resolves its node + * there — ids repeat across definitions, so `event.node.id` alone is not a + * key. + */ + onNodeChanged( + listener: (event: NodeChangeEvent) => void, + options?: NodeChangeOptions + ): Unsubscribe + /** + * The application has finished starting: canvas, settings and graph all + * exist, and node definitions are registered. + * + * This is `registerExtension({ setup })`. A pack's module body is the `init` + * half — it runs before definitions register — so anything that needs the + * running app belongs here. Registering after the app has already started is + * fine; the listener is called on the next microtask rather than dropped, + * which is what makes this safe for a pack loaded lazily. + * + * Do not poll for the DOM instead. Several packs shipped a `waitForElements` + * loop to paper over the missing hook, and a poll that outlives its target + * is a leak that only shows up on someone else's machine. + */ + onReady(listener: () => void): Unsubscribe + /** Starting a run, and knowing when one starts. */ + queue: QueueHandle + /** + * The node the backend is executing, or `undefined` between runs. + * + * Packs tracked this from the raw `executing` message to badge the running + * node or follow it with the view. + */ + executingNode(): NodeHandle | undefined + /** Resolves a backend execution id, including a nested subgraph path. */ + executionNode(id: string): NodeHandle | undefined + /** Fires when {@link executingNode} changes, including to nothing. */ + onExecutingNodeChanged( + listener: (node: NodeHandle | undefined) => void + ): Unsubscribe + /** + * A workflow finished loading, and the graph is the new one. + * + * This is `afterConfigureGraph`. Unlike {@link onReady} it fires again for + * every workflow the user opens, which is what a pack re-attaching itself to + * the document needs — `onReady` fires once and misses every later open. + * + * It also fires for undo, redo and a reload of the same document, because a + * pack rebuilding state from the graph needs those too. The handle says + * which of them happened: an id equal to the one from last time means this + * document was rebuilt, not replaced. `undefined` when the host cannot name + * a document, as when raw workflow data is loaded with no file behind it. + */ + onWorkflowLoaded( + listener: (document: DocumentHandle | undefined) => void + ): Unsubscribe + /** + * A document's editing session began. + * + * Where per-document state belongs. Fires for a tab opened in the + * background too, so a pack that allocates here and releases in + * {@link onDocumentClosed} stays balanced however the user moves around. + */ + onDocumentOpened(listener: (document: DocumentHandle) => void): Unsubscribe + /** + * A document became the one on screen. + * + * Distinct from opening: the user returning to a tab activates a document + * that was already open, and its state is still valid. Anything tied to + * *being visible* — a panel, a canvas overlay — belongs here. + */ + onDocumentActivated(listener: (document: DocumentHandle) => void): Unsubscribe + /** + * A document stopped being the one on screen, but is still open. + * + * Fires before the next document is activated, so a pack moving something + * between them never sees two claiming the screen at once. + */ + onDocumentDeactivated( + listener: (document: DocumentHandle) => void + ): Unsubscribe + /** + * A document's editing session ended, however it ended — the user closing + * the tab, a temporary workflow being deleted, or the host discarding a + * background tab whose file changed on disk. + * + * Release everything keyed to it. The handle already reports `isDeleted`, + * and carries the id so a pack can find what it stored; it will not describe + * the document, because there is no longer one to describe. + */ + onDocumentClosed(listener: (document: DocumentHandle) => void): Unsubscribe +} + +// ─── commandsHandle.ts ─────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface KeyCombo { + readonly key: string + readonly ctrl?: boolean + readonly alt?: boolean + readonly shift?: boolean + readonly meta?: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CommandDef { + /** Namespaced, e.g. `MyPack.doTheThing`. Shared with core and every pack. */ + readonly id: string + /** + * A function when the label depends on state — a toggle that reads "Follow + * execution" and then "Stop following execution". It is read each time the + * label is shown, so it must return quickly. + */ + readonly label: string | (() => string) + readonly run: () => void | Promise + /** Bound as a default, so a user's own binding still wins. */ + readonly keybinding?: KeyCombo + /** + * Where the keybinding applies. Defaults to anywhere in the application. + * + * `'canvas'` limits it to the graph, so it will not fire while the user is + * typing in a node's text widget or any other field. The host already + * withholds combos a text input owns — every bare arrow, Ctrl+Left/Right, + * Ctrl+A/C/V/X/Z — but a pack binding something it does not, say Ctrl+Up, + * would otherwise fire mid-sentence. + */ + readonly scope?: 'canvas' +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NotifyDef { + readonly severity?: 'success' | 'info' | 'warn' | 'error' + readonly summary: string + readonly detail?: string + /** Milliseconds. Omit for the host's default. */ + readonly life?: number +} + +export interface CommandsHandle { + register(def: CommandDef): void + notify(def: NotifyDef): void + /** + * Runs a command the host or another pack registered, by id. + * + * Packs reached into internals to do what a command already does — opening + * the mask editor was `ComfyApp.copyToClipspace` plus `clipspace_return_node` + * plus invoking `Comfy.MaskEditor.OpenMaskEditor` by hand. Commands are the + * sanctioned action layer, so a pack can ask for the behaviour without the + * host having to publish the machinery behind it. + * + * Rejects if no such command is registered — a pack naming a command that + * has been renamed should hear about it rather than silently do nothing. + */ + run(id: string): Promise + /** Whether a command exists, for a pack that offers an entry conditionally. */ + has(id: string): boolean +} +```` diff --git a/custom-nodes/v2/reference/javascript-definitions.mdx b/custom-nodes/v2/reference/javascript-definitions.mdx new file mode 100644 index 000000000..f9f9e4b8b --- /dev/null +++ b/custom-nodes/v2/reference/javascript-definitions.mdx @@ -0,0 +1,602 @@ +--- +title: "JavaScript definitions API" +description: "Node definitions, definition builders, lifecycle hooks, and frontend node declarations." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 17 exported declarations: `NodeDef`, `ExecutionResult`, `PreviewFrame`, `ConnectionChangeEvent`, `PromptInputProjection`, `PromptInputProjector`, `NodeDefBuilder`, `NodeCreatedEvent`, `UnplacedLinkEvent`, `BeforeConnectEvent`, `NodeSubMenuItem`, `NodeColor`, `NodeMenuItem`, `DefSelector`, `NodeDefinition`, `DefRegistry`, `PropertyChangeEvent`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── defsRegistry.ts ───────────────────────────────────────────── + +/** + * The read view of a node definition. Frozen and inert, like every read here. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeDef { + readonly type: string + readonly title: string + readonly category: string + readonly description: string + readonly inputs: readonly Readonly<{ + name: string + type: string + /** The translated caption core renders for this input, when it differs. */ + localizedName?: string + /** The declared choices for a COMBO input, in backend order. */ + values?: readonly (string | number)[] + /** + * The input's declaration dict, verbatim from the backend. + * + * Same passthrough reasoning as `ExecutionResult.raw`: a pack declares its + * own keys on its own Python input spec and reads them back here to drive + * frontend behaviour, so discarding unrecognised keys breaks the pack + * against its own data. Carries `default`, `min`, `max` and the like too. + */ + options: Readonly> + }>[] + readonly outputs: readonly Readonly<{ + name: string + type: string + tooltip?: string + }>[] + readonly isOutputNode: boolean + /** + * The node's `hidden` input declarations, verbatim. + * + * Deliberately not merged into {@link inputs}: a hidden input is not a slot, + * and listing it as one would put a connectable input on the node for + * something the server fills in. + * + * Packs ship their own data here and read it back — easy-use and + * tinyterraNodes both carry an XY-plot axis catalogue as + * `input.hidden.plot_dict[0]`, on their own key, from their own Python spec. + * That is the same passthrough reasoning `inputs[].options` already rests on, + * and dropping it broke both packs against their own data. + * + * These are declarations, not values. `PROMPT`, `UNIQUE_ID` and + * `EXTRA_PNGINFO` appear here as the type markers the node asked for; the + * server substitutes the real thing at execution time and it never passes + * through here. + */ + readonly hidden: Readonly> + /** Which pack supplied it, when the backend reports one. */ + readonly source: string | undefined +} + +/** + * Node output as it arrives from the backend. + * + * `raw` carries everything else verbatim — ADR 0007's passthrough schema + * guarantees custom output keys survive, so a pack reading a bespoke key keeps + * working. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface ExecutionResult { + readonly images: readonly Readonly>[] + readonly text: readonly string[] + readonly raw: Readonly> +} + +/** + * A preview frame the backend produced while this node was running. + * + * Per node rather than per channel, deliberately. Packs currently subscribe to + * `b_preview_with_metadata` *and* `b_preview`, track the executing node id in a + * module global to correlate the second one, and probe + * `serverSupportsFeature('supports_preview_metadata')` to decide which to + * trust — all to answer "is this frame mine?". Answering it once here removes + * the global, and with it the mis-attribution when two nodes preview at once. + */ +export interface PreviewFrame { + readonly blob: Blob + /** Object URL for the blob, revoked when the next frame arrives. */ + readonly url: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface ConnectionChangeEvent { + readonly side: 'input' | 'output' + readonly index: number + readonly connected: boolean + /** + * The node at the other end, or `undefined` on a disconnect. + * + * Packs read `link_info.origin_id` to decide what the new neighbour means — + * retype a slot to match it, adopt its label. Knowing only that *something* + * connected forced a re-walk of the whole graph to find out what. + */ + readonly peerNodeId?: string + /** The slot index at the other end, or `undefined` on a disconnect. */ + readonly peerIndex?: number +} + +/** + * The only change a node extension may make to one queued API prompt. + * + * Inputs are named from that node type's own backend declaration. The saved + * workflow is untouched; the prompt builder removes these names only from the + * executable payload it is assembling now. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface PromptInputProjection { + readonly omitInputs: readonly string[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type PromptInputProjector = ( + node: NodeHandle +) => PromptInputProjection | Promise + +export interface NodeDefBuilder { + /** Current state of the definition, after any earlier extensions ran. */ + readonly def: NodeDef + + setTitle(title: string): void + setCategory(category: string): void + /** + * Declares that this node type never reaches the backend. + * + * `defs.define` takes `execution: 'frontend'` for a type the pack owns, but + * packs also mark *backend-registered* types frontend-only — a tools or + * control node that exists to drive other nodes and must not appear in the + * prompt. Without this they reach for `node.isVirtualNode`, and dropping that + * line puts a new node into `graphToPrompt`, which is a wire-format break. + * + * Supply `resolve` when the node carries a value through to something else; + * omit it and the node is simply left out. See `resolution.ts` — `resolve` is + * pure over a read-only view and must not mutate the graph. + */ + setExecution(execution: 'backend' | 'frontend', resolve?: Resolver): void + /** + * Declares what this node feeds into *other* nodes' unconnected inputs. + * + * The counterpart of `setExecution`'s `resolve`, which answers only "what + * feeds my own outputs" and is never called for a node with none. Broadcast + * packs are the reverse: they name inputs on nodes that are not themselves, + * and discover those edges rather than declaring them. + * + * Available here and not only on `defs.define` because the types that + * broadcast are registered by the pack's Python, and `defs.define` refuses a + * type that already exists — which left `supply` unreachable for every pack + * that actually needed it. + * + * Not gated on `setExecution('frontend')`: feeding somebody else and being + * skipped by the prompt builder are separate questions, and a node may + * legitimately both execute and broadcast. + */ + setSupply(supply: Supplier): void + addWidget(def: WidgetDef): void + hideWidget(name: string): void + + // Behaviour hooks, ordered by measured usage across the 1,265 packs. + /** + * Fires once the node exists *and is addressable* — after it joins a graph. + * + * Deliberately not litegraph's `onNodeCreated`, which runs inside + * `createNode()` before the node has an id, a graph, or store registration. + * A handle is id-backed, so at that moment there is nothing to hand back, and + * widget writes would land on an unregistered node and be lost on insert. + */ + onCreated(callback: (node: NodeHandle, event: NodeCreatedEvent) => void): void // 943 packs + onExecuted( + callback: (node: NodeHandle, result: ExecutionResult) => void + ): void // 497 packs + onConfigured( + callback: (node: NodeHandle, data: Record) => void + ): void // 429 packs + onConnectionsChanged( + callback: (node: NodeHandle, event: ConnectionChangeEvent) => void + ): void // 223 packs + onRemoved(callback: (node: NodeHandle) => void): void // 158 packs + /** + * The node was resized, by the user or by a layout pass. + * + * Packs hung a `ResizeObserver` on their mounted element to notice this, + * which fires for the element rather than the node and misses a resize that + * does not change the element. + */ + onResized(callback: (node: NodeHandle, size: Size) => void): void + /** + * The pointer entered or left the node. + * + * Packs read `canvas.node_over` or set `node.mouseOver` to rebuild a list + * the moment the pointer arrives, or to decide which node a tooltip belongs + * to. Both are canvas internals, and the canvas is what Nodes 2.0 replaces. + */ + onHover(callback: (node: NodeHandle, hovering: boolean) => void): void + /** + * The node was double-clicked. + * + * Deliberately carries no coordinates. Hit-testing a pointer against + * node-local geometry is a pack drawing its own front end; the published + * answer is `widgets.mount` and ordinary DOM events on the element you own. + */ + onDoubleClick(callback: (node: NodeHandle) => void): void + /** + * Whether this node can accept the current browser drag. + * + * The event is the browser's data-transfer surface, not a renderer object. + * Returning `true` makes both node renderers present and route the drop. + */ + onDragOver( + callback: (node: NodeHandle, event: DragEvent) => boolean | void + ): void + /** Handles a drop the node accepted. Returning `true` claims it. */ + onDrop( + callback: ( + node: NodeHandle, + event: DragEvent + ) => boolean | void | Promise + ): void + /** + * A property the user edited in the node's properties panel. + * + * Packs used `onPropertyChanged` to keep a hand-entered value sane — rgthree + * clamps a seed's `randomMax` as it is typed. litegraph's own callback can + * only veto, reverting to the previous value, which throws the user's input + * away rather than correcting it. `setValue` replaces it instead, and writes + * without going back through `setProperty`, so a clamp cannot recurse. + */ + onPropertyChanged( + callback: (node: NodeHandle, event: PropertyChangeEvent) => void + ): void + /** Preview frames for this node, already correlated. */ + onPreview(callback: (node: NodeHandle, frame: PreviewFrame) => void): void + /** + * Contributes the pack's own state to the saved node. + * + * The returned object is merged into the serialized node, and comes back + * through `onConfigured`. Only keys the pack owns: core fields are not + * writable from here, because a pack must not be able to change what the + * workflow means. + */ + onSerialize(callback: (node: NodeHandle) => Record): void + /** + * Omits declared inputs from this node in the API prompt being built. + * + * This is not a prompt rewrite: the callback receives no prompt or input + * values, may not name another node, and cannot inject replacements. It is + * awaited on the prompt path so an extension answers from its + * current read-only node snapshot rather than a stale cached value. + */ + onPromptSerialize(callback: PromptInputProjector): void + /** + * Vetoes or permits an incoming connection *before* it is wired. + * + * Distinct from `onConnectionsChanged`, which fires after the fact — packs + * use the pre-hook to refuse an incompatible link or relabel a slot while + * the type is still known. Returning `false` refuses. + */ + onBeforeConnect( + callback: (node: NodeHandle, event: BeforeConnectEvent) => boolean | void + ): void + /** + * The user dropped a link on a node's body and the host found no single slot + * that fits. Wire it yourself and return `true`; return nothing to let the + * host report the drop unplaceable. + * + * For a node whose one slot carries a bundle of values — a context, a pipe — + * and which wants to unpack it into several of the peer's slots at once. Both + * ends of the drag are asked, the one the user aimed at first, because the + * node with the knowledge is the drop target in one direction and the drag's + * origin in the other. + * + * The published alternative to replacing `connectByType` on the prototype, + * which is how packs did this: that changes link routing for every node in + * the document, so one pack's convenience became every other pack's + * behaviour. + */ + onUnplacedLink( + callback: (node: NodeHandle, event: UnplacedLinkEvent) => boolean | void + ): void + /** Adds an entry to this node type's context menu. */ + addMenuItem(item: NodeMenuItem): void +} + +export interface NodeCreatedEvent { + /** + * The node arrived carrying saved state — pasted, duplicated, or loaded from + * a workflow — rather than being made fresh. + * + * Read as "was `configure` called on it before it joined the graph", which is + * what actually distinguishes the cases. Packs overrode `clone()` to reset + * state a copy should not inherit — a duplicated node keeping the dynamic + * slots that were fed by the original's upstream, a duplicated reroute born + * hard-typed and refusing every other type — and `clone()` runs before the + * node has an id, so there is nothing to hand a pack there. + */ + readonly restored: boolean + /** + * The whole graph was being loaded, so {@link restored} means "came from the + * saved file" rather than "came from the clipboard". + * + * The distinction is the point: a pasted node should drop slots it cannot + * still be fed through, and a loaded one must keep every one of them or the + * workflow opens wrong. + */ + readonly loading: boolean +} + +export interface UnplacedLinkEvent { + /** Which of this node's slots the link would land on. */ + readonly side: 'input' | 'output' + /** The node at the other end of the drag. */ + readonly peerNodeId: string + /** The slot on the peer the drag started from. */ + readonly peerIndex: number + readonly type: string + /** + * The user held the modifier that means "overwrite what is already wired". + * + * Published because packs read a global keyboard service of their own to get + * it, and which modifier means this is the host's to decide. + */ + readonly replaceExisting: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface BeforeConnectEvent { + readonly side: 'input' | 'output' + readonly index: number + /** The node at the other end, when one is known. */ + readonly peerNodeId: string | undefined + /** The slot at the other end, when one is known. */ + readonly peerIndex: number | undefined + readonly peerType: string | undefined +} + +/** One entry inside a menu item's submenu. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeSubMenuItem { + readonly label: string + run(node: NodeHandle): void +} + +/** + * One entry of ComfyUI's node palette: the title bar, the body, and the shade + * a group of that colour is filled with. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeColor { + readonly color: string + readonly bgColor: string + readonly groupColor: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeMenuItem { + /** + * A function when the text depends on the node — packs label entries with + * the current state ("Unmute 3 nodes"), which a string fixed at + * registration cannot express. + */ + readonly label: string | ((node: NodeHandle) => string) + /** + * Shown only when this returns true. Without it a pack that wants an entry + * to appear conditionally has to either show it always or not at all — + * efficiency-nodes hides its seed submenu when the feature is off, and + * flattening that to a permanent entry is a worse lie than omitting it. + */ + when?(node: NodeHandle): boolean + /** Omit when the item only opens a submenu. */ + run?(node: NodeHandle): void + /** + * Turns the entry into a submenu. One level deep, deliberately: every + * measured pack uses exactly one, and nesting further is a menu design + * problem rather than an API one. + * + * A function when the children depend on the node's current state, which is + * the common case rather than the exotic one: efficiency-nodes' LoRA Stacker + * declares fifty `lora_name_N` widgets and lists only the two or three a + * user has filled. A fixed array would put fifty rows in that menu, which is + * a different menu, so the alternative to this was omitting the feature. + */ + readonly items?: + | readonly NodeSubMenuItem[] + | ((node: NodeHandle) => readonly NodeSubMenuItem[]) + /** + * Sort position among this node's pack-added entries. Lower first; entries + * without one keep registration order, which is module-load order and so + * depends on import sequence rather than intent. + */ + readonly order?: number +} + +/** + * Which definitions an extension applies to. + * + * Indexed rather than run-and-return: this predicate is almost always the guard + * clause the pack already had at the top of its hook. + */ +export type DefSelector = + | string + | readonly string[] + | RegExp + /** + * A predicate over the definition, for a guard the other forms cannot + * express — "any node taking a VAE input", which is a shape rather than a + * name. + * + * Deliberately last, and deliberately discouraged. The declarative forms + * exist because a name check can be indexed, while a predicate has to run for + * every registered type; with thousands of types that is the boot cost this + * API set out to remove. Use it only when the guard genuinely reads a def's + * inputs or outputs. + */ + | ((def: NodeDef) => boolean) + /** + * A `RegExp` category covers the prefix filter 53 packs open their hook with + * (`nodeData.category.startsWith('KJNodes')` → `{ category: /^KJNodes/ }`). + */ + | { readonly category: string | RegExp } + +/** + * A node type the pack owns, declared rather than subclassed. + * + * 86 packs (18.2% of installs) do this today with `extends LGraphNode` + + * `LiteGraph.registerNodeType`, which is OOP entity modelling — the thing ADR + * 0008 rules out. Here the definition is plain data; the class behind it is an + * internal detail of this layer, never the pack's. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeDefinition { + readonly type: string + readonly title?: string + readonly category?: string + readonly description?: string + readonly inputs?: readonly { name: string; type: string }[] + readonly outputs?: readonly { + name: string + type: string + shape?: SlotShape + }[] + readonly widgets?: readonly WidgetDef[] + /** + * `'frontend'` nodes never reach the backend: they are resolved away at + * prompt time by the resolution system, or simply omitted. + */ + readonly execution?: 'backend' | 'frontend' + /** + * Answers what each output resolves to, purely, over a read-only view. + * See `resolution.ts` — this replaces `applyToGraph`, which mutated the + * live graph mid-serialize. + */ + readonly resolve?: Resolver + /** + * What this node feeds into *other* nodes' unconnected inputs. + * + * The broadcast direction: `resolve` cannot express it, because the nodes + * being fed are not this one and the edges are discovered rather than + * declared. + */ + readonly supply?: Supplier + + onCreated?(node: NodeHandle, event: NodeCreatedEvent): void + onExecuted?(node: NodeHandle, result: ExecutionResult): void + onConfigured?(node: NodeHandle, data: Record): void + onConnectionsChanged?(node: NodeHandle, event: ConnectionChangeEvent): void + onPropertyChanged?(node: NodeHandle, event: PropertyChangeEvent): void + onDragOver?(node: NodeHandle, event: DragEvent): boolean | void + onDrop?( + node: NodeHandle, + event: DragEvent + ): boolean | void | Promise + onRemoved?(node: NodeHandle): void + onSerialize?(node: NodeHandle): Record + onPromptSerialize?: PromptInputProjector +} + +export interface DefRegistry { + /** + * Declares how an input *type* is presented — the replacement for + * `getCustomWidgets`. + * + * Not decoration: the host decides widget-vs-socket purely by whether a type + * is registered, so an unregistered one turns the input into a socket and + * drops its value from `widgets_values`. See `widgetTypes.ts`. + */ + defineWidgetType(type: string, def: WidgetTypeDef): Unsubscribe + /** + * Registers a node type the pack owns. Returns a handle that unregisters + * it — which `LiteGraph.registerNodeType` never offered. + */ + define(definition: NodeDefinition): Unsubscribe + get(type: string): NodeDef | undefined + all(): readonly NodeDef[] + has(type: string): boolean + extend( + selector: DefSelector, + apply: (builder: NodeDefBuilder) => void + ): Unsubscribe + /** + * Asks the host to reload node definitions from the backend. + * + * Combo inputs whose values the backend supplies — model lists, LoRA names, + * sampler names — are captured when definitions load, so a pack that adds a + * file server-side leaves every open picker showing the old list. This is + * `app.refreshComboInNodes()`, which packs called after saving a model + * preview or writing a new file. + * + * Refreshing is not free: it refetches every definition. Call it after a + * change the user made, not on a timer. + */ + /** + * The colour links and slots of a type are drawn in. + * + * A pack matching the theme in its own DOM — a legend, a chip, a preview — + * read `LGraphCanvas.link_type_colors` for this. Reading a design token to + * match is the opposite of drawing your own front end, so it is published; + * the table itself is not. + */ + typeColor(type: string): string + /** + * The colours behind a name in ComfyUI's node palette — `red`, `pale_blue` — + * or `undefined` for a name it does not define. + * + * Same reasoning as {@link typeColor}, and the same limit: the resolver is + * published, the table is not. What makes this a design token rather than a + * renderer internal is that the names are the user's own vocabulary. They + * pick "green" from a menu; nothing records the word, only the hex it stood + * for. So a pack offering "mute every red group" cannot match what the user + * chose without being told which hex "red" meant, and two packs did it by + * reading `LGraphCanvas.node_colors` directly. + * + * Colours move with the palette, names do not. Resolve on use; do not cache + * the result and do not persist it in a workflow. + */ + nodeColor(name: string): NodeColor | undefined + /** + * Tests an output type against an input type using the host's connection + * rules, including wildcards and comma-delimited unions. + */ + isTypeCompatible(outputType: string, inputType: string): boolean + /** + * Declares the colour for a data type this pack introduces. + * + * Packs shipping their own types — `PIPE_LINE`, `LORA_STACK`, `XYPLOT` — + * wrote straight into `LGraphCanvas.link_type_colors` so their links were + * not all grey. + * + * Refuses a type the host already colours. That write is global: one pack + * recolouring `IMAGE` restyles every graph for every other pack and the + * user has no way to see who did it. Colouring a type you brought is + * additive; colouring one you did not is not yours to decide. + */ + setTypeColor(type: string, color: string): Unsubscribe + refresh(): Promise + /** + * Node definitions were reloaded — by this pack, another pack, or the user. + * + * The listening half of `refresh()`, and what the `refreshComboInNodes` + * extension hook gave packs. A pack holding its own cached copy of a combo's + * values — a model list it filters, a picker it built — needs to rebuild it + * when the list changes underneath, and the pack that caused the change is + * usually not this one. + */ + onRefreshed(listener: () => void): Unsubscribe +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PropertyChangeEvent { + readonly name: string + readonly value: unknown + readonly previous: unknown + /** Replaces what is stored. Last writer wins if several packs respond. */ + setValue(value: unknown): void + /** Discards the edit, restoring `previous`. */ + reject(): void +} +```` diff --git a/custom-nodes/v2/reference/javascript-documents-graphs.mdx b/custom-nodes/v2/reference/javascript-documents-graphs.mdx new file mode 100644 index 000000000..38d366880 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-documents-graphs.mdx @@ -0,0 +1,672 @@ +--- +title: "JavaScript documents and graphs API" +description: "Documents, graphs, groups, node handles, changes, geometry, and interactions." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 27 exported declarations: `DocumentHandle`, `DocumentSource`, `DocumentReader`, `DocumentPhase`, `NodeInit`, `NodeQueryScope`, `NodeQuery`, `GraphHandle`, `GraphScopeHandle`, `GroupHandle`, `NodeMoveEvent`, `NodeMoveSource`, `NodeDragEndSource`, `TrackedProperty`, `NodeChangeScope`, `NodeChangeOptions`, `NodeChangeEvent`, `NodeMode`, `NodeShape`, `BadgeDef`, `Point`, `Size`, `Bounds`, `NodeSnapshot`, `SizeConstraints`, `NodeHandle`, `NodeCollections`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── documentHandle.ts ─────────────────────────────────────────── + +export interface DocumentHandle extends HandleCommon { + /** + * Identity of this editing session. Stable for as long as the document is + * open — including across undo, redo and tab switches — and never reused. + * + * Not the id inside the workflow JSON, which travels with the file, so two + * opens of it and any copy made outside the app all share one value. Not the + * path either, which is a storage address and changes on rename. Do not + * persist this: it means nothing in the next page load. + */ + readonly id: string + /** Display name, without the directory or extension. */ + readonly name: string | undefined + /** + * Storage path, for addressing the file. Undefined for a document with no + * file behind it yet. Changes when the user renames, so key pack state on + * {@link id} instead. + */ + readonly path: string | undefined + /** Whether there are edits the user has not saved. */ + readonly isModified: boolean + /** + * True once this editing session has ended. + * + * A handle is a snapshot of a session, and a pack may hold one across a tab + * close or a background unload. Check before acting on stored state rather + * than trusting a captured handle, exactly as for a node or a widget. + */ + readonly isDeleted: boolean +} + +/** What the host must supply to describe one open document. */ +export interface DocumentSource { + readonly sessionId: string | null + readonly filename?: string + readonly path?: string + readonly isModified?: boolean + /** Whether this is the document the editor is showing. */ + readonly isActive?: boolean +} + +/** + * Every document currently open, including background tabs. + * + * One reader rather than one per question: a handle has to answer for a + * document that is open but not on screen, and a lookup that only knew the + * active one would report every background tab as closed. + */ +export type DocumentReader = () => readonly DocumentSource[] + +// ─── documentLifecycle.ts ──────────────────────────────────────── + +/** + * The transitions a document makes. + * + * `opened` and `closed` bracket the session's existence; `activated` and + * `deactivated` bracket its time on screen. A document opened in the + * background is `opened` without being `activated`, which is why they are + * separate: a pack that allocates on `opened` and releases on `closed` stays + * balanced no matter how the user moves between tabs. + + */ +export type DocumentPhase = 'opened' | 'activated' | 'deactivated' | 'closed' + +// ─── graphHandle.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeInit { + title?: string + position?: { x: number; y: number } +} + +/** + * How far {@link GraphHandle.queryNodes} looks. + * + * `'visible'` is the graph on screen and the default, matching `nodes()`. + * `'root-and-subgraphs'` is the root graph and every subgraph *definition* — + * the same set `onNodeChanged`'s `'document'` scope reports over. A subgraph + * placed three times contributes its nodes once, which is what a pack acting + * on "each of my nodes" means. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export type NodeQueryScope = 'visible' | 'root-and-subgraphs' + +/** + * Which nodes {@link GraphHandle.queryNodes} should return. + * + * Every field narrows; omitting all of them returns the whole scope. They + * compose as AND, because the cases packs actually hand-rolled — "my nodes, + * anywhere in the document", "everything in this group" — are intersections. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeQuery { + readonly scope?: NodeQueryScope + /** + * Node type. A string matches exactly, an array matches any of them, and a + * regular expression matches by pattern — which is how a pack asks for its + * own nodes without listing every type it ships. + */ + readonly type?: string | RegExp | readonly string[] + /** + * Restrict to nodes in the graph the user is looking at. + * + * Only meaningful under `'root-and-subgraphs'`: it is the difference between + * "every node in the document" and "the ones the user can currently see". + * This is *not* a viewport test — a node scrolled off the edge of a graph + * the user is in is still rendered by this definition. Culling belongs to + * the renderer and differs between the two of them. + */ + readonly rendered?: boolean + /** Restrict to nodes the group currently contains. */ + readonly group?: GroupHandle +} + +export interface GraphHandle { + readonly id: string + node(id: string): NodeHandle | undefined + nodes(): readonly NodeHandle[] + nodesOfType(type: string): readonly NodeHandle[] + /** + * One flat query over graph-scoped nodes. + * + * `nodes()` and `nodesOfType()` address the graph on screen, so a pack that + * wanted "every node of mine in this document" had to walk `root()` and each + * `subgraphs()` entry itself and concatenate the results — and the ones that + * did not simply stopped working the moment a user nested anything. + * + * Handles come from the scope that owns each node, so a node reached here + * under `'root-and-subgraphs'` is not `===` the one `graph.node()` returns + * for it. That is the same scope rule `sameEntity()` exists for; compare + * with `comfy.sameEntity()` rather than `===`. + */ + queryNodes(query?: NodeQuery): readonly NodeHandle[] + add(type: string, init?: NodeInit): NodeHandle + remove(id: string): boolean + links(): readonly LinkInfo[] + /** + * The supply edges prompt execution would use in this graph right now. + * + * Re-runs the registered pure suppliers and the host's priority arbitration, + * returning graph-local ids suitable for {@link OutputSlotHandle.connectTo}. + * Exact priority ties are absent, just as they are from the prompt. The + * frozen snapshot never mutates the graph. + */ + resolvedSupplies(): readonly ResolvedSupply[] + /** + * The nodes the user currently has selected. + * + * 15 packs read `canvas.selected_nodes` or `selectedItems` for this — a + * canvas internal, and the canvas is exactly what Nodes 2.0 replaces. + * Selection is a property of the document, so it is asked of the graph. + */ + selection(): readonly NodeHandle[] + /** + * Replaces the selection with these nodes. An empty list clears it. + * + * A node a pack just created is the usual case — `LGraphCanvas.add`'s + * `options.select` put it straight under the user's cursor, and without this + * the node appears but the user has to find and click it. + * + * `add: true` extends the selection instead of replacing it. + */ + select(nodes: readonly NodeHandle[], options?: { add?: boolean }): void + /** + * Pans the view so a node sits in the middle of it. + * + * Packs wrote `canvas.ds.offset` themselves to do this, which bakes in the + * renderer's transform and the device pixel ratio. Does not change zoom. + */ + centerOn(node: NodeHandle): void + /** + * The groups on the canvas, in draw order. + * + * Packs read `graph._groups` to build a group muter, a group runner, or a + * navigator. A group is a rectangle plus a title: which nodes it holds is + * derived from what it overlaps, which is why `nodes()` is a method and not + * a stored list. + */ + groups(): readonly GroupHandle[] + /** + * Scales the view. 1 is unzoomed. + * + * Packs saved a zoom level alongside a node to restore a view; without this + * a bookmark could pan but the number it stored was inert. Clamped to what + * the canvas allows, so a stored extreme cannot strand the user. + */ + setZoom(scale: number): void + /** + * Where the pointer is, in graph space — the coordinates {@link nodeAt} and + * {@link NodeHandle.setPosition} use. + * + * A pack adding a node from a menu put it under the cursor. Without this the + * node lands at the graph origin, which on any panned view is off screen. + * + * `undefined` when there is no canvas to measure against. + */ + pointerPosition(): Point | undefined + /** + * The document's root graph, even while the user is viewing a subgraph. + * Undefined before a document exists. + */ + root(): GraphScopeHandle | undefined + /** + * The subgraph definitions in the document, each scoped to its own nodes. + * + * `nodes()` and `node()` address the graph on screen only, so a pack that + * must reach every node — refreshing its own nodes after a run, walking a + * chain — misses anything nested. + * + * Access is *through* the subgraph rather than a flattened list. Ids are + * allocated from the root graph's counter, so they do not collide among + * nodes created in one session — but a subgraph loaded from a file brings + * its authored ids, and `configure` raises that counter without renumbering + * anything. Two independently authored subgraphs can therefore carry the + * same id. Resolving inside the owning graph is correct either way, and does + * not rest on an invariant litegraph does not promise. + * + * These are definitions, not instances. A subgraph placed three times has + * one entry, and its nodes appear once — which is what a pack acting on + * "each of my nodes" wants. + */ + subgraphs(): readonly GraphScopeHandle[] + /** + * Runs several mutations as one undo step. + * + * Without it, a pack that adds three nodes and wires them leaves the user + * pressing undo four times to get back. `graph.beforeChange()` / + * `afterChange()` did this by counting nesting depth. + * + * A scope rather than a pair of calls: the counter only captures when it + * returns to zero, so one throw between a manual `before` and `after` stops + * undo capturing anything at all, for the rest of the session, with nothing + * to show why. The scope closes on the way out either way. + * + * Synchronous on purpose. Holding the group open across an `await` would + * fold whatever the user did while waiting into the pack's undo step. + */ + batch(mutations: () => T): T + /** + * The topmost node at a point in graph space, if any. + * + * Packs building a gesture were walking every node and re-deriving its + * rectangle from renderer constants. The graph already knows, and its answer + * respects z-order, collapsed nodes and the active renderer's layout. + * + * Answers against the *rendered* layout, which is the only sensible reading + * of "what is under this point" — and is why it is not refreshed per call: a + * gesture asks this on every pointer move, and remeasuring every node each + * time would be the expensive mistake. Before the first frame it finds + * nothing. + */ + nodeAt(point: { x: number; y: number }): NodeHandle | undefined + /** + * A copy of a node, carrying its widget values and properties, added to the + * graph without links. + * + * `add(type)` only makes a fresh node of a type, so a pack duplicating a + * configured node — a prompt box the user has filled in — had no way to keep + * what it contained. Links are deliberately not copied: a duplicate wired + * into the same places is a different operation, and the caller can connect + * it themselves. + * + * `undefined` if the node is gone, or if its type is not registered — the + * copy is built through the registry, so there is nothing to build from. + * Widget values carry over only for a type that serializes them, which every + * backend-registered type does. + */ + duplicate( + id: string, + position?: { x: number; y: number } + ): NodeHandle | undefined + /** + * Rebuilds a node, optionally as another type, keeping what the user set and + * every link that still fits. Replacing with the same type repairs a node + * whose registered definition changed without discarding its state. + * `undefined` if the node is gone; throws if the type is not registered. + * + * This is a real feature four packs ship — "Convert to Context Big", "Swap to + * KSampler (Efficient)" — and all four hand-rolled it out of `graph.links`, + * `getNodeById` and `LiteGraph.createNode`, which is most of what this + * migration exists to delete. All four also got it wrong: one drops every + * widget value and hardcodes "slot 0 only", the other recurses through + * requestAnimationFrame forever on an inverted comparison and leaves a + * separate undo step for the add, each connection, and the remove. + * + * Position, custom title, colour, mode, declared properties and widget values + * carry over by name. Size is the larger of what the user set and what the new + * type needs, so a node that grew more slots is not clipped. Links are re-made + * by slot name, falling back to the same index; type checking is the ordinary + * connection rule, so a link that no longer fits is dropped and warned about + * rather than forced. The whole swap is one undo step. + */ + replace(id: string, type: string): NodeHandle | undefined + /** + * Changes when the graph does: nodes added, removed or reconfigured, links + * connected or disconnected, slots and subgraph inputs/outputs altered, and + * the node flags a reader can see — collapsed, pinned, advanced. + * + * Hold one and compare it later to learn whether anything moved since. That + * is the whole contract: an opaque token, not a count. Do not subtract two + * of them, do not expect it to start anywhere in particular, and do not + * expect consecutive changes to differ by one. Coalesced edits are free to + * advance it once, and `batch()` exists precisely so they can. + * + * A widget value committed by the user or through + * `WidgetHandle.setValue()` advances it through the same host protocol. Data + * a pack keeps outside graph and widget state does not; a canvas widget + * holding such data has `redraw()`. + */ + readonly version: number + /** Diagnostics: live handle-cache slots across all kinds. */ + readonly cacheSize: number +} + +/** + * A subgraph definition, scoped to its own contents. + * + * Deliberately narrower than {@link GraphHandle}: adding, selecting, centring + * and zooming all address what the user is looking at, and a subgraph + * definition is not that. This is for reading and reaching nodes. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface GraphScopeHandle { + /** Stable across every instance of this subgraph. */ + readonly id: string + readonly name: string | undefined + nodes(): readonly NodeHandle[] + node(nodeId: string): NodeHandle | undefined + /** + * The groups drawn inside this subgraph. + * + * A group muter or runner that skipped these reported nothing for a + * subgraph's contents while appearing to work. + */ + groups(): readonly GroupHandle[] + /** The supply edges prompt execution would use inside this graph. */ + resolvedSupplies(): readonly ResolvedSupply[] +} + +// ─── groupHandle.ts ────────────────────────────────────────────── + +export interface GroupHandle { + readonly id: string + getTitle(): string + setTitle(title: string): void + /** Colour as the renderer holds it, or undefined for the default. */ + getColor(): string | undefined + setColor(color: string): void + /** + * The nodes the group currently contains, recomputed on each call. + * + * Packs muted or queued "the group", which always meant its nodes. Do not + * cache the result: a drag changes it with no event. + */ + nodes(): readonly NodeHandle[] + /** The group's rectangle in graph space, title bar included. */ + getBounds(): Bounds + /** Pans the view so this group is in the middle of it. Zoom is unchanged. */ + centerOn(): void +} + +// ─── interaction.ts ────────────────────────────────────────────── + +export interface NodeMoveEvent { + readonly node: NodeHandle + readonly position: { readonly x: number; readonly y: number } +} + +/** + * Where movement comes from, supplied by the renderer. + * + * `platform/` cannot import `renderer/`, and the layout store lives there. This + * is the same seam `registerBadgeRowsProvider` uses so litegraph never reaches + * into the store: the upper layer pushes the source down at boot. + */ +export type NodeMoveSource = ( + onMove: (nodeId: string, position: { x: number; y: number }) => void +) => Unsubscribe + +/** Reports a completed drag with the ids of every node it moved. */ +export type NodeDragEndSource = ( + onDragEnd: (nodeIds: readonly string[]) => void +) => Unsubscribe + +// ─── nodeChanges.ts ────────────────────────────────────────────── + +/** A field the host tracks and reports. Not every property is one. */ +export type TrackedProperty = + | 'title' + | 'mode' + | 'color' + | 'bgcolor' + | 'shape' + | 'showAdvanced' + +/** + * Which graphs a listener hears from. + * + * `'visible'` is the default and the graph on screen, following the user into + * and out of subgraphs — what a pack decorating what the user is looking at + * wants. + * + * `'document'` is the root graph and every subgraph definition. A pack that + * *computes* from other nodes needs it: rgthree's relay derives a group's mute + * state from its inputs, and inside a subgraph the user had navigated away from + * it stopped recomputing while still asserting its last answer — so a group + * stayed muted against its inputs, intermittently, and healed on navigation. + */ +export type NodeChangeScope = 'visible' | 'document' + +export interface NodeChangeOptions { + scope?: NodeChangeScope +} + +export interface NodeChangeEvent { + /** The node that changed. It may belong to another pack, or to none. */ + readonly node: NodeHandle + /** + * The graph the change happened in — the root graph's id, or a subgraph + * definition's. Node ids are unique only within a graph, so a pack keeping + * its own records under `'document'` must key on both. + */ + readonly graphId: string + /** + * The editing session the change happened in, or `undefined` when the host + * cannot name one. + * + * `graphId` is restored from the saved workflow and round-trips through + * `serialize()`, so it identifies the graph on disk, not the document open + * in front of the user — two opens of one file report the same value. A pack + * holding records across a document swap needs this to know they are stale. + */ + readonly documentId: string | undefined + readonly property: TrackedProperty + readonly from: unknown + readonly to: unknown +} + +// ─── nodeHandle.ts ─────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type NodeMode = 'always' | 'never' | 'bypass' | 'on-event' | 'on-trigger' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type NodeShape = 'default' | 'box' | 'round' | 'circle' | 'card' + +export interface BadgeDef { + readonly text: string + /** Text colour. Defaults to core's badge foreground. */ + readonly color?: string + /** Background colour. Defaults to core's badge background. */ + readonly bgColor?: string + /** + * Makes the badge clickable. + * + * Two conversions declined to turn a button into a badge because a badge + * that looks pressable and does nothing is worse than the thing it replaced. + */ + onClick?(): void +} + +export interface Point { + readonly x: number + readonly y: number +} + +export interface Size { + readonly width: number + readonly height: number +} + +/** A rectangle in graph space. */ +export interface Bounds { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +export interface NodeSnapshot { + readonly id: string + readonly type: string + readonly title: string + readonly mode: NodeMode + readonly collapsed: boolean + readonly pinned: boolean + readonly color: string | undefined + readonly bgColor: string | undefined + readonly shape: NodeShape + readonly position: Point + readonly size: Size +} + +/** + * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract: + * accessor methods rather than properties, so a read can be a store query and + * a write can dispatch a command. + */ +export interface SizeConstraints { + minWidth?: number + minHeight?: number + maxWidth?: number + maxHeight?: number + /** Grow to fit content rather than holding a fixed height. */ + autoHeight?: boolean +} + +export interface NodeHandle extends HandleCommon { + readonly id: string + readonly type: string + readonly comfyClass: string + + getTitle(): string + setTitle(title: string): void + getMode(): NodeMode + setMode(mode: NodeMode): void + isCollapsed(): boolean + setCollapsed(collapsed: boolean): void + isPinned(): boolean + setPinned(pinned: boolean): void + getColor(): string | undefined + setColor(color: string | undefined): void + getBgColor(): string | undefined + setBgColor(color: string | undefined): void + getShape(): NodeShape + setShape(shape: NodeShape): void + getProperty(key: string): T | undefined + getProperties(): Readonly> + setProperty(key: string, value: WidgetValue): void + /** + * Whether this node emits `widgets_values` when the workflow is serialized. + * + * Writable because packs vary it per node type, and the value is part of the + * wire format — a conversion that could not set it would change what the + * saved workflow contains. + */ + isSerializingWidgets(): boolean + setSerializeWidgets(serialize: boolean): void + + getPosition(): Point + setPosition(pos: Point): void + getSize(): Size + /** Changes size through the host's resize protocol, including `onResized`. */ + setSize(size: Size): void + /** + * The node's rectangle in graph space, title bar included. + * + * `getPosition()` is the body's top-left, so packs building a gesture were + * reconstructing this by subtracting a title height read off the renderer — + * which is only right for the default layout, and wrong for a collapsed node + * or under a different renderer. Ask the renderer instead of re-deriving it. + */ + getBounds(): Bounds + /** + * Where a slot sits, in graph space. + * + * The renderer's own answer, so it stays correct for collapsed nodes, + * widget-backed inputs and layouts that are not the default vertical stack — + * all cases the `(index + 0.7) * slotHeight` reconstruction gets wrong. + * + * `undefined` if there is no slot at that index. + */ + getSlotPosition(side: 'input' | 'output', index: number): Point | undefined + /** + * Where the node currently sits on screen, in client coordinates. + * + * For anchoring a floating panel to a node. Packs did this by reading the + * viewport's pan and zoom and doing the arithmetic themselves, which is both + * the renderer's business and wrong the moment the transform changes shape. + * + * The answer already accounts for zoom, so a pack needing to convert a pixel + * drag into graph units can divide by `width / getBounds().width` rather than + * asking for the scale factor. + * + * `undefined` when nothing is on screen to measure against. + */ + getScreenRect(): Bounds | undefined + /** + * URLs of the images this node produced when it last executed. + * + * Packs read `node.imgs` — the loaded `HTMLImageElement`s core hangs on the + * node — to walk upstream for the nearest ancestor holding a composite, or + * to scan the selection for something to feed an editor. `onExecuted` does + * not answer that: it is per node type, so it never sees another pack's + * outputs, and it only fires at the moment of execution. + * + * URLs rather than elements, deliberately. The loaded element is the + * renderer's, and its lifetime is the renderer's; a pack that wants pixels + * can load the URL itself and own the result. This also covers previews, + * which are what the node is showing when a run is still in flight. + * + * Empty when the node has not produced images. + */ + getOutputImages(): readonly string[] + /** + * Which of {@link getOutputImages} the user is looking at, or `undefined` + * when they have neither selected nor hovered one. + * + * A pack copying "the image" or saving one as a model's preview meant the + * one under the cursor, not the first of the batch. `undefined` is why this + * is not simply `0`: an entry that acts on a guess writes the wrong file to + * the server, silently. + */ + getDisplayedImageIndex(): number | undefined + /** + * The id of the graph holding this node — the root graph's id, or a + * subgraph's. + * + * A pack keeping its own records against nodes needs it: node ids are unique + * per graph, so a key built from the id alone collides once subgraphs are + * involved. Pair it with `comfy.graph.subgraphs()` to get back to the node. + */ + readonly graphId: string | undefined + /** + * Puts a small label on the node's title bar. Returns a handle that removes + * it again. + * + * Packs draw a status, a count, a cost, a model name. They did it by + * overriding `onDrawForeground` and painting into the canvas context, which + * only works under the legacy renderer and puts the pack in the business of + * laying out text. `badges` is core's own extension point and both renderers + * draw it. + * + * Pass a function for a label that changes: it is called each time the node + * is drawn, so return quickly and do not build strings you could cache. + */ + addBadge(badge: BadgeDef | (() => BadgeDef)): Unsubscribe + /** + * Declares how the node may be sized, instead of re-asserting it per frame. + * + * 39 packs recompute size inside a draw or resize callback, which is both a + * per-frame cost and a fight with the layout. `autoHeight` is usually the + * real intent: the pack mounted something of unknown height and wants the + * node to fit it. + */ + setSizeConstraints(constraints: SizeConstraints): void + getSizeConstraints(): Readonly + + readonly inputs: SlotCollection + readonly outputs: SlotCollection + readonly widgets: WidgetCollection + snapshot(): Readonly | undefined + remove(): void +} + +/** Per-node collections, supplied by the graph layer that owns their caches. */ +export interface NodeCollections { + inputs(nodeId: string): SlotCollection + outputs(nodeId: string): SlotCollection + widgets(nodeId: string): WidgetCollection +} +```` diff --git a/custom-nodes/v2/reference/javascript-execution.mdx b/custom-nodes/v2/reference/javascript-execution.mdx new file mode 100644 index 000000000..206fe6c64 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-execution.mdx @@ -0,0 +1,404 @@ +--- +title: "JavaScript execution API" +description: "Queue control, frontend-only resolution, suppliers, and execution results." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 22 exported declarations: `RunOptions`, `RunSubmittedEvent`, `RunSubmission`, `RunRejectionError`, `RunRejectedNode`, `RunRejectedEvent`, `AutoQueueMode`, `QueueHandle`, `InputRef`, `OutputResolution`, `ResolvedNodeView`, `ResolveView`, `Resolver`, `ResolvedSource`, `OwnInput`, `OwnOutput`, `GroupMembership`, `UnconnectedInput`, `SuppliedEdge`, `SupplyView`, `Supplier`, `ResolvedSupply`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── queueHandle.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunOptions { + /** + * Run only these nodes and whatever feeds them, instead of the whole + * workflow. Empty is rejected rather than treated as "everything": a filter + * that matched nothing must not silently run the entire graph. + */ + nodes?: readonly NodeHandle[] + /** How many times to run. Defaults to 1. */ + batch?: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunSubmittedEvent { + /** Ids the backend accepted, in submission order. */ + readonly promptIds: readonly string[] + /** The accepted prompts and how many backend nodes each will execute. */ + readonly submissions?: readonly RunSubmission[] + /** How many submissions the backend refused. */ + readonly rejected: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunSubmission { + readonly promptId: string + readonly nodeCount: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectionError { + readonly type: string + readonly message: string + readonly details: string + readonly inputName?: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectedNode { + readonly nodeId: string + readonly nodeType: string + readonly errors: readonly RunRejectionError[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectedEvent { + readonly status?: number + readonly error: RunRejectionError + readonly nodeErrors: readonly RunRejectedNode[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type AutoQueueMode = 'disabled' | 'change' | 'instant' + +export interface QueueHandle { + /** + * Queues the current workflow, exactly as pressing Run does. + * + * Resolves once the prompt has been submitted — not when it finishes + * executing. `false` means another queue call was already in flight and this + * one was folded into it. + */ + run(options?: RunOptions): Promise + /** + * A run is about to be submitted. + * + * This is `beforeQueuing`. For a last write before the prompt is built — + * syncing a value the pack keeps outside the widget. Keep it synchronous: + * the prompt build does not wait, so work started here can lose the race. + */ + /** + * Return a function to have it run when the attempt is over — whether the + * run started, was refused, or threw. + * + * For a pack that changes the graph to build the prompt and must put it back: + * unmute a branch, let the prompt be built, re-mute it. Pairing it with the + * setup rather than publishing a second top-level event is deliberate — you + * cannot receive the cleanup without having run the setup, and there is no + * second "after" member to confuse with {@link onAfterRun}, which means + * something different and narrower. + */ + onBeforeRun(listener: () => (() => void) | void): Unsubscribe + /** + * A run was submitted. This is `afterQueued` — for advancing state that + * should differ on the next run. + * + * The event names what the backend accepted, so a pack can tie its own + * progress tracking to the run it started rather than guessing that the next + * execution message belongs to it. Each submission includes the exact count + * of executable backend nodes without exposing the built prompt. `rejected` + * is how many submissions the backend refused: `onBeforeRun` fires either + * way, so without this a pack cannot tell a run that started from one that + * never did. + */ + onAfterRun(listener: (event: RunSubmittedEvent) => void): Unsubscribe + /** + * The backend refused a submitted prompt before execution began. + * + * This exposes prompt and per-node validation details without coupling a + * pack to host notifications. It does not fire for transport failures or an + * error raised after execution starts. + */ + onRejected(listener: (event: RunRejectedEvent) => void): Unsubscribe + /** + * How many runs are waiting, including the one executing. + * + * Packs tracked this from the backend's own `status` message to re-implement + * `app.ui.lastQueueSize` — deciding whether a button says Run or Cancel, + * whether an auto-runner should submit again. + */ + pending(): number + /** Fires whenever {@link pending} changes, with the new count. */ + onPendingChanged(listener: (pending: number) => void): Unsubscribe + /** + * Cancels the run in progress. The rest of the queue is untouched. + * + * Packs wrapped `api.interrupt` both to call it and to notice one — a node + * waiting on the user needs to stop waiting when the run is cancelled. + * {@link onInterrupted} is that second half. + */ + interrupt(): Promise + /** Execution was interrupted, by this pack, another, or the user. */ + onInterrupted(listener: () => void): Unsubscribe + /** The user-facing automatic queue mode. Both internal instant states read as `instant`. */ + autoQueueMode(): AutoQueueMode + /** Changes automatic queuing. `instant` arms continuous execution. */ + setAutoQueueMode(mode: AutoQueueMode): void + /** The batch count the host's own Run action will use. */ + batchCount(): number + /** Changes the host Run action's batch count. */ + setBatchCount(count: number): void + /** + * Turns off automatic queuing without cancelling the current run. + * + * A conditional workflow can use this before interrupting itself so the + * stopped iteration does not immediately start again. + */ + disableAutoQueue(): void + /** + * Holds a run until a check finishes, and can cancel it. + * + * {@link onBeforeRun} only observes: it is a notification, and the prompt + * build does not wait. Packs that needed to *stop* a run — confirm an + * incoming prompt, validate a field, warn about a cost — wrapped + * `app.queuePrompt` to do it, which is the surface being retired. + * + * Return `false` to cancel. Every guard runs, and any one `false` cancels; + * the user is not asked twice. + * + * A guard that never settles would make the application unrunnable, so one + * that takes longer than a few seconds is abandoned and the run proceeds. Do + * not put a dialog with no timeout behind this. + */ + guard(check: () => boolean | Promise): Unsubscribe +} + +// ─── resolution.ts ─────────────────────────────────────────────── + +/** + * "Whatever feeds this input." The only way one resolution names another. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface InputRef { + readonly nodeId: string + readonly input: number +} + +export type OutputResolution = + | { readonly omit: true } + | { readonly forwardTo: InputRef } + | { readonly literal: WidgetValue } + +/** + * What a resolver may see. Reads only — there is nothing here that writes. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface ResolvedNodeView { + readonly id: string + readonly type: string + /** + * The node's own properties, frozen. + * + * A broadcaster keeps its per-node opt-in here — cg-use-everywhere reads + * `properties.ue_properties` to decide what it may feed. Candidate inputs + * already carry `nodeProperties`, so without this a supplier could read + * every node's configuration except its own. + */ + readonly properties: Readonly> + /** The groups this node sits inside — the other half of "my group". */ + readonly groups: readonly GroupMembership[] + /** Muted, bypassed or normal, as `LGraphEventMode`. */ + readonly mode: number + readonly color: string | undefined + /** + * This node's own inputs. + * + * `unconnectedInputs()` already describes every *other* node's slots, and a + * supplier needs the same of its own: "send whatever is plugged into me to + * every unconnected input of the same type" cannot be written without + * knowing what type is plugged in. Without it a supplier is type-blind and + * would feed a CLIP into a MODEL slot in silence. + * + * `type` is the slot's declared type; `connectedType` is what actually + * arrives, resolved through reroutes, and is undefined when nothing is + * connected. + */ + readonly inputs: readonly OwnInput[] + /** This node's own outputs, in slot order. */ + readonly outputs: readonly OwnOutput[] + widgetValue(name: string): WidgetValue | undefined + input(ref: string | number): InputRef | undefined +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface ResolveView { + readonly self: ResolvedNodeView + nodesOfType(type: string): readonly ResolvedNodeView[] +} + +/** + * May answer asynchronously: a pack's resolver may run in a worker, so + * its answer can only arrive as a promise. The prompt path awaits it; the + * synchronous entry points (`input.resolvedSource()`, `resolvedSupplies()`) + * treat a promise as unresolved and say so — see `resolution.async.test.ts`. + */ +export type Resolver = ( + view: ResolveView +) => + | Record + | Promise> + +/** Where an output ends up after every frontend node in the chain resolves. */ +export type ResolvedSource = + | { + readonly kind: 'output' + readonly nodeId: string + readonly output: number + } + | { readonly kind: 'literal'; readonly value: WidgetValue } + | { readonly kind: 'omitted'; readonly reason: string } + +/** An input in the graph that no link feeds. */ +/** One of a node's own inputs, as its supplier sees it. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface OwnInput { + readonly index: number + readonly name: string + /** What the user sees — `label`, else `localized_name`, else `name`. */ + readonly label: string + readonly type: string + readonly connected: boolean + /** The type actually arriving, or undefined when nothing is connected. */ + readonly connectedType: string | undefined + /** The node feeding this input, if any. */ + readonly sourceNodeId: string | undefined +} + +/** One of a node's own outputs, as its supplier sees it. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface OwnOutput { + readonly index: number + readonly name: string + /** What the user sees — `label`, else `localized_name`, else `name`. */ + readonly label: string + readonly type: string +} + +/** A group a node sits inside. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface GroupMembership { + readonly id: string + readonly title: string +} + +export interface UnconnectedInput { + readonly nodeId: string + readonly nodeType: string + readonly input: number + readonly name: string + readonly type: string + /** + * What the user actually sees on the slot — `label`, else `localized_name`, + * else `name`. Broadcast packs match against this, not `name`, and the two + * differ in every non-English locale. + */ + readonly label: string + /** The socket form of a widget rather than a plain input. */ + readonly isWidgetInput: boolean + /** The owning node, for matching by title, mode, colour, or opt-in flags. */ + readonly nodeTitle: string + readonly nodeMode: number + readonly nodeColor: string | undefined + /** + * The groups the owning node sits inside, innermost first. + * + * Broadcast packs restrict by group — "only nodes in my group", "only nodes + * outside it", "only groups whose title matches this regex". Membership is + * geometric and recomputed here, so it matches what the user sees rather + * than anything stored. + */ + readonly nodeGroups: readonly GroupMembership[] + /** + * The owning node's properties, frozen. + * + * Broadcast packs keep their per-node opt-in here — which inputs a user has + * allowed to be fed. Without it a supplier can only match by type and would + * feed every unconnected input of that type, which is the silent + * wrong-broadcast failure this view exists to prevent. + */ + readonly nodeProperties: Readonly> +} + +/** + * An edge a node supplies into somebody else's unconnected input. + * + * `from` is the supplier's own output index, or a literal. It is deliberately + * not an arbitrary node reference: a node may only offer what it itself has, + * so one pack cannot rewire two other nodes to each other. + */ +export interface SuppliedEdge { + readonly to: InputRef + /** + * Which claim wins when several suppliers name the same input. Higher wins; + * defaults to 0. + * + * **Equal claims feed nothing.** Two suppliers that both say "highest + * priority" for one input have no correct answer, and picking either makes + * the prompt depend on node order — so the input is left unfed and the + * conflict logged. That is what the broadcast pack this exists for does, and + * it is the only choice that cannot silently produce a different image. + */ + readonly priority?: number + readonly from: + | { readonly output: number } + | { readonly literal: WidgetValue } + /** + * Whatever feeds this node's own input `k` — for a node that rebroadcasts + * its upstream rather than producing a value. + * + * The broadcast nodes this exists for have inputs and **no outputs**, so + * `{ output: n }` cannot describe them: it would name a slot the backend + * never declared and force it to execute a node that produces nothing. + * Resolved exactly as `Resolver`'s `forwardTo`, so it chains through + * reroutes for free. + */ + | { readonly forwardInput: number } +} + +export interface SupplyView { + readonly self: ResolvedNodeView + nodesOfType(type: string): readonly ResolvedNodeView[] + /** + * Every unfed input in the graph — what a broadcaster matches against by + * type, by name, or by its own regex. + */ + unconnectedInputs(): readonly UnconnectedInput[] +} + +/** + * Answers "what do I feed", the mirror of `Resolver`'s "what feeds me". + * + * `Resolver` is demand-side: it is asked about the resolver's own outputs, and + * is never called for a node with none. cg-use-everywhere broadcasts a value + * into every matching unconnected input in the graph, which that shape cannot + * express at all — the nodes being fed are not the resolver, and the edges are + * discovered rather than declared. Hence a second, supply-side pass. + * + */ +/** May answer asynchronously, under the same rules as {@link Resolver}. */ +export type Supplier = ( + view: SupplyView +) => readonly SuppliedEdge[] | Promise + +/** + * One winning supply after priority arbitration and source resolution. + */ +export interface ResolvedSupply { + /** The node whose supplier offered this edge. */ + readonly supplierNodeId: string + /** The unconnected input the supplier won. */ + readonly to: InputRef + /** The final source the prompt builder will use. */ + readonly from: ResolvedSource +} +```` diff --git a/custom-nodes/v2/reference/javascript-settings-storage.mdx b/custom-nodes/v2/reference/javascript-settings-storage.mdx new file mode 100644 index 000000000..be0ec4f76 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-settings-storage.mdx @@ -0,0 +1,151 @@ +--- +title: "JavaScript settings and storage API" +description: "Pack settings and user-scoped persistent storage." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 7 exported declarations: `SettingValue`, `SettingDef`, `SettingOption`, `SettingAttrs`, `SettingsHandle`, `StorageUsage`, `StorageHandle`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── settingsHandle.ts ─────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SettingValue = string | number | boolean | readonly string[] + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SettingDef { + /** + * Namespaced, by convention `.` — it shares one space with core + * and every other pack, and it is what the value is stored under forever. + */ + readonly id: string + readonly name: string + /** + * Which control the panel shows. Every one of these is declarative — the + * host renders it. + * + * A pack-supplied renderer is deliberately absent. Core's own setting type + * accepts a function that is handed the value and a setter and returns an + * element; publishing that would put packs in charge of the settings + * panel's markup, which is the thing that cannot then be restyled. Packs + * that needed a colour or a file were falling back to a text field the user + * pasted into, so the gap was the missing *types*, not a missing slot. + */ + readonly type: + | 'boolean' + | 'number' + | 'slider' + | 'knob' + | 'combo' + | 'radio' + | 'text' + | 'password' + | 'color' + | 'image' + | 'url' + readonly defaultValue: SettingValue + readonly tooltip?: string + /** Panel grouping. Defaults to the id split on dots. */ + readonly category?: readonly string[] + /** + * Choices for `combo` and `radio`. + * + * A bare string is both the stored value and the label. Use the pair form + * when they differ — several packs store a semantic number and show words + * for it (`0` = off, `1` = selected, `2` = all), and comparing those + * numerically is the whole point. Flattening them to strings silently + * re-types every user's saved choice. + */ + readonly options?: readonly SettingOption[] + /** + * Bounds for `number` and `slider`. Without these a slider has no range to + * draw and packs fall back to a plain text box. + */ + readonly attrs?: SettingAttrs + readonly onChange?: (value: SettingValue, previous?: SettingValue) => void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SettingOption = + | string + | { readonly value: string | number; readonly label: string } + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SettingAttrs { + readonly min?: number + readonly max?: number + readonly step?: number +} + +export interface SettingsHandle { + /** + * Registers a setting. Call once, at extension load: a value already stored + * for this id survives, so re-declaring cannot reset a user's choice. + */ + declare(def: SettingDef): void + get(id: string): T | undefined + set(id: string, value: SettingValue): Promise + /** + * Watches a setting, including one the pack did not declare. + * + * `declare`'s own `onChange` only fires for settings the pack owns, so a + * pack that needs to react to a *core* preference — colour palette, link + * render mode, locale — had nothing to observe and polled or ignored it. + * + * Fires on change only, not on registration. Returns a function that stops + * watching; call it from wherever the pack tears down. + */ + onChange( + id: string, + listener: (value: T | undefined, previous: T | undefined) => void + ): Unsubscribe +} + +// ─── storageHandle.ts ──────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface StorageUsage { + /** Total bytes stored under the namespace. */ + readonly usedBytes: number + /** How many entries make up {@link usedBytes}. */ + readonly entryCount: number + /** + * The ceiling this host enforces, or `undefined` where it enforces none. + * + * Undefined is the honest answer for a local install with the user's own + * disk behind it, and it is deliberately not reported as `Infinity`: a pack + * dividing by it to draw a gauge would get a meaningless bar rather than the + * chance to skip drawing one. Do not treat a present number as a promise + * that a write below it succeeds — another namespace shares the same store. + */ + readonly quotaBytes?: number +} + +export interface StorageHandle { + /** + * Names stored under a namespace, which must be one this pack owns. + * + * Empty when nothing has been stored yet — absence is not an error. + */ + list(namespace: string): Promise + /** The stored text, or `undefined` if there is none. */ + get(name: string): Promise + set(name: string, value: string): Promise + remove(name: string): Promise + /** + * What a namespace currently occupies. + * + * For a pack that stores things a user accumulates — presets, captions, + * saved prompts — so it can show what it is holding and offer to prune it, + * rather than growing without bound until someone else's write fails. + */ + usage(namespace: string): Promise +} +```` diff --git a/custom-nodes/v2/reference/javascript-slots.mdx b/custom-nodes/v2/reference/javascript-slots.mdx new file mode 100644 index 000000000..9115c0b27 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-slots.mdx @@ -0,0 +1,311 @@ +--- +title: "JavaScript slots and links API" +description: "Slot identity, connections, dynamic slots, link snapshots, and input resolution." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 17 exported declarations: `LinkInfo`, `SlotType`, `SlotDirection`, `SlotPosition`, `SlotPatch`, `InputSlotPatch`, `InputWidgetConfig`, `SlotSnapshot`, `ResolvedInputSource`, `InputSlotHandle`, `OutputSlotHandle`, `SlotCollection`, `SlotShape`, `SlotOptions`, `SlotId`, `SlotRef`, `ResolveOptions`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── slotHandle.ts ─────────────────────────────────────────────── + +export interface LinkInfo { + readonly id: string + readonly sourceNodeId: string + readonly sourceSlotId: SlotId + readonly targetNodeId: string + readonly targetSlotId: SlotId + readonly type: string + /** Position at snapshot time. Do not store across mutations. */ + readonly sourceIndex: number + readonly targetIndex: number +} + +/** + * Fields a pack may change on an existing slot. + * + * Applied atomically as one command, so a retype-plus-rename is a single undo + * step rather than two. Retyping deliberately **keeps existing links**: dynamic + * retyping (`*` -> `MODEL`) is the whole point for `SetNode`-style packs, and + * silently dropping connections is the failure mode this API exists to end. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +/** + * A slot's type, which may be a union. + * + * An array spells "this slot accepts any of these" — rgthree's + * `addInput('input', ['IMAGE', 'LATENT', 'MASK'])` is the shipped example, so + * packs do write it even though litegraph's own `ISlotType` says + * `number | string`. + * + * Both forms are accepted and stored as the comma string, because that is what + * litegraph compares against: it normalises with `String(type).split(',')`, so + * `['IMAGE','LATENT','MASK']` and `'IMAGE,LATENT,MASK'` are the same slot to + * every connection check. The saved workflow therefore holds the string where + * the original held an array — a byte difference with no behavioural one, and + * the same call already taken for slot `shape`. + * + * Reads stay `string` for the same reason. + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export type SlotType = string | string[] + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SlotDirection = 'none' | 'up' | 'down' | 'left' | 'right' | 'center' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotPosition { + readonly x: number + readonly y: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotPatch { + name?: string + label?: string | undefined + /** The backend-provided translated caption. Null clears it. */ + localizedName?: string | null + type?: SlotType + /** Slot centre relative to the node body. Null restores automatic layout. */ + position?: SlotPosition | null + /** Direction in which links leave the slot. Null restores the default. */ + direction?: SlotDirection | null + /** + * The dot's colour when connected and when not. + * + * Not decoration, despite appearances: both sit on `INodeSlot` and + * `ISerialisableNodeInput` omits only `boundingRect`, `widget` and `link`, + * so they are written into the saved workflow. A pack that coloured its + * slots and then stopped saves different bytes than it used to. + * + * `null` clears one back to the renderer's default. + */ + color?: string | null + colorWhenUnconnected?: string | null + /** + * Sits on the same `INodeSlot` as the colours above and is omitted by the + * same `Omit`, so the argument made for them holds verbatim: a pack that + * shaped its slots and then stopped saves different bytes than it used to. + * + * `'default'` clears it back to the renderer's own choice. + */ + shape?: SlotShape +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface InputSlotPatch extends SlotPatch { + /** Retargets the widget this input is the socket form of. Null clears it. */ + widget?: string | null + /** Replaces the input declaration used by connected Primitive nodes. */ + widgetConfig?: InputWidgetConfig +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface InputWidgetConfig { + /** Backend input type, or the choices for a COMBO input. */ + readonly type: string | readonly (string | number)[] + readonly options?: Readonly> +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotSnapshot { + readonly id: SlotId + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly localizedName: string | undefined + readonly position: SlotPosition | undefined + readonly direction: SlotDirection | undefined + readonly shape: SlotShape + readonly isConnected: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type ResolvedInputSource = + | { + readonly kind: 'output' + readonly graphId: string + readonly nodeId: string + readonly outputIndex: number + } + | { readonly kind: 'literal'; readonly value: WidgetValue } + | { readonly kind: 'omitted'; readonly reason: string } + +export interface InputSlotHandle { + readonly id: SlotId + /** Volatile — shifts when other slots are added or removed. */ + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly isConnected: boolean + /** The type arriving through the link, including across a subgraph input. */ + readonly connectedType: string | undefined + /** Whether this input is the socket form of a widget. */ + readonly isWidgetInput: boolean + /** The declaration a connected Primitive node renders. */ + widgetConfig(): Readonly | undefined + /** Intersects this input's declaration with another compatible one. */ + mergeWidgetConfig( + config: InputWidgetConfig + ): Readonly | undefined + link(): LinkInfo | undefined + source(): { nodeId: string; outputIndex: number } | undefined + /** + * What ultimately feeds this input after frontend nodes resolve. + * + * `source()` reports the physical link, which is right for editing topology. + * This reports the executable source through reroutes, Get/Set nodes and any + * other frontend node declared with `defs.define({ resolve })`. Resolution is + * read-only and leaves the graph untouched. + */ + resolvedSource(): ResolvedInputSource | undefined + disconnect(): boolean + modify(patch: InputSlotPatch): void + /** Replaces `{...input}`, which now yields nothing useful. */ + snapshot(): Readonly +} + +export interface OutputSlotHandle { + readonly id: SlotId + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly isConnected: boolean + /** Frozen snapshot — safe to iterate while disconnecting. */ + links(): readonly LinkInfo[] + targets(): readonly { nodeId: string; inputIndex: number }[] + connectTo(targetNodeId: string, input: SlotRef): LinkInfo | undefined + disconnect(targetNodeId?: string): boolean + modify(patch: SlotPatch): void + /** + * Moves every link on this output to another output of the same node, + * **preserving link ids**. + * + * Disconnect-and-reconnect is not equivalent: it allocates new ids, so the + * serialized workflow changes. Packs that re-home their own outputs during a + * migration depend on identity being kept. + * + * Slot types are **not** re-validated. The real-world sequence moves links + * off an output and then retypes it, so enforcing compatibility mid-move + * would reject exactly the case this exists for. + */ + moveLinksTo(target: SlotRef): readonly LinkInfo[] + snapshot(): Readonly +} + +export interface SlotCollection { + readonly length: number + get(ref: SlotRef): THandle | undefined + byId(id: SlotId): THandle | undefined + byName(name: string): THandle | undefined + /** Explicit positional access. */ + at(index: number): THandle | undefined + all(): readonly THandle[] + ids(): readonly SlotId[] + names(): readonly string[] + /** + * Adds a slot. 18 packs grow their inputs as the last one fills — the + * "Multi" combiner pattern — which needed `node.addInput` until now. + * + * `shape` is not decoration: it is written into the saved workflow, so a + * slot added without the one its pack used to set serialises differently + * from one the pack itself wrote. `'optional'` is the hollow circle + * ComfyUI draws for an input that need not be connected. + */ + add(name: string, type: SlotType, options?: SlotOptions): THandle + /** + * Removes a slot by reference. Any link into it is dropped, as it would be + * on the legacy path. + */ + remove(ref: SlotRef): boolean + /** + * Puts the slots in the given order. `names` must be a permutation of the + * current ones. + * + * Every link into or out of this node is re-pointed as part of the move, in + * one batch, so link ids — and therefore the saved workflow's `links` array + * — are unchanged. That is the whole reason this exists rather than being + * left to packs: a link stores its endpoint as a slot *index*, so a pack + * permuting the array itself silently re-points every connection, and the + * damage only shows when the workflow is next run. + * + * The slot *order* is serialized, so this changes the saved file by design — + * it is how a pack keeps its dynamic inputs matching what the backend + * declares. + */ + reorder(names: readonly string[]): void + [Symbol.iterator](): Iterator +} + +/** + * How a slot is drawn, which ComfyUI overloads to mean how it behaves. + * + * Named rather than numbered: packs wrote `{ shape: 7 }`, and 7 is meaningless + * without litegraph's RenderShape enum in front of you. + */ +export type SlotShape = 'default' | 'optional' | 'list' | 'directional' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotOptions { + /** + * `'optional'` is the hollow circle for an input that need not be connected, + * `'list'` the grid ComfyUI draws for an output that yields many values, and + * `'directional'` the arrow a pack uses for a slot that only ever feeds one + * particular kind of node. + */ + shape?: SlotShape + localizedName?: string + position?: SlotPosition + direction?: SlotDirection + /** + * Names the widget this slot is the socket form of — the "convert widget to + * input" shape. + * + * Not decoration either: a slot carrying it serialises as + * `{ widget: { name } }` where a plain socket serialises as `{ pos }`, and + * the widget keeps its place in `widgets_values`. A dynamic input added + * without it changes the saved file. + */ + widget?: string + /** The declaration a connected Primitive node should render. */ + widgetConfig?: InputWidgetConfig +} + +// ─── slotRef.ts ────────────────────────────────────────────────── + +export type SlotId = string & { readonly __brand: 'SlotId' } + +/** + * A slot reference: a string (id or name), or an explicit `{ index }`. + * + * A bare `number` is deliberately not accepted so positional access is visible + * at the call site and greppable: + * + * output.connectTo(node, 'image') // by name — preferred + * output.connectTo(node, { index: 0 }) // by position — explicit + */ +export type SlotRef = SlotId | string | { readonly index: number } + +export interface ResolveOptions { + /** + * Whether the backend supplies slot names yet. While false, a canonical + * integer string resolves positionally, so `'0'` addresses slot 0 and call + * sites need no rewrite once names arrive. + * + * Retire this together with the release that ships names — until then a pack + * passing `'2'` meaning a name would silently bind slot 2. + */ + readonly namedSlotsAvailable: boolean +} +```` diff --git a/custom-nodes/v2/reference/javascript-ui-widgets.mdx b/custom-nodes/v2/reference/javascript-ui-widgets.mdx new file mode 100644 index 000000000..e87273754 --- /dev/null +++ b/custom-nodes/v2/reference/javascript-ui-widgets.mdx @@ -0,0 +1,862 @@ +--- +title: "JavaScript UI and widgets API" +description: "Host-rendered UI, mounted and canvas widgets, widget events, and custom widget types." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 46 exported declarations: `SidebarTabBase`, `MountedSidebarTab`, `VueSidebarTab`, `SidebarTabDef`, `VueComponent`, `DialogBase`, `DialogKeyEvent`, `MountedDialog`, `VueDialog`, `DialogDef`, `DialogHandle`, `UiHandle`, `PromptDef`, `MenuItemDef`, `MenuDef`, `MenuHandle`, `WidgetValue`, `WidgetOptions`, `WidgetHandle`, `WidgetSerializeEvent`, `Unsubscribe`, `MountDef`, `MountedData`, `MountedValue`, `CanvasPointerEvent`, `CanvasTheme`, `CanvasDef`, `CanvasHandle`, `WidgetDef`, `WidgetCollection`, `ComboPreviewRegistration`, `ComboPreviewAssignment`, `WidgetsHandle`, `LocalizationMessage`, `LocalizationCatalog`, `LocalizationHandle`, `WidgetTextSelection`, `WidgetTextEventBase`, `WidgetTextInputEvent`, `WidgetTextWheelEvent`, `WidgetTextKeyEvent`, `WidgetTextInteractionEvent`, `WidgetTypeData`, `WidgetTypeValue`, `WidgetTypeContext`, `WidgetTypeDef`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── uiHandle.ts ───────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SidebarTabBase { + /** + * Unique across every pack, so namespace it — `'mtb.assets'`, not + * `'assets'`. Registering an id twice throws rather than silently replacing + * the other pack's tab. + */ + readonly id: string + readonly title: string + /** + * An iconify class, e.g. `'icon-[lucide--activity]'`. Omit for no icon. + */ + readonly icon?: string + readonly tooltip?: string +} + +/** + * A tab the pack draws into a container itself. + * + * Framework-agnostic, and the only form available to a pack that ships + * hand-written ES modules with no build step — which is most of them. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MountedSidebarTab extends SidebarTabBase { + /** + * Fills the tab's panel. Called each time the tab becomes visible, so treat + * it as mount rather than as one-time setup, and put teardown in `destroy`. + */ + render(container: HTMLElement): void + /** Releases what `render` retained — listeners, timers, observers. */ + destroy?(): void +} + +/** + * A tab that is a Vue component, mounted and torn down by the host. + * + * The preferred form where a pack can build. It keeps reactivity, scoped + * styles and `onUnmounted`, and the host mounts and unmounts it. + * + * Per ADR 0005 the pack bundles its own Vue (~30KB gzipped) — there is no + * import map, so `import { defineComponent } from 'vue'` resolves at the + * pack's build time, not ours. That is a second Vue instance on the page, + * which the ADR weighed and accepted; nothing is shared across the boundary, + * so the two runtimes never touch. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface VueSidebarTab extends SidebarTabBase { + readonly component: VueComponent +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SidebarTabDef = MountedSidebarTab | VueSidebarTab + +/** A Vue component bundled by the pack. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type VueComponent = object + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogBase { + /** + * Unique across every pack, so namespace it. The host prefixes it with + * `extension-`, which keeps packs out of the internal dialog keyspace. + */ + readonly key: string + readonly title?: string +} + +/** A bounded keyboard event captured while a mounted dialog owns focus. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogKeyEvent { + readonly key: string + readonly code: string + readonly repeat: boolean + readonly altKey: boolean + readonly ctrlKey: boolean + readonly metaKey: boolean + readonly shiftKey: boolean + /** True for an input, textarea, select, or editable content target. */ + readonly editableTarget: boolean +} + +/** A dialog the pack draws into a container itself. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MountedDialog extends DialogBase { + render(container: HTMLElement): void + /** Receives dialog-scoped key events even before a child takes focus. */ + onKeyDown?(event: DialogKeyEvent): void | Promise + destroy?(): void +} + +/** A dialog that is a Vue component, mounted and torn down by the host. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface VueDialog extends DialogBase { + readonly component: VueComponent + readonly props?: Readonly> +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type DialogDef = MountedDialog | VueDialog + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogHandle { + close(): void +} + +export interface UiHandle { + /** + * Adds a tab to the sidebar. Returns a function that removes it again. + */ + addSidebarTab(def: SidebarTabDef): Unsubscribe + /** + * Shows a small readout in the top bar — a status, a count, a live metric. + * + * Replaces `app.menu.settingsGroup` and inserting an element next to + * `.comfy-settings-btn`. Declarative on purpose: the pack says what to show + * and the host renders it, in house style and at whatever size the viewport + * allows. Nothing here takes an element, a class or a style, which is what + * keeps the chrome ours to restyle. + * + * Returns a handle rather than an unsubscribe: for a value that changes, + * call `update({ text })`. A closure would not work — the host renders when + * reactive state changes and cannot see a plain function, so the readout + * would show its first value forever. + */ + addTopBarBadge(badge: BadgeContribution): ChromeItemHandle + /** + * Adds a button to the action bar. `run` is called on click. + * + * For a pack that also wants a keyboard shortcut or a palette entry, + * register a command and call it from `run`, rather than duplicating the + * behaviour in both places. + */ + addActionBarButton( + button: ButtonContribution + ): ChromeItemHandle + /** + * Opens a modal dialog. Returns a handle that closes it again. + * + * Replaces `app.ui.dialog` and the `new app.ui.dialog.constructor()` idiom. + * Several conversions hand-rolled a native `` or borrowed core's + * `.comfy-modal` class names instead — the latter couples a pack to markup + * we rename freely, so both are worth retiring. + */ + showDialog(def: DialogDef): DialogHandle + /** + * Shows a menu where the user clicked. + * + * `b.addMenuItem` is the node's own context menu — a different menu, on a + * different target, opened by the host. This is for a menu a pack raises + * itself: a lora row's Move Up / Remove, a chip that picks an output type. + * Four files hand-rolled it by constructing the renderer's menu class + * directly, which pins them to a renderer we intend to replace. + * + * Positioned from the event so the menu lands under the pointer, which is the + * only placement that reads as a context menu. Arrow keys traverse nested + * items, Enter or Tab selects one, and Escape closes the menu. + */ + showMenu(def: MenuDef): MenuHandle + /** + * Asks the user for a value. Resolves `undefined` if they cancel. + * + * Packs called `canvas.prompt(...)`, which draws a small field at the cursor + * — clicking a lora's strength to type a new one. That field belongs to the + * legacy canvas and the host itself no longer uses it; this is the prompt the + * host does use, so a pack keeps the capability and loses only the placement. + */ + prompt(def: PromptDef): Promise +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PromptDef { + /** What is being asked for — "Strength", "Label". */ + readonly label: string + readonly value?: string + readonly placeholder?: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuItemDef { + readonly label: string + /** Shown but not selectable. */ + readonly disabled?: boolean + /** A nested menu. Mutually exclusive with {@link run}. */ + readonly submenu?: readonly MenuItemDef[] + run?(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuDef { + readonly items: readonly MenuItemDef[] + /** Shown above the items. */ + readonly title?: string + /** The event that asked for the menu; it decides where the menu appears. */ + readonly event: MouseEvent +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuHandle { + close(): void +} + +// ─── widgetHandle.ts ───────────────────────────────────────────── + +// `null` is included because core's own `WidgetValue` has it and +// `addWidget('button', name, null, cb)` produced exactly that. Omitting it made +// a null value inexpressible through the published API, so a converted button's +// `widgets_values` entry changed and the saved workflow differed. +export type WidgetValue = string | number | boolean | object | undefined | null + +/** Options understood by core or by a widget type declared by the pack. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetOptions { + readonly [key: string]: unknown + readonly on?: string + readonly off?: string + readonly max?: number + readonly min?: number + readonly precision?: number + readonly read_only?: boolean + readonly step?: number + readonly step2?: number + readonly multiline?: boolean + readonly property?: string + readonly socketless?: boolean + readonly canvasOnly?: boolean + readonly hideInPanel?: boolean + readonly nodeType?: string + readonly serialize?: boolean + readonly values?: unknown + readonly iconClass?: string + readonly disabled?: boolean + readonly useGrouping?: boolean + readonly placeholder?: string + readonly showThumbnails?: boolean + readonly showItemNavigators?: boolean + readonly hidden?: boolean +} + +/** + * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract. + * + * Accessor methods rather than properties, so a read can be a store query and + * a write can dispatch a command. + */ +export interface WidgetHandle extends HandleCommon { + readonly name: string + readonly widgetType: string + + getValue(): T + /** + * Commits a value exactly as a user edit does: the value is written, a + * widget bound to a node property syncs it, the widget's callback chain and + * the node's `onWidgetChanged` run, and `graph.version` advances. This + * replaces the manual pair `widget.value = x; widget.callback?.(x)` — and + * the bare write too, because a write the rest of the system cannot see was + * never a feature, it was litegraph defaulting to inconsistency. + * + * Writing the current value again is a no-op, which is also what ends a + * cycle of handlers writing to each other. `on('change')` fires once per + * commit; `on('activate')` does not fire, because activate reports a user's + * act. + */ + setValue(value: WidgetValue): void + + /** + * The widgets core attached to this one — a seed's `control_after_generate`, + * a bounding box's components. + * + * `setHidden` already cascades through these, so hiding needs no call here. + * What does is reading one: a pack asks a seed's control widget whether it + * says `fixed` or `randomize` to know what the node will do next. + */ + linked(): readonly WidgetHandle[] + /** + * Replaces the controls attached to this widget. + * + * Core uses this relationship for compound inputs: hiding a seed also hides + * its `control_after_generate` picker. Packs build the same compound control + * when they add a random-seed button or an index policy, and assigning + * `linkedWidgets` directly was the only way to make conversion-to-input hide + * the whole unit. + * + * Every name must identify another widget on this node. Pass an empty array + * to clear the relationship. + */ + setLinked(names: readonly string[]): void + + isHidden(): boolean + /** + * Replaces the `type = 'converted-widget'` hack. Value is retained. + * + * Cascades to the widgets core attached to this one — a seed's + * `control_after_generate`, a bounding box's components. The legacy + * `hideWidget` helper this replaces recursed through `linkedWidgets`, and + * packs that lost the cascade were left with an orphaned control widget + * floating where its owner used to be. + */ + setHidden(hidden: boolean): void + getOptions(): Readonly | undefined + setOption(key: string, value: unknown): void + setLabel(label: string): void + + isDisabled(): boolean + setDisabled(disabled: boolean): void + isSerialized(): boolean + /** The height the host most recently allocated, or undefined before layout. */ + getHeight(): number | undefined + /** + * Pins the widget's height in graph units, instead of letting it share + * whatever space the node has spare. + * + * The node divides free height between every widget that does not state one, + * so a node carrying two mounted strips gave each half the node however + * small they were meant to be. `MountDef.height` does not do this — it sets + * the container's CSS height *inside* an allocation the renderer already + * chose, which is why a fixed strip still drifted. + * + * Replaces re-assigning `node.computeSize`, which is what packs did and + * which is not published. Omit it for a panel meant to fill the node: the + * growable path is the one that fills. + */ + setHeight(px: number): void + + /** + * Replaces capture-and-chain on `widget.callback`, which 1,000+ sites do and + * which silently drops an earlier pack's listener whenever one forgets to + * call through. Listeners here are additive and independent. + */ + on( + event: 'change', + listener: (value: WidgetValue, oldValue: WidgetValue) => void + ): Unsubscribe + on(event: 'removed', listener: () => void): Unsubscribe + /** + * The widget was activated — a button click, or a value committed. + * + * Buttons carry no value, so `change` can never fire for one and a button + * created through this API would otherwise be inert. Prefer `change` when you + * care about the value; use this when you care that the user acted — a + * programmatic `setValue` never fires it. + */ + on(event: 'activate', listener: (value: WidgetValue) => void): Unsubscribe + /** + * Contributes behavior to a host-owned multiline text editor without exposing + * its DOM. The event reports the live value and caret on each input, + * selection change, or wheel gesture; its write method preserves both the + * widget commit protocol and the requested selection. + */ + on( + event: 'textInteraction', + listener: (event: WidgetTextInteractionEvent) => void + ): Unsubscribe + /** + * The value is about to be written out, and may be replaced for this + * destination only. + * + * This is what `widget.serializeValue` did, and the reason it is back: a + * static `serialize` flag can only *suppress* a value, and a whole class of + * packs needs to *supply* a different one. rgthree's Seed keeps the sentinel + * `-1` in the saved workflow and sends the rolled seed; pysssss' PresetText + * expands `@name` into the queued prompt while the user keeps seeing the + * reference; Impact Pack embeds image data the canvas never shows. + * + * `context` says which destination is being built, because those packs want + * to change one and not the other: + * + * - `'workflow'` — the file the user saves. + * - `'prompt'` — the queued API payload the backend executes. + * - `'embedded'` — the copy of the workflow that travels with that prompt + * and is written into the output image. Distinct from `'workflow'` + * because a pack may want the image to reproduce the run while the saved + * file keeps its sentinel: rgthree's Seed saves `-1` but embeds the seed + * it actually rolled, so dragging the PNG back in reproduces it. + * + * A handler that ignores `context` changes all three. + * + * Calling `setSerializedValue` replaces the value for this write only; the + * widget itself is untouched, so the user still sees what they typed. Last + * handler to call it wins. + */ + on( + event: 'beforeSerialize', + listener: (event: WidgetSerializeEvent) => void + ): Unsubscribe +} + +/** Where a value is being written, and the chance to change it. */ +export interface WidgetSerializeEvent { + readonly context: 'workflow' | 'prompt' | 'embedded' + /** What would be written if no handler intervened. */ + readonly value: WidgetValue + setSerializedValue(value: WidgetValue): void +} + +export type Unsubscribe = () => void + +/** + * A widget whose body the pack renders itself. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface MountDef { + readonly name: string + /** + * Fills the mounted container. Called once, with an element already attached + * to the node. + * + * `value` holds meaningful serialized state only when `defaultValue` was + * given. A decorative mount receives the same accessor for one render shape, + * but should not use it as storage. + */ + render(container: HTMLElement, value: MountedValue): void + /** Releases anything `render` retained — listeners, timers, observers. */ + destroy?(): void + /** Reserved height in graph units. Omit to size to content. */ + readonly height?: number + /** Set false to keep the element rendered at low zoom. Defaults to true. */ + readonly hideOnZoom?: boolean + readonly hidden?: boolean + /** + * Whether the value is written into the saved workflow. + * + * Defaults to `true` when `defaultValue` makes this a value-holding control, + * and to `false` for a decorative mount. + */ + readonly serialize?: boolean + /** + * Whether the value is sent in the API prompt. Defaults to `serialize`. + * + * These are two different flags in litegraph — `widget.serialize` gates the + * saved workflow, `options.serialize` gates the prompt — and collapsing them + * into one boolean made two states unsayable. "Saved but not sent" is the + * one packs need: it is exactly what the legacy + * `addDOMWidget(…, { serialize: false })` did, and a readout that a node + * fills in from its own execution result belongs in the workflow but has no + * business appearing as an input on the next queue. + * + * Set it apart from `serialize` only when the two genuinely differ. + */ + readonly sendToPrompt?: boolean + /** + * Makes this a value-holding widget rather than decoration. + * + * Without it a mount is a drawing: it can occupy a `widgets_values` slot but + * has nothing to put in it, so a colour picker or a text box converted onto + * `mount` kept its position and silently lost what the user typed. Supplying + * a default gives the widget a real cell, reachable through `render`'s second + * argument. + */ + readonly defaultValue?: MountedData +} + +/** What a mounted control can hold. @knipIgnoreUnusedButUsedByCustomNodes */ +export type MountedData = string | number | boolean | object | null + +/** + * Reading and writing a mounted widget's value. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface MountedValue { + get(): MountedData + set(value: MountedData): void + /** Notified when the value changed elsewhere — a workflow load. */ + onChange(listener: (value: MountedData) => void): Unsubscribe +} + +/** + * A pointer event on the widget's own canvas, in the same units `draw` uses. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface CanvasPointerEvent { + /** Distance from the canvas's left edge, in CSS pixels. */ + readonly x: number + /** Distance from its top edge, in CSS pixels. */ + readonly y: number + /** The DOM event, for modifier keys, `button`, and `preventDefault()`. */ + readonly event: PointerEvent +} + +/** + * The colours a pack should draw its own controls in. + * + * Published because we told packs to draw. A widget that hardcodes its palette + * looks wrong the moment the user switches theme, and the alternative — reading + * `LiteGraph.WIDGET_BGCOLOR` and friends — is a renderer constant we intend to + * delete. These are the design system's own tokens, resolved from the widget's + * computed style, so they follow the theme without the pack knowing which one + * is active. + * + * Named by intent rather than by token, because the token names will churn and + * a pack should not have to follow. Re-read on every draw, so a theme switch + * needs nothing from the pack. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface CanvasTheme { + /** A control's background. */ + readonly surface: string + /** The same under the pointer. */ + readonly surfaceHovered: string + /** A control's outline. */ + readonly border: string + /** A label. */ + readonly text: string + /** A value, a unit, anything the label outranks. */ + readonly textSecondary: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CanvasDef { + readonly name: string + /** Reserved height in pixels. Omit to size to the node's width. */ + readonly height?: number + draw( + context: CanvasRenderingContext2D, + size: readonly [number, number], + theme: CanvasTheme, + value: MountedValue | undefined + ): void + /** + * The pointer went down on this widget. + * + * Coordinates are relative to the canvas and in the same units `draw` + * receives, so a hit test written against the drawing works unchanged — + * which is the point. A pack that drew its own controls keeps both the + * drawing and the hit testing; only the surface changes, from the host's + * canvas to its own. + * + * The primary button is taken: it stops here rather than also reaching the + * node, or adjusting a slider would drag the node underneath it. Middle and + * right are left alone, so panning and the context menu still work over the + * widget. + * + * The pointer is captured for the gesture, so a drag that leaves the widget + * still reports moves and the release. + */ + onPointerDown?(event: CanvasPointerEvent): void + /** Moves during a drag, and hover when no button is down. */ + onPointerMove?(event: CanvasPointerEvent): void + onPointerUp?(event: CanvasPointerEvent): void + /** + * The secondary button went down on this widget. + * + * Right-click is left alone by {@link onPointerDown} so the node's own + * context menu keeps working over a widget, which is right by default and + * wrong for a widget that has its own menu — a lora row wants Move Up, Move + * Down, Remove. Declaring this claims the gesture: the browser menu is + * suppressed and the node's does not open. + */ + onContextMenu?(event: CanvasPointerEvent): void + /** + * Makes the surface hold a value rather than only draw one. + * + * Without it a drawn control that stores something has to be two widgets — a + * hidden value widget and a surface — and two widgets cannot occupy the one + * position the original had. That is not a tidiness point: `serialize` writes + * at each widget's own index and leaves a hole where a non-serializing widget + * sits, so the pair has to be ordered value-first to keep the saved array + * intact, and a pack that gets that wrong writes a null into every workflow + * the node has ever appeared in. It moved rgthree's Power Puter chip row + * below its code box. + * + * `draw` receives the current value as its fourth argument. + */ + readonly defaultValue?: MountedData + /** Whether the value reaches the saved workflow. See {@link MountDef.serialize}. */ + readonly serialize?: boolean + /** Whether the value reaches the API prompt. See {@link MountDef.sendToPrompt}. */ + readonly sendToPrompt?: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CanvasHandle { + readonly widget: WidgetHandle + /** Redraws now. Call when the data behind the drawing changed. */ + redraw(): void +} + +/** Everything needed to create a widget. */ +export interface WidgetDef { + readonly type: string + readonly name: string + readonly value?: WidgetValue + readonly options?: WidgetOptions + /** Display-only widgets — replaces the readOnly/opacity DOM fiddling. */ + readonly disabled?: boolean + readonly hidden?: boolean + /** + * Whether the value is written into the saved workflow. + * + * Replaces `widget.serializeValue = async () => {}`, the idiom packs use to + * keep a derived readout out of `widgets_values`. Orthogonal to `hidden`. + */ + readonly serialize?: boolean +} + +export interface WidgetCollection { + readonly length: number + get(name: string): WidgetHandle | undefined + at(index: number): WidgetHandle | undefined + all(): readonly WidgetHandle[] + names(): readonly string[] + /** + * Replaces splice/assign reordering. `names` must be a permutation of the + * current names — a partial list throws rather than silently dropping + * widgets, which is how the array-splice idiom lost them. + */ + reorder(names: readonly string[]): void + move(name: string, toIndex: number): void + /** + * Creates a widget on this node. + * + * The counterpart to `remove` — packs that rebuild a readout widget do + * remove-then-create, and without this only half the operation has a + * destination, which makes the conversion cosmetic. + */ + add(def: WidgetDef): WidgetHandle + /** + * Mounts an element on the node and hands it to the pack to fill. + * + * The replacement for `addDOMWidget`, and the destination for hand-painted + * canvas controls. Across kjnodes' canvas editors the drawing is rectangles, + * images, straight lines and text — all DOM primitives — but a pack that + * wants to keep its existing `ctx` code can append a `` to the + * container and carry it over unchanged. + * + * The gain is not the drawing, it is the input: these editors hand-roll + * hit-testing against bounding boxes because canvas gives them nothing to + * attach a listener to. Mounted in the DOM, pointer events land on the + * element and most of that code goes away. + */ + mount(def: MountDef): WidgetHandle + /** + * A per-node drawing surface, and the destination for `onDrawForeground`. + * + * Works under both renderers without the pack knowing which it is on: the + * canvas is a DOM element, which the legacy renderer positions over the + * graph canvas and Nodes 2.0 renders directly. That is the whole reason it + * is a mounted element rather than a hook into the graph's own context — + * drawing into the shared context is what ties a pack to the old renderer. + * + * `draw` is called on mount, on resize, and whenever `redraw()` is called. + */ + canvas(def: CanvasDef): CanvasHandle + remove(name: string): boolean + [Symbol.iterator](): Iterator +} + +export interface ComboPreviewRegistration { + /** Namespaced registration id. */ + readonly id: string + /** Managed model catalogues searched in order. */ + readonly modelCategories: readonly ( + | 'loras' + | 'checkpoints' + | 'unet' + | 'diffusion_models' + )[] + /** Model filename suffixes that activate this policy. */ + readonly extensions: readonly ( + | 'safetensors' + | 'sft' + | 'pt' + | 'ckpt' + | 'gguf' + )[] + /** Host-owned adjacent-preview lookup policy. */ + readonly candidatePolicy: 'adjacent-model-preview-v1' + /** Preview media types the host may display. */ + readonly media: readonly ( + | 'image/png' + | 'image/webp' + | 'image/jpeg' + | 'video/mp4' + | 'video/webm' + )[] +} + +export interface ComboPreviewAssignment { + /** Managed model catalogue containing `modelValue`. */ + readonly category: 'loras' | 'checkpoints' | 'unet' | 'diffusion_models' + /** Logical model filename from the managed combo; never a host path. */ + readonly modelValue: string + /** Graph node whose host-owned output image is used as the preview. */ + readonly sourceNodeId: string + /** Exact image in that node's current host-owned output list. */ + readonly imageIndex: number + readonly policy: 'adjacent-model-preview-v1' +} + +export interface WidgetsHandle { + /** + * Adds a declarative preview policy to host-owned combo option menus. + * The host resolves managed assets and renders the hover surface; the pack + * receives neither filesystem paths nor media URLs. + */ + registerComboPreview(definition: ComboPreviewRegistration): Unsubscribe + /** + * Re-encodes one managed graph output as an adjacent managed-model preview. + * The host resolves both resources; the pack receives no path or image bytes. + */ + assignComboPreview(assignment: ComboPreviewAssignment): Promise +} + +export type LocalizationMessage = + | string + | null + | { readonly [key: string]: LocalizationMessage } + +export interface LocalizationCatalog { + /** Native vue-i18n-shaped messages such as main/nodeDefs/nodeCategories. */ + readonly messages: Readonly> + /** Exact-source fallback translations used only at host-owned render points. */ + readonly phrases?: Readonly> +} + +export interface LocalizationHandle { + /** + * Contributes one bounded catalog for a host-supported locale. The host + * owns merging, rendering, precedence, and cleanup; no DOM access is given. + */ + registerCatalog(locale: string, catalog: LocalizationCatalog): Unsubscribe +} + +// ─── widgetTextInteraction.ts ──────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextSelection { + readonly start: number + readonly end: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextEventBase { + readonly value: string + readonly selection: WidgetTextSelection + /** Positions a host menu at the text editor without exposing its element. */ + readonly menuEvent: MouseEvent + /** Commits through the widget protocol and optionally restores the caret. */ + setValue(value: string, selection?: WidgetTextSelection): void + focus(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextInputEvent extends WidgetTextEventBase { + readonly kind: 'input' | 'selection' +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextWheelEvent extends WidgetTextEventBase { + readonly kind: 'wheel' + readonly deltaY: number + readonly ctrlKey: boolean + /** Claims the wheel gesture so the canvas does not pan or zoom. */ + preventDefault(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextKeyEvent extends WidgetTextEventBase { + readonly kind: 'keydown' + readonly key: string + readonly ctrlKey: boolean + readonly altKey: boolean + readonly shiftKey: boolean + readonly metaKey: boolean + readonly repeat: boolean + preventDefault(): void + stopPropagation(): void +} + +/** + * An interaction with a host-owned multiline text editor. + * + * This is the renderer-independent replacement for reaching through + * `widget.inputEl`: packs can inspect the live caret, offer a menu through + * `menuEvent`, replace text, and implement selection-based wheel edits without + * receiving the host's element or markup. + */ +export type WidgetTextInteractionEvent = + | WidgetTextInputEvent + | WidgetTextWheelEvent + | WidgetTextKeyEvent + +// ─── widgetTypes.ts ────────────────────────────────────────────── + +/** What a pack-declared widget can hold. */ +export type WidgetTypeData = string | number | boolean | object | null + +/** + * Reading and writing the widget's value, for the renderer to bind to. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface WidgetTypeValue { + get(): WidgetTypeData + set(value: WidgetTypeData): void + /** Notified when the value changes for any other reason — a workflow load. */ + onChange(listener: (value: WidgetTypeData) => void): Unsubscribe +} + +export interface WidgetTypeContext { + /** A frozen snapshot of the input declaration's current options. */ + getOptions(): Readonly> + /** + * Runs while the widget's owning node belongs to a graph. + * + * Widget constructors run before a node has an id or graph, so a node handle + * cannot be supplied directly to `render`. The listener runs after the node + * joins a graph and tears down when it leaves. + */ + onNodeReady(listener: (node: NodeHandle) => Unsubscribe | void): Unsubscribe +} + +export interface WidgetTypeDef { + /** Used when the definition supplies none. */ + readonly defaultValue?: WidgetTypeData + /** Height in pixels. Omit to size to content. */ + readonly height?: number + /** Smallest width the control needs, in pixels. */ + readonly minWidth?: number + /** Smallest height the control needs, in pixels. */ + readonly minHeight?: number + /** + * Whether the value is saved and sent. Defaults to `true`: this widget holds + * a real input value, unlike a mounted decoration. + */ + readonly serialize?: boolean + /** + * Fills the container. Return a teardown if the control owns listeners, + * timers or observers. + * + * `name` is the input being rendered — controls commonly label themselves + * with it, which a type-level renderer otherwise has no way to know. + */ + render( + container: HTMLElement, + value: WidgetTypeValue, + name: string, + context: WidgetTypeContext + ): Unsubscribe | void +} +```` diff --git a/custom-nodes/v2/reference/javascript-workflow.mdx b/custom-nodes/v2/reference/javascript-workflow.mdx new file mode 100644 index 000000000..fc6f3b48d --- /dev/null +++ b/custom-nodes/v2/reference/javascript-workflow.mdx @@ -0,0 +1,88 @@ +--- +title: "JavaScript workflow API" +description: "Opening workflow data and applying host text replacements." +--- + + + This page is generated from the authoritative declaration file. Do not edit it by hand. Contract SHA-256: 152c7fab547f. + + +This reference contains 5 exported declarations: `WorkflowData`, `WorkflowImportContext`, `WorkflowImportResult`, `WorkflowImporter`, `WorkflowHandle`. + +Download the complete contract from the docs source: [TypeScript declaration](https://github.com/Comfy-Org/docs/blob/main/public/custom-nodes-sdk/v2/comfy-api.d.ts). + +## Contract + +````typescript +// ─── workflowHandle.ts ─────────────────────────────────────────── + +/** Parsed ComfyUI workflow JSON. */ +export type WorkflowData = Readonly> + +export interface WorkflowImportContext { + readonly name: string + readonly type: string +} + +export type WorkflowImportResult = + | { readonly workflow: WorkflowData | string } + | { readonly prompt: Readonly> | string } + +export interface WorkflowImporter { + /** Namespaced and unique within the pack. */ + readonly id: string + readonly mimeTypes?: readonly string[] + readonly extensions?: readonly string[] + /** Per-file limit; the host-wide ceiling is 16 MiB. */ + readonly maxBytes: number + enabled?(): boolean | Promise + parse( + bytes: Uint8Array, + context: WorkflowImportContext + ): + | WorkflowImportResult + | null + | undefined + | Promise +} + +export interface WorkflowHandle { + /** Replaces the active document with parsed ComfyUI workflow JSON. */ + open(data: WorkflowData): Promise + /** Returns the current saved-format workflow, bounded to 8 MiB. */ + snapshot(): Promise + /** Registers a bounded worker-side parser for host-opened or dropped files. */ + registerImporter(importer: WorkflowImporter): Unsubscribe + /** Expands the active document's `%date:...%` and `%Node.widget%` tokens. */ + applyTextReplacements(value: string): string + /** + * The active document's identity: a process-local id minted fresh each time + * a workflow finishes loading — including a second load of the same file, + * which gets a different id from the first. `undefined` before the first + * workflow has loaded this page load. + * + * Distinct from the workflow's own saved identity (its file path, or the + * `id` written into the workflow JSON): that one is meant to survive a + * reload and compare equal across sessions. This one is the opposite by + * design — it exists so a pack can tell "the document I was looking at got + * replaced" from "the document I was looking at got edited", which + * comparing graph contents cannot do, since editing IS mutating the graph + * contents of the very document that is still current. + * + * Equivalent to `current()?.id`, and kept because reading the id is the + * common case and does not need a handle. + */ + documentId(): string | undefined + /** + * The document on screen, or `undefined` before one is open. + * + * A handle rather than the bare id when a pack needs to know what it is + * looking at — the name to label its own UI, whether there are unsaved + * edits, and whether a document it stored state for is still open. + * + * Read-only: opening has its own explicit call, and saving, closing and + * renaming belong to the user. + */ + current(): DocumentHandle | undefined +} +```` diff --git a/custom-nodes/v2/testing.mdx b/custom-nodes/v2/testing.mdx new file mode 100644 index 000000000..95e3d646b --- /dev/null +++ b/custom-nodes/v2/testing.mdx @@ -0,0 +1,81 @@ +--- +title: "Test a V2 pack" +description: "Verify frontend extension behavior, workflows, serialization, package layout, and API compatibility." +--- + +Test a V2 node the same way users experience it: add it to a workflow, connect realistic inputs, run it, save the workflow, and load it again. + +MAGIC PATCH creates a migration starting point. Its output still needs the pack's ordinary behavior and compatibility tests before release. + +## Test node behavior + +For every node the extension changes, verify: + +- the node appears with the expected name, category, and badges; +- each slot accepts and rejects the correct connection types; +- widget defaults, minimums, maximums, and optional widgets behave correctly; +- custom widgets, panels, previews, and menus render and update as expected; +- lifecycle hooks run on creation, configuration, and removal without leaving stale subscriptions. + +Use unit tests for pure logic. Mock `comfy` handles and services when a full ComfyUI host is unnecessary. + +Add browser tests for at least one realistic workflow that exercises the extension end to end. + +## Test workflows and serialization + +Save and reload representative workflows. Confirm that: + +- node IDs and widget values survive a round trip; +- prompt serialization contains the expected values; +- links reconnect to the correct slots; +- renamed or deprecated nodes migrate as intended; +- embedded workflow data in outputs remains usable when the pack supports it. + +Saved workflows, queued prompts, and workflows embedded in outputs are separate serialization destinations. Test each destination your widgets customize. + +## Test JavaScript extensions + +Exercise definition hooks, widget events, graph edits, menus, commands, notifications, storage, and backend requests used by the pack. + +The frontend repository's [`examples/node-api` example packs](/custom-nodes/v2/javascript/example-packs) +provide executable patterns and browser-test coverage for these behaviors. Use +them as a starting point for pack-specific tests rather than relying only on +static snippets. + +Check that compound graph edits create the intended undo step and that removed nodes or widgets do not leave stale subscriptions behind. + +For routes that require host authentication, use `comfy.backend.fetch()` in the test path so a bare `fetch()` does not hide an authentication problem. + +## Test the pack layout + +For a converted pack, the installation contains two complete alternative pack roots: + +- the existing V1 distribution at the top level; +- the V2 replacement distribution under `v2/`. + +Test that the root V1 entrypoint still loads and that the V2 loader finds `v2/__init__.py` and serves the frontend module from `v2/web/`. V2 frontend files, dependencies, metadata, and assets must resolve from the V2 tree without falling back to a similarly named V1 file. + +A packaging test should reject an incomplete `v2/` tree, flattened V2 files, missing required assets, or an accidental `v2/v2/` directory. + +## Check API compatibility + +- Type-check JavaScript or TypeScript against `comfy-api.d.ts`. +- Import only published modules. +- Keep a released pack on a supported major with `comfy.forMajor()`. +- Regenerate reference docs when the declaration snapshot changes. + +## Test supported environments + +Run the main V2 workflow in every ComfyUI version and renderer the pack claims to support. The schemas, refs, outputs, and frontend behavior should remain consistent. + +If part of the pack deliberately reaches into private Python backend or web-page internals, label that feature clearly and verify that the rest of the pack still behaves when the feature is unavailable. + +## Documentation checks + +Before release, verify: + +- setup instructions work from a clean pack install; +- every copied example parses or type-checks; +- navigation and internal links resolve; +- search finds the pack's important classes, methods, and services; +- code blocks and tables are readable in light and dark mode. diff --git a/custom-nodes/v2/troubleshooting.mdx b/custom-nodes/v2/troubleshooting.mdx new file mode 100644 index 000000000..792c10f2a --- /dev/null +++ b/custom-nodes/v2/troubleshooting.mdx @@ -0,0 +1,47 @@ +--- +title: "Troubleshooting" +description: "Fix common V2 pack layout, JavaScript handle, widget, serialization, and capability problems." +--- + +## The host loads V1 instead of V2 + +Confirm that the pack still has its V1 distribution at the installation root and a complete replacement V2 distribution directly under `v2/`. The V2 entrypoint must be `v2/__init__.py`, not a replacement root entrypoint and not `v2/v2/__init__.py`. + +Repeat the pack's required module, frontend, dependency, metadata, and asset layout inside `v2/`. The V2 loader treats that directory as the pack root and does not merge or recursively discover fallback files from the root V1 tree. + +## A required capability is unavailable + +`comfy.require()` throws when the host does not implement or grant the capability. Probe optional behavior with `comfy.supports()` and degrade cleanly when it is absent. Report the missing capability by name and explain the use case it prevents; the API can add new capabilities and extension points. A private application import is not a stable fallback. + +## A frontend handle reports `isDeleted` + +The node, widget, or document session ended. Release pack-owned state and reacquire a handle from the current graph or lifecycle event. Do not keep polling the deleted handle. + +## Two handles are not `===` + +They may come from different graph scopes or API instances. Use `comfy.sameEntity(a, b)`. Use `comfy.adopt()` when you need a node handle in the current API instance. + +## A backend request returns 401 + +Use `comfy.backend.fetch('/route')`. `backend.url()` only builds a string, and a bare browser `fetch()` does not receive host authentication. + +## A static asset returns 404 + +For a file next to the JavaScript module, resolve it relative to `import.meta.url`: + +```javascript +const stylesheet = new URL('./panel.css', import.meta.url) +``` + +Do not guess the installed pack directory. Use `backend.assetUrl()` only for a host-served absolute path the pack already knows. + +For a converted pack, also confirm that the asset exists inside the V2 distribution at the same relative location expected by the V2 module. A similarly named file in the root V1 tree is not the V2 asset. + +## A graph edit creates several undo steps + +Wrap synchronous related mutations in `comfy.graph.batch()`. Do not keep a batch open across asynchronous work. + +## A widget value is missing from saved workflows or prompts + +Check `serialize`, `sendToPrompt`, widget ordering, and `beforeSerialize` handling. Saved workflow, prompt, and embedded workflow are separate destinations. + diff --git a/custom-nodes/v2/versioning-capabilities.mdx b/custom-nodes/v2/versioning-capabilities.mdx new file mode 100644 index 000000000..9712cc22d --- /dev/null +++ b/custom-nodes/v2/versioning-capabilities.mdx @@ -0,0 +1,88 @@ +--- +title: "Versioning and capabilities" +description: "Track the frontend contract, probe optional behavior, and declare hard dependencies." +--- + +A pack selects a filesystem version, and the JavaScript API then reports its own contract version and capability grants. + +## Pack filesystem versions + +The pack's filesystem version is selected before either language API loads. A converted pack retains its complete V1 distribution at the pack root and stores its complete replacement V2 distribution under `v2/`. + +```text +my_pack/ +├── +└── v2/ + └── +``` + +The `v2/` directory is a replacement pack root, not a patch or overlay and not the Python API module name. It is generally copied from the top-level distribution before conversion. When selected, frontend modules inside it import `/comfy/api/v2.js`. Keep version-specific entrypoints, dependencies, assets, and metadata inside their respective distribution trees; do not rely on fallback to V1. + +## JavaScript versions + +Import the published module directly: + +```javascript +import { comfy } from '/comfy/api/v2.js' +``` + +`comfy.version` reports the contract as `major.minor`. A major change can remove behavior. A minor change only adds behavior. `comfy.forMajor(major)` keeps a pack on a supported major during a deprecation period. + +Do not branch on a version string when you need one feature. Backports and grant policy can make a version comparison inaccurate. + +## Probe a capability + +```javascript +if (comfy.supports('slots.connectedType')) { + installTypedConnectionBehavior() +} +``` + +`supports()` is cheap and does not throw. Its answer includes both host support and the current pack grant. + +Use `require()` when the extension has no useful behavior without the feature: + +```javascript +comfy.require('defs.extend') +``` + +Use `comfy.capabilities()` for diagnostics or a compatibility report, not to request everything the host offers. + + + Capability discovery does not grant authority. A pack should declare the narrow set it needs and continue without optional enhancements when possible. + + +## Request a missing capability + +The V2 API is intended to grow with real node-author needs. If a useful node cannot be implemented through the published surface, report the missing operation or extension point instead of building a permanent dependency on private ComfyUI internals. + +A useful capability request includes: + +- the user-visible behavior the node is trying to provide; +- the old API, private object, or monkeypatch currently used; +- why existing refs, context services, handles, hooks, or UI contributions are insufficient; +- whether the capability is required or an optional enhancement; +- a small example showing the desired author-facing API when practical. + +New capabilities should express the useful outcome without exposing unrelated application internals. Until one is available, keep the workaround clearly optional so the main V2 behavior does not depend on a private implementation detail. + +## Handle API generations + +Two handles can describe the same entity while coming from different API instances, graph scopes, or majors. Compare them with `comfy.sameEntity()`: + +```javascript +if (comfy.sameEntity(current, event.node)) { + updateStatus() +} +``` + +Use `comfy.adopt(handle)` to re-resolve a node handle into the current API instance. It returns `undefined` when the value is not a node handle or the entity is gone. + +## Compatibility policy for a pack + +1. Import `/comfy/api/v2.js` and require only hard dependencies. +2. Probe optional enhancements individually with `supports()`. +3. Keep a released pack on a supported major with `comfy.forMajor()`. +4. Treat an ungranted capability as unavailable rather than reaching for a private equivalent. +5. Never fall back to a private import, global object, path, or DOM selector. +6. Report a named API gap and its user-visible use case when the supported surface cannot express required behavior. diff --git a/docs.json b/docs.json index 79f6017a8..dd823ad26 100644 --- a/docs.json +++ b/docs.json @@ -3109,6 +3109,66 @@ "custom-nodes/js/javascript_examples" ] }, + { + "group": "Custom Nodes SDK V2 (JavaScript)", + "icon": "code", + "pages": [ + { + "group": "Getting started", + "icon": "rocket", + "pages": [ + "custom-nodes/v2/index" + ] + }, + { + "group": "Main concepts", + "icon": "lightbulb", + "pages": [ + "custom-nodes/v2/javascript/concepts" + ] + }, + { + "group": "Tutorial", + "icon": "graduation-cap", + "pages": [ + "custom-nodes/v2/javascript/tutorial" + ] + }, + { + "group": "How-to guides", + "icon": "list-check", + "pages": [ + "custom-nodes/v2/javascript/example-packs", + "custom-nodes/v2/javascript/migration-recipes", + "custom-nodes/v2/javascript/registration", + "custom-nodes/v2/javascript/definitions", + "custom-nodes/v2/javascript/graphs-nodes", + "custom-nodes/v2/javascript/slots-links", + "custom-nodes/v2/javascript/widgets-ui", + "custom-nodes/v2/javascript/execution", + "custom-nodes/v2/javascript/execution-services", + "custom-nodes/v2/testing", + "custom-nodes/v2/troubleshooting" + ] + }, + { + "group": "Reference", + "icon": "book", + "pages": [ + "custom-nodes/v2/reference-overview", + "custom-nodes/v2/versioning-capabilities", + "custom-nodes/v2/reference/javascript-core", + "custom-nodes/v2/reference/javascript-definitions", + "custom-nodes/v2/reference/javascript-documents-graphs", + "custom-nodes/v2/reference/javascript-execution", + "custom-nodes/v2/reference/javascript-settings-storage", + "custom-nodes/v2/reference/javascript-slots", + "custom-nodes/v2/reference/javascript-ui-widgets", + "custom-nodes/v2/reference/javascript-workflow" + ] + } + ] + }, "custom-nodes/i18n", { "group": "Migration Guides", @@ -13897,4 +13957,4 @@ "destination": "/tutorials/partner-nodes/wan/wan3-0" } ] -} \ No newline at end of file +} diff --git a/public/custom-nodes-sdk/v2/comfy-api.d.ts b/public/custom-nodes-sdk/v2/comfy-api.d.ts new file mode 100644 index 000000000..5c5c8131c --- /dev/null +++ b/public/custom-nodes-sdk/v2/comfy-api.d.ts @@ -0,0 +1,3548 @@ +/** + * The published ComfyUI custom-node API — the complete surface. + * + * Generated from src/platform/nodeApi. If a member is not here it does not + * exist: do not call it, and punt as api-gap naming what is missing. + * Reached from a converted pack as: + * + * import { comfy } from '/comfy/api/v2.js' + */ + +// ─── backendHandle.ts ──────────────────────────────────────────── + +export interface BackendHandle { + /** + * Absolute URL for a backend route, honouring however the host is served — + * a base path, a different port, a proxy. + */ + url(route: string): string + /** + * Absolute URL for a file the host serves, rather than an API route. + * + * Distinct from `url()` because that one addresses the API and prepends + * `/api`, so a static path built through it produced `/api/extensions/…`, + * which 404s. + * + * This is for a path the caller already knows absolutely. It is *not* the + * way a pack should reach its own neighbouring files: the host serves those + * from `/extensions//`, and that directory name is chosen when + * the pack is installed and can be renamed, so it is not knowable from + * source. `new URL('x.css', import.meta.url)` resolves against the module's + * real location and stays correct. One pack ships two spellings of its own + * directory with an `onerror` fallback between them, which is what guessing + * costs. + */ + assetUrl(route: string): string + /** + * Identifies this frontend connection to a pack's own backend route. + * Undefined until the backend establishes the connection; do not persist it. + */ + sessionId(): string | undefined + /** + * Fires when {@link sessionId} becomes a different value. + * + * A pack that keys ephemeral server-side work by session — a scratch + * directory, a warmed model, a subscription — needs to know its old key is + * dead. The id changes on the first connection and again whenever the socket + * reconnects under a new identity, and the work filed under the previous one + * is no longer addressable. + * + * The session is not the user, the workflow or the node. It does not survive + * a reload, and storing it in any of those is how a pack ends up reading + * another tab's scratch state. + */ + onSessionChanged( + listener: (sessionId: string | undefined) => void + ): Unsubscribe + /** + * Subscribes to a backend message. The name is whatever the backend emits; + * `detail` is its payload, unparsed. + */ + on(event: string, listener: (detail: unknown) => void): Unsubscribe + /** + * Calls a backend route with the host's own credentials attached. + * + * `url()` only builds a string, so a pack calling `fetch()` on it sends an + * unauthenticated request — fine for a public route, a 401 when host authentication is required. + * Packs ship their own Python routes and were reaching for `api.fetchApi` + * precisely to inherit the session; this is that, and nothing more. + * + * The route is API-relative and must start with `/`, as `url()` requires. + */ + fetch(route: string, init?: RequestInit): Promise +} + +// ─── chromeContributions.ts ────────────────────────────────────── + +export interface BadgeContribution { + /** Namespaced, e.g. `Crystools.monitor`. Registering the same id twice throws. */ + readonly id: string + readonly text: string + readonly label?: string + readonly variant?: 'info' | 'warning' | 'error' + /** An iconify or PrimeIcons class, e.g. `pi-chart-bar`. */ + readonly icon?: string + readonly tooltip?: string +} + +/** What a pack keeps after contributing something to the chrome. */ +export interface ChromeItemHandle { + /** Changes what is shown. Only the fields given are replaced. */ + update(changes: Partial>): void + remove(): void +} + +export interface ButtonContribution { + readonly id: string + readonly icon: string + readonly label?: string + readonly tooltip?: string + /** + * The click. The event is passed because packs branch on modifiers — one + * opens its panel in a sized window on shift-click — and without it that + * behaviour has nothing to read. + */ + run(event: MouseEvent): void +} + +// ─── boundedFiles.ts ───────────────────────────────────────────── + +export interface FilePickOptions { + readonly extensions?: readonly string[] + readonly mimeTypes?: readonly string[] + /** Maximum accepted file size. The host-wide ceiling is 16 MiB. */ + readonly maxBytes: number +} + +export interface FilePickManyOptions extends FilePickOptions { + /** Maximum number of selected files. The host-wide ceiling is 50. */ + readonly maxFiles: number + /** Maximum aggregate payload. The host-wide ceiling is 256 MiB. */ + readonly maxTotalBytes: number +} + +export interface PickedFileData { + /** Basename only; no host path is exposed. */ + readonly name: string + readonly type: string + readonly bytes: Uint8Array +} + +export interface FileDownloadOptions { + /** Safe basename only. */ + readonly name: string + readonly mimeType: string + /** At most 16 MiB. */ + readonly bytes: Uint8Array +} + +export interface FilesHandle { + /** Opens one explicit host file picker; cancellation resolves undefined. */ + pick(options: FilePickOptions): Promise + /** Opens one bounded multi-file picker; cancellation resolves an empty list. */ + pickMany(options: FilePickManyOptions): Promise + /** Asks the host to download one bounded in-memory file. */ + download(options: FileDownloadOptions): Promise +} + +// ─── cryptoHandle.ts ───────────────────────────────────────────── + +export interface AesCbcEncryptOptions { + readonly key: Uint8Array + readonly iv: Uint8Array + readonly plaintext: Uint8Array +} + +export interface AesCbcDecryptOptions { + readonly key: Uint8Array + readonly iv: Uint8Array + readonly ciphertext: Uint8Array +} + +export interface HmacSha256Options { + readonly key: Uint8Array + readonly data: Uint8Array +} + +export interface VerifyHmacSha256Options extends HmacSha256Options { + readonly signature: Uint8Array +} + +/** Fixed canonical primitives; no caller-selected algorithms or retained keys. */ +export interface CryptoHandle { + aesCbcEncrypt(options: AesCbcEncryptOptions): Promise + aesCbcDecrypt(options: AesCbcDecryptOptions): Promise + hmacSha256(options: HmacSha256Options): Promise + verifyHmacSha256(options: VerifyHmacSha256Options): Promise +} + +// ─── integrationsHandle.ts ─────────────────────────────────────── + +export interface OllamaListModelsOptions { + /** Exact loopback Ollama origin or an `ollama://name` admin profile. */ + readonly endpoint: string +} + +export interface OllamaIntegrationHandle { + listModels(options: OllamaListModelsOptions): Promise +} + +/** Vendor pass-throughs have a weaker stability promise than generic APIs. */ +export interface IntegrationsHandle { + readonly ollama: OllamaIntegrationHandle +} + +// ─── closedProxy.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PropSpec { + get(target: TTarget): unknown + set?(target: TTarget, value: unknown): void + /** Appended to the error when a pack assigns to a read-only property. */ + readonlyHint?: string +} + +export interface HandleSpec { + /** Used in errors and `Symbol.toStringTag`, e.g. 'node'. */ + readonly kind: string + readonly props: Readonly>> + readonly methods?: Readonly< + Record unknown> + > + /** + * Methods that also need the handle's own id. + * + * A widget target is just the widget: it holds no reference back to its + * node, by design, so a method that has to name a sibling cannot find one + * from the target alone. Separate from `methods` so the common signature + * stays two arguments. + */ + readonly idMethods?: Readonly< + Record unknown> + > + /** + * Props that remain readable after deletion. Identity only — an id or type is + * still useful for logging and cleanup once the entity is gone. + */ + readonly identityProps?: readonly string[] +} + +/** Present on every handle. Never throws, even when the entity is gone. */ +export interface HandleCommon { + readonly isDeleted: boolean +} + +export interface HandleToken { + readonly kind: string + readonly id: string +} + +// ─── comfyApi.ts ───────────────────────────────────────────────── + +export interface Comfy { + /** + * `major.minor`. Prefer `supports()` over comparing this — a capability + * survives being backported or reordered across minors; a version comparison + * does not. + */ + readonly version: string + /** Breaking-change generation. Incremented only when something is removed. */ + readonly major: number + /** + * Cheap, never throws. The supported way to branch. + * + * Answers whether this host can do something, under the grant it is running + * with. It is not a permission request: asking does not obtain authority, and + * a pack never enumerates capabilities to be allowed to run. + */ + supports(capability: string): boolean + /** Asserts a capability, with an actionable error naming it. */ + require(capability: string): void + /** Every capability this host provides. */ + capabilities(): readonly string[] + /** + * Pins to a specific major. + * + * A major stays available until it is announced for removal and withdrawn + * through the normal phased deprecation process, so a pack written against + * one keeps working across that period rather than breaking on a release. + */ + forMajor(major: number): Comfy + + /** + * True when two handles refer to the same entity, whatever major, API + * instance or graph scope produced them. + * + * `===` is only reliable for handles from the same instance, the same major + * AND the same scope. Scope is the one most likely to catch a pack out: a + * node reached through `comfy.graph` while it is on screen and the same node + * reached through `graph.subgraphs()` or through a document-scoped + * `onNodeChanged` come from different handle caches, so they are equal here + * and not equal under `===`. Use this whenever a handle may have come from + * another pack, from an event, or from a graph other than the visible one. + */ + sameEntity(a: unknown, b: unknown): boolean + + /** + * Re-resolves a handle from any major or instance into one of this instance's + * own. Returns `undefined` if it is not a handle, or its entity is gone. + */ + adopt(handle: unknown): NodeHandle | undefined + + readonly graph: GraphHandle + /** Node definitions, and the replacement for `beforeRegisterNodeDef`. */ + readonly defs: DefRegistry + /** Declaring, reading and writing pack settings. */ + readonly settings: SettingsHandle + /** + * Per-user persistent storage for documents the pack's users author — + * templates, presets, saved prompts. Server-side, so it follows the user + * between machines. + */ + readonly storage: StorageHandle + /** Bounded, host-sampled hardware metrics. */ + readonly system: SystemHandle + /** The sanctioned slice of app chrome — sidebar tabs. */ + readonly ui: UiHandle + /** Host-owned facilities shared by widget implementations. */ + readonly widgets: WidgetsHandle + /** Bounded declarative locale catalogs rendered by host-native i18n. */ + readonly localization: LocalizationHandle + /** Commands, their keybindings, and notifications. */ + readonly commands: CommandsHandle + /** Backend URLs and messages, including a pack's own events. */ + readonly backend: BackendHandle + /** Loading a parsed workflow into a new active document. */ + readonly workflow: WorkflowHandle + /** Explicit, bounded host file selection and download. */ + readonly files: FilesHandle + /** Fixed host cryptographic primitives available to pack UI workers. */ + readonly crypto: CryptoHandle + /** Bounded vendor-specific facilities. */ + readonly integrations: IntegrationsHandle + /** + * The editor is already mid-gesture — dragging a link, resizing a node, + * dragging a widget. A pack running its own pointer gesture must stand down + * while this is true. + */ + isInteracting(): boolean + /** + * Observes nodes being moved, under either renderer. + * + * For building an editing gesture — swap, insert-on-link, shake-to-detach. + * A pack that moves nodes itself will see its own writes, so guard re-entry. + */ + onNodeMoved(listener: (event: NodeMoveEvent) => void): Unsubscribe + /** + * A drag finished; every node it moved. + * + * Where an editing gesture commits — swap the pair, insert into the link + * under the cursor. **Nodes 2.0 only**: the legacy canvas renderer publishes + * no drag lifecycle, so this never fires under it. + */ + onNodeDragEnd(listener: (nodes: readonly NodeHandle[]) => void): Unsubscribe + /** + * The view panned, zoomed or was resized. + * + * For keeping something anchored to a node in sync — ask + * `node.getScreenRect()` again when this fires. Carries no payload: where a + * node is belongs to the node, and the transform belongs to the renderer. + */ + onViewportChanged(listener: () => void): Unsubscribe + /** + * A node changed — its mode, title, colour or shape. + * + * For observing nodes the pack does not own. rgthree's relay polls every + * 500ms and installs a `defineProperty` trap on `mode` because nothing + * reports it; this is that signal. + * + * One stream rather than a subscription per node, deliberately: node + * identity does not survive undo, reload or re-entering a subgraph, so + * anything keyed by the object stops firing silently, and keying by id + * instead never gets collected. Filter by `event.node.id`. + * + * Only fields the host tracks are reported. Position is not among them — it + * changes per frame during a drag and is served by {@link onNodeMoved}. + * + * Reports the graph on screen unless `scope: 'document'` asks for the root + * graph and every subgraph definition as well. A pack that computes from + * other nodes wants `'document'`: a relay in a subgraph the user has + * navigated away from otherwise stops recomputing while still asserting its + * last answer. Each event names the graph it came from, and resolves its node + * there — ids repeat across definitions, so `event.node.id` alone is not a + * key. + */ + onNodeChanged( + listener: (event: NodeChangeEvent) => void, + options?: NodeChangeOptions + ): Unsubscribe + /** + * The application has finished starting: canvas, settings and graph all + * exist, and node definitions are registered. + * + * This is `registerExtension({ setup })`. A pack's module body is the `init` + * half — it runs before definitions register — so anything that needs the + * running app belongs here. Registering after the app has already started is + * fine; the listener is called on the next microtask rather than dropped, + * which is what makes this safe for a pack loaded lazily. + * + * Do not poll for the DOM instead. Several packs shipped a `waitForElements` + * loop to paper over the missing hook, and a poll that outlives its target + * is a leak that only shows up on someone else's machine. + */ + onReady(listener: () => void): Unsubscribe + /** Starting a run, and knowing when one starts. */ + queue: QueueHandle + /** + * The node the backend is executing, or `undefined` between runs. + * + * Packs tracked this from the raw `executing` message to badge the running + * node or follow it with the view. + */ + executingNode(): NodeHandle | undefined + /** Resolves a backend execution id, including a nested subgraph path. */ + executionNode(id: string): NodeHandle | undefined + /** Fires when {@link executingNode} changes, including to nothing. */ + onExecutingNodeChanged( + listener: (node: NodeHandle | undefined) => void + ): Unsubscribe + /** + * A workflow finished loading, and the graph is the new one. + * + * This is `afterConfigureGraph`. Unlike {@link onReady} it fires again for + * every workflow the user opens, which is what a pack re-attaching itself to + * the document needs — `onReady` fires once and misses every later open. + * + * It also fires for undo, redo and a reload of the same document, because a + * pack rebuilding state from the graph needs those too. The handle says + * which of them happened: an id equal to the one from last time means this + * document was rebuilt, not replaced. `undefined` when the host cannot name + * a document, as when raw workflow data is loaded with no file behind it. + */ + onWorkflowLoaded( + listener: (document: DocumentHandle | undefined) => void + ): Unsubscribe + /** + * A document's editing session began. + * + * Where per-document state belongs. Fires for a tab opened in the + * background too, so a pack that allocates here and releases in + * {@link onDocumentClosed} stays balanced however the user moves around. + */ + onDocumentOpened(listener: (document: DocumentHandle) => void): Unsubscribe + /** + * A document became the one on screen. + * + * Distinct from opening: the user returning to a tab activates a document + * that was already open, and its state is still valid. Anything tied to + * *being visible* — a panel, a canvas overlay — belongs here. + */ + onDocumentActivated(listener: (document: DocumentHandle) => void): Unsubscribe + /** + * A document stopped being the one on screen, but is still open. + * + * Fires before the next document is activated, so a pack moving something + * between them never sees two claiming the screen at once. + */ + onDocumentDeactivated( + listener: (document: DocumentHandle) => void + ): Unsubscribe + /** + * A document's editing session ended, however it ended — the user closing + * the tab, a temporary workflow being deleted, or the host discarding a + * background tab whose file changed on disk. + * + * Release everything keyed to it. The handle already reports `isDeleted`, + * and carries the id so a pack can find what it stored; it will not describe + * the document, because there is no longer one to describe. + */ + onDocumentClosed(listener: (document: DocumentHandle) => void): Unsubscribe +} + +// ─── commandsHandle.ts ─────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface KeyCombo { + readonly key: string + readonly ctrl?: boolean + readonly alt?: boolean + readonly shift?: boolean + readonly meta?: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CommandDef { + /** Namespaced, e.g. `MyPack.doTheThing`. Shared with core and every pack. */ + readonly id: string + /** + * A function when the label depends on state — a toggle that reads "Follow + * execution" and then "Stop following execution". It is read each time the + * label is shown, so it must return quickly. + */ + readonly label: string | (() => string) + readonly run: () => void | Promise + /** Bound as a default, so a user's own binding still wins. */ + readonly keybinding?: KeyCombo + /** + * Where the keybinding applies. Defaults to anywhere in the application. + * + * `'canvas'` limits it to the graph, so it will not fire while the user is + * typing in a node's text widget or any other field. The host already + * withholds combos a text input owns — every bare arrow, Ctrl+Left/Right, + * Ctrl+A/C/V/X/Z — but a pack binding something it does not, say Ctrl+Up, + * would otherwise fire mid-sentence. + */ + readonly scope?: 'canvas' +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NotifyDef { + readonly severity?: 'success' | 'info' | 'warn' | 'error' + readonly summary: string + readonly detail?: string + /** Milliseconds. Omit for the host's default. */ + readonly life?: number +} + +export interface CommandsHandle { + register(def: CommandDef): void + notify(def: NotifyDef): void + /** + * Runs a command the host or another pack registered, by id. + * + * Packs reached into internals to do what a command already does — opening + * the mask editor was `ComfyApp.copyToClipspace` plus `clipspace_return_node` + * plus invoking `Comfy.MaskEditor.OpenMaskEditor` by hand. Commands are the + * sanctioned action layer, so a pack can ask for the behaviour without the + * host having to publish the machinery behind it. + * + * Rejects if no such command is registered — a pack naming a command that + * has been renamed should hear about it rather than silently do nothing. + */ + run(id: string): Promise + /** Whether a command exists, for a pack that offers an entry conditionally. */ + has(id: string): boolean +} + +// ─── defsRegistry.ts ───────────────────────────────────────────── + +/** + * The read view of a node definition. Frozen and inert, like every read here. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeDef { + readonly type: string + readonly title: string + readonly category: string + readonly description: string + readonly inputs: readonly Readonly<{ + name: string + type: string + /** The translated caption core renders for this input, when it differs. */ + localizedName?: string + /** The declared choices for a COMBO input, in backend order. */ + values?: readonly (string | number)[] + /** + * The input's declaration dict, verbatim from the backend. + * + * Same passthrough reasoning as `ExecutionResult.raw`: a pack declares its + * own keys on its own Python input spec and reads them back here to drive + * frontend behaviour, so discarding unrecognised keys breaks the pack + * against its own data. Carries `default`, `min`, `max` and the like too. + */ + options: Readonly> + }>[] + readonly outputs: readonly Readonly<{ + name: string + type: string + tooltip?: string + }>[] + readonly isOutputNode: boolean + /** + * The node's `hidden` input declarations, verbatim. + * + * Deliberately not merged into {@link inputs}: a hidden input is not a slot, + * and listing it as one would put a connectable input on the node for + * something the server fills in. + * + * Packs ship their own data here and read it back — easy-use and + * tinyterraNodes both carry an XY-plot axis catalogue as + * `input.hidden.plot_dict[0]`, on their own key, from their own Python spec. + * That is the same passthrough reasoning `inputs[].options` already rests on, + * and dropping it broke both packs against their own data. + * + * These are declarations, not values. `PROMPT`, `UNIQUE_ID` and + * `EXTRA_PNGINFO` appear here as the type markers the node asked for; the + * server substitutes the real thing at execution time and it never passes + * through here. + */ + readonly hidden: Readonly> + /** Which pack supplied it, when the backend reports one. */ + readonly source: string | undefined +} + +/** + * Node output as it arrives from the backend. + * + * `raw` carries everything else verbatim — ADR 0007's passthrough schema + * guarantees custom output keys survive, so a pack reading a bespoke key keeps + * working. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface ExecutionResult { + readonly images: readonly Readonly>[] + readonly text: readonly string[] + readonly raw: Readonly> +} + +/** + * A preview frame the backend produced while this node was running. + * + * Per node rather than per channel, deliberately. Packs currently subscribe to + * `b_preview_with_metadata` *and* `b_preview`, track the executing node id in a + * module global to correlate the second one, and probe + * `serverSupportsFeature('supports_preview_metadata')` to decide which to + * trust — all to answer "is this frame mine?". Answering it once here removes + * the global, and with it the mis-attribution when two nodes preview at once. + */ +export interface PreviewFrame { + readonly blob: Blob + /** Object URL for the blob, revoked when the next frame arrives. */ + readonly url: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface ConnectionChangeEvent { + readonly side: 'input' | 'output' + readonly index: number + readonly connected: boolean + /** + * The node at the other end, or `undefined` on a disconnect. + * + * Packs read `link_info.origin_id` to decide what the new neighbour means — + * retype a slot to match it, adopt its label. Knowing only that *something* + * connected forced a re-walk of the whole graph to find out what. + */ + readonly peerNodeId?: string + /** The slot index at the other end, or `undefined` on a disconnect. */ + readonly peerIndex?: number +} + +/** + * The only change a node extension may make to one queued API prompt. + * + * Inputs are named from that node type's own backend declaration. The saved + * workflow is untouched; the prompt builder removes these names only from the + * executable payload it is assembling now. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface PromptInputProjection { + readonly omitInputs: readonly string[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type PromptInputProjector = ( + node: NodeHandle +) => PromptInputProjection | Promise + +export interface NodeDefBuilder { + /** Current state of the definition, after any earlier extensions ran. */ + readonly def: NodeDef + + setTitle(title: string): void + setCategory(category: string): void + /** + * Declares that this node type never reaches the backend. + * + * `defs.define` takes `execution: 'frontend'` for a type the pack owns, but + * packs also mark *backend-registered* types frontend-only — a tools or + * control node that exists to drive other nodes and must not appear in the + * prompt. Without this they reach for `node.isVirtualNode`, and dropping that + * line puts a new node into `graphToPrompt`, which is a wire-format break. + * + * Supply `resolve` when the node carries a value through to something else; + * omit it and the node is simply left out. See `resolution.ts` — `resolve` is + * pure over a read-only view and must not mutate the graph. + */ + setExecution(execution: 'backend' | 'frontend', resolve?: Resolver): void + /** + * Declares what this node feeds into *other* nodes' unconnected inputs. + * + * The counterpart of `setExecution`'s `resolve`, which answers only "what + * feeds my own outputs" and is never called for a node with none. Broadcast + * packs are the reverse: they name inputs on nodes that are not themselves, + * and discover those edges rather than declaring them. + * + * Available here and not only on `defs.define` because the types that + * broadcast are registered by the pack's Python, and `defs.define` refuses a + * type that already exists — which left `supply` unreachable for every pack + * that actually needed it. + * + * Not gated on `setExecution('frontend')`: feeding somebody else and being + * skipped by the prompt builder are separate questions, and a node may + * legitimately both execute and broadcast. + */ + setSupply(supply: Supplier): void + addWidget(def: WidgetDef): void + hideWidget(name: string): void + + // Behaviour hooks, ordered by measured usage across the 1,265 packs. + /** + * Fires once the node exists *and is addressable* — after it joins a graph. + * + * Deliberately not litegraph's `onNodeCreated`, which runs inside + * `createNode()` before the node has an id, a graph, or store registration. + * A handle is id-backed, so at that moment there is nothing to hand back, and + * widget writes would land on an unregistered node and be lost on insert. + */ + onCreated(callback: (node: NodeHandle, event: NodeCreatedEvent) => void): void // 943 packs + onExecuted( + callback: (node: NodeHandle, result: ExecutionResult) => void + ): void // 497 packs + onConfigured( + callback: (node: NodeHandle, data: Record) => void + ): void // 429 packs + onConnectionsChanged( + callback: (node: NodeHandle, event: ConnectionChangeEvent) => void + ): void // 223 packs + onRemoved(callback: (node: NodeHandle) => void): void // 158 packs + /** + * The node was resized, by the user or by a layout pass. + * + * Packs hung a `ResizeObserver` on their mounted element to notice this, + * which fires for the element rather than the node and misses a resize that + * does not change the element. + */ + onResized(callback: (node: NodeHandle, size: Size) => void): void + /** + * The pointer entered or left the node. + * + * Packs read `canvas.node_over` or set `node.mouseOver` to rebuild a list + * the moment the pointer arrives, or to decide which node a tooltip belongs + * to. Both are canvas internals, and the canvas is what Nodes 2.0 replaces. + */ + onHover(callback: (node: NodeHandle, hovering: boolean) => void): void + /** + * The node was double-clicked. + * + * Deliberately carries no coordinates. Hit-testing a pointer against + * node-local geometry is a pack drawing its own front end; the published + * answer is `widgets.mount` and ordinary DOM events on the element you own. + */ + onDoubleClick(callback: (node: NodeHandle) => void): void + /** + * Whether this node can accept the current browser drag. + * + * The event is the browser's data-transfer surface, not a renderer object. + * Returning `true` makes both node renderers present and route the drop. + */ + onDragOver( + callback: (node: NodeHandle, event: DragEvent) => boolean | void + ): void + /** Handles a drop the node accepted. Returning `true` claims it. */ + onDrop( + callback: ( + node: NodeHandle, + event: DragEvent + ) => boolean | void | Promise + ): void + /** + * A property the user edited in the node's properties panel. + * + * Packs used `onPropertyChanged` to keep a hand-entered value sane — rgthree + * clamps a seed's `randomMax` as it is typed. litegraph's own callback can + * only veto, reverting to the previous value, which throws the user's input + * away rather than correcting it. `setValue` replaces it instead, and writes + * without going back through `setProperty`, so a clamp cannot recurse. + */ + onPropertyChanged( + callback: (node: NodeHandle, event: PropertyChangeEvent) => void + ): void + /** Preview frames for this node, already correlated. */ + onPreview(callback: (node: NodeHandle, frame: PreviewFrame) => void): void + /** + * Contributes the pack's own state to the saved node. + * + * The returned object is merged into the serialized node, and comes back + * through `onConfigured`. Only keys the pack owns: core fields are not + * writable from here, because a pack must not be able to change what the + * workflow means. + */ + onSerialize(callback: (node: NodeHandle) => Record): void + /** + * Omits declared inputs from this node in the API prompt being built. + * + * This is not a prompt rewrite: the callback receives no prompt or input + * values, may not name another node, and cannot inject replacements. It is + * awaited on the prompt path so an extension answers from its + * current read-only node snapshot rather than a stale cached value. + */ + onPromptSerialize(callback: PromptInputProjector): void + /** + * Vetoes or permits an incoming connection *before* it is wired. + * + * Distinct from `onConnectionsChanged`, which fires after the fact — packs + * use the pre-hook to refuse an incompatible link or relabel a slot while + * the type is still known. Returning `false` refuses. + */ + onBeforeConnect( + callback: (node: NodeHandle, event: BeforeConnectEvent) => boolean | void + ): void + /** + * The user dropped a link on a node's body and the host found no single slot + * that fits. Wire it yourself and return `true`; return nothing to let the + * host report the drop unplaceable. + * + * For a node whose one slot carries a bundle of values — a context, a pipe — + * and which wants to unpack it into several of the peer's slots at once. Both + * ends of the drag are asked, the one the user aimed at first, because the + * node with the knowledge is the drop target in one direction and the drag's + * origin in the other. + * + * The published alternative to replacing `connectByType` on the prototype, + * which is how packs did this: that changes link routing for every node in + * the document, so one pack's convenience became every other pack's + * behaviour. + */ + onUnplacedLink( + callback: (node: NodeHandle, event: UnplacedLinkEvent) => boolean | void + ): void + /** Adds an entry to this node type's context menu. */ + addMenuItem(item: NodeMenuItem): void +} + +export interface NodeCreatedEvent { + /** + * The node arrived carrying saved state — pasted, duplicated, or loaded from + * a workflow — rather than being made fresh. + * + * Read as "was `configure` called on it before it joined the graph", which is + * what actually distinguishes the cases. Packs overrode `clone()` to reset + * state a copy should not inherit — a duplicated node keeping the dynamic + * slots that were fed by the original's upstream, a duplicated reroute born + * hard-typed and refusing every other type — and `clone()` runs before the + * node has an id, so there is nothing to hand a pack there. + */ + readonly restored: boolean + /** + * The whole graph was being loaded, so {@link restored} means "came from the + * saved file" rather than "came from the clipboard". + * + * The distinction is the point: a pasted node should drop slots it cannot + * still be fed through, and a loaded one must keep every one of them or the + * workflow opens wrong. + */ + readonly loading: boolean +} + +export interface UnplacedLinkEvent { + /** Which of this node's slots the link would land on. */ + readonly side: 'input' | 'output' + /** The node at the other end of the drag. */ + readonly peerNodeId: string + /** The slot on the peer the drag started from. */ + readonly peerIndex: number + readonly type: string + /** + * The user held the modifier that means "overwrite what is already wired". + * + * Published because packs read a global keyboard service of their own to get + * it, and which modifier means this is the host's to decide. + */ + readonly replaceExisting: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface BeforeConnectEvent { + readonly side: 'input' | 'output' + readonly index: number + /** The node at the other end, when one is known. */ + readonly peerNodeId: string | undefined + /** The slot at the other end, when one is known. */ + readonly peerIndex: number | undefined + readonly peerType: string | undefined +} + +/** One entry inside a menu item's submenu. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeSubMenuItem { + readonly label: string + run(node: NodeHandle): void +} + +/** + * One entry of ComfyUI's node palette: the title bar, the body, and the shade + * a group of that colour is filled with. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeColor { + readonly color: string + readonly bgColor: string + readonly groupColor: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeMenuItem { + /** + * A function when the text depends on the node — packs label entries with + * the current state ("Unmute 3 nodes"), which a string fixed at + * registration cannot express. + */ + readonly label: string | ((node: NodeHandle) => string) + /** + * Shown only when this returns true. Without it a pack that wants an entry + * to appear conditionally has to either show it always or not at all — + * efficiency-nodes hides its seed submenu when the feature is off, and + * flattening that to a permanent entry is a worse lie than omitting it. + */ + when?(node: NodeHandle): boolean + /** Omit when the item only opens a submenu. */ + run?(node: NodeHandle): void + /** + * Turns the entry into a submenu. One level deep, deliberately: every + * measured pack uses exactly one, and nesting further is a menu design + * problem rather than an API one. + * + * A function when the children depend on the node's current state, which is + * the common case rather than the exotic one: efficiency-nodes' LoRA Stacker + * declares fifty `lora_name_N` widgets and lists only the two or three a + * user has filled. A fixed array would put fifty rows in that menu, which is + * a different menu, so the alternative to this was omitting the feature. + */ + readonly items?: + | readonly NodeSubMenuItem[] + | ((node: NodeHandle) => readonly NodeSubMenuItem[]) + /** + * Sort position among this node's pack-added entries. Lower first; entries + * without one keep registration order, which is module-load order and so + * depends on import sequence rather than intent. + */ + readonly order?: number +} + +/** + * Which definitions an extension applies to. + * + * Indexed rather than run-and-return: this predicate is almost always the guard + * clause the pack already had at the top of its hook. + */ +export type DefSelector = + | string + | readonly string[] + | RegExp + /** + * A predicate over the definition, for a guard the other forms cannot + * express — "any node taking a VAE input", which is a shape rather than a + * name. + * + * Deliberately last, and deliberately discouraged. The declarative forms + * exist because a name check can be indexed, while a predicate has to run for + * every registered type; with thousands of types that is the boot cost this + * API set out to remove. Use it only when the guard genuinely reads a def's + * inputs or outputs. + */ + | ((def: NodeDef) => boolean) + /** + * A `RegExp` category covers the prefix filter 53 packs open their hook with + * (`nodeData.category.startsWith('KJNodes')` → `{ category: /^KJNodes/ }`). + */ + | { readonly category: string | RegExp } + +/** + * A node type the pack owns, declared rather than subclassed. + * + * 86 packs (18.2% of installs) do this today with `extends LGraphNode` + + * `LiteGraph.registerNodeType`, which is OOP entity modelling — the thing ADR + * 0008 rules out. Here the definition is plain data; the class behind it is an + * internal detail of this layer, never the pack's. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeDefinition { + readonly type: string + readonly title?: string + readonly category?: string + readonly description?: string + readonly inputs?: readonly { name: string; type: string }[] + readonly outputs?: readonly { + name: string + type: string + shape?: SlotShape + }[] + readonly widgets?: readonly WidgetDef[] + /** + * `'frontend'` nodes never reach the backend: they are resolved away at + * prompt time by the resolution system, or simply omitted. + */ + readonly execution?: 'backend' | 'frontend' + /** + * Answers what each output resolves to, purely, over a read-only view. + * See `resolution.ts` — this replaces `applyToGraph`, which mutated the + * live graph mid-serialize. + */ + readonly resolve?: Resolver + /** + * What this node feeds into *other* nodes' unconnected inputs. + * + * The broadcast direction: `resolve` cannot express it, because the nodes + * being fed are not this one and the edges are discovered rather than + * declared. + */ + readonly supply?: Supplier + + onCreated?(node: NodeHandle, event: NodeCreatedEvent): void + onExecuted?(node: NodeHandle, result: ExecutionResult): void + onConfigured?(node: NodeHandle, data: Record): void + onConnectionsChanged?(node: NodeHandle, event: ConnectionChangeEvent): void + onPropertyChanged?(node: NodeHandle, event: PropertyChangeEvent): void + onDragOver?(node: NodeHandle, event: DragEvent): boolean | void + onDrop?( + node: NodeHandle, + event: DragEvent + ): boolean | void | Promise + onRemoved?(node: NodeHandle): void + onSerialize?(node: NodeHandle): Record + onPromptSerialize?: PromptInputProjector +} + +export interface DefRegistry { + /** + * Declares how an input *type* is presented — the replacement for + * `getCustomWidgets`. + * + * Not decoration: the host decides widget-vs-socket purely by whether a type + * is registered, so an unregistered one turns the input into a socket and + * drops its value from `widgets_values`. See `widgetTypes.ts`. + */ + defineWidgetType(type: string, def: WidgetTypeDef): Unsubscribe + /** + * Registers a node type the pack owns. Returns a handle that unregisters + * it — which `LiteGraph.registerNodeType` never offered. + */ + define(definition: NodeDefinition): Unsubscribe + get(type: string): NodeDef | undefined + all(): readonly NodeDef[] + has(type: string): boolean + extend( + selector: DefSelector, + apply: (builder: NodeDefBuilder) => void + ): Unsubscribe + /** + * Asks the host to reload node definitions from the backend. + * + * Combo inputs whose values the backend supplies — model lists, LoRA names, + * sampler names — are captured when definitions load, so a pack that adds a + * file server-side leaves every open picker showing the old list. This is + * `app.refreshComboInNodes()`, which packs called after saving a model + * preview or writing a new file. + * + * Refreshing is not free: it refetches every definition. Call it after a + * change the user made, not on a timer. + */ + /** + * The colour links and slots of a type are drawn in. + * + * A pack matching the theme in its own DOM — a legend, a chip, a preview — + * read `LGraphCanvas.link_type_colors` for this. Reading a design token to + * match is the opposite of drawing your own front end, so it is published; + * the table itself is not. + */ + typeColor(type: string): string + /** + * The colours behind a name in ComfyUI's node palette — `red`, `pale_blue` — + * or `undefined` for a name it does not define. + * + * Same reasoning as {@link typeColor}, and the same limit: the resolver is + * published, the table is not. What makes this a design token rather than a + * renderer internal is that the names are the user's own vocabulary. They + * pick "green" from a menu; nothing records the word, only the hex it stood + * for. So a pack offering "mute every red group" cannot match what the user + * chose without being told which hex "red" meant, and two packs did it by + * reading `LGraphCanvas.node_colors` directly. + * + * Colours move with the palette, names do not. Resolve on use; do not cache + * the result and do not persist it in a workflow. + */ + nodeColor(name: string): NodeColor | undefined + /** + * Tests an output type against an input type using the host's connection + * rules, including wildcards and comma-delimited unions. + */ + isTypeCompatible(outputType: string, inputType: string): boolean + /** + * Declares the colour for a data type this pack introduces. + * + * Packs shipping their own types — `PIPE_LINE`, `LORA_STACK`, `XYPLOT` — + * wrote straight into `LGraphCanvas.link_type_colors` so their links were + * not all grey. + * + * Refuses a type the host already colours. That write is global: one pack + * recolouring `IMAGE` restyles every graph for every other pack and the + * user has no way to see who did it. Colouring a type you brought is + * additive; colouring one you did not is not yours to decide. + */ + setTypeColor(type: string, color: string): Unsubscribe + refresh(): Promise + /** + * Node definitions were reloaded — by this pack, another pack, or the user. + * + * The listening half of `refresh()`, and what the `refreshComboInNodes` + * extension hook gave packs. A pack holding its own cached copy of a combo's + * values — a model list it filters, a picker it built — needs to rebuild it + * when the list changes underneath, and the pack that caused the change is + * usually not this one. + */ + onRefreshed(listener: () => void): Unsubscribe +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PropertyChangeEvent { + readonly name: string + readonly value: unknown + readonly previous: unknown + /** Replaces what is stored. Last writer wins if several packs respond. */ + setValue(value: unknown): void + /** Discards the edit, restoring `previous`. */ + reject(): void +} + +// ─── documentHandle.ts ─────────────────────────────────────────── + +export interface DocumentHandle extends HandleCommon { + /** + * Identity of this editing session. Stable for as long as the document is + * open — including across undo, redo and tab switches — and never reused. + * + * Not the id inside the workflow JSON, which travels with the file, so two + * opens of it and any copy made outside the app all share one value. Not the + * path either, which is a storage address and changes on rename. Do not + * persist this: it means nothing in the next page load. + */ + readonly id: string + /** Display name, without the directory or extension. */ + readonly name: string | undefined + /** + * Storage path, for addressing the file. Undefined for a document with no + * file behind it yet. Changes when the user renames, so key pack state on + * {@link id} instead. + */ + readonly path: string | undefined + /** Whether there are edits the user has not saved. */ + readonly isModified: boolean + /** + * True once this editing session has ended. + * + * A handle is a snapshot of a session, and a pack may hold one across a tab + * close or a background unload. Check before acting on stored state rather + * than trusting a captured handle, exactly as for a node or a widget. + */ + readonly isDeleted: boolean +} + +/** What the host must supply to describe one open document. */ +export interface DocumentSource { + readonly sessionId: string | null + readonly filename?: string + readonly path?: string + readonly isModified?: boolean + /** Whether this is the document the editor is showing. */ + readonly isActive?: boolean +} + +/** + * Every document currently open, including background tabs. + * + * One reader rather than one per question: a handle has to answer for a + * document that is open but not on screen, and a lookup that only knew the + * active one would report every background tab as closed. + */ +export type DocumentReader = () => readonly DocumentSource[] + +// ─── documentLifecycle.ts ──────────────────────────────────────── + +/** + * The transitions a document makes. + * + * `opened` and `closed` bracket the session's existence; `activated` and + * `deactivated` bracket its time on screen. A document opened in the + * background is `opened` without being `activated`, which is why they are + * separate: a pack that allocates on `opened` and releases on `closed` stays + * balanced no matter how the user moves between tabs. + + */ +export type DocumentPhase = 'opened' | 'activated' | 'deactivated' | 'closed' + +// ─── graphHandle.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface NodeInit { + title?: string + position?: { x: number; y: number } +} + +/** + * How far {@link GraphHandle.queryNodes} looks. + * + * `'visible'` is the graph on screen and the default, matching `nodes()`. + * `'root-and-subgraphs'` is the root graph and every subgraph *definition* — + * the same set `onNodeChanged`'s `'document'` scope reports over. A subgraph + * placed three times contributes its nodes once, which is what a pack acting + * on "each of my nodes" means. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export type NodeQueryScope = 'visible' | 'root-and-subgraphs' + +/** + * Which nodes {@link GraphHandle.queryNodes} should return. + * + * Every field narrows; omitting all of them returns the whole scope. They + * compose as AND, because the cases packs actually hand-rolled — "my nodes, + * anywhere in the document", "everything in this group" — are intersections. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface NodeQuery { + readonly scope?: NodeQueryScope + /** + * Node type. A string matches exactly, an array matches any of them, and a + * regular expression matches by pattern — which is how a pack asks for its + * own nodes without listing every type it ships. + */ + readonly type?: string | RegExp | readonly string[] + /** + * Restrict to nodes in the graph the user is looking at. + * + * Only meaningful under `'root-and-subgraphs'`: it is the difference between + * "every node in the document" and "the ones the user can currently see". + * This is *not* a viewport test — a node scrolled off the edge of a graph + * the user is in is still rendered by this definition. Culling belongs to + * the renderer and differs between the two of them. + */ + readonly rendered?: boolean + /** Restrict to nodes the group currently contains. */ + readonly group?: GroupHandle +} + +export interface GraphHandle { + readonly id: string + node(id: string): NodeHandle | undefined + nodes(): readonly NodeHandle[] + nodesOfType(type: string): readonly NodeHandle[] + /** + * One flat query over graph-scoped nodes. + * + * `nodes()` and `nodesOfType()` address the graph on screen, so a pack that + * wanted "every node of mine in this document" had to walk `root()` and each + * `subgraphs()` entry itself and concatenate the results — and the ones that + * did not simply stopped working the moment a user nested anything. + * + * Handles come from the scope that owns each node, so a node reached here + * under `'root-and-subgraphs'` is not `===` the one `graph.node()` returns + * for it. That is the same scope rule `sameEntity()` exists for; compare + * with `comfy.sameEntity()` rather than `===`. + */ + queryNodes(query?: NodeQuery): readonly NodeHandle[] + add(type: string, init?: NodeInit): NodeHandle + remove(id: string): boolean + links(): readonly LinkInfo[] + /** + * The supply edges prompt execution would use in this graph right now. + * + * Re-runs the registered pure suppliers and the host's priority arbitration, + * returning graph-local ids suitable for {@link OutputSlotHandle.connectTo}. + * Exact priority ties are absent, just as they are from the prompt. The + * frozen snapshot never mutates the graph. + */ + resolvedSupplies(): readonly ResolvedSupply[] + /** + * The nodes the user currently has selected. + * + * 15 packs read `canvas.selected_nodes` or `selectedItems` for this — a + * canvas internal, and the canvas is exactly what Nodes 2.0 replaces. + * Selection is a property of the document, so it is asked of the graph. + */ + selection(): readonly NodeHandle[] + /** + * Replaces the selection with these nodes. An empty list clears it. + * + * A node a pack just created is the usual case — `LGraphCanvas.add`'s + * `options.select` put it straight under the user's cursor, and without this + * the node appears but the user has to find and click it. + * + * `add: true` extends the selection instead of replacing it. + */ + select(nodes: readonly NodeHandle[], options?: { add?: boolean }): void + /** + * Pans the view so a node sits in the middle of it. + * + * Packs wrote `canvas.ds.offset` themselves to do this, which bakes in the + * renderer's transform and the device pixel ratio. Does not change zoom. + */ + centerOn(node: NodeHandle): void + /** + * The groups on the canvas, in draw order. + * + * Packs read `graph._groups` to build a group muter, a group runner, or a + * navigator. A group is a rectangle plus a title: which nodes it holds is + * derived from what it overlaps, which is why `nodes()` is a method and not + * a stored list. + */ + groups(): readonly GroupHandle[] + /** + * Scales the view. 1 is unzoomed. + * + * Packs saved a zoom level alongside a node to restore a view; without this + * a bookmark could pan but the number it stored was inert. Clamped to what + * the canvas allows, so a stored extreme cannot strand the user. + */ + setZoom(scale: number): void + /** + * Where the pointer is, in graph space — the coordinates {@link nodeAt} and + * {@link NodeHandle.setPosition} use. + * + * A pack adding a node from a menu put it under the cursor. Without this the + * node lands at the graph origin, which on any panned view is off screen. + * + * `undefined` when there is no canvas to measure against. + */ + pointerPosition(): Point | undefined + /** + * The document's root graph, even while the user is viewing a subgraph. + * Undefined before a document exists. + */ + root(): GraphScopeHandle | undefined + /** + * The subgraph definitions in the document, each scoped to its own nodes. + * + * `nodes()` and `node()` address the graph on screen only, so a pack that + * must reach every node — refreshing its own nodes after a run, walking a + * chain — misses anything nested. + * + * Access is *through* the subgraph rather than a flattened list. Ids are + * allocated from the root graph's counter, so they do not collide among + * nodes created in one session — but a subgraph loaded from a file brings + * its authored ids, and `configure` raises that counter without renumbering + * anything. Two independently authored subgraphs can therefore carry the + * same id. Resolving inside the owning graph is correct either way, and does + * not rest on an invariant litegraph does not promise. + * + * These are definitions, not instances. A subgraph placed three times has + * one entry, and its nodes appear once — which is what a pack acting on + * "each of my nodes" wants. + */ + subgraphs(): readonly GraphScopeHandle[] + /** + * Runs several mutations as one undo step. + * + * Without it, a pack that adds three nodes and wires them leaves the user + * pressing undo four times to get back. `graph.beforeChange()` / + * `afterChange()` did this by counting nesting depth. + * + * A scope rather than a pair of calls: the counter only captures when it + * returns to zero, so one throw between a manual `before` and `after` stops + * undo capturing anything at all, for the rest of the session, with nothing + * to show why. The scope closes on the way out either way. + * + * Synchronous on purpose. Holding the group open across an `await` would + * fold whatever the user did while waiting into the pack's undo step. + */ + batch(mutations: () => T): T + /** + * The topmost node at a point in graph space, if any. + * + * Packs building a gesture were walking every node and re-deriving its + * rectangle from renderer constants. The graph already knows, and its answer + * respects z-order, collapsed nodes and the active renderer's layout. + * + * Answers against the *rendered* layout, which is the only sensible reading + * of "what is under this point" — and is why it is not refreshed per call: a + * gesture asks this on every pointer move, and remeasuring every node each + * time would be the expensive mistake. Before the first frame it finds + * nothing. + */ + nodeAt(point: { x: number; y: number }): NodeHandle | undefined + /** + * A copy of a node, carrying its widget values and properties, added to the + * graph without links. + * + * `add(type)` only makes a fresh node of a type, so a pack duplicating a + * configured node — a prompt box the user has filled in — had no way to keep + * what it contained. Links are deliberately not copied: a duplicate wired + * into the same places is a different operation, and the caller can connect + * it themselves. + * + * `undefined` if the node is gone, or if its type is not registered — the + * copy is built through the registry, so there is nothing to build from. + * Widget values carry over only for a type that serializes them, which every + * backend-registered type does. + */ + duplicate( + id: string, + position?: { x: number; y: number } + ): NodeHandle | undefined + /** + * Rebuilds a node, optionally as another type, keeping what the user set and + * every link that still fits. Replacing with the same type repairs a node + * whose registered definition changed without discarding its state. + * `undefined` if the node is gone; throws if the type is not registered. + * + * This is a real feature four packs ship — "Convert to Context Big", "Swap to + * KSampler (Efficient)" — and all four hand-rolled it out of `graph.links`, + * `getNodeById` and `LiteGraph.createNode`, which is most of what this + * migration exists to delete. All four also got it wrong: one drops every + * widget value and hardcodes "slot 0 only", the other recurses through + * requestAnimationFrame forever on an inverted comparison and leaves a + * separate undo step for the add, each connection, and the remove. + * + * Position, custom title, colour, mode, declared properties and widget values + * carry over by name. Size is the larger of what the user set and what the new + * type needs, so a node that grew more slots is not clipped. Links are re-made + * by slot name, falling back to the same index; type checking is the ordinary + * connection rule, so a link that no longer fits is dropped and warned about + * rather than forced. The whole swap is one undo step. + */ + replace(id: string, type: string): NodeHandle | undefined + /** + * Changes when the graph does: nodes added, removed or reconfigured, links + * connected or disconnected, slots and subgraph inputs/outputs altered, and + * the node flags a reader can see — collapsed, pinned, advanced. + * + * Hold one and compare it later to learn whether anything moved since. That + * is the whole contract: an opaque token, not a count. Do not subtract two + * of them, do not expect it to start anywhere in particular, and do not + * expect consecutive changes to differ by one. Coalesced edits are free to + * advance it once, and `batch()` exists precisely so they can. + * + * A widget value committed by the user or through + * `WidgetHandle.setValue()` advances it through the same host protocol. Data + * a pack keeps outside graph and widget state does not; a canvas widget + * holding such data has `redraw()`. + */ + readonly version: number + /** Diagnostics: live handle-cache slots across all kinds. */ + readonly cacheSize: number +} + +/** + * A subgraph definition, scoped to its own contents. + * + * Deliberately narrower than {@link GraphHandle}: adding, selecting, centring + * and zooming all address what the user is looking at, and a subgraph + * definition is not that. This is for reading and reaching nodes. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface GraphScopeHandle { + /** Stable across every instance of this subgraph. */ + readonly id: string + readonly name: string | undefined + nodes(): readonly NodeHandle[] + node(nodeId: string): NodeHandle | undefined + /** + * The groups drawn inside this subgraph. + * + * A group muter or runner that skipped these reported nothing for a + * subgraph's contents while appearing to work. + */ + groups(): readonly GroupHandle[] + /** The supply edges prompt execution would use inside this graph. */ + resolvedSupplies(): readonly ResolvedSupply[] +} + +// ─── groupHandle.ts ────────────────────────────────────────────── + +export interface GroupHandle { + readonly id: string + getTitle(): string + setTitle(title: string): void + /** Colour as the renderer holds it, or undefined for the default. */ + getColor(): string | undefined + setColor(color: string): void + /** + * The nodes the group currently contains, recomputed on each call. + * + * Packs muted or queued "the group", which always meant its nodes. Do not + * cache the result: a drag changes it with no event. + */ + nodes(): readonly NodeHandle[] + /** The group's rectangle in graph space, title bar included. */ + getBounds(): Bounds + /** Pans the view so this group is in the middle of it. Zoom is unchanged. */ + centerOn(): void +} + +// ─── interaction.ts ────────────────────────────────────────────── + +export interface NodeMoveEvent { + readonly node: NodeHandle + readonly position: { readonly x: number; readonly y: number } +} + +/** + * Where movement comes from, supplied by the renderer. + * + * `platform/` cannot import `renderer/`, and the layout store lives there. This + * is the same seam `registerBadgeRowsProvider` uses so litegraph never reaches + * into the store: the upper layer pushes the source down at boot. + */ +export type NodeMoveSource = ( + onMove: (nodeId: string, position: { x: number; y: number }) => void +) => Unsubscribe + +/** Reports a completed drag with the ids of every node it moved. */ +export type NodeDragEndSource = ( + onDragEnd: (nodeIds: readonly string[]) => void +) => Unsubscribe + +// ─── nodeChanges.ts ────────────────────────────────────────────── + +/** A field the host tracks and reports. Not every property is one. */ +export type TrackedProperty = + | 'title' + | 'mode' + | 'color' + | 'bgcolor' + | 'shape' + | 'showAdvanced' + +/** + * Which graphs a listener hears from. + * + * `'visible'` is the default and the graph on screen, following the user into + * and out of subgraphs — what a pack decorating what the user is looking at + * wants. + * + * `'document'` is the root graph and every subgraph definition. A pack that + * *computes* from other nodes needs it: rgthree's relay derives a group's mute + * state from its inputs, and inside a subgraph the user had navigated away from + * it stopped recomputing while still asserting its last answer — so a group + * stayed muted against its inputs, intermittently, and healed on navigation. + */ +export type NodeChangeScope = 'visible' | 'document' + +export interface NodeChangeOptions { + scope?: NodeChangeScope +} + +export interface NodeChangeEvent { + /** The node that changed. It may belong to another pack, or to none. */ + readonly node: NodeHandle + /** + * The graph the change happened in — the root graph's id, or a subgraph + * definition's. Node ids are unique only within a graph, so a pack keeping + * its own records under `'document'` must key on both. + */ + readonly graphId: string + /** + * The editing session the change happened in, or `undefined` when the host + * cannot name one. + * + * `graphId` is restored from the saved workflow and round-trips through + * `serialize()`, so it identifies the graph on disk, not the document open + * in front of the user — two opens of one file report the same value. A pack + * holding records across a document swap needs this to know they are stale. + */ + readonly documentId: string | undefined + readonly property: TrackedProperty + readonly from: unknown + readonly to: unknown +} + +// ─── nodeHandle.ts ─────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type NodeMode = 'always' | 'never' | 'bypass' | 'on-event' | 'on-trigger' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type NodeShape = 'default' | 'box' | 'round' | 'circle' | 'card' + +export interface BadgeDef { + readonly text: string + /** Text colour. Defaults to core's badge foreground. */ + readonly color?: string + /** Background colour. Defaults to core's badge background. */ + readonly bgColor?: string + /** + * Makes the badge clickable. + * + * Two conversions declined to turn a button into a badge because a badge + * that looks pressable and does nothing is worse than the thing it replaced. + */ + onClick?(): void +} + +export interface Point { + readonly x: number + readonly y: number +} + +export interface Size { + readonly width: number + readonly height: number +} + +/** A rectangle in graph space. */ +export interface Bounds { + readonly x: number + readonly y: number + readonly width: number + readonly height: number +} + +export interface NodeSnapshot { + readonly id: string + readonly type: string + readonly title: string + readonly mode: NodeMode + readonly collapsed: boolean + readonly pinned: boolean + readonly color: string | undefined + readonly bgColor: string | undefined + readonly shape: NodeShape + readonly position: Point + readonly size: Size +} + +/** + * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract: + * accessor methods rather than properties, so a read can be a store query and + * a write can dispatch a command. + */ +export interface SizeConstraints { + minWidth?: number + minHeight?: number + maxWidth?: number + maxHeight?: number + /** Grow to fit content rather than holding a fixed height. */ + autoHeight?: boolean +} + +export interface NodeHandle extends HandleCommon { + readonly id: string + readonly type: string + readonly comfyClass: string + + getTitle(): string + setTitle(title: string): void + getMode(): NodeMode + setMode(mode: NodeMode): void + isCollapsed(): boolean + setCollapsed(collapsed: boolean): void + isPinned(): boolean + setPinned(pinned: boolean): void + getColor(): string | undefined + setColor(color: string | undefined): void + getBgColor(): string | undefined + setBgColor(color: string | undefined): void + getShape(): NodeShape + setShape(shape: NodeShape): void + getProperty(key: string): T | undefined + getProperties(): Readonly> + setProperty(key: string, value: WidgetValue): void + /** + * Whether this node emits `widgets_values` when the workflow is serialized. + * + * Writable because packs vary it per node type, and the value is part of the + * wire format — a conversion that could not set it would change what the + * saved workflow contains. + */ + isSerializingWidgets(): boolean + setSerializeWidgets(serialize: boolean): void + + getPosition(): Point + setPosition(pos: Point): void + getSize(): Size + /** Changes size through the host's resize protocol, including `onResized`. */ + setSize(size: Size): void + /** + * The node's rectangle in graph space, title bar included. + * + * `getPosition()` is the body's top-left, so packs building a gesture were + * reconstructing this by subtracting a title height read off the renderer — + * which is only right for the default layout, and wrong for a collapsed node + * or under a different renderer. Ask the renderer instead of re-deriving it. + */ + getBounds(): Bounds + /** + * Where a slot sits, in graph space. + * + * The renderer's own answer, so it stays correct for collapsed nodes, + * widget-backed inputs and layouts that are not the default vertical stack — + * all cases the `(index + 0.7) * slotHeight` reconstruction gets wrong. + * + * `undefined` if there is no slot at that index. + */ + getSlotPosition(side: 'input' | 'output', index: number): Point | undefined + /** + * Where the node currently sits on screen, in client coordinates. + * + * For anchoring a floating panel to a node. Packs did this by reading the + * viewport's pan and zoom and doing the arithmetic themselves, which is both + * the renderer's business and wrong the moment the transform changes shape. + * + * The answer already accounts for zoom, so a pack needing to convert a pixel + * drag into graph units can divide by `width / getBounds().width` rather than + * asking for the scale factor. + * + * `undefined` when nothing is on screen to measure against. + */ + getScreenRect(): Bounds | undefined + /** + * URLs of the images this node produced when it last executed. + * + * Packs read `node.imgs` — the loaded `HTMLImageElement`s core hangs on the + * node — to walk upstream for the nearest ancestor holding a composite, or + * to scan the selection for something to feed an editor. `onExecuted` does + * not answer that: it is per node type, so it never sees another pack's + * outputs, and it only fires at the moment of execution. + * + * URLs rather than elements, deliberately. The loaded element is the + * renderer's, and its lifetime is the renderer's; a pack that wants pixels + * can load the URL itself and own the result. This also covers previews, + * which are what the node is showing when a run is still in flight. + * + * Empty when the node has not produced images. + */ + getOutputImages(): readonly string[] + /** + * Which of {@link getOutputImages} the user is looking at, or `undefined` + * when they have neither selected nor hovered one. + * + * A pack copying "the image" or saving one as a model's preview meant the + * one under the cursor, not the first of the batch. `undefined` is why this + * is not simply `0`: an entry that acts on a guess writes the wrong file to + * the server, silently. + */ + getDisplayedImageIndex(): number | undefined + /** + * The id of the graph holding this node — the root graph's id, or a + * subgraph's. + * + * A pack keeping its own records against nodes needs it: node ids are unique + * per graph, so a key built from the id alone collides once subgraphs are + * involved. Pair it with `comfy.graph.subgraphs()` to get back to the node. + */ + readonly graphId: string | undefined + /** + * Puts a small label on the node's title bar. Returns a handle that removes + * it again. + * + * Packs draw a status, a count, a cost, a model name. They did it by + * overriding `onDrawForeground` and painting into the canvas context, which + * only works under the legacy renderer and puts the pack in the business of + * laying out text. `badges` is core's own extension point and both renderers + * draw it. + * + * Pass a function for a label that changes: it is called each time the node + * is drawn, so return quickly and do not build strings you could cache. + */ + addBadge(badge: BadgeDef | (() => BadgeDef)): Unsubscribe + /** + * Declares how the node may be sized, instead of re-asserting it per frame. + * + * 39 packs recompute size inside a draw or resize callback, which is both a + * per-frame cost and a fight with the layout. `autoHeight` is usually the + * real intent: the pack mounted something of unknown height and wants the + * node to fit it. + */ + setSizeConstraints(constraints: SizeConstraints): void + getSizeConstraints(): Readonly + + readonly inputs: SlotCollection + readonly outputs: SlotCollection + readonly widgets: WidgetCollection + snapshot(): Readonly | undefined + remove(): void +} + +/** Per-node collections, supplied by the graph layer that owns their caches. */ +export interface NodeCollections { + inputs(nodeId: string): SlotCollection + outputs(nodeId: string): SlotCollection + widgets(nodeId: string): WidgetCollection +} + +// ─── queueHandle.ts ────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunOptions { + /** + * Run only these nodes and whatever feeds them, instead of the whole + * workflow. Empty is rejected rather than treated as "everything": a filter + * that matched nothing must not silently run the entire graph. + */ + nodes?: readonly NodeHandle[] + /** How many times to run. Defaults to 1. */ + batch?: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunSubmittedEvent { + /** Ids the backend accepted, in submission order. */ + readonly promptIds: readonly string[] + /** The accepted prompts and how many backend nodes each will execute. */ + readonly submissions?: readonly RunSubmission[] + /** How many submissions the backend refused. */ + readonly rejected: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunSubmission { + readonly promptId: string + readonly nodeCount: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectionError { + readonly type: string + readonly message: string + readonly details: string + readonly inputName?: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectedNode { + readonly nodeId: string + readonly nodeType: string + readonly errors: readonly RunRejectionError[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface RunRejectedEvent { + readonly status?: number + readonly error: RunRejectionError + readonly nodeErrors: readonly RunRejectedNode[] +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type AutoQueueMode = 'disabled' | 'change' | 'instant' + +export interface QueueHandle { + /** + * Queues the current workflow, exactly as pressing Run does. + * + * Resolves once the prompt has been submitted — not when it finishes + * executing. `false` means another queue call was already in flight and this + * one was folded into it. + */ + run(options?: RunOptions): Promise + /** + * A run is about to be submitted. + * + * This is `beforeQueuing`. For a last write before the prompt is built — + * syncing a value the pack keeps outside the widget. Keep it synchronous: + * the prompt build does not wait, so work started here can lose the race. + */ + /** + * Return a function to have it run when the attempt is over — whether the + * run started, was refused, or threw. + * + * For a pack that changes the graph to build the prompt and must put it back: + * unmute a branch, let the prompt be built, re-mute it. Pairing it with the + * setup rather than publishing a second top-level event is deliberate — you + * cannot receive the cleanup without having run the setup, and there is no + * second "after" member to confuse with {@link onAfterRun}, which means + * something different and narrower. + */ + onBeforeRun(listener: () => (() => void) | void): Unsubscribe + /** + * A run was submitted. This is `afterQueued` — for advancing state that + * should differ on the next run. + * + * The event names what the backend accepted, so a pack can tie its own + * progress tracking to the run it started rather than guessing that the next + * execution message belongs to it. Each submission includes the exact count + * of executable backend nodes without exposing the built prompt. `rejected` + * is how many submissions the backend refused: `onBeforeRun` fires either + * way, so without this a pack cannot tell a run that started from one that + * never did. + */ + onAfterRun(listener: (event: RunSubmittedEvent) => void): Unsubscribe + /** + * The backend refused a submitted prompt before execution began. + * + * This exposes prompt and per-node validation details without coupling a + * pack to host notifications. It does not fire for transport failures or an + * error raised after execution starts. + */ + onRejected(listener: (event: RunRejectedEvent) => void): Unsubscribe + /** + * How many runs are waiting, including the one executing. + * + * Packs tracked this from the backend's own `status` message to re-implement + * `app.ui.lastQueueSize` — deciding whether a button says Run or Cancel, + * whether an auto-runner should submit again. + */ + pending(): number + /** Fires whenever {@link pending} changes, with the new count. */ + onPendingChanged(listener: (pending: number) => void): Unsubscribe + /** + * Cancels the run in progress. The rest of the queue is untouched. + * + * Packs wrapped `api.interrupt` both to call it and to notice one — a node + * waiting on the user needs to stop waiting when the run is cancelled. + * {@link onInterrupted} is that second half. + */ + interrupt(): Promise + /** Execution was interrupted, by this pack, another, or the user. */ + onInterrupted(listener: () => void): Unsubscribe + /** The user-facing automatic queue mode. Both internal instant states read as `instant`. */ + autoQueueMode(): AutoQueueMode + /** Changes automatic queuing. `instant` arms continuous execution. */ + setAutoQueueMode(mode: AutoQueueMode): void + /** The batch count the host's own Run action will use. */ + batchCount(): number + /** Changes the host Run action's batch count. */ + setBatchCount(count: number): void + /** + * Turns off automatic queuing without cancelling the current run. + * + * A conditional workflow can use this before interrupting itself so the + * stopped iteration does not immediately start again. + */ + disableAutoQueue(): void + /** + * Holds a run until a check finishes, and can cancel it. + * + * {@link onBeforeRun} only observes: it is a notification, and the prompt + * build does not wait. Packs that needed to *stop* a run — confirm an + * incoming prompt, validate a field, warn about a cost — wrapped + * `app.queuePrompt` to do it, which is the surface being retired. + * + * Return `false` to cancel. Every guard runs, and any one `false` cancels; + * the user is not asked twice. + * + * A guard that never settles would make the application unrunnable, so one + * that takes longer than a few seconds is abandoned and the run proceeds. Do + * not put a dialog with no timeout behind this. + */ + guard(check: () => boolean | Promise): Unsubscribe +} + +// ─── resolution.ts ─────────────────────────────────────────────── + +/** + * "Whatever feeds this input." The only way one resolution names another. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface InputRef { + readonly nodeId: string + readonly input: number +} + +export type OutputResolution = + | { readonly omit: true } + | { readonly forwardTo: InputRef } + | { readonly literal: WidgetValue } + +/** + * What a resolver may see. Reads only — there is nothing here that writes. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface ResolvedNodeView { + readonly id: string + readonly type: string + /** + * The node's own properties, frozen. + * + * A broadcaster keeps its per-node opt-in here — cg-use-everywhere reads + * `properties.ue_properties` to decide what it may feed. Candidate inputs + * already carry `nodeProperties`, so without this a supplier could read + * every node's configuration except its own. + */ + readonly properties: Readonly> + /** The groups this node sits inside — the other half of "my group". */ + readonly groups: readonly GroupMembership[] + /** Muted, bypassed or normal, as `LGraphEventMode`. */ + readonly mode: number + readonly color: string | undefined + /** + * This node's own inputs. + * + * `unconnectedInputs()` already describes every *other* node's slots, and a + * supplier needs the same of its own: "send whatever is plugged into me to + * every unconnected input of the same type" cannot be written without + * knowing what type is plugged in. Without it a supplier is type-blind and + * would feed a CLIP into a MODEL slot in silence. + * + * `type` is the slot's declared type; `connectedType` is what actually + * arrives, resolved through reroutes, and is undefined when nothing is + * connected. + */ + readonly inputs: readonly OwnInput[] + /** This node's own outputs, in slot order. */ + readonly outputs: readonly OwnOutput[] + widgetValue(name: string): WidgetValue | undefined + input(ref: string | number): InputRef | undefined +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface ResolveView { + readonly self: ResolvedNodeView + nodesOfType(type: string): readonly ResolvedNodeView[] +} + +/** + * May answer asynchronously: a pack's resolver may run in a worker, so + * its answer can only arrive as a promise. The prompt path awaits it; the + * synchronous entry points (`input.resolvedSource()`, `resolvedSupplies()`) + * treat a promise as unresolved and say so — see `resolution.async.test.ts`. + */ +export type Resolver = ( + view: ResolveView +) => + | Record + | Promise> + +/** Where an output ends up after every frontend node in the chain resolves. */ +export type ResolvedSource = + | { + readonly kind: 'output' + readonly nodeId: string + readonly output: number + } + | { readonly kind: 'literal'; readonly value: WidgetValue } + | { readonly kind: 'omitted'; readonly reason: string } + +/** An input in the graph that no link feeds. */ +/** One of a node's own inputs, as its supplier sees it. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface OwnInput { + readonly index: number + readonly name: string + /** What the user sees — `label`, else `localized_name`, else `name`. */ + readonly label: string + readonly type: string + readonly connected: boolean + /** The type actually arriving, or undefined when nothing is connected. */ + readonly connectedType: string | undefined + /** The node feeding this input, if any. */ + readonly sourceNodeId: string | undefined +} + +/** One of a node's own outputs, as its supplier sees it. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface OwnOutput { + readonly index: number + readonly name: string + /** What the user sees — `label`, else `localized_name`, else `name`. */ + readonly label: string + readonly type: string +} + +/** A group a node sits inside. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface GroupMembership { + readonly id: string + readonly title: string +} + +export interface UnconnectedInput { + readonly nodeId: string + readonly nodeType: string + readonly input: number + readonly name: string + readonly type: string + /** + * What the user actually sees on the slot — `label`, else `localized_name`, + * else `name`. Broadcast packs match against this, not `name`, and the two + * differ in every non-English locale. + */ + readonly label: string + /** The socket form of a widget rather than a plain input. */ + readonly isWidgetInput: boolean + /** The owning node, for matching by title, mode, colour, or opt-in flags. */ + readonly nodeTitle: string + readonly nodeMode: number + readonly nodeColor: string | undefined + /** + * The groups the owning node sits inside, innermost first. + * + * Broadcast packs restrict by group — "only nodes in my group", "only nodes + * outside it", "only groups whose title matches this regex". Membership is + * geometric and recomputed here, so it matches what the user sees rather + * than anything stored. + */ + readonly nodeGroups: readonly GroupMembership[] + /** + * The owning node's properties, frozen. + * + * Broadcast packs keep their per-node opt-in here — which inputs a user has + * allowed to be fed. Without it a supplier can only match by type and would + * feed every unconnected input of that type, which is the silent + * wrong-broadcast failure this view exists to prevent. + */ + readonly nodeProperties: Readonly> +} + +/** + * An edge a node supplies into somebody else's unconnected input. + * + * `from` is the supplier's own output index, or a literal. It is deliberately + * not an arbitrary node reference: a node may only offer what it itself has, + * so one pack cannot rewire two other nodes to each other. + */ +export interface SuppliedEdge { + readonly to: InputRef + /** + * Which claim wins when several suppliers name the same input. Higher wins; + * defaults to 0. + * + * **Equal claims feed nothing.** Two suppliers that both say "highest + * priority" for one input have no correct answer, and picking either makes + * the prompt depend on node order — so the input is left unfed and the + * conflict logged. That is what the broadcast pack this exists for does, and + * it is the only choice that cannot silently produce a different image. + */ + readonly priority?: number + readonly from: + | { readonly output: number } + | { readonly literal: WidgetValue } + /** + * Whatever feeds this node's own input `k` — for a node that rebroadcasts + * its upstream rather than producing a value. + * + * The broadcast nodes this exists for have inputs and **no outputs**, so + * `{ output: n }` cannot describe them: it would name a slot the backend + * never declared and force it to execute a node that produces nothing. + * Resolved exactly as `Resolver`'s `forwardTo`, so it chains through + * reroutes for free. + */ + | { readonly forwardInput: number } +} + +export interface SupplyView { + readonly self: ResolvedNodeView + nodesOfType(type: string): readonly ResolvedNodeView[] + /** + * Every unfed input in the graph — what a broadcaster matches against by + * type, by name, or by its own regex. + */ + unconnectedInputs(): readonly UnconnectedInput[] +} + +/** + * Answers "what do I feed", the mirror of `Resolver`'s "what feeds me". + * + * `Resolver` is demand-side: it is asked about the resolver's own outputs, and + * is never called for a node with none. cg-use-everywhere broadcasts a value + * into every matching unconnected input in the graph, which that shape cannot + * express at all — the nodes being fed are not the resolver, and the edges are + * discovered rather than declared. Hence a second, supply-side pass. + * + */ +/** May answer asynchronously, under the same rules as {@link Resolver}. */ +export type Supplier = ( + view: SupplyView +) => readonly SuppliedEdge[] | Promise + +/** + * One winning supply after priority arbitration and source resolution. + */ +export interface ResolvedSupply { + /** The node whose supplier offered this edge. */ + readonly supplierNodeId: string + /** The unconnected input the supplier won. */ + readonly to: InputRef + /** The final source the prompt builder will use. */ + readonly from: ResolvedSource +} + +// ─── settingsHandle.ts ─────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SettingValue = string | number | boolean | readonly string[] + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SettingDef { + /** + * Namespaced, by convention `.` — it shares one space with core + * and every other pack, and it is what the value is stored under forever. + */ + readonly id: string + readonly name: string + /** + * Which control the panel shows. Every one of these is declarative — the + * host renders it. + * + * A pack-supplied renderer is deliberately absent. Core's own setting type + * accepts a function that is handed the value and a setter and returns an + * element; publishing that would put packs in charge of the settings + * panel's markup, which is the thing that cannot then be restyled. Packs + * that needed a colour or a file were falling back to a text field the user + * pasted into, so the gap was the missing *types*, not a missing slot. + */ + readonly type: + | 'boolean' + | 'number' + | 'slider' + | 'knob' + | 'combo' + | 'radio' + | 'text' + | 'password' + | 'color' + | 'image' + | 'url' + readonly defaultValue: SettingValue + readonly tooltip?: string + /** Panel grouping. Defaults to the id split on dots. */ + readonly category?: readonly string[] + /** + * Choices for `combo` and `radio`. + * + * A bare string is both the stored value and the label. Use the pair form + * when they differ — several packs store a semantic number and show words + * for it (`0` = off, `1` = selected, `2` = all), and comparing those + * numerically is the whole point. Flattening them to strings silently + * re-types every user's saved choice. + */ + readonly options?: readonly SettingOption[] + /** + * Bounds for `number` and `slider`. Without these a slider has no range to + * draw and packs fall back to a plain text box. + */ + readonly attrs?: SettingAttrs + readonly onChange?: (value: SettingValue, previous?: SettingValue) => void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SettingOption = + | string + | { readonly value: string | number; readonly label: string } + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SettingAttrs { + readonly min?: number + readonly max?: number + readonly step?: number +} + +export interface SettingsHandle { + /** + * Registers a setting. Call once, at extension load: a value already stored + * for this id survives, so re-declaring cannot reset a user's choice. + */ + declare(def: SettingDef): void + get(id: string): T | undefined + set(id: string, value: SettingValue): Promise + /** + * Watches a setting, including one the pack did not declare. + * + * `declare`'s own `onChange` only fires for settings the pack owns, so a + * pack that needs to react to a *core* preference — colour palette, link + * render mode, locale — had nothing to observe and polled or ignored it. + * + * Fires on change only, not on registration. Returns a function that stops + * watching; call it from wherever the pack tears down. + */ + onChange( + id: string, + listener: (value: T | undefined, previous: T | undefined) => void + ): Unsubscribe +} + +// ─── slotHandle.ts ─────────────────────────────────────────────── + +export interface LinkInfo { + readonly id: string + readonly sourceNodeId: string + readonly sourceSlotId: SlotId + readonly targetNodeId: string + readonly targetSlotId: SlotId + readonly type: string + /** Position at snapshot time. Do not store across mutations. */ + readonly sourceIndex: number + readonly targetIndex: number +} + +/** + * Fields a pack may change on an existing slot. + * + * Applied atomically as one command, so a retype-plus-rename is a single undo + * step rather than two. Retyping deliberately **keeps existing links**: dynamic + * retyping (`*` -> `MODEL`) is the whole point for `SetNode`-style packs, and + * silently dropping connections is the failure mode this API exists to end. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +/** + * A slot's type, which may be a union. + * + * An array spells "this slot accepts any of these" — rgthree's + * `addInput('input', ['IMAGE', 'LATENT', 'MASK'])` is the shipped example, so + * packs do write it even though litegraph's own `ISlotType` says + * `number | string`. + * + * Both forms are accepted and stored as the comma string, because that is what + * litegraph compares against: it normalises with `String(type).split(',')`, so + * `['IMAGE','LATENT','MASK']` and `'IMAGE,LATENT,MASK'` are the same slot to + * every connection check. The saved workflow therefore holds the string where + * the original held an array — a byte difference with no behavioural one, and + * the same call already taken for slot `shape`. + * + * Reads stay `string` for the same reason. + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export type SlotType = string | string[] + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SlotDirection = 'none' | 'up' | 'down' | 'left' | 'right' | 'center' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotPosition { + readonly x: number + readonly y: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotPatch { + name?: string + label?: string | undefined + /** The backend-provided translated caption. Null clears it. */ + localizedName?: string | null + type?: SlotType + /** Slot centre relative to the node body. Null restores automatic layout. */ + position?: SlotPosition | null + /** Direction in which links leave the slot. Null restores the default. */ + direction?: SlotDirection | null + /** + * The dot's colour when connected and when not. + * + * Not decoration, despite appearances: both sit on `INodeSlot` and + * `ISerialisableNodeInput` omits only `boundingRect`, `widget` and `link`, + * so they are written into the saved workflow. A pack that coloured its + * slots and then stopped saves different bytes than it used to. + * + * `null` clears one back to the renderer's default. + */ + color?: string | null + colorWhenUnconnected?: string | null + /** + * Sits on the same `INodeSlot` as the colours above and is omitted by the + * same `Omit`, so the argument made for them holds verbatim: a pack that + * shaped its slots and then stopped saves different bytes than it used to. + * + * `'default'` clears it back to the renderer's own choice. + */ + shape?: SlotShape +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface InputSlotPatch extends SlotPatch { + /** Retargets the widget this input is the socket form of. Null clears it. */ + widget?: string | null + /** Replaces the input declaration used by connected Primitive nodes. */ + widgetConfig?: InputWidgetConfig +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface InputWidgetConfig { + /** Backend input type, or the choices for a COMBO input. */ + readonly type: string | readonly (string | number)[] + readonly options?: Readonly> +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotSnapshot { + readonly id: SlotId + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly localizedName: string | undefined + readonly position: SlotPosition | undefined + readonly direction: SlotDirection | undefined + readonly shape: SlotShape + readonly isConnected: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type ResolvedInputSource = + | { + readonly kind: 'output' + readonly graphId: string + readonly nodeId: string + readonly outputIndex: number + } + | { readonly kind: 'literal'; readonly value: WidgetValue } + | { readonly kind: 'omitted'; readonly reason: string } + +export interface InputSlotHandle { + readonly id: SlotId + /** Volatile — shifts when other slots are added or removed. */ + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly isConnected: boolean + /** The type arriving through the link, including across a subgraph input. */ + readonly connectedType: string | undefined + /** Whether this input is the socket form of a widget. */ + readonly isWidgetInput: boolean + /** The declaration a connected Primitive node renders. */ + widgetConfig(): Readonly | undefined + /** Intersects this input's declaration with another compatible one. */ + mergeWidgetConfig( + config: InputWidgetConfig + ): Readonly | undefined + link(): LinkInfo | undefined + source(): { nodeId: string; outputIndex: number } | undefined + /** + * What ultimately feeds this input after frontend nodes resolve. + * + * `source()` reports the physical link, which is right for editing topology. + * This reports the executable source through reroutes, Get/Set nodes and any + * other frontend node declared with `defs.define({ resolve })`. Resolution is + * read-only and leaves the graph untouched. + */ + resolvedSource(): ResolvedInputSource | undefined + disconnect(): boolean + modify(patch: InputSlotPatch): void + /** Replaces `{...input}`, which now yields nothing useful. */ + snapshot(): Readonly +} + +export interface OutputSlotHandle { + readonly id: SlotId + readonly index: number + readonly name: string + readonly type: string + readonly label: string | undefined + readonly isConnected: boolean + /** Frozen snapshot — safe to iterate while disconnecting. */ + links(): readonly LinkInfo[] + targets(): readonly { nodeId: string; inputIndex: number }[] + connectTo(targetNodeId: string, input: SlotRef): LinkInfo | undefined + disconnect(targetNodeId?: string): boolean + modify(patch: SlotPatch): void + /** + * Moves every link on this output to another output of the same node, + * **preserving link ids**. + * + * Disconnect-and-reconnect is not equivalent: it allocates new ids, so the + * serialized workflow changes. Packs that re-home their own outputs during a + * migration depend on identity being kept. + * + * Slot types are **not** re-validated. The real-world sequence moves links + * off an output and then retypes it, so enforcing compatibility mid-move + * would reject exactly the case this exists for. + */ + moveLinksTo(target: SlotRef): readonly LinkInfo[] + snapshot(): Readonly +} + +export interface SlotCollection { + readonly length: number + get(ref: SlotRef): THandle | undefined + byId(id: SlotId): THandle | undefined + byName(name: string): THandle | undefined + /** Explicit positional access. */ + at(index: number): THandle | undefined + all(): readonly THandle[] + ids(): readonly SlotId[] + names(): readonly string[] + /** + * Adds a slot. 18 packs grow their inputs as the last one fills — the + * "Multi" combiner pattern — which needed `node.addInput` until now. + * + * `shape` is not decoration: it is written into the saved workflow, so a + * slot added without the one its pack used to set serialises differently + * from one the pack itself wrote. `'optional'` is the hollow circle + * ComfyUI draws for an input that need not be connected. + */ + add(name: string, type: SlotType, options?: SlotOptions): THandle + /** + * Removes a slot by reference. Any link into it is dropped, as it would be + * on the legacy path. + */ + remove(ref: SlotRef): boolean + /** + * Puts the slots in the given order. `names` must be a permutation of the + * current ones. + * + * Every link into or out of this node is re-pointed as part of the move, in + * one batch, so link ids — and therefore the saved workflow's `links` array + * — are unchanged. That is the whole reason this exists rather than being + * left to packs: a link stores its endpoint as a slot *index*, so a pack + * permuting the array itself silently re-points every connection, and the + * damage only shows when the workflow is next run. + * + * The slot *order* is serialized, so this changes the saved file by design — + * it is how a pack keeps its dynamic inputs matching what the backend + * declares. + */ + reorder(names: readonly string[]): void + [Symbol.iterator](): Iterator +} + +/** + * How a slot is drawn, which ComfyUI overloads to mean how it behaves. + * + * Named rather than numbered: packs wrote `{ shape: 7 }`, and 7 is meaningless + * without litegraph's RenderShape enum in front of you. + */ +export type SlotShape = 'default' | 'optional' | 'list' | 'directional' + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SlotOptions { + /** + * `'optional'` is the hollow circle for an input that need not be connected, + * `'list'` the grid ComfyUI draws for an output that yields many values, and + * `'directional'` the arrow a pack uses for a slot that only ever feeds one + * particular kind of node. + */ + shape?: SlotShape + localizedName?: string + position?: SlotPosition + direction?: SlotDirection + /** + * Names the widget this slot is the socket form of — the "convert widget to + * input" shape. + * + * Not decoration either: a slot carrying it serialises as + * `{ widget: { name } }` where a plain socket serialises as `{ pos }`, and + * the widget keeps its place in `widgets_values`. A dynamic input added + * without it changes the saved file. + */ + widget?: string + /** The declaration a connected Primitive node should render. */ + widgetConfig?: InputWidgetConfig +} + +// ─── slotRef.ts ────────────────────────────────────────────────── + +export type SlotId = string & { readonly __brand: 'SlotId' } + +/** + * A slot reference: a string (id or name), or an explicit `{ index }`. + * + * A bare `number` is deliberately not accepted so positional access is visible + * at the call site and greppable: + * + * output.connectTo(node, 'image') // by name — preferred + * output.connectTo(node, { index: 0 }) // by position — explicit + */ +export type SlotRef = SlotId | string | { readonly index: number } + +export interface ResolveOptions { + /** + * Whether the backend supplies slot names yet. While false, a canonical + * integer string resolves positionally, so `'0'` addresses slot 0 and call + * sites need no rewrite once names arrive. + * + * Retire this together with the release that ships names — until then a pack + * passing `'2'` meaning a name would silently bind slot 2. + */ + readonly namedSlotsAvailable: boolean +} + +// ─── storageHandle.ts ──────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface StorageUsage { + /** Total bytes stored under the namespace. */ + readonly usedBytes: number + /** How many entries make up {@link usedBytes}. */ + readonly entryCount: number + /** + * The ceiling this host enforces, or `undefined` where it enforces none. + * + * Undefined is the honest answer for a local install with the user's own + * disk behind it, and it is deliberately not reported as `Infinity`: a pack + * dividing by it to draw a gauge would get a meaningless bar rather than the + * chance to skip drawing one. Do not treat a present number as a promise + * that a write below it succeeds — another namespace shares the same store. + */ + readonly quotaBytes?: number +} + +export interface StorageHandle { + /** + * Names stored under a namespace, which must be one this pack owns. + * + * Empty when nothing has been stored yet — absence is not an error. + */ + list(namespace: string): Promise + /** The stored text, or `undefined` if there is none. */ + get(name: string): Promise + set(name: string, value: string): Promise + remove(name: string): Promise + /** + * What a namespace currently occupies. + * + * For a pack that stores things a user accumulates — presets, captions, + * saved prompts — so it can show what it is holding and offer to prune it, + * rather than growing without bound until someone else's write fails. + */ + usage(namespace: string): Promise +} + +// ─── systemHandle.ts ───────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SystemMonitorCpu { + readonly utilization_percent: number | null +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SystemMonitorMemory { + readonly total: number + readonly available: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SystemMonitorVolume extends SystemMonitorMemory { + readonly id: string + readonly label: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SystemMonitorAccelerator { + readonly id: string + readonly name: string + readonly memory_total: number + readonly memory_available: number + readonly utilization_percent: number | null + readonly temperature_c: number | null +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SystemMonitorSnapshot { + readonly cpu: SystemMonitorCpu + readonly memory: SystemMonitorMemory + readonly volumes: readonly SystemMonitorVolume[] + readonly accelerators: readonly SystemMonitorAccelerator[] +} + +export interface SystemHandle { + /** + * Returns one host-sampled hardware snapshot. Volume ids are opaque and + * unsupported utilization or temperature sensors are null. + */ + monitor(): Promise +} + +// ─── uiHandle.ts ───────────────────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface SidebarTabBase { + /** + * Unique across every pack, so namespace it — `'mtb.assets'`, not + * `'assets'`. Registering an id twice throws rather than silently replacing + * the other pack's tab. + */ + readonly id: string + readonly title: string + /** + * An iconify class, e.g. `'icon-[lucide--activity]'`. Omit for no icon. + */ + readonly icon?: string + readonly tooltip?: string +} + +/** + * A tab the pack draws into a container itself. + * + * Framework-agnostic, and the only form available to a pack that ships + * hand-written ES modules with no build step — which is most of them. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MountedSidebarTab extends SidebarTabBase { + /** + * Fills the tab's panel. Called each time the tab becomes visible, so treat + * it as mount rather than as one-time setup, and put teardown in `destroy`. + */ + render(container: HTMLElement): void + /** Releases what `render` retained — listeners, timers, observers. */ + destroy?(): void +} + +/** + * A tab that is a Vue component, mounted and torn down by the host. + * + * The preferred form where a pack can build. It keeps reactivity, scoped + * styles and `onUnmounted`, and the host mounts and unmounts it. + * + * Per ADR 0005 the pack bundles its own Vue (~30KB gzipped) — there is no + * import map, so `import { defineComponent } from 'vue'` resolves at the + * pack's build time, not ours. That is a second Vue instance on the page, + * which the ADR weighed and accepted; nothing is shared across the boundary, + * so the two runtimes never touch. + */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface VueSidebarTab extends SidebarTabBase { + readonly component: VueComponent +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type SidebarTabDef = MountedSidebarTab | VueSidebarTab + +/** A Vue component bundled by the pack. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type VueComponent = object + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogBase { + /** + * Unique across every pack, so namespace it. The host prefixes it with + * `extension-`, which keeps packs out of the internal dialog keyspace. + */ + readonly key: string + readonly title?: string +} + +/** A bounded keyboard event captured while a mounted dialog owns focus. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogKeyEvent { + readonly key: string + readonly code: string + readonly repeat: boolean + readonly altKey: boolean + readonly ctrlKey: boolean + readonly metaKey: boolean + readonly shiftKey: boolean + /** True for an input, textarea, select, or editable content target. */ + readonly editableTarget: boolean +} + +/** A dialog the pack draws into a container itself. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MountedDialog extends DialogBase { + render(container: HTMLElement): void + /** Receives dialog-scoped key events even before a child takes focus. */ + onKeyDown?(event: DialogKeyEvent): void | Promise + destroy?(): void +} + +/** A dialog that is a Vue component, mounted and torn down by the host. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface VueDialog extends DialogBase { + readonly component: VueComponent + readonly props?: Readonly> +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export type DialogDef = MountedDialog | VueDialog + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface DialogHandle { + close(): void +} + +export interface UiHandle { + /** + * Adds a tab to the sidebar. Returns a function that removes it again. + */ + addSidebarTab(def: SidebarTabDef): Unsubscribe + /** + * Shows a small readout in the top bar — a status, a count, a live metric. + * + * Replaces `app.menu.settingsGroup` and inserting an element next to + * `.comfy-settings-btn`. Declarative on purpose: the pack says what to show + * and the host renders it, in house style and at whatever size the viewport + * allows. Nothing here takes an element, a class or a style, which is what + * keeps the chrome ours to restyle. + * + * Returns a handle rather than an unsubscribe: for a value that changes, + * call `update({ text })`. A closure would not work — the host renders when + * reactive state changes and cannot see a plain function, so the readout + * would show its first value forever. + */ + addTopBarBadge(badge: BadgeContribution): ChromeItemHandle + /** + * Adds a button to the action bar. `run` is called on click. + * + * For a pack that also wants a keyboard shortcut or a palette entry, + * register a command and call it from `run`, rather than duplicating the + * behaviour in both places. + */ + addActionBarButton( + button: ButtonContribution + ): ChromeItemHandle + /** + * Opens a modal dialog. Returns a handle that closes it again. + * + * Replaces `app.ui.dialog` and the `new app.ui.dialog.constructor()` idiom. + * Several conversions hand-rolled a native `` or borrowed core's + * `.comfy-modal` class names instead — the latter couples a pack to markup + * we rename freely, so both are worth retiring. + */ + showDialog(def: DialogDef): DialogHandle + /** + * Shows a menu where the user clicked. + * + * `b.addMenuItem` is the node's own context menu — a different menu, on a + * different target, opened by the host. This is for a menu a pack raises + * itself: a lora row's Move Up / Remove, a chip that picks an output type. + * Four files hand-rolled it by constructing the renderer's menu class + * directly, which pins them to a renderer we intend to replace. + * + * Positioned from the event so the menu lands under the pointer, which is the + * only placement that reads as a context menu. Arrow keys traverse nested + * items, Enter or Tab selects one, and Escape closes the menu. + */ + showMenu(def: MenuDef): MenuHandle + /** + * Asks the user for a value. Resolves `undefined` if they cancel. + * + * Packs called `canvas.prompt(...)`, which draws a small field at the cursor + * — clicking a lora's strength to type a new one. That field belongs to the + * legacy canvas and the host itself no longer uses it; this is the prompt the + * host does use, so a pack keeps the capability and loses only the placement. + */ + prompt(def: PromptDef): Promise +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface PromptDef { + /** What is being asked for — "Strength", "Label". */ + readonly label: string + readonly value?: string + readonly placeholder?: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuItemDef { + readonly label: string + /** Shown but not selectable. */ + readonly disabled?: boolean + /** A nested menu. Mutually exclusive with {@link run}. */ + readonly submenu?: readonly MenuItemDef[] + run?(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuDef { + readonly items: readonly MenuItemDef[] + /** Shown above the items. */ + readonly title?: string + /** The event that asked for the menu; it decides where the menu appears. */ + readonly event: MouseEvent +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface MenuHandle { + close(): void +} + +// ─── widgetHandle.ts ───────────────────────────────────────────── + +// `null` is included because core's own `WidgetValue` has it and +// `addWidget('button', name, null, cb)` produced exactly that. Omitting it made +// a null value inexpressible through the published API, so a converted button's +// `widgets_values` entry changed and the saved workflow differed. +export type WidgetValue = string | number | boolean | object | undefined | null + +/** Options understood by core or by a widget type declared by the pack. */ +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetOptions { + readonly [key: string]: unknown + readonly on?: string + readonly off?: string + readonly max?: number + readonly min?: number + readonly precision?: number + readonly read_only?: boolean + readonly step?: number + readonly step2?: number + readonly multiline?: boolean + readonly property?: string + readonly socketless?: boolean + readonly canvasOnly?: boolean + readonly hideInPanel?: boolean + readonly nodeType?: string + readonly serialize?: boolean + readonly values?: unknown + readonly iconClass?: string + readonly disabled?: boolean + readonly useGrouping?: boolean + readonly placeholder?: string + readonly showThumbnails?: boolean + readonly showItemNavigators?: boolean + readonly hidden?: boolean +} + +/** + * Shapes follow `src/types/extensionV2.ts`, the agreed extension contract. + * + * Accessor methods rather than properties, so a read can be a store query and + * a write can dispatch a command. + */ +export interface WidgetHandle extends HandleCommon { + readonly name: string + readonly widgetType: string + + getValue(): T + /** + * Commits a value exactly as a user edit does: the value is written, a + * widget bound to a node property syncs it, the widget's callback chain and + * the node's `onWidgetChanged` run, and `graph.version` advances. This + * replaces the manual pair `widget.value = x; widget.callback?.(x)` — and + * the bare write too, because a write the rest of the system cannot see was + * never a feature, it was litegraph defaulting to inconsistency. + * + * Writing the current value again is a no-op, which is also what ends a + * cycle of handlers writing to each other. `on('change')` fires once per + * commit; `on('activate')` does not fire, because activate reports a user's + * act. + */ + setValue(value: WidgetValue): void + + /** + * The widgets core attached to this one — a seed's `control_after_generate`, + * a bounding box's components. + * + * `setHidden` already cascades through these, so hiding needs no call here. + * What does is reading one: a pack asks a seed's control widget whether it + * says `fixed` or `randomize` to know what the node will do next. + */ + linked(): readonly WidgetHandle[] + /** + * Replaces the controls attached to this widget. + * + * Core uses this relationship for compound inputs: hiding a seed also hides + * its `control_after_generate` picker. Packs build the same compound control + * when they add a random-seed button or an index policy, and assigning + * `linkedWidgets` directly was the only way to make conversion-to-input hide + * the whole unit. + * + * Every name must identify another widget on this node. Pass an empty array + * to clear the relationship. + */ + setLinked(names: readonly string[]): void + + isHidden(): boolean + /** + * Replaces the `type = 'converted-widget'` hack. Value is retained. + * + * Cascades to the widgets core attached to this one — a seed's + * `control_after_generate`, a bounding box's components. The legacy + * `hideWidget` helper this replaces recursed through `linkedWidgets`, and + * packs that lost the cascade were left with an orphaned control widget + * floating where its owner used to be. + */ + setHidden(hidden: boolean): void + getOptions(): Readonly | undefined + setOption(key: string, value: unknown): void + setLabel(label: string): void + + isDisabled(): boolean + setDisabled(disabled: boolean): void + isSerialized(): boolean + /** The height the host most recently allocated, or undefined before layout. */ + getHeight(): number | undefined + /** + * Pins the widget's height in graph units, instead of letting it share + * whatever space the node has spare. + * + * The node divides free height between every widget that does not state one, + * so a node carrying two mounted strips gave each half the node however + * small they were meant to be. `MountDef.height` does not do this — it sets + * the container's CSS height *inside* an allocation the renderer already + * chose, which is why a fixed strip still drifted. + * + * Replaces re-assigning `node.computeSize`, which is what packs did and + * which is not published. Omit it for a panel meant to fill the node: the + * growable path is the one that fills. + */ + setHeight(px: number): void + + /** + * Replaces capture-and-chain on `widget.callback`, which 1,000+ sites do and + * which silently drops an earlier pack's listener whenever one forgets to + * call through. Listeners here are additive and independent. + */ + on( + event: 'change', + listener: (value: WidgetValue, oldValue: WidgetValue) => void + ): Unsubscribe + on(event: 'removed', listener: () => void): Unsubscribe + /** + * The widget was activated — a button click, or a value committed. + * + * Buttons carry no value, so `change` can never fire for one and a button + * created through this API would otherwise be inert. Prefer `change` when you + * care about the value; use this when you care that the user acted — a + * programmatic `setValue` never fires it. + */ + on(event: 'activate', listener: (value: WidgetValue) => void): Unsubscribe + /** + * Contributes behavior to a host-owned multiline text editor without exposing + * its DOM. The event reports the live value and caret on each input, + * selection change, or wheel gesture; its write method preserves both the + * widget commit protocol and the requested selection. + */ + on( + event: 'textInteraction', + listener: (event: WidgetTextInteractionEvent) => void + ): Unsubscribe + /** + * The value is about to be written out, and may be replaced for this + * destination only. + * + * This is what `widget.serializeValue` did, and the reason it is back: a + * static `serialize` flag can only *suppress* a value, and a whole class of + * packs needs to *supply* a different one. rgthree's Seed keeps the sentinel + * `-1` in the saved workflow and sends the rolled seed; pysssss' PresetText + * expands `@name` into the queued prompt while the user keeps seeing the + * reference; Impact Pack embeds image data the canvas never shows. + * + * `context` says which destination is being built, because those packs want + * to change one and not the other: + * + * - `'workflow'` — the file the user saves. + * - `'prompt'` — the queued API payload the backend executes. + * - `'embedded'` — the copy of the workflow that travels with that prompt + * and is written into the output image. Distinct from `'workflow'` + * because a pack may want the image to reproduce the run while the saved + * file keeps its sentinel: rgthree's Seed saves `-1` but embeds the seed + * it actually rolled, so dragging the PNG back in reproduces it. + * + * A handler that ignores `context` changes all three. + * + * Calling `setSerializedValue` replaces the value for this write only; the + * widget itself is untouched, so the user still sees what they typed. Last + * handler to call it wins. + */ + on( + event: 'beforeSerialize', + listener: (event: WidgetSerializeEvent) => void + ): Unsubscribe +} + +/** Where a value is being written, and the chance to change it. */ +export interface WidgetSerializeEvent { + readonly context: 'workflow' | 'prompt' | 'embedded' + /** What would be written if no handler intervened. */ + readonly value: WidgetValue + setSerializedValue(value: WidgetValue): void +} + +export type Unsubscribe = () => void + +/** + * A widget whose body the pack renders itself. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface MountDef { + readonly name: string + /** + * Fills the mounted container. Called once, with an element already attached + * to the node. + * + * `value` holds meaningful serialized state only when `defaultValue` was + * given. A decorative mount receives the same accessor for one render shape, + * but should not use it as storage. + */ + render(container: HTMLElement, value: MountedValue): void + /** Releases anything `render` retained — listeners, timers, observers. */ + destroy?(): void + /** Reserved height in graph units. Omit to size to content. */ + readonly height?: number + /** Set false to keep the element rendered at low zoom. Defaults to true. */ + readonly hideOnZoom?: boolean + readonly hidden?: boolean + /** + * Whether the value is written into the saved workflow. + * + * Defaults to `true` when `defaultValue` makes this a value-holding control, + * and to `false` for a decorative mount. + */ + readonly serialize?: boolean + /** + * Whether the value is sent in the API prompt. Defaults to `serialize`. + * + * These are two different flags in litegraph — `widget.serialize` gates the + * saved workflow, `options.serialize` gates the prompt — and collapsing them + * into one boolean made two states unsayable. "Saved but not sent" is the + * one packs need: it is exactly what the legacy + * `addDOMWidget(…, { serialize: false })` did, and a readout that a node + * fills in from its own execution result belongs in the workflow but has no + * business appearing as an input on the next queue. + * + * Set it apart from `serialize` only when the two genuinely differ. + */ + readonly sendToPrompt?: boolean + /** + * Makes this a value-holding widget rather than decoration. + * + * Without it a mount is a drawing: it can occupy a `widgets_values` slot but + * has nothing to put in it, so a colour picker or a text box converted onto + * `mount` kept its position and silently lost what the user typed. Supplying + * a default gives the widget a real cell, reachable through `render`'s second + * argument. + */ + readonly defaultValue?: MountedData +} + +/** What a mounted control can hold. @knipIgnoreUnusedButUsedByCustomNodes */ +export type MountedData = string | number | boolean | object | null + +/** + * Reading and writing a mounted widget's value. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface MountedValue { + get(): MountedData + set(value: MountedData): void + /** Notified when the value changed elsewhere — a workflow load. */ + onChange(listener: (value: MountedData) => void): Unsubscribe +} + +/** + * A pointer event on the widget's own canvas, in the same units `draw` uses. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface CanvasPointerEvent { + /** Distance from the canvas's left edge, in CSS pixels. */ + readonly x: number + /** Distance from its top edge, in CSS pixels. */ + readonly y: number + /** The DOM event, for modifier keys, `button`, and `preventDefault()`. */ + readonly event: PointerEvent +} + +/** + * The colours a pack should draw its own controls in. + * + * Published because we told packs to draw. A widget that hardcodes its palette + * looks wrong the moment the user switches theme, and the alternative — reading + * `LiteGraph.WIDGET_BGCOLOR` and friends — is a renderer constant we intend to + * delete. These are the design system's own tokens, resolved from the widget's + * computed style, so they follow the theme without the pack knowing which one + * is active. + * + * Named by intent rather than by token, because the token names will churn and + * a pack should not have to follow. Re-read on every draw, so a theme switch + * needs nothing from the pack. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface CanvasTheme { + /** A control's background. */ + readonly surface: string + /** The same under the pointer. */ + readonly surfaceHovered: string + /** A control's outline. */ + readonly border: string + /** A label. */ + readonly text: string + /** A value, a unit, anything the label outranks. */ + readonly textSecondary: string +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CanvasDef { + readonly name: string + /** Reserved height in pixels. Omit to size to the node's width. */ + readonly height?: number + draw( + context: CanvasRenderingContext2D, + size: readonly [number, number], + theme: CanvasTheme, + value: MountedValue | undefined + ): void + /** + * The pointer went down on this widget. + * + * Coordinates are relative to the canvas and in the same units `draw` + * receives, so a hit test written against the drawing works unchanged — + * which is the point. A pack that drew its own controls keeps both the + * drawing and the hit testing; only the surface changes, from the host's + * canvas to its own. + * + * The primary button is taken: it stops here rather than also reaching the + * node, or adjusting a slider would drag the node underneath it. Middle and + * right are left alone, so panning and the context menu still work over the + * widget. + * + * The pointer is captured for the gesture, so a drag that leaves the widget + * still reports moves and the release. + */ + onPointerDown?(event: CanvasPointerEvent): void + /** Moves during a drag, and hover when no button is down. */ + onPointerMove?(event: CanvasPointerEvent): void + onPointerUp?(event: CanvasPointerEvent): void + /** + * The secondary button went down on this widget. + * + * Right-click is left alone by {@link onPointerDown} so the node's own + * context menu keeps working over a widget, which is right by default and + * wrong for a widget that has its own menu — a lora row wants Move Up, Move + * Down, Remove. Declaring this claims the gesture: the browser menu is + * suppressed and the node's does not open. + */ + onContextMenu?(event: CanvasPointerEvent): void + /** + * Makes the surface hold a value rather than only draw one. + * + * Without it a drawn control that stores something has to be two widgets — a + * hidden value widget and a surface — and two widgets cannot occupy the one + * position the original had. That is not a tidiness point: `serialize` writes + * at each widget's own index and leaves a hole where a non-serializing widget + * sits, so the pair has to be ordered value-first to keep the saved array + * intact, and a pack that gets that wrong writes a null into every workflow + * the node has ever appeared in. It moved rgthree's Power Puter chip row + * below its code box. + * + * `draw` receives the current value as its fourth argument. + */ + readonly defaultValue?: MountedData + /** Whether the value reaches the saved workflow. See {@link MountDef.serialize}. */ + readonly serialize?: boolean + /** Whether the value reaches the API prompt. See {@link MountDef.sendToPrompt}. */ + readonly sendToPrompt?: boolean +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface CanvasHandle { + readonly widget: WidgetHandle + /** Redraws now. Call when the data behind the drawing changed. */ + redraw(): void +} + +/** Everything needed to create a widget. */ +export interface WidgetDef { + readonly type: string + readonly name: string + readonly value?: WidgetValue + readonly options?: WidgetOptions + /** Display-only widgets — replaces the readOnly/opacity DOM fiddling. */ + readonly disabled?: boolean + readonly hidden?: boolean + /** + * Whether the value is written into the saved workflow. + * + * Replaces `widget.serializeValue = async () => {}`, the idiom packs use to + * keep a derived readout out of `widgets_values`. Orthogonal to `hidden`. + */ + readonly serialize?: boolean +} + +export interface WidgetCollection { + readonly length: number + get(name: string): WidgetHandle | undefined + at(index: number): WidgetHandle | undefined + all(): readonly WidgetHandle[] + names(): readonly string[] + /** + * Replaces splice/assign reordering. `names` must be a permutation of the + * current names — a partial list throws rather than silently dropping + * widgets, which is how the array-splice idiom lost them. + */ + reorder(names: readonly string[]): void + move(name: string, toIndex: number): void + /** + * Creates a widget on this node. + * + * The counterpart to `remove` — packs that rebuild a readout widget do + * remove-then-create, and without this only half the operation has a + * destination, which makes the conversion cosmetic. + */ + add(def: WidgetDef): WidgetHandle + /** + * Mounts an element on the node and hands it to the pack to fill. + * + * The replacement for `addDOMWidget`, and the destination for hand-painted + * canvas controls. Across kjnodes' canvas editors the drawing is rectangles, + * images, straight lines and text — all DOM primitives — but a pack that + * wants to keep its existing `ctx` code can append a `` to the + * container and carry it over unchanged. + * + * The gain is not the drawing, it is the input: these editors hand-roll + * hit-testing against bounding boxes because canvas gives them nothing to + * attach a listener to. Mounted in the DOM, pointer events land on the + * element and most of that code goes away. + */ + mount(def: MountDef): WidgetHandle + /** + * A per-node drawing surface, and the destination for `onDrawForeground`. + * + * Works under both renderers without the pack knowing which it is on: the + * canvas is a DOM element, which the legacy renderer positions over the + * graph canvas and Nodes 2.0 renders directly. That is the whole reason it + * is a mounted element rather than a hook into the graph's own context — + * drawing into the shared context is what ties a pack to the old renderer. + * + * `draw` is called on mount, on resize, and whenever `redraw()` is called. + */ + canvas(def: CanvasDef): CanvasHandle + remove(name: string): boolean + [Symbol.iterator](): Iterator +} + +export interface ComboPreviewRegistration { + /** Namespaced registration id. */ + readonly id: string + /** Managed model catalogues searched in order. */ + readonly modelCategories: readonly ( + | 'loras' + | 'checkpoints' + | 'unet' + | 'diffusion_models' + )[] + /** Model filename suffixes that activate this policy. */ + readonly extensions: readonly ( + | 'safetensors' + | 'sft' + | 'pt' + | 'ckpt' + | 'gguf' + )[] + /** Host-owned adjacent-preview lookup policy. */ + readonly candidatePolicy: 'adjacent-model-preview-v1' + /** Preview media types the host may display. */ + readonly media: readonly ( + | 'image/png' + | 'image/webp' + | 'image/jpeg' + | 'video/mp4' + | 'video/webm' + )[] +} + +export interface ComboPreviewAssignment { + /** Managed model catalogue containing `modelValue`. */ + readonly category: 'loras' | 'checkpoints' | 'unet' | 'diffusion_models' + /** Logical model filename from the managed combo; never a host path. */ + readonly modelValue: string + /** Graph node whose host-owned output image is used as the preview. */ + readonly sourceNodeId: string + /** Exact image in that node's current host-owned output list. */ + readonly imageIndex: number + readonly policy: 'adjacent-model-preview-v1' +} + +export interface WidgetsHandle { + /** + * Adds a declarative preview policy to host-owned combo option menus. + * The host resolves managed assets and renders the hover surface; the pack + * receives neither filesystem paths nor media URLs. + */ + registerComboPreview(definition: ComboPreviewRegistration): Unsubscribe + /** + * Re-encodes one managed graph output as an adjacent managed-model preview. + * The host resolves both resources; the pack receives no path or image bytes. + */ + assignComboPreview(assignment: ComboPreviewAssignment): Promise +} + +export type LocalizationMessage = + | string + | null + | { readonly [key: string]: LocalizationMessage } + +export interface LocalizationCatalog { + /** Native vue-i18n-shaped messages such as main/nodeDefs/nodeCategories. */ + readonly messages: Readonly> + /** Exact-source fallback translations used only at host-owned render points. */ + readonly phrases?: Readonly> +} + +export interface LocalizationHandle { + /** + * Contributes one bounded catalog for a host-supported locale. The host + * owns merging, rendering, precedence, and cleanup; no DOM access is given. + */ + registerCatalog(locale: string, catalog: LocalizationCatalog): Unsubscribe +} + +// ─── widgetTextInteraction.ts ──────────────────────────────────── + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextSelection { + readonly start: number + readonly end: number +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextEventBase { + readonly value: string + readonly selection: WidgetTextSelection + /** Positions a host menu at the text editor without exposing its element. */ + readonly menuEvent: MouseEvent + /** Commits through the widget protocol and optionally restores the caret. */ + setValue(value: string, selection?: WidgetTextSelection): void + focus(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextInputEvent extends WidgetTextEventBase { + readonly kind: 'input' | 'selection' +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextWheelEvent extends WidgetTextEventBase { + readonly kind: 'wheel' + readonly deltaY: number + readonly ctrlKey: boolean + /** Claims the wheel gesture so the canvas does not pan or zoom. */ + preventDefault(): void +} + +/** @knipIgnoreUnusedButUsedByCustomNodes */ +export interface WidgetTextKeyEvent extends WidgetTextEventBase { + readonly kind: 'keydown' + readonly key: string + readonly ctrlKey: boolean + readonly altKey: boolean + readonly shiftKey: boolean + readonly metaKey: boolean + readonly repeat: boolean + preventDefault(): void + stopPropagation(): void +} + +/** + * An interaction with a host-owned multiline text editor. + * + * This is the renderer-independent replacement for reaching through + * `widget.inputEl`: packs can inspect the live caret, offer a menu through + * `menuEvent`, replace text, and implement selection-based wheel edits without + * receiving the host's element or markup. + */ +export type WidgetTextInteractionEvent = + | WidgetTextInputEvent + | WidgetTextWheelEvent + | WidgetTextKeyEvent + +// ─── widgetTypes.ts ────────────────────────────────────────────── + +/** What a pack-declared widget can hold. */ +export type WidgetTypeData = string | number | boolean | object | null + +/** + * Reading and writing the widget's value, for the renderer to bind to. + * + * @knipIgnoreUnusedButUsedByCustomNodes + */ +export interface WidgetTypeValue { + get(): WidgetTypeData + set(value: WidgetTypeData): void + /** Notified when the value changes for any other reason — a workflow load. */ + onChange(listener: (value: WidgetTypeData) => void): Unsubscribe +} + +export interface WidgetTypeContext { + /** A frozen snapshot of the input declaration's current options. */ + getOptions(): Readonly> + /** + * Runs while the widget's owning node belongs to a graph. + * + * Widget constructors run before a node has an id or graph, so a node handle + * cannot be supplied directly to `render`. The listener runs after the node + * joins a graph and tears down when it leaves. + */ + onNodeReady(listener: (node: NodeHandle) => Unsubscribe | void): Unsubscribe +} + +export interface WidgetTypeDef { + /** Used when the definition supplies none. */ + readonly defaultValue?: WidgetTypeData + /** Height in pixels. Omit to size to content. */ + readonly height?: number + /** Smallest width the control needs, in pixels. */ + readonly minWidth?: number + /** Smallest height the control needs, in pixels. */ + readonly minHeight?: number + /** + * Whether the value is saved and sent. Defaults to `true`: this widget holds + * a real input value, unlike a mounted decoration. + */ + readonly serialize?: boolean + /** + * Fills the container. Return a teardown if the control owns listeners, + * timers or observers. + * + * `name` is the input being rendered — controls commonly label themselves + * with it, which a type-level renderer otherwise has no way to know. + */ + render( + container: HTMLElement, + value: WidgetTypeValue, + name: string, + context: WidgetTypeContext + ): Unsubscribe | void +} + +// ─── workflowHandle.ts ─────────────────────────────────────────── + +/** Parsed ComfyUI workflow JSON. */ +export type WorkflowData = Readonly> + +export interface WorkflowImportContext { + readonly name: string + readonly type: string +} + +export type WorkflowImportResult = + | { readonly workflow: WorkflowData | string } + | { readonly prompt: Readonly> | string } + +export interface WorkflowImporter { + /** Namespaced and unique within the pack. */ + readonly id: string + readonly mimeTypes?: readonly string[] + readonly extensions?: readonly string[] + /** Per-file limit; the host-wide ceiling is 16 MiB. */ + readonly maxBytes: number + enabled?(): boolean | Promise + parse( + bytes: Uint8Array, + context: WorkflowImportContext + ): + | WorkflowImportResult + | null + | undefined + | Promise +} + +export interface WorkflowHandle { + /** Replaces the active document with parsed ComfyUI workflow JSON. */ + open(data: WorkflowData): Promise + /** Returns the current saved-format workflow, bounded to 8 MiB. */ + snapshot(): Promise + /** Registers a bounded worker-side parser for host-opened or dropped files. */ + registerImporter(importer: WorkflowImporter): Unsubscribe + /** Expands the active document's `%date:...%` and `%Node.widget%` tokens. */ + applyTextReplacements(value: string): string + /** + * The active document's identity: a process-local id minted fresh each time + * a workflow finishes loading — including a second load of the same file, + * which gets a different id from the first. `undefined` before the first + * workflow has loaded this page load. + * + * Distinct from the workflow's own saved identity (its file path, or the + * `id` written into the workflow JSON): that one is meant to survive a + * reload and compare equal across sessions. This one is the opposite by + * design — it exists so a pack can tell "the document I was looking at got + * replaced" from "the document I was looking at got edited", which + * comparing graph contents cannot do, since editing IS mutating the graph + * contents of the very document that is still current. + * + * Equivalent to `current()?.id`, and kept because reading the id is the + * common case and does not need a handle. + */ + documentId(): string | undefined + /** + * The document on screen, or `undefined` before one is open. + * + * A handle rather than the bare id when a pack needs to know what it is + * looking at — the name to label its own UI, whether there are unsaved + * edits, and whether a document it stored state for is still open. + * + * Read-only: opening has its own explicit call, and saving, closing and + * renaming belong to the user. + */ + current(): DocumentHandle | undefined +} From 61fb4b83b4276939de91d922b9be910d2cabc7c0 Mon Sep 17 00:00:00 2001 From: Ben Cooley Date: Wed, 2 Sep 2026 14:13:49 -0700 Subject: [PATCH 2/4] docs(custom-nodes): correct two contract errors found in review `ResolvedSource` for `kind: 'output'` carries `nodeId` and `output`. The page listed `graphId` and `outputIndex`, neither of which the declaration defines, so code copied from it would have read undefined. Resolvers may be asynchronous: `Resolver` returns `Record | Promise<...>`, and prompt construction awaits it. The page said a resolver must be synchronous, which would have led authors to reject supported implementations. What is actually true is narrower, and now stated: the synchronous readers cannot wait, so `resolvedSource()` and `resolvedSupplies()` report a pending resolver as unresolved. Also drops a marketing clause from the migration sentence, aligns the opening line on "V2" rather than "2.0", and replaces a dash-delimited clause with parentheses. Not fixed here: the em dashes in the generated reference prose come from JSDoc in `comfy-api.d.ts`. Editing the rendered page would break the generator's drift check; the declaration is where that belongs. --- custom-nodes/v2/index.mdx | 4 ++-- custom-nodes/v2/javascript/definitions.mdx | 4 ++-- custom-nodes/v2/javascript/execution.mdx | 6 ++++-- custom-nodes/v2/javascript/slots-links.mdx | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/custom-nodes/v2/index.mdx b/custom-nodes/v2/index.mdx index ef3e1434a..8166c739e 100644 --- a/custom-nodes/v2/index.mdx +++ b/custom-nodes/v2/index.mdx @@ -3,7 +3,7 @@ title: "Getting started" description: "Build JavaScript extensions for ComfyUI nodes with the published frontend API." --- -The Custom Nodes 2.0 frontend SDK is the published JavaScript API for extending ComfyUI node behavior, widgets, graphs, and interface features. +The Custom Nodes V2 frontend SDK is the published JavaScript API for extending ComfyUI node behavior, widgets, graphs, and interface features. A pack's frontend extension can: @@ -88,7 +88,7 @@ The host selects either the top level as the V1 pack root or `v2/` as the V2 pac V2 frontend modules and their assets live under `v2/web/`. A similarly named file in the root `web/` tree is not a V2 asset. -For an existing pack, the recommended migration path is MAGIC PATCH, which uses Claude Code or Codex installed on your computer with the same conversion skills Comfy uses for its pack catalog. See [Migrate legacy frontend code](/custom-nodes/v2/javascript/migration-recipes) for the frontend mappings it produces. +For an existing pack, the recommended migration path is MAGIC PATCH, a local conversion tool that drives Claude Code or Codex on your own machine. See [Migrate legacy frontend code](/custom-nodes/v2/javascript/migration-recipes) for the frontend mappings it produces. ## Build durable node code diff --git a/custom-nodes/v2/javascript/definitions.mdx b/custom-nodes/v2/javascript/definitions.mdx index 3e61f3faf..12edecc66 100644 --- a/custom-nodes/v2/javascript/definitions.mdx +++ b/custom-nodes/v2/javascript/definitions.mdx @@ -71,8 +71,8 @@ than making each pack capture and call a previous prototype method. | `hideWidget(name)` | Hide a declared widget while retaining its value. | | `addMenuItem(item)` | Add a host-rendered context-menu entry. | -Structural changes to a live node - dynamic slots, values, ordering, or -connections - belong on the instance handles supplied by lifecycle callbacks. +Structural changes to a live node (dynamic slots, values, ordering, or +connections) belong on the instance handles supplied by lifecycle callbacks. ### Lifecycle and behavior hooks diff --git a/custom-nodes/v2/javascript/execution.mdx b/custom-nodes/v2/javascript/execution.mdx index 71ae21920..0ec8c5da9 100644 --- a/custom-nodes/v2/javascript/execution.mdx +++ b/custom-nodes/v2/javascript/execution.mdx @@ -203,8 +203,10 @@ The resolver receives a frozen `ResolveView`: - `self.input(nameOrIndex)` creates the only reference a resolver may forward. Resolution follows chains to a physical backend output, literal, or omission, -with cycle detection. A resolver must be pure and synchronous. It cannot edit -the graph or a prompt draft. +with cycle detection. A resolver must be pure: it cannot edit the graph or a +prompt draft. It may return a promise, and prompt construction awaits it. The +synchronous readers cannot wait, so `input.resolvedSource()` and +`resolvedSupplies()` report a pending resolver as unresolved. `InputSlotHandle.resolvedSource()` exposes the same final result for editor behavior without changing topology. diff --git a/custom-nodes/v2/javascript/slots-links.mdx b/custom-nodes/v2/javascript/slots-links.mdx index f101264eb..325d57dc3 100644 --- a/custom-nodes/v2/javascript/slots-links.mdx +++ b/custom-nodes/v2/javascript/slots-links.mdx @@ -226,7 +226,7 @@ const executable = input.resolvedSource() An executable source is one of: -- `{ kind: 'output', graphId, nodeId, outputIndex }`; +- `{ kind: 'output', nodeId, output }`; - `{ kind: 'literal', value }`; - `{ kind: 'omitted', reason }`. From 208d9aed2ef9cfb0aee8fa5483352c496bf038ab Mon Sep 17 00:00:00 2001 From: Ben Cooley Date: Wed, 2 Sep 2026 16:58:02 -0700 Subject: [PATCH 3/4] docs(custom-nodes): restore the correct resolvedSource() shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts one of the two changes in 61fb4b83. The contract defines two similar types, and the review finding — and my verification of it — matched the wrong one. `ResolvedSource` (comfy-api.d.ts:2004) is `{ kind: 'output', nodeId, output }` and describes where an output lands after the resolver chain runs. `ResolvedInputSource` (:2367) is `{ kind: 'output', graphId, nodeId, outputIndex }`, and that is what `InputSlotHandle.resolvedSource()` returns (:2405) — which is the call this page documents. The original text was right. The other half of 61fb4b83 stands: `Resolver` does return `Record | Promise<...>` (:1997-2001), so resolvers may be asynchronous. --- custom-nodes/v2/javascript/slots-links.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom-nodes/v2/javascript/slots-links.mdx b/custom-nodes/v2/javascript/slots-links.mdx index 325d57dc3..f101264eb 100644 --- a/custom-nodes/v2/javascript/slots-links.mdx +++ b/custom-nodes/v2/javascript/slots-links.mdx @@ -226,7 +226,7 @@ const executable = input.resolvedSource() An executable source is one of: -- `{ kind: 'output', nodeId, output }`; +- `{ kind: 'output', graphId, nodeId, outputIndex }`; - `{ kind: 'literal', value }`; - `{ kind: 'omitted', reason }`. From 3943dfcf84cce6e71305b4e7834908beda010156 Mon Sep 17 00:00:00 2001 From: Ben Cooley Date: Fri, 4 Sep 2026 15:49:17 -0700 Subject: [PATCH 4/4] docs(custom-nodes): stop wrapping single pages in their own section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtering the shared source down to the JavaScript pages left the first three sections holding one page each, so the sidebar showed three folders that each opened onto a single article. "Getting started" was the worst of them: a folder whose only child was a page also titled "Getting started". Merged into one opening section that follows the reading order — overview, the handle and lifecycle model, then the tutorial — and retitled the landing page to "Overview" so it no longer repeats its container. Sections are now 3, 11 and 10 pages, with no singletons. No page was added, removed, or reordered within a section. --- custom-nodes/v2/index.mdx | 2 +- docs.json | 16 ++-------------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/custom-nodes/v2/index.mdx b/custom-nodes/v2/index.mdx index 8166c739e..2c1e481c2 100644 --- a/custom-nodes/v2/index.mdx +++ b/custom-nodes/v2/index.mdx @@ -1,5 +1,5 @@ --- -title: "Getting started" +title: "Overview" description: "Build JavaScript extensions for ComfyUI nodes with the published frontend API." --- diff --git a/docs.json b/docs.json index dd823ad26..4305f9c7f 100644 --- a/docs.json +++ b/docs.json @@ -3117,20 +3117,8 @@ "group": "Getting started", "icon": "rocket", "pages": [ - "custom-nodes/v2/index" - ] - }, - { - "group": "Main concepts", - "icon": "lightbulb", - "pages": [ - "custom-nodes/v2/javascript/concepts" - ] - }, - { - "group": "Tutorial", - "icon": "graduation-cap", - "pages": [ + "custom-nodes/v2/index", + "custom-nodes/v2/javascript/concepts", "custom-nodes/v2/javascript/tutorial" ] },