Compare commits

...

44 Commits

Author SHA1 Message Date
Connor Byrne
24d893d401 docs(ext-api): lift @example onto shell-UI type declarations (close W17.N.2)
typedoc renders re-exported type pages from the original-declaration
site (src/types/extensionTypes.ts), not from the @comfyorg/extension-api
barrel re-export. The shell-UI type aliases (ToastMessageOptions,
HotkeyExtension, AboutBadgeExtension, ToolbarButtonExtension) therefore
shipped to the dashboard without any @example blocks even though their
matching defineX entry points carry full examples in registrations.ts /
imperatives.ts.

Lift the canonical @example from each defineX onto the source
declaration so the dashboard type page renders with a working snippet.

Pure JSDoc — zero exported-type diff. Phase A freeze respected.

Wave-18 W18.J. Closes W17.N.2.
2026-05-21 14:01:24 -07:00
Connor Byrne
3d09f89251 docs(ext-api): refresh @example blocks + deprecate notify (W17.C/D/E)
- W17.C: refresh 5 stale @example blocks (types.ts, lifecycle.ts, node.ts,
  imperatives.ts ×2) — replace deprecated `async setup()` with
  `setup() { onMounted(...) }`; remove A1-removed `node.getWidget` from
  defineNode + NodeHandle.on migration examples; align imperatives examples
  with W17.B notify-toast verdict.
- W17.D: author ~37 new @example blocks across 9 files for sparse exports
  surfaced by W17.A audit. Pure JSDoc — zero exported-type diff.
- W17.E: add @deprecated JSDoc to notify() + NotifyOptions per
  D-notify-toast-consolidation (W17.B verdict (a) soft-deprecate).

Pure JSDoc — no signature, type, or export changes.
Phase A surface remains FROZEN at ee0537fdb5 (foundation surface SHA
unchanged; only @example / @deprecated text differs).

See research/dashboard-snippet-audit-2026-05-21.md (W17.A) for full
per-edit rationale.
2026-05-20 20:07:13 -07:00
Connor Byrne
dd5335df7c fix(review): drop internal task-name refs from app.ts comment (W15.H)
Address PR #12142 review thread PRRT_kwDOMIrOxs6BQjVr —
remove '(I-SR.3 / MIG1.E5)' internal task-tracker reference from the
startExtensionSystem() comment. Wave-11 hygiene cleanup did the same
across src/extension-api/**; this completes the same hygiene for the
single call site in src/scripts/app.ts.

Review: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12142#discussion_r3222947433
2026-05-20 18:29:01 -07:00
Connor Byrne
ee0537fdb5 chore(ext-api): hygiene cleanup — D-design-review-hygiene-cleanup (wave-11)
Closes the (a)+(b) propagation legs for five design-review-12142 rows:

- Row #1 (Comments hygiene): strip 25 HR-style section dividers
  (`// ── XYZ ──`) across widget.ts/node.ts/registrations.ts/events.ts/
  index.ts; strip ~30 sites of decision archaeology refs (D5, D20,
  D-immutability-enforcement (Hybrid C), D-bootstrap-hooks (W6.P6.C),
  D-shell-ui-entrypoints (W6.P5.C), etc.) from per-export JSDoc;
  preserve PHASE_A_EXCLUDED axiom-ref blocks (functional — tells
  axiom-checker + future readers WHY surfaces are absent); preserve
  AXIOMS.md §A1/A14/A15/A16/A12 cross-refs; delete shell.ts
  "What changed in W6.P5" wave-history banner.
- Row #2 (@stability stable): already DONE in code (0 stable hits);
  this ADR closes leg (b).
- Row #4 (apiVersion drop): already DONE in code (0 hits); this ADR
  closes leg (b).
- Row #5 (defineNode/defineWidget short-form): D11 ACCEPTED 2026-05-12;
  one stale JSDoc @example in lifecycle.ts:113,115 fixed
  (defineWidgetExtension → defineWidget).
- Row #6 (Widget-prefixed methods): already DONE via D-bootstrap-hooks
  (unprefixed onMounted/onUnmounted etc.); this ADR closes leg (b).

Also drops two now-unused imports in node.ts (WidgetHandle, WidgetOptions
were only referenced in commented-out A14-deferred surfaces).

ADR: decisions/D-design-review-hygiene-cleanup.md
Net diff: -62 lines, zero behavior change.
Quality gates: lint + format:check + axiom-compliance all green.
2026-05-20 16:09:45 -07:00
Connor Byrne
d5d5692928 feat(ext-api): A15 enforcement on v1 addWidget family — D-ban-runtime-addwidget (wave-10)
Wave-10 follow-up to D-widget-serialization-simplification (wave-9). Closes
the v1 enforcement loop on AXIOMS.md A15 (Widget Declarativity).

User direction 2026-05-20: "we dont need soft deprecate for beforeSerialize
OR addWidget — they just simply shouldnt be present in the new API. for the
old api surfaces, we can just add deprecations with the suggested
remediation."

## v2 surface — absent (no soft-deprecate)
- Delete dead `addWidget` + `addDOMWidget` shims in
  `src/services/extension-api-service.ts` (~30 lines), along with the
  `domWidgetElements` side-table and `getDOMWidgetElement` accessor.
  With the public NodeHandle no longer exposing these methods, all of
  this is unreachable.
- Drop `DOMWidgetOptions` import from the service.
- Scrub 5 v2 JSDoc `@example` blocks that still referenced
  `node.addWidget(...)` as if it were a valid call:
  node.ts (NodeBeforeSerialize migration example), widget.ts (3 spots:
  name doc, label doc, WidgetOptions doc), types.ts (defineWidget doc),
  lifecycle.ts (defineNode example).
- Replace each example with one of A15's three remediation paths
  (boxed widget / non-widget UI primitive / schema input).

## v1 surface — @deprecated + once-per-session console.warn
- Add @deprecated JSDoc + `warnDeprecated` (session-deduped via the
  existing `./utils/feedback` mechanism) on:
  - `LGraphNode.addWidget` (LGraphNode.ts)
  - `LGraphNode.addCustomWidget` (same file)
  - `LGraphNode.prototype.addDOMWidget` (src/scripts/domWidget.ts)
- Shared message constant `ADD_WIDGET_DEPRECATION_MESSAGE` so all three
  warn paths dedupe to one console line per session. Message text cites
  AXIOMS.md A15 + decisions/D-ban-runtime-addwidget.md and enumerates
  the three migration paths.

## First-party core call sites — silenced via sentinel
- New `__suppressDeprecationWarning?: boolean` field on
  `IWidgetOptions` (marked @internal, NOT part of public surface).
- Added to all first-party call sites that haven't yet migrated:
  src/scripts/widgets.ts (valueControl, comboFilter),
  src/extensions/core/uploadAudio.ts (4 sites),
  src/extensions/core/load3d.ts (3 sites),
  src/extensions/core/webcamCapture.ts (2 sites),
  src/extensions/core/customWidgets.ts (1 site).
- `src/scripts/domWidget.ts addWidget()` helper (internal renderer
  registration path) auto-injects the sentinel before delegating to
  `addCustomWidget` — third-party callers of LGraphNode.addCustomWidget
  still see the warning.
- Migration of these first-party sites tracked as wave-9 follow-up
  #2 (preview/progress/audio widgets migration audit).

## Axiom-Excluded Test Annotation Policy — new, codified
- AXIOMS.md: new "Axiom-Excluded Test Annotation Policy" section after
  A14, retiring the "compat-floor blast_radius ≥ N MUST pass" doctrine.
- AGENTS.md Rule 11: tests asserting on axiom-excluded surfaces MUST
  use `axiomExcluded({...})` (vitest test.fails + task.meta.annotations)
  rather than deletion. Deletion only when the *scenario* is no longer
  meaningful.
- decisions/D-ban-runtime-addwidget.md: "Testing policy" section
  documenting concrete application to bc-05 / bc-11 BC tests in tf
  branch (deferred to tf cascade step).

## Decision propagation
- (a) commit on PR branches — this commit (foundation) + cascade
- (b) ADR — `decisions/D-ban-runtime-addwidget.md` ACCEPTED 2026-05-20
- (c) dashboard MDX — refresh via scripts/refresh-api-docs.sh after
  cascade

## Validation
- scripts/check-axiom-compliance.sh: all 7 checks pass (no new strict
  patterns added; A15 already strict-fails on v2 re-introduction per
  wave-9, and the v1 deprecation is enforced at the call-site level
  via the warnDeprecated mechanism)
- oxlint: 0 warnings/errors on all 13 touched files
- oxfmt: applied to all 13 touched files
- tsc: no new errors introduced (pre-existing TS2305/TS2353/TS2307 errors
  from prior waves remain under AGENTS.md Rule 8 carve-out)
- Bundle size shrinks by ~30 lines (dead v2 shims removed)

## Compatibility (D6 parallel-paths)
- v1 path remains fully functional. Deprecation is a soft signal — no
  throw, no exception, no behavior change beyond one console.warn per
  session.
- Existing custom-node packs (R7 sweep: 5+ evidenced) continue to work;
  authors see migration text on first addWidget call.

Refs: AXIOMS.md A15, A16; D-widget-serialization-simplification (wave-9);
ADR-0010 (node-level beforeSerialize deprecation pattern); D6
parallel-paths.
2026-05-20 15:02:02 -07:00
Connor Byrne
e56187adf3 feat(ext-api): A16 closure — D-widget-serialization-simplification (wave-9)
Codify A15 (Widget Declarativity) + A16 (Unified Serialization Target) by
collapsing widget-level serialization to its irreducible core.

Per D-widget-serialization-simplification (wave-9, 2026-05-20) — comment out
in src/extension-api/widget.ts (A14 pattern):

- WidgetHandle.setSerializeEnabled(enabled) / .isSerializeEnabled()
- WidgetBeforeSerializeEvent.context: 'workflow'|'prompt'|'clone'|'subgraph-promote'
- WidgetBeforeSerializeEvent.skip()
- WidgetPropertyChangeEvent (vacuous union after 'serialize' removed)
- WidgetHandle.on('propertyChange', handler) overload
- WidgetOptions.serialize?: boolean key

Per A16, the sole extension-author interface to serialization is
`widget.on('beforeSerialize', handler)`. The framework writes one payload
to every transport (workflow JSON, API prompt, clone, subgraph-promote);
authors do not branch on transport and cannot disable serialization. Per
A15, runtime widget addition was the underlying reason for the disable
matrix — declarativity removes the cause, A16 removes the escape hatch.

Removed framework-side adapters in src/services/extension-api-service.ts:
isSerializeEnabled / setSerializeEnabled blocks (lines 302-315) and the
propertyChange branch of the event dispatcher (line 362). Dropped
WidgetComponentSerialize import (now unused).

Removed WidgetPropertyChangeEvent from src/extension-api/index.ts barrel.

Updated JSDoc: stripped 4-context narrative + skip() examples from
WidgetBeforeSerializeEvent, dropped widget.options.serialize references
from the options accessor, dropped e.context === 'prompt' from the
serializeValue accessor example.

node.on('beforeSerialize') (separate surface) remains `@deprecated` +
runtime-warned per ADR-0010 — NOT banned by this commit (per user
direction 2026-05-20).

Blast radius: 0 v2 ext call sites for any removed surface (verified via
rg sweep across restack-ext-v2/src and foundation extensions/core).
v1 path untouched per D6 parallel-paths migration.

Refs:
- decisions/D-widget-serialization-simplification.md (ACCEPTED 2026-05-20)
- AXIOMS.md §A15 (Widget Declarativity), §A16 (Unified Serialization Target)
- AXIOMS.md §A14 (7 new rows in COMMENTED OUT table)
- D14-serialization-convergence.md (predecessor — promised this end-state)
- D-no-node-widget-access.md (wave-8, structural sibling)
- design-review-12142.md row #11

Verified: scripts/check-axiom-compliance.sh  all checks pass
(A1, A2, A3, A4, A6, A15, A16 — strict-fail on re-introduction)
2026-05-20 13:41:40 -07:00
Connor Byrne
446d0a216e feat(ext-api): bilateral A1 closure — D-no-node-widget-access
Comments out NodeHandle.getWidget(name) + NodeHandle.getWidgets() per
D-no-node-widget-access (ACCEPTED 2026-05-19, ADR landed). Closes the
node→widget direction so that the v2 public surface enforces A1 as
written: 'nodes cannot enumerate widgets' is now bilateral with
'nodes cannot reference widgets' — the widget→node direction
(widget.parentNode) remains the sole node↔widget channel.

Origin: a contradiction was surfaced during handoff-14 (wave-7
post-cascade). AXIOMS.md §A1 is NON-NEGOTIABLE and says 'Node hooks
cannot reference/modify widgets' + 'Widgets can know their parent
node, but nodes cannot enumerate widgets'. However design-review-12142
decision #13 (2026-05-12, 'getWidget seems good') had renamed
widget() → getWidget() without re-checking A1, and a codified
read-only carve-out in scripts/check-axiom-compliance.sh:22 papered
over the gap. The carve-out is removed; the axiom checker is now
strict-fail on any node→widget surface.

Changes:
- node.ts: getWidget(name) + getWidgets() commented out with A1+A2
  rationale block (A2 pattern: 'comment out if not 100% sure')
- widget.ts: 2 JSDoc examples rewritten to use
  defineWidget({mount}, ctx.widget) — the only legal v2 path to a
  WidgetHandle

Migration cost (v2 surface):
- 0 call sites in the tf test suite
- 1 call site in ext-v2 (extensions/core/dynamicPrompts.v2.ts) —
  stubbed pending follow-up 'defineWidgetAugmenter' API; v1 path
  (Comfy.DynamicPrompts) continues to work
- TypeScript-only signature change; no runtime change

Restoration criteria: see AXIOMS.md §A14 entry — a documented use
case unexpressible via widget.parentNode + defineWidget({mount}) +
provide/inject, A1 re-audit pass, and bc-XX test triplet coverage
across promotion / app-mode / linked-instance scenarios.

Refs:
- ADR: decisions/D-no-node-widget-access.md (ACCEPTED 2026-05-19)
- AXIOMS.md §A1 + §A14
- scripts/check-axiom-compliance.sh (strict bilateral check)
2026-05-20 12:35:47 -07:00
Connor Byrne
bd4d195230 chore(ext-api): comment out NodeMode + Slot surfaces per AXIOMS.md A14
Pre-existing A14 enforcement work (was sitting uncommitted across
the wave-6/wave-7 reconciliations). Comments out the following
Phase A exclusions from the public NodeHandle / WidgetHandle surface:

node.ts:
- NodeMode (egregious use patterns per meeting; A2)
- SlotDirection / SlotInfo / SlotEntityId (slot/connection deferred; A2)
- NodeConnectedEvent / NodeDisconnectedEvent
- on('modeChanged'), on('connected'), on('disconnected') subscriptions
- getMode() / setMode()
- inputs() / outputs() slot enumeration
- getPosition()/setPosition()/getSize()/setSize() (deferred pending A13)
- getTitle()/setTitle(), isSelected()

widget.ts:
- isHidden()/setHidden(), isDisabled()/setDisabled()
  (deferred pending serialization convergence)

These align with the AXIOMS.md A14 'COMMENTED OUT' table. No
behavior change — TypeScript interface narrowing only. Restoration
criteria documented in AXIOMS.md A14.

Refs: AXIOMS.md §A14
2026-05-20 12:35:06 -07:00
Connor Byrne
ff314491da feat(extension-api): D-shell-ui-entrypoints (W6.P5.C) — per-surface defineX entries + toast/notify inline imperatives
Adopt per-surface defineX entries plus inline-imperative carve-out for
toast + notify, per D-shell-ui-entrypoints ADR (ACCEPTED 2026-05-18,
foundation impl handoff-13).

## What lands

New shell-UI registration entries (registrations.ts, NEW):
- defineSidebarTab / defineBottomPanelTab — wire to workspace stores
- defineCommand — wires to commandStore.registerCommand
- defineHotkey — parses key combo, wires to keybindingStore
- defineSetting — wires to settingStore.addSetting
- defineAboutBadge / defineToolbarButton — module-level registries
  (consumed by aboutPanelStore / action-bar component via P5.E follow-up)

All defineX return DisposableHandle { dispose(): void }. Lazy-mount
queue (pendingEntries[]) flushed by startExtensionSystem() via the new
_flushShellRegistrations() hook so defineX is safe to call at
module-eval time (before Pinia is ready). Async store imports inside
each mount-fn keep the registration module itself dependency-free.

Inline imperatives (imperatives.ts, NEW):
- toast.show / toast.remove / toast.removeAll
- notify({ kind, message, detail, life }) — thin convenience wrapper
- Fire-and-forget; no DisposableHandle, no defineX wrapper
- 100% imperative across 166 ecosystem hits / 16 repos (R1+R2+R3)

Net-new public arg types (extensionTypes.ts):
- CommandDefinition (alias of v1 ComfyCommand)
- HotkeyExtension (keys + commandId + optional targetElementId)
- AboutBadgeExtension (label/url/icon/severity)
- SettingDefinition<TValue> (alias of SettingParams<TValue>)
- ToolbarButtonExtension (id/icon/label/tooltip/class/onClick)

Barrel reshape (shell.ts + index.ts):
- Re-export the 5 new arg types from shell.ts
- DROP ExtensionManager + CommandManager from public re-export per
  W6.P5.B reconciliation — v2 uses per-surface disposables, not
  umbrella handles. Internal callers still import from
  @/types/extensionTypes directly.
- Export 7 new defineX + DisposableHandle + toast/notify/NotifyOptions
  from index.ts

v1 deprecation (comfy.ts):
- ComfyExtension.{commands, keybindings, settings, bottomPanelTabs,
  aboutPageBadges, actionBarButtons} gain @deprecated JSDoc pointing at
  the v2 defineX equivalent. Slots remain functional for back-compat
  during the deprecation window (v1 extensionService still loads them).
- menuCommands + topbarBadges marked @deprecated with scope-note that
  they're not part of W6.P5 (follow-on ADRs).

Runtime wiring (extension-api-service.ts):
- startExtensionSystem() dynamic-imports registrations and calls
  _flushShellRegistrations() so pending defineX calls land into stores
  exactly once at bootstrap.

Verification:
- tsc --noEmit: zero new errors in modified files (pre-existing node.ts
  / widget.ts errors are uncommitted working-tree state from prior
  agent, not from this work)
- oxfmt: clean
- oxlint: 0 warnings / 0 errors on touched files
- vitest/knip blocked by Node 20-vs-24 env requirement (env, not code)

ADR: decisions/D-shell-ui-entrypoints.md §Implementation plan Steps 1-5.
Steps 6 (codemod) lands in PKG; Step 7 (.d.ts regen) cascades via
restack-after-foundation.sh; Steps 8-13 land in EXT/TF/docs/dashboard.

Per AGENTS.md §Decision propagation: subject contains
'D-shell-ui-entrypoints' so check-decision-propagation.sh picks it up.
2026-05-18 17:45:40 -07:00
Connor Byrne
b3b3b10fea feat(extension-api): D-bootstrap-hooks (W6.P6.C) — context-scoped lifecycle hooks + 4 event-namespace facades
Adopt context-scoped Vue-style lifecycle hooks inside setup() bodies
plus four typed event-channel namespaces, per D-bootstrap-hooks ADR
(ACCEPTED 2026-05-14, foundation impl handoff-11).

## What lands

New lifecycle hooks (extension-api-service.ts + lifecycle.ts barrel):
- onMounted / onBeforeMount / onUnmounted / onActivated / onDeactivated
- All synchronous-only inside defineExtension/SidebarTab/BottomPanelTab.setup
- Throw in dev / no-op in prod outside context
- onActivated/onDeactivated kind-restricted to sidebarTab/bottomPanel

Event-namespace facades (events.ts):
- graph / execution / server / workbench — module-level singletons
- Subscriptions inside setup() auto-dispose via unmountHooks
- Handler<EventPayloadMap[K]> typed (defaults to unknown; D5 tightens later)

Sub-decisions resolved (handoff-11):
- SD-1 (a) tagged-union lifecycle context (kind: extension|sidebarTab|bottomPanel)
- SD-2 (pragmatic parallel slots — _currentScope unchanged, new _currentExtensionInstance; full consolidation is follow-up)
- SD-3 (b) lazy setup invocation via existing invokeV2AppExtensions
- SD-4 (a) module-level singletons + auto-dispose via currentContext
- SD-5 (b) typed EventPayloadMap with module augmentation

Deprecation (types.ts):
- ExtensionOptions.init and .setup gain @deprecated JSDoc per ADR migration table
- Runtime mapper implicit: invokeV2AppExtensions still invokes both for back-compat

Runtime (invokeV2AppExtensions):
- Phase 1: setup() invoked inside withExtensionInstance bracket
- Phase 2: beforeMountHooks → mountHooks flushed lex-order across all extensions
- teardownV2AppExtensions flushes unmountHooks at app teardown

Verification:
- tsc --noEmit: zero new errors in modified files
- oxfmt: clean
- oxlint: 3 warnings (pre-existing, unrelated), 0 errors
- vitest/knip blocked by Node 20 vs 24 env requirement (env, not code)

ADR: decisions/D-bootstrap-hooks.md §Implementation plan Steps 1+2+3.
Cascade tail W6.P6.D (dashboard + design-review row + PR comment) runs
separately in the handoff thread after this commit lands.

Per AGENTS.md §Decision propagation: subject contains 'D-bootstrap-hooks'
so check-decision-propagation.sh picks it up.
2026-05-18 16:59:01 -07:00
Connor Byrne
fa3229b402 docs(extension-api): D-coord-space (W6.P4.C) — single-coordinate-space policy on NodeHandle spatial accessors
Per D-coord-space ACCEPTED 2026-05-18 (option iii, Christian PICK) + new Axiom A13 (Single Coordinate Space — Canvas) in workspace AXIOMS.md.

Expands JSDoc on NodeHandle.{getPosition,setPosition,getSize,setSize} to: (1) explicitly state 'canvas units', (2) name client/CSS spaces in prose so the distinction is visible at the API boundary, (3) document the escape-hatch (window.app.canvas.ds.{scale,offset} + window.app.canvas.canvas.getBoundingClientRect() + window.devicePixelRatio) for legitimate screen-space cases, (4) point AI agents and human authors at A13 + ADR D-coord-space, (5) carry @stability stable. Also adds a SPATIAL STATE section header comment block stating the single-space policy once at the top.

No runtime surface change — getPosition/getSize/setPosition/setSize already returned/accepted canvas units post W6.P8.C (df921f3512). The PICK ships as documentation + axiom because the runtime contract was already in the right shape. R1+R2+R3 evidence (8,424 EXT hits / 103 repos / ★41,621; bug-class 2,617 hits / 71 repos / ★40,040) backs the choice.

Phase A gates: format clean / lint 0 errors 3 pre-existing warnings / knip 6 pre-existing tag hints 0 failures.
2026-05-18 16:10:00 -07:00
Connor Byrne
2f102353fa feat(extension-api): converge DOMWidget ↔ Widget via mount-lifecycle (D-widget-converge, A12)
Per D-widget-converge ACCEPTED 2026-05-18 (W6.P3.B PICK: option iii) and
new Axiom A12 (Mount-Lifecycle as the Sole DOM Seam):

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

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

Migration path: v1 patterns (addDOMWidget, widget.element, widget.inputEl,
addWidget('unregistered-type', …)) keep working through the v1 surface
during the D6 parallel-paths window and break deliberately at D6 Phase D
sunset. See decisions/D-widget-converge.md §Migration-guide rows.
2026-05-18 14:46:51 -07:00
Connor Byrne
df921f3512 feat(extension-api): apply Hybrid C Readonly typing per D-immutability-enforcement
Implements the W6.P8.C foundation slice of D-immutability-enforcement
(ACCEPTED 2026-05-14, Hybrid C):

* Point/Size are now readonly tuples — getPosition()[0] = X and
  getSize()[0] = X raise TS-ERR at compile time.
* getWidgets() now returns ReadonlyArray<Readonly<WidgetHandle>>
  — node.getWidgets().push(...) and []= raise TS-ERR.
* inputs()/outputs() are renamed to getInputs()/getOutputs() returning
  ReadonlyArray<Readonly<SlotInfo>>. Old names kept as @deprecated
  aliases for one minor release; SlotInfo fields were already readonly.
* New WidgetHandle.options: Readonly<WidgetOptions> accessor — bulk read
  of the options bag; widget.options.min = 0 and widget.options = {...}
  raise TS-ERR. Mutate via setOption(key, value).
* New WidgetHandle.serializeValue accessor-only — direct assignment
  raises TS-ERR; the v2 path is on('beforeSerialize') per D5.
* widget.value (setValue/getValue pair) is unchanged — already ZERO row
  in R3 per D14.

Zero runtime cost — no Object.freeze, no Proxy. The 'as any' /
'@ts-ignore' / JS-not-TS escape gap is the explicit trade per ADR; the
follow-up W6.P8.FREEZE closes that gap when prioritized.

Refs: decisions/D-immutability-enforcement.md (ACCEPTED 2026-05-14)
      research/architecture/D-immutability-enforcement-blast-radius.md
      W6.P8.B PICK (handoff-6, captured in tasks/W6-P8-B.lock)

Phase A — gates: lint OK / format:check OK / knip OK.
typecheck/test failures on the v2 stack are EXPECTED per AGENTS.md
Rule #8 until rebased onto Alex's ECS branch (PR #11939).
2026-05-18 13:34:58 -07:00
Connor Byrne
a058a410ac feat(ext-api/foundation): D20 identity convergence — rename .entityId → .id, add .equals(), demote *EntityId brands to @internal
Per decisions/D20-id-type-convergence.md (closes design-review rows #3 and #8):

- NodeHandle.entityId → NodeHandle.id (string), add equals(other)
- WidgetHandle.entityId → WidgetHandle.id (string), add equals(other)
- SlotInfo.entityId → SlotInfo.id (string), add equals(other)
- SlotInfo.nodeEntityId → SlotInfo.nodeId (string)
- Drop NodeEntityId/WidgetEntityId/SlotEntityId re-exports from src/extension-api/index.ts
- Mark the three brand re-exports in node.ts/widget.ts as @internal — still
  available to internal package modules but absent from the published barrel
  and TypeDoc output
- knip.config.ts: add -internal tag so the @internal demoted brands stop
  failing the unused-export check
- Update scope-registry.test.ts to use handle.id instead of handle.entityId
- Update createNodeHandle/createWidgetHandle/inputs/outputs in
  extension-api-service.ts to expose id + equals
- Drop unused PublicSlotEntityId import alias

Identity is now opaque on the public surface (90% case). Branded
NodeLocatorId / NodeExecutionId remain public for the protocol-boundary
10% case (workflow JSON, websocket frames) per D20 Tier 2.
2026-05-14 12:49:44 -07:00
Connor Byrne
300be13a4c fix(ext-api): correct NodeMode doc-comments to match LGraphEventMode
Per AUDIT-LG.4 finding: the previous doc-comments had numeric values
mapped to wrong names (claimed 1=Never, 2=Bypass, 3=once, 4=trigger,
but the actual LiteGraph runtime enum is 0=ALWAYS, 1=ON_EVENT,
2=NEVER, 3=ON_TRIGGER, 4=BYPASS). An extension following the old
docs and writing setMode(3) for 'execute once' would actually wire
the dead ON_TRIGGER plumbing.

Also notes that slots 1 and 3 are legacy ABI-reserved positions for
the dead trigger/action subsystem flagged for removal by AUDIT-LG.5.

Cross-ref: research/architecture/audit-litegraph-pruning.md
\xc2\xa7AUDIT-LG.4 \xc2\xa7AUDIT-LG.5
2026-05-14 12:11:22 -07:00
Connor Byrne
f069f540ce chore(ext-api): pull forward review refinements from PKG/EXT
Folds the foundation-file edits that were previously living in PRs #12143
and #12144 into the foundation PR (#12142) so each downstream PR's diff
contains only its own concerns.

From #12143 (pkg):
  - src/extension-api/index.ts: drop startExtensionSystem from public barrel
    (it is the internal boot fn; app.ts imports directly from the service)
  - src/extension-api/types.ts: fix bad escape in widgetCreated JSDoc example
  - src/services/extension-api-service.ts: add @internal tag to
    startExtensionSystem; widen widget.on() Phase-A stub warnings; harden
    addWidget name parse with explanatory TODO
  - src/services/__tests__/scope-registry.test.ts: drop unused removedCb in
    no-dispose-on-subgraph-promotion test

From #12144 (ext):
  - src/extension-api/lifecycle.ts: add JSDoc @example to defineWidgetExtension
  - src/extension-api/node.ts: hoist DOMWidgetOptions interface above NodeHandle
  - src/extension-api/shell.ts: clarify shell.ts is a re-export, not a move
  - src/services/extension-api-service.ts: more informative Phase-A stub
    warning text on node.on() (replaces pkg's terser version per ext review)

These are pure refinements (JSDoc, dev-only warnings, structural tidying);
no runtime behavior change. After this commit, rebasing pkg/ext onto the
new foundation will drop the corresponding source-touching commits as
empty no-ops and leave each PR scoped to its own concerns.
2026-05-14 12:11:22 -07:00
Connor Byrne
d4323d7ab1 feat(ext-api/foundation): D18 Phase 1 brand + registries; D19 drop VueExtension/CustomExtension from public barrel
D18 Phase 1 (decisions/D18-pure-functions-loader-registration.md):
- Add EXTENSION_BRAND symbol + isBrandedExtension type-guard in
  src/extension-api/brand.ts. Phase 2 loader will use these to identify
  define* outputs without grepping module exports.
- Stamp brand inside defineNode / defineWidget / defineExtension. Side-
  effect registration remains for Phase 1; Phase 2 removes it.
- Scaffold src/services/registries/{node,widget,app}ExtensionRegistry.ts
  with the {register,getAll,_clearForTesting} shape the loader will call.

D19 (decisions/D19-vueextension-disposition.md):
- Remove VueExtension and CustomExtension from the @comfyorg/extension-api
  barrel and from src/extension-api/shell.ts. They remain exported from
  src/types/extensionTypes.ts for ExtensionSlot.vue (the only internal
  consumer).

knip.config.ts: ignore the new Phase-1-scaffolding modules until Phase 2
wires them in. All three Phase-A quality gates (lint, format:check, knip)
pass.
2026-05-14 12:01:24 -07:00
Connor Byrne
c5d7fb113f fix(ext-api/foundation): add stub test:extension-api script
Add test:extension-api script that skips gracefully when the vitest
config doesn't exist. This allows CI to pass on stacked PRs that
precede the tf branch (which adds the actual tests).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 20:26:48 -07:00
Connor Byrne
6345359ca8 fix(ext-api/foundation): skip compat-floor gate when tests dir missing
The compat-floor gate should only enforce when extension-api-v2 tests
exist. On stacked PRs that precede the tf branch (which adds tests),
gracefully skip instead of failing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 20:23:41 -07:00
Connor Byrne
b2e9c8f749 feat(ext-api/foundation): add compat-floor CI gate script + touch-point data
Add scripts/check-compat-floor.py and research/touch-points data files
to foundation branch so CI compat-floor job passes on all stacked PRs.

Per PLAN.md §Compat-floor, all blast_radius ≥ 2.0 categories must have
complete test triples before v2 ships.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 19:51:51 -07:00
Connor Byrne
20daf22a68 fix(ext-api): address CodeRabbit review comments (CR-12142)
- Remove startExtensionSystem from public exports (internal-only)
- Remove unused World interface export (make module-local)
- Make addDOMWidget command serializable (store element in side table)
- Wrap event values in proper event objects ({pos}, {size}, {mode})
- defineExtension/defineNode/defineWidget now return options object

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 19:27:30 -07:00
Connor Byrne
f83510a223 refactor(ext-api): rename widgets() to getWidgets()
Aligns with getter naming convention (getPosition, getSize, etc.).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 16:46:58 -07:00
Connor Byrne
ba636765a7 docs(extension-api): improve setup-scope lifecycle hook documentation
Add comprehensive JSDoc explaining the implicit-context pattern for
onNodeMounted and onNodeRemoved hooks:

- Document how the runtime sets a global scope slot before nodeCreated()
- Explain why hooks must be called synchronously (Vue-style constraint)
- Add code examples showing correct vs incorrect async usage
- Document automatic cleanup and memory-safety benefits

Per design review decision #7.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:57:33 -07:00
Connor Byrne
d0614e595f fix(ext-api): remove decision refs from public API comments
Per design review decision #1: strip internal decision references
(D10c, D12) from JSDoc comments in the public API surface.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:49:48 -07:00
Connor Byrne
8e71fd0436 refactor(ext-api): remove HR comments, wire position/size to layoutStore
Design review cleanup:
- Remove all HR section header comments per design-review-12142.md Decision #1
- SlotEntityId changed from number to string brand (per D13 Slack resolution)

LayoutStore integration (D13 §4):
- NodeHandle.getPosition/setPosition now read/write via layoutStore
- NodeHandle.getSize/setSize now read/write via layoutStore
- Position/size are Yjs CRDT-backed with operation logging (undo/redo ready)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 15:46:08 -07:00
Connor Byrne
8bc2ff0800 docs(ext-api): update README to reflect implementation status
- Change status from "scaffolded" to "implemented (Phase A)"
- Update file structure to match actual files
- Replace stale decision doc refs with ADR links
- Add related research document links

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 13:55:39 -07:00
Connor Byrne
ceec47df88 feat(ext-api): add dev-mode warnings for deprecated/missing patterns
- Add console.warn when node.on('beforeSerialize') is used (ADR-0010)
- Add console.warn if extensions registered but startExtensionSystem()
  never called (ADR-0012 mitigation)
- Enhance JSDoc with migration examples for deprecated beforeSerialize

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-13 12:51:41 -07:00
Connor Byrne
aa0b00953b docs(research): widget state categories documentation
Documents the 5 categories of widget state (identity, value,
properties, options bag, DOM-specific) and their constraints
for agents working with the extension API.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:31:24 -07:00
Connor Byrne
7b9ea4a01f docs(research): serialization context simplification analysis
Analyzes whether the 4 serialization contexts can be reduced.
Recommends keeping all 4 - each has distinct semantics that
extensions may need to differentiate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:30:12 -07:00
Connor Byrne
e292976f8d docs(research): DOM widget convergence analysis
Analyzes the relationship between DOM widgets and base widgets.
Recommends keeping the current partial convergence - unified at
entity/interface/command level, separate at creation API level.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:29:20 -07:00
Connor Byrne
a0478f66ea docs(research): coordinate systems analysis
Analyzes canvas vs screen coordinates in the extension API.
Current approach is appropriate - node positions in canvas space,
widget heights in pixels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:28:28 -07:00
Connor Byrne
52146d918f docs(research): identity encapsulation analysis
Analyzes when extensions need raw entity IDs and recommends keeping
them exposed with clear documentation that they are opaque values.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:27:34 -07:00
Connor Byrne
fa0079dfb5 docs(adr): ADR-0012 pure function loader pattern
Documents the decision to use pure function registration (defineNode,
defineWidget) with a centralized loader (startExtensionSystem) rather
than side-effect registration at import time.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:26:22 -07:00
Connor Byrne
f10990df3a docs(adr): ADR-0011 immutability enforcement via fresh copies
Decision: All collection-returning methods (widgets(), inputs(), etc.)
and object-returning methods (getProperties()) return fresh copies.

Benefits:
- True immutability: mutations never affect internal state
- JavaScript-safe: works regardless of TypeScript types
- Simple mental model: "this is your copy"

Applied consistently across the extension API surface.

Addresses review discussion item #10 from design-review-12142.md (Topic 14)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:23:52 -07:00
Connor Byrne
ccfd53bdf5 docs(adr): ADR-0010 deprecate node-level serialization control
Node-level `node.on('beforeSerialize')` is wrong-layered:
- Extension state should flow through widgets, not arbitrary node fields
- Widget-level `widget.on('beforeSerialize')` handles all legitimate use cases
- Node-level hooks encourage ad-hoc state storage that breaks clean separation

Decision: Deprecate now, remove in v1.0. Add migration guidance to ADR.

Addresses review discussion item #9 from design-review-12142.md (Topic 11)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:22:28 -07:00
Connor Byrne
8da221b5db refactor(ext-api): rename defineNodeExtension → defineNode, defineWidgetExtension → defineWidget
Shorter function names improve ergonomics while maintaining clarity:
- defineNode() - register node-scoped extensions
- defineWidget() - register widget type extensions

Old names kept as deprecated aliases for backwards compatibility.
Will be removed in v1.0.

Updates all docs, examples, tests, and internal references.

Addresses review discussion item #4 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 18:20:11 -07:00
Connor Byrne
e74250fd8a docs(ext-api): document setup-scope hooks rationale
Add clear documentation explaining why onNodeMounted/onNodeRemoved must
be called synchronously in setup scope:
- Implicit context pattern (Vue-style _currentScope slot)
- Automatic cleanup via EffectScope disposal
- Memory leak prevention through scope-tied garbage collection

Also removes remaining decision refs (D6, D10a, etc.) and @stability stable tags.

Addresses review discussion item #6 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:28:49 -07:00
Connor Byrne
bf272a784d refactor(ext-api): rename widgetCreated → created in WidgetExtensionOptions
Remove redundant "widget" prefix since we're already in widget context.

Addresses review discussion item #5 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:27:24 -07:00
Connor Byrne
e25e210933 refactor(ext-api): clean up API surface per design review decisions
Changes:
- Strip internal decision refs (D3, D5, D6, D7, etc.) from JSDoc
- Remove @stability stable tags (pre-v1.0, nothing is stable yet)
- Remove apiVersion from ExtensionOptions (telemetry deferred)
- Change NodeMode from number union to string union
  ('always'|'never'|'bypass'|'once'|'onTrigger')
- Rename widget() to getWidget() for consistency

Addresses review discussion items #1, #2, #3, #7, #8 from design-review-12142.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 17:25:35 -07:00
Connor Byrne
5b05f2b793 fix(ext-api): add knip entry + @publicAPI tags for v2 surface
knip flagged the new src/extension-api/* files and exports as unused
because nothing internal consumes them — by design. They are the public
API surface published by PKG2 (#12143).

Changes:
- knip.config.ts: add src/extension-api/index.ts as entry point (per
  AGENTS.md, this barrel is the explicit exception to the
  no-barrel-files-in-src rule because it IS the package entry).
- knip.config.ts: ignore src/types/extensionV2.ts (deprecated stub,
  removed once PKG2 ships).
- knip.config.ts: register `-publicAPI` tag.
- Tag externally-consumed exports with @publicAPI:
  - lifecycle.ts: defineNodeExtension/Extension/WidgetExtension declares
    + back-compat type re-exports
  - extension-api-service.ts: _setDispatchImplForTesting, NodeInstanceScope
  - worldInstance.ts: World

Verifies the full lint-format CI matrix locally:
  pnpm lint        
  pnpm stylelint   (unaffected, CSS-only)
  pnpm format:check 
  pnpm knip        
2026-05-12 12:13:34 -07:00
Connor Byrne
3476d06fc9 docs(ext-api): clarify pauseTracking/resetTracking no-op stub comment (review #12142.A5)
The previous comment described what the Vue intrinsics do but called them
no-op shims without explaining the contract gap. That read as if the
service was relying on dep-tracking suppression that the shim was silently
violating.

Rewrite the comment to make the Phase A scope explicit: this is a no-op
stub by design (the API surface is the contract being stabilized, not the
dep-tracking guarantee), and the real @vue/reactivity import lands together
with the reactive-World contract in #11939. Adds the TODO(#11939) marker so
the swap-in is grep-able alongside the rest of the Phase B handoff points.

No code change - comment-only. Per Architect review #5, we deliberately do
NOT add @vue/reactivity as a direct dep in Phase A.
2026-05-11 17:43:43 -07:00
Connor Byrne
c1748c6fe3 fix(ext-api): break extension-api-service <-> lifecycle circular import (review #12142.A3)
Extract NodeExtensionOptions, ExtensionOptions, and WidgetExtensionOptions
into a new src/extension-api/types.ts so both the runtime service and the
public lifecycle barrel depend on the type contracts without forming a cycle.

Before:
  - extension-api-service.ts imported the option types from
    @/extension-api/lifecycle.
  - lifecycle.ts re-exported onNodeMounted/onNodeRemoved from
    @/services/extension-api-service.
  -> service <-> lifecycle circular import (Architect review #3).

After:
  - types.ts owns the three option-type interfaces (single source of truth).
  - lifecycle.ts re-exports them from ./types so the public path
    @/extension-api/lifecycle keeps working.
  - extension-api-service.ts imports the types from @/extension-api/types.
  - The barrel @/extension-api re-exports from ./types directly.

No public API change - all named exports remain available from
@comfyorg/extension-api and from @/extension-api/lifecycle.
2026-05-11 17:43:43 -07:00
GitHub Action
9a6fff645d [automated] Apply ESLint and Oxfmt fixes 2026-05-11 20:12:22 +00:00
Connor Byrne
2de2e07b36 feat(extension-api): foundation API surface + scope registry + Phase A world stubs
Restratified foundation. Includes:
- Public API declaration files (src/extension-api/{events,identifiers,index,
  lifecycle,node,shell,widget}.ts + README)
- Scope registry runtime (src/services/extension-api-service.ts) +
  scope-registry tests
- Boot wiring (src/scripts/app.ts, src/services/extensionService.ts)
- Phase A ECS world stubs (src/world/{componentKey,entityIds,
  widgets/widgetComponents,worldInstance}.ts)
- Public type re-exports (src/types/extensionV2.ts)

Test framework moved to ext-api/i-tf, npm package + docgen moved to
ext-api/i-pkg, core extension v2 conversions moved to ext-api/i-ext.

Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-foundation
and tag backup/restratify/ext-api-i-foundation on fork.
2026-05-11 12:36:20 -07:00
49 changed files with 8393 additions and 32 deletions

View File

@@ -0,0 +1,108 @@
# 10. Deprecate Node-Level Serialization Control
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API initially included `node.on('beforeSerialize', handler)` as a migration path from v1 patterns like `node.onSerialize` and `nodeType.prototype.serialize` patching. This allowed extensions to:
1. **Append extra fields** to the serialized node object
2. **Transform the entire serialized object** via a replace function
However, during design review (PR #12142), we questioned whether node-level serialization control is the right abstraction:
### The Problem
Node-level serialization control is fundamentally **wrong-layered**:
- **Extension state should live in widgets**, not as arbitrary fields on the node
- Widget-level `beforeSerialize` already handles all legitimate use cases
- Node-level hooks encourage storing extension state in ad-hoc `node.properties` or custom fields, which:
- Breaks the clean separation between framework concerns and extension concerns
- Creates hidden dependencies between serialization format and extension behavior
- Makes migration and format evolution harder
### v1 Usage Analysis
Touch-point audit of `nodeType.prototype.serialize` and `node.onSerialize` patterns in the wild:
| Use Case | Proper v2 Alternative |
| --------------------------- | --------------------------------------------------- |
| Store extension state | Use widget values with `beforeSerialize` |
| Persist per-instance config | Use `widget.setOption()``widget_options` sidecar |
| Add metadata for export | Use a dedicated extension state widget |
| Transform output format | Framework concern, not extension concern |
No use case requires node-level control that can't be better served by widget-level APIs.
## Decision
**Deprecate `node.on('beforeSerialize')`** — mark as `@deprecated` with clear guidance pointing to widget-level alternatives. Remove in v1.0.
Widget-level serialization control (`widget.on('beforeSerialize')`) remains fully supported as the correct abstraction.
### Migration Path
Extensions currently using `node.on('beforeSerialize')` should:
1. **Store state in widgets** instead of arbitrary node fields
2. **Use `widget.on('beforeSerialize')`** to control serialization per-widget
3. **Use `widget.setOption()`** for per-instance configuration
Example migration:
```ts
// BEFORE (v1 / deprecated v2)
node.on('beforeSerialize', (e) => {
e.data['my_extension_state'] = computeState()
})
// AFTER (recommended v2)
const stateWidget = node.addWidget('STRING', '_my_state', '', {
hidden: true,
serialize: true
})
stateWidget.on('beforeSerialize', (e) => {
e.setSerializedValue(JSON.stringify(computeState()))
})
```
### Implementation Steps
1. Add `@deprecated` tag to `node.on('beforeSerialize')` with migration guidance
2. Add console.warn when the deprecated event is used (dev mode only)
3. Update documentation to recommend widget-level patterns
4. Remove `NodeBeforeSerializeEvent` type and handler in v1.0
## Consequences
### Positive
- **Cleaner architecture**: Extension state flows through widgets, the designed data channel
- **Better debuggability**: Widget values are visible in workflow JSON at predictable locations
- **Easier migration**: Future format changes only need to consider widget serialization
- **Reduced API surface**: One less event type to maintain and document
### Negative
- **Migration burden**: Extensions using node-level serialization must refactor
- **Potential edge cases**: Some exotic use cases may require workarounds
### Risk Mitigation
- Deprecation warning gives extension authors runway to migrate
- Widget-level APIs are already more capable than node-level alternatives
- The `@deprecated` tag and docs provide clear migration path
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 11 for the full discussion thread.
Related decisions:
- Widget-level `beforeSerialize` remains the primary extension serialization hook
- `setSerializeEnabled()` remains for simple static opt-out cases

View File

@@ -0,0 +1,111 @@
# 11. Immutability Enforcement via Fresh Copies
Date: 2026-05-12
## Status
Accepted
## Context
The extension API exposes collection-returning methods like `widgets()`, `inputs()`, `outputs()`, and object-returning methods like `getProperties()`. These methods need immutability guarantees to prevent extensions from accidentally or intentionally mutating internal state.
### The Problem
Without runtime immutability enforcement:
- Extensions could push items into `widgets()` array, corrupting internal state
- Mutations to returned objects would silently affect internal data
- Debugging would be difficult — state corruption could surface far from the mutation site
- Internal framework code might inadvertently rely on returned arrays being stable
TypeScript's `readonly` modifier and JSDoc annotations provide compile-time protection, but:
- JavaScript consumers have no protection
- Type assertions can bypass readonly
- Agent-generated code may not respect type hints
### Options Considered
| Option | Pros | Cons |
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------- |
| **1. `Object.freeze()`** | Runtime immutability, throws on mutation | Performance overhead, nested objects need deep freeze |
| **2. Return fresh copy** | Simple, functional style, no mutation affects source | Slight memory overhead, multiple calls = multiple arrays |
| **3. Proxy wrapper** | Helpful error messages, can intercept specific operations | Complexity, performance overhead, harder to debug |
| **4. TypeScript only** | Zero runtime cost | No protection for JS consumers, can be bypassed |
| **5. Private fields** | True encapsulation | Blocks read access too, not suitable for APIs |
## Decision
**Return fresh copies** (Option 2) for all collection-returning and object-returning methods in the extension API.
### Implementation Pattern
```ts
// CORRECT: Return fresh copy
widgets(): readonly WidgetHandle[] {
const container = world.getComponent(nodeId, WidgetComponentContainer)
return (container?.widgetIds ?? []).map(createWidgetHandle)
// Each call creates new array — mutations don't affect internal state
}
getProperties(): Record<string, unknown> {
return { ...world.getComponent(nodeId, NodeTypeKey)?.properties }
// Shallow copy — mutations don't affect source
}
```
### Scope
Apply this pattern to:
- `NodeHandle.widgets()` — returns fresh `WidgetHandle[]`
- `NodeHandle.inputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.outputs()` — returns fresh `SlotInfo[]`
- `NodeHandle.getProperties()` — returns fresh `Record<string, unknown>`
- `WidgetHandle` methods that return objects (if any)
- Any future collection/object-returning methods
### Internal Callers
Framework-internal code must also use mutation APIs rather than mutating returned collections:
```ts
// WRONG: Mutating returned array
const widgets = node.widgets()
widgets.push(newWidget) // No effect on node!
// CORRECT: Use mutation API
node.addWidget(type, name, value, options)
```
## Consequences
### Positive
- **True immutability**: Mutations to returned data never affect internal state
- **Predictable behavior**: Each call returns fresh data reflecting current state
- **Simple mental model**: "This is your copy, do what you want with it"
- **JavaScript-safe**: Works regardless of TypeScript types
### Negative
- **Memory overhead**: Multiple calls create multiple arrays (usually negligible)
- **No mutation detection**: Extensions silently get isolated copies, won't know their mutations are ignored
- **Fresh reference each call**: Cannot use `===` to detect changes (use deep comparison or events)
### Mitigations
- Document that returned collections are snapshots
- Use events (`valueChange`, `propertyChange`) to observe changes
- The memory overhead is negligible for typical widget/slot counts
## Notes
This decision was made during design review of PR #12142 (ext-api foundation). See `design-review-12142.md` Topic 14 for the full discussion thread.
The alternative of `Object.freeze()` was rejected because:
- It requires deep freezing for nested objects
- Performance overhead for each call
- Fresh copies achieve the same goal more simply

View File

@@ -0,0 +1,138 @@
# 12. Pure Function Loader Pattern for Extension Registration
Date: 2026-05-12
## Status
Accepted
## Context
The v2 extension API needs a mechanism for extensions to register themselves with the runtime. Two broad approaches exist:
### Side-Effect Registration (Vue 2 Plugin Pattern)
```ts
// Extension self-registers at import time
import { app } from '@comfyorg/core'
app.use({
install(app) {
app.component('MyWidget', MyWidget)
app.directive('my-directive', myDirective)
}
})
```
Problems:
- **Import order matters**: If extension A depends on extension B being registered first, import order must be carefully managed
- **Hard to test**: Side effects at import time make mocking difficult; tests must manipulate module cache
- **Hard to tree-shake**: Bundlers can't eliminate unused extensions — the import executes
- **Timing coupling**: Registration and activation are conflated; can't collect extensions first, then activate later
### Pure Function + Loader Pattern
```ts
// Extension declares intent — no side effects
export default defineNode({
name: 'my-extension',
nodeTypes: ['MyNode'],
nodeCreated(handle) {
// ...
}
})
// App bootstrap activates all registered extensions
startExtensionSystem()
```
## Decision
**Adopt the pure function + loader pattern** for v2 extension registration.
### Implementation
```ts
// Extension Registry (data collection only)
const nodeExtensions: NodeExtensionOptions[] = []
export function defineNode(options: NodeExtensionOptions): void {
nodeExtensions.push(options)
}
// Loader (activation)
export function startExtensionSystem(): void {
const world = getWorld()
watch(
() => world.entitiesWith(NodeTypeKey),
(nodeEntityIds) => {
for (const id of nodeEntityIds) {
mountExtensionsForNode(id)
}
},
{ immediate: true }
)
}
```
### Key Properties
1. **Pure registration**: `defineNode()` has no side effects beyond pushing to an array. It doesn't touch the World, DOM, or any reactive state.
2. **Centralized activation**: `startExtensionSystem()` is called exactly once during app bootstrap. This single entry point controls when the extension system "goes live".
3. **Reactive mounting**: The loader watches the World for entity changes. Extensions are mounted/unmounted in response to ECS state, not imperative calls.
4. **Order independence**: Extensions can be defined in any order. The loader sorts by name (lexicographic, see D10b) for deterministic execution.
### Registration Flow
```
Extension files App bootstrap World
| | |
| defineNode({...}) | |
|--------------------->| |
| (push to array) | |
| | |
| | startExtensionSystem()
| |------------------>|
| | (watch for NodeType entities)
| | |
| | NodeType added |
| |<------------------|
| | |
| | mountExtensionsForNode(id)
| | (runs setup) |
```
## Consequences
### Positive
- **Testability**: Extensions are plain objects; tests can construct them without side effects. `_clearExtensionsForTesting()` resets state between tests.
- **Tree-shakeable**: Bundlers can eliminate unused extension files if their exports are never referenced.
- **Order independent**: No import order bugs — the loader handles activation order.
- **Lazy activation**: Registration is instant; activation only happens when `startExtensionSystem()` is called.
- **SSR friendly**: Pure functions don't execute browser-only code at import time.
### Negative
- **Manual bootstrap**: App must call `startExtensionSystem()` — forgetting it silently disables extensions.
- **Two-step mental model**: Developers must understand "register" vs "activate" phases.
### Mitigations
- App bootstrap is a well-defined location; the call is hard to miss.
- Clear documentation and starter templates include the bootstrap call.
- Dev-mode warnings if extensions are defined but the system never starts.
## Notes
This pattern aligns with modern framework conventions:
- **Vite plugins**: `vite.config.ts` collects plugins as an array; Vite activates them at build time.
- **Vue 3 Composition API**: `setup()` returns reactive state; the framework activates it.
- **React hooks**: Pure functions declare effects; React schedules them.
The key insight is separating **declaration** (what do I want?) from **execution** (make it happen). This separation enables testing, lazy loading, and predictable behavior.

View File

@@ -8,16 +8,19 @@ An Architecture Decision Record captures an important architectural decision mad
## ADR Index
| ADR | Title | Status | Date |
| -------------------------------------------------------- | ---------------------------------------- | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| ADR | Title | Status | Date |
| ---------------------------------------------------------- | ------------------------------------------ | -------- | ---------- |
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
| [0003](0003-crdt-based-layout-system.md) | Centralized Layout Management with CRDT | Proposed | 2025-08-27 |
| [0004](0004-fork-primevue-ui-library.md) | Fork PrimeVue UI Library | Rejected | 2025-08-27 |
| [0005](0005-remove-importmap-for-vue-extensions.md) | Remove Import Map for Vue Extensions | Accepted | 2025-12-13 |
| [0006](0006-primitive-node-copy-paste-lifecycle.md) | PrimitiveNode Copy/Paste Lifecycle | Proposed | 2026-02-22 |
| [0007](0007-node-execution-output-passthrough-schema.md) | NodeExecutionOutput Passthrough Schema | Accepted | 2026-03-11 |
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0010](0010-deprecate-node-level-serialization-control.md) | Deprecate Node-Level Serialization Control | Accepted | 2026-05-12 |
| [0011](0011-immutability-via-fresh-copies.md) | Immutability Enforcement via Fresh Copies | Accepted | 2026-05-12 |
| [0012](0012-pure-function-loader-pattern.md) | Pure Function Loader Pattern | Accepted | 2026-05-12 |
## Creating a New ADR

View File

@@ -0,0 +1,93 @@
# Research: Canvas vs Client/Pixel Coordinate Usage
Date: 2026-05-12
## Question
How should the extension API handle coordinate systems? Should it expose canvas coordinates, screen/client coordinates, or both?
## Coordinate Systems in ComfyUI
### 1. Canvas Space (Logical Units)
Node positions and sizes are in canvas logical units:
- Independent of zoom/pan
- `[0, 0]` is the canvas origin
- Moving a node to `[100, 200]` places it at canvas position (100, 200) regardless of viewport state
### 2. Screen/Client Space (Pixels)
DOM elements use pixel coordinates relative to the viewport:
- Affected by zoom/pan/scroll
- `clientX`/`clientY` from mouse events
- `getBoundingClientRect()` returns pixel values
### 3. Widget Height (Pixels)
DOM widgets reserve height in pixels:
```ts
addDOMWidget({ name: 'preview', element: img, height: 200 }) // 200px
```
## Current Extension API
| Method | Coordinate System | Notes |
| -------------------------- | ----------------- | ----------------------------------------- |
| `getPosition()` | Canvas | Returns `[x, y]` in canvas units |
| `setPosition()` | Canvas | Accepts `[x, y]` in canvas units |
| `getSize()` | Canvas | Returns `[width, height]` in canvas units |
| `setSize()` | Canvas | Accepts `[width, height]` in canvas units |
| `addDOMWidget({ height })` | Pixels | Reserved height in pixels |
| `widget.setHeight(px)` | Pixels | Widget height in pixels |
## Analysis
### When Extensions Need Canvas Coordinates
1. **Node positioning**: Placing nodes relative to each other
2. **Layout algorithms**: Auto-arranging nodes in a pattern
3. **Collision detection**: Checking if nodes overlap
### When Extensions Need Screen Coordinates
1. **Custom overlays**: Drawing UI at a specific screen location
2. **Drag-and-drop from external sources**: Converting mouse position to canvas position
3. **Context menus**: Positioning menus near the cursor
### Current State
The extension API currently exposes:
- **Canvas coordinates** for node position/size — appropriate, as these are logical values
- **Pixel values** for DOM widget height — appropriate, as these are DOM measurements
**Missing**: No conversion helpers between canvas and screen coordinates.
## Recommendation
**The current approach is appropriate.** Extensions that manipulate node positions should work in canvas space. This is the natural abstraction — extensions shouldn't need to account for zoom/pan when laying out nodes.
### For Advanced Cases
Extensions needing coordinate conversion (e.g., custom overlays) should either:
1. **Use LiteGraph's existing transform utilities** (available on `app.canvas`)
2. **Access the transform state** via a future canvas API (not part of node/widget handles)
### Why Not Expose Conversion Helpers on NodeHandle?
- **Wrong abstraction level**: Coordinate conversion is a canvas concern, not a node concern
- **State dependency**: Conversion requires current zoom/pan state, which changes frequently
- **Rare use case**: Most extensions work entirely in canvas space
## Future Considerations
If multiple extensions need coordinate conversion, consider:
1. **Canvas API**: `canvas.screenToCanvas(point)` / `canvas.canvasToScreen(point)`
2. **Events with both coordinates**: `positionChanged` could include both canvas and screen positions
For now, no changes are needed — the current API serves the common cases well.

View File

@@ -0,0 +1,93 @@
# Research: DOM Widget Convergence with Base Widget
Date: 2026-05-12
## Question
Should DOM widgets be unified with base widgets, or kept as a separate concept?
## Current State
### Creation APIs
- `node.addWidget(type, name, value, options)` — creates a standard widget
- `node.addDOMWidget({ name, element, height })` — creates a DOM-backed widget
### Internal Implementation
Both use the same underlying `CreateWidget` command:
```ts
addWidget(type, name, defaultValue, options) {
return dispatch({ type: 'CreateWidget', widgetType: type, ... })
}
addDOMWidget(opts) {
return dispatch({ type: 'CreateWidget', widgetType: 'DOM', ... })
}
```
DOM widgets are just widgets with `widgetType: 'DOM'` and an element reference.
### Shared WidgetHandle Interface
Both widget types share the same `WidgetHandle` interface:
| Method | Standard Widget | DOM Widget |
| -------------------------------- | --------------- | ----------------------- |
| `entityId`, `name`, `widgetType` | ✓ | ✓ |
| `getValue()` / `setValue()` | ✓ (scalar) | ✓ (often unused) |
| `isHidden()` / `setHidden()` | ✓ | ✓ |
| `isDisabled()` / `setDisabled()` | ✓ | ✓ |
| `setHeight(px)` | no-op | ✓ (updates reservation) |
| `on('valueChange')` | ✓ | ✓ |
| `getOption()` / `setOption()` | ✓ | ✓ |
## Analysis
### Arguments FOR Full Convergence
1. **Single mental model**: Extensions learn one widget concept, not two.
2. **Consistent behavior**: All widgets appear in `node.widgets()`, serialize the same way.
3. **Simpler API surface**: Fewer methods to document and maintain.
### Arguments FOR Keeping Separate APIs
1. **Different ergonomics**: Standard widgets are data-driven (name, value, options); DOM widgets are element-driven (pass an HTMLElement).
2. **Type safety**: `addDOMWidget` can require `element: HTMLElement` at compile time; merging would make it optional with runtime checks.
3. **Clear intent**: Separate APIs signal different use cases.
## Recommendation
**Keep the current partial convergence.** The implementation is unified (`CreateWidget` command), but the creation APIs remain separate for ergonomic reasons.
### Rationale
1. **Creation differs, usage is unified.** Extensions create DOM widgets differently (need an element), but interact with them the same way (via `WidgetHandle`).
2. **Type safety is valuable.** `addDOMWidget({ element })` is clearer than `addWidget('DOM', name, null, { element })`.
3. **Already well-integrated.** DOM widgets appear in `node.widgets()`, get the same events, and use the same serialization infrastructure.
### What "Convergence" Means Here
The widgets are already converged at:
- **Entity level**: Same `WidgetEntityId` brand
- **Interface level**: Same `WidgetHandle` type
- **Command level**: Same `CreateWidget` command internally
The APIs are intentionally separate at:
- **Creation level**: `addWidget` vs `addDOMWidget`
This is the right split — unified where it matters (runtime behavior), separate where it improves DX (creation ergonomics).
## Future Considerations
If we add more widget creation patterns (e.g., `addCanvasWidget`, `addThreeJSWidget`), we might consider:
1. **Factory pattern**: `node.widgets.create('DOM', { element })` / `node.widgets.create('INT', { min, max })`
2. **Builder pattern**: `node.addWidget('DOM').withElement(el).withHeight(200).build()`
For now, two explicit methods (`addWidget`, `addDOMWidget`) serve the common cases well.

View File

@@ -0,0 +1,112 @@
# Research: Identity Encapsulation in the Extension API
Date: 2026-05-12
## Question
When do extensions need access to raw entity IDs (`NodeEntityId`, `WidgetEntityId`, `SlotEntityId`)? Should these be exposed or hidden?
## Current State
The v2 extension API exposes entity IDs as read-only properties:
```ts
interface NodeHandle {
readonly entityId: NodeEntityId
// ...
}
interface WidgetHandle {
readonly entityId: WidgetEntityId
// ...
}
interface SlotInfo {
readonly entityId: SlotEntityId
// ...
}
```
All IDs are **branded types** to prevent accidental mixing at compile time.
## Use Cases for Raw Entity IDs
### 1. Per-Instance State Mapping
Extensions maintaining external state per node:
```ts
const nodeCache = new Map<NodeEntityId, CachedData>()
defineNode({
name: 'my-cache-extension',
nodeCreated(handle) {
nodeCache.set(handle.entityId, computeExpensiveData())
onNodeRemoved(() => nodeCache.delete(handle.entityId))
}
})
```
### 2. Logging and Debugging
```ts
node.on('executed', (e) => {
console.log(`[${node.entityId}] Output:`, e.output)
})
```
### 3. Inter-Extension Communication
Extensions that need to coordinate across multiple nodes:
```ts
// Extension A stores data
globalState.set(nodeA.entityId, data)
// Extension B retrieves it
const data = globalState.get(nodeB.entityId)
```
### 4. External System Interop
Extensions integrating with analytics, debugging tools, or external services that need stable node identifiers.
## Analysis
### Arguments FOR Exposing Entity IDs
1. **Legitimate need exists** — The use cases above are real and common.
2. **Branded types prevent misuse** — Can't accidentally use `NodeEntityId` where `WidgetEntityId` is expected.
3. **Read-only access** — Extensions can't mutate the ID or corrupt internal state.
4. **Opaque value** — The format (`node:<graphUuid>:<localId>`) is an implementation detail; extensions should treat it as an opaque string.
### Arguments AGAINST Exposing Entity IDs
1. **Format coupling** — Extensions might parse the ID string and break if format changes.
2. **Internal detail leakage** — Knowing the ID scheme reveals ECS architecture.
3. **Future migration friction** — Changing ID representation requires careful deprecation.
### Mitigations
- **Document as opaque**: JSDoc clearly states IDs are opaque, not to be parsed.
- **Branded types**: TypeScript prevents misuse across entity categories.
- **Phase A format**: Current format includes graph UUID + local ID; this can evolve via semver.
## Recommendation
**Keep exposing entity IDs.** The use cases are legitimate, the branded types provide safety, and the read-only nature limits risk. Document that IDs are opaque strings — extensions should never parse or construct them.
### Guidelines for Extension Authors
1. **Use IDs only for keying** — Maps, Sets, logging, external system references.
2. **Never parse IDs** — The format is an implementation detail subject to change.
3. **Prefer handles over IDs** — When passing references between functions, use the handle object, not the raw ID.
4. **Clean up on removal** — Always use `onNodeRemoved()` to clean up Maps keyed by entityId.
## Future Considerations
If the ID format needs to change significantly, the branded types allow us to:
1. Introduce a new branded type (e.g., `NodeEntityIdV2`)
2. Deprecate the old ID with migration guidance
3. Keep both supported during a transition period

View File

@@ -0,0 +1,121 @@
# Research: Serialization Context Simplification
Date: 2026-05-12
## Question
Can the serialization context be simplified from 4 values to fewer?
Current contexts:
- `'workflow'` — saving workflow to disk
- `'prompt'` — queueing a run (API call)
- `'clone'` — copy/paste operation
- `'subgraph-promote'` — widget becoming subgraph IO
## Use Case Analysis
### Context: 'workflow'
**Purpose**: Full persistence of user's work.
**What extensions need**: Serialize everything the user configured.
**Example**: A widget storing user preferences needs to include all settings.
### Context: 'prompt'
**Purpose**: Sending data to the backend for execution.
**What extensions need**:
- Transform values (dynamic prompts → resolved text)
- Skip preview-only widgets
- Materialize async sources (webcam → frame data)
**Example**:
```ts
widget.on('beforeSerialize', async (e) => {
if (e.context === 'prompt') {
e.setSerializedValue(await captureFrame())
}
})
```
### Context: 'clone'
**Purpose**: Copy/paste should yield independent copy.
**What extensions need**: Reset instance-specific state while keeping user settings.
**Example**: A random seed widget might want a new seed on paste.
### Context: 'subgraph-promote'
**Purpose**: Widget becomes an input/output on a subgraph.
**What extensions need**: Convert internal representation to subgraph IO format.
**Example**: Internal state becomes an exposed parameter.
## Simplification Options
### Option A: Keep All 4 (Current State)
| Pro | Con |
| ---------------------------------------- | ----------------- |
| Each context has distinct semantics | 4 cases to handle |
| Type system enforces valid values | More complex API |
| Clear intent for each serialization path | |
### Option B: Collapse to 2 ('persist' | 'execute')
```ts
context: 'persist' | 'execute'
// 'persist' = workflow, clone, subgraph-promote
// 'execute' = prompt
```
| Pro | Con |
| ------------------------------------------ | ------------------------------- |
| Simpler mental model | Loses clone/promote distinction |
| Most extensions only care about this split | Can't reset seed on clone |
### Option C: Remove Context Entirely
Extensions always transform regardless of context. The framework handles differences.
| Pro | Con |
| ---------------------------- | ---------------------------------------------- |
| Simplest API | Loses control for edge cases |
| Framework handles all nuance | Some extensions need context-specific behavior |
## Recommendation
**Keep all 4 contexts.** The use cases are genuinely different:
1. **workflow vs prompt**: Very common distinction. Dynamic prompts only process on prompt; preview widgets skip prompt. This is the most important split.
2. **clone**: Less common, but needed for stateful widgets (random seeds, generated IDs, captured frames).
3. **subgraph-promote**: Specialized, but necessary for the subgraph feature to work correctly.
### Rationale
- Extensions that don't care can ignore the context.
- Extensions that do care have the information they need.
- The 4 values map to 4 distinct operations in the framework.
- Collapsing contexts would remove functionality with no real simplification gain.
### Mitigation for Complexity
- Document common patterns clearly
- Most extensions only need: `if (context === 'prompt')`
- Provide examples in JSDoc
## Note on Deprecation
The `NodeBeforeSerializeEvent` is deprecated (ADR-0010). The `WidgetBeforeSerializeEvent` remains supported and uses the same 4 contexts.
Since node-level serialization is being removed, this research applies to widget-level serialization only.

View File

@@ -0,0 +1,148 @@
# Widget State Categories
Date: 2026-05-12
## Overview
Widget state in the v2 extension API is organized into distinct categories, each with different characteristics for mutability, persistence, and event handling.
## Categories
### 1. Identity (Read-Only Invariants)
Set at construction, never change.
| Property | Type | Notes |
| ------------ | ---------------- | ------------------------------------ |
| `entityId` | `WidgetEntityId` | Branded, stable for widget lifetime |
| `name` | `string` | Widget name as registered |
| `widgetType` | `string` | e.g., `'INT'`, `'STRING'`, `'COMBO'` |
| `label` | `string` | Display label, defaults to `name` |
**Constraints:**
- No setters exist for these properties
- Extensions cannot modify identity after creation
- Attempting to change identity is a design error
### 2. Value (First-Class, Every Widget)
The primary user-edited data.
| Method | Notes |
| ------------------- | ----------------------------------- |
| `getValue()` | Returns current value |
| `setValue(v)` | Dispatches `SetWidgetValue` command |
| `on('valueChange')` | Fires on value mutation |
**Constraints:**
- Type varies by widget type (`number` for INT, `string` for STRING, etc.)
- Persisted to `widgets_values` in workflow JSON
- Included in API prompt by default (unless `setSerializeEnabled(false)`)
- Changes are undo-able via command dispatch
### 3. Properties (First-Class, Every Widget)
Common properties all widgets share.
| Property | Getter | Setter | Event |
| ----------- | ---------------------- | ------------------------ | ---------------- |
| `hidden` | `isHidden()` | `setHidden(b)` | `propertyChange` |
| `disabled` | `isDisabled()` | `setDisabled(b)` | `propertyChange` |
| `serialize` | `isSerializeEnabled()` | `setSerializeEnabled(b)` | `propertyChange` |
**Constraints:**
- Boolean values only
- `hidden` affects UI visibility, not serialization
- `disabled` makes widget read-only in UI
- `serialize` controls inclusion in workflow/prompt output
- Changes fire `propertyChange`, not `valueChange`
### 4. Options Bag (Type-Specific)
Per-instance overrides for type-specific configuration.
| Method | Notes |
| ----------------------- | ---------------------------------------------- |
| `getOption(key)` | Returns per-instance override or class default |
| `setOption(key, value)` | Persists to `widget_options` sidecar |
| `on('optionChange')` | Fires on option mutation |
**Common options by widget type:**
| Widget Type | Options |
| ----------- | ---------------------------------- |
| INT, FLOAT | `min`, `max`, `step`, `precision` |
| STRING | `multiline`, `placeholder`, `rows` |
| COMBO | `values` |
**Constraints:**
- Options are JSON-serializable values
- Persisted separately from `widgets_values` (additive, backward-compatible)
- Extensions can add custom options
- Option keys should be documented per widget type
### 5. DOM-Specific
Properties unique to DOM widgets.
| Method | Notes |
| --------------- | ------------------------------------------ |
| `setHeight(px)` | Updates reserved height, triggers relayout |
**Constraints:**
- Only meaningful for `addDOMWidget()` widgets
- No-op for non-DOM widgets
- Measured in pixels (screen space)
- No event fired; relayout is automatic
## Category Interaction Rules
### Event Separation
Each category has its own event:
| Category | Event |
| ---------- | ---------------- |
| Value | `valueChange` |
| Properties | `propertyChange` |
| Options | `optionChange` |
**Rule**: Events do not cross categories. Changing `hidden` does not fire `valueChange`.
### Serialization Behavior
| Category | Serialization |
| ---------- | ---------------------------------------------------------------- |
| Identity | Not serialized (derived from node type) |
| Value | `widgets_values` array |
| Properties | `hidden`/`disabled` not persisted; `serialize` affects inclusion |
| Options | `widget_options` sidecar object |
### Mutability Summary
| Category | Mutable | Undo-able | Fires Event |
| ---------- | ------- | --------- | ---------------- |
| Identity | ✗ | — | — |
| Value | ✓ | ✓ | `valueChange` |
| Properties | ✓ | ✓ | `propertyChange` |
| Options | ✓ | ✓ | `optionChange` |
| DOM Height | ✓ | ✗ | — |
## Agent Implementation Notes
Agents working with widget state should:
1. **Respect category boundaries**: Don't try to `setValue()` to change visibility; use `setHidden()`.
2. **Use appropriate events**: Listen to `propertyChange` for UI state, `valueChange` for data.
3. **Handle type-specific options carefully**: Check widget type before accessing type-specific options.
4. **Preserve identity invariants**: Never try to change `entityId`, `name`, `widgetType`, or `label`.
5. **Consider serialization context**: Options persist to a sidecar; values persist to the main array.

View File

@@ -9,6 +9,10 @@ const config: KnipConfig = {
'src/assets/css/style.css',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
// Public extension API surface — published package entry point.
// Per AGENTS.md, this barrel is the explicit exception to the
// no-barrel-files-in-src rule because it IS the package entry.
'src/extension-api/index.ts',
'src/storybook/mocks/**/*.ts'
],
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
@@ -60,7 +64,19 @@ const config: KnipConfig = {
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js',
// Devtools extensions, included dynamically
'tools/devtools/web/**'
'tools/devtools/web/**',
// Deprecated stub re-exporting from `@/extension-api`. Will be removed
// once PKG2 (`@comfyorg/extension-api`) ships and downstream imports
// migrate to the package path.
'src/types/extensionV2.ts',
// D18 Phase 1 scaffolding — empty registries the loader will populate
// in Phase 2 once side-effect registration moves out of
// extension-api-service. See decisions/D18-pure-functions-loader-registration.md.
'src/services/registries/**',
// D18 Phase 1 — brand symbol + isBrandedExtension guard. Currently
// consumed only by the define* call sites inside extension-api-service;
// the type-guard and getBrandKind are exported for the Phase 2 loader.
'src/extension-api/brand.ts'
],
vite: {
config: ['vite?(.*).config.mts']
@@ -79,7 +95,15 @@ const config: KnipConfig = {
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch',
'-knipIgnoreUsedByStackedPR'
'-knipIgnoreUsedByStackedPR',
// Public API surface consumed externally by extension authors and the
// TypeDoc docgen pipeline (PKG2). Mark exports with @publicAPI when they
// are part of `@comfyorg/extension-api` but not internally referenced.
'-publicAPI',
// Per D20, the three *EntityId brand re-exports in src/extension-api/{node,widget}.ts
// are demoted to @internal — they stay available for internal package modules
// but are removed from the public barrel and from TypeDoc output.
'-internal'
]
}

View File

@@ -48,6 +48,7 @@
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:unit": "nx run test",
"test:extension-api": "[ -f vitest.extension-api.config.mts ] && vitest run --config vitest.extension-api.config.mts || echo 'SKIP: vitest.extension-api.config.mts not found'",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",
"typecheck:desktop": "nx run @comfyorg/desktop-ui:typecheck",

View File

@@ -0,0 +1,915 @@
meta:
schema_version: 1
generated_from:
- database.yaml
- rollup.yaml
- star-cache.yaml
generated_by: scripts/build-behavior-categories.py (I-TF.1)
source_pattern_count: 62
category_count: 37
usage_weight_formula: sum_over_members(blast_radius * occurrences)
exemplar_ranking: repo_stars desc, then pattern blast_radius desc; distinct (repo, pattern_id)
notes:
- Categories cluster by intent, not by surface_family. S2 is split into creation / teardown / hydration / interaction /
drawing / connection / serialization / properties.
- S1 hooks are merged with their prototype-patching equivalents where intent matches (BC.20 node-type reg, BC.22 menus,
BC.03 hydration).
- S8.P1 isVirtualNode is a registration-time flag, so it lives in BC.20 alongside the node-type registration hooks.
- S10.D3 setSize and S15.OS1 dynamic outputs join S10.D1 in BC.09 dynamic-slot-mutation since they all describe runtime
topology mutation.
- S14.ID1 NodeLocatorId joins S11.G2 graph enumeration in BC.29 because both are about cross-scope node identity/resolution.
- S11.G1/G3/G4 (version, batching, setDirtyCanvas) collapse into BC.30 graph change-tracking — the v2 reactivity story replaces
all three.
- BC.21 (S1.H2 getCustomWidgets) has only 2 evidence rows in database.yaml; this is the 'small family — 2 + 1 minor variant'
acceptance carve-out. The two exemplars are kept as-is, no synthetic third row.
- BC.31 and BC.32 added 2026-05-08 from Notion API usage research (notion-api-research-evidence.yaml staging).
S16 is a new surface family (DOM injection) not previously tracked. S16.VUE1 grouped with BC.32 (embedded runtimes).
S3.C2 (ContextMenu replacement) added to BC.06 member list.
- Notion source also upgrades occurrence signal on BC.01/BC.02/BC.04/BC.06/BC.07/BC.09/BC.26/BC.29/BC.30 — reflected
in staging file; usage_weight values below are NOT yet updated (need re-run of rollup-blast-radius.py after merge).
- BC.33 (cross-ext DOM widget obs), BC.34 (settings dialog), BC.35 (pre-queue validation) added 2026-05-08 from Notion COM-3668.
- BC.36 (PrimeVue widget API surface) added 2026-05-08 from Notion Widget Component APIs page; was erroneously numbered BC.33 — corrected.
- BC.37 (VueNode bridge timing) added 2026-05-08 from Notion Frontend Architecture page (3536d73d). Captures the
nodeCreated→VueNode-not-yet-mounted hazard and the waitForLoad3d deferral pattern as a concrete test fixture.
categories:
- category_id: BC.01
name: 'Node lifecycle: creation'
intent: Hooks fired when a node is constructed or attached to the graph (per-instance setup).
notes: >-
nodeCreated fires BEFORE the VueNode Vue component mounts. Extensions that need to access
VueNode-backed state (DOM widgets, Three.js renderers, etc.) must defer to onNodeMounted
(v2) or waitForLoad3d-style callbacks (v1). See BC.37 for the deferred-mount bridge pattern.
Source: Notion Frontend Architecture page (2026-05-08).
member_pattern_ids:
- S2.N1
- S2.N8
usage_weight: 37.56
exemplars:
- pattern_id: S2.N1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/saveImageExtraOutput.ts#L31
stars: 1787
- pattern_id: S2.N8
repo: Azornes/Comfyui-LayerForge
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/src/CanvasView.ts#L1401
stars: 313
- pattern_id: S2.N1
repo: SKBv0/ComfyUI_SpideyReroute
url: https://github.com/SKBv0/ComfyUI_SpideyReroute/blob/main/js/SpideyReroute.js#L41
stars: 13
- category_id: BC.02
name: 'Node lifecycle: teardown'
intent: Single de-facto teardown surface for cleaning up DOM widgets, intervals, and observers when a node is removed.
member_pattern_ids:
- S2.N4
usage_weight: 29.35
exemplars:
- pattern_id: S2.N4
repo: Lightricks/ComfyUI-LTXVideo
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L137
stars: 3581
- pattern_id: S2.N4
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js#L348
stars: 2568
- pattern_id: S2.N4
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/docs/architecture/ecs-migration-plan.md#L587
stars: 1787
- category_id: BC.03
name: 'Node lifecycle: hydration from saved workflows'
intent: React when a node is rehydrated from a stored workflow; the working replacement for the unused loadedGraphNode hook.
member_pattern_ids:
- S1.H1
- S2.N7
usage_weight: 15.42
exemplars:
- pattern_id: S1.H1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H1
repo: sofakid/dandy
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L114
stars: 54
- pattern_id: S2.N7
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1265
stars: 4
- category_id: BC.04
name: 'Node interaction: pointer, selection, resize'
intent: 'User-driven per-node events: mouse down for custom click regions, selection focus, and resize feedback for relayout.'
member_pattern_ids:
- S2.N10
- S2.N17
- S2.N19
usage_weight: 38.07
exemplars:
- pattern_id: S2.N10
repo: diodiogod/TTS-Audio-Suite
url: https://github.com/diodiogod/TTS-Audio-Suite/blob/main/web/chatterbox_voice_capture.js#L202
stars: 906
- pattern_id: S2.N10
repo: melMass/comfy_mtb
url: https://github.com/melMass/comfy_mtb/blob/main/web/comfy_shared.js#L1047
stars: 702
- pattern_id: S2.N10
repo: pixaroma/ComfyUI-Pixaroma
url: https://github.com/pixaroma/ComfyUI-Pixaroma/blob/main/js/compare/index.js#L360
stars: 137
- category_id: BC.05
name: Custom DOM widgets and node sizing
intent: Contribute DOM-backed widgets and override computeSize so the node reserves the right area for them.
member_pattern_ids:
- S4.W2
- S2.N11
usage_weight: 33.35
exemplars:
- pattern_id: S4.W2
repo: Lightricks/ComfyUI-LTXVideo
url: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
stars: 3581
- pattern_id: S4.W2
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/editors/editor_base.js#L511
stars: 2568
- pattern_id: S2.N11
repo: o-l-l-i/ComfyUI-Olm-ImageAdjust
url: https://github.com/o-l-l-i/ComfyUI-Olm-ImageAdjust/blob/main/web/olm_imageadjust.js#L319
stars: 45
- category_id: BC.06
name: Custom canvas drawing (per-node and canvas-level)
intent:
Per-node onDrawForeground and full LGraphCanvas.prototype overrides for badges, indicators, keyboard, and custom
render passes. Includes global ContextMenu replacement (S3.C2) as the most destructive canvas-level override.
v1_scope_note: >-
Simon Tranter (COM-3668, 2025-05-12) explicitly vetoed canvas drawing overrides as "too hacky/specific
to implement APIs for". Confirmed out of v2 v1 scope. S3.C* patterns remain in DB for blast-radius
tracking and strangler-fig planning but v2 need not replace them 1:1. Supports D9 Phase C deferral.
member_pattern_ids:
- S2.N9
- S3.C1
- S3.C2
usage_weight: 58.97
exemplars:
- pattern_id: S3.C1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1256
stars: 2568
- pattern_id: S3.C1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/simpleTouchSupport.ts#L174
stars: 1787
- pattern_id: S3.C1
repo: melMass/comfy_mtb
url: https://github.com/melMass/comfy_mtb/blob/main/web/note_plus.js#L1
stars: 702
- category_id: BC.07
name: Connection observation, intercept, and veto
intent:
Subscribe to link connect/disconnect events on a node and intercept incoming/outgoing connections before they are
wired to refuse them, mutate slots, or coerce types.
member_pattern_ids:
- S2.N3
- S2.N12
- S2.N13
usage_weight: 51.08
exemplars:
- pattern_id: S2.N13
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/node_mode_relay.js#L90
stars: 3049
- pattern_id: S2.N12
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/jsnodes.js#L152
stars: 2568
- pattern_id: S2.N12
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/core/graph/widgets/dynamicWidgets.ts#L539
stars: 1787
- category_id: BC.08
name: Programmatic linking
intent: Extensions wire connections from code (workflow templates, auto-routing).
member_pattern_ids:
- S10.D2
usage_weight: 11.81
exemplars:
- pattern_id: S10.D2
repo: MockbaTheBorg/ComfyUI-Mockba
url: https://github.com/MockbaTheBorg/ComfyUI-Mockba/blob/main/js/slider.js#L1
stars: 1
- pattern_id: S10.D2
repo: vjumpkung/comfyui-infinitetalk-native-sampler
url: https://github.com/vjumpkung/comfyui-infinitetalk-native-sampler/blob/main/README.md#L1
stars: 1
- pattern_id: S10.D2
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts/blob/main/web/js/quickNodes.js#L138
stars: 0
- category_id: BC.09
name: Dynamic slot and output mutation
intent: Grow/shrink inputs and outputs at runtime, with the obligatory computeSize+setSize reflow that follows.
member_pattern_ids:
- S10.D1
- S10.D3
- S15.OS1
usage_weight: 38.63
exemplars:
- pattern_id: S10.D1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/lib/litegraph/src/canvas/LinkConnector.core.test.ts#L121
stars: 1787
- pattern_id: S10.D1
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-mode-nodes.js#L42
stars: 19
- pattern_id: S15.OS1
repo: yorkane/ComfyUI-KYNode
url: https://github.com/yorkane/ComfyUI-KYNode/blob/main/web/python-editor.js#L243
stars: 10
- category_id: BC.10
name: Widget value subscription
intent: Subscribe to widget value changes either at the widget (callback chain) or node (onWidgetChanged) level.
member_pattern_ids:
- S4.W1
- S2.N14
usage_weight: 32.22
exemplars:
- pattern_id: S2.N14
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/widgetInputs.ts#L317
stars: 1787
- pattern_id: S4.W1
repo: crom8505/ComfyUI-Dynamic-Sigmas
url: https://github.com/crom8505/ComfyUI-Dynamic-Sigmas/blob/main/web/js/graph_sigmas.js#L79
stars: 8
- pattern_id: S4.W1
repo: 834t/ComfyUI_834t_scene_composer
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L148
stars: 5
- category_id: BC.11
name: Widget imperative state writes
intent: Imperatively mutate widget value, COMBO option lists, or the node.widgets array (insert/remove/reorder).
member_pattern_ids:
- S4.W4
- S4.W5
- S2.N16
usage_weight: 28.42
exemplars:
- pattern_id: S2.N16
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
stars: 19
- pattern_id: S4.W4
repo: EnragedAntelope/EA_LMStudio
url: https://github.com/EnragedAntelope/EA_LMStudio/blob/main/web/ea_lmstudio.js#L11
stars: 7
- pattern_id: S4.W4
repo: zzggi2024/shaobkj
url: https://github.com/zzggi2024/shaobkj/blob/main/js/dynamic_inputs.js#L374
stars: 1
- category_id: BC.12
name: Per-widget serialization transform
intent: Transform a widget's value at workflow-serialization time (dynamic prompts, hidden state, expand-on-save).
notes: >-
widget.options.serialize===false widgets (e.g. control_after_generate) still occupy a widgets_values
slot and still fire serializeValue — excluded only from the backend prompt by graphToPrompt(). Test
triple must cover this case explicitly. PR #10392 widgets_values_named is the v2 migration path;
WidgetHandle identity must be by name not position. See research/architecture/widget-serialization-historical-analysis.md.
member_pattern_ids:
- S4.W3
usage_weight: 27.94
exemplars:
- pattern_id: S4.W3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/helpers/painter.ts#L70
stars: 1787
- pattern_id: S4.W3
repo: Raykosan/ComfyUI_RaykoStudio
url: https://github.com/Raykosan/ComfyUI_RaykoStudio/blob/main/web/rayko_lora_widget.js#L31
stars: 45
- pattern_id: S4.W3
repo: 834t/ComfyUI_834t_scene_composer
url: https://github.com/834t/ComfyUI_834t_scene_composer/blob/main/js/b34t_scene_composer.js#L135
stars: 5
- category_id: BC.13
name: Per-node serialization interception
intent: Intercept node-level serialize/onSerialize to inject custom workflow JSON fields.
notes: >-
Root cause: widgets_values is positional — prototype.serialize patchers consume/produce this array
directly. Three index-drift sources: control_after_generate slot occupancy, extension-injected
widgets, V3 IO.MultiType topology-dependent widget count. NaN→null pipeline produces silent
corruption (backend crash is first visible symptom). Test triple must cover: (a) positional v1
compat, (b) named-map v2 round-trip parity, (c) null-in-numeric-widget logs warning + substitutes
default. PR #11884 guard, PR #10392 named map. See research/architecture/widget-serialization-historical-analysis.md.
member_pattern_ids:
- S2.N6
- S2.N15
usage_weight: 47.07
exemplars:
- pattern_id: S2.N15
repo: Azornes/Comfyui-LayerForge
url: https://github.com/Azornes/Comfyui-LayerForge/blob/main/js/CanvasView.js#L1438
stars: 313
- pattern_id: S2.N15
repo: IAMCCS/IAMCCS-nodes
url: https://github.com/IAMCCS/IAMCCS-nodes/blob/main/web/iamccs_wan_motion_presets.js#L598
stars: 92
- pattern_id: S2.N15
repo: DazzleNodes/ComfyUI-Smart-Resolution-Calc
url: https://github.com/DazzleNodes/ComfyUI-Smart-Resolution-Calc/blob/main/web/utils/serialization.js#L32
stars: 7
- category_id: BC.14
name: Workflow → API serialization interception (graphToPrompt)
intent: Patch app.graphToPrompt to resolve virtual nodes, inject custom metadata, or rewrite the API payload before submit.
member_pattern_ids:
- S6.A1
usage_weight: 46.66
exemplars:
- pattern_id: S6.A1
repo: Comfy-Org/ComfyUI-Manager
url: https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/js/components-manager.js#L781
stars: 14554
- pattern_id: S6.A1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
stars: 2568
- pattern_id: S6.A1
repo: m3rr/h4_Live
url: https://github.com/m3rr/h4_Live/blob/main/js/h4_datastream.js#L23
stars: 2
- category_id: BC.15
name: Workflow loading into the editor
intent: External/embed scenario where a workflow JSON is pushed into the running editor via app.loadGraphData.
member_pattern_ids:
- S6.A2
usage_weight: 20.31
exemplars:
- pattern_id: S6.A2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/fixtures/helpers/WorkflowHelper.ts#L215
stars: 1787
- pattern_id: S6.A2
repo: BennyKok/comfyui-deploy
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/workflow-list.js#L456
stars: 1507
- pattern_id: S6.A2
repo: ketle-man/ComfyUI-Workflow-Studio
url: https://github.com/ketle-man/ComfyUI-Workflow-Studio/blob/main/static/js/workflow-tab.js#L67
stars: 2
- category_id: BC.16
name: Execution output consumption (per-node)
intent: Consume backend execution output on a specific node (text, JSON, image) to drive display.
member_pattern_ids:
- S2.N2
usage_weight: 5.74
exemplars:
- pattern_id: S2.N2
repo: andreszs/ComfyUI-Ultralytics-Studio
url: https://github.com/andreszs/ComfyUI-Ultralytics-Studio/blob/main/js/show_string.js#L9
stars: 3
- pattern_id: S2.N2
repo: AlexZ1967/ComfyUI_ALEXZ_tools
url: https://github.com/AlexZ1967/ComfyUI_ALEXZ_tools/blob/main/web/show_json.js#L49
stars: 0
- pattern_id: S2.N2
repo: becky3/comfyui-workspace
url: https://github.com/becky3/comfyui-workspace/blob/main/custom_nodes/ComfyUI-Becky3-Common/js/show_text.js#L33
stars: 0
- category_id: BC.17
name: Backend execution lifecycle and progress events
intent: Subscribe to api.addEventListener for execution_*, progress, status, and reconnecting events.
member_pattern_ids:
- S5.A1
- S5.A2
- S5.A3
usage_weight: 51.25
exemplars:
- pattern_id: S5.A2
repo: AIGODLIKE/AIGODLIKE-ComfyUI-Studio
url: https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Studio/blob/main/loader/components/public/iconRenderer.js#L39
stars: 405
- pattern_id: S5.A3
repo: kyuz0/amd-strix-halo-comfyui-toolboxes
url: https://github.com/kyuz0/amd-strix-halo-comfyui-toolboxes/blob/main/scripts/benchmark_workflows.py#L52
stars: 109
- pattern_id: S5.A1
repo: ShakerSmith/ShakerNodesSuite
url: https://github.com/ShakerSmith/ShakerNodesSuite/blob/main/js/shaker_preview_ui.js#L58
stars: 8
- category_id: BC.18
name: Backend HTTP calls
intent: Call ComfyAPI.fetchApi as the canonical authenticated path to backend HTTP endpoints.
member_pattern_ids:
- S6.A3
usage_weight: 22.74
exemplars:
- pattern_id: S6.A3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/components/common/BackgroundImageUpload.vue#L61
stars: 1787
- pattern_id: S6.A3
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L1227
stars: 4
- pattern_id: S6.A3
repo: zhupeter010903/ComfyUI-XYZ-prompt-library
url: https://github.com/zhupeter010903/ComfyUI-XYZ-prompt-library/blob/main/js/prompt_library_window.js#L1379
stars: 1
- category_id: BC.19
name: Workflow execution trigger
intent: Trigger or intercept queuePrompt for sidebar Run buttons, auth tokens, or payload mutation.
member_pattern_ids:
- S6.A4
usage_weight: 12.65
exemplars:
- pattern_id: S6.A4
repo: MajoorWaldi/ComfyUI-Majoor-AssetsManager
url: https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager/blob/main/js/features/viewer/workflowSidebar/sidebarRunButton.js#L317
stars: 97
- pattern_id: S6.A4
repo: gigici/ComfyUI_BlendPack
url: https://github.com/gigici/ComfyUI_BlendPack/blob/main/js/ui/NodeUI.js#L99
stars: 1
- pattern_id: S6.A4
repo: rohapa/comfyui-replay
url: https://github.com/rohapa/comfyui-replay/blob/main/README.md#L497
stars: 0
- category_id: BC.20
name: Custom node-type registration (frontend-only / virtual)
intent: Register pure-frontend or fully virtual node types and mark them with isVirtualNode so the backend ignores them.
member_pattern_ids:
- S1.H5
- S1.H6
- S8.P1
usage_weight: 27.49
exemplars:
- pattern_id: S1.H6
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/rerouteNode.ts
stars: 1787
- pattern_id: S1.H5
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H6
repo: sofakid/dandy
url: https://github.com/sofakid/dandy/blob/main/web/main.js#L111
stars: 54
- category_id: BC.21
name: Custom widget-type registration
intent: Register new widget types (color picker, file uploader, custom inputs) via getCustomWidgets.
member_pattern_ids:
- S1.H2
usage_weight: 7.17
exemplars:
- pattern_id: S1.H2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H2
repo: haohaocreates/PR-rk-comfy-nodes-36d8f0a5
url: https://github.com/haohaocreates/PR-rk-comfy-nodes-36d8f0a5/blob/main/web/rk_nodes.ts#L22
stars: 0
- category_id: BC.22
name: Context menu contributions (node and canvas)
intent:
Contribute right-click menu items at both the node and canvas scope, including legacy prototype patches and the
supported v1 hooks.
member_pattern_ids:
- S2.N5
- S1.H3
- S1.H4
usage_weight: 19.53
exemplars:
- pattern_id: S1.H3
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H4
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/
stars: 1787
- pattern_id: S1.H3
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-canvas-utils.js#L2
stars: 19
- category_id: BC.23
name: Node property bag mutations
intent: React to mutations of node.properties — the persistent property bag that survives serialization.
member_pattern_ids:
- S2.N18
usage_weight: 14.42
exemplars:
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/src_web/comfyui/seed.ts#L78
stars: 3049
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/seed.js#L26
stars: 3049
- pattern_id: S2.N18
repo: rgthree/rgthree-comfy
url: https://github.com/rgthree/rgthree-comfy/blob/main/web/comfyui/power_primitive.js#L142
stars: 3049
- category_id: BC.24
name: Node-def schema inspection
intent: Branch on ComfyNodeDef shape (input.required/optional/hidden, output, output_node, category) to drive UI.
member_pattern_ids:
- S13.SC1
usage_weight: 22.43
exemplars:
- pattern_id: S13.SC1
repo: BennyKok/comfyui-deploy
url: https://github.com/BennyKok/comfyui-deploy/blob/main/web-plugin/index.js#L1
stars: 1507
- pattern_id: S13.SC1
repo: StableLlama/ComfyUI-basic_data_handling
url: https://github.com/StableLlama/ComfyUI-basic_data_handling/blob/main/web/js/dynamicnode.js#L1
stars: 43
- pattern_id: S13.SC1
repo: xeinherjer-dev/ComfyUI-XENodes
url: https://github.com/xeinherjer-dev/ComfyUI-XENodes/blob/main/web/js/combo_selector.js#L1
stars: 1
- category_id: BC.25
name: Shell UI registration (commands, sidebars, toasts)
intent: Declarative shell-UI contributions through extensionManager / commandManager / sidebarTab / bottomPanel.
member_pattern_ids:
- S12.UI1
usage_weight: 10.98
exemplars:
- pattern_id: S12.UI1
repo: robertvoy/ComfyUI-Distributed
url: https://github.com/robertvoy/ComfyUI-Distributed/blob/main/web/main.js#L269
stars: 544
- pattern_id: S12.UI1
repo: maxi45274/ComfyUI_LinkFX
url: https://github.com/maxi45274/ComfyUI_LinkFX/blob/main/js/LinkFX.js#L707
stars: 3
- pattern_id: S12.UI1
repo: criskb/Comfypencil
url: https://github.com/criskb/Comfypencil/blob/main/web/comfy_pencil_extension.js#L955
stars: 0
- category_id: BC.26
name: Globals as ABI (window.LiteGraph, window.comfyAPI)
intent: Reach into the global namespace for LiteGraph constructors/enums or the module-as-global comfyAPI registry.
member_pattern_ids:
- S7.G1
usage_weight: 27.0
exemplars:
- pattern_id: S7.G1
repo: ryanontheinside/ComfyUI_RyanOnTheInside
url: https://github.com/ryanontheinside/ComfyUI_RyanOnTheInside/blob/main/web/js/index.js#L1
stars: 801
- pattern_id: S7.G1
repo: ArtHommage/HommageTools
url: https://github.com/ArtHommage/HommageTools/blob/main/web/js/index.js#L1
stars: 4
- pattern_id: S7.G1
repo: PROJECTMAD/PROJECT-MAD-NODES
url: https://github.com/PROJECTMAD/PROJECT-MAD-NODES/blob/main/web/js/index.js#L1
stars: 4
- category_id: BC.27
name: LiteGraph entity direct manipulation (reroute, group, link, slot)
intent: Direct read/mutation of reroutes, groups, links, and slots — no public extension API exists today.
member_pattern_ids:
- S9.R1
- S9.G1
- S9.L1
- S9.S1
usage_weight: 39.37
exemplars:
- pattern_id: S9.R1
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
stars: 330
- pattern_id: S9.S1
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L267
stars: 330
- pattern_id: S9.S1
repo: Stibo/comfyui-nifty-nodes
url: https://github.com/Stibo/comfyui-nifty-nodes/blob/main/js/nifty_nodes.js#L112
stars: 3
- category_id: BC.28
name: Subgraph fan-out via set/get virtual nodes
intent: Fan out a single named value across the graph without explicit links (KJNodes-style Set/Get nodes).
member_pattern_ids:
- S9.SG1
usage_weight: 16.89
exemplars:
- pattern_id: S9.SG1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/setgetnodes.js#L1406
stars: 2568
- pattern_id: S9.SG1
repo: krismasdev/ComfyUI-Flux-Continuum
url: https://github.com/krismasdev/ComfyUI-Flux-Continuum/blob/main/web/hint.js#L1
stars: 0
- pattern_id: S9.SG1
repo: SpaceWarpStudio/ComfyUI-SetInputGetOutput
url: https://github.com/SpaceWarpStudio/ComfyUI-SetInputGetOutput/blob/main/web/js/setinputgetoutput.js#L1
stars: 0
- category_id: BC.29
name: Graph enumeration, mutation, and cross-scope identity
intent:
Enumerate or mutate the node set (graph.add/remove/findNodesByType/serialize/configure) and resolve cross-subgraph
references via NodeLocatorId / NodeExecutionId.
member_pattern_ids:
- S11.G2
- S14.ID1
usage_weight: 23.56
exemplars:
- pattern_id: S11.G2
repo: yolain/ComfyUI-Easy-Use
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easyExtraMenu.js#L439
stars: 2503
- pattern_id: S11.G2
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/tests/workflowPersistence.spec.ts#L351
stars: 1787
- pattern_id: S11.G2
repo: r-vage/ComfyUI_Eclipse
url: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-ui-enhancements.js#L29
stars: 19
- category_id: BC.30
name: Graph change tracking, batching, and reactivity flush
intent:
'Coordinate graph-level change: graph._version monotonic counter, beforeChange/afterChange batching, and the imperative
setDirtyCanvas redraw flush.'
member_pattern_ids:
- S11.G1
- S11.G3
- S11.G4
usage_weight: 34.38
exemplars:
- pattern_id: S11.G3
repo: nodetool-ai/nodetool
url: https://github.com/nodetool-ai/nodetool/blob/main/subgraphs.md#L1
stars: 330
- pattern_id: S11.G4
repo: akawana/ComfyUI-Folded-Prompts
url: https://github.com/akawana/ComfyUI-Folded-Prompts/blob/main/js/FPFoldedPrompts.js#L776
stars: 4
- pattern_id: S11.G3
repo: linjm8780860/ljm_comfyui
url: https://github.com/linjm8780860/ljm_comfyui/blob/main/src/utils/vintageClipboard.ts#L1
stars: 0
- category_id: BC.31
name: DOM injection and style management
intent:
Extensions add UI chrome, toolbars, and style overrides directly into the document outside any provided API —
style tags into head, arbitrary elements into body, innerHTML rendering, and external script loading.
member_pattern_ids:
- S16.DOM1
- S16.DOM2
- S16.DOM3
- S16.DOM4
usage_weight: 0.0
notes:
'usage_weight pending rollup-blast-radius.py re-run after database.yaml merge (I-N4.1). Notion counts: DOM1=354
occ, DOM2=364 occ, DOM3=443 occ, DOM4=232 occ across ~81 packages — among the highest raw occurrence counts in
the entire dataset. v2 replacements: injectStyles(), addPanel(), addToolbarItem(), safe HTML rendering API.'
exemplars:
- pattern_id: S16.DOM1
repo: kijai/ComfyUI-KJNodes
url: https://github.com/kijai/ComfyUI-KJNodes/blob/main/web/js/help_popup.js
stars: 2568
- pattern_id: S16.DOM2
repo: yolain/ComfyUI-Easy-Use
url: https://github.com/yolain/ComfyUI-Easy-Use/blob/main/web_version/v1/js/easy/easy.js
stars: 2503
- pattern_id: S16.DOM3
repo: '(aggregate — Notion §2.3)'
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
- category_id: BC.32
name: Embedded framework runtimes and Vue widget bundling
intent:
Extensions bundle their own copy of Vue (or another framework) inside a DOM widget, bypassing the host app
instance and losing access to shared stores, i18n, and theme.
member_pattern_ids:
- S16.VUE1
usage_weight: 0.0
notes:
'usage_weight pending rollup-blast-radius.py re-run. 9 packages confirmed (Notion §2.9). v2 replacement:
registerVueWidget(nodeType, name, Component) sharing host Vue instance — already in plans/P1 §5 Custom widget type.
This BC provides the evidence base for that P1 design decision.'
exemplars:
- pattern_id: S16.VUE1
repo: ComfyUI-NKD-Sigmas-Curve
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
- pattern_id: S16.VUE1
repo: '(aggregate — 9 packages, Notion §2.9)'
url: https://www.notion.so/comfy-org/ComfyUI-Custom-Node-Frontend-API-Usage-Research-3356d73d365080dbaacafe8e52d52692
stars: 0
# ── Categories added 2026-05-08 from Notion COM-3668 (Simon Tranter, Custom Scripts API requirements) ──
- category_id: BC.33
name: Cross-extension DOM widget creation observation
intent:
An extension observes when *any* DOM widget is created (by any other extension) so it can attach its own
listeners — the mechanism the Autocomplete extension needs to wire its input handler to every text widget.
member_pattern_ids:
- S4.W6
usage_weight: 0.0
notes: >-
Identified from COM-3668. Distinct from BC.05 (creating DOM widgets) and BC.10 (subscribing to value changes).
Gap: no v1 hook fires for cross-extension widget creation observation. v2 shape: onDOMWidgetCreated(handler)
in defineExtension setup context. usage_weight pending blast-radius re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S4.W6
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.34
name: Settings-panel custom dialog integration
intent: Extensions open custom modal dialogs triggered from the settings panel, rather than injecting raw DOM.
member_pattern_ids:
- S12.UI3
usage_weight: 0.0
notes: >-
Identified from COM-3668. Currently worked around via S16.DOM3 innerHTML injection. Distinct from S12.UI1
(sidebar/command registration) — this is about dialog lifecycle tied to settings entries. v2 shape:
app.ui.openDialog(Component) or settings entry type 'dialog-trigger'. usage_weight pending blast-radius re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S12.UI3
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.35
name: Pre-queue widget validation
intent:
Validate widget values before a workflow is submitted and surface typed errors to the user — rejecting
the queue rather than silently mutating or failing.
member_pattern_ids:
- S6.A5
usage_weight: 0.0
notes: >-
Identified from COM-3668. Currently worked around via S6.A4 queuePrompt monkey-patching (silent_breakage=true
when multiple extensions patch). Distinct from D5 beforeSerialize (transforms values) and BC.19 (triggers
execution). v2 needs explicit beforeQueue event with event.reject(message). usage_weight pending re-run.
source: notion-COM-3668
exemplars:
- pattern_id: S6.A5
repo: goodtab/ComfyUI-Custom-Scripts
url: https://github.com/goodtab/ComfyUI-Custom-Scripts
stars: 0
- category_id: BC.36
name: PrimeVue widget component API surface
intent: >-
Custom node authors configuring widget behavior via per-component prop subsets — the v2 replacement
for direct widget.options mutation (S4.W4, S4.W1) and DOM widget construction (S4.W5, S4.W6).
15 PrimeVue components are the authoritative widget-kind enumeration for v2.
member_pattern_ids:
- S4.W1
- S4.W4
- S4.W5
notes: >-
Source: Notion page "Widget Component APIs" (2026-05-08). 15 components: Button, InputText, Select,
ColorPicker, MultiSelect, SelectButton, Slider, Textarea, ToggleSwitch, Chart, Image, ImageCompare,
Galleria, FileUpload, TreeSelect. Exclusion rule (Pablo): strip style/class/dt/pt/*Class/*Style.
ToggleSwitch is the only component with completed Pick<> types so far (WIP).
Informs: D7 typed options bags (future pivot), I-TF.2 widget-kind test triples,
PKG2 WidgetHandle.getOption key surface. disabled/readonly map to D7 first-class fields,
not options bag.
usage_weight: 0.0
exemplars:
- pattern_id: S4.W4
repo: '(see database.yaml S4.W4 exemplars — widget.options.values mutation)'
url: https://www.notion.so/comfy-org/Widget-Component-APIs-2126d73d365080b0bf30f241c09dd756
stars: 0
- category_id: BC.37
name: VueNode bridge timing — deferred mount access
intent: >-
Extensions that register in nodeCreated but need to access Vue-component-backed state
(Three.js renderer, DOM widget, ComponentWidgetImpl value) must defer until the Vue
component's onMounted fires. The v1 pattern is waitForLoad3d(node, cb); the v2 pattern
is onNodeMounted(() => { ... }) inside defineNodeExtension.
member_pattern_ids:
- S4.W5
notes: >-
Source: Notion Frontend Architecture page 3536d73d (2026-05-08). nodeCreated gives the
LiteGraph node; the VueNode Vue component has NOT mounted yet. waitForLoad3d in
src/extensions/core/Load3D is the canonical v1 fixture. ComponentWidgetImpl dual-identity:
LiteGraph side (value/callback/name) vs Vue side (props/emits/lifecycle).
v2 contract: onNodeMounted() hook fires after Vue component mount — this is the correct
timing for accessing VueNode-backed resources.
Informs: I-SR.2.B2 (NodeInstanceScope must not sync-access VueNode at setup time),
I-TF.3.C1 (harness must simulate two-phase mount), I-TF.2 test triple for BC.37.
D8 relevance: app.rootGraph is not reactive (confirmed by this doc) — the exact gap D8 solves.
usage_weight: 0.0
source: notion-frontend-architecture-3536d73d
exemplars:
- pattern_id: S4.W5
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/extensions/core/load3d.ts
stars: 1787
- category_id: BC.38
name: Canvas mode observation
intent: >-
Detect and react to ComfyUI canvas mode transitions (graph / app / builder:inputs /
builder:outputs / builder:arrange). Custom nodes that adapt rendering, widget resize
behavior, or read-only state across modes need a stable event — not polling or heuristics
against internal Pinia store state.
member_pattern_ids:
- S17.AM1
mechanism: absent-api
notes: >-
appModeStore is a Pinia composable; JS extensions cannot use Vue composables. v2 gap:
no node.on('canvasModeChanged') exists yet in node.ts — distinct from NodeModeChangedEvent
(execution mode only). v2 contract: app-level or node-level canvasModeChanged event.
Flagged: Terry DX walkthrough A.1. Informs: node.ts overloads (add canvasModeChanged
or document as known gap), I-TF.2 test triple for BC.38.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.AM1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.39
name: Subgraph boundary event propagation
intent: >-
Custom node callbacks (onExecuted, MatchType, autogrow onConnectionsChange, promoted widget
callbacks) that must propagate across subgraph boundaries. Four distinct silent-failure modes
when custom nodes are placed inside subgraphs.
member_pattern_ids:
- S17.SB1
mechanism: absent-api
notes: >-
Requires D9 Phase B (post-Alex rebase on #11939). ECS substrate must forward SubgraphNode
execution events from internal nodes. MatchType and autogrow propagation require subgraph
boundary awareness in World dispatcher. Blocked: I-PG.B1. Short-term: @experimental on
affected NodeHandle events; subgraphCompatible flag in NodeExtensionOptions.
Intersects: ADR 0006 (I-NEW.1), Austin's fix-linked-widget-promotion.
Flagged: Terry DX walkthrough A.2.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.SB1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.40
name: File upload and asset URL construction
intent: >-
Upload files to ComfyUI backend and construct retrieval URLs. 32+ packages duplicate this
pattern from scratch — FormData construction, fetchApi('/upload/image'), /view?filename URL
assembly. A helper API would collapse this to comfyAPI.uploadFile() + comfyAPI.getFileUrl().
member_pattern_ids:
- S17.FA1
mechanism: absent-api
notes: >-
Out of scope for @comfyorg/extension-api (node extension surface). Belongs in future
@comfyorg/comfy-api package. 32+ packages affected; 9 implement video upload variants.
Upload timeout hardcoded 120s; large 3D/video fail silently. No temp file lifecycle.
Document as known gap in src/extension-api/README.md.
Flagged: Terry DX walkthrough A.3.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.FA1
repo: (first-principles assessment — Terry Jia)
url: https://www.notion.so/comfy-org/Develop-a-custom-node-from-scratch-pain-point-assessment-33c6d73d365080f49126c0b5affa7559
stars: 0
- category_id: BC.41
name: Widget values positional serialization fragility
intent: >-
Widget values serialized as positional array [v1, v2, v3] instead of named dict.
Any input definition change (add, reorder, rename, remove, required→optional) silently
misaligns values when loading existing workflows. Root cause of #1 user complaint:
"my workflow broke after I updated the custom node."
member_pattern_ids:
- S17.WV1
mechanism: positional-array
notes: >-
Blocked on workflow-schema-migration (out of v2 surface scope). D7 Part 4 (4→2
serialization collapse) + beforeSerialize as partial mitigation. Long-term fix: named dict
format { widgetName: value } — breaking JSON schema change requiring versioning +
migrateWidgetValues() callback. PR #10392 added widgets_values_named opt-in; PR #11884
null guard. v2 contract: name-keyed identity (WidgetHandle by name not position).
Intersects: ADR 0006, widget-serialization-historical-analysis.md, Austin's work.
Flagged: Terry DX walkthrough A.4.
usage_weight: 0.0
source: notion-pain-point-assessment
exemplars:
- pattern_id: S17.WV1
repo: Comfy-Org/ComfyUI_frontend
url: https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/utils/nodeDefOrderingUtil.ts
stars: 1787

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
Compat-floor gate: Verify all high-impact behavior categories have test triples.
Per PLAN.md §Compat-floor: "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 +
migration tests before v2 ships."
This script:
1. Reads research/touch-points/behavior-categories.yaml
2. Finds all categories with usage_weight >= 2.0 (blast_radius threshold)
3. Checks that each has all three test files: bc-XX.v1.test.ts, bc-XX.v2.test.ts, bc-XX.migration.test.ts
4. Exits 0 if all present, exits 1 if any missing (fails CI)
Usage: python3 scripts/check-compat-floor.py
"""
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print("ERROR: PyYAML not installed. Run: pip install pyyaml", file=sys.stderr)
sys.exit(1)
COMPAT_FLOOR_THRESHOLD = 2.0
BEHAVIOR_CATEGORIES_PATH = Path("research/touch-points/behavior-categories.yaml")
TESTS_DIR = Path("src/extension-api-v2/__tests__")
def main():
# Check that behavior-categories.yaml exists
if not BEHAVIOR_CATEGORIES_PATH.exists():
print(f"ERROR: {BEHAVIOR_CATEGORIES_PATH} not found", file=sys.stderr)
print(" Run scripts/build-behavior-categories.py first or copy from workspace", file=sys.stderr)
sys.exit(1)
# Skip check if tests directory doesn't exist (tests only in tf branch)
if not TESTS_DIR.exists():
print(f"SKIP: {TESTS_DIR} not found — compat-floor tests not yet added to this branch")
print(" The compat-floor gate only enforces on branches with extension-api-v2 tests.")
sys.exit(0)
# Load categories
with open(BEHAVIOR_CATEGORIES_PATH, "r") as f:
data = yaml.safe_load(f)
categories = data.get("categories", [])
# Find categories above compat floor
above_floor = []
for cat in categories:
cat_id = cat.get("category_id", "")
usage_weight = cat.get("usage_weight", 0)
if usage_weight >= COMPAT_FLOOR_THRESHOLD:
above_floor.append({
"id": cat_id,
"name": cat.get("name", ""),
"usage_weight": usage_weight
})
print(f"Compat-floor check: {len(above_floor)} categories with usage_weight >= {COMPAT_FLOOR_THRESHOLD}")
print()
# Check each category for test triples
missing = []
for cat in above_floor:
cat_id = cat["id"]
# Extract number from BC.XX
num_str = cat_id.replace("BC.", "").zfill(2)
required_files = [
f"bc-{num_str}.v1.test.ts",
f"bc-{num_str}.v2.test.ts",
f"bc-{num_str}.migration.test.ts"
]
cat_missing = []
for fname in required_files:
fpath = TESTS_DIR / fname
if not fpath.exists():
cat_missing.append(fname)
if cat_missing:
missing.append({
"category": cat_id,
"name": cat["name"],
"usage_weight": cat["usage_weight"],
"missing": cat_missing
})
status = "❌ MISSING"
else:
status = ""
print(f" {cat_id} ({cat['usage_weight']:.2f}) {cat['name'][:40]:<40} {status}")
if cat_missing:
for m in cat_missing:
print(f" └─ {m}")
print()
if missing:
print(f"FAIL: {len(missing)} categories missing test files", file=sys.stderr)
print()
print("Per PLAN.md §Compat-floor, all blast_radius >= 2.0 categories", file=sys.stderr)
print("must have complete test triples (v1, v2, migration) before v2 ships.", file=sys.stderr)
print()
print("Missing files:", file=sys.stderr)
for m in missing:
for f in m["missing"]:
print(f" - {TESTS_DIR / f}", file=sys.stderr)
sys.exit(1)
else:
print(f"PASS: All {len(above_floor)} compat-floor categories have test triples")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,68 @@
# Extension API — Public Source of Truth
> **Status**: Implemented (Phase A). Runtime backed by stub ECS components;
> full ECS integration lands with #11939.
This folder is the single source of truth for the public ComfyUI extension
API. Every file here is part of the published `@comfyorg/extension-api`
npm package. Do not re-export from `/src` — this barrel is the **published
package entry point**, which is the explicit exception to the project's
"no barrel files in /src" rule (root AGENTS.md rule #19).
## File structure
```
extension-api/
├── index.ts ← barrel — package entry point
├── node.ts ← NodeHandle interface + node event payload types
├── widget.ts ← WidgetHandle interface + widget event payload types
├── types.ts ← ExtensionOptions, NodeExtensionOptions, WidgetExtensionOptions
├── events.ts ← Handler<E>, AsyncHandler<E>, Unsubscribe
├── lifecycle.ts ← onNodeMounted, onNodeRemoved hooks + rationale docs
├── shell.ts ← SidebarTabExtension, BottomPanelExtension, CommandManager, etc.
├── identifiers.ts ← NodeLocatorId, NodeExecutionId + parsers/type guards
└── README.md ← this file
```
## What about v1?
v1 (`ComfyExtension` interface in `../types/comfy.ts`, `app.registerExtension(...)`
runtime entry point in `../scripts/app.ts`) **stays in its current locations**.
Custom extensions in the wild consume the runtime entry point, not the type
file — moving the type file would churn ~30 internal imports for zero runtime
benefit. The v1↔v2 distinction is at the entry point, not the folder.
## Authoring rules
1. **Hand-authored**, not generated. This is a public API; we own the shape.
2. **No `any`, no `as any`, no `@ts-expect-error`.** If you need an escape
hatch, the type is wrong.
3. Every public type has a TSDoc block with at minimum:
- 1-line summary
- `@stability` tag (`stable` | `experimental` | `deprecated`)
- `@example` block (where applicable)
4. Naming follows conventions:
- Read-only invariants (set at construction): `readonly` property
- Read-only state (changes over time): method (`getValue()`)
- Mutating actions: method (`setValue(v)`)
- Boolean predicates: method (`isHidden()`)
5. Events: typed payloads, no `Function`, split-channel events
(`valueChange` / `optionChange` / `propertyChange`).
6. No internal types (`World`, `Component<T>`, branded `EntityId` internals)
leak through this barrel.
## Key design decisions
| ADR | Decision |
| ----------------------------------------------------------------------------- | --------------------------------------------------------- |
| [ADR-0008](../../docs/adr/0008-entity-component-system.md) | Entity Component System architecture |
| [ADR-0010](../../docs/adr/0010-deprecate-node-level-serialization-control.md) | Deprecate `node.on('beforeSerialize')` — use widget-level |
| [ADR-0011](../../docs/adr/0011-immutability-via-fresh-copies.md) | Return fresh copies from collection methods |
| [ADR-0012](../../docs/adr/0012-pure-function-loader-pattern.md) | Pure function registration + loader activation |
## Related research
- [Identity encapsulation](../../docs/research/identity-encapsulation.md) — when extensions need raw entity IDs
- [Coordinate systems](../../docs/research/coordinate-systems.md) — canvas vs screen coordinates
- [Widget state categories](../../docs/research/widget-state-categories.md) — value/properties/options/DOM
- [Serialization context](../../docs/research/serialization-context.md) — workflow/prompt/clone/subgraph-promote

View File

@@ -0,0 +1,61 @@
/**
* D18 Phase 1 — brand symbols for `define*` outputs.
*
* Per D18, `defineNode` / `defineWidget` / `defineExtension` will become
* pure functions whose return values are recognized at registration time
* by a loader that walks module exports and dispatches based on the brand.
*
* Phase 1 (this file) introduces the brand symbol and the `isBrandedExtension`
* type-guard. The `define*` functions stamp the brand on their returned
* options so a future loader can identify them. Side-effect registration
* remains unchanged in Phase 1; Phase 2 removes it.
*
* The brand is a `Symbol.for(...)` so HMR + duplicate-package scenarios still
* resolve to the same identity (per realm / per JS context).
*
* @internal — not re-exported from `@comfyorg/extension-api/index.ts`. The
* loader lives inside the runtime, not in the published package.
*/
export const EXTENSION_BRAND = Symbol.for('@comfyorg/extension-api:brand')
export type ExtensionKind = 'node' | 'widget' | 'app'
export interface Branded {
readonly [EXTENSION_BRAND]: ExtensionKind
}
/**
* Stamp a brand on an options object and freeze it. Returned reference is
* the same object — branding is non-enumerable so JSON serialization,
* spread operations, and shallow-equal comparisons are unaffected.
*/
export function stampBrand<T extends object>(
options: T,
kind: ExtensionKind
): T & Branded {
Object.defineProperty(options, EXTENSION_BRAND, {
value: kind,
enumerable: false,
writable: false,
configurable: false
})
return Object.freeze(options) as T & Branded
}
/**
* Type-guard for branded extension options. The loader uses this to
* decide whether a module export is a `defineX(...)` result.
*
* Unbranded values (utility exports, constants, helper functions) return
* `false` and are silently ignored by the loader.
*/
export function isBrandedExtension(value: unknown): value is Branded {
if (value === null || typeof value !== 'object') return false
const kind = (value as Record<symbol, unknown>)[EXTENSION_BRAND]
return kind === 'node' || kind === 'widget' || kind === 'app'
}
export function getBrandKind(value: Branded): ExtensionKind {
return value[EXTENSION_BRAND]
}

277
src/extension-api/events.ts Normal file
View File

@@ -0,0 +1,277 @@
/**
* Shared event infrastructure for the ComfyUI extension API.
*
* @packageDocumentation
*/
/**
* A typed event handler function.
*
* @typeParam E - The event payload type.
* @example
* ```ts
* const handler: Handler<WidgetValueChangeEvent<number>> = (e) => {
* console.log(e.oldValue, '->', e.newValue)
* }
* ```
*/
export type Handler<E> = (event: E) => void
/**
* A typed async-capable event handler. Only valid for events that explicitly
* support async handling (currently only `beforeSerialize`).
*
* @typeParam E - The event payload type.
* @example
* ```ts
* import type { AsyncHandler, WidgetBeforeSerializeEvent } from '@comfyorg/extension-api'
*
* const handler: AsyncHandler<WidgetBeforeSerializeEvent> = async (e) => {
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* }
* ```
*/
export type AsyncHandler<E> = (event: E) => void | Promise<void>
/**
* Cleanup function returned by `on()` — call to remove the listener.
*
* @example
* ```ts
* const off = node.on('executed', handler)
* // later:
* off()
* ```
*/
export type Unsubscribe = () => void
// Event-namespace facades
//
// Four typed event-namespace handles (`graph` / `execution` / `server` /
// `workbench`) replace the ad-hoc `api.addEventListener('execution_start', ...)`
// pattern documented in 360+ ecosystem call sites. Each namespace is a
// module-level singleton (SD-4 (a), handoff-11) — call from any setup() body
// or hook closure. Subscriptions registered inside a setup context auto-dispose
// when the surrounding instance is unmounted (Vue-style; subscription is added
// to the context's unmountHooks). Outside a setup context, the returned
// `Unsubscribe` is the caller's responsibility.
//
// Payload typing (SD-5 (b)): each `on()` accepts a string event name and
// returns `Handler<EventPayloadMap[ns][evt]>`. The maps default to `unknown`
// today and are tightened by D5 module augmentation in a follow-on PR. Authors
// get autocomplete on canonical event names; payload narrowing arrives when
// D5 lands.
import { api } from '@/scripts/api'
import { getCurrentExtensionInstance } from '@/services/extension-api-service'
/**
* Per-namespace event payload map. **Augment via TS module augmentation** to
* narrow payloads for canonical events. Until D5 ships, all payloads default
* to `unknown`.
*
* @example
* ```ts
* declare module '@comfyorg/extension-api' {
* interface ExecutionEventPayloads {
* start: { promptId: string }
* progress: { value: number; max: number }
* }
* }
* ```
*/
export interface GraphEventPayloads {
[event: string]: unknown
}
/**
* See {@link GraphEventPayloads | the augmentation example} —
* augment this interface the same way to narrow `execution.*` payloads.
*/
export interface ExecutionEventPayloads {
[event: string]: unknown
}
/**
* See {@link GraphEventPayloads | the augmentation example} —
* augment this interface the same way to narrow `server.*` payloads.
*/
export interface ServerEventPayloads {
[event: string]: unknown
}
/**
* See {@link GraphEventPayloads | the augmentation example} —
* augment this interface the same way to narrow `workbench.*` payloads.
*/
export interface WorkbenchEventPayloads {
[event: string]: unknown
}
interface EventNamespace<M> {
/**
* Subscribe to an event. Returns an {@link Unsubscribe} function.
*
* Inside a `setup()` body the subscription is also added to the
* surrounding instance's `onUnmounted` queue and auto-disposes when the
* extension/tab/panel is unmounted.
*/
on<K extends keyof M & string>(event: K, handler: Handler<M[K]>): Unsubscribe
/**
* Remove a previously registered handler. Same as the {@link Unsubscribe}
* returned by `on()`. Exposed for symmetry with `addEventListener`/`removeEventListener`.
*/
off<K extends keyof M & string>(event: K, handler: Handler<M[K]>): void
}
function makeNamespace<M>(rename: (evt: string) => string): EventNamespace<M> {
// ComfyApi extends EventTarget but its addEventListener is strictly typed
// against the validated ApiCalls union. The bootstrap-hooks facade
// accepts any string (custom-node events ride server.on with arbitrary
// names per ADR), so we widen via EventTarget to get the generic overload.
const target = api as unknown as EventTarget
return {
on<K extends keyof M & string>(
event: K,
handler: Handler<M[K]>
): Unsubscribe {
const wireName = rename(event)
// payload arrives as CustomEvent.detail.
const adapter = (e: Event): void => {
const detail = (e as CustomEvent).detail as M[K]
handler(detail)
}
target.addEventListener(wireName, adapter)
const unsubscribe: Unsubscribe = () => {
target.removeEventListener(wireName, adapter)
}
// Auto-dispose inside a setup() context (mirrors Vue's onScopeDispose).
const ctx = getCurrentExtensionInstance()
if (ctx) {
ctx.unmountHooks.push(unsubscribe)
}
return unsubscribe
},
off<K extends keyof M & string>(event: K, handler: Handler<M[K]>): void {
// Note: off() with a raw handler only matches if the caller saved the
// exact adapter reference returned from on(). The recommended path is
// to call the Unsubscribe returned by on(). This off() is retained for
// API symmetry but does NOT round-trip with on() handlers — they wrap
// the user fn in an adapter for CustomEvent unwrap. Authors that need
// explicit off() should use the Unsubscribe handle.
target.removeEventListener(
rename(event),
handler as unknown as EventListener
)
}
}
}
/**
* Graph-mutation events (frontend-dispatched).
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { onMounted, graph } from '@comfyorg/extension-api'
*
* defineExtension({
* name: 'my-ext',
* setup() {
* onMounted(() => {
* graph.on('changed', (e) => console.log('graph changed', e))
* })
* }
* })
* ```
*/
export const graph: EventNamespace<GraphEventPayloads> = makeNamespace(
(evt) => `graph:${evt}`
)
/**
* Prompt-run lifecycle events (backend-dispatched).
*
* Canonical events: `'start'`, `'end'`, `'error'`, `'interrupted'`, `'cached'`,
* `'executing'`, `'progress'`, `'preview'`. The wire-name mapping rewrites
* `'start'` → `'execution_start'`, etc., matching the legacy
* `api.addEventListener('execution_start', ...)` shape.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineExtension, onMounted, execution } from '@comfyorg/extension-api'
*
* defineExtension({
* name: 'my-ext',
* setup() {
* onMounted(() => {
* execution.on('start', (e) => console.log('run started', e))
* execution.on('progress', (e) => console.log('progress', e))
* execution.on('end', () => console.log('run done'))
* })
* }
* })
* ```
*/
export const execution: EventNamespace<ExecutionEventPayloads> = makeNamespace(
(evt) => `execution_${evt}`
)
/**
* Non-execution backend events + custom-node events.
*
* Canonical events: `'status'`, `'logs'`, `'reconnected'`, `'feature_flags'`,
* `'assets'`. Custom-node events ride this channel with arbitrary string
* (e.g. `server.on('rayko.inspline.show', ...)`). Module-augment
* `ServerEventPayloads` to type custom events.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineExtension, onMounted, server } from '@comfyorg/extension-api'
*
* defineExtension({
* name: 'my-ext',
* setup() {
* onMounted(() => {
* server.on('reconnected', () => console.log('server back online'))
* })
* }
* })
* ```
*/
export const server: EventNamespace<ServerEventPayloads> = makeNamespace(
(evt) => evt
)
/**
* UI shell events.
*
* Canonical events today: `'notification'`. Future: `'themeChanged'`,
* `'panelToggled'`, `'commandInvoked'`. NOT a DI container — see
* the bootstrap-hooks design for the "thin event-namespace handle only"
* scope-back.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineExtension, onMounted, workbench } from '@comfyorg/extension-api'
*
* defineExtension({
* name: 'my-ext',
* setup() {
* onMounted(() => {
* workbench.on('notification', (e) => console.log('workbench notif', e))
* })
* }
* })
* ```
*/
export const workbench: EventNamespace<WorkbenchEventPayloads> = makeNamespace(
(evt) => `workbench:${evt}`
)

View File

@@ -0,0 +1,54 @@
/**
* Node identity helpers — re-exported from internal `nodeIdentification.ts`.
*
* `NodeLocatorId` and `NodeExecutionId` are the two stable node identity
* primitives in the public API. All extension-facing code that needs to
* reference a node across subgraph boundaries or execution runs should use
* these rather than raw LiteGraph integer node IDs.
*
* @packageDocumentation
*/
export type { NodeLocatorId, NodeExecutionId } from '@/types/nodeIdentification'
/**
* Node identity round-trip helpers. Create/parse branded `NodeLocatorId` and
* `NodeExecutionId` values, or narrow an `unknown` to one with the type
* guards. Use these instead of raw string manipulation so future changes to
* the identity scheme stay transparent.
*
* @example
* ```ts
* import {
* createNodeLocatorId,
* parseNodeLocatorId,
* isNodeLocatorId,
* createNodeExecutionId,
* parseNodeExecutionId,
* isNodeExecutionId
* } from '@comfyorg/extension-api'
*
* // Construct
* const locator = createNodeLocatorId(graphUuid, localId)
* const execId = createNodeExecutionId(locator, runTag)
*
* // Narrow
* if (isNodeLocatorId(maybe)) {
* const parts = parseNodeLocatorId(maybe)
* console.log(parts.graphUuid, parts.localId)
* }
*
* if (isNodeExecutionId(maybe)) {
* const parts = parseNodeExecutionId(maybe)
* console.log(parts.locator, parts.runTag)
* }
* ```
*/
export {
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from '@/types/nodeIdentification'

View File

@@ -0,0 +1,154 @@
/**
* Inline imperative shell APIs — `toast` + `notify`
*.
*
* Per the ACCEPTED PICK (option (ii) "separate entries" with an
* inline-imperative carve-out), `toast` and `notify` are NOT exposed as
* `defineToast` / `defineNotify` `defineX` entries — the R1+R2+R3 evidence
* showed both are 100% imperative in the ecosystem today (166 toast hits /
* 16 repos, zero use as a registration target). Forcing a `defineX` wrapper
* would invent a registration concept that doesn't exist in user mental
* models.
*
* Authors call these directly from any setup body or hook closure:
*
* ```ts
* import {
* defineExtension,
* onMounted,
* toast
* } from '@comfyorg/extension-api'
*
* defineExtension({
* name: 'my-ext',
* setup() {
* onMounted(() => {
* toast.show({ severity: 'info', summary: 'Ready' })
* })
* }
* })
* ```
*
* Both APIs are fire-and-forget; there is no handle to dispose. The toast
* component manages its own lifetime (auto-dismiss via the `life` option).
*
* @packageDocumentation
*/
import type { ToastMessageOptions } from '@/types/extensionTypes'
/**
* Optional shape for {@link notify} — a thinner convenience API over
* {@link toast}. `kind` maps onto PrimeVue toast severities; `message` maps
* to `summary`; `detail` is optional supplementary text.
*
* @deprecated Use {@link ToastMessageOptions} via `toast.show(...)`. See
* D-notify-toast-consolidation.
* @publicAPI
* @stability experimental
*/
export interface NotifyOptions {
/** Severity kind. Default `'info'`. */
kind?: 'success' | 'info' | 'warn' | 'error'
/** Primary message text. */
message: string
/** Optional supplementary detail line. */
detail?: string
/** Auto-dismiss delay in ms. Defaults to PrimeVue's `life`. */
life?: number
}
/**
* Toast surface — call `toast.show(...)` from any setup body or hook
* closure.
*
* Fire-and-forget. The toast is rendered by the global Toast component
* mounted at app root; the call is a no-op if the app is not yet mounted
* (the message is silently dropped rather than queued).
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* toast.show({ severity: 'info', summary: 'Saved', life: 2000 })
* ```
*/
export const toast: {
/** Show a toast. Severity defaults to `'info'`. */
show(opts: ToastMessageOptions): void
/** Remove a previously-shown toast (matches by reference). */
remove(opts: ToastMessageOptions): void
/** Clear every toast currently visible. */
removeAll(): void
} = {
show(opts: ToastMessageOptions): void {
void _withToastStore((store) => store.add(opts))
},
remove(opts: ToastMessageOptions): void {
void _withToastStore((store) => store.remove(opts))
},
removeAll(): void {
void _withToastStore((store) => store.removeAll())
}
}
/**
* Convenience notification API — a thinner wrapper over {@link toast} that
* accepts a `{ kind, message, detail }` shape closer to OS notification
* vocabulary. Use whichever shape you prefer; they share the same
* underlying transport.
*
* Fire-and-forget.
*
* @deprecated Use {@link toast.show} — `notify` is a 1:1 wrapper sharing the
* same transport. See D-notify-toast-consolidation.
* @publicAPI
* @stability experimental
* @example
* ```ts
* // `notify` is deprecated — prefer `toast.show` directly. See
* // D-notify-toast-consolidation.
* import { toast } from '@comfyorg/extension-api'
*
* toast.show({
* severity: 'error',
* summary: 'Workflow failed',
* detail: err.message
* })
* ```
*/
export function notify(opts: NotifyOptions): void {
const severity = opts.kind ?? 'info'
toast.show({
severity,
summary: opts.message,
detail: opts.detail,
life: opts.life
})
}
/**
* Resolve the toast store on demand. Lazy-imported so this module is safe
* to evaluate at module-init time (before Pinia is ready). Errors are
* surfaced loudly in dev and swallowed in prod — toasts are non-critical.
*
* @internal
*/
async function _withToastStore(
fn: (store: {
add(opts: ToastMessageOptions): void
remove(opts: ToastMessageOptions): void
removeAll(): void
}) => void
): Promise<void> {
try {
const { useToastStore } =
await import('@/platform/updates/common/toastStore')
const store = useToastStore()
fn(store)
} catch (err) {
if (import.meta.env.DEV) {
console.error('[extension-api] toast call failed:', err)
}
}
}

184
src/extension-api/index.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* @comfyorg/extension-api — Public Extension API for ComfyUI
*
* This barrel is the published package entry point. Every export here is
* part of the public contract that extension authors depend on.
*
* Import directly — no dependency on `window.app` at module evaluation time:
*
* ```ts
* import { defineNode, defineExtension } from '@comfyorg/extension-api'
* ```
*
* ## API surface overview
*
* | Export | Purpose |
* |--------|---------|
* | `defineNode` | Register a node-scoped extension (the primary entry point) |
* | `defineExtension` | Register an app-scoped extension (init, setup, shell UI) |
* | `onNodeMounted`, `onNodeRemoved` | Implicit-context lifecycle hooks (call inside nodeCreated) |
* | `NodeHandle` | Controlled access to node state and events |
* | `WidgetHandle` | Controlled access to widget state and events |
* | `WidgetBeforeQueueEvent` | Pre-queue validation event — call `reject(msg)` to cancel |
* | `SlotInfo` | Read-only slot snapshot |
* | Shell UI types | `SidebarTabExtension`, `BottomPanelExtension`, `CommandManager`, etc. |
* | Identity helpers | `NodeLocatorId`, `NodeExecutionId`, parsers, type guards |
*
* ## Identity (D20)
*
* Handles expose `id: string` and `equals(other)` — the 90% case never needs
* a branded type. The runtime `*EntityId` brands (`NodeEntityId`,
* `WidgetEntityId`, `SlotEntityId`) are an internal storage concern and are
* NOT re-exported from this barrel. Protocol-boundary identifiers
* (`NodeLocatorId` from workflow JSON, `NodeExecutionId` from websocket
* frames) remain public because authors **receive** them from event
* payloads.
*
* ## API style
*
* The public API is **event + getter/setter**, not signals. Vue reactivity is
* the internal engine; extension authors never import from Vue or use
* `ref`/`computed`/`effect` directly. State is read via methods (`getValue()`,
* `getPosition()`), mutated via command-dispatch methods (`setValue()`,
* `setPosition()`), and observed via typed event subscriptions (`on('executed', fn)`).
* Read-only invariants (set at construction, never change) are exposed as
* accessors (`get entityId`, `get type`).
*
* ## Barrel-file rule exception
*
* ComfyUI_frontend AGENTS.md rule #19 normally forbids barrel files in `/src`.
* This barrel is the **published package entry point** — not an internal
* re-export — and is the explicit exception documented in AGENTS.md.
*
* @packageDocumentation
*/
export type {
ExtensionOptions,
NodeExtensionOptions,
WidgetExtensionOptions
} from './types'
// Runtime implementations live in the service; the types above are the
// public contract. The barrel re-exports the concrete fns from the service
// so `import { defineNode } from '@comfyorg/extension-api'` works
// at both typecheck and runtime.
//
// Note: startExtensionSystem is intentionally NOT exported here — it's an
// internal boot function, not part of the extension author API. App wiring
// imports it directly from @/services/extension-api-service.
export {
defineExtension,
defineNode,
defineWidget
} from '@/services/extension-api-service'
export { onNodeMounted, onNodeRemoved } from './lifecycle'
// context-scoped Vue-idiomatic lifecycle hooks
// usable inside `defineExtension.setup` / `defineSidebarTab.setup` /
// `defineBottomPanelTab.setup` bodies.
export {
onBeforeMount,
onMounted,
onUnmounted,
onActivated,
onDeactivated
} from './lifecycle'
// four typed event-namespace handles.
// Payload types default to `unknown` and are tightened via D5 module
// augmentation in a follow-on PR. Custom-node events ride `server.on(...)`.
export { graph, execution, server, workbench } from './events'
export type {
GraphEventPayloads,
ExecutionEventPayloads,
ServerEventPayloads,
WorkbenchEventPayloads
} from './events'
export type {
NodeHandle,
SlotInfo,
SlotDirection,
NodeMode,
Point,
Size,
NodeExecutedEvent,
NodeConnectedEvent,
NodeDisconnectedEvent,
NodePositionChangedEvent,
NodeSizeChangedEvent,
NodeModeChangedEvent,
NodeBeforeSerializeEvent
} from './node'
export type {
WidgetHandle,
WidgetValue,
WidgetOptions,
WidgetValueChangeEvent,
WidgetOptionChangeEvent,
// WidgetPropertyChangeEvent removed per A16 (D-widget-serialization-simplification, wave-9)
WidgetBeforeSerializeEvent,
WidgetBeforeQueueEvent,
// Mount-lifecycle surface per D-widget-converge / Axiom A12 ────────────
WidgetCleanup,
WidgetMountContext,
WidgetMountFn
} from './widget'
export type { Handler, AsyncHandler, Unsubscribe } from './events'
// Per D19 — VueExtension and CustomExtension are discriminated-union
// ingredients of SidebarTabExtension / BottomPanelExtension and are NOT
// part of the public surface.
//
// Note: ExtensionManager + CommandManager are
// DROPPED from the public surface — the v2 model uses per-surface defineX
// entries (defineSidebarTab, defineCommand, …) each returning a disposable,
// not a centralized umbrella handle. Internal callers continue importing
// the legacy umbrella types directly from '@/types/extensionTypes'.
export type {
// Pre-existing
SidebarTabExtension,
BottomPanelExtension,
ToastMessageOptions,
ToastManager,
// Shell-UI arg types
CommandDefinition,
HotkeyExtension,
AboutBadgeExtension,
SettingDefinition,
ToolbarButtonExtension
} from './shell'
// per-surface defineX entries. Each
// returns a DisposableHandle; carve-out: toast + notify remain inline
// imperative (exported below from ./imperatives), NOT as defineX wrappers.
export {
defineSidebarTab,
defineBottomPanelTab,
defineCommand,
defineHotkey,
defineSetting,
defineAboutBadge,
defineToolbarButton
} from './registrations'
export type { DisposableHandle } from './registrations'
// inline imperative carve-out. Fire-and-forget;
// no defineX wrapper, no DisposableHandle. Call from any setup() body or
// hook closure.
export { toast, notify } from './imperatives'
export type { NotifyOptions } from './imperatives'
export type { NodeLocatorId, NodeExecutionId } from './identifiers'
export {
isNodeLocatorId,
isNodeExecutionId,
parseNodeLocatorId,
createNodeLocatorId,
parseNodeExecutionId,
createNodeExecutionId
} from './identifiers'

View File

@@ -0,0 +1,293 @@
/**
* Extension lifecycle — `defineExtension`, `defineNode`, and
* the implicit-context lifecycle hooks (`onNodeMounted`, `onNodeRemoved`).
*
* Key behaviors:
* - Hook firing order = registration order with lexicographic tie-break
* on extension name.
* - `setup()` is synchronous. `async setup` throws in dev, emits
* console.error in prod.
* - The object returned by `setup()` is wrapped with `proxyRefs()` so
* callers read `entity.extensionState['my-ext'].count` without `.value`.
*
* Module-level import only. Extensions do NOT depend on `window.app` being
* initialized at registration time.
*
* @packageDocumentation
*/
//
// The option-type contracts live in ./types so that both this module and the
// runtime service (`@/services/extension-api-service`) can depend on them
// without forming a circular import. This module re-exports them so the
// existing public path `@/extension-api/lifecycle` keeps working.
/**
* @publicAPI
* Back-compat re-exports of the extension option contracts. Prefer importing
* from `@comfyorg/extension-api` (or `@/extension-api`); the
* `@/extension-api/lifecycle` path is preserved for downstream code that
* imported these types from the original module.
*/
export type {
NodeExtensionOptions,
ExtensionOptions,
WidgetExtensionOptions
} from './types'
import type {
NodeExtensionOptions,
ExtensionOptions,
WidgetExtensionOptions
} from './types'
/**
* Register a node extension. The runtime calls `nodeCreated` or
* `loadedGraphNode` once per node entity matching `nodeTypes`.
*
* This is the primary entry point for extensions that interact with nodes and
* widgets. Import directly from `@comfyorg/extension-api` — no dependency on
* `window.app` at module evaluation time.
*
* Hook firing order across multiple extensions on the same entity follows
* extension registration order with a lexicographic tie-break on `name`.
*
* @publicAPI
* @example
* ```ts
* import { defineNode } from '@comfyorg/extension-api'
*
* // Per AXIOMS.md §A1, nodes cannot enumerate widgets. To attach
* // per-widget behavior, register a widget type via `defineWidget` and
* // use the mount context's `ctx.widget` handle. This example reacts to
* // node-level execution only.
* export default defineNode({
* name: 'my-org.executed-logger',
* nodeTypes: ['KSampler'],
*
* nodeCreated(node) {
* node.on('executed', (e) => {
* console.log('node executed:', e.output)
* })
* }
* })
* ```
*/
export declare function defineNode(
options: NodeExtensionOptions
): NodeExtensionOptions
/**
* Register an extension for app-wide lifecycle and shell UI contributions.
*
* Use `defineNode` for node/widget interactions. Use this for
* `init`, `setup`, sidebar tabs, commands, and other app-level concerns.
*
* @publicAPI
* @example
* ```ts
* import { defineExtension } from '@comfyorg/extension-api'
*
* export default defineExtension({
* name: 'my-org.my-extension',
* setup() {
* console.log('Extension ready')
* }
* })
* ```
*/
export declare function defineExtension(
options: ExtensionOptions
): ExtensionOptions
/**
* Register a custom widget type. Called once at module load time to declare
* a new widget kind.
*
* @stability experimental
* @publicAPI
* @example
* ```ts
* import { defineWidget } from '@comfyorg/extension-api'
*
* export default defineWidget({
* name: 'my-org.color-picker',
* type: 'COLOR_PICKER'
* })
* ```
*/
export declare function defineWidget(
options: WidgetExtensionOptions
): WidgetExtensionOptions
/**
* ## Implicit-Context Lifecycle Hooks
*
* `onNodeMounted` and `onNodeRemoved` use Vue-style implicit context to
* associate callbacks with the current node's cleanup scope. This pattern
* provides automatic cleanup without manual unsubscribe bookkeeping.
*
* ### How it works
*
* 1. The runtime sets a global scope slot before calling `nodeCreated()`
* 2. Lifecycle hooks read this slot to register callbacks in the node's scope
* 3. When the node is removed, the scope auto-disposes all registered callbacks
*
* ### Why synchronous-only?
*
* These hooks **must** be called synchronously inside `nodeCreated` or
* `loadedGraphNode`. After an `await`, the call stack has unwound and the
* implicit scope context is gone — the same constraint as Vue's `onMounted`.
*
* ```ts
* // ✅ CORRECT — synchronous call
* nodeCreated(node) {
* onNodeMounted(() => console.log('mounted'))
* }
*
* // ❌ WRONG — after await, scope context is lost
* async nodeCreated(node) {
* await fetch('/api')
* onNodeMounted(() => {}) // Throws in dev, silent no-op in prod
* }
* ```
*
* ### Benefits
*
* - **Automatic cleanup**: no manual `unsubscribe()` calls needed
* - **Memory-safe**: callbacks are garbage-collected with the node
* - **Familiar pattern**: mirrors Vue Composition API (`onMounted`, `onUnmounted`)
*
* @see {@link onNodeMounted} — fires after node is fully mounted
* @see {@link onNodeRemoved} — fires before node cleanup (not on subgraph moves)
*/
/**
* ## Context-Scoped Bootstrap Hooks
*
* In addition to the node-level `onNodeMounted` / `onNodeRemoved` hooks above,
* the v2 API exposes Vue-idiomatic context-scoped hooks for
* `defineExtension.setup` / `defineSidebarTab.setup` / `defineBottomPanelTab.setup`
* bodies:
*
* - `onBeforeMount` — before the surrounding instance is mounted
* - `onMounted` — after the surrounding instance is mounted
* - `onUnmounted` — when the surrounding instance is unmounted
* - `onActivated` — when a sidebar tab / bottom panel is shown (tab/panel only)
* - `onDeactivated` — when a sidebar tab / bottom panel is hidden (tab/panel only)
*
* Like Vue's `onMounted`, these must be called synchronously inside the
* surrounding `setup()` body. Calling them after an `await` (or outside any
* setup context) throws in development and silently no-ops in production.
*
* See {@link onMounted} for full usage examples.
*
* @example
* ```ts
* import {
* defineSidebarTab,
* onMounted,
* onUnmounted,
* onActivated,
* onDeactivated
* } from '@comfyorg/extension-api'
*
* defineSidebarTab({
* id: 'my-tab',
* title: 'My Tab',
* type: 'vue',
* component: MyTab,
* setup() {
* onMounted(() => console.log('tab mounted'))
* onActivated(() => console.log('tab shown'))
* onDeactivated(() => console.log('tab hidden'))
* onUnmounted(() => console.log('tab unmounted'))
* }
* })
* ```
*/
export {
onBeforeMount,
onMounted,
onUnmounted,
onActivated,
onDeactivated
} from '@/services/extension-api-service'
export {
/**
* Register a callback to fire when the node is fully mounted to the graph.
*
* "Mounted" means: the reactive mount watcher has run, the node's scope is
* active, and any `setup()` return value has been captured. Safe to access
* DOM widgets, canvas elements, and other post-mount resources.
*
* **Must be called synchronously** inside `nodeCreated` or `loadedGraphNode`.
* Calling after an `await` throws in development and silently no-ops in
* production (see module docs for rationale).
*
* @stability experimental
* @example
* ```ts
* import { defineNode, onNodeMounted } from '@comfyorg/extension-api'
*
* export default defineNode({
* name: 'my-ext',
* nodeTypes: ['MyNode'],
*
* nodeCreated(node) {
* // Register mount callback synchronously
* onNodeMounted(() => {
* console.log('Node fully mounted, DOM ready')
* // Safe to query DOM widgets, measure sizes, etc.
* })
*
* // Can register multiple callbacks
* onNodeMounted(() => {
* node.setSize([300, 200]) // Resize after mount
* })
* }
* })
* ```
*/
onNodeMounted,
/**
* Register a callback to fire when the node is removed from the graph.
*
* Use for cleanup: close connections, abort fetches, release resources.
* Does NOT fire on subgraph promotion (which is a DOM move, not removal) —
* the node's entity ID is preserved across promotion.
*
* Replaces the v1 `nodeType.prototype.onRemoved` patching pattern.
*
* **Must be called synchronously** inside `nodeCreated` or `loadedGraphNode`.
* Calling after an `await` throws in development and silently no-ops in
* production (see module docs for rationale).
*
* @stability experimental
* @example
* ```ts
* import { defineNode, onNodeRemoved } from '@comfyorg/extension-api'
*
* export default defineNode({
* name: 'my-ext',
* nodeTypes: ['MyNode'],
*
* nodeCreated(node) {
* const controller = new AbortController()
*
* // Start a long-running fetch
* fetch('/api/stream', { signal: controller.signal })
* .then(res => processStream(res))
*
* // Clean up when node is deleted
* onNodeRemoved(() => {
* controller.abort()
* console.log('Cleanup complete')
* })
* }
* })
* ```
*/
onNodeRemoved
} from '@/services/extension-api-service'

408
src/extension-api/node.ts Normal file
View File

@@ -0,0 +1,408 @@
/**
* NodeHandle — the controlled surface for node access in v2 extensions.
*
* Reads query ECS World components directly. Writes dispatch commands
* (undo-able, serializable, validatable). Events are backed by Vue
* reactivity watching World component changes.
*
* @packageDocumentation
*/
import type { AsyncHandler, Handler, Unsubscribe } from './events'
import type { NodeEntityId } from '@/world/entityIds'
/**
* Branded entity ID for nodes. Prevents mixing node IDs with widget IDs
* at compile time. Re-exported from the world layer so the entire codebase
* shares a single brand. The underlying value is `string` in Phase A
* (e.g. `node:<graphUuid>:<localId>`).
*
* @internal Per D20 — extension authors use `node.id: string` and
* `node.equals(other)`. The branded type is reserved for internal package
* modules and is intentionally absent from the published barrel.
*/
export type { NodeEntityId }
/**
* A 2D point as `[x, y]`.
*
* **Immutable tuple.** Attempts to
* mutate via `node.getPosition()[0] = X` raise a TypeScript error. Use
* {@link NodeHandle.setPosition} to move the node.
*
* @example
* ```ts
* import type { Point } from '@comfyorg/extension-api'
*
* // Per-pixel mouse coordinate from a canvas event
* const cursor: Point = [event.canvasX, event.canvasY]
* ```
*/
export type Point = readonly [x: number, y: number]
/**
* A 2D size as `[width, height]`.
*
* **Immutable tuple.** Attempts to
* mutate via `node.getSize()[0] = X` raise a TypeScript error. Use
* {@link NodeHandle.setSize} to resize the node.
*
* @example
* ```ts
* import type { Size } from '@comfyorg/extension-api'
*
* const target: Size = [320, 240]
* ```
*/
export type Size = readonly [width: number, height: number]
// PHASE_A_EXCLUDED per AXIOMS.md A14: nodeMode has "egregious" use patterns.
// export type NodeMode = 'always' | 'never' | 'bypass' | 'once' | 'onTrigger'
// PHASE_A_EXCLUDED per AXIOMS.md A14: Slot/connection hooks deferred.
// export type SlotDirection = 'input' | 'output'
// export interface SlotInfo { ... }
// export type SlotEntityId = string & { readonly __brand: 'SlotEntityId' }
/**
* Payload for `node.on('executed', handler)`.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern.
*
* @example
* ```ts
* node.on('executed', (e) => {
* const text = e.output['text'] as string[]
* previewWidget.setValue(text.join('\n'))
* })
* ```
*/
export interface NodeExecutedEvent {
/** The backend execution output for this node. Shape varies by node type. */
readonly output: Record<string, unknown>
}
// PHASE_A_EXCLUDED per AXIOMS.md A14: Slot/connection hooks deferred.
// export interface NodeConnectedEvent { slot: SlotInfo; remote: SlotInfo }
// export interface NodeDisconnectedEvent { slot: SlotInfo }
// PHASE_A_EXCLUDED per AXIOMS.md A14: Spatial events deferred.
// export interface NodePositionChangedEvent { pos: Point }
// export interface NodeSizeChangedEvent { size: Size }
// PHASE_A_EXCLUDED per AXIOMS.md A14: nodeMode has "egregious" use patterns.
// export interface NodeModeChangedEvent { mode: NodeMode }
/**
* Payload for `node.on('beforeSerialize', handler)`.
*
* @deprecated Node-level serialization control will be removed in v1.0.
* Use widget-level `widget.on('beforeSerialize')` instead. Store extension
* state in widgets rather than arbitrary node fields.
*
* **Why widget-level is better:**
* - Widget values are visible at predictable locations in workflow JSON
* - Cleaner separation between framework and extension concerns
* - Widget serialization hooks support async operations
*
* See ADR-0010 for full migration guidance.
*
* @stability experimental
*/
export interface NodeBeforeSerializeEvent {
/** Which serialization path triggered this. */
readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The mutable serialized node object. Mutate in place to append fields.
* Type intentionally loose — the exact shape is `ISerialisedNode`.
*/
readonly data: Record<string, unknown>
/**
* Replace the serialized object by providing a transform function.
* `fn` receives the current `data` and should return the replacement.
* Calling this multiple times chains: each call's `fn` receives the
* previous call's output.
*/
replace(fn: (orig: Record<string, unknown>) => Record<string, unknown>): void
}
/**
* Controlled surface for node access. Reads query the ECS World; writes
* dispatch commands. Events are Vue-reactive watches on World components.
*
* @example
* ```ts
* import { defineNode } from '@comfyorg/extension-api'
*
* export default defineNode({
* name: 'my-size-enforcer',
* nodeTypes: ['MyCustomNode'],
*
* nodeCreated(node) {
* const [w, h] = node.getSize()
* node.setSize([Math.max(w, 300), Math.max(h, 200)])
*
* node.on('executed', (e) => {
* console.log('output:', e.output)
* })
* }
* })
* ```
*/
export interface NodeHandle {
/**
* Opaque identifier for this node. Stable for the lifetime of the node
* entity. Treat as a string token: do not parse, slice, or compare its
* internal structure. Use {@link NodeHandle.equals} to compare with
* another handle.
*
* @remarks
* The underlying value is a branded `NodeEntityId` at runtime
* but is narrowed to `string` on the public surface so authors never
* need to import a brand to type a local variable.
*/
readonly id: string
/**
* Returns `true` if `other` represents the same node entity as this one.
* Equivalent to `this.id === other.id` but the canonical comparator —
* prefer `equals` over manual string comparison so future changes to the
* identity scheme remain transparent.
*/
equals(other: NodeHandle): boolean
/**
* The LiteGraph node type string (e.g. `'KSampler'`).
* Read-only invariant: set at construction, never changes.
*
*/
readonly type: string
/**
* The ComfyUI backend class name (e.g. `'KSampler'`).
* Equal to `type` for most nodes; differs for reroute/virtual nodes.
* Read-only invariant.
*
*/
readonly comfyClass: string
// PHASE_A_EXCLUDED per AXIOMS.md A14: Deferred pending A13 coord-space stabilization.
// getPosition(): Point
// setPosition(pos: Point): void
// getSize(): Size
// setSize(size: Size): void
// PHASE_A_EXCLUDED per AXIOMS.md A14: Uncertain use case.
// getTitle(): string
// setTitle(title: string): void
// isSelected(): boolean
// PHASE_A_EXCLUDED per AXIOMS.md A14: nodeMode has "egregious" use patterns.
// getMode(): NodeMode
// setMode(mode: NodeMode): void
/**
* Returns a per-node-instance property by key.
*
* In v2, prefer routing persistent state through widget values or
* `beforeSerialize` events. `node.properties` is kept as a migration shim
* for v1 extensions that used it for per-instance widget config (e.g. min/max).
*
*/
getProperty<T = unknown>(key: string): T | undefined
/**
* Returns a copy of all per-node-instance properties.
*
*/
getProperties(): Record<string, unknown>
/**
* Sets a per-node-instance property. Dispatches a `SetNodeProperty` command.
*
* In v2, prefer `widget.setOption(key, value)` for widget-scoped per-instance
* config (it persists to the `widget_options` sidecar in the workflow JSON).
*
*/
setProperty(key: string, value: unknown): void
// COMMENTED OUT per AXIOMS.md A1 + A2:
// Nodes cannot reference or enumerate their widgets. Bilateral (node→widget)
// direction is closed; the widget→node direction (`widget.parentNode`)
// remains the sole channel. Extensions needing per-widget coordination
// register via `defineWidget({mount, ...})` and share state through Vue
// `provide()` / `inject()` or the World event bus. Restoration criteria
// in AXIOMS.md §A14.
//
// /**
// * Returns a `WidgetHandle` for the named widget, or `undefined` if no such
// * widget exists on this node.
// *
// * @example
// * ```ts
// * const steps = node.getWidget('steps')
// * if (steps) steps.setValue(20)
// * ```
// */
// getWidget(name: string): WidgetHandle | undefined
//
// /**
// * Returns all widgets on this node as `WidgetHandle` instances.
// *
// * **Immutable view.** The returned
// * array cannot be mutated (`push`, `splice`, `length =`, index assignment
// * all raise TS errors). Each `WidgetHandle` is also surface-frozen — use
// * the `WidgetHandle` setter methods (`setValue`, `setHidden`, etc.) to
// * mutate widget state.
// *
// * @example
// * ```ts
// * // ❌ TS-ERR — readonly array; v1 patterns no longer compile
// * node.getWidgets().push(newWidget)
// * node.getWidgets()[0] = newWidget
// *
// * // ✅ Iterate / read freely
// * for (const w of node.getWidgets()) console.log(w.name)
// * const labels = node.getWidgets().map((w) => w.label)
// * ```
// */
// getWidgets(): ReadonlyArray<Readonly<WidgetHandle>>
// REMOVED per AXIOMS.md A14: Widgets are defined in Python node schema,
// not created at frontend runtime. Node->widget mutation violates A1.
// addWidget(type, name, defaultValue, options?): WidgetHandle
// NOTE: `addDOMWidget(opts)` was removed per D-widget-converge / Axiom A12.
// Custom DOM widgets are now registered via `defineWidget({type, mount})`
// and instantiated through the same `addWidget(type, name, …)` call as
// every other widget. The runtime invokes the registered `mount(host, ctx)`
// hook against a per-widget host `<div>` it owns. See `WidgetMountFn` and
// `WidgetMountContext` in `./widget` for the lifecycle contract.
/**
* Returns all input slots on this node.
*
* **Immutable view.** The returned
* array and each slot are `Readonly` — `node.getInputs().push(...)`,
* `node.getInputs()[i] = X`, and `node.getInputs()[i].name = "x"` all raise
* TypeScript errors at compile time. Per-slot mutators (`setInputName`,
* `replaceInput`, bulk field setters) are tracked under
* *
* @example
* ```ts
* // ❌ TS-ERR — readonly array; v1 patterns no longer compile
* node.getInputs().push({ name: 'x', type: 'INT' })
* node.getInputs()[0].name = 'renamed'
*
* // ✅ Read / iterate freely
* const types = node.getInputs().map((s) => s.type)
* ```
*/
getInputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* Returns all output slots on this node.
*
* **Immutable view.** Same
* read-only semantics as {@link NodeHandle.getInputs}. Per-slot mutators
* tracked separately.
*/
getOutputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* @deprecated Use {@link NodeHandle.getInputs} instead. Renamed to align
* with the `getX()` accessor convention.
* Will be removed in v1.0.
*/
inputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* @deprecated Use {@link NodeHandle.getOutputs} instead. Renamed to align
* with the `getX()` accessor convention.
* Will be removed in v1.0.
*/
outputs(): ReadonlyArray<Readonly<SlotInfo>>
/**
* Subscribe to node removal (graph deletion, not subgraph promotion).
*
* Replaces the v1 `nodeType.prototype.onRemoved` patching pattern.
* Does NOT fire on subgraph promotion — the node's entity ID is preserved
* across promotion.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'removed', handler: Handler<void>): Unsubscribe
/**
* Subscribe to backend execution completion for this node.
*
* Replaces the v1 `nodeType.prototype.onExecuted` patching pattern (the
* most widely used anti-pattern per R4-P3; 5+ confirmed repos).
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'executed', handler: Handler<NodeExecutedEvent>): Unsubscribe
/**
* Subscribe to workflow hydration (node loaded from a saved workflow).
*
* Replaces the v1 `nodeType.prototype.onConfigure` / `loadedGraphNode`
* patterns. Fires after all widget values are restored from the workflow JSON.
*
* @returns A cleanup function to remove the listener.
*/
on(event: 'configured', handler: Handler<void>): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14: Slot/connection hooks deferred.
// on(event: 'connected', handler: Handler<NodeConnectedEvent>): Unsubscribe
// on(event: 'disconnected', handler: Handler<NodeDisconnectedEvent>): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14: Spatial events deferred pending A13.
// on(event: 'positionChanged', handler: Handler<NodePositionChangedEvent>): Unsubscribe
// on(event: 'sizeChanged', handler: Handler<NodeSizeChangedEvent>): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14: nodeMode has "egregious" use patterns.
// on(event: 'modeChanged', handler: Handler<NodeModeChangedEvent>): Unsubscribe
/**
* Subscribe to node serialization. Async-capable.
*
* @deprecated Node-level serialization control will be removed in v1.0.
* Use widget-level `widget.on('beforeSerialize')` instead — store extension
* state in widgets rather than arbitrary node fields. See ADR-0010.
*
* **Migration example:**
* ```ts
* // BEFORE (deprecated — node-level)
* node.on('beforeSerialize', (e) => {
* e.data['my_extension_state'] = computeState()
* })
*
* // AFTER (recommended — widget-level, schema-declared)
* // Declare `_my_state` in the Python node's INPUT_TYPES as a hidden
* // STRING input. Then attach the serialization transform inside
* // `defineWidget({mount})` — `ctx.widget` is the only legal handle:
* import { defineWidget } from '@comfyorg/extension-api'
*
* defineWidget({
* name: 'my-org.state-shim',
* type: 'STRING',
* mount(_host, ctx) {
* ctx.widget.on('beforeSerialize', (e) => {
* e.setSerializedValue(JSON.stringify(computeState()))
* })
* }
* })
* ```
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<NodeBeforeSerializeEvent>
): Unsubscribe
}

View File

@@ -0,0 +1,536 @@
/**
* Per-surface shell UI registration entry points
*.
*
* Each `defineX` function in this module is the v2 replacement for one slot
* of the v1 `app.registerExtension({ commands, keybindings, settings, … })`
* mega-call. Per the ACCEPTED PICK (option (ii) "separate entries" with an
* inline-imperative carve-out for `toast` / `notify`), each surface gets its
* own typed, per-import-testable, independently disposable entry point.
*
* ## Dispose contract
*
* Every `defineX` returns a `{ dispose(): void }` handle:
*
* - **Idempotent**: calling `dispose()` more than once is safe and is a no-op
* after the first call.
* - **Synchronous teardown**: the actual unregister-from-store happens
* synchronously inside `dispose()` (no async cleanup).
* - **Ordering on multi-surface extensions**: dispose handles are
* independent. If you hold handles A, B, C from three `defineX` calls and
* call `A.dispose()`, B and C are unaffected. Callers that need ordered
* teardown (e.g. drop the hotkey before its command) should sequence
* `dispose()` calls explicitly.
* - **Pre-mount dispose**: if `dispose()` is called before the runtime has
* started the extension system (and thus before the spec has been wired
* into the underlying store), the spec is removed from the pending queue
* and never reaches the store.
*
* ## Lazy mount + queue-then-flush pattern
*
* Each `defineX` is safe to call at module-evaluation time — i.e. before
* Pinia is initialized and before any store is reachable. The spec is
* pushed onto a per-surface pending queue; the runtime flushes the queue
* via {@link _flushShellRegistrations} once `startExtensionSystem()` runs
* during app bootstrap. After flush, subsequent `defineX` calls mount
* immediately.
*
* This mirrors the existing `defineExtension` / `defineNode` / `defineWidget`
* pattern in `@/services/extension-api-service` (they also push onto
* module-level arrays that bootstrap drains later).
*
* @packageDocumentation
*/
import type {
AboutBadgeExtension,
BottomPanelExtension,
CommandDefinition,
HotkeyExtension,
SettingDefinition,
SidebarTabExtension,
ToolbarButtonExtension
} from '@/types/extensionTypes'
/**
* Handle returned by every `defineX`. Call `dispose()` to remove the
* registration. Idempotent and synchronous — see module-level "Dispose
* contract" notes.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineSidebarTab, type DisposableHandle } from '@comfyorg/extension-api'
*
* const handle: DisposableHandle = defineSidebarTab({
* id: 'my-tab',
* title: 'My Tab',
* type: 'vue',
* component: MyTab
* })
*
* // Later: tear down
* handle.dispose()
* ```
*/
export interface DisposableHandle {
dispose(): void
}
// Internal mount-queue infrastructure
/**
* Registration side effect: registers something into a store and returns a
* cleanup. May be async (most registrations dynamic-import their store at
* runtime to defer Pinia coupling to bootstrap time).
*
* @internal
*/
type CleanupFn = () => void
type Mounter = () => CleanupFn | void | Promise<CleanupFn | void>
interface PendingEntry {
mount: Mounter
cleanup: CleanupFn | null
disposed: boolean
}
const pendingEntries: PendingEntry[] = []
let _systemStarted = false
/**
* Run a mounter and capture its cleanup (sync or async). Async failures are
* surfaced loudly in dev, swallowed in prod.
*
* @internal
*/
function runMounter(entry: PendingEntry): void {
try {
const result = entry.mount()
if (result && typeof (result as Promise<unknown>).then === 'function') {
void (result as Promise<CleanupFn | void>).then(
(cleanup) => {
if (entry.disposed) {
// Already disposed before async mount resolved — invoke the cleanup
// immediately to avoid a leak.
if (cleanup) {
try {
cleanup()
} catch {
/* swallow */
}
}
return
}
entry.cleanup = cleanup ?? null
},
(err) => {
if (import.meta.env.DEV) {
console.error('[extension-api] defineX mount failed:', err)
} else {
console.warn('[extension-api] defineX mount failed:', err)
}
}
)
} else {
entry.cleanup = (result as CleanupFn | void) ?? null
}
} catch (err) {
if (import.meta.env.DEV) {
console.error('[extension-api] defineX mount failed:', err)
} else {
console.warn('[extension-api] defineX mount failed:', err)
}
}
}
/**
* Push a mount-fn onto the pending queue, returning a disposable handle.
* If the system is already started, mount immediately.
*
* @internal
*/
function register(mount: Mounter): DisposableHandle {
const entry: PendingEntry = { mount, cleanup: null, disposed: false }
pendingEntries.push(entry)
if (_systemStarted) {
runMounter(entry)
}
return {
dispose() {
if (entry.disposed) return
entry.disposed = true
if (entry.cleanup) {
try {
entry.cleanup()
} catch (err) {
if (import.meta.env.DEV) {
console.error('[extension-api] defineX dispose failed:', err)
}
}
entry.cleanup = null
}
}
}
}
/**
* Flush all pending shell-UI registrations. Called by
* `startExtensionSystem()` once Pinia and the underlying stores are ready.
*
* Idempotent: subsequent calls re-flush only entries that have not yet
* been mounted (allowing `defineX` calls made after start to mount lazily
* via the `_systemStarted` flag in {@link register}).
*
* @internal
*/
export function _flushShellRegistrations(): void {
_systemStarted = true
for (const entry of pendingEntries) {
if (entry.disposed || entry.cleanup) continue
runMounter(entry)
}
}
/** @internal Test-only: drop all pending registrations and reset state. */
export function _clearShellRegistrationsForTesting(): void {
for (const entry of pendingEntries) {
if (entry.cleanup) {
try {
entry.cleanup()
} catch {
/* ignore */
}
}
}
pendingEntries.length = 0
_systemStarted = false
}
// Public defineX entry points
/**
* Register a sidebar tab. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to remove the tab.
*
* The tab spec is a thin POJO (see {@link SidebarTabExtension}); the runtime
* mounts it via the workspace sidebar-tab store at app bootstrap time. May
* be called at module-evaluation time; mount is deferred until the system
* starts.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineSidebarTab } from '@comfyorg/extension-api'
*
* const tab = defineSidebarTab({
* id: 'my-tab',
* title: 'My Tab',
* type: 'vue',
* component: MyTabComponent
* })
*
* // Later: remove the tab
* tab.dispose()
* ```
*/
export function defineSidebarTab(opts: SidebarTabExtension): DisposableHandle {
return register(async () => {
const { useSidebarTabStore } =
await import('@/stores/workspace/sidebarTabStore')
const store = useSidebarTabStore()
store.registerSidebarTab(opts)
return () => store.unregisterSidebarTab(opts.id)
})
}
/**
* Register a bottom-panel tab. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to remove the tab.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineBottomPanelTab } from '@comfyorg/extension-api'
*
* defineBottomPanelTab({
* id: 'my-logs',
* title: 'My Logs',
* type: 'vue',
* component: MyLogsComponent
* })
* ```
*/
export function defineBottomPanelTab(
opts: BottomPanelExtension
): DisposableHandle {
return register(async () => {
const { useBottomPanelStore } =
await import('@/stores/workspace/bottomPanelStore')
const store = useBottomPanelStore()
store.registerBottomPanelTab(opts)
// bottomPanelStore exposes no unregister API today; remove from the
// reactive list manually. Filed as follow-up when the store gains a
// first-class `unregisterBottomPanelTab(id)`.
return () => {
const idx = store.bottomPanelTabs.findIndex((t) => t.id === opts.id)
if (idx >= 0) store.bottomPanelTabs.splice(idx, 1)
}
})
}
/**
* Register a command. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to unregister the command.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineCommand } from '@comfyorg/extension-api'
*
* defineCommand({
* id: 'my-org.my-command',
* label: 'Do The Thing',
* function: () => { console.log('doing the thing') }
* })
* ```
*/
export function defineCommand(opts: CommandDefinition): DisposableHandle {
return register(async () => {
const { useCommandStore } = await import('@/stores/commandStore')
const store = useCommandStore()
store.registerCommand(opts)
// commandStore lacks an `unregisterCommand` API today; delete the entry
// from its internal map via best-effort. Follow-up to add a first-class
// unregister method.
return () => {
const cmds = store.commands as unknown as Array<{ id: string }>
const idx = cmds.findIndex((c) => c.id === opts.id)
if (idx >= 0) cmds.splice(idx, 1)
}
})
}
/**
* Register a hotkey binding. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to unbind.
*
* The hotkey targets a command id; register the command separately via
* {@link defineCommand}. The `keys` string is parsed via the standard
* key-combo grammar (`mod+k`, `ctrl+shift+f`, etc.).
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineCommand, defineHotkey } from '@comfyorg/extension-api'
*
* defineCommand({ id: 'my.cmd', function: () => {} })
* defineHotkey({ keys: 'mod+k', commandId: 'my.cmd' })
* ```
*/
export function defineHotkey(opts: HotkeyExtension): DisposableHandle {
return register(async () => {
const [{ useKeybindingStore }, { KeybindingImpl }] = await Promise.all([
import('@/platform/keybindings/keybindingStore'),
import('@/platform/keybindings/keybinding')
])
const store = useKeybindingStore()
const combo = parseKeyComboString(opts.keys)
const kb = new KeybindingImpl({
commandId: opts.commandId,
combo,
targetElementId: opts.targetElementId
})
store.addDefaultKeybinding(kb)
// keybindingStore has no first-class unbind for default keybindings;
// remove the combo from the reactive map directly.
return () => {
const target = (
store as unknown as {
defaultKeybindings: { value: Record<string, unknown> }
}
).defaultKeybindings
if (target?.value) {
const key = kb.combo.serialize()
const next = { ...target.value }
delete next[key]
target.value = next
}
}
})
}
/**
* Parse `'mod+shift+k'` → `{ key: 'k', ctrl: true (or meta on mac), shift: true }`.
* Matches the convention used by the keybinding store.
*
* @internal
*/
function parseKeyComboString(input: string): {
key: string
ctrl?: boolean
alt?: boolean
shift?: boolean
meta?: boolean
} {
const parts = input.split('+').map((p) => p.trim().toLowerCase())
const isMac =
typeof navigator !== 'undefined' &&
/mac|iphone|ipad|ipod/i.test(navigator.platform)
const result: {
key: string
ctrl?: boolean
alt?: boolean
shift?: boolean
meta?: boolean
} = { key: '' }
for (const part of parts) {
if (part === 'ctrl' || part === 'control') result.ctrl = true
else if (part === 'alt' || part === 'option') result.alt = true
else if (part === 'shift') result.shift = true
else if (part === 'meta' || part === 'cmd' || part === 'command')
result.meta = true
else if (part === 'mod') {
if (isMac) result.meta = true
else result.ctrl = true
} else {
result.key = part
}
}
return result
}
/**
* Register a setting. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to remove the setting from the settings menu.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineSetting } from '@comfyorg/extension-api'
*
* defineSetting({
* id: 'my.option' as never, // widen until Settings is augmented
* name: 'My Option',
* type: 'boolean',
* defaultValue: false
* })
* ```
*/
export function defineSetting<TValue = unknown>(
opts: SettingDefinition<TValue>
): DisposableHandle {
// The underlying SettingParams<TValue> is invariant in TValue inside the
// store; widen to `unknown` at the boundary so the typed public arg accepts
// any TValue without forcing every internal store to be generic.
const widened = opts as unknown as SettingDefinition<unknown>
return register(async () => {
const { useSettingStore } = await import('@/platform/settings/settingStore')
const store = useSettingStore()
store.addSetting(widened)
// settingStore has no removeSetting API today; clear via direct map
// mutation. Follow-up to add a first-class remove.
return () => {
const settingsById = (
store as unknown as { settingsById?: Record<string, unknown> }
).settingsById
if (settingsById && opts.id in settingsById) {
delete settingsById[opts.id]
}
}
})
}
/**
* Register an About-page badge. Returns a {@link DisposableHandle} — call
* `handle.dispose()` to remove the badge.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineAboutBadge } from '@comfyorg/extension-api'
*
* defineAboutBadge({
* label: 'GitHub',
* url: 'https://github.com/me/my-ext',
* icon: 'pi-github'
* })
* ```
*/
export function defineAboutBadge(opts: AboutBadgeExtension): DisposableHandle {
// The aboutPanelStore today computes its badge list reactively from
// `extensionStore.extensions.flatMap(e => e.aboutPageBadges ?? [])`. There
// is no direct register API yet. We push into a module-level array that
// the aboutPanelStore will consume after a follow-up wires it in (P5.E).
// Until then, the call still registers a dispose-aware entry so author
// code is forward-compatible.
const entry = { ...opts }
_aboutBadgeRegistry.push(entry)
return register(() => {
return () => {
const idx = _aboutBadgeRegistry.indexOf(entry)
if (idx >= 0) _aboutBadgeRegistry.splice(idx, 1)
}
})
}
/**
* Internal registry for badges registered via {@link defineAboutBadge}.
* Consumed by the about-panel store wiring (P5.E follow-up).
*
* @internal
*/
export const _aboutBadgeRegistry: AboutBadgeExtension[] = []
/**
* Register a toolbar button (action-bar button). Returns a
* {@link DisposableHandle} — call `handle.dispose()` to remove the button.
*
* **Net-new surface**: no v1 registration path existed. Authors using this
* are first-movers; the API may evolve before stabilizing.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineToolbarButton } from '@comfyorg/extension-api'
*
* defineToolbarButton({
* id: 'my.help',
* icon: 'pi-question-circle',
* tooltip: 'Get help',
* onClick: () => openHelp()
* })
* ```
*/
export function defineToolbarButton(
opts: ToolbarButtonExtension
): DisposableHandle {
// The action-bar today renders from `extensionStore.extensions.flatMap(e =>
// e.actionBarButtons ?? [])`. As with defineAboutBadge, the v2 path uses a
// module-level registry that the action-bar component will consume after
// a follow-up wires it in (P5.E).
const entry = { ...opts }
_toolbarButtonRegistry.push(entry)
return register(() => {
return () => {
const idx = _toolbarButtonRegistry.indexOf(entry)
if (idx >= 0) _toolbarButtonRegistry.splice(idx, 1)
}
})
}
/**
* Internal registry for buttons registered via {@link defineToolbarButton}.
* Consumed by the action-bar component wiring (P5.E follow-up).
*
* @internal
*/
export const _toolbarButtonRegistry: ToolbarButtonExtension[] = []

View File

@@ -0,0 +1,66 @@
/**
* Shell UI extension types — sidebar tabs, bottom panels, commands, hotkeys,
* settings, about badges, toolbar buttons, toasts.
*
* Re-exported from `src/types/extensionTypes.ts` with no shape changes. Each
* registerable shell UI surface has its own `defineX` entry point in
* `@/extension-api/registrations`; the arg types for those entries are
* re-exported here. Toast + notification surfaces remain inline-imperative
* (see `@/extension-api/imperatives`).
*
* @packageDocumentation
*/
// VueExtension and CustomExtension are intentionally NOT re-exported — they
// are discriminated-union ingredients of SidebarTabExtension /
// BottomPanelExtension, not author-facing entry points. Internal callers
// (ExtensionSlot.vue) import them directly from '@/types/extensionTypes'.
export type {
// Pre-existing types (unchanged shape)
/**
* Options bag for {@link defineSidebarTab}.
* @see {@link defineSidebarTab} for a usage example.
*/
SidebarTabExtension,
/**
* Options bag for {@link defineBottomPanelTab}.
* @see {@link defineBottomPanelTab} for a usage example.
*/
BottomPanelExtension,
/**
* Options bag for {@link toast.show} / {@link toast.remove}.
* @see {@link toast} for a usage example.
*/
ToastMessageOptions,
/**
* Manager interface backing the {@link toast} surface.
* @see {@link toast} for a usage example.
*/
ToastManager,
// Net-new shell-UI arg types
/**
* Options bag for {@link defineCommand}.
* @see {@link defineCommand} for a usage example.
*/
CommandDefinition,
/**
* Options bag for {@link defineHotkey}.
* @see {@link defineHotkey} for a usage example.
*/
HotkeyExtension,
/**
* Options bag for {@link defineAboutBadge}.
* @see {@link defineAboutBadge} for a usage example.
*/
AboutBadgeExtension,
/**
* Options bag for {@link defineSetting}.
* @see {@link defineSetting} for a usage example.
*/
SettingDefinition,
/**
* Options bag for {@link defineToolbarButton}.
* @see {@link defineToolbarButton} for a usage example.
*/
ToolbarButtonExtension
} from '@/types/extensionTypes'

204
src/extension-api/types.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* Extension option interfaces — the type contracts for `defineNode`,
* `defineExtension`, and `defineWidget`.
*
* Lives in its own module so the runtime service (`@/services/extension-api-service`)
* and the public lifecycle barrel (`@/extension-api/lifecycle`) can both depend on
* these types without forming a circular import (the service implements the
* `defineXxx` functions and the `onNodeMounted` / `onNodeRemoved` hooks that the
* lifecycle module re-exports).
*
* @packageDocumentation
*/
import type { NodeHandle } from './node'
import type { WidgetMountFn } from './widget'
/**
* Options for `defineNode`. Describes an extension that reacts to
* node lifecycle events.
*
* @example
* ```ts
* import { defineNode } from '@comfyorg/extension-api'
*
* export default defineNode({
* name: 'my-org.my-extension',
* nodeTypes: ['KSampler'],
*
* nodeCreated(node) {
* node.on('executed', (e) => console.log('done', e.output))
* }
* })
* ```
*/
export interface NodeExtensionOptions {
/**
* Globally unique extension name. Used for scope registry keying, hook
* ordering (lexicographic tie-break), and debug messages.
*
* Convention: `'org.extension-name'` or `'Comfy.ExtensionName'`.
*/
name: string
/**
* Filter to specific `comfyClass` names. When omitted, the extension
* receives `nodeCreated` / `loadedGraphNode` for every node type.
*
* Replaces the v1 `beforeRegisterNodeDef` filtering pattern.
*
* @example
* ```ts
* nodeTypes: ['KSampler', 'KSamplerAdvanced']
* ```
*/
nodeTypes?: string[]
/**
* Called once per node instance when the node is first created (typed in,
* pasted from clipboard, duplicated, or loaded without an existing workflow).
*
* - Runs inside a Vue `EffectScope`. All `watch` / `computed` / `onNodeMounted`
* calls made here are captured and disposed automatically on node removal.
* - Must be synchronous. Kick off async work inside the body; use
* `loading: ref(true)` for async-dependent state.
* - Called only once per entity ID lifetime. Copy/paste creates a fresh entity
* and fires `nodeCreated` again on the new entity (reset-to-fresh).
*/
nodeCreated?(node: NodeHandle): void
/**
* Called once per node instance when the node is restored from a saved
* workflow. Widget values are already populated when this fires.
*
* Same rules as `nodeCreated`. Exactly one of `nodeCreated` or
* `loadedGraphNode` fires per node entity, never both.
*
* Replaces the v1 `loadedGraphNode` hook and `nodeType.prototype.onConfigure`
* patching.
*/
loadedGraphNode?(node: NodeHandle): void
}
/**
* Options for the global `defineExtension` entry point. Covers extension-wide
* lifecycle and shell UI contributions.
*
* @example
* ```ts
* import { defineExtension, onMounted } from '@comfyorg/extension-api'
*
* export default defineExtension({
* name: 'my-org.my-extension',
* setup() {
* onMounted(() => {
* // App is ready; register commands, sidebar tabs, etc.
* })
* }
* })
* ```
*/
export interface ExtensionOptions {
/**
* Globally unique extension name. Matches the format of
* `NodeExtensionOptions.name`.
*/
name: string
/**
* Runs once during app initialization (after the app is mounted but before
* the first workflow is loaded). Equivalent to the v1 `ComfyExtension.init`.
*
* @deprecated move the
* `init` body into `setup()`. The body of `setup()` runs at the same point
* `init` used to run (early lifecycle); use `onMounted(() => ...)` inside
* `setup()` for what `init` did via late-lifecycle assumptions. A codemod
* ships in `@comfyorg/extension-api` to perform the rewrite mechanically.
* The v1 hook is retained for back-compat during the deprecation window.
*/
init?(): void | Promise<void>
/**
* Runs once after the app and all core extensions are initialized. Equivalent
* to the v1 `ComfyExtension.setup`. Safe to call shell UI registration APIs
* (`ExtensionManager`, `CommandManager`) here.
*
* @deprecated the
* `setup` property name is retained, but the v1 semantic "fires after all
* core extensions ready" now lives in `onMounted(() => ...)` *inside* the
* `setup()` body. The `setup()` body itself now runs at the earlier
* registration-equivalent point (where v1 `init` used to run). Use
* `onBeforeMount` / `onMounted` / `onUnmounted` / `onActivated` /
* `onDeactivated` for fine-grained lifecycle hooks.
*
* Migration:
* ```ts
* // v1
* setup() { api.addEventListener('execution_start', fn) }
* // v2
* setup() { onMounted(() => execution.on('start', fn)) }
* ```
*
* A codemod ships in `@comfyorg/extension-api`.
*/
setup?(): void | Promise<void>
}
/**
* Options for `defineWidget`. Registers a custom widget type that renders
* through the mount-lifecycle seam (Axiom A12 / D-widget-converge).
*
* Once registered, the widget type can be referenced from Python
* `INPUT_TYPES` schema declarations. The runtime allocates a per-widget
* host `<div>` and invokes the registered `mount(host, ctx)` hook against
* it. The widget's mount body captures the host (and any DOM it
* constructs) via closure — there is no `widget.element` accessor on the
* handle.
*
* Runtime widget addition (`node.addWidget(...)`) is forbidden per
* AXIOMS.md A15 / `decisions/D-ban-runtime-addwidget.md` — widgets are
* schema-declared, never created at runtime by extensions.
*
* `mount` is optional: omit it for value-only widgets (numeric, combo, etc.)
* that render through the native widget renderer with no custom DOM.
*
* @stability experimental
* @example
* ```ts
* import { defineWidget } from '@comfyorg/extension-api'
*
* export default defineWidget({
* name: 'my-org.color-picker',
* type: 'COLOR_PICKER',
*
* mount(host, ctx) {
* const input = document.createElement('input')
* input.type = 'color'
* input.value = String(ctx.widget.getValue() ?? '#000000')
* input.addEventListener('input', () => ctx.widget.setValue(input.value))
* host.appendChild(input)
* // Optional cleanup — fires once on widget destruction.
* return () => input.remove()
* }
* })
* ```
*/
export interface WidgetExtensionOptions {
/** Globally unique extension name. */
name: string
/** Widget type string this extension provides (e.g. `'COLOR_PICKER'`). */
type: string
/**
* Mount lifecycle hook — the **sole** DOM seam per Axiom A12. Called once
* per widget instance when the widget is first attached to its node host
* in the DOM. May return a `WidgetCleanup` function that fires on widget
* destruction (host remount does NOT fire cleanup; see
* `WidgetMountContext.onBeforeRemount` / `onAfterRemount`).
*
* Omit entirely for value-only widgets that need no custom DOM.
*
* @stability experimental
*/
mount?: WidgetMountFn
}

658
src/extension-api/widget.ts Normal file
View File

@@ -0,0 +1,658 @@
/**
* WidgetHandle — the controlled surface for widget access in v2 extensions.
*
* All state reads and writes go through this interface. Internal ECS
* components and World references are never exposed.
*
* @packageDocumentation
*/
import type { AsyncHandler, Handler, Unsubscribe } from './events'
import type { NodeHandle } from './node'
import type { WidgetEntityId } from '@/world/entityIds'
/**
* Branded entity ID for widgets. Prevents mixing widget IDs with node IDs
* at compile time. Re-exported from the world layer so the entire codebase
* shares a single brand. The underlying value is `string` in Phase A.
*
* @internal Per D20 — extension authors use `widget.id: string` and
* `widget.equals(other)`. The branded type is reserved for internal package
* modules and is intentionally absent from the published barrel.
*/
export type { WidgetEntityId }
/**
* The union of all legal widget scalar values. Complex widgets (DOM, canvas)
* may return their own serializable shapes.
*
* @example
* ```ts
* import type { WidgetValue } from '@comfyorg/extension-api'
*
* // `WidgetValue` is `string | number | boolean | null` — the four
* // primitive-widget value shapes.
* const val: WidgetValue = 42
* ```
*/
export type WidgetValue = string | number | boolean | null
/**
* Payload for `widget.on('valueChange', handler)`.
*
* Replaces the v1 `widget.callback` pattern.
*
* @typeParam T - The widget's value type.
* @example
* ```ts
* widget.on('valueChange', (e) => {
* console.log('changed from', e.oldValue, 'to', e.newValue)
* })
* ```
*/
export interface WidgetValueChangeEvent<T = WidgetValue> {
/** Value before the change. */
readonly oldValue: T
/** Value after the change. */
readonly newValue: T
}
/**
* Payload for `widget.on('optionChange', handler)`.
*
* Fires when a type-specific option is mutated via `setOption(key, value)`.
* The exact set of observable option keys is type-dependent (e.g. `min`,
* `max`, `step` for numeric widgets; `multiline` for strings).
*
* This event covers the options-bag tier (type-specific, not every-widget).
*
* @stability experimental
* @example
* ```ts
* widget.on('optionChange', (e) => {
* if (e.key === 'min') clampValue(e.newValue as number)
* })
* ```
*/
export interface WidgetOptionChangeEvent {
/** The option key that changed (e.g. `'min'`, `'max'`, `'multiline'`). */
readonly key: string
/** Value before the change. */
readonly oldValue: unknown
/** Value after the change. */
readonly newValue: unknown
}
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `WidgetPropertyChangeEvent` is vacuous after `'serialize'` was removed from
// the property union (A16: authors cannot disable serialization). The other
// historical members ('hidden', 'disabled') were already A14-deferred.
// Restoration requires a new first-class property to surface that satisfies
// A1A16 and an axiom amendment.
//
// export interface WidgetPropertyChangeEvent {
// readonly property: 'serialize'
// readonly oldValue: boolean
// readonly newValue: boolean
// }
/**
* Payload for `widget.on('beforeSerialize', handler)`.
*
* This is the **only async-allowed event** in the API and, per AXIOMS.md
* §A16 (Unified Serialization Target), the **sole** extension-author
* interface to serialization. Replaces every v1 serialization hook:
* `widget.serializeValue = fn`, `widget.options.serialize = false`,
* `nodeType.prototype.serialize`.
*
* The hook fires **once per serialization**. The framework writes the
* resulting payload to every transport (workflow JSON `widgets_values[i]`,
* API prompt, clone target, subgraph promotion). Extensions do not see and
* cannot branch on the transport — that is a framework concern (A16).
*
* Call `event.setSerializedValue(v)` to override what is written. Do not
* call it to pass through the widget's current `getValue()` unchanged.
*
* Per A16 there is no `skip()` and no `context` discriminator. Per A15
* (Widget Declarativity) there is no way to exclude a widget from
* serialization — if a widget should not contribute to the payload, it
* should not be a widget (use a boxed widget, a non-widget UI primitive,
* or a schema input).
*
* @typeParam T - The widget's value type.
* @example
* ```ts
* // Dynamic prompts: replace value at serialize time
* widget.on('beforeSerialize', (e) => {
* e.setSerializedValue(processDynamicPrompt(widget.getValue()))
* })
*
* // Async: webcam capture — materialize frame before serialization
* widget.on('beforeSerialize', async (e) => {
* const frame = await captureFrame()
* e.setSerializedValue(frame)
* })
* ```
*/
export interface WidgetBeforeSerializeEvent<T = WidgetValue> {
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// The 4-way transport discriminator inverted the direction of knowledge
// flow — framework owns transport, extensions own value. Workflow JSON
// and API prompt converge to a single serialized payload; clone and
// subgraph-promote are framework concerns. Restoration requires an
// axiom amendment to A16.
//
// readonly context: 'workflow' | 'prompt' | 'clone' | 'subgraph-promote'
/**
* The widget's current value at the time of serialization (before any override).
* Equivalent to calling `widget.getValue()`.
*/
readonly value: T
/**
* Override the serialized value. The provided value is written to every
* transport (workflow JSON `widgets_values[i]`, API prompt, clone target,
* subgraph promotion). Calling this multiple times keeps the last call's
* value.
*
* @param v - The value to serialize. Must be JSON-serializable.
*/
setSerializedValue(v: unknown): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `skip()` IS a per-call disable — authors cannot disable serialization
// (A16). If a widget should not contribute to the payload, it should not
// be a widget (A15). Restoration requires axiom amendments to A15 + A16.
//
// skip(): void
}
/**
* Payload for `widget.on('beforeQueue', handler)`.
*
* Fires when the user triggers a prompt queue (before `graphToPrompt` runs).
* Call `event.reject(message)` to cancel the queue attempt with a user-visible
* error. Do not call `reject` to allow the queue to proceed.
*
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation (e.g. required field empty, value out of range).
* For cross-node/graph-wide rejection, see the app-level `beforePrompt` event
* (I-UWF.4 — not yet in the API).
*
* @stability experimental
* @example
* ```ts
* // Reject if a required field is empty
* widget.on('beforeQueue', (e) => {
* if (!widget.getValue()) {
* e.reject('Prompt text is required before queueing.')
* }
* })
*
* // Reject with a dynamic message
* widget.on('beforeQueue', (e) => {
* const val = widget.getValue<number>()
* const min = widget.getOption<number>('min') ?? 0
* if (val < min) {
* e.reject(`Value ${val} is below the minimum of ${min}.`)
* }
* })
* ```
*/
export interface WidgetBeforeQueueEvent {
/**
* Reject the queue attempt, showing `message` to the user.
* Once any handler calls `reject`, the queue is cancelled — subsequent
* handlers still run but their `reject` calls are no-ops.
*
* @param message - Human-readable reason shown in the UI toast.
*/
reject(message: string): void
}
/**
* Controlled surface for widget access. Backed by ECS `WidgetValue` and
* `WidgetIdentity` components in the World. Reads query components directly;
* writes dispatch commands (undo-able, serializable, validatable).
*
* All views (node, properties panel, promoted copy) share the same backing
* `WidgetEntityId`, so mutations from any source trigger `valueChange`.
*
* @typeParam T - The type of `getValue()` / `setValue()`. Defaults to `WidgetValue`.
* @example
* ```ts
* import { defineWidget } from '@comfyorg/extension-api'
*
* // Per AXIOMS.md A1, nodes cannot
* // enumerate or reference widgets — `node.getWidget(name)` was removed.
* // To react to a specific widget's lifecycle and value changes, register
* // a widget type and use the `mount` context's `ctx.widget` handle:
*
* export default defineWidget({
* name: 'my-extension',
* type: 'INT',
* mount(_host, { widget }) {
* widget.on('valueChange', (e) => console.log(widget.name, '=', e.newValue))
* widget.setOption('min', 1)
* widget.setOption('max', 150)
* }
* })
* ```
*/
export interface WidgetHandle<T = WidgetValue> {
/**
* Opaque identifier for this widget. Stable for the lifetime of the
* widget entity. Treat as a string token: do not parse, slice, or compare
* its internal structure. Use {@link WidgetHandle.equals} to compare with
* another handle.
*
* @remarks
* The underlying value is a branded `WidgetEntityId` at runtime
* but is narrowed to `string` on the public surface so authors never need
* to import a brand to type a local variable.
*/
readonly id: string
/**
* Returns `true` if `other` represents the same widget entity as this
* one. Equivalent to `this.id === other.id` but the canonical comparator
* — prefer `equals` over manual string comparison so future changes to
* the identity scheme remain transparent.
*/
equals(other: WidgetHandle): boolean
/**
* The widget's name as registered in the node's `INPUT_TYPES` schema.
* Stable for the lifetime of the node; never changes after creation.
*
*/
readonly name: string
/**
* The widget's type string (e.g. `'INT'`, `'STRING'`, `'COMBO'`,
* `'MARKDOWN'`). Read-only invariant set at creation.
*
*/
readonly widgetType: string
/**
* Returns the widget's current user-edited value.
*
* @typeParam T - Narrows the return type when you know the widget type.
* @example
* ```ts
* // Inside `defineWidget({mount})` — `ctx.widget` is the only legal
* // path to a `WidgetHandle` (nodes cannot enumerate widgets per A1).
* const value = widget.getValue<number>()
* ```
*/
getValue(): T
/**
* Sets the widget's value. Dispatches a `SetWidgetValue` command (undo-able).
* Triggers `valueChange` handlers on all views.
*
*/
setValue(value: T): void
// PHASE_A_EXCLUDED per AXIOMS.md A14: Deferred pending serialization convergence.
// isHidden(): boolean
// setHidden(hidden: boolean): void
// PHASE_A_EXCLUDED per AXIOMS.md A14: Deferred pending serialization convergence.
// isDisabled(): boolean
// setDisabled(disabled: boolean): void
/**
* The widget's display label shown to the user. Defaults to the widget name.
* Read-only invariant (set at creation, never changes after).
*
* The label is set by the Python node's `INPUT_TYPES` schema (e.g. via
* the `label` key on the input options dict).
*/
readonly label: string
/**
* Updates the reserved height for this widget and triggers a node relayout.
*
* Meaningful for widgets registered via {@link defineWidget} with a
* {@link WidgetMountFn} `mount()` body — the reserved height bounds the
* runtime-owned host `<div>` that the mount body renders into. For widgets
* that render through the native widget renderer (no `mount`), this is a
* no-op.
*
* Replaces the v1 pattern of re-assigning `node.computeSize` to return a new
* height whenever the embedded element resizes.
*
* @param px - New reserved height in pixels.
* @stability experimental
*/
setHeight(px: number): void
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// Authors cannot disable serialization at the widget level (A16). If a
// widget should not contribute to the serialized payload, it should not
// be a widget (A15) — use a boxed/composed widget (BBOX-style), a
// non-widget UI primitive, or a schema input. The sole serialization
// interface is `widget.on('beforeSerialize', handler)`. Restoration
// requires axiom amendments to A15 + A16 + a validated ecosystem use
// case that no boxed/composed pattern can serve.
//
// isSerializeEnabled(): boolean
// setSerializeEnabled(enabled: boolean): void
/**
* Read-only snapshot of the full options bag for this widget.
*
* **Immutable.** The returned
* object is `Readonly<WidgetOptions>` — `widget.options.min = 0`,
* `widget.options = {...}`, and `widget.options.values = [...]` all raise
* TypeScript errors at compile time. To mutate, use
* {@link WidgetHandle.setOption} per-key.
*
* Note: this is an accessor pair on the v2 surface. Reading is free; the
* setter intentionally does not exist on the public type. Per AXIOMS.md
* §A16, `serialize` is no longer a writable option (and no longer a key
* on this bag) — there is no widget-level serialization disable.
* `widget.options.values = [...]` (combo refresh) migrates to a future
* `setValues` mutator.
*
* @example
* ```ts
* // ❌ TS-ERR — every option write raises a compile-time error
* widget.options.min = 0
* widget.options = { min: 0, max: 100 }
*
* // ✅ Read freely
* const min = widget.options.min ?? 0
*
* // ✅ Mutate via typed setters
* widget.setOption('min', 0)
* ```
*/
readonly options: Readonly<WidgetOptions>
/**
* Returns the per-instance override for `key`, or the class-default value
* from `INPUT_TYPES` if no override has been set, or `undefined` if the key
* is unknown for this widget type.
*
* Type-specific option names: `min`, `max`, `step` (INT/FLOAT); `multiline`,
* `dynamicPrompts` (STRING); `image_folder`, `upload_to` (upload widgets).
*
* @example
* ```ts
* const min = widget.getOption<number>('min') ?? 0
* ```
*/
getOption<K = unknown>(key: string): K | undefined
/**
* Set a per-instance option override. Persisted as a `widget_options` sidecar
* in the workflow JSON (additive, backward-compatible). Does not change the
* backend prompt schema unless the extension explicitly opts in via
* `beforeSerialize`.
*
* @example
* ```ts
* // Primitive Int/Float per-instance config (replaces node.properties anti-pattern)
* widget.setOption('min', 0)
* widget.setOption('max', 100)
* widget.setOption('step', 1)
* ```
*/
setOption(key: string, value: unknown): void
/**
* The widget's current `serializeValue` function (or `undefined` if none is
* registered).
*
* **Accessor-only.** The setter
* intentionally does not exist on the public type — assignment
* (`widget.serializeValue = fn`) raises a TypeScript error. The v2
* migration target is the {@link WidgetHandle.on | `on('beforeSerialize', fn)`}
* event, which is typed, async-capable, and composable across
* multiple extensions on the same widget.
*
* @deprecated v1 callers reading `widget.serializeValue` to invoke the
* function directly should subscribe to `'beforeSerialize'` instead. This
* read-only accessor exists for debugging / introspection only and may be
* removed once the v1 surface is fully retired.
*
* @example
* ```ts
* // ❌ TS-ERR — direct assignment no longer compiles
* widget.serializeValue = () => 'static value'
*
* // ✅ Subscribe to the typed event (D5). Per A16 the hook fires once
* // and the framework writes the resulting payload to every transport;
* // there is no transport discriminator.
* widget.on('beforeSerialize', (e) => {
* e.setSerializedValue('static value')
* })
* ```
*
* @stability experimental
*/
readonly serializeValue: ((...args: unknown[]) => unknown) | undefined
/**
* Subscribe to the widget's value changes.
*
* Replaces the v1 `widget.callback` pattern.
* Fires synchronously after the value is committed.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'valueChange',
handler: Handler<WidgetValueChangeEvent<T>>
): Unsubscribe
/**
* Subscribe to type-specific option mutations (`setOption(key, value)`).
*
* Fires for options-bag changes (e.g. `min`, `max`, `step`, `multiline`).
* Does NOT fire for value changes or first-class field changes.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'optionChange',
handler: Handler<WidgetOptionChangeEvent>
): Unsubscribe
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `WidgetPropertyChangeEvent` is vacuous — the only property the event
// ever surfaced was `'serialize'`, which is gone per A16. `setHidden` /
// `setDisabled` were already A14-deferred. Restoration requires a new
// first-class property to surface that satisfies A1A16.
//
// on(
// event: 'propertyChange',
// handler: Handler<WidgetPropertyChangeEvent>
// ): Unsubscribe
/**
* Subscribe to widget serialization. The only async-allowed event.
*
* Per AXIOMS.md §A16 this is the **sole** extension-author interface
* to serialization. The hook fires once per serialization and the
* framework writes the resulting payload to every transport (workflow
* JSON `widgets_values`, API prompt, clone target, subgraph promotion).
* Replaces the v1 `widget.serializeValue = fn` /
* `widget.options.serialize` patterns.
*
* The handler may be sync or async; async handlers are awaited before
* the serialization payload is sent.
*
* @returns A cleanup function to remove the listener.
*/
on(
event: 'beforeSerialize',
handler: AsyncHandler<WidgetBeforeSerializeEvent<T>>
): Unsubscribe
/**
* Subscribe to pre-queue validation. Fires before `graphToPrompt` runs.
*
* Call `event.reject(message)` to cancel the queue with a user-visible error.
* Replaces the v1 `app.queuePrompt` monkey-patching pattern (S6.A4/S6.A5)
* for per-widget validation use cases.
*
* Handlers are sync-only — use for validation logic only, not I/O.
*
* @returns A cleanup function to remove the listener.
* @stability experimental
*/
on(
event: 'beforeQueue',
handler: Handler<WidgetBeforeQueueEvent>
): Unsubscribe
}
/**
* Cleanup function returned from a widget's `mount()`. Fires exactly once,
* when the widget entity is destroyed. **Does NOT fire on host remount**
* (graph↔app mode, subgraph promotion, `<KeepAlive>` shuffle) — use
* {@link WidgetMountContext.onBeforeRemount} / {@link WidgetMountContext.onAfterRemount}
* for those.
*
* @stability experimental
* @example
* ```ts
* import { defineWidget, type WidgetCleanup } from '@comfyorg/extension-api'
*
* defineWidget({
* name: 'my-ext',
* type: 'STRING',
* mount(host): WidgetCleanup {
* const input = document.createElement('input')
* host.appendChild(input)
* return () => input.remove()
* }
* })
* ```
*/
export type WidgetCleanup = () => void
/**
* Context passed to a widget's `mount()` function.
*
* Per **Axiom A12** (Mount-Lifecycle as the Sole DOM Seam), this is the only
* surface through which DOM enters a widget. Authors capture the host element
* and any constructed DOM via closure inside `mount()` — there is no
* `widget.element` / `widget.inputEl` accessor on the handle.
*
* @stability experimental
*/
export interface WidgetMountContext {
/** The widget being mounted. Use for `getValue` / `setValue` / `on(...)`. */
readonly widget: WidgetHandle
/** The node hosting this widget. */
readonly node: NodeHandle
/**
* Register a callback that fires when the widget entity is destroyed.
* Equivalent to returning a cleanup function from `mount()`; provided as
* a hook for composition (e.g. inside helpers that own their own
* sub-resources).
*/
onUnmount(fn: () => void): void
/**
* Register a callback that fires immediately **before** the widget's host
* `<div>` is moved to a new location (graph↔app mode, subgraph promotion,
* Vue `<KeepAlive>` shuffle). Use to detach observers, pause animations,
* or capture scroll position before the move.
*
* The widget's mount body is NOT re-invoked across a remount; only
* `onBeforeRemount` then `onAfterRemount` fire.
*/
onBeforeRemount(fn: () => void): void
/**
* Register a callback that fires immediately **after** the widget's host
* `<div>` has been moved to a new location. Receives the new host element
* so authors can re-attach observers, restore scroll position, etc.
*/
onAfterRemount(fn: (newHost: HTMLElement) => void): void
}
/**
* Mount function for a widget. Called once when the widget is first attached
* to a node host in the DOM. Returns an optional cleanup function that fires
* on widget destruction.
*
* @param host - A runtime-owned empty `<div>` for the widget to mount into.
* The widget MAY append children, set inline styles, attach event listeners,
* etc. It MUST NOT replace or remove the host itself.
* @param ctx - Mount context with the widget/node handles and remount hooks.
* @returns Optional cleanup function called on widget destruction. Host
* remount fires `ctx.onBeforeRemount` / `ctx.onAfterRemount` instead.
*
* @stability experimental
* @example
* ```ts
* import type { WidgetMountFn } from '@comfyorg/extension-api'
*
* const mount: WidgetMountFn = (host, ctx) => {
* const el = document.createElement('div')
* el.textContent = String(ctx.widget.getValue() ?? '')
* host.appendChild(el)
* return () => el.remove()
* }
* ```
*/
export type WidgetMountFn = (
host: HTMLElement,
ctx: WidgetMountContext
) => void | WidgetCleanup
/**
* Options surfaced on each widget instance. Type-specific keys (e.g. `min`,
* `max`, `step` for numeric widgets; `multiline`, `dynamicPrompts` for
* strings) are passed through from the node's `INPUT_TYPES` schema as-is.
*
* Runtime widget addition is forbidden per AXIOMS.md A15 (Widget
* Declarativity) / `decisions/D-ban-runtime-addwidget.md` — every widget
* originates from the Python `INPUT_TYPES` declaration; this type
* describes the options surfaced on the resulting `WidgetHandle`, not a
* constructor argument bag.
*/
export interface WidgetOptions {
/** If `true`, the widget is hidden from the node UI on creation. */
hidden?: boolean
/** If `true`, the widget is rendered read-only (no user editing). */
readonly?: boolean
// PHASE_A_EXCLUDED per AXIOMS.md A14 + A16:
// `serialize` contradicted A16 even as a read-only key — there is no
// widget-level serialization disable. Removed from the type entirely.
//
// serialize?: boolean
/** Display label override. Defaults to the widget `name`. */
label?: string
/** Toggle label shown when value is `true` (BOOLEAN widgets). */
labelOn?: string
/** Toggle label shown when value is `false` (BOOLEAN widgets). */
labelOff?: string
/** Multiline text input (STRING widgets). */
multiline?: boolean
/**
* When `true`, the widget value is processed for dynamic prompt syntax
* at serialize time. (STRING widgets with `dynamicPrompts: true`.)
*/
dynamicPrompts?: boolean
/** Min value for numeric widgets (INT, FLOAT). */
min?: number
/** Max value for numeric widgets. */
max?: number
/** Step size for numeric widgets. */
step?: number
/** Default value at construction time. */
default?: unknown
/** Any additional type-specific option. */
[key: string]: unknown
}

View File

@@ -44,7 +44,9 @@ function onCustomComboCreated(this: LGraphNode) {
if (!node.widgets) return
const newCount = node.widgets.length - 1
const widgetName = `option${newCount}`
const widget = node.addWidget('string', widgetName, '', () => {})
const widget = node.addWidget('string', widgetName, '', () => {}, {
__suppressDeprecationWarning: true
})
if (!widget) return
let localValue = `${widget.value ?? ''}`

View File

@@ -272,9 +272,15 @@ useExtensionService().registerExtension({
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
node.addWidget(
'button',
'upload 3d model',
'upload3dmodel',
() => {
fileInput.click()
},
{ __suppressDeprecationWarning: true }
)
const resourcesInput = createFileInput('*', true)
@@ -289,15 +295,24 @@ useExtensionService().registerExtension({
'uploadExtraResources',
() => {
resourcesInput.click()
}
},
{ __suppressDeprecationWarning: true }
)
node.addWidget('button', 'clear', 'clear', () => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
})
node.addWidget(
'button',
'clear',
'clear',
() => {
const modelWidget = node.widgets?.find(
(w) => w.name === 'model_file'
)
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
},
{ __suppressDeprecationWarning: true }
)
const widget = new ComponentWidgetImpl({
node: node,

View File

@@ -121,7 +121,9 @@ app.registerExtension({
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio, {
__suppressDeprecationWarning: true
})
audioUIWidget.serialize = false
const { nodeData } = node.constructor
if (nodeData == null) throw new TypeError('nodeData is null')
@@ -263,7 +265,11 @@ app.registerExtension({
inputName,
'',
openFileSelection,
{ serialize: false, canvasOnly: true }
{
serialize: false,
canvasOnly: true,
__suppressDeprecationWarning: true
}
)
uploadWidget.label = t('g.choose_file_to_upload')
@@ -296,7 +302,9 @@ app.registerExtension({
audio.classList.add('comfy-audio')
audio.setAttribute('name', 'media')
const audioUIWidget: DOMWidget<HTMLAudioElement, string> =
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio)
node.addDOMWidget(inputName, /* name=*/ 'audioUI', audio, {
__suppressDeprecationWarning: true
})
audioUIWidget.options.canvasOnly = false
let mediaRecorder: MediaRecorder | null = null
@@ -420,7 +428,11 @@ app.registerExtension({
mediaRecorder.stop()
}
},
{ serialize: false, canvasOnly: false }
{
serialize: false,
canvasOnly: false,
__suppressDeprecationWarning: true
}
)
recordWidget.label = t('g.startRecording')

View File

@@ -64,7 +64,11 @@ app.registerExtension({
loadVideo()
return { widget: node.addDOMWidget(inputName, 'WEBCAM', container) }
return {
widget: node.addDOMWidget(inputName, 'WEBCAM', container, {
__suppressDeprecationWarning: true
})
}
}
}
},
@@ -111,7 +115,7 @@ app.registerExtension({
'waiting for camera...',
'capture',
capture,
{}
{ __suppressDeprecationWarning: true }
)
btn.disabled = true
btn.serializeValue = () => undefined

View File

@@ -224,6 +224,17 @@ export interface LGraphNode {
// #endregion Types
/**
* Shared deprecation message for the v1 `addWidget` / `addCustomWidget` /
* `addDOMWidget` family. Sharing one message string lets `warnDeprecated`
* deduplicate naturally — calling `addWidget` warns once per session even
* if it internally invokes `addCustomWidget`. See AXIOMS.md A15 and
* `decisions/D-ban-runtime-addwidget.md`.
*/
const ADD_WIDGET_DEPRECATION_MESSAGE =
"LGraphNode.addWidget(...) / addCustomWidget(...) / addDOMWidget(...) is deprecated and will be removed in v1.0. Widgets must be declared in the Python node's INPUT_TYPES per AXIOMS.md A15 (Widget Declarativity). Migration paths: (1) boxed widget — declare a single rich-value INPUT_TYPES field (e.g. BBOX [x,y,w,h]) and compose the multi-control UI against it; (2) non-widget UI primitive — mount custom DOM from defineNode/defineExtension setup() via bootstrap-hooks, owned by the extension not the widget system; (3) schema input — add the field to INPUT_TYPES and let the frontend render it normally. See AXIOMS.md A15 and decisions/D-ban-runtime-addwidget.md."
export { ADD_WIDGET_DEPRECATION_MESSAGE }
/**
* Base class for all nodes
* @param title a name for the node
@@ -1930,6 +1941,21 @@ export class LGraphNode
}
/**
* @deprecated Runtime widget addition will be removed in v1.0 per
* **AXIOMS.md A15 (Widget Declarativity)**. Widgets must be declared in
* the Python node's `INPUT_TYPES`. Migration paths:
*
* 1. **Boxed widget** — declare a single rich-value `INPUT_TYPES` field
* (e.g. `BBOX [x,y,w,h]`) and compose the multi-control UI inside one
* widget against that one value.
* 2. **Non-widget UI primitive** — mount custom DOM from
* `defineNode` / `defineExtension` `setup()` via the bootstrap-hooks
* lifecycle. Owned by the extension, not the widget system.
* 3. **Schema input** — add the field to Python `INPUT_TYPES` and let the
* frontend render it normally.
*
* See `AXIOMS.md` §A15 and `decisions/D-ban-runtime-addwidget.md`.
*
* Defines a widget inside the node, it will be rendered on top of the node, you can control lots of properties
* @param type the widget type
* @param name the text to show on the widget
@@ -1948,6 +1974,13 @@ export class LGraphNode
callback: IBaseWidget['callback'] | string | null,
options?: IWidgetOptions | string
): WidgetTypeMap[Type] | IBaseWidget {
const suppress =
typeof options === 'object' &&
options !== null &&
(options as IWidgetOptions).__suppressDeprecationWarning === true
if (!suppress) {
warnDeprecated(ADD_WIDGET_DEPRECATION_MESSAGE, this)
}
this.widgets ||= []
if (!options && callback && typeof callback === 'object') {
@@ -1993,9 +2026,21 @@ export class LGraphNode
return widget
}
/**
* @deprecated Runtime widget addition will be removed in v1.0 per
* **AXIOMS.md A15 (Widget Declarativity)**. See {@link addWidget} for
* the three migration paths (boxed widget / non-widget UI primitive /
* schema input). Reference: `decisions/D-ban-runtime-addwidget.md`.
*/
addCustomWidget<TPlainWidget extends IBaseWidget>(
custom_widget: TPlainWidget
): TPlainWidget | WidgetTypeMap[TPlainWidget['type']] {
const customOptions = (custom_widget as { options?: IWidgetOptions })
.options
const suppress = customOptions?.__suppressDeprecationWarning === true
if (!suppress) {
warnDeprecated(ADD_WIDGET_DEPRECATION_MESSAGE, this)
}
this.widgets ||= []
const widget = toConcreteWidget(custom_widget, this, false) ?? custom_widget
this.widgets.push(widget)

View File

@@ -21,6 +21,15 @@ export interface NodeBindable {
}
export interface IWidgetOptions<TValues = unknown> {
/**
* @internal
* Suppresses the `LGraphNode.addWidget` / `addCustomWidget` / `addDOMWidget`
* deprecation warning (D-ban-runtime-addwidget / AXIOMS.md A15) for
* first-party core call sites that have not yet been migrated to a
* schema-declared widget, boxed widget, or non-widget UI primitive.
* NOT part of the public surface — stripped before serialization.
*/
__suppressDeprecationWarning?: boolean
on?: string
off?: string
max?: number

View File

@@ -59,6 +59,10 @@ import {
} from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import {
invokeV2AppExtensions,
startExtensionSystem
} from '@/services/extension-api-service'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -855,6 +859,10 @@ export class ComfyApp {
//Doesn't need to block. Blueprints will load async
void useSubgraphStore().fetchSubgraphs()
await useExtensionService().loadExtensions()
// Start the v2 node-extension reactive mount watcher. Must run after
// loadExtensions() so all defineNode() calls have pushed into
// nodeExtensions[] before the first watcher tick.
startExtensionSystem()
this.addProcessKeyHandler()
this.addConfigureHandler()
@@ -949,11 +957,13 @@ export class ComfyApp {
})
await useExtensionService().invokeExtensionsAsync('init')
await invokeV2AppExtensions('init')
await this.registerNodes()
this.addDropHandler()
await useExtensionService().invokeExtensionsAsync('setup')
await invokeV2AppExtensions('setup')
this.positionConversion = useCanvasPositionConversion(
this.canvasContainer,

View File

@@ -2,6 +2,7 @@ import _ from 'es-toolkit/compat'
import { type Component, toRaw } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { ADD_WIDGET_DEPRECATION_MESSAGE } from '@/lib/litegraph/src/LGraphNode'
import {
LGraphNode,
LegacyWidget,
@@ -11,6 +12,7 @@ import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -358,6 +360,14 @@ export const addWidget = <W extends BaseDOMWidget<object | string>>(
node: LGraphNode,
widget: W
) => {
// Internal first-party registration path — silence the v1
// `addCustomWidget` deprecation warning per
// decisions/D-ban-runtime-addwidget.md. Third-party callers of
// `LGraphNode.addCustomWidget` still see the warning.
;(widget as { options?: IWidgetOptions }).options ||= {}
;(
widget as { options: IWidgetOptions }
).options.__suppressDeprecationWarning = true
node.addCustomWidget(widget)
if (node.graph) {
@@ -378,6 +388,13 @@ export const addWidget = <W extends BaseDOMWidget<object | string>>(
})
}
/**
* @deprecated Runtime DOM widget addition will be removed in v1.0 per
* **AXIOMS.md A15 (Widget Declarativity)**. Custom DOM widgets register
* via `defineWidget({ type, mount })` (D-widget-converge / Axiom A12) and
* are instantiated through schema declarations in Python `INPUT_TYPES`.
* See AXIOMS.md A15 and `decisions/D-ban-runtime-addwidget.md`.
*/
LGraphNode.prototype.addDOMWidget = function <
T extends HTMLElement,
V extends object | string
@@ -388,6 +405,9 @@ LGraphNode.prototype.addDOMWidget = function <
element: T,
options: DOMWidgetOptions<V> = {}
): DOMWidget<T, V> {
if (options.__suppressDeprecationWarning !== true) {
warnDeprecated(ADD_WIDGET_DEPRECATION_MESSAGE, this)
}
const widget = new DOMWidgetImpl({
node: this,
name,

View File

@@ -135,7 +135,8 @@ export function addValueControlWidgets(
{
values: ['fixed', 'increment', 'decrement', 'randomize'],
serialize: false, // Don't include this in prompt.
canvasOnly: true
canvasOnly: true,
__suppressDeprecationWarning: true
}
) as IComboWidget
@@ -161,7 +162,8 @@ export function addValueControlWidgets(
'',
function () {},
{
serialize: false // Don't include this in prompt.
serialize: false, // Don't include this in prompt.
__suppressDeprecationWarning: true
}
) as IStringWidget
updateControlWidgetLabel(comboFilter)

View File

@@ -0,0 +1,420 @@
// D12 regression: copy/paste must yield reset-to-fresh extensionState, not clone.
// Decision: decisions/D12-scope-clone-on-copy.md — Option (c) accepted.
// Task: I-SR.2.B5
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ── Mock world ────────────────────────────────────────────────────────────────
// The world must be mocked before the service is imported so the import-time
// getWorld() call picks up the mock. We fake only what the service exercises:
// getComponent() with NodeTypeKey (gates mountExtensionsForNode early-return)
// and entitiesWith() (used by startExtensionSystem's watch — not exercised here).
const mockGetComponent = vi.fn()
const mockEntitiesWith = vi.fn(() => [])
vi.mock('@/world/worldInstance', () => ({
getWorld: () => ({
getComponent: mockGetComponent,
entitiesWith: mockEntitiesWith,
setComponent: vi.fn(),
removeComponent: vi.fn()
})
}))
// Widget/node component modules must exist as mock modules so the service's
// top-level imports don't fail. The actual values are opaque keys; we just need
// them to be non-null references so `getComponent(id, key)` calls resolve.
vi.mock('@/world/widgets/widgetComponents', () => ({
WidgetComponentContainer: Symbol('WidgetComponentContainer'),
WidgetComponentDisplay: Symbol('WidgetComponentDisplay'),
WidgetComponentSchema: Symbol('WidgetComponentSchema'),
WidgetComponentSerialize: Symbol('WidgetComponentSerialize'),
WidgetComponentValue: Symbol('WidgetComponentValue')
}))
vi.mock('@/world/entityIds', () => ({}))
// defineComponentKey returns an identity object; tests don't need real ECS queries.
vi.mock('@/world/componentKey', () => ({
defineComponentKey: (name: string) => ({ name })
}))
// extension-api types: service re-exports from these, mocking prevents import errors.
vi.mock('@/extension-api/node', () => ({}))
vi.mock('@/extension-api/widget', () => ({}))
vi.mock('@/extension-api/lifecycle', () => ({}))
// ── Import service (after mocks are in place) ────────────────────────────────
import {
_clearExtensionsForTesting,
defineNode,
getCurrentScope,
getScopeRegistry,
mountExtensionsForNode,
onNodeMounted,
onNodeRemoved,
unmountExtensionsForNode
} from '../extension-api-service'
import type { NodeEntityId } from '@/world/entityIds'
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
return `node:graph-uuid-test:${n}` as NodeEntityId
}
function stubNodeType(nodeEntityId: NodeEntityId, comfyClass = 'TestNode') {
mockGetComponent.mockImplementation((id, key) => {
if (id === nodeEntityId && key?.name === 'NodeType')
return { type: comfyClass, comfyClass }
return undefined
})
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe('scope-registry — D12 copy/paste reset semantics', () => {
beforeEach(() => {
vi.clearAllMocks()
_clearExtensionsForTesting()
;[1, 2, 3, 4, 100, 101, 102, 103].forEach((n) => {
unmountExtensionsForNode(makeNodeId(n))
})
})
describe('D12(c): clone gets fresh extensionState, not source value', () => {
it('copy-pasted node (new entityId) starts with setup-default extensionState, not mutated source state', () => {
const SOURCE_ID = makeNodeId(1)
const CLONE_ID = makeNodeId(2)
// Register an extension that initialises count = 0 every time setup runs.
// The extension stores a ref so we can mutate it after mount.
const counters = new Map<NodeEntityId, ReturnType<typeof ref>>()
defineNode({
name: 'z-counter',
nodeCreated(handle) {
const count = ref(0)
counters.set(handle.id as NodeEntityId, count)
return { count }
}
})
// Mount source node.
stubNodeType(SOURCE_ID)
mountExtensionsForNode(SOURCE_ID)
// Mutate source extensionState (simulates user interaction driving count up).
const sourceCounter = counters.get(SOURCE_ID)!
expect(sourceCounter.value).toBe(0)
sourceCounter.value = 42
// Verify the mutation is visible via getScopeRegistry().
// proxyRefs unwraps refs, so extensionState.count returns the number directly (D10d).
const sourceEntry = getScopeRegistry().get(`z-counter:${SOURCE_ID}`)!
expect((sourceEntry.extensionState as { count: number }).count).toBe(42)
// Simulate copy/paste: new entityId added to the world.
// D12(c): mountExtensionsForNode runs fresh setup — no priorState.
stubNodeType(CLONE_ID)
mountExtensionsForNode(CLONE_ID)
// Clone's extensionState must be the setup-default (0), not source's (42).
const cloneEntry = getScopeRegistry().get(`z-counter:${CLONE_ID}`)!
expect(cloneEntry).toBeDefined()
expect((cloneEntry.extensionState as { count: number }).count).toBe(0)
// Source is unaffected.
const sourceAfter = getScopeRegistry().get(`z-counter:${SOURCE_ID}`)!
expect((sourceAfter.extensionState as { count: number }).count).toBe(42)
})
it('N pastes from the same source all start at setup-default (no shared state)', () => {
const SOURCE_ID = makeNodeId(100)
const PASTE_IDS = [
makeNodeId(101),
makeNodeId(102),
makeNodeId(103)
] as NodeEntityId[]
let setupCallCount = 0
defineNode({
name: 'a-setup-counter',
nodeCreated() {
setupCallCount++
return { iteration: ref(setupCallCount) }
}
})
stubNodeType(SOURCE_ID)
mountExtensionsForNode(SOURCE_ID)
const countAfterSource = setupCallCount // 1
for (const pasteId of PASTE_IDS) {
stubNodeType(pasteId)
mountExtensionsForNode(pasteId)
}
// Each paste ran setup() independently.
expect(setupCallCount).toBe(countAfterSource + PASTE_IDS.length)
// Each paste scope holds its own `iteration` value — no aliasing.
// proxyRefs unwraps refs so we access .iteration directly (D10d).
const iterations = PASTE_IDS.map((id) => {
const entry = getScopeRegistry().get(`a-setup-counter:${id}`)!
return (entry.extensionState as { iteration: number }).iteration
})
const unique = new Set(iterations)
expect(unique.size).toBe(PASTE_IDS.length)
})
it('unmounting source does not affect clone scope', () => {
const SOURCE_ID = makeNodeId(3)
const CLONE_ID = makeNodeId(4)
defineNode({
name: 'b-flag',
nodeCreated() {
return { flag: ref(true) }
}
})
stubNodeType(SOURCE_ID)
mountExtensionsForNode(SOURCE_ID)
stubNodeType(CLONE_ID)
mountExtensionsForNode(CLONE_ID)
unmountExtensionsForNode(SOURCE_ID)
// Source scope removed from registry.
expect(getScopeRegistry().get(`b-flag:${SOURCE_ID}`)).toBeUndefined()
// Clone scope survives independently.
const cloneEntry = getScopeRegistry().get(`b-flag:${CLONE_ID}`)
expect(cloneEntry).toBeDefined()
// proxyRefs unwraps refs so .flag returns the boolean directly (D10d).
expect((cloneEntry!.extensionState as { flag: boolean }).flag).toBe(true)
})
})
})
// ── I-SR.2.B3 + I-SR.3: currentExtension slot + lifecycle hooks ───────────────
// Tests that _currentScope is set/restored around setup() and that
// onNodeMounted/onNodeRemoved read it correctly (D10a).
describe('currentExtension global slot (D10a) + lifecycle hooks (I-SR.2.B3 / I-SR.3)', () => {
beforeEach(() => {
vi.clearAllMocks()
_clearExtensionsForTesting()
;[10, 11, 12].forEach((n) => unmountExtensionsForNode(makeNodeId(n)))
})
it('getCurrentScope() returns null outside of setup', () => {
expect(getCurrentScope()).toBeNull()
})
it('getCurrentScope() is non-null during nodeCreated setup', () => {
const NODE_ID = makeNodeId(10)
let scopeDuringSetup: ReturnType<typeof getCurrentScope> = null
defineNode({
name: 'c-slot-check',
nodeCreated() {
scopeDuringSetup = getCurrentScope()
}
})
stubNodeType(NODE_ID)
mountExtensionsForNode(NODE_ID)
expect(scopeDuringSetup).not.toBeNull()
expect(scopeDuringSetup!.extensionName).toBe('c-slot-check')
expect(scopeDuringSetup!.nodeEntityId).toBe(NODE_ID)
})
it('getCurrentScope() is restored to null after setup completes', () => {
const NODE_ID = makeNodeId(11)
defineNode({
name: 'd-slot-restore',
nodeCreated() {
/* no-op */
}
})
stubNodeType(NODE_ID)
mountExtensionsForNode(NODE_ID)
expect(getCurrentScope()).toBeNull()
})
it('onNodeRemoved callback fires when node is unmounted', () => {
const NODE_ID = makeNodeId(12)
const removedCb = vi.fn()
defineNode({
name: 'e-on-removed',
nodeCreated() {
onNodeRemoved(removedCb)
}
})
stubNodeType(NODE_ID)
mountExtensionsForNode(NODE_ID)
expect(removedCb).not.toHaveBeenCalled()
unmountExtensionsForNode(NODE_ID)
expect(removedCb).toHaveBeenCalledOnce()
})
it('onNodeRemoved outside setup context throws in dev', () => {
expect(() => onNodeRemoved(() => {})).toThrow(/outside setup context/)
})
it('onNodeMounted outside setup context throws in dev', () => {
expect(() => onNodeMounted(() => {})).toThrow(/outside setup context/)
})
})
// ── I-SR.6: missing lifecycle invariants ────────────────────────────────────────
// (a) setup-runs-once: calling mountExtensionsForNode twice on the same entity
// must not re-run setup. The scope registry already has an entry — getOrCreateScope
// short-circuits and setup is skipped.
// (b) no-dispose-on-subgraph-promotion: scopes survive DOM moves (subgraph promote).
// The v2 contract is: scope lifetime = entity lifetime, NOT DOM lifetime.
// Subgraph promotion creates a new logical location for the same entityId, but
// the scope must survive — only unmountExtensionsForNode destroys it.
describe('I-SR.6 — scope lifecycle invariants', () => {
beforeEach(() => {
vi.clearAllMocks()
_clearExtensionsForTesting()
;[30, 31, 32].forEach((n) => unmountExtensionsForNode(makeNodeId(n)))
})
it('(setup-runs-once) mounting the same node twice does not re-invoke nodeCreated', () => {
const NODE_ID = makeNodeId(30)
let setupCount = 0
defineNode({
name: 'h-once',
nodeCreated() {
setupCount++
}
})
mockGetComponent.mockImplementation((id, key) => {
if (id === NODE_ID && key?.name === 'NodeType')
return { type: 'TestNode', comfyClass: 'TestNode' }
return undefined
})
mountExtensionsForNode(NODE_ID)
// Second call on same entity — must be idempotent
mountExtensionsForNode(NODE_ID)
// setup ran exactly once (getOrCreateScope short-circuits on second call)
expect(setupCount).toBe(1)
})
it('(no-dispose-on-subgraph-promotion) scope survives a non-removal remount; only unmount destroys it', () => {
const NODE_ID = makeNodeId(31)
let setupCount = 0
defineNode({
name: 'i-promotion',
nodeCreated() {
setupCount++
// (In real Phase B, onNodeRemoved would be used; here we verify via
// setupCount that setup does not re-run, meaning scope was preserved.)
}
})
mockGetComponent.mockImplementation((id, key) => {
if (id === NODE_ID && key?.name === 'NodeType')
return { type: 'TestNode', comfyClass: 'TestNode' }
return undefined
})
mountExtensionsForNode(NODE_ID)
expect(setupCount).toBe(1)
// Simulate subgraph promotion: the runtime calls mountExtensionsForNode again
// for the same entity (the node "moved" but the entityId is unchanged).
mountExtensionsForNode(NODE_ID)
// Scope was NOT disposed — setup did not re-run
expect(setupCount).toBe(1)
// Only an explicit unmount destroys the scope
unmountExtensionsForNode(NODE_ID)
// Scope removed from registry
const entry = getScopeRegistry().get(`i-promotion:${NODE_ID}`)
expect(entry).toBeUndefined()
})
})
// ── I-SR.3.B4: reactive dispatch — LoadedFromWorkflow tag ─────────────────────
// Tests that LoadedFromWorkflow presence routes to loadedGraphNode hook
// (hydration) rather than nodeCreated (fresh creation).
describe('LoadedFromWorkflow tag routes to correct hook (I-SR.3)', () => {
beforeEach(() => {
vi.clearAllMocks()
_clearExtensionsForTesting()
;[20, 21].forEach((n) => unmountExtensionsForNode(makeNodeId(n)))
})
it('node without LoadedFromWorkflow tag calls nodeCreated', () => {
const NODE_ID = makeNodeId(20)
const created = vi.fn()
const loaded = vi.fn()
defineNode({
name: 'f-routing',
nodeCreated: created,
loadedGraphNode: loaded
})
// Stub: no LoadedFromWorkflow component (getComponent returns undefined for it)
mockGetComponent.mockImplementation((id, key) => {
if (id === NODE_ID && key?.name === 'NodeType')
return { type: 'TestNode', comfyClass: 'TestNode' }
return undefined // no LoadedFromWorkflow
})
mountExtensionsForNode(NODE_ID)
expect(created).toHaveBeenCalledOnce()
expect(loaded).not.toHaveBeenCalled()
})
it('node with LoadedFromWorkflow tag calls loadedGraphNode', () => {
const NODE_ID = makeNodeId(21)
const created = vi.fn()
const loaded = vi.fn()
defineNode({
name: 'g-routing',
nodeCreated: created,
loadedGraphNode: loaded
})
mockGetComponent.mockImplementation((id, key) => {
if (id === NODE_ID && key?.name === 'NodeType')
return { type: 'TestNode', comfyClass: 'TestNode' }
if (id === NODE_ID && key?.name === 'LoadedFromWorkflow')
return { _tag: 'LoadedFromWorkflow' }
return undefined
})
mountExtensionsForNode(NODE_ID)
expect(loaded).toHaveBeenCalledOnce()
expect(created).not.toHaveBeenCalled()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,9 @@ import type { AuthUserInfo } from '@/types/authTypes'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
// Tracks which extensions have already received the beforeRegisterNodeDef deprecation warning
const _warnedBeforeRegisterNodeDef = new Set<string>()
export const useExtensionService = () => {
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()
@@ -209,6 +212,19 @@ export const useExtensionService = () => {
legacyMenuCompat.setCurrentExtension(ext.name)
}
// DEP1: warn once per extension that uses beforeRegisterNodeDef
if (
method === 'beforeRegisterNodeDef' &&
!_warnedBeforeRegisterNodeDef.has(ext.name)
) {
_warnedBeforeRegisterNodeDef.add(ext.name)
console.warn(
`[ComfyUI] Extension "${ext.name}" uses deprecated hook "beforeRegisterNodeDef". ` +
'Use defineNode({ nodeCreated(handle) { ... } }) with a nodeTypes filter instead. ' +
'See https://docs.comfy.org/extensions/api for the v2 API.'
)
}
const result = await fn.call(ext, ...args, app)
// Clear current extension after setup

View File

@@ -0,0 +1,18 @@
# `src/services/registries/`
D18 Phase 1 scaffolding — empty registry modules that the loader will
populate in Phase 2 once side-effect registration is removed from
`@/services/extension-api-service`.
Each module owns one extension kind:
- `nodeExtensionRegistry.ts` — outputs of `defineNode(...)`
- `widgetExtensionRegistry.ts` — outputs of `defineWidget(...)`
- `appExtensionRegistry.ts` — outputs of `defineExtension(...)`
These modules are intentionally minimal in Phase 1. They expose the
`register / getAll / clearForTesting` shape the future loader will call,
and a stub adapter the existing service will switch to in Phase 2.
See `decisions/D18-pure-functions-loader-registration.md` for the full
plan and rationale.

View File

@@ -0,0 +1,25 @@
/**
* Registry for `defineExtension(...)` outputs (D18 Phase 1 scaffolding).
*
* See `nodeExtensionRegistry.ts` for the rollout plan and
* `decisions/D18-pure-functions-loader-registration.md` for rationale.
*
* @internal — runtime-only; not part of `@comfyorg/extension-api`.
*/
import type { ExtensionOptions } from '@/extension-api/types'
const _appExtensions: ExtensionOptions[] = []
export function register(options: ExtensionOptions): void {
_appExtensions.push(options)
}
export function getAll(): readonly ExtensionOptions[] {
return _appExtensions
}
/** @internal Test-only. */
export function _clearForTesting(): void {
_appExtensions.length = 0
}

View File

@@ -0,0 +1,30 @@
/**
* Registry for `defineNode(...)` outputs (D18 Phase 1 scaffolding).
*
* Phase 1: this module is empty / unused. The existing
* `extension-api-service.ts` continues to push into its module-local
* `nodeExtensions` array on import-time side effect.
*
* Phase 2: the side-effect path is removed, the loader walks every
* imported extension module and calls `register(...)` on each branded
* `defineNode` result.
*
* @internal — runtime-only; not part of `@comfyorg/extension-api`.
*/
import type { NodeExtensionOptions } from '@/extension-api/types'
const _nodeExtensions: NodeExtensionOptions[] = []
export function register(options: NodeExtensionOptions): void {
_nodeExtensions.push(options)
}
export function getAll(): readonly NodeExtensionOptions[] {
return _nodeExtensions
}
/** @internal Test-only. */
export function _clearForTesting(): void {
_nodeExtensions.length = 0
}

View File

@@ -0,0 +1,25 @@
/**
* Registry for `defineWidget(...)` outputs (D18 Phase 1 scaffolding).
*
* See `nodeExtensionRegistry.ts` for the rollout plan and
* `decisions/D18-pure-functions-loader-registration.md` for rationale.
*
* @internal — runtime-only; not part of `@comfyorg/extension-api`.
*/
import type { WidgetExtensionOptions } from '@/extension-api/types'
const _widgetExtensions: WidgetExtensionOptions[] = []
export function register(options: WidgetExtensionOptions): void {
_widgetExtensions.push(options)
}
export function getAll(): readonly WidgetExtensionOptions[] {
return _widgetExtensions
}
/** @internal Test-only. */
export function _clearForTesting(): void {
_widgetExtensions.length = 0
}

View File

@@ -108,34 +108,75 @@ export interface ComfyExtension {
name: string
/**
* The commands defined by the extension
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* the per-surface `defineCommand(...)` entry point from
* `@comfyorg/extension-api` instead. Each call returns a `DisposableHandle`
* for independent unregister. A codemod in `@comfyorg/extension-api` ships
* to perform the v1→v2 rewrite mechanically. The v1 slot is retained for
* back-compat during the deprecation window.
*/
commands?: ComfyCommand[]
/**
* The keybindings defined by the extension
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* `defineHotkey(...)` from `@comfyorg/extension-api`. Each call returns a
* `DisposableHandle`. The v1 slot is retained for back-compat during the
* deprecation window.
*/
keybindings?: Keybinding[]
/**
* Menu commands to add to the menu bar
*
* @deprecated Menu surface is out of scope for D-shell-ui-entrypoints
* (W6.P5.C) and will be addressed in a follow-on ADR (the prototype-patch
* `getExtraMenuOptions` tax is the load-bearing migration question and
* needs its own treatment). The v1 slot remains supported until that
* ADR ships.
*/
menuCommands?: MenuCommandGroup[]
/**
* Settings to add to the settings menu
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* `defineSetting(...)` from `@comfyorg/extension-api`. The v1 slot is
* retained for back-compat during the deprecation window.
*/
settings?: SettingParams[]
/**
* Bottom panel tabs to add to the bottom panel
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* `defineBottomPanelTab(...)` from `@comfyorg/extension-api`. The v1 slot
* is retained for back-compat during the deprecation window.
*/
bottomPanelTabs?: BottomPanelExtension[]
/**
* Badges to add to the about page
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* `defineAboutBadge(...)` from `@comfyorg/extension-api`. The v1 slot is
* retained for back-compat during the deprecation window.
*/
aboutPageBadges?: AboutPageBadge[]
/**
* Badges to add to the top bar
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18):
* topbar badges are not part of the W6.P5 scope (the R3 evidence sweep
* found this surface had effectively zero ecosystem use and was treated as
* an internal-only concern). A `defineTopbarBadge` entry may be added in
* a follow-on PR; the v1 slot remains supported until then.
*/
topbarBadges?: TopbarBadge[]
/**
* Buttons to add to the action bar
*
* @deprecated Per D-shell-ui-entrypoints (W6.P5.C, ACCEPTED 2026-05-18): use
* `defineToolbarButton(...)` from `@comfyorg/extension-api`. Note this is
* a net-new surface (0 hits in the v1 R3 evidence sweep) — the v1 slot
* existed but was undocumented; any first-mover usage is greenfield.
*/
actionBarButtons?: ActionBarButton[]
/**

View File

@@ -1,5 +1,6 @@
import type { Component } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import type { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ExecutionErrorWsMessage, NodeError } from '@/schemas/apiSchema'
@@ -48,7 +49,17 @@ export type BottomPanelExtension =
| CustomBottomPanelExtension
/**
* Defines message options in Toast component.
* Defines message options in Toast component. Passed to {@link toast.show} /
* {@link toast.remove} to surface a transient message to the user.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { toast } from '@comfyorg/extension-api'
*
* toast.show({ severity: 'info', summary: 'Saved', life: 2000 })
* ```
*/
export interface ToastMessageOptions {
/**
@@ -139,3 +150,154 @@ export interface CommandManager {
}
): void
}
// ─────────────────────────────────────────────────────────────────────────────
// D-shell-ui-entrypoints (W6.P5.C) — per-surface registration arg types
// ─────────────────────────────────────────────────────────────────────────────
//
// Each `defineX` entry point in `@comfyorg/extension-api` accepts one of the
// types below. The shapes are deliberately thin POJOs (not class instances) so
// they are JSON-friendly, tree-shake-friendly, and easy to author/test in
// isolation. The runtime wraps each one into the appropriate store registration
// at mount time (see `src/extension-api/registrations.ts`).
//
// New types added under (ii) "separate entries" per W6.P5.B reconciliation:
// - HotkeyExtension
// - AboutBadgeExtension
// - SettingDefinition
// - ToolbarButtonExtension
// - CommandDefinition (alias of v1 ComfyCommand for the public surface)
/**
* Public arg type for {@link defineCommand}. Alias of the v1 `ComfyCommand`
* shape exported from `@/stores/commandStore`, re-surfaced under a public name
* so authors do not import from the internal `stores/` path.
*
* @publicAPI
* @stability experimental
*/
export type CommandDefinition = ComfyCommand
/**
* Public arg type for {@link defineHotkey}. A hotkey binds a key combination
* (already-registered command) to fire on press. Mirrors v1
* `extension.keybindings[]` which used the internal `Keybinding` shape.
*
* `keys` accepts a human-readable combo string (e.g. `'mod+k'`, `'ctrl+shift+f'`)
* matching the Vue/PrimeVue keybinding convention. `mod` resolves to `cmd` on
* macOS and `ctrl` elsewhere. The runtime parses this into the underlying
* `KeyCombo` shape used by the keybinding store.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineCommand, defineHotkey } from '@comfyorg/extension-api'
*
* defineCommand({ id: 'my.cmd', function: () => {} })
* defineHotkey({ keys: 'mod+k', commandId: 'my.cmd' })
* ```
*/
export interface HotkeyExtension {
/**
* Key combination to listen for. Examples: `'mod+k'`, `'ctrl+shift+f'`,
* `'alt+enter'`. `mod` resolves to `cmd` on macOS and `ctrl` elsewhere.
*/
keys: string
/**
* The id of an already-registered command. Use {@link defineCommand} to
* register the command before (or alongside) the hotkey.
*/
commandId: string
/**
* Optional id of the DOM element that must be focused for the hotkey to
* fire. When omitted, the hotkey is global.
*/
targetElementId?: string
}
/**
* Public arg type for {@link defineAboutBadge}. A badge that appears on the
* About page (linked label + icon + optional severity). Mirrors v1
* `extension.aboutPageBadges[]`.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineAboutBadge } from '@comfyorg/extension-api'
*
* defineAboutBadge({
* label: 'GitHub',
* url: 'https://github.com/me/my-ext',
* icon: 'pi-github'
* })
* ```
*/
export interface AboutBadgeExtension {
/** Display label for the badge. */
label: string
/** URL the badge links to on click. */
url: string
/** Icon class (e.g. `'pi-github'`) shown next to the label. */
icon: string
/** Optional severity tint; defaults to neutral. */
severity?: 'danger' | 'warn'
}
/**
* Public arg type for {@link defineSetting}. Re-surfaces the existing
* `SettingParams` shape (from `@/platform/settings/types`) under a public name
* so authors do not import from the internal `platform/` path.
*
* Note: the underlying `SettingParams.id` is typed against the `Settings`
* keymap; for ecosystem extension settings, authors widen the id type via
* `as keyof Settings` or rely on TS module augmentation of `Settings` (a
* follow-on RFC will formalize the augmentation path).
*
* @publicAPI
* @stability experimental
*/
export type SettingDefinition<TValue = unknown> = SettingParams<TValue>
/**
* Public arg type for {@link defineToolbarButton}. A button that appears in
* the action bar (top of the canvas area). Wraps the v1
* `extension.actionBarButtons[]` shape with an `id` so each registration is
* independently disposable.
*
* **Net-new surface**: no v1 registration path existed for toolbar buttons
* (action-bar buttons were possible but undocumented and never had a
* `defineToolbarButton` equivalent). Per W6.P5 evidence sweep this surface
* had 0 hits across the 138-repo corpus — any first-mover use is greenfield.
*
* @publicAPI
* @stability experimental
* @example
* ```ts
* import { defineToolbarButton } from '@comfyorg/extension-api'
*
* defineToolbarButton({
* id: 'my.help',
* icon: 'pi-question-circle',
* tooltip: 'Get help',
* onClick: () => openHelp()
* })
* ```
*/
export interface ToolbarButtonExtension {
/** Stable id for the button — used by `dispose()` to unregister. */
id: string
/**
* Icon class to display (e.g. `'icon-[lucide--message-circle-question-mark]'`).
*/
icon: string
/** Optional label text shown next to the icon. */
label?: string
/** Optional tooltip shown on hover. */
tooltip?: string
/** Optional CSS class string applied to the button element. */
class?: string
/** Click handler invoked when the button is pressed. */
onClick: () => void
}

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

@@ -0,0 +1,24 @@
/**
* @deprecated Import from `@comfyorg/extension-api` (or `@/extension-api`)
* instead. This stub will be removed in the next release after PKG2 lands.
*
* See `src/extension-api/` for the new source of truth.
*/
export type {
NodeEntityId,
WidgetEntityId,
SlotEntityId,
Point,
Size,
NodeMode,
SlotDirection,
SlotInfo,
WidgetHandle,
WidgetOptions,
NodeHandle
} from '@/extension-api'
export type { NodeExtensionOptions, ExtensionOptions } from '@/extension-api'
export { defineNode, defineExtension } from '@/extension-api'

12
src/world/componentKey.ts Normal file
View File

@@ -0,0 +1,12 @@
// Phase A stub — replaced by real ECS componentKey when PR #11939 lands.
// Tests mock this module via vi.mock('@/world/componentKey').
export interface ComponentKey<_TData, _TEntity> {
readonly name: string
}
export function defineComponentKey<TData, TEntity>(
name: string
): ComponentKey<TData, TEntity> {
return { name }
}

8
src/world/entityIds.ts Normal file
View File

@@ -0,0 +1,8 @@
// Phase A stub — replaced by real ECS entityIds when PR #11939 lands.
// Tests mock this module via vi.mock('@/world/entityIds').
export type Brand<T, B extends string> = T & { readonly __brand: B }
export type NodeEntityId = Brand<string, 'NodeEntityId'>
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
export type EntityId = NodeEntityId | WidgetEntityId

View File

@@ -0,0 +1,45 @@
// Phase A stub — replaced by real ECS widget components when PR #11939 lands.
// Tests mock this module via vi.mock('@/world/widgets/widgetComponents').
import { defineComponentKey } from '../componentKey'
import type { NodeEntityId, WidgetEntityId } from '../entityIds'
interface WidgetContainerData {
widgetIds: WidgetEntityId[]
}
interface WidgetDisplayData {
label?: string
hidden?: boolean
disabled?: boolean
}
interface WidgetSchemaData {
type?: string
options?: Record<string, unknown>
}
interface WidgetSerializeData {
serialize?: boolean
}
interface WidgetValueData {
value?: unknown
}
export const WidgetComponentContainer = defineComponentKey<
WidgetContainerData,
NodeEntityId
>('WidgetComponentContainer')
export const WidgetComponentDisplay = defineComponentKey<
WidgetDisplayData,
WidgetEntityId
>('WidgetComponentDisplay')
export const WidgetComponentSchema = defineComponentKey<
WidgetSchemaData,
WidgetEntityId
>('WidgetComponentSchema')
export const WidgetComponentSerialize = defineComponentKey<
WidgetSerializeData,
WidgetEntityId
>('WidgetComponentSerialize')
export const WidgetComponentValue = defineComponentKey<
WidgetValueData,
WidgetEntityId
>('WidgetComponentValue')

View File

@@ -0,0 +1,34 @@
// Phase A stub — replaced by real ECS world when PR #11939 lands.
// Tests mock this module via vi.mock('@/world/worldInstance').
import type { ComponentKey } from './componentKey'
import type { EntityId } from './entityIds'
/**
* ECS World contract. Internal only — not part of public extension API.
* Phase A surface; replaced by Alex's PR #11939 (ECS substrate slice 2).
*/
interface World {
getComponent<TData, TEntity extends EntityId>(
entityId: TEntity,
key: ComponentKey<TData, TEntity>
): TData | undefined
setComponent<TData, TEntity extends EntityId>(
entityId: TEntity,
key: ComponentKey<TData, TEntity>,
data: TData
): void
removeComponent<TData, TEntity extends EntityId>(
entityId: TEntity,
key: ComponentKey<TData, TEntity>
): void
entitiesWith<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): TEntity[]
}
export function getWorld(): World {
throw new Error(
'[worldInstance] ECS world not yet initialized (Phase A stub)'
)
}