feat: extension v2 API proposal — NodeHandle/WidgetHandle with ECS bridge

Model the new extension API in code rather than docs:

- extensionV2.ts: Public types (NodeHandle, WidgetHandle, branded entity IDs,
  defineNodeExtension/defineWidgetExtension)
- extensionV2Service.ts: Bridge layer that wraps LGraphNode/IBaseWidget in the
  stable NodeHandle/WidgetHandle interfaces, with EffectScope-based lifecycle
  management per extension+entity pair
- Three core extensions converted as proof-of-concept:
  - previewAny.v2.ts (90 lines prototype patching -> 49 lines clean)
  - dynamicPrompts.v2.ts
  - imageCrop.v2.ts

This is pseudocode-grade -- it typechecks but is not wired into the app.
The bridge reads from LGraphNode directly; once ADR 0008 ECS World
exists, internals swap to World.getComponent() while NodeHandle stays
identical.

Key decisions modeled:
- Events over signals (node.on('executed', fn) not effect/derived)
- getValue/setValue over .value (widget.getValue() dispatches commands)
- nodeTypes filter over beforeRegisterNodeDef (no prototype patching)
- Automatic scope cleanup on entity removal
This commit is contained in:
bymyself
2026-04-14 21:09:27 -07:00
parent e34548724d
commit fa2f79ad3a
5 changed files with 599 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
/**
* DynamicPrompts — rewritten with the v2 extension API.
*
* v1: reads node.widgets, assigns widget.serializeValue
* v2: same logic, uses WidgetHandle instead of raw widget
*/
import { defineNodeExtension } from '@/services/extensionV2Service'
import { processDynamicPrompt } from '@/utils/formatUtil'
defineNodeExtension({
name: 'Comfy.DynamicPrompts.V2',
nodeCreated(node) {
for (const widget of node.widgets()) {
if (widget.getOptions().dynamicPrompts) {
widget.setSerializeValue((_workflowNode, _widgetIndex) => {
const value = widget.getValue<string>()
return typeof value === 'string'
? processDynamicPrompt(value)
: value
})
}
}
}
})

View File

@@ -0,0 +1,18 @@
/**
* ImageCrop — rewritten with the v2 extension API.
*
* v1: 13 lines, accesses node.size and node.constructor.comfyClass directly
* v2: 12 lines, uses NodeHandle — type filtering via nodeTypes option
*/
import { defineNodeExtension } from '@/services/extensionV2Service'
defineNodeExtension({
name: 'Comfy.ImageCrop.V2',
nodeTypes: ['ImageCropV2'],
nodeCreated(node) {
const [w, h] = node.getSize()
node.setSize([Math.max(w, 300), Math.max(h, 450)])
}
})

View File

@@ -0,0 +1,49 @@
/**
* PreviewAny — rewritten with the v2 extension API.
*
* Compare with previewAny.ts (v1) which uses beforeRegisterNodeDef +
* prototype patching + manual callback chaining.
*
* v1: 90 lines, prototype.onNodeCreated override, prototype.onExecuted override
* v2: 35 lines, no prototype access, no manual chaining
*/
import { defineNodeExtension } from '@/services/extensionV2Service'
defineNodeExtension({
name: 'Comfy.PreviewAny.V2',
nodeTypes: ['PreviewAny'],
nodeCreated(node) {
const markdown = node.addWidget('MARKDOWN', 'preview_markdown', '', {
hidden: true,
readonly: true,
serialize: false
})
markdown.setLabel('Preview')
const plaintext = node.addWidget('STRING', 'preview_text', '', {
multiline: true,
readonly: true,
serialize: false
})
plaintext.setLabel('Preview')
const toggle = node.addWidget('BOOLEAN', 'previewMode', false, {
labelOn: 'Markdown',
labelOff: 'Plaintext'
})
toggle.on('change', (value) => {
markdown.setHidden(!value)
plaintext.setHidden(value as boolean)
})
node.on('executed', (output) => {
const text = (output.text as string | string[]) ?? ''
const content = Array.isArray(text) ? text.join('\n\n') : text
markdown.setValue(content)
plaintext.setValue(content)
})
}
})

View File

@@ -0,0 +1,337 @@
/**
* Extension V2 Service — wires the new extension API to the existing system.
*
* This is the bridge layer. It:
* 1. Registers v2 extensions alongside v1 extensions
* 2. Creates NodeHandle/WidgetHandle wrappers around LGraphNode/BaseWidget
* 3. Manages EffectScopes per extension+entity for automatic cleanup
* 4. Dispatches nodeCreated/loadedGraphNode hooks to v2 extensions
*
* The LGraphNode bridge will be replaced by direct ECS World access once
* ADR 0008 is implemented. The NodeHandle interface stays the same.
*/
import { EffectScope, onScopeDispose } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
NodeEntityId,
NodeExtensionOptions,
NodeHandle,
Point,
Size,
SlotInfo,
WidgetEntityId,
WidgetExtensionOptions,
WidgetHandle,
WidgetOptions
} from '@/types/extensionV2'
// ─── Scope Registry ──────────────────────────────────────────────────
// One EffectScope per extension+node pair. When the node is removed,
// scope.stop() auto-cleans all watchers, listeners, and derived state.
const scopeRegistry = new Map<string, EffectScope>()
function scopeKey(extensionName: string, entityId: number): string {
return `${extensionName}:${entityId}`
}
function getOrCreateScope(extensionName: string, entityId: number): EffectScope {
const key = scopeKey(extensionName, entityId)
let scope = scopeRegistry.get(key)
if (!scope) {
scope = new EffectScope(true)
scopeRegistry.set(key, scope)
}
return scope
}
function disposeScope(extensionName: string, entityId: number): void {
const key = scopeKey(extensionName, entityId)
const scope = scopeRegistry.get(key)
if (scope) {
scope.stop()
scopeRegistry.delete(key)
}
}
// ─── Bridge: LGraphNode → NodeHandle ─────────────────────────────────
// Wraps the legacy class in the stable public interface.
// Once ECS World exists, this reads from World components instead.
let nextEntityId = 1
const nodeEntityMap = new WeakMap<LGraphNode, NodeEntityId>()
function getNodeEntityId(node: LGraphNode): NodeEntityId {
let id = nodeEntityMap.get(node)
if (id === undefined) {
id = nextEntityId++ as NodeEntityId
nodeEntityMap.set(node, id)
}
return id
}
function createWidgetHandle(
widget: IBaseWidget,
_extensionName: string
): WidgetHandle {
const entityId = nextEntityId++ as WidgetEntityId
const listeners: Record<string, Set<Function>> = {}
const handle: WidgetHandle = {
entityId,
name: widget.name,
widgetType: String(widget.type),
getValue<T = unknown>(): T {
return widget.value as T
},
setValue(value: unknown) {
const old = widget.value
widget.value = value as typeof widget.value
listeners['change']?.forEach((fn) => fn(value, old))
},
isHidden() {
return widget.hidden ?? false
},
setHidden(hidden: boolean) {
widget.hidden = hidden
if (widget.options) widget.options.hidden = hidden
},
getOptions(): WidgetOptions {
return { ...widget.options } as WidgetOptions
},
setOption(key: string, value: unknown) {
if (widget.options) {
;(widget.options as Record<string, unknown>)[key] = value
}
},
setLabel(label: string) {
widget.label = label
},
on(event: string, fn: Function) {
if (!listeners[event]) listeners[event] = new Set()
listeners[event].add(fn)
},
setSerializeValue(
fn: (workflowNode: unknown, widgetIndex: number) => unknown
) {
widget.serializeValue = fn as never
}
}
// Bridge: wire legacy widget.callback to 'change' event
const origCallback = widget.callback
widget.callback = (...args: unknown[]) => {
;(origCallback as Function)?.call(widget, ...args)
const old = widget.value
listeners['change']?.forEach((fn) => fn(args[0], old))
}
return handle
}
function createNodeHandle(
node: LGraphNode,
extensionName: string
): NodeHandle {
const entityId = getNodeEntityId(node)
const listeners: Record<string, Set<Function>> = {}
function on(event: string, fn: Function) {
if (!listeners[event]) listeners[event] = new Set()
listeners[event].add(fn)
// Auto-cleanup when scope stops
onScopeDispose(() => {
listeners[event]?.delete(fn)
})
}
// Wire legacy LGraphNode callbacks → event dispatch.
// Once ECS exists, these become World component watches instead.
const origOnRemoved = node.onRemoved
node.onRemoved = () => {
origOnRemoved?.call(node)
listeners['removed']?.forEach((fn) => fn())
disposeScope(extensionName, entityId)
}
const origOnExecuted = node.onExecuted
node.onExecuted = (output: Record<string, unknown>) => {
origOnExecuted?.call(node, output)
listeners['executed']?.forEach((fn) => fn(output))
}
const origOnConfigure = node.onConfigure
node.onConfigure = function (this: LGraphNode, ...args: unknown[]) {
origOnConfigure?.apply(this, args as never)
listeners['configured']?.forEach((fn) => fn())
}
const handle: NodeHandle = {
entityId,
type: node.type ?? '',
comfyClass: (node.constructor as { comfyClass?: string }).comfyClass ?? '',
// ─── Reads (will become World.getComponent queries) ──────
getPosition(): Point {
return [node.pos[0], node.pos[1]]
},
getSize(): Size {
return [node.size[0], node.size[1]]
},
getTitle() {
return node.title
},
getMode() {
return node.mode
},
getProperty<T = unknown>(key: string): T | undefined {
return node.properties?.[key] as T
},
getProperties() {
return { ...node.properties }
},
isSelected() {
return node.is_selected ?? false
},
// ─── Writes (will become command dispatches) ─────────────
setPosition(pos: Point) {
node.pos[0] = pos[0]
node.pos[1] = pos[1]
listeners['positionChanged']?.forEach((fn) => fn(pos))
},
setSize(size: Size) {
node.setSize(size)
listeners['sizeChanged']?.forEach((fn) => fn(size))
},
setTitle(title: string) {
node.title = title
},
setMode(mode) {
node.mode = mode
listeners['modeChanged']?.forEach((fn) => fn(mode))
},
setProperty(key: string, value: unknown) {
if (!node.properties) node.properties = {}
;(node.properties as Record<string, unknown>)[key] = value
},
// ─── Widgets ─────────────────────────────────────────────
widget(name: string) {
const w = node.widgets?.find((w) => w.name === name)
return w ? createWidgetHandle(w, extensionName) : undefined
},
widgets() {
return (node.widgets ?? []).map((w) => createWidgetHandle(w, extensionName))
},
addWidget(
type: string,
name: string,
defaultValue: unknown,
options?: Partial<WidgetOptions>
) {
// Bridge to legacy widget creation.
// TODO: Replace with ECS entity creation once World exists.
const widget = node.addCustomWidget({
name,
type,
value: defaultValue,
options: options ?? {},
draw: () => {},
computeSize: () => [0, 20]
} as unknown as Parameters<typeof node.addCustomWidget>[0])
return createWidgetHandle(widget, extensionName)
},
// ─── Slots ───────────────────────────────────────────────
inputs() {
return (node.inputs ?? []).map((input) => ({
entityId: nextEntityId++ as unknown as SlotInfo['entityId'],
name: input.name,
type: String(input.type),
direction: 'input' as const,
nodeEntityId: entityId
}))
},
outputs() {
return (node.outputs ?? []).map((output) => ({
entityId: nextEntityId++ as unknown as SlotInfo['entityId'],
name: output.name,
type: String(output.type),
direction: 'output' as const,
nodeEntityId: entityId
}))
},
on: on as NodeHandle['on']
}
return handle
}
// ─── Extension Registry ──────────────────────────────────────────────
const nodeExtensions: NodeExtensionOptions[] = []
const widgetExtensions: WidgetExtensionOptions[] = []
export function defineNodeExtension(options: NodeExtensionOptions): void {
nodeExtensions.push(options)
}
export function defineWidgetExtension(options: WidgetExtensionOptions): void {
widgetExtensions.push(options)
}
/**
* Called by the framework when a node is created.
* Dispatches to all registered v2 node extensions.
*/
export function dispatchNodeCreated(node: LGraphNode): void {
const comfyClass =
(node.constructor as { comfyClass?: string }).comfyClass ?? ''
for (const ext of nodeExtensions) {
// Skip if this extension filters to specific types and this node isn't one
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
if (!ext.nodeCreated) continue
const entityId = getNodeEntityId(node)
const scope = getOrCreateScope(ext.name, entityId)
scope.run(() => {
const handle = createNodeHandle(node, ext.name)
ext.nodeCreated!(handle)
})
}
}
/**
* Called by the framework when a node is loaded from a saved workflow.
*/
export function dispatchLoadedGraphNode(node: LGraphNode): void {
const comfyClass =
(node.constructor as { comfyClass?: string }).comfyClass ?? ''
for (const ext of nodeExtensions) {
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
if (!ext.loadedGraphNode) continue
const entityId = getNodeEntityId(node)
const scope = getOrCreateScope(ext.name, entityId)
scope.run(() => {
const handle = createNodeHandle(node, ext.name)
ext.loadedGraphNode!(handle)
})
}
}

169
src/types/extensionV2.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* Extension V2 API — public types for the new extension system.
*
* This is the stable public contract. Extensions depend on these types.
* Internal implementation (ECS World, commands, scopes) is hidden.
*
* @packageDocumentation
*/
// ─── Branded Entity IDs ──────────────────────────────────────────────
// Compile-time distinct. Prevents passing a WidgetEntityId where a
// NodeEntityId is expected. The underlying value is always `number`.
export type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
export type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
export type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
export type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
// ─── Geometry ────────────────────────────────────────────────────────
export type Point = [x: number, y: number]
export type Size = [width: number, height: number]
// ─── Enums ───────────────────────────────────────────────────────────
export type NodeMode = 0 | 1 | 2 | 3 | 4
export type SlotDirection = 'input' | 'output'
// ─── Slot Info ───────────────────────────────────────────────────────
export interface SlotInfo {
readonly entityId: SlotEntityId
readonly name: string
readonly type: string
readonly direction: SlotDirection
readonly nodeEntityId: NodeEntityId
}
// ─── Widget Options ──────────────────────────────────────────────────
export interface WidgetOptions {
readonly?: boolean
multiline?: boolean
hidden?: boolean
serialize?: boolean
labelOn?: string
labelOff?: string
default?: unknown
[key: string]: unknown
}
// ─── WidgetHandle ────────────────────────────────────────────────────
// Controlled surface for widget access. Backed by WidgetValue component
// in the ECS World. getValue/setValue dispatch commands internally.
// All views (node, properties panel, promoted copy) share the same
// backing WidgetEntityId, so changes from any source fire 'change'.
export interface WidgetHandle {
readonly entityId: WidgetEntityId
readonly name: string
readonly widgetType: string
getValue<T = unknown>(): T
setValue(value: unknown): void
isHidden(): boolean
setHidden(hidden: boolean): void
getOptions(): WidgetOptions
setOption(key: string, value: unknown): void
setLabel(label: string): void
on(event: 'change', fn: (value: unknown, oldValue: unknown) => void): void
on(event: 'removed', fn: () => void): void
setSerializeValue(
fn: (workflowNode: unknown, widgetIndex: number) => unknown
): void
}
// ─── NodeHandle ──────────────────────────────────────────────────────
// Controlled surface for node access. Backed by ECS components.
// Getters query the World. Setters dispatch commands (undo-able,
// serializable, validatable). Events are World component subscriptions
// dispatched through Vue reactivity internally.
export interface NodeHandle {
readonly entityId: NodeEntityId
readonly type: string
readonly comfyClass: string
getPosition(): Point
getSize(): Size
getTitle(): string
getMode(): NodeMode
getProperty<T = unknown>(key: string): T | undefined
getProperties(): Record<string, unknown>
isSelected(): boolean
setPosition(pos: Point): void
setSize(size: Size): void
setTitle(title: string): void
setMode(mode: NodeMode): void
setProperty(key: string, value: unknown): void
widget(name: string): WidgetHandle | undefined
widgets(): readonly WidgetHandle[]
addWidget(
type: string,
name: string,
defaultValue: unknown,
options?: Partial<WidgetOptions>
): WidgetHandle
inputs(): readonly SlotInfo[]
outputs(): readonly SlotInfo[]
on(event: 'removed', fn: () => void): void
on(event: 'executed', fn: (output: Record<string, unknown>) => void): void
on(event: 'configured', fn: () => void): void
on(
event: 'connected',
fn: (slot: SlotInfo, remote: SlotInfo) => void
): void
on(event: 'disconnected', fn: (slot: SlotInfo) => void): void
on(event: 'positionChanged', fn: (pos: Point) => void): void
on(event: 'sizeChanged', fn: (size: Size) => void): void
on(event: 'modeChanged', fn: (mode: NodeMode) => void): void
}
// ─── Extension Registration ─────────────────────────────────────────
export interface NodeExtensionOptions {
/** Unique extension name */
name: string
/** Filter to specific comfyClass names. Omit = runs for all nodes. */
nodeTypes?: string[]
/** Runs once per node instance. Everything declared here is scoped to the node's lifetime. */
nodeCreated?(node: NodeHandle): void
/** Runs for nodes loaded from a saved workflow. */
loadedGraphNode?(node: NodeHandle): void
}
export interface WidgetExtensionOptions {
/** Unique extension name */
name: string
/** Widget type string this extension provides (e.g., 'COLOR_PICKER') */
type: string
/** Runs once per widget instance. Return a render/destroy pair for custom DOM. */
widgetCreated?(
widget: WidgetHandle,
parentNode: NodeHandle | null
):
| {
render(container: HTMLElement): void
destroy?(): void
}
| void
}
// Re-export ComfyExtension-compatible options for global lifecycle.
// Commands, keybindings, settings, etc. are unchanged from v1.
export interface ExtensionOptions {
name: string
init?(): void | Promise<void>
setup?(): void | Promise<void>
}