Compare commits

...

3 Commits

Author SHA1 Message Date
bymyself
cab3f218e2 refactor: reactive extension mounting with Vue setupStatefulComponent pattern
- Replace imperative dispatchNodeCreated/dispatchLoadedGraphNode with
  startExtensionSystem() — a single watcher on world.queryAll(NodeType)
- Extensions mount/unmount as reactive consequence of World state changes
- Add pauseTracking/resetTracking around hook execution (matches Vue's
  setupStatefulComponent to prevent accidental dependency tracking)
- Use LoadedFromWorkflow tag component to select nodeCreated vs
  loadedGraphNode hook
- flush: 'post' ensures extensions mount after DOM updates
2026-04-14 21:33:25 -07:00
bymyself
2f8fe0013b refactor: replace bridge layer with direct ECS imports
Pretend the ECS world/command layer already exists. Import from
@/ecs/world, @/ecs/commands, @/ecs/components, @/ecs/entityIds —
these modules don't exist yet and this won't compile.

The point: show what the service looks like when it uses World
queries and command dispatches directly, with zero LGraphNode
bridge code. NodeHandle/WidgetHandle interface unchanged.
2026-04-14 21:20:07 -07:00
bymyself
fa2f79ad3a 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
2026-04-14 21:09:27 -07:00
5 changed files with 646 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,384 @@
/**
* Extension V2 Service
*
* Manages extension lifecycle: scope creation, handle construction,
* reactive dispatch. The extension system watches the ECS World for
* entity creation/removal and auto-mounts/unmounts extension scopes.
*
* Pattern mirrors Vue's setupStatefulComponent (component.ts:829-927):
* - scope.run() activates the EffectScope (like setCurrentInstance)
* - pauseTracking() prevents accidental deps during setup
* - All watches/effects created inside scope.run() are captured
* - scope.stop() on entity removal cleans up everything
*
* See decisions/D3.5-reactive-dispatch-and-scope-alignment.md
*/
import {
EffectScope,
onScopeDispose,
pauseTracking,
resetTracking,
watch
} from 'vue'
// These modules don't exist yet — they will be created as part of ADR 0008.
import { useWorld } from '@/ecs/world'
import { dispatch } from '@/ecs/commands'
import {
Position,
Dimensions,
NodeVisual,
NodeType,
Execution,
WidgetValue,
WidgetIdentity,
WidgetContainer,
Connectivity,
SlotIdentity,
LoadedFromWorkflow
} from '@/ecs/components'
import type {
NodeEntityId,
WidgetEntityId,
SlotEntityId
} from '@/ecs/entityIds'
import type {
NodeExtensionOptions,
NodeHandle,
WidgetExtensionOptions,
WidgetHandle,
WidgetOptions,
SlotInfo,
Point,
Size
} from '@/types/extensionV2'
// ─── Scope Registry ──────────────────────────────────────────────────
// One EffectScope per extension+entity pair. Disposed when the entity is
// removed from the World (detected by the reactive mount watcher).
const scopeRegistry = new Map<string, EffectScope>()
function getOrCreateScope(
extensionName: string,
entityId: number
): EffectScope {
const key = `${extensionName}:${entityId}`
let scope = scopeRegistry.get(key)
if (!scope) {
scope = new EffectScope(true)
scopeRegistry.set(key, scope)
}
return scope
}
function stopScope(extensionName: string, entityId: number): void {
const key = `${extensionName}:${entityId}`
const scope = scopeRegistry.get(key)
if (scope) {
scope.stop()
scopeRegistry.delete(key)
}
}
// ─── WidgetHandle ────────────────────────────────────────────────────
function createWidgetHandle(widgetId: WidgetEntityId): WidgetHandle {
const world = useWorld()
return {
entityId: widgetId,
get name() {
return world.getComponent(widgetId, WidgetIdentity).name
},
get widgetType() {
return world.getComponent(widgetId, WidgetIdentity).type
},
getValue<T = unknown>(): T {
return world.getComponent(widgetId, WidgetValue).value as T
},
setValue(value: unknown) {
dispatch({ type: 'SetWidgetValue', widgetId, value })
},
isHidden() {
return (
world.getComponent(widgetId, WidgetValue).options?.hidden ?? false
)
},
setHidden(hidden: boolean) {
dispatch({
type: 'SetWidgetOption',
widgetId,
key: 'hidden',
value: hidden
})
},
getOptions(): WidgetOptions {
return world.getComponent(widgetId, WidgetValue).options ?? {}
},
setOption(key: string, value: unknown) {
dispatch({ type: 'SetWidgetOption', widgetId, key, value })
},
setLabel(label: string) {
dispatch({ type: 'SetWidgetLabel', widgetId, label })
},
on(event: string, fn: Function) {
if (event === 'change') {
watch(
() => world.getComponent(widgetId, WidgetValue).value,
(newVal, oldVal) => fn(newVal, oldVal)
)
}
if (event === 'removed') {
onScopeDispose(() => fn())
}
},
setSerializeValue(fn) {
dispatch({ type: 'SetWidgetSerializer', widgetId, serializer: fn })
}
}
}
// ─── NodeHandle ──────────────────────────────────────────────────────
function createNodeHandle(nodeId: NodeEntityId): NodeHandle {
const world = useWorld()
return {
entityId: nodeId,
get type() {
return world.getComponent(nodeId, NodeType).type
},
get comfyClass() {
return world.getComponent(nodeId, NodeType).comfyClass
},
// Reads — direct World queries
getPosition(): Point {
return world.getComponent(nodeId, Position).pos
},
getSize(): Size {
return world.getComponent(nodeId, Dimensions).size
},
getTitle() {
return world.getComponent(nodeId, NodeVisual).title
},
getMode() {
return world.getComponent(nodeId, Execution).mode
},
getProperty<T = unknown>(key: string): T | undefined {
return world.getComponent(nodeId, NodeType).properties?.[key] as T
},
getProperties() {
return { ...world.getComponent(nodeId, NodeType).properties }
},
isSelected() {
return world.getComponent(nodeId, NodeVisual).selected ?? false
},
// Writes — command dispatches
setPosition(pos: Point) {
dispatch({ type: 'MoveNode', nodeId, pos })
},
setSize(size: Size) {
dispatch({ type: 'ResizeNode', nodeId, size })
},
setTitle(title: string) {
dispatch({ type: 'SetNodeVisual', nodeId, patch: { title } })
},
setMode(mode) {
dispatch({ type: 'SetNodeMode', nodeId, mode })
},
setProperty(key: string, value: unknown) {
dispatch({ type: 'SetNodeProperty', nodeId, key, value })
},
// Widgets
widget(name: string) {
const container = world.getComponent(nodeId, WidgetContainer)
const widgetId = container.widgetIds.find((id) => {
return world.getComponent(id, WidgetIdentity).name === name
})
return widgetId ? createWidgetHandle(widgetId) : undefined
},
widgets() {
const container = world.getComponent(nodeId, WidgetContainer)
return container.widgetIds.map(createWidgetHandle)
},
addWidget(type, name, defaultValue, options) {
const widgetId = dispatch({
type: 'CreateWidget',
parentNodeId: nodeId,
widgetType: type,
name,
defaultValue,
options
})
return createWidgetHandle(widgetId)
},
// Slots
inputs() {
const conn = world.getComponent(nodeId, Connectivity)
return conn.inputSlotIds.map((slotId) => {
const slot = world.getComponent(slotId, SlotIdentity)
return {
entityId: slotId,
name: slot.name,
type: slot.type,
direction: 'input' as const,
nodeEntityId: nodeId
} satisfies SlotInfo
})
},
outputs() {
const conn = world.getComponent(nodeId, Connectivity)
return conn.outputSlotIds.map((slotId) => {
const slot = world.getComponent(slotId, SlotIdentity)
return {
entityId: slotId,
name: slot.name,
type: slot.type,
direction: 'output' as const,
nodeEntityId: nodeId
} satisfies SlotInfo
})
},
// Events — backed by World component watches
on(event: string, fn: Function) {
if (event === 'positionChanged') {
watch(
() => world.getComponent(nodeId, Position).pos,
(pos) => fn(pos)
)
} else if (event === 'sizeChanged') {
watch(
() => world.getComponent(nodeId, Dimensions).size,
(s) => fn(s)
)
} else if (event === 'modeChanged') {
watch(
() => world.getComponent(nodeId, Execution).mode,
(m) => fn(m)
)
} else if (event === 'executed') {
world.onSystemEvent(nodeId, 'executed', fn)
} else if (event === 'connected') {
world.onSystemEvent(nodeId, 'connected', fn)
} else if (event === 'disconnected') {
world.onSystemEvent(nodeId, 'disconnected', fn)
} else if (event === 'configured') {
world.onSystemEvent(nodeId, 'configured', fn)
} else if (event === 'removed') {
onScopeDispose(() => fn())
}
}
}
}
// ─── 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)
}
// ─── Reactive Mount System ───────────────────────────────────────────
// Watches the World for entity creation/removal. When a NodeType
// component appears, extensions are mounted for that entity. When it
// disappears, all extension scopes for that entity are stopped.
//
// This replaces the imperative dispatchNodeCreated/dispatchLoadedGraphNode
// pattern. The World is the single source of truth — if an entity
// exists, its extensions are mounted.
/**
* Mount extensions for a newly detected node entity.
*
* Follows Vue's setupStatefulComponent pattern:
* 1. scope.run() activates the EffectScope
* 2. pauseTracking() prevents accidental dependency tracking
* 3. Extension hook runs — explicit watches via node.on() are captured
* 4. resetTracking() restores tracking state
*/
function mountExtensionsForNode(nodeId: NodeEntityId): void {
const world = useWorld()
const comfyClass = world.getComponent(nodeId, NodeType).comfyClass
const isLoaded = world.hasComponent(nodeId, LoadedFromWorkflow)
for (const ext of nodeExtensions) {
if (ext.nodeTypes && !ext.nodeTypes.includes(comfyClass)) continue
const hook = isLoaded ? ext.loadedGraphNode : ext.nodeCreated
if (!hook) continue
const scope = getOrCreateScope(ext.name, nodeId)
scope.run(() => {
pauseTracking()
try {
hook(createNodeHandle(nodeId))
} finally {
resetTracking()
}
})
}
}
/**
* Unmount all extension scopes for a removed node entity.
* scope.stop() disposes all watches, computed, and onScopeDispose
* callbacks created during the extension's setup.
*/
function unmountExtensionsForNode(nodeId: NodeEntityId): void {
for (const ext of nodeExtensions) {
stopScope(ext.name, nodeId)
}
}
/**
* Start the reactive extension mount system.
*
* Called once during app initialization. Watches the World's entity list
* and auto-mounts/unmounts extensions as entities appear/disappear.
*
* This means no code path (add node, paste, load workflow, undo, CRDT
* sync) needs to manually call a dispatch function — the World is the
* single source of truth.
*/
export function startExtensionSystem(): void {
const world = useWorld()
watch(
() => world.queryAll(NodeType),
(currentIds, previousIds) => {
const prev = new Set(previousIds ?? [])
const curr = new Set(currentIds)
for (const nodeId of currentIds) {
if (!prev.has(nodeId)) {
mountExtensionsForNode(nodeId)
}
}
for (const nodeId of previousIds ?? []) {
if (!curr.has(nodeId)) {
unmountExtensionsForNode(nodeId)
}
}
},
{ flush: 'post' }
)
}

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>
}