Files

3521 lines
136 KiB
TypeScript

/**
* 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/<install-dir>/`, 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 on a local install, a 401 on a hosted one.
* 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<Response>
}
// ─── 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<T> {
/** Changes what is shown. Only the fields given are replaced. */
update(changes: Partial<Omit<T, 'id'>>): 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<PickedFileData | undefined>
/** Opens one bounded multi-file picker; cancellation resolves an empty list. */
pickMany(options: FilePickManyOptions): Promise<PickedFileData[]>
/** Asks the host to download one bounded in-memory file. */
download(options: FileDownloadOptions): Promise<void>
}
// ─── 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<Uint8Array>
aesCbcDecrypt(options: AesCbcDecryptOptions): Promise<Uint8Array>
hmacSha256(options: HmacSha256Options): Promise<Uint8Array>
verifyHmacSha256(options: VerifyHmacSha256Options): Promise<boolean>
}
// ─── 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<string[]>
}
/** Vendor pass-throughs have a weaker stability promise than generic APIs. */
export interface IntegrationsHandle {
readonly ollama: OllamaIntegrationHandle
}
// ─── closedProxy.ts ──────────────────────────────────────────────
/** @knipIgnoreUnusedButUsedByCustomNodes */
export interface PropSpec<TTarget> {
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<TTarget> {
/** Used in errors and `Symbol.toStringTag`, e.g. 'node'. */
readonly kind: string
readonly props: Readonly<Record<string, PropSpec<TTarget>>>
readonly methods?: Readonly<
Record<string, (target: TTarget, ...args: never[]) => 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<string, (target: TTarget, id: string, ...args: never[]) => 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 opaque-origin 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<void>
/** 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<void>
/** 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<Record<string, unknown>>
}>[]
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<Record<string, unknown>>
/** 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<Record<string, unknown>>[]
readonly text: readonly string[]
readonly raw: Readonly<Record<string, unknown>>
}
/**
* 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
}
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<string, unknown>) => 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<boolean | void>
): 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<string, unknown>): 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<string, unknown>): 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<boolean | void>
onRemoved?(node: NodeHandle): void
onSerialize?(node: NodeHandle): Record<string, unknown>
}
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<void>
/**
* 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<T>(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<T = unknown>(key: string): T | undefined
getProperties(): Readonly<Record<string, unknown>>
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<SizeConstraints>
readonly inputs: SlotCollection<InputSlotHandle>
readonly outputs: SlotCollection<OutputSlotHandle>
readonly widgets: WidgetCollection
snapshot(): Readonly<NodeSnapshot> | undefined
remove(): void
}
/** Per-node collections, supplied by the graph layer that owns their caches. */
export interface NodeCollections {
inputs(nodeId: string): SlotCollection<InputSlotHandle>
outputs(nodeId: string): SlotCollection<OutputSlotHandle>
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<boolean>
/**
* 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<void>
/** 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<boolean>): 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<Record<string, unknown>>
/** 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 sandboxed pack's resolver runs 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<string, OutputResolution>
| Promise<Record<string, OutputResolution>>
/** 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<Record<string, unknown>>
}
/**
* 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<readonly SuppliedEdge[]>
/**
* 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 `<Pack>.<name>` — 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<T extends SettingValue = SettingValue>(id: string): T | undefined
set(id: string, value: SettingValue): Promise<void>
/**
* 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<T extends SettingValue = SettingValue>(
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<Record<string, unknown>>
}
/** @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<InputWidgetConfig> | undefined
/** Intersects this input's declaration with another compatible one. */
mergeWidgetConfig(
config: InputWidgetConfig
): Readonly<InputWidgetConfig> | 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<SlotSnapshot>
}
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<SlotSnapshot>
}
export interface SlotCollection<THandle> {
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<THandle>
}
/**
* 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<readonly string[]>
/** The stored text, or `undefined` if there is none. */
get(name: string): Promise<string | undefined>
set(name: string, value: string): Promise<void>
remove(name: string): Promise<void>
/**
* 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<StorageUsage>
}
// ─── 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<SystemMonitorSnapshot>
}
// ─── 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<void>
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<Record<string, unknown>>
}
/** @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<BadgeContribution>
/**
* 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<ButtonContribution>
/**
* 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 `<dialog>` 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<string | undefined>
}
/** @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 = WidgetValue>(): 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<WidgetOptions> | 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 `<canvas>` 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<WidgetHandle>
}
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<void>
}
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<Record<string, LocalizationMessage>>
/** Exact-source fallback translations used only at host-owned render points. */
readonly phrases?: Readonly<Record<string, string>>
}
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<Record<string, unknown>>
/**
* 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<Record<string, unknown>>
export interface WorkflowImportContext {
readonly name: string
readonly type: string
}
export type WorkflowImportResult =
| { readonly workflow: WorkflowData | string }
| { readonly prompt: Readonly<Record<string, unknown>> | 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<boolean>
parse(
bytes: Uint8Array,
context: WorkflowImportContext
):
| WorkflowImportResult
| null
| undefined
| Promise<WorkflowImportResult | null | undefined>
}
export interface WorkflowHandle {
/** Replaces the active document with parsed ComfyUI workflow JSON. */
open(data: WorkflowData): Promise<void>
/** Returns the current saved-format workflow, bounded to 8 MiB. */
snapshot(): Promise<WorkflowData>
/** 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
}