Files
ComfyUI_frontend/src/extension-api/node.ts
Connor Byrne 2f102353fa feat(extension-api): converge DOMWidget ↔ Widget via mount-lifecycle (D-widget-converge, A12)
Per D-widget-converge ACCEPTED 2026-05-18 (W6.P3.B PICK: option iii) and
new Axiom A12 (Mount-Lifecycle as the Sole DOM Seam):

- Add WidgetCleanup, WidgetMountContext, WidgetMountFn to widget.ts. The
  mount context provides widget/node handles plus onUnmount /
  onBeforeRemount / onAfterRemount lifecycle hooks. Cleanup fires on
  destruction only; host remount uses the remount hooks.
- Replace WidgetExtensionOptions.created({render, destroy}) with optional
  mount: WidgetMountFn. Authors capture host element via closure inside
  mount() — there is no widget.element accessor on the handle.
- Delete DOMWidgetOptions interface from node.ts.
- Delete NodeHandle.addDOMWidget() method (replaced by defineWidget + the
  same addWidget call as native widgets).
- Update WidgetHandle.setHeight() JSDoc — applies to widgets registered
  with a mount() body, not "DOM widgets" as a category.
- Drop DOMWidgetOptions from public barrel; add WidgetCleanup,
  WidgetMountContext, WidgetMountFn to the barrel.

Phase A gates (lint + format:check + knip) pass. typecheck/test on v2
stack continue to fail per AGENTS.md Rule #8 (parallel-paths stack).

Migration path: v1 patterns (addDOMWidget, widget.element, widget.inputEl,
addWidget('unregistered-type', …)) keep working through the v1 surface
during the D6 parallel-paths window and break deliberately at D6 Phase D
sunset. See decisions/D-widget-converge.md §Migration-guide rows.
2026-05-18 14:46:51 -07:00

568 lines
18 KiB
TypeScript

/**
* NodeHandle — the controlled surface for node access in v2 extensions.
*
* Reads query ECS World components directly. Writes dispatch commands
* (undo-able, serializable, validatable). Events are backed by Vue
* reactivity watching World component changes.
*
* @packageDocumentation
*/
import type { AsyncHandler, Handler, Unsubscribe } from './events'
import type { WidgetHandle, WidgetOptions } from './widget'
import type { NodeEntityId } from '@/world/entityIds'
/**
* Branded entity ID for nodes. Prevents mixing node IDs with widget IDs
* at compile time. Re-exported from the world layer so the entire codebase
* shares a single brand. The underlying value is `string` in Phase A
* (e.g. `node:<graphUuid>:<localId>`).
*
* @internal Per D20 — extension authors use `node.id: string` and
* `node.equals(other)`. The branded type is reserved for internal package
* modules and is intentionally absent from the published barrel.
*/
export type { NodeEntityId }
/**
* A 2D point as `[x, y]`.
*
* **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to
* mutate via `node.getPosition()[0] = X` raise a TypeScript error. Use
* {@link NodeHandle.setPosition} to move the node.
*/
export type Point = readonly [x: number, y: number]
/**
* A 2D size as `[width, height]`.
*
* **Immutable tuple per D-immutability-enforcement (Hybrid C).** Attempts to
* mutate via `node.getSize()[0] = X` raise a TypeScript error. Use
* {@link NodeHandle.setSize} to resize the node.
*/
export type Size = readonly [width: number, height: number]
/**
* LiteGraph node execution mode.
*
* String values map to the underlying `LGraphEventMode` numeric enum
* (`ALWAYS=0`, `ON_EVENT=1`, `NEVER=2`, `ON_TRIGGER=3`, `BYPASS=4`).
*
* - `'always'` — Execute every run (default).
* - `'never'` — Muted; node is skipped during execution.
* - `'bypass'` — Passthrough; inputs forwarded to outputs without running.
* - `'once'` — Execute once then mute.
* - `'onTrigger'` — Legacy ABI-reserved slot for the dead trigger/action
* subsystem; gated behind `LiteGraph.do_add_triggers_slots` (always
* `false`). Has no behavioural effect in the current scheduler. Reserved
* for compatibility — **do not use in new extensions**. Flagged for removal
* by AUDIT-LG.4 / AUDIT-LG.5.
*
* Cross-ref: research/architecture/audit-litegraph-pruning.md
* §AUDIT-LG.4 §AUDIT-LG.5
*/
export type NodeMode = 'always' | 'never' | 'bypass' | 'once' | 'onTrigger'
/**
* Direction of a slot on a node.
*/
export type SlotDirection = 'input' | 'output'
/**
* Read-only snapshot of a single slot (input or output) on a node.
*
* Identity is opaque per D20: use `slot.id` and `slot.equals(other)` for
* comparisons; do not parse the string format.
*/
export interface SlotInfo {
/** Opaque identifier for this slot. Treat as a string token; do not parse. */
readonly id: string
/** Slot name as declared in `INPUT_TYPES` or `addInput`/`addOutput`. */
readonly name: string
/** Slot type string (e.g. `'IMAGE'`, `'LATENT'`, `'*'`). */
readonly type: string
/** Whether this is an input or output slot. */
readonly direction: SlotDirection
/** Opaque identifier of the node this slot belongs to. */
readonly nodeId: string
/**
* Returns `true` if `other` represents the same slot entity as this one.
* Equivalent to `this.id === other.id` but the canonical comparator.
*/
equals(other: SlotInfo): boolean
}
/**
* Branded entity ID for slots. Prevents mixing slot IDs with node/widget IDs.
*
* Phase A uses synthetic content-addressed format: `slot:${nodeId}:${direction}:${index}`.
* Phase B will migrate to opaque UUIDs when ECS adds slot entity support.
*
* @internal Per D20 — extension authors use `slot.id: string` and
* `slot.equals(other)`. The branded type is intentionally absent from the
* published barrel.
*/
export type SlotEntityId = string & { readonly __brand: 'SlotEntityId' }
/**
* Payload for `node.on('executed', handler)`.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern.
*
* @example
* ```ts
* node.on('executed', (e) => {
* const text = e.output['text'] as string[]
* previewWidget.setValue(text.join('\n'))
* })
* ```
*/
export interface NodeExecutedEvent {
/** The backend execution output for this node. Shape varies by node type. */
readonly output: Record<string, unknown>
}
/**
* Payload for `node.on('connected', handler)`.
*
* Replaces `nodeType.prototype.onConnectInput` / `onConnectOutput` and
* `nodeType.prototype.onConnectionsChange` patching.
*/
export interface NodeConnectedEvent {
/** The local slot that was connected. */
readonly slot: SlotInfo
/** The remote slot on the other node. */
readonly remote: SlotInfo
}
/**
* Payload for `node.on('disconnected', handler)`.
*/
export interface NodeDisconnectedEvent {
/** The local slot that was disconnected. */
readonly slot: SlotInfo
}
/**
* Payload for `node.on('positionChanged', handler)`.
*/
export interface NodePositionChangedEvent {
/** The new position. */
readonly pos: Point
}
/**
* Payload for `node.on('sizeChanged', handler)`.
*/
export interface NodeSizeChangedEvent {
/** The new size. */
readonly size: Size
}
/**
* Payload for `node.on('modeChanged', handler)`.
*/
export interface NodeModeChangedEvent {
/** The new execution mode. */
readonly mode: NodeMode
}
/**
* Payload for `node.on('beforeSerialize', handler)`.
*
* @deprecated Node-level serialization control will be removed in v1.0.
* Use widget-level `widget.on('beforeSerialize')` instead. Store extension
* state in widgets rather than arbitrary node fields.
*
* **Why widget-level is better:**
* - Widget values are visible at predictable locations in workflow JSON
* - Cleaner separation between framework and extension concerns
* - Widget serialization hooks support async operations
*
* See ADR-0010 for full migration guidance.
*
* @stability experimental
*/
export interface NodeBeforeSerializeEvent {
/** Which serialization path triggered this. */
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The mutable serialized node object. Mutate in place to append fields.
* Type intentionally loose — the exact shape is `ISerialisedNode`.
*/
readonly data: Record<string, unknown>
/**
* Replace the serialized object by providing a transform function.
* `fn` receives the current `data` and should return the replacement.
* Calling this multiple times chains: each call's `fn` receives the
* previous call's output.
*/
replace(fn: (orig: Record<string, unknown>) => Record<string, unknown>): void
}
/**
* Controlled surface for node access. Reads query the ECS World; writes
* dispatch commands. Events are Vue-reactive watches on World components.
*
* @example
* ```ts
* import { defineNode } from '@comfyorg/extension-api'
*
* export default defineNode({
* name: 'my-size-enforcer',
* nodeTypes: ['MyCustomNode'],
*
* nodeCreated(node) {
* const [w, h] = node.getSize()
* node.setSize([Math.max(w, 300), Math.max(h, 200)])
*
* node.on('executed', (e) => {
* console.log('output:', e.output)
* })
* }
* })
* ```
*/
export interface NodeHandle {
// ── IDENTITY ──────────────────────────────────────────────────────────────
/**
* Opaque identifier for this node. Stable for the lifetime of the node
* entity. Treat as a string token: do not parse, slice, or compare its
* internal structure. Use {@link NodeHandle.equals} to compare with
* another handle.
*
* @remarks
* Per D20, the underlying value is a branded `NodeEntityId` at runtime
* but is narrowed to `string` on the public surface so authors never
* need to import a brand to type a local variable.
*/
readonly id: string
/**
* Returns `true` if `other` represents the same node entity as this one.
* Equivalent to `this.id === other.id` but the canonical comparator —
* prefer `equals` over manual string comparison so future changes to the
* identity scheme remain transparent.
*/
equals(other: NodeHandle): boolean
/**
* The LiteGraph node type string (e.g. `'KSampler'`).
* Read-only invariant: set at construction, never changes.
*
*/
readonly type: string
/**
* The ComfyUI backend class name (e.g. `'KSampler'`).
* Equal to `type` for most nodes; differs for reroute/virtual nodes.
* Read-only invariant.
*
*/
readonly comfyClass: string
// ── SPATIAL STATE ─────────────────────────────────────────────────────────
/**
* Returns the node's current canvas position as `[x, y]`.
*
*/
getPosition(): Point
/**
* Moves the node to a new canvas position. Dispatches a `MoveNode` command.
*
*/
setPosition(pos: Point): void
/**
* Returns the node's current size as `[width, height]`.
*
*/
getSize(): Size
/**
* Resizes the node. Dispatches a `ResizeNode` command.
*
*/
setSize(size: Size): void
// ── VISUAL STATE ──────────────────────────────────────────────────────────
/**
* Returns the node's display title. Defaults to the node type string.
*
*/
getTitle(): string
/**
* Sets the node's display title. Dispatches a `SetNodeVisual` command.
*
*/
setTitle(title: string): void
/**
* Returns `true` if the node is currently selected on the canvas.
*
*/
isSelected(): boolean
// ── EXECUTION MODE ────────────────────────────────────────────────────────
/**
* Returns the node's current execution mode.
*
*/
getMode(): NodeMode
/**
* Sets the node's execution mode. Dispatches a `SetNodeMode` command.
*
*/
setMode(mode: NodeMode): void
// ── PROPERTIES (migration shim) ───────────────────────────────────────────
/**
* Returns a per-node-instance property by key.
*
* In v2, prefer routing persistent state through widget values or
* `beforeSerialize` events. `node.properties` is kept as a migration shim
* for v1 extensions that used it for per-instance widget config (e.g. min/max).
*
*/
getProperty<T = unknown>(key: string): T | undefined
/**
* Returns a copy of all per-node-instance properties.
*
*/
getProperties(): Record<string, unknown>
/**
* Sets a per-node-instance property. Dispatches a `SetNodeProperty` command.
*
* In v2, prefer `widget.setOption(key, value)` for widget-scoped per-instance
* config (it persists to the `widget_options` sidecar in the workflow JSON).
*
*/
setProperty(key: string, value: unknown): void
// ── WIDGETS ───────────────────────────────────────────────────────────────
/**
* Returns a `WidgetHandle` for the named widget, or `undefined` if no such
* widget exists on this node.
*
* @example
* ```ts
* const steps = node.getWidget('steps')
* if (steps) steps.setValue(20)
* ```
*/
getWidget(name: string): WidgetHandle | undefined
/**
* Returns all widgets on this node as `WidgetHandle` instances.
*
* **Immutable view per D-immutability-enforcement (Hybrid C).** The returned
* array cannot be mutated (`push`, `splice`, `length =`, index assignment
* all raise TS errors). Each `WidgetHandle` is also surface-frozen — use
* the `WidgetHandle` setter methods (`setValue`, `setHidden`, etc.) to
* mutate widget state. To add or remove widgets, use
* {@link NodeHandle.addWidget} / future `removeWidget` (W6.P8.UNMIGRATABLE).
*
* @example
* ```ts
* // ❌ TS-ERR — readonly array; v1 patterns no longer compile
* node.getWidgets().push(newWidget)
* node.getWidgets()[0] = newWidget
*
* // ✅ Iterate / read freely
* for (const w of node.getWidgets()) console.log(w.name)
* const labels = node.getWidgets().map((w) => w.label)
* ```
*/
getWidgets(): ReadonlyArray<Readonly<WidgetHandle>>
/**
* Adds a new widget to this node.
*
* @param type - Widget type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`).
* @param name - Unique widget name on this node.
* @param defaultValue - Initial value.
* @param options - Optional type-specific options.
* @returns The new `WidgetHandle`.
*/
addWidget(
type: string,
name: string,
defaultValue: unknown,
options?: Partial<WidgetOptions>
): WidgetHandle
// NOTE: `addDOMWidget(opts)` was removed per D-widget-converge / Axiom A12.
// Custom DOM widgets are now registered via `defineWidget({type, mount})`
// and instantiated through the same `addWidget(type, name, …)` call as
// every other widget. The runtime invokes the registered `mount(host, ctx)`
// hook against a per-widget host `<div>` it owns. See `WidgetMountFn` and
// `WidgetMountContext` in `./widget` for the lifecycle contract.
// ── SLOTS ─────────────────────────────────────────────────────────────────
/**
* Returns all input slots on this node.
*
* **Immutable view per D-immutability-enforcement (Hybrid C).** The returned
* array and each slot are `Readonly` — `node.getInputs().push(...)`,
* `node.getInputs()[i] = X`, and `node.getInputs()[i].name = "x"` all raise
* TypeScript errors at compile time. Per-slot mutators (`setInputName`,
* `replaceInput`, bulk field setters) are tracked under
* W6.P8.UNMIGRATABLE / D-input-output-shape.
*
* @example
* ```ts
* // ❌ TS-ERR — readonly array; v1 patterns no longer compile
* node.getInputs().push({ name: 'x', type: 'INT' })
* node.getInputs()[0].name = 'renamed'
*
* // ✅ Read / iterate freely
* const types = node.getInputs().map((s) => s.type)
* ```
*/
getInputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* Returns all output slots on this node.
*
* **Immutable view per D-immutability-enforcement (Hybrid C).** Same
* read-only semantics as {@link NodeHandle.getInputs}. Per-slot mutators
* tracked under W6.P8.UNMIGRATABLE / D-input-output-shape.
*/
getOutputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* @deprecated Use {@link NodeHandle.getInputs} instead. Renamed to align
* with the `getX()` accessor convention (D11/D-immutability-enforcement).
* Will be removed in v1.0.
*/
inputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* @deprecated Use {@link NodeHandle.getOutputs} instead. Renamed to align
* with the `getX()` accessor convention (D11/D-immutability-enforcement).
* Will be removed in v1.0.
*/
outputs(): ReadonlyArray<Readonly<SlotInfo>>
// ── EVENTS ────────────────────────────────────────────────────────────────
/**
* Subscribe to node removal (graph deletion, not subgraph promotion).
*
* Replaces the v1 `nodeType.prototype.onRemoved` patching pattern.
* Does NOT fire on subgraph promotion — the node's entity ID is preserved
* across promotion.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'removed', handler: Handler<void>): Unsubscribe
/**
* Subscribe to backend execution completion for this node.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern (the
* most widely used anti-pattern per R4-P3; 5+ confirmed repos).
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'executed', handler: Handler<NodeExecutedEvent>): Unsubscribe
/**
* Subscribe to workflow hydration (node loaded from a saved workflow).
*
* Replaces the v1 `nodeType.prototype.onConfigure` / `loadedGraphNode`
* patterns. Fires after all widget values are restored from the workflow JSON.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'configured', handler: Handler<void>): Unsubscribe
/**
* Subscribe to slot connection events.
*
* Replaces `nodeType.prototype.onConnectInput`, `onConnectOutput`, and
* `onConnectionsChange` patching patterns (R4-P4: six distinct signatures
* in the wild — this single typed event resolves the confusion).
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'connected', handler: Handler<NodeConnectedEvent>): Unsubscribe
/**
* Subscribe to slot disconnection events.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'disconnected',
handler: Handler<NodeDisconnectedEvent>
): Unsubscribe
/**
* Subscribe to canvas position changes.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'positionChanged',
handler: Handler<NodePositionChangedEvent>
): Unsubscribe
/**
* Subscribe to node size changes.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'sizeChanged', handler: Handler<NodeSizeChangedEvent>): Unsubscribe
/**
* Subscribe to execution mode changes.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'modeChanged', handler: Handler<NodeModeChangedEvent>): Unsubscribe
/**
* Subscribe to node serialization. Async-capable.
*
* @deprecated Node-level serialization control will be removed in v1.0.
* Use widget-level `widget.on('beforeSerialize')` instead — store extension
* state in widgets rather than arbitrary node fields. See ADR-0010.
*
* **Migration example:**
* ```ts
* // BEFORE (deprecated)
* node.on('beforeSerialize', (e) => {
* e.data['my_extension_state'] = computeState()
* })
*
* // AFTER (recommended)
* const stateWidget = node.addWidget('STRING', '_my_state', '', { hidden: true })
* stateWidget.on('beforeSerialize', (e) => {
* e.setSerializedValue(JSON.stringify(computeState()))
* })
* ```
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<NodeBeforeSerializeEvent>
): Unsubscribe
}