Compare commits

..

75 Commits

Author SHA1 Message Date
Connor Byrne
2cc1457596 fix(ext): stub dynamicPrompts.v2 pending defineWidgetAugmenter
Per D-no-node-widget-access (2026-05-19, bilateral A1 closure), nodes
can no longer enumerate widgets — `node.getWidgets()` was removed
from NodeHandle. The previous dynamicPrompts.v2 implementation
iterated node widgets to attach per-widget `beforeSerialize`
handlers; that pattern is now forbidden.

Stubbed pending a follow-up public API (`defineWidgetAugmenter` or
per-widget `setup` on `defineWidget`) that lets extensions attach
behavior to existing widget types matching a predicate. v1
(`Comfy.DynamicPrompts`) continues to work — this is a v2 surface
gap only, not a user-visible regression.

Refs: decisions/D-no-node-widget-access.md
2026-05-21 14:04:40 -07:00
Connor Byrne
616a30ddb3 feat(ext): add coordSpaceDemo.v2 canary for D-coord-space (W6.P4.D / A13)
Strangler-pattern v2 example demonstrating the single-coordinate-space policy from D-coord-space (ACCEPTED 2026-05-18, option iii) and Axiom A13 (Single Coordinate Space — Canvas).

Three sections: (1) default path — every NodeHandle spatial accessor speaks canvas units; (2) escape-hatch path — globalThis.app.canvas.{ds,canvas} + window.devicePixelRatio with the required '// escape-hatch — see D-coord-space.md' annotation comment on every use site; (3) cliff documentation — what's NOT on the public surface (no getScreenPosition, no space param, no branded ClientPoint type).

Serves as the executable canary that the documented pattern actually works. Authors and AI agents reading the v2 extensions/core/*.v2.ts directory see the policy demonstrated alongside the existing dynamicPrompts/previewAny/imageCrop/noteNode/slotDefaults/rerouteNode/webcamCapture examples.

knip.config.ts: add new file to strangler ignore list. Phase A gates: format clean / lint 0 errors 3 pre-existing warnings / knip 6 pre-existing tag hints + 1 pre-existing config hint.
2026-05-21 14:04:40 -07:00
Connor Byrne
f182d1ff96 feat(ext): add webcamCapture.v2 example exercising defineWidget+mount (W6.P3.D)
Strangler-pattern port of the WEBCAM custom widget type from the v1 webcamCapture extension to the v2 mount-lifecycle seam (Axiom A12 / D-widget-converge §Decision).

- Registers WEBCAM via defineWidget({type:'WEBCAM',mount}). The mount body constructs the <video> + container, captures them via closure (no widget.element accessor exposed per A12), and returns a cleanup that stops MediaStream tracks on widget destruction. - Uses ctx.onAfterRemount to re-attach the cached container when the host is swapped (graph ↔ app mode swap, subgraph promotion) per D-widget-converge §Clarification 1 — mount body is NOT re-invoked. - Companion defineNode stays a placeholder: GAP-2 (no type-construction addWidget('button',…)) and GAP-11 (no async setSerializedValue path; v2's WidgetBeforeSerializeEvent doesn't yet promise async resolution) block the full node-side port. v1 webcamCapture.ts remains authoritative until those gaps close.

knip.config.ts: add the new file to ignore list (matches existing v2 strangler entries; not wired into bootstrap).

Phase A gates: lint 0 errors / 3 pre-existing warnings; format:check clean; knip 6 pre-existing tag hints + 1 pre-existing config hint / 0 new failures.
2026-05-21 14:04:40 -07:00
Connor Byrne
524830023d docs(ext-api): document v1+v2 parallel loading (F-12144-1) 2026-05-21 14:04:40 -07:00
Connor Byrne
d70ead814d fix(ext): update v2 example extensions to current API
- defineNodeExtension → defineNode
- widgets() → getWidgets()
- Add explicit types for event handlers
- Add extension-api scripts entry to knip config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 14:04:40 -07:00
Connor Byrne
542256eeec style(ext-api/ext): apply CodeRabbit polish (F-12144-2/3/4/10/11)
F-12144-2: rewrite shell.ts header to say 're-exported from' (matches the
re-export code; the original 'moved from' wording was ambiguous).

F-12144-3: tag SlotDefaults v2 extension as '(DEMO — incomplete migration)'
and add a JSDoc @remarks block listing the v1 features (beforeRegisterNodeDef
node metadata, settings-dialog contribution, slot-type registry mutation)
that remain TBD. Intentionally NOT porting them — staging-demo intent per the
GAP-4/5/6 feedback to Simon/Austin.

F-12144-4: drop the redundant 'as string' cast in dynamicPrompts.v2; the
immediate 'typeof value === "string"' check makes the cast self-contradicting.
Defensive runtime check stays.

F-12144-10: add a 3-line @example to defineWidgetExtension JSDoc to match the
defineNodeExtension/defineExtension docs.

F-12144-11: reorder DOMWidgetOptions and NodeHandle JSDoc blocks so each
docblock immediately precedes its declaration.
2026-05-21 14:04:40 -07:00
GitHub Action
5df5ee1d0b [automated] Apply ESLint and Oxfmt fixes 2026-05-21 14:04:40 -07:00
Connor Byrne
2f76a931a8 fix(ext-api/ext): ignore strangler v2 extensions in knip (review #12144) 2026-05-21 14:04:40 -07:00
Connor Byrne
dcdc9e7bfa chore(ext-api/ext): tag rerouteNode globalThis.app accesses with strangler-bridge:Phase-B (review #12144.1.6)
Per D9 strangler-bridge taxonomy, every direct globalThis.app access in
v2 conversion files must carry a 'strangler-bridge:Phase-B' marker so
the bridge audit can enumerate remaining touch-points before Phase-B
removes the global.

Tag the three sites in rerouteNode.v2.ts (configuringGraph guard in
onConnectionsChange, configuringGraph guard in updateLink, and the
canvas.setDirty call from the context-menu callback).
2026-05-21 14:04:40 -07:00
Connor Byrne
3f639da07d fix(ext-api/ext): warn on node.on stub-channel registration (review #12144.1.5)
Known-stubbed channels (executed/connected/disconnected/configured)
dispatch to a Phase-A stub bus that does not deliver events. Previously
these registrations were silent — extensions saw no warning that their
handlers were dead until #11939 lands the dispatch substrate.

Add a DEV console.warn (eslint-disable-next-line no-console, matching
the established pattern in this file) for each known-stubbed channel
registration so authors know to wait on #11939.
2026-05-21 14:04:40 -07:00
GitHub Action
d66f989a96 [automated] Apply ESLint and Oxfmt fixes 2026-05-21 14:04:40 -07:00
Connor Byrne
de7730b67b feat(extension-api): core extension v2 conversions
Restratified i-ext. Adds v2 conversions for 6 core extensions:
- dynamicPrompts.v2.ts
- imageCrop.v2.ts
- previewAny.v2.ts
- noteNode.v2.ts
- rerouteNode.v2.ts
- slotDefaults.v2.ts
And registers the first 3 in src/extensions/core/index.ts.

Note: noteNode.v2, rerouteNode.v2, slotDefaults.v2 are NOT yet
registered in index.ts (pre-existing issue from original i-ext branch).
Filed as a follow-up TODO.

Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-ext.
2026-05-21 14:04:40 -07:00
Connor Byrne
528d014e15 build(extension-api): regenerate .d.ts for hygiene cleanup (wave-11)
Hand-patched mirror of foundation hygiene cleanup per AGENTS.md Rule 8
(Node 20 vs vite blocks `pnpm build` in this worktree):

- Strip HR-style section dividers
- Strip parenthetical decision archaeology
  ((per D5), (D-immutability-enforcement, Hybrid C),
  D-bootstrap-hooks (W6.P6.C), D-shell-ui-entrypoints (W6.P5.C), etc.)
  from per-export JSDoc — preserve AXIOMS.md §A1/A14/A15/A16/A12 refs
  and PHASE_A_EXCLUDED axiom blocks (functional content).
- Strip "W6.P8.UNMIGRATABLE / D-input-output-shape" todo refs.
- One stale `defineWidgetExtension` JSDoc example fixed (was already
  on foundation; rebase carried the fix through).

Mirrors foundation commit `ee0537fdb5`.

ADR: decisions/D-design-review-hygiene-cleanup.md
Zero published-surface change (no types added/removed/renamed).
2026-05-21 14:01:37 -07:00
Connor Byrne
b4fe848527 build(extension-api): regenerate .d.ts for D-ban-runtime-addwidget (wave-10)
Hand-patched mirror of foundation surface changes per AGENTS.md Rule 8
(Node 20 vs vite blocks `pnpm build` in this worktree):

- Remove `NodeHandle.addWidget(...)` method declaration (A15 closure)
- Scrub 5 JSDoc references to `node.addWidget(...)` across NodeHandle
  getWidgets doc, NodeBeforeSerialize migration example, defineWidget
  options doc, WidgetHandle name doc, WidgetHandle label doc,
  WidgetOptions doc
- Add A15 / D-ban-runtime-addwidget cross-references

Mirrors foundation commit `d5d5692928`.
2026-05-21 14:01:37 -07:00
Connor Byrne
e4f2feb6f8 chore(ext-api/pkg): rebuild .d.ts for D-widget-serialization-simplification (wave-9)
Hand-patched packages/extension-api/build/index.d.ts to match the
foundation surface after A16 closure:

- Drop `isSerializeEnabled` / `setSerializeEnabled` from WidgetHandle
- Drop `WidgetBeforeSerializeEvent.context` 4-way discriminator
- Drop `WidgetBeforeSerializeEvent.skip()`
- Drop `on('propertyChange', handler)` overload
- Drop entire `WidgetPropertyChangeEvent` interface
- Drop `WidgetOptions.serialize?: boolean` key
- Update JSDoc on `WidgetBeforeSerializeEvent` / `options` / `serializeValue`
  to reference A16 / drop transport-discriminator examples

`NodeBeforeSerializeEvent.context` is intentionally retained — the
node-level surface stays `@deprecated` + runtime-warned per ADR-0010
(not banned by D-widget-serialization-simplification).

Hand-edit pattern because pnpm build is blocked by Node 20 vs vite
requirement per AGENTS.md Rule 8. Surface verified to match foundation
HEAD e56187adf3.

Refs: decisions/D-widget-serialization-simplification.md, AXIOMS.md §A15+A16
2026-05-21 14:01:37 -07:00
Connor Byrne
e9d51335c2 chore(ext-api/pkg): rebuild .d.ts for D-coord-space (W6.P4.C) JSDoc cascade
Picks up the canvas-units / escape-hatch / A13 JSDoc on NodeHandle.{getPosition,setPosition,getSize,setSize} from foundation fa3229b402 so the published .d.ts and the docgen pipeline see the policy text. No symbol changes.
2026-05-21 14:01:37 -07:00
Connor Byrne
06fb27d233 chore(ext-api/pkg): rebuild .d.ts for D-immutability-enforcement (W6.P8.C) + D-widget-converge (W6.P3.C) cascade
Regenerates packages/extension-api/build/index.d.ts to ship the merged
foundation surface from the two cherry-picked commits:
  - df921f3512 / a22682b7fe — Hybrid C Readonly typing per
    D-immutability-enforcement (8 mutation-family accessors now readonly,
    getInputs/getOutputs renamed, widget.options + widget.serializeValue
    accessor-only).
  - 2f102353fa / d76592e95b — DOMWidget ↔ Widget convergence via mount
    lifecycle per D-widget-converge / Axiom A12. New WidgetCleanup /
    WidgetMountContext / WidgetMountFn exports; DOMWidgetOptions and
    NodeHandle.addDOMWidget() removed.

Verification:
  $ grep -c 'WidgetMountContext\|WidgetCleanup\|WidgetMountFn' build/index.d.ts
  8
  $ grep -c 'DOMWidgetOptions\|addDOMWidget' build/index.d.ts
  0

+222/-53 lines net (build/index.d.ts).
2026-05-21 14:01:37 -07:00
Connor Byrne
c2ab350cb7 feat(ext-api/pkg): D20 identity convergence cascade — rebuild .d.ts, drop *EntityId from extensionV2 stub
Cascade of D20 (decisions/D20-id-type-convergence.md) from foundation:

- Rebuild packages/extension-api/build/index.d.ts via vite build so the
  published .d.ts ships the new id/equals surface and drops the three
  demoted brand re-exports.
- src/types/extensionV2.ts: stop re-exporting NodeEntityId, WidgetEntityId,
  SlotEntityId — they are no longer in the @/extension-api barrel per D20.
  Use node.id/widget.id (string) and equals(other) for the public surface.
2026-05-21 14:01:37 -07:00
Connor Byrne
4eaf898cc2 chore(ext-api/pkg): exclude generated build artifacts from oxfmt check
The packages/extension-api/build/ directory contains TypeDoc/tsc-generated
.d.ts snapshots committed for reviewability (per D17). These should not
be format-checked since they're machine-generated.

Adds packages/extension-api/build/** to oxfmt ignorePatterns so that
'pnpm format:check' passes cleanly post-restratify reconciliation.

Part of RECONCILE-PKG (PR #12143).
2026-05-21 14:01:37 -07:00
GitHub Action
83132ab5a1 [automated] Apply ESLint and Oxfmt fixes 2026-05-21 14:01:37 -07:00
Connor Byrne
9ab998003c fix(ext-api/pkg): update action versions to match .pinact.yaml
Update workflow actions to versions allowed in .pinact.yaml:
- actions/setup-node@v4 → v6
- actions/github-script@v7 → v8

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 14:01:37 -07:00
Connor Byrne
42a1ba05f4 chore(ext-api/pkg): rebuild .d.ts with getWidgets() rename
Updates bundled types after foundation renamed widgets() → getWidgets().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 14:01:37 -07:00
Connor Byrne
a059009dce chore(extension-api): track only .d.ts in build, not JS
Remove index.js and index.js.map from git tracking. The .d.ts file is
committed for npm package visibility and reviewer inspection, while JS
files are generated at build time and don't need to be in version control.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 14:01:37 -07:00
Connor Byrne
0ffb475d74 feat(ext-api/pkg): commit built .d.ts and .js to repo
- Remove build/ from .gitignore so types are visible in PR
- Update vite config to use rollupTypes for bundled declarations
- Update package.json types path to ./build/index.d.ts
- Exclude packages/*/build/ from lint-staged
- Include index.d.ts (37KB bundled types), index.js (2MB), index.js.map

The bundled index.d.ts contains all public API types inline.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 14:01:37 -07:00
Connor Byrne
14349fe23f fix(ext-api/pkg): build emits both .d.ts and .js via Option C (D17)
Resolves CodeRabbit findings F-12143-1, F-12143-3, F-12143-7.

Per D17-pkg2-build-strategy ADR: package now builds via Vite library
mode + vite-plugin-dts, dropping the inconsistent rootDir restriction
and the --emitDeclarationOnly flag. Vite resolves @/* aliases against
the canonical surface in main app src/extension-api/, externalizes vue
as a peer dep, and bundles to a single 3.95 kB build/index.js.
vite-plugin-dts emits the per-file .d.ts closure under build/ with
@/* imports rewritten to relative paths.

The original tsc-only plan in #12143 was unworkable because tsc does
not rewrite path aliases — emitted JS would have unresolvable @/...
imports at runtime. Vite library mode is the smallest deviation that
satisfies PKG3 acceptance ('emits BOTH .d.ts + .js') while preserving
'the barrel is the source of truth in main app src/extension-api/'
intent in packages/extension-api/AGENTS.md.

Verified:
  pnpm build emits build/index.js (3.95 kB) + build/.../*.d.ts tree
  node -e "import('./build/index.js')" returns real functions for
    defineNodeExtension, defineExtension, onNodeMounted, onNodeRemoved
  synthetic downstream consumer importing NodeHandle/WidgetHandle
    types and runtime fns type-checks under tsc --noEmit
  pnpm lint && pnpm format:check && pnpm knip pass

Also adds packages/extension-api/vite.config.mts to the eslint
allowDefaultProject list so the typed lint rules can resolve it.

See decisions/D17-pkg2-build-strategy.md in the cross-repo workspace
for the full options analysis (A: types-only — rejected; B: vendor
sources — deferred to post-#11939; C: build from main app sources —
chosen).
2026-05-21 14:01:37 -07:00
Connor Byrne
73ba37a78e fix(ci): correct extension-api-publish.yml dry_run boolean handling (review F-12143-8/9/10)
The dry_run input was declared as type: boolean but the default was the
string 'true' and the conditional comparisons used '== "true"' / '== "false"'.
GitHub Actions coerces typed boolean inputs to actual booleans, so the
string comparisons would never match a workflow_dispatch invocation.

- default: 'true' → default: true (match declared type)
- inputs.dry_run == 'false' → !inputs.dry_run
- inputs.dry_run == 'true' → inputs.dry_run

Validated locally with actionlint (clean).

Refs CodeRabbit findings F-12143-8, F-12143-9, F-12143-10 in
research/reviews/coderabbit-batch-2026-05-12.md.
2026-05-21 14:01:37 -07:00
GitHub Action
ca909b2832 [automated] Apply ESLint and Oxfmt fixes 2026-05-21 14:01:37 -07:00
Connor Byrne
5bf589f8d1 fix(ext-api): unblock CI auto-fix on docgen script
oxlint flags console.log in packages/extension-api/scripts/build-docs.ts
(intentional CLI progress output). The pre-commit lint-staged hook in
ci-lint-format then aborts before the [automated] format commit can be
pushed back, blocking the entire lint-and-format job.

- Add file-level eslint-disable for no-console (CLI build script)
- Whitelist build-docs.ts in eslint.config.ts allowDefaultProject so
  typed-lint does not error on the file outside the tsconfig project graph

Discovered while rebasing #12143 onto the foundation-v2 knip fix
(5b05f2b793).
2026-05-21 14:01:37 -07:00
Connor Byrne
6b6d4773d9 refactor(ext-api): collapse package barrel to canonical surface re-export (review #12143.M1+A9)
The package barrel at packages/extension-api/src/index.ts duplicated the
canonical surface in src/extension-api/index.ts. Two hand-maintained
exports lists drift out of sync — Architect #4's removal of
startExtensionSystem from the public surface, for example, only landed in
the canonical barrel.

Collapse the package barrel to a single 'export * from
@/extension-api/index' re-export so the canonical file is the one source of
truth. Future surface additions/removals flow through automatically; no
risk of asymmetric export sets between the in-tree consumer
(@/extension-api) and the npm package consumer
(@comfyorg/extension-api).
2026-05-21 14:01:37 -07:00
Connor Byrne
a86dc1cc2b feat(extension-api): npm package + TypeDoc docgen + CI workflows
Restratified i-pkg. Adds the @comfyorg/extension-api npm package
(packages/extension-api/) — package.json, tsconfig.{json,build,docs},
typedoc.json, scripts/build-docs.ts, README, .npmignore — plus three
CI workflows (extension-api-typecheck.yml, extension-api-publish.yml,
ci-tests-extension-api.yaml) and the docgen helper script.

Modifies foundation's src/extension-api/index.ts and
src/services/extension-api-service.ts with the package-aware tweaks
required for npm consumption.

Lockfile regenerated to include packages/extension-api workspace deps.

Original (pre-restratify) branch tip backed up at
refs/backup/restratify-20260511/ext-api-i-pkg.
2026-05-21 14:01:37 -07:00
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
Christian Byrne
759ed3d4e2 feat(website): add community-workflows demo page (#11942)
*PR Created by the Glary-Bot Agent*

---

Adds a new interactive demo page at
`comfy.org/demos/community-workflows` for the [Explore and Use a
Community Workflow from the
Hub](https://app.arcade.software/flows/mqZh17oWDuWIyhK0xwEV/view) Arcade
walkthrough.

Built on top of the demo infrastructure merged in #11436.

## Changes

- `apps/website/src/config/demos.ts` — register the new demo
- `apps/website/src/i18n/translations.ts` — add en + zh-CN strings
(title, description, transcript)
- `apps/website/public/images/demos/community-workflows-og.png` —
1200×630 OG image so email/social previews render correctly
- `apps/website/public/images/demos/community-workflows-thumb.webp` —
1280×720 WebP thumbnail
- `apps/website/e2e/demos.spec.ts` — refactored to iterate `demos` from
config so every demo (current + future) is exercised in both en and
zh-CN, and the iframe `src` is asserted to contain the correct Arcade ID

Adding a new demo only requires editing `demos.ts` + `translations.ts`
going forward; the e2e refactor is a one-time generalization that gives
future demos coverage automatically.

## Verification

- `pnpm typecheck:website`: 0 errors, 0 warnings, 0 hints
- Pre-commit hook ran `pnpm typecheck`, `oxfmt`, `oxlint`, `eslint` —
all clean on staged files
- `npx astro build`: 53 pages built; `/demos/community-workflows/` and
`/zh-CN/demos/community-workflows/` generated and present in
`sitemap-0.xml`
- Page rendered in Playwright preview: hero (title, GETTING STARTED,
BEGINNER, ~2 min), Arcade embed loads, transcript section present,
"What's Next" links to `image-to-video`
- zh-CN page shows localized title (探索并使用社区工作流), description, badges,
and "What's Next" heading
- OG meta tag references the new 1200×630 PNG

## Screenshots

![English demo page at /demos/community-workflows showing hero, embedded
Arcade walkthrough, transcript section, and What's Next
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965156-23d92854-3b6a-4470-92fb-86facb28a915.png)

![Chinese localized demo page at /zh-CN/demos/community-workflows with
translated title, category, badges, and
navigation](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9e9c345d23c5dd7674edc13e935c656681feb2a09b0928339594232502fa74db/pr-images/1777945965557-f981dfb3-4080-41e5-b93c-30801b4e1e0c.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11942-feat-website-add-community-workflows-demo-page-3576d73d36508139b647c774b1d39323)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-05-11 12:21:58 -07:00
93 changed files with 12020 additions and 384 deletions

View File

@@ -0,0 +1,88 @@
# Description: Extension API test suite (I-TF) + compat-floor gate (I-TF.7)
#
# Runs on any PR touching extension-api declaration files, extension-api-v2
# implementation/tests, or the touch-point DB/rollup (blast-radius changes).
#
# Two jobs:
# test — vitest run against src/extension-api-v2/__tests__/
# compat-floor — python scripts/check-compat-floor.py (exits 1 if any
# blast_radius ≥ 2.0 category is missing a stub triple)
#
# The compat-floor job is the CI enforcement of PLAN.md §Compat-floor:
# "Every blast_radius ≥ 2.0 pattern MUST pass v1 + v2 + migration before v2 ships."
name: 'CI: Tests Extension API'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extension-api-v2/**'
- 'packages/extension-api/**'
- 'vitest.extension-api.config.mts'
- 'research/touch-points/rollup.yaml'
- 'research/touch-points/behavior-categories.yaml'
- 'scripts/check-compat-floor.py'
- 'pnpm-lock.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Extension API tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run extension-api test suite
run: pnpm test:extension-api
- name: Run with coverage (push only)
if: github.event_name == 'push'
run: pnpm test:extension-api:coverage
- name: Upload coverage to Codecov
if: github.event_name == 'push'
uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3
with:
files: coverage/lcov.info
flags: extension-api
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
compat-floor:
name: Compat-floor gate (blast_radius ≥ 2.0)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.11'
- name: Install PyYAML
run: pip install pyyaml
- name: Check compat floor
run: python3 scripts/check-compat-floor.py
# Exits 1 if any blast_radius ≥ 2.0 behavior category is missing
# any of its three stub files (v1/v2/migration). Enforces PLAN.md §Compat-floor.

View File

@@ -0,0 +1,97 @@
# Description: Publish @comfyorg/extension-api to npm with provenance attestation.
#
# Triggered by a tag push matching 'extension-api-v*' (e.g. extension-api-v0.1.0).
# Also supports workflow_dispatch for a manual dry-run (set dry_run: true).
#
# Prerequisites (one-time human setup):
# - NPM_TOKEN secret must be set in the repo/org settings with publish
# access to the @comfyorg scope on npmjs.com.
# - The @comfyorg npm scope already exists (used by @comfyorg/comfyui-frontend).
#
# PKG4.D4 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Publish'
on:
push:
tags:
- 'extension-api-v*'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run — build and verify without publishing'
required: false
default: true
type: boolean
permissions:
contents: write # needed to create GitHub Release
id-token: write # needed for npm provenance via OIDC
jobs:
publish:
name: Publish @comfyorg/extension-api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # full history for release notes
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Setup npm registry
uses: actions/setup-node@v6
with:
registry-url: 'https://registry.npmjs.org/'
- name: Build package
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Verify package version matches tag
if: github.event_name == 'push'
run: |
TAG="${GITHUB_REF_NAME}" # e.g. extension-api-v0.1.0
PKG_VERSION=$(node -p "require('./packages/extension-api/package.json').version")
TAG_VERSION="${TAG#extension-api-v}" # strip prefix → 0.1.0
if [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
echo "::error::Tag '$TAG' implies version '$TAG_VERSION' but packages/extension-api/package.json has '$PKG_VERSION'. Update the package.json before tagging."
exit 1
fi
echo "Version check passed: $PKG_VERSION"
- name: Publish to npm (with provenance)
if: github.event_name == 'push' || !inputs.dry_run
run: |
cd packages/extension-api
npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Dry-run report
if: inputs.dry_run
run: |
echo "=== DRY RUN — would publish ==="
cd packages/extension-api
npm pack --dry-run
echo "=== End dry run ==="
- name: Create GitHub Release
if: github.event_name == 'push'
uses: actions/github-script@v8
with:
script: |
const tag = context.ref.replace('refs/tags/', '')
const { data: release } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: tag,
generate_release_notes: true,
draft: false,
prerelease: context.ref.includes('-alpha') || context.ref.includes('-beta') || context.ref.includes('-rc')
})
console.log(`Release created: ${release.html_url}`)

View File

@@ -0,0 +1,65 @@
# Description: Typecheck and build the @comfyorg/extension-api package.
# Runs on PRs and pushes touching the public type surface, the core .v2.ts
# implementations, or the package scaffold — so regressions in the published
# contract are caught before merge.
#
# PKG4.D3 (MIG1 / Phase A — surface-only shim)
name: 'Extension API: Typecheck'
on:
push:
branches: [main, master, dev*, core/*, extension-v2*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
pull_request:
branches-ignore: [wip/*, draft/*, temp/*]
paths:
- 'src/extension-api/**'
- 'src/extensions/core/*.v2.ts'
- 'src/services/extension-api-service.ts'
- 'packages/extension-api/**'
- '.github/workflows/extension-api-*.yml'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
typecheck:
name: Build + typecheck @comfyorg/extension-api
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build package (emit declarations)
run: pnpm --filter @comfyorg/extension-api build
- name: Typecheck package
run: pnpm --filter @comfyorg/extension-api typecheck
- name: Smoke-test consumer (tsc --noEmit on minimal extension)
# Verifies the published types are consumable from an external module
# that imports from '@comfyorg/extension-api'. Uses a minimal fixture
# checked in to packages/extension-api/test/smoke/.
run: |
cd packages/extension-api
if [ -d test/smoke ]; then
pnpm exec tsc --noEmit --project test/smoke/tsconfig.json
else
echo "No smoke test found — skipping (add packages/extension-api/test/smoke/ to enable)"
fi

View File

@@ -6,6 +6,7 @@
"trailingComma": "none",
"printWidth": 80,
"ignorePatterns": [
"packages/extension-api/build/**",
"packages/registry-types/src/comfyRegistryTypes.ts",
"public/materialdesignicons.min.css",
"src/types/generatedManagerTypes.ts",

View File

@@ -1,27 +1,71 @@
import { expect, test } from '@playwright/test'
import { demos, getNextDemo } from '../src/config/demos'
import { t } from '../src/i18n/translations'
const escapeRegExp = (value: string): string =>
value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
test.describe('Demo pages @smoke', () => {
test('demo detail page renders hero and embed', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'Create a Video from an Image'
)
const iframe = page.locator('iframe[title*="Interactive demo"]')
await expect(iframe).toBeAttached()
})
for (const demo of demos) {
const nextDemo = getNextDemo(demo.slug)
test('demo detail page has transcript section', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
})
test(`/demos/${demo.slug} renders hero, embed, transcript, and next-demo nav`, async ({
page
}) => {
await page.goto(`/demos/${demo.slug}`)
test('demo detail page has next demo navigation', async ({ page }) => {
await page.goto('/demos/image-to-video')
await expect(page.getByText(/what's next/i)).toBeVisible()
})
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
await expect(heading).toContainText(t(demo.title, 'en'))
const ogImage = page.locator('head meta[property="og:image"]')
await expect(ogImage).toHaveAttribute(
'content',
new RegExp(`${escapeRegExp(demo.slug)}-og\\.png`)
)
const iframe = page.locator(
`iframe[title*="${t('demos.embed.label', 'en')}"]`
)
await expect(iframe).toBeAttached()
await expect(iframe).toHaveAttribute(
'src',
new RegExp(escapeRegExp(demo.arcadeId))
)
await expect(
page.getByRole('button', { name: /demo transcript/i })
).toBeVisible()
await expect(
page.getByText(t(nextDemo.title, 'en')).first()
).toBeVisible()
const nextThumb = page.locator(`img[src="${nextDemo.thumbnail}"]`).first()
await expect(nextThumb).toBeAttached()
await expect(nextThumb).toBeVisible()
const naturalWidth = await nextThumb.evaluate(
(img) => (img as HTMLImageElement).naturalWidth
)
expect(naturalWidth).toBeGreaterThan(1)
})
test(`/zh-CN/demos/${demo.slug} renders localized content`, async ({
page
}) => {
await page.goto(`/zh-CN/demos/${demo.slug}`)
await expect(page).toHaveURL(/\/zh-CN\/demos\//)
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toContainText(t(demo.title, 'zh-CN'))
await expect(heading).toContainText(/[\u4E00-\u9FFF]/)
await expect(
page.getByText(t(nextDemo.title, 'zh-CN')).first()
).toBeVisible()
})
}
test('demo library page renders', async ({ page }) => {
await page.goto('/demos')
@@ -32,13 +76,4 @@ test.describe('Demo pages @smoke', () => {
const response = await page.goto('/demos/nonexistent')
expect(response?.status()).toBe(404)
})
test('zh-CN demo page renders localized content', async ({ page }) => {
await page.goto('/zh-CN/demos/image-to-video')
await expect(page.getByRole('heading', { level: 1 })).toContainText(
'从图片创建视频'
)
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
await expect(nextDemoLink).toBeAttached()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -8,10 +8,12 @@ import { t } from '../../i18n/translations'
const {
arcadeId,
title,
aspectRatio = 16 / 9,
locale = 'en'
} = defineProps<{
arcadeId: string
title: string
aspectRatio?: number
locale?: Locale
}>()
@@ -24,7 +26,8 @@ const loaded = ref(false)
:aria-label="t('demos.embed.label', locale)"
>
<div
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
class="relative mx-auto max-w-6xl overflow-hidden rounded-4xl border border-white/10"
:style="{ aspectRatio }"
>
<div
v-if="!loaded"

View File

@@ -15,6 +15,14 @@ interface Demo {
readonly transcript?: TranslationKey
readonly publishedDate: string
readonly modifiedDate: string
/**
* Width / height of the Arcade demo's source recording (e.g. 1.93 for a
* landscape screencast). Sizes the embed container to match so rounded
* corners hug the content instead of empty letterbox space. Source from
* Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
* invert it). Defaults to 16/9 if omitted.
*/
readonly aspectRatio?: number
}
export const demos: readonly Demo[] = [
@@ -32,7 +40,8 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['templates', 'image', 'video'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'workflow-templates',
@@ -48,7 +57,25 @@ export const demos: readonly Demo[] = [
difficulty: 'beginner',
tags: ['getting-started', 'templates', 'workflow'],
publishedDate: '2026-04-19',
modifiedDate: '2026-04-19'
modifiedDate: '2026-04-19',
aspectRatio: 1.931
},
{
slug: 'community-workflows',
arcadeId: 'mqZh17oWDuWIyhK0xwEV',
category: 'demos.category.gettingStarted',
title: 'demos.community-workflows.title',
description: 'demos.community-workflows.description',
transcript: 'demos.community-workflows.transcript',
ogImage: '/images/demos/community-workflows-og.png',
thumbnail: '/images/demos/community-workflows-thumb.webp',
estimatedTime: 'demos.duration.2min',
durationIso: 'PT2M',
difficulty: 'beginner',
tags: ['getting-started', 'community', 'workflow', 'hub'],
publishedDate: '2026-05-04',
modifiedDate: '2026-05-04',
aspectRatio: 1.931
}
]

View File

@@ -3570,6 +3570,20 @@ const translations = {
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
},
'demos.community-workflows.title': {
en: 'Explore and Use a Community Workflow from the Hub',
'zh-CN': '探索并使用社区工作流'
},
'demos.community-workflows.description': {
en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
},
'demos.community-workflows.transcript': {
en: '<ol><li><strong>Open the Workflow Hub</strong> — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.</li><li><strong>Browse popular workflows</strong> — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.</li><li><strong>Preview a workflow</strong> — Click a workflow card to see example outputs, required models, and a description of what it produces.</li><li><strong>Open in ComfyUI</strong> — Use the "Get Started" action to load the selected community workflow directly onto your canvas.</li><li><strong>Run and customize</strong> — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.</li></ol>',
'zh-CN':
'<ol><li><strong>打开工作流中心</strong> — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。</li><li><strong>浏览热门工作流</strong> — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。</li><li><strong>预览工作流</strong> — 点击工作流卡片,查看示例输出、所需模型和功能描述。</li><li><strong>在 ComfyUI 中打开</strong> — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。</li><li><strong>运行并自定义</strong> — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。</li></ol>'
},
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },

View File

@@ -121,6 +121,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
client:load
/>

View File

@@ -122,6 +122,7 @@ const breadcrumbJsonLd = {
<ArcadeEmbed
arcadeId={demo.arcadeId}
title={title}
aspectRatio={demo.aspectRatio}
locale="zh-CN"
client:load
/>

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

@@ -103,7 +103,9 @@ export default defineConfig([
projectService: {
allowDefaultProject: [
'vite.electron.config.mts',
'vite.types.config.mts'
'vite.types.config.mts',
'packages/extension-api/scripts/build-docs.ts',
'packages/extension-api/vite.config.mts'
]
}
}

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/**']
@@ -32,6 +36,12 @@ const config: KnipConfig = {
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
},
'packages/extension-api': {
// Build output is committed for npm package visibility
ignore: ['build/**'],
// typedoc is invoked via execSync in scripts/build-docs.ts
ignoreDependencies: ['typedoc']
},
'apps/website': {
entry: ['src/scripts/**/*.ts']
}
@@ -60,7 +70,30 @@ 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',
// Strangler-pattern v2 conversions of core extensions. Not yet wired
// into the bootstrap (registration lands in a follow-up PR alongside
// the v1→v2 cut-over). Tracked by I-EXT (#12144).
'src/extensions/core/noteNode.v2.ts',
'src/extensions/core/rerouteNode.v2.ts',
'src/extensions/core/slotDefaults.v2.ts',
// W6.P3.D — defineWidget+mount showcase port (D-widget-converge / A12).
'src/extensions/core/webcamCapture.v2.ts',
// W6.P4.D — canvas-units canary + escape-hatch annotation example
// (D-coord-space / A13).
'src/extensions/core/coordSpaceDemo.v2.ts'
],
vite: {
config: ['vite?(.*).config.mts']
@@ -79,7 +112,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

@@ -31,7 +31,12 @@ export default {
}
function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
// Exclude package build directories from linting
const filtered = fileNames.filter(
(f) => !f.includes('/packages/') || !f.includes('/build/')
)
if (filtered.length === 0) return []
const joinedPaths = toJoinedRelativePaths(filtered)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
`pnpm exec oxlint --fix ${joinedPaths}`,

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",

2
packages/extension-api/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
docs-build/
node_modules/

View File

@@ -0,0 +1,9 @@
src/
scripts/
tsconfig*.json
typedoc.json
docs-build/
*.test.ts
*.spec.ts
__tests__/
node_modules/

View File

@@ -0,0 +1,50 @@
# @comfyorg/extension-api
> **Status**: scaffolded. Package implementation pending PKG3 — see
> `../../../plans/P2-extension-api-package.md` and
> `../../../plans/prompts/PKG3-npm-package.md` in the workspace root.
The official TypeScript declaration package for ComfyUI extensions. This
package replaces the practice of vendoring `comfy.d.ts` files in custom
node repos.
## Install (post-publish)
```bash
pnpm add -D @comfyorg/extension-api
```
```ts
import { defineExtension } from '@comfyorg/extension-api'
export default defineExtension({
name: 'MyExtension',
setup(ctx) {
ctx.onNodeMounted((node) => {
// ...
})
}
})
```
## Source
This package is built from the source-of-truth folder
`../../src/extension-api/`. Do not edit the package's `build/` output
directly.
## Versioning
- `0.x.y` — experimental during parallel-paths transition (D6 Phase A).
- `1.0.0` — first stable release once D5/D6/D7/D8 are accepted and the
surface has stabilized.
- Breaking changes follow semver strictly from `1.0.0` onward.
## Cross-references
- `decisions/D6-parallel-paths-migration.md` — versioning rationale
- `plans/P2-extension-api-package.md` — package structure plan
- `plans/prompts/PKG3-npm-package.md` — implementation prompt
- `plans/prompts/PKG4-ci-workflows.md` — publish workflow
- `plans/prompts/PKG5-docgen-mdx.md` — docgen pipeline
- `plans/prompts/PKG6-docs-comfy-org.md` — docs.comfy.org integration

View File

@@ -0,0 +1,2 @@
index.js
index.js.map

1254
packages/extension-api/build/index.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"name": "@comfyorg/extension-api",
"version": "0.1.0",
"description": "Official TypeScript extension API for ComfyUI custom nodes",
"files": [
"build",
"README.md"
],
"type": "module",
"types": "./build/index.d.ts",
"exports": {
".": {
"types": "./build/index.d.ts",
"import": "./build/index.js",
"default": "./build/index.js"
}
},
"scripts": {
"typecheck": "vite build --logLevel warn",
"build": "vite build",
"docs:build": "tsx scripts/build-docs.ts",
"docs:watch": "tsx scripts/build-docs.ts --watch"
},
"devDependencies": {
"tsx": "catalog:",
"typedoc": "0.28.19",
"typedoc-plugin-markdown": "^4.6.3",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-dts": "catalog:"
},
"peerDependencies": {
"vue": "catalog:"
},
"nx": {
"tags": [
"scope:shared",
"type:api"
]
}
}

View File

@@ -0,0 +1,495 @@
#!/usr/bin/env tsx
/* eslint-disable no-console -- CLI build script; stdout progress is intentional */
/**
* PKG5 docgen pipeline: TypeDoc → Mintlify MDX
*
* Steps:
* 1. Run TypeDoc with typedoc-plugin-markdown to emit raw markdown into docs-build/raw/
* 2. Post-process each markdown file:
* - Add Mintlify frontmatter (title, description, sidebarTitle, icon)
* - Convert ``` fences without lang tag → ```ts
* - Replace raw [TypeName] cross-refs with MDX relative links
* - Wrap @example blocks in proper code fences
* 3. Write final .mdx files to docs-build/mintlify/
* 4. Emit docs-build/mintlify/nav-snippet.json — merges into docs.comfy.org mint.json
*
* Run: pnpm --filter @comfyorg/extension-api docs:build
*/
import { execSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const pkgRoot = path.resolve(__dirname, '..')
const rawDir = path.join(pkgRoot, 'docs-build', 'raw')
const mintlifyDir = path.join(pkgRoot, 'docs-build', 'mintlify')
const watchMode = process.argv.includes('--watch')
// ── Page metadata ────────────────────────────────────────────────────────────
// Controls frontmatter for each generated page. Key = TypeDoc output filename
// stem (lowercased). Unrecognised files get generic metadata.
interface PageMeta {
title: string
sidebarTitle?: string
description: string
icon?: string
group: 'core' | 'handles' | 'events' | 'shell' | 'identity' | 'root'
order: number
}
const PAGE_META: Record<string, PageMeta> = {
// Top-level overview
index: {
title: 'Extension API Overview',
description: 'TypeScript API reference for ComfyUI custom node extensions.',
icon: 'puzzle-piece',
group: 'root',
order: 0
},
// Lifecycle / registration
defineextension: {
title: 'defineExtension',
description:
'Register an app-scoped extension for init, setup, and shell UI contributions.',
icon: 'code',
group: 'core',
order: 1
},
definenodeextension: {
title: 'defineNodeExtension',
description:
'Register a node-scoped extension reacting to node lifecycle events.',
icon: 'code',
group: 'core',
order: 2
},
definewidgetextension: {
title: 'defineWidgetExtension',
description: 'Register a custom widget type with its own DOM rendering.',
icon: 'code',
group: 'core',
order: 3
},
extensionoptions: {
title: 'ExtensionOptions',
description:
'Options object for defineExtension — app-wide lifecycle and shell UI.',
group: 'core',
order: 4
},
nodeextensionoptions: {
title: 'NodeExtensionOptions',
description:
'Options object for defineNodeExtension — node lifecycle hooks.',
group: 'core',
order: 5
},
widgetextensionoptions: {
title: 'WidgetExtensionOptions',
description:
'Options object for defineWidgetExtension — custom widget rendering.',
group: 'core',
order: 6
},
onnoderemoved: {
title: 'onNodeRemoved',
sidebarTitle: 'onNodeRemoved',
description:
'Implicit-context lifecycle hook: fires when a node is removed from the graph.',
group: 'core',
order: 7
},
onnodemounted: {
title: 'onNodeMounted',
sidebarTitle: 'onNodeMounted',
description:
'Implicit-context lifecycle hook: fires when a node is fully mounted.',
group: 'core',
order: 8
},
// Handles
nodehandle: {
title: 'NodeHandle',
description:
'Controlled access to node state, mutations, slots, and events.',
icon: 'circle-nodes',
group: 'handles',
order: 10
},
widgethandle: {
title: 'WidgetHandle',
description: 'Controlled access to widget state, mutations, and events.',
icon: 'sliders',
group: 'handles',
order: 11
},
slotinfo: {
title: 'SlotInfo',
description: 'Read-only snapshot of a node slot (input or output).',
group: 'handles',
order: 12
},
// Events
nodeexecutedevent: {
title: 'NodeExecutedEvent',
description: 'Payload fired when a node finishes execution.',
group: 'events',
order: 20
},
nodeconnectedevent: {
title: 'NodeConnectedEvent',
description: 'Payload fired when a slot connection is made.',
group: 'events',
order: 21
},
nodedisconnectedevent: {
title: 'NodeDisconnectedEvent',
description: 'Payload fired when a slot connection is removed.',
group: 'events',
order: 22
},
nodepositionchangedevent: {
title: 'NodePositionChangedEvent',
description: 'Payload fired when a node is moved on the canvas.',
group: 'events',
order: 23
},
nodesizechangedevent: {
title: 'NodeSizeChangedEvent',
description: 'Payload fired when a node is resized.',
group: 'events',
order: 24
},
nodemodechangedevent: {
title: 'NodeModeChangedEvent',
description: 'Payload fired when a node execution mode changes.',
group: 'events',
order: 25
},
nodebeforeserializeevent: {
title: 'NodeBeforeSerializeEvent',
description: 'Pre-serialization hook payload — override or skip node data.',
group: 'events',
order: 26
},
widgetvaluechangeevent: {
title: 'WidgetValueChangeEvent',
description: 'Payload fired when a widget value changes.',
group: 'events',
order: 27
},
widgetbeforeserializeevent: {
title: 'WidgetBeforeSerializeEvent',
description:
'Pre-serialization hook payload — override or skip widget value.',
group: 'events',
order: 28
},
widgetbeforequeueevent: {
title: 'WidgetBeforeQueueEvent',
description:
'Pre-queue validation payload — call reject() to cancel queue.',
group: 'events',
order: 29
},
// Shell UI
sidebartabextension: {
title: 'SidebarTabExtension',
description: 'Register a custom sidebar tab.',
group: 'shell',
order: 40
},
bottompanelextension: {
title: 'BottomPanelExtension',
description: 'Register a custom bottom panel tab.',
group: 'shell',
order: 41
},
toastmanager: {
title: 'ToastManager',
description: 'Show toast notifications to the user.',
group: 'shell',
order: 42
},
commandmanager: {
title: 'CommandManager',
description: 'Register keyboard shortcuts and command palette entries.',
group: 'shell',
order: 43
},
extensionmanager: {
title: 'ExtensionManager',
description: 'Access shell UI registration APIs.',
group: 'shell',
order: 44
},
// Identity
nodelocatorid: {
title: 'NodeLocatorId',
description:
'Branded string ID that uniquely locates a node across graph snapshots.',
group: 'identity',
order: 50
},
nodeexecutionid: {
title: 'NodeExecutionId',
description: 'Branded string ID for a specific node execution run.',
group: 'identity',
order: 51
}
}
const GROUP_LABELS: Record<PageMeta['group'], string> = {
root: 'Extensions API',
core: 'Registration',
handles: 'Handles',
events: 'Events',
shell: 'Shell UI',
identity: 'Identity'
}
// ── Utilities ────────────────────────────────────────────────────────────────
function slug(stem: string): string {
return stem
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function metaFor(stem: string): PageMeta {
const key = stem.toLowerCase().replace(/[^a-z]/g, '')
return (
PAGE_META[key] ?? {
title: stem,
description: `API reference for ${stem}.`,
group: 'core',
order: 99
}
)
}
/** Convert TypeDoc raw markdown to Mintlify-compatible MDX. */
function toMintlifyMdx(raw: string, stem: string): string {
const meta = metaFor(stem)
// Build frontmatter
const fm: string[] = [
`---`,
`title: "${meta.title}"`,
...(meta.sidebarTitle ? [`sidebarTitle: "${meta.sidebarTitle}"`] : []),
`description: "${meta.description}"`,
...(meta.icon ? [`icon: "${meta.icon}"`] : []),
`---`
]
let body = raw
// Strip TypeDoc breadcrumb header lines (e.g. "[**@comfyorg/...**](../index.md)\n\n***\n\n[@comfyorg...]...")
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\)\n+\*+\n+/gm, '')
body = body.replace(/^\[.*?\]\(\.\.\/index\.md\).*\n+/gm, '')
// Remove the TypeDoc-generated H1 (we use frontmatter title instead)
body = body.replace(/^# .+\n+/, '')
// Ensure opening code fences that have no lang tag get `ts`
// Only match a ``` that is immediately followed by a newline (opening fence),
// not a closing fence (which also has just ``` + newline but we can detect
// by context: opening fences follow non-fence lines; closing fences follow content).
// Simpler heuristic: replace ``` at start of line only when not already closing a block.
// We track state via a flag pass instead of a single regex.
let inBlock = false
body = body
.split('\n')
.map((line) => {
if (inBlock) {
if (line.trim() === '```') {
inBlock = false
return line
}
return line
}
if (line.startsWith('```')) {
if (line.trim() === '```') {
// bare opening fence → add ts
inBlock = true
return '```ts'
}
// has a lang tag already
inBlock = true
return line
}
return line
})
.join('\n')
// TypeDoc emits `typescript` lang tag; normalize to `ts`
body = body.replace(/^```typescript\b/gm, '```ts')
// Fix TypeDoc cross-ref links: [TypeName](../type-alias/TypeName.md) → relative MDX paths
// Pattern: [Label](../category/FileName.md) → [Label](./filename)
body = body.replace(
/\[([^\]]+)\]\(\.\.\/([\w-]+)\/([\w-]+)\.md\)/g,
(_match, label, _category, file) => `[${label}](./${slug(file)})`
)
// Same-dir links
body = body.replace(
/\[([^\]]+)\]\(([\w-]+)\.md\)/g,
(_match, label, file) => `[${label}](./${slug(file)})`
)
// TypeDoc wraps @example content in a "## Example" heading; Mintlify prefers
// code examples to be directly under prose without a sub-heading.
// Flatten "## Example\n\n```ts" → "```ts"
body = body.replace(/^## Example\s*\n+/gm, '')
// Stability tags: render as a <Tip> callout
body = body.replace(
/\*\*Stability\*\*: `(stable|experimental|deprecated)`/g,
(_match, level) => {
const label =
level === 'stable'
? '<Tip>**Stability:** Stable — part of the public API contract.</Tip>'
: level === 'experimental'
? '<Warning>**Stability:** Experimental — may change before 1.0.</Warning>'
: '<Warning>**Stability:** Deprecated — will be removed. See migration guide.</Warning>'
return label
}
)
// @stability TSDoc tag (appears as plain text after TypeDoc strips tags)
body = body.replace(
/^Stability: (stable|experimental|deprecated)\s*$/gm,
(_match, level) => {
if (level === 'stable') return '<Tip>**Stability:** Stable</Tip>'
if (level === 'experimental')
return '<Warning>**Stability:** Experimental</Warning>'
return '<Warning>**Stability:** Deprecated</Warning>'
}
)
return [...fm, '', body.trim(), ''].join('\n')
}
// ── Nav snippet builder ───────────────────────────────────────────────────────
interface NavPage {
group?: string
pages: (string | NavPage)[]
}
function buildNavSnippet(stems: string[]): NavPage {
// Sort stems by order then group by category
const sortedStems = stems
.slice()
.sort((a, b) => metaFor(a).order - metaFor(b).order)
const sortedByGroup: Record<string, string[]> = {}
for (const stem of sortedStems) {
const group = metaFor(stem).group
if (!sortedByGroup[group]) sortedByGroup[group] = []
sortedByGroup[group].push(`extensions/api/${slug(stem)}`)
}
const groupOrder: PageMeta['group'][] = [
'root',
'core',
'handles',
'events',
'shell',
'identity'
]
const pages: (string | NavPage)[] = []
// Overview at top level
if (sortedByGroup['root']) {
for (const p of sortedByGroup['root']) pages.push(p)
}
for (const grp of groupOrder) {
if (grp === 'root') continue
const grpPages = sortedByGroup[grp]
if (!grpPages?.length) continue
pages.push({ group: GROUP_LABELS[grp], pages: grpPages })
}
return { group: 'Extensions API', pages }
}
// ── Main pipeline ────────────────────────────────────────────────────────────
function runTypedoc(): void {
console.log('▶ Running TypeDoc...')
execSync(
`pnpm exec typedoc --options ${path.join(pkgRoot, 'typedoc.json')} --out ${rawDir}`,
{ cwd: pkgRoot, stdio: 'inherit' }
)
}
function processFiles(): void {
if (!fs.existsSync(rawDir)) {
throw new Error(`TypeDoc output directory not found: ${rawDir}`)
}
fs.mkdirSync(mintlifyDir, { recursive: true })
const mdFiles = fs
.readdirSync(rawDir, { recursive: true })
.filter((f): f is string => typeof f === 'string' && f.endsWith('.md'))
const stems: string[] = []
for (const relPath of mdFiles) {
const src = path.join(rawDir, relPath)
const stem = path.basename(relPath, '.md')
const raw = fs.readFileSync(src, 'utf8')
const mdx = toMintlifyMdx(raw, stem)
const destName = slug(stem) + '.mdx'
const dest = path.join(mintlifyDir, destName)
fs.writeFileSync(dest, mdx)
console.log(`${relPath} → mintlify/${destName}`)
stems.push(stem)
}
// Write nav snippet
const nav = buildNavSnippet(stems)
const navDest = path.join(mintlifyDir, 'nav-snippet.json')
fs.writeFileSync(navDest, JSON.stringify(nav, null, 2) + '\n')
console.log(` ✔ nav-snippet.json`)
console.log(`\n✅ Mintlify MDX written to: ${mintlifyDir}`)
console.log(` ${stems.length} pages + nav-snippet.json`)
}
function run(): void {
runTypedoc()
processFiles()
}
if (watchMode) {
// Simple watch: re-run on change to source files
console.log('👁 Watch mode — watching src/extension-api/**')
const srcDir = path.resolve(pkgRoot, '../../src/extension-api')
let debounce: ReturnType<typeof setTimeout> | null = null
run()
fs.watch(srcDir, { recursive: true }, () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
console.log('\n🔄 Source changed — rebuilding...')
try {
run()
} catch (e) {
console.error(e)
}
}, 500)
})
} else {
run()
}

View File

@@ -0,0 +1,17 @@
/**
* @comfyorg/extension-api — Public Extension API for ComfyUI
*
* This is the package entry point compiled to `build/index.js` + `build/index.d.ts`.
* It is a single re-export of the canonical surface defined in
* `src/extension-api/index.ts` in the main app — that file is the one source
* of truth for what is part of the stable, semver-versioned public contract.
*
* Do NOT add exports here. Add them to `src/extension-api/index.ts` and they
* will flow through this barrel automatically.
*
* The tsconfig.json `paths` alias `@/*` → `../../src/*` resolves the import
* below at both typecheck and build time.
*
* @packageDocumentation
*/
export * from '@/extension-api/index'

View File

@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "ES2023.Array", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"declaration": true,
"declarationMap": false,
"noEmit": false,
"outDir": "./build",
"paths": {
"@/*": ["../../src/*"],
"@/utils/formatUtil": [
"../../packages/shared-frontend-utils/src/formatUtil.ts"
],
"@/utils/networkUtil": [
"../../packages/shared-frontend-utils/src/networkUtil.ts"
]
}
},
"include": [
"../../src/**/*.ts",
"../../src/types/litegraph-augmentation.d.ts",
"../../global.d.ts"
],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue",
"**/*.test.ts",
"**/*.spec.ts",
"scripts/**"
]
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["../../src/*"]
}
},
"include": ["../../src/extension-api/**/*.ts"],
"exclude": [
"../../src/**/*.test.ts",
"../../src/**/*.spec.ts",
"../../src/**/*.vue"
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"noEmit": true,
"declaration": false,
"declarationMap": false
}
}

View File

@@ -0,0 +1,45 @@
{
"entryPoints": ["../../src/extension-api/index.ts"],
"tsconfig": "./tsconfig.docs.json",
"out": "./docs-build/raw",
"plugin": ["typedoc-plugin-markdown"],
"excludeInternal": true,
"excludePrivate": true,
"excludeProtected": true,
"readme": "none",
"skipErrorChecking": true,
"githubPages": false,
"blockTags": [
"@stability",
"@packageDocumentation",
"@example",
"@typeParam",
"@returns",
"@deprecated",
"@remarks"
],
"hideGenerator": true,
"useCodeBlocks": true,
"flattenOutputFiles": false,
"entryFileName": "index",
"fileExtension": ".md",
"outputFileStrategy": "members",
"hidePageHeader": false,
"hideBreadcrumbs": false,
"useHTMLAnchors": false,
"sanitizeComments": true,
"expandObjects": false,
"parametersFormat": "table",
"propertiesFormat": "table",
"typeDeclarationFormat": "table",
"indexFormat": "table",
"tableColumnSettings": {
"hideDefaults": false,
"hideInherited": false,
"hideModifiers": false,
"hideOverrides": false,
"hideSources": true,
"hideValues": false,
"leftAlignHeaders": false
}
}

View File

@@ -0,0 +1,74 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import dts from 'vite-plugin-dts'
const here = fileURLToPath(new URL('.', import.meta.url))
const repoRoot = resolve(here, '..', '..')
const repoSrc = resolve(repoRoot, 'src')
const surfaceRoot = resolve(repoSrc, 'extension-api')
/**
* Library build for `@comfyorg/extension-api`.
*
* Per ADR D17 (PKG2 build strategy), the package is built from the canonical
* surface defined in the main app at `src/extension-api/index.ts`. Vite
* resolves the `@/*` aliases against the main app's `src/` directory and
* emits a single bundled `index.js` plus a single bundled `index.d.ts`.
*
* The package barrel at `packages/extension-api/src/index.ts` is the
* Vite entry point and re-exports `@/extension-api/index` — preserving
* "the barrel is the source of truth in main app `src/extension-api/`"
* intent in `packages/extension-api/AGENTS.md`.
*
* Vue is externalized as a peer dependency (per D6.1 Phase A — extension
* authors share the host app's Vue runtime).
*/
export default defineConfig({
resolve: {
alias: {
'@/utils/formatUtil': resolve(
repoRoot,
'packages/shared-frontend-utils/src/formatUtil.ts'
),
'@/utils/networkUtil': resolve(
repoRoot,
'packages/shared-frontend-utils/src/networkUtil.ts'
),
'@': repoSrc
}
},
build: {
outDir: resolve(here, 'build'),
emptyOutDir: true,
sourcemap: true,
target: 'es2022',
minify: false,
lib: {
// Build directly from the canonical surface in the main app — the
// package's own `src/index.ts` exists only as a documented entry
// point that re-exports the same surface, but we point Vite at the
// canonical file so dts paths line up cleanly with the JS bundle.
entry: resolve(surfaceRoot, 'index.ts'),
formats: ['es'],
fileName: () => 'index.js'
},
rollupOptions: {
// Vue is provided by the host app at runtime.
external: ['vue', /^@vue\//]
}
},
plugins: [
dts({
// Bundle all types into a single index.d.ts. This ensures the package
// is self-contained and doesn't reference paths outside build/.
rollupTypes: true,
outDir: resolve(here, 'build'),
tsconfigPath: resolve(here, 'tsconfig.build.json'),
logLevel: 'warn',
// Only include the extension-api surface, not the entire app
include: [resolve(surfaceRoot, '**/*.ts')]
})
]
})

420
pnpm-lock.yaml generated
View File

@@ -650,7 +650,7 @@ importers:
version: 22.6.1(@babel/traverse@7.29.0)(@zkochan/js-yaml@0.0.7)(eslint@9.39.1(jiti@2.6.1))(nx@22.6.1)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
'@nx/vite':
specifier: 'catalog:'
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
version: 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)
'@pinia/testing':
specifier: 'catalog:'
version: 1.0.3(pinia@3.0.4(typescript@5.9.3)(vue@3.5.13(typescript@5.9.3)))
@@ -662,7 +662,7 @@ importers:
version: 4.6.0
'@storybook/addon-docs':
specifier: 'catalog:'
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/addon-mcp':
specifier: 'catalog:'
version: 0.1.6(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
@@ -671,10 +671,10 @@ importers:
version: 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
'@storybook/vue3-vite':
specifier: 'catalog:'
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
version: 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@testing-library/jest-dom':
specifier: 'catalog:'
version: 6.9.1
@@ -704,7 +704,7 @@ importers:
version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vitest/coverage-v8':
specifier: 'catalog:'
version: 4.0.16(vitest@4.0.16)
@@ -842,19 +842,19 @@ importers:
version: 11.1.0
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-dts:
specifier: 'catalog:'
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-html:
specifier: 'catalog:'
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
version: 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue-component-type-helpers:
specifier: 'catalog:'
version: 3.2.6
@@ -912,10 +912,10 @@ importers:
devDependencies:
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
version: 6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
dotenv:
specifier: 'catalog:'
version: 16.6.1
@@ -927,13 +927,13 @@ importers:
version: 30.0.0(@babel/parser@7.29.0)(vue@3.5.13(typescript@5.9.3))
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-html:
specifier: 'catalog:'
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-devtools:
specifier: 'catalog:'
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
version: 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
vue-tsc:
specifier: 'catalog:'
version: 3.2.5(typescript@5.9.3)
@@ -982,16 +982,16 @@ importers:
version: 0.9.8(prettier@3.7.4)(typescript@5.9.3)
'@astrojs/vue':
specifier: 'catalog:'
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)
version: 5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0)
'@playwright/test':
specifier: 'catalog:'
version: 1.58.1
'@tailwindcss/vite':
specifier: 'catalog:'
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
version: 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
astro:
specifier: 'catalog:'
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
version: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0)
tailwindcss:
specifier: 'catalog:'
version: 4.2.0
@@ -1003,7 +1003,7 @@ importers:
version: 5.9.3
vitest:
specifier: 'catalog:'
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
packages/design-system:
dependencies:
@@ -1030,6 +1030,31 @@ importers:
specifier: 'catalog:'
version: 5.9.3
packages/extension-api:
dependencies:
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
devDependencies:
tsx:
specifier: 'catalog:'
version: 4.19.4
typedoc:
specifier: 0.28.19
version: 0.28.19(typescript@5.9.3)
typedoc-plugin-markdown:
specifier: ^4.6.3
version: 4.11.0(typedoc@0.28.19(typescript@5.9.3))
typescript:
specifier: 'catalog:'
version: 5.9.3
vite:
specifier: ^8.0.0
version: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-dts:
specifier: 'catalog:'
version: 4.5.4(@types/node@25.0.3)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
packages/ingest-types:
dependencies:
zod:
@@ -2431,6 +2456,9 @@ packages:
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
'@gerrit0/mini-shiki@3.23.0':
resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==}
'@grpc/grpc-js@1.9.15':
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
engines: {node: ^8.13.0 || >=10.10.0}
@@ -5453,6 +5481,10 @@ packages:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -7624,6 +7656,9 @@ packages:
resolution: {integrity: sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==}
engines: {node: '>=16.14'}
lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -7864,6 +7899,10 @@ packages:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
@@ -9343,6 +9382,19 @@ packages:
typed-binary@4.3.2:
resolution: {integrity: sha512-HT3pIBM2njCZUmeczDaQUUErGiM6GXFCqMsHegE12HCoBtvHCkfR10JJni0TeGOTnLilTd6YFyj+YhflqQDrDQ==}
typedoc-plugin-markdown@4.11.0:
resolution: {integrity: sha512-2iunh2ALyfyh204OF7h2u0kuQ84xB3jFZtFyUr01nThJkLvR8oGGSSDlyt2gyO4kXhvUxDcVbO0y43+qX+wFbw==}
engines: {node: '>= 18'}
peerDependencies:
typedoc: 0.28.x
typedoc@0.28.19:
resolution: {integrity: sha512-wKh+lhdmMFivMlc6vRRcMGXeGEHGU2g8a2CkPTJjJlwRf1iXbimWIPcFolCqe4E0d/FRtGszpIrsp3WLpDB8Pw==}
engines: {node: '>= 18', pnpm: '>= 10'}
hasBin: true
peerDependencies:
typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x
typegpu@0.8.2:
resolution: {integrity: sha512-wkMJWhJE0pSkw2G/FesjqjbtHkREyOKu1Zmyj19xfmaX5+65YFwgfQNKSK8CxqN4kJkP7JFelLDJTSYY536TYg==}
engines: {node: '>=12.20.0'}
@@ -10186,6 +10238,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -10467,14 +10524,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.8.2)':
'@astrojs/vue@5.1.4(@types/node@25.0.3)(astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0))(esbuild@0.27.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(vue@3.5.13(typescript@5.9.3))(yaml@2.9.0)':
dependencies:
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue': 5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx': 4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/compiler-sfc': 3.5.28
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
astro: 5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-vue-devtools: 7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- '@nuxt/kit'
@@ -11863,6 +11920,14 @@ snapshots:
'@formkit/auto-animate@0.9.0': {}
'@gerrit0/mini-shiki@3.23.0':
dependencies:
'@shikijs/engine-oniguruma': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@grpc/grpc-js@1.9.15':
dependencies:
'@grpc/proto-loader': 0.7.13
@@ -12251,6 +12316,14 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor-model@7.33.1(@types/node@25.0.3)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@microsoft/tsdoc-config': 0.18.0
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.57.2(@types/node@24.10.4)':
dependencies:
'@microsoft/api-extractor-model': 7.33.1(@types/node@24.10.4)
@@ -12270,6 +12343,25 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@microsoft/api-extractor@7.57.2(@types/node@25.0.3)':
dependencies:
'@microsoft/api-extractor-model': 7.33.1(@types/node@25.0.3)
'@microsoft/tsdoc': 0.16.0
'@microsoft/tsdoc-config': 0.18.0
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
'@rushstack/rig-package': 0.7.1
'@rushstack/terminal': 0.22.1(@types/node@25.0.3)
'@rushstack/ts-command-line': 5.3.1(@types/node@25.0.3)
diff: 8.0.3
lodash: 4.17.23
minimatch: 10.2.1
resolve: 1.22.11
semver: 7.5.4
source-map: 0.6.1
typescript: 5.8.2
transitivePeerDependencies:
- '@types/node'
'@microsoft/tsdoc-config@0.18.0':
dependencies:
'@microsoft/tsdoc': 0.16.0
@@ -12495,11 +12587,11 @@ snapshots:
- typescript
- verdaccio
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
'@nx/vite@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)':
dependencies:
'@nx/devkit': 22.6.1(nx@22.6.1)
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)
'@nx/vitest': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)
'@phenomnomnominal/tsquery': 6.1.4(typescript@5.9.3)
ajv: 8.18.0
enquirer: 2.3.6
@@ -12507,8 +12599,8 @@ snapshots:
semver: 7.7.4
tsconfig-paths: 4.2.0
tslib: 2.8.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -12519,7 +12611,7 @@ snapshots:
- typescript
- verdaccio
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vitest@4.0.16)':
'@nx/vitest@22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vitest@4.0.16)':
dependencies:
'@nx/devkit': 22.6.1(nx@22.6.1)
'@nx/js': 22.6.1(@babel/traverse@7.29.0)(nx@22.6.1)
@@ -12527,8 +12619,8 @@ snapshots:
semver: 7.7.4
tslib: 2.8.1
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- '@babel/traverse'
- '@swc-node/register'
@@ -13142,10 +13234,27 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/node-core-library@5.20.1(@types/node@25.0.3)':
dependencies:
ajv: 8.13.0
ajv-draft-04: 1.0.0(ajv@8.13.0)
ajv-formats: 3.0.1(ajv@8.13.0)
fs-extra: 11.3.2
import-lazy: 4.0.0
jju: 1.4.0
resolve: 1.22.11
semver: 7.5.4
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/problem-matcher@0.2.1(@types/node@24.10.4)':
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/problem-matcher@0.2.1(@types/node@25.0.3)':
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/rig-package@0.7.1':
dependencies:
resolve: 1.22.11
@@ -13159,6 +13268,14 @@ snapshots:
optionalDependencies:
'@types/node': 24.10.4
'@rushstack/terminal@0.22.1(@types/node@25.0.3)':
dependencies:
'@rushstack/node-core-library': 5.20.1(@types/node@25.0.3)
'@rushstack/problem-matcher': 0.2.1(@types/node@25.0.3)
supports-color: 8.1.1
optionalDependencies:
'@types/node': 25.0.3
'@rushstack/ts-command-line@5.3.1(@types/node@24.10.4)':
dependencies:
'@rushstack/terminal': 0.22.1(@types/node@24.10.4)
@@ -13168,6 +13285,15 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@rushstack/ts-command-line@5.3.1(@types/node@25.0.3)':
dependencies:
'@rushstack/terminal': 0.22.1(@types/node@25.0.3)
'@types/argparse': 1.0.38
argparse: 1.0.10
string-argv: 0.3.2
transitivePeerDependencies:
- '@types/node'
'@sec-ant/readable-stream@0.4.1': {}
'@sentry-internal/browser-utils@10.32.1':
@@ -13317,10 +13443,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@storybook/addon-docs@10.2.10(@types/react@19.1.9)(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@mdx-js/react': 3.1.1(@types/react@19.1.9)(react@19.2.4)
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/react-dom-shim': 10.2.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
react: 19.2.4
@@ -13346,25 +13472,25 @@ snapshots:
- '@tmcp/auth'
- typescript
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@storybook/builder-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/csf-plugin': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-dedent: 2.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- esbuild
- rollup
- webpack
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@storybook/csf-plugin@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
unplugin: 2.3.11
optionalDependencies:
esbuild: 0.27.3
rollup: 4.53.5
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@storybook/global@5.0.0': {}
@@ -13389,14 +13515,14 @@ snapshots:
react-dom: 19.2.4(react@19.2.4)
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@storybook/vue3-vite@10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@storybook/builder-vite': 10.2.10(esbuild@0.27.3)(rollup@4.53.5)(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@storybook/vue3': 10.2.10(storybook@10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.13(typescript@5.9.3))
magic-string: 0.30.21
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
typescript: 5.9.3
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue-component-meta: 2.2.12(typescript@5.9.3)
vue-docgen-api: 4.79.2(vue@3.5.13(typescript@5.9.3))
transitivePeerDependencies:
@@ -13478,19 +13604,19 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.0
'@tailwindcss/oxide-win32-x64-msvc': 4.2.0
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.2.0
'@tailwindcss/oxide': 4.2.0
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@tailwindcss/vite@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.2.0
'@tailwindcss/oxide': 4.2.0
tailwindcss: 4.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@tanstack/virtual-core@3.13.12': {}
@@ -14083,32 +14209,32 @@ snapshots:
vue: 3.5.13(typescript@5.9.3)
vue-router: 4.4.3(vue@3.5.13(typescript@5.9.3))
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue-jsx@4.2.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-rc.9
'@vue/babel-plugin-jsx': 1.4.0(@babel/core@7.29.0)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@5.2.4(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.13(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.13(typescript@5.9.3)
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vue: 3.5.13(typescript@5.9.3)
'@vitest/coverage-v8@4.0.16(vitest@4.0.16)':
@@ -14124,7 +14250,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
@@ -14145,21 +14271,21 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))':
'@vitest/mocker@4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.0.16
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@vitest/pretty-format@3.2.4':
dependencies:
@@ -14195,7 +14321,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
'@vitest/utils@3.2.4':
dependencies:
@@ -14370,38 +14496,38 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-shared': 7.7.9
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))':
'@vue/devtools-core@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))':
dependencies:
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
mitt: 3.0.1
nanoid: 5.1.5
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vue: 3.5.13(typescript@5.9.3)
transitivePeerDependencies:
- vite
@@ -14809,7 +14935,7 @@ snapshots:
astral-regex@2.0.0: {}
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.8.2):
astro@5.18.1(@types/node@25.0.3)(jiti@2.6.1)(rollup@4.53.5)(terser@5.39.2)(tsx@4.19.4)(typescript@5.9.3)(yaml@2.9.0):
dependencies:
'@astrojs/compiler': 2.13.1
'@astrojs/internal-helpers': 0.7.6
@@ -14866,8 +14992,8 @@ snapshots:
unist-util-visit: 5.1.0
unstorage: 1.17.4
vfile: 6.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitefu: 1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
@@ -15060,6 +15186,10 @@ snapshots:
dependencies:
balanced-match: 4.0.3
brace-expansion@5.0.6:
dependencies:
balanced-match: 4.0.3
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -17463,6 +17593,8 @@ snapshots:
lru-cache@8.0.5: {}
lunr@2.3.9: {}
lz-string@1.5.0: {}
lz-utils@2.1.0: {}
@@ -17898,6 +18030,10 @@ snapshots:
dependencies:
brace-expansion: 5.0.2
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.12
@@ -19827,6 +19963,19 @@ snapshots:
typed-binary@4.3.2: {}
typedoc-plugin-markdown@4.11.0(typedoc@0.28.19(typescript@5.9.3)):
dependencies:
typedoc: 0.28.19(typescript@5.9.3)
typedoc@0.28.19(typescript@5.9.3):
dependencies:
'@gerrit0/mini-shiki': 3.23.0
lunr: 2.3.9
markdown-it: 14.1.1
minimatch: 10.2.5
typescript: 5.9.3
yaml: 2.9.0
typegpu@0.8.2:
dependencies:
tinyest: 0.1.2
@@ -20111,27 +20260,27 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
birpc: 2.9.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-dev-rpc@1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
birpc: 2.9.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-hot-client: 2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-hot-client@2.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-hot-client@2.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-dts@4.5.4(@types/node@24.10.4)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@microsoft/api-extractor': 7.57.2(@types/node@24.10.4)
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
@@ -20144,13 +20293,32 @@ snapshots:
magic-string: 0.30.21
typescript: 5.9.3
optionalDependencies:
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-dts@4.5.4(@types/node@25.0.3)(rollup@4.53.5)(typescript@5.9.3)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@microsoft/api-extractor': 7.57.2(@types/node@25.0.3)
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
'@volar/typescript': 2.4.28
'@vue/language-core': 2.2.0(typescript@5.9.3)
compare-versions: 6.1.1
debug: 4.4.3
kolorist: 1.8.0
local-pkg: 1.1.2
magic-string: 0.30.21
typescript: 5.9.3
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- '@types/node'
- rollup
- supports-color
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -20164,9 +20332,9 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-html@3.2.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@rollup/pluginutils': 4.2.1
colorette: 2.0.20
@@ -20180,9 +20348,9 @@ snapshots:
html-minifier-terser: 6.1.0
node-html-parser: 5.4.2
pathe: 0.2.0
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-inspect@0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.3.0(rollup@4.53.5)
@@ -20193,12 +20361,12 @@ snapshots:
perfect-debounce: 1.0.0
picocolors: 1.1.1
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- rollup
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@@ -20208,12 +20376,12 @@ snapshots:
perfect-debounce: 2.0.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-inspect@11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@@ -20223,56 +20391,56 @@ snapshots:
perfect-debounce: 2.0.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-dev-rpc: 1.1.0(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@7.7.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 7.7.9(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 7.7.9
'@vue/devtools-shared': 7.7.9
execa: 9.6.1
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 0.8.9(rollup@4.53.5)(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- rollup
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3)):
vite-plugin-vue-devtools@8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3)):
dependencies:
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-core': 8.0.5(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))(vue@3.5.13(typescript@5.9.3))
'@vue/devtools-kit': 8.0.5
'@vue/devtools-shared': 8.0.5
sirv: 3.0.2
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vite-plugin-inspect: 11.3.3(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
vite-plugin-vue-inspector: 5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -20283,11 +20451,11 @@ snapshots:
'@vue/compiler-dom': 3.5.28
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vite-plugin-vue-inspector@5.3.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -20298,11 +20466,11 @@ snapshots:
'@vue/compiler-dom': 3.5.28
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
transitivePeerDependencies:
- supports-color
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
@@ -20317,9 +20485,9 @@ snapshots:
jiti: 2.6.1
terser: 5.39.2
tsx: 4.19.4
yaml: 2.8.2
yaml: 2.9.0
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
dependencies:
'@oxc-project/runtime': 0.115.0
lightningcss: 1.32.0
@@ -20334,16 +20502,16 @@ snapshots:
jiti: 2.6.1
terser: 5.39.2
tsx: 4.19.4
yaml: 2.8.2
yaml: 2.9.0
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)):
vitefu@1.1.2(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)):
optionalDependencies:
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20360,7 +20528,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -20382,10 +20550,10 @@ snapshots:
- tsx
- yaml
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2):
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0):
dependencies:
'@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))
'@vitest/mocker': 4.0.16(vite@8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16
@@ -20402,7 +20570,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
vite: 8.0.0(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -20812,7 +20980,7 @@ snapshots:
yaml-eslint-parser@1.3.0:
dependencies:
eslint-visitor-keys: 3.4.3
yaml: 2.8.2
yaml: 2.9.0
yaml-language-server@1.20.0:
dependencies:
@@ -20834,6 +21002,8 @@ snapshots:
yaml@2.8.2: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:

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()

28
scripts/generate-docs.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# PKG5.D6 — Generate TypeDoc → Mintlify MDX for @comfyorg/extension-api
#
# Output: packages/extension-api/docs-build/mintlify/*.mdx
# packages/extension-api/docs-build/mintlify/nav-snippet.json
#
# Prerequisites: pnpm install must have been run (typedoc, tsx)
# Usage: ./scripts/generate-docs.sh [--watch]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PKG_DIR="$REPO_ROOT/packages/extension-api"
if [ ! -f "$PKG_DIR/package.json" ]; then
echo "ERROR: $PKG_DIR/package.json not found — run from repo root or ensure packages/extension-api exists." >&2
exit 1
fi
if [ "${1:-}" = "--watch" ]; then
echo "Starting docs watch mode..."
pnpm --filter @comfyorg/extension-api docs:watch
else
echo "Generating extension API docs..."
pnpm --filter @comfyorg/extension-api docs:build
echo ""
echo "Done. MDX files written to: $PKG_DIR/docs-build/mintlify/"
echo "Copy to Comfy-Org/docs: cp -r $PKG_DIR/docs-build/mintlify/* <docs-repo>/extensions/api/"
fi

View File

@@ -91,35 +91,6 @@ function makeWidget(name: string, value: unknown = null): IBaseWidget {
} as unknown as IBaseWidget
}
/**
* Builds a minimal HTMLCanvasElement-like stub with a 2D context that exposes
* the methods `usePainter` actually calls (`getImageData`, `clearRect`,
* `drawImage`, `toBlob`). jsdom's canvas implementation is incomplete, so we
* synthesize one to drive the pixel-emptiness check deterministically.
*/
function makeFakeCanvas(
width: number,
height: number,
pixels: Uint8ClampedArray
): HTMLCanvasElement {
const ctx = {
getImageData: vi.fn(() => ({ data: pixels })),
clearRect: vi.fn(),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
fillRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn()
}
return {
width,
height,
getContext: vi.fn(() => ctx),
toBlob: (cb: BlobCallback) => cb(new Blob(['x']))
} as unknown as HTMLCanvasElement
}
/**
* Mounts a thin wrapper component so Vue lifecycle hooks fire.
*/
@@ -388,71 +359,12 @@ describe('usePainter', () => {
expect(result).toBe('')
})
it('returns existing modelValue when not dirty (regression: WidgetPainter remount must not blank a workflow-restored mask reference)', async () => {
it('returns empty string when canvas has no strokes even if modelValue is set', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
mountPainter('test-node', 'painter/existing.png [temp]')
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/existing.png [temp]')
})
it('uploads canvas content even when the isDirty flag is false (regression: stroke-tracking flag can desync from real canvas pixel data on remount or non-primary pointerdown)', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const fetchApiMock = vi.mocked(api.fetchApi)
fetchApiMock.mockResolvedValueOnce({
status: 200,
json: async () => ({ name: 'uploaded.png' })
} as Response)
const { canvasEl } = mountPainter('test-node', '')
// Simulate a remount-style scenario: closure flags say "no strokes",
// but the canvas itself has visible pixel content (e.g. produced by a
// pointerdown path that bypassed startStroke, or compositeStrokeToMain
// that ran before the new closure was installed).
const paintedPixels = new Uint8ClampedArray(4 * 4 * 4)
// Mark pixel 0 as opaque red.
paintedPixels[3] = 255
canvasEl.value = makeFakeCanvas(4, 4, paintedPixels)
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('painter/uploaded.png [temp]')
expect(fetchApiMock).toHaveBeenCalledWith(
'/upload/image',
expect.objectContaining({ method: 'POST' })
)
})
it('returns empty string when canvas has no pixels and modelValue is empty', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { canvasEl } = mountPainter('test-node', '')
// All-zero alpha — canvas considered empty.
canvasEl.value = makeFakeCanvas(4, 4, new Uint8ClampedArray(4 * 4 * 4))
await nextTick()
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')
})
it('returns empty string after handleClear even when modelValue previously held an upload reference', async () => {
const maskWidget = makeWidget('mask', '')
mockWidgets.push(maskWidget)
const { painter, canvasEl, modelValue } = mountPainter(
'test-node',
'painter/old-upload.png [temp]'
)
canvasEl.value = makeFakeCanvas(4, 4, new Uint8ClampedArray(4 * 4 * 4))
await nextTick()
painter.handleClear()
expect(modelValue.value).toBe('')
const { modelValue } = mountPainter()
modelValue.value = 'painter/existing.png [temp]'
const result = await maskWidget.serializeValue!({} as LGraphNode, 0)
expect(result).toBe('')

View File

@@ -61,6 +61,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
let baseCanvas: HTMLCanvasElement | null = null
let baseCtx: CanvasRenderingContext2D | null = null
let hasBaseSnapshot = false
let hasStrokes = false
let dirtyX0 = 0
let dirtyY0 = 0
@@ -412,10 +413,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
isDrawing = true
isDirty.value = true
console.warn('[painter] startStroke: isDirty=true', {
nodeId,
modelValue: modelValue.value
})
hasStrokes = true
snapshotBrush()
strokeProcessor = new StrokeProcessor(Math.max(1, strokeBrush!.radius / 2))
strokeProcessor.addPoint(point)
@@ -515,13 +513,7 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
if (!el || !ctx) return
ctx.clearRect(0, 0, el.width, el.height)
isDirty.value = true
// Clear any cached upload reference. Without this, an empty canvas
// combined with a stale `modelValue` would resurrect the previously
// uploaded mask on the next serialize.
modelValue.value = ''
console.warn('[painter] handleClear: canvas cleared, modelValue=""', {
nodeId
})
hasStrokes = false
}
function updateCursorPos(e: PointerEvent) {
@@ -627,73 +619,17 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
return { filename, subfolder, type }
}
/**
* Reads canvas pixel data to determine whether the canvas has any visible
* content. Robust against state-flag drift caused by closure resets on
* remount, handleClear edge cases, and pointerdown variants where
* `e.button !== 0` short-circuits `startStroke`.
*/
function isCanvasPixelEmpty(el: HTMLCanvasElement): boolean {
const ctx = el.getContext('2d')
if (!ctx) return true
const { data } = ctx.getImageData(0, 0, el.width, el.height)
for (let i = 3; i < data.length; i += 4) {
if (data[i] !== 0) return false
}
return true
function isCanvasEmpty(): boolean {
return !hasStrokes
}
async function serializeValue(): Promise<string> {
const el = canvasEl.value
if (!el) {
console.warn('[painter] serializeValue: no canvas el', {
nodeId,
modelValue: modelValue.value
})
return modelValue.value
}
if (!el) return ''
const pixelEmpty = isCanvasPixelEmpty(el)
console.warn('[painter] serializeValue: entry', {
nodeId,
isDirty: isDirty.value,
modelValue: modelValue.value,
pixelEmpty,
canvasW: el.width,
canvasH: el.height
})
if (isCanvasEmpty()) return ''
// Authoritative emptiness check: read actual pixel data instead of
// relying on the `isDirty` flag, which can desync from canvas content
// on WidgetPainter remount or on non-primary pointerdown variants where
// the closure-local stroke bookkeeping was bypassed.
// When the canvas is empty, defer to `modelValue` so a workflow-restored
// mask reference (or a pending image-restore) survives. `handleClear`
// explicitly resets `modelValue` so a user-initiated clear still yields ''.
if (pixelEmpty) {
console.warn(
'[painter] serializeValue: canvas pixel-empty → return modelValue',
{
nodeId,
modelValue: modelValue.value
}
)
return modelValue.value
}
// Canvas has visible content. If we already uploaded this exact content
// (no new strokes since last successful upload) and the cached value is
// valid, reuse it to avoid redundant uploads.
if (!isDirty.value && modelValue.value) {
console.warn(
'[painter] serializeValue: !isDirty && modelValue → reuse cached',
{
nodeId,
modelValue: modelValue.value
}
)
return modelValue.value
}
if (!isDirty.value) return modelValue.value
const blob = await new Promise<Blob | null>((resolve) =>
el.toBlob(resolve, 'image/png')
@@ -747,11 +683,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
: `painter/${data.name} [temp]`
modelValue.value = result
isDirty.value = false
console.warn('[painter] serializeValue: upload OK', {
nodeId,
result,
isDirtyAfter: isDirty.value
})
return result
}
@@ -778,11 +709,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
const url = api.apiURL('/view?' + params.toString())
const img = new Image()
img.crossOrigin = 'anonymous'
console.warn('[painter] restoreCanvas: start loading', {
nodeId,
url,
modelValue: modelValue.value
})
img.onload = () => {
const el = canvasEl.value
if (!el) return
@@ -790,21 +716,10 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
canvasHeight.value = img.naturalHeight
mainCtx = null
getCtx()?.drawImage(img, 0, 0)
const isDirtyBefore = isDirty.value
isDirty.value = false
console.warn(
'[painter] restoreCanvas: onload → drawImage + isDirty=false',
{
nodeId,
isDirtyBefore,
modelValue: modelValue.value
}
)
hasStrokes = true
}
img.onerror = () => {
console.warn('[painter] restoreCanvas: onerror → modelValue=""', {
nodeId
})
modelValue.value = ''
}
img.src = url
@@ -826,10 +741,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
watch(backgroundColor, syncBackgroundColorToWidget)
function initialize() {
console.warn('[painter] mounted / initialize', {
nodeId,
modelValue: modelValue.value
})
syncCanvasSizeFromWidgets()
resizeCanvas()
registerWidgetSerialization()
@@ -841,11 +752,6 @@ export function usePainter(nodeId: string, options: UsePainterOptions) {
onMounted(initialize)
onUnmounted(() => {
console.warn('[painter] unmounted', {
nodeId,
modelValue: modelValue.value,
isDirty: isDirty.value
})
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null

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

@@ -0,0 +1,76 @@
/**
* CoordSpaceDemo — canary example for the D-coord-space PICK
* (W6.P4 ACCEPTED 2026-05-18, Axiom A13 Single Coordinate Space).
*
* Shows three things:
*
* 1. **The default — canvas units everywhere.** `node.getPosition()` /
* `getSize()` / `setPosition()` / `setSize()` all speak canvas units.
* Zoom and pan don't perturb the numbers; devicePixelRatio is
* invisible. This is the path 96%+ of extensions should ever take.
*
* 2. **The escape-hatch — explicit + annotated.** For the legitimate
* cases that need screen-space coords (custom GPU canvas, floating
* overlay anchored to absolute browser coords, hi-DPI export math),
* drop to `window.app.canvas.{ds,canvas}` + `window.devicePixelRatio`.
* Every escape-hatch use site MUST carry the
* `// escape-hatch — see D-coord-space.md` comment so reviewers
* (human or AI) can see the dependency is deliberate.
*
* 3. **The cliff — what's NOT on the public surface.** No
* `node.getScreenPosition()`, no `node.getCSSPosition()`, no
* `space: 'client' | 'css'` parameter, no branded `ClientPoint`
* type. Reaching for any of those is a sign the author wants the
* escape-hatch.
*/
import { defineExtension, defineNode, type NodeHandle } from '@/extension-api'
defineNode({
name: 'Comfy.CoordSpaceDemo.V2',
nodeCreated(node: NodeHandle) {
// ── (1) Default path: canvas units, no conversion needed ──────────
const [x, y] = node.getPosition() // canvas units
const [w, h] = node.getSize() // canvas units
// Move a node 16 canvas units down-and-right of its current spot.
// No /scale, no *scale, no devicePixelRatio — the runtime owns it.
node.setPosition([x + 16, y + 16]) // canvas units
// Reserve a minimum size — also canvas units; zoom doesn't matter.
if (w < 200) node.setSize([200, h])
}
})
defineExtension({
name: 'Comfy.CoordSpaceDemo.V2.Escape',
setup() {
// ── (2) Escape-hatch path — explicit + annotated ──────────────────
//
// Use case: extension wants to draw a 2x-pixel-density preview
// thumbnail PNG of the visible viewport. PNG export needs *device
// pixels* (so the saved image is crisp on hi-DPI displays); the v2
// public surface does not expose dpr or screen-pixel sizing.
//
// The escape-hatch is the same shape extension authors are already
// using today (213 dpr hits across 27 repos in the W6.P4.R1 sweep).
// escape-hatch — see D-coord-space.md § Documentation contract
const dpr = window.devicePixelRatio
// escape-hatch — see D-coord-space.md § Documentation contract
const canvas = globalThis.app?.canvas
if (!canvas) return
// escape-hatch — see D-coord-space.md § Documentation contract
const { scale } = canvas.ds
// escape-hatch — see D-coord-space.md § Documentation contract
const rect = canvas.canvas.getBoundingClientRect()
// From here, the author owns dpr math + canvas↔screen conversions.
// The runtime makes no stability promise about ds.scale or the
// shape of window.app.canvas — escape-hatch is intentionally
// fragile per Axiom A13.
void { dpr, scale, rect }
}
})

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

@@ -0,0 +1,32 @@
/**
* DynamicPrompts — v2 extension API.
*
* STUBBED PENDING NEW PUBLIC API per D-no-node-widget-access (2026-05-19).
*
* The previous v2 implementation iterated `node.getWidgets()` inside
* `defineNode.nodeCreated` to attach `beforeSerialize` handlers to every
* widget with `options.dynamicPrompts === true`. That pattern is now
* forbidden by the bilateral A1 closure: nodes cannot enumerate or
* reference their widgets.
*
* The clean v2 path for "augment all widgets matching predicate P with
* behavior B" does not yet exist on the public surface. `defineWidget`
* registers a NEW widget type with a `mount` lifecycle; it does not
* augment existing types' instances.
*
* Restoration plan (follow-up issue to file):
* - Add a `defineWidgetAugmenter({ matches, setup })` (or per-widget
* `setup(widget)` hook on `defineWidget`) so extensions can attach
* behavior to existing-typed widgets opted in via `options`.
* - Migrate this stub to that API once it ships.
* - Update D-no-node-widget-access "Restoration criteria" if the
* augmenter API requires loosening A1 (it shouldn't — the augmenter
* hands the extension a `WidgetHandle` directly, never via a node).
*
* v1 (`Comfy.DynamicPrompts`) continues to work — this is a v2 surface
* gap only, not a user-visible regression.
*/
// Intentionally empty — no `defineNode` / `defineWidget` registration.
// See block comment above for context.
export {}

View File

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

View File

@@ -4,6 +4,10 @@ import './clipspace'
import './contextMenuFilter'
import './customWidgets'
import './dynamicPrompts'
// v1 and v2 conversions are loaded side-by-side during the migration window
// (D6 parallel paths). v2 extensions register under distinct names
// (e.g. `Comfy.DynamicPrompts.V2`), so no idempotent guard is needed.
import './dynamicPrompts.v2'
import './editAttention'
import './electronAdapter'
import './groupNode'
@@ -11,6 +15,7 @@ import './groupNodeManage'
import './groupOptions'
import './imageCompare'
import './imageCrop'
import './imageCrop.v2'
// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB)
// The lazy loader triggers loading when a 3D node is used
import './load3dLazy'
@@ -21,6 +26,7 @@ if (!isCloud) {
import './noteNode'
import './painter'
import './previewAny'
import './previewAny.v2'
import './rerouteNode'
import './saveImageExtraOutput'
// saveMesh is loaded on-demand with load3d (see load3dLazy.ts)

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

@@ -0,0 +1,124 @@
/**
* NoteNode + MarkdownNoteNode — rewritten with the v2 extension API.
*
* v1 used `registerCustomNodes` to call `LiteGraph.registerNodeType()` directly.
* v2 does NOT yet have a `registerNodeType` hook on `defineExtension`. The
* custom-node-type registration surface is a planned addition (gap tracked in
* the inline comment below).
*
* What this file demonstrates to Simon/Austin:
* 1. Pure app-level extensions use `defineExtension({ setup() })`.
* 2. Shell settings are accessed via the `ExtensionManager` passed to `setup`.
* 3. Custom LiteGraph node type registration has NO v2 equivalent yet.
* The v2 API surface covers node *instance* hooks (nodeCreated, executed,
* etc.) but not node *type* registration, which today still requires
* LiteGraph.registerNodeType(). That gap will be addressed in PKG4 /
* the ComfyNodeRegistry design.
*
* Compare with noteNode.ts (v1):
* v1: registerCustomNodes() callback, direct LiteGraph + ComfyWidgets calls
* v2: setup() callback, custom-node-type registration still needs v1 bridge
*
* API GAPS (feedback items for Simon/Austin):
* GAP-1: No `registerNodeTypes` hook on `ExtensionOptions` — can't replace
* `registerCustomNodes` in pure v2. Need a `NodeTypeRegistry` surface
* or a first-class "custom node type" abstraction in the v2 API.
* GAP-2: No `addWidget` for node *type* construction time (before any
* instance exists) — `ComfyWidgets.STRING(this, ...)` has no analog.
* GAP-3: Node colour + visual styling (`this.color`, `this.bgcolor`,
* `this.groupcolor`) has no API surface; would need NodeHandle setter.
*
* Interim bridge: call LiteGraph directly inside `setup()` to register the
* types, then rely on `defineNodeExtension({ nodeTypes: ['Note'] })` for any
* per-instance extension logic. This hybrid is the least-bad option until
* GAP-1 is closed.
*/
import {
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { ComfyWidgets } from '../../scripts/widgets'
import { defineExtension } from '@/extension-api'
// ── GAP-1: Interim bridge for custom node type registration ──────────────────
// We still call LiteGraph.registerNodeType() directly because there is no v2
// `registerNodeTypes` hook. This is intentionally non-ideal — the explicit goal
// is to surface this gap for the Simon/Austin design discussion.
function registerNoteTypes() {
class NoteNode extends LGraphNode {
static override category: string
static collapsable: boolean
static title_mode: number
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
override isVirtualNode: boolean
constructor(title: string) {
super(title)
// GAP-3: node colour should be settable via NodeHandle in nodeCreated.
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) this.properties = { text: '' }
// GAP-2: no v2 analog for widget addition at type-construction time.
ComfyWidgets.STRING(
this,
'text',
['STRING', { default: this.properties.text, multiline: true }],
// @ts-expect-error app not available at this layer
undefined
)
this.serialize_widgets = true
this.isVirtualNode = true
}
}
LiteGraph.registerNodeType(
'Note',
Object.assign(NoteNode, {
title_mode: LiteGraph.NORMAL_TITLE,
title: 'Note',
collapsable: true
})
)
NoteNode.category = 'utils'
class MarkdownNoteNode extends LGraphNode {
static override title = 'Markdown Note'
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
constructor(title: string) {
super(title)
this.color = LGraphCanvas.node_colors.yellow.color
this.bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
if (!this.properties) this.properties = { text: '' }
ComfyWidgets.MARKDOWN(
this,
'text',
['STRING', { default: this.properties.text }],
// @ts-expect-error app not available at this layer
undefined
)
this.serialize_widgets = true
this.isVirtualNode = true
}
}
LiteGraph.registerNodeType('MarkdownNote', MarkdownNoteNode)
MarkdownNoteNode.category = 'utils'
}
// ── v2 registration ──────────────────────────────────────────────────────────
defineExtension({
name: 'Comfy.NoteNode.V2',
setup() {
// GAP-1: Custom node types must be registered here via LiteGraph directly.
// In the intended v2 design this would be a `registerNodeTypes(registry)`
// hook on ExtensionOptions where `registry.add('Note', NoteNodeDef)`.
registerNoteTypes()
}
})

View File

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

View File

@@ -0,0 +1,353 @@
/**
* RerouteNode — annotated port to the v2 extension API.
*
* v1 used `registerCustomNodes` to call `LiteGraph.registerNodeType()` with
* a class that heavily overrides LiteGraph node behaviour (`onConnectionsChange`,
* `clone`, `computeSize`, `getExtraMenuOptions`).
*
* RerouteNode is the *most v1-coupled* core extension: its entire value lives
* in LiteGraph prototype methods. It is the intentional hard case for this
* conversion exercise.
*
* What this file demonstrates to Simon/Austin:
* 1. The `defineNodeExtension` pattern works only for *per-instance hooks*
* — events that fire after a node exists. LiteGraph prototype overrides
* (`onConnectionsChange`, `computeSize`, `clone`) fire synchronously
* inside LiteGraph's own rendering loop and have no v2 equivalent.
* 2. Custom context-menu contributions (`getExtraMenuOptions`) have no v2
* surface. This is intentionally out of scope for the initial API.
* 3. `localStorage` / settings persistence (`defaultVisibility`) works the
* same in v2 — no v2 API involvement needed.
*
* API GAPS (feedback items for Simon/Austin):
* GAP-1: (same as noteNode.v2) No `registerNodeTypes` hook — custom LiteGraph
* node types cannot be registered via the v2 API.
* GAP-7: No v2 hook for `onConnectionsChange`. This is a hot-path LiteGraph
* callback that fires during canvas interaction. Mapping it to the v2
* model would require an `NodeConnectedEvent` / `NodeDisconnectedEvent`
* that fires SYNCHRONOUSLY and allows the handler to mutate outputs
* and downstream nodes. Current v2 `node.on('connected')` is async-safe
* and does not support synchronous output-type mutation.
* GAP-8: No v2 surface for `getExtraMenuOptions` (context menu extension).
* Would need an `onContextMenu(items)` hook on NodeExtensionOptions
* that allows item injection.
* GAP-9: `clone()` override. No v2 equivalent. If we want the cloned reroute
* node to have its output reset, we'd need a post-copy lifecycle hook
* (e.g. `nodeCopied(clone, source)`) which D12 explicitly deferred.
* GAP-10: `computeSize()` override. Pure LiteGraph geometry; unlikely to
* ever have a v2 equivalent. Extensions that need custom size should
* either accept a fixed size or use a separate API.
*
* Conclusion: RerouteNode cannot be converted to pure v2 in the current API.
* It is a LiteGraph-native "virtual node" with synchronous connection-type
* propagation logic. The correct long-term path is to make RerouteNode a
* first-class feature of the ComfyUI graph engine (not an extension at all)
* and expose its behaviour through a higher-level abstraction.
*
* What *can* be expressed in v2 is shown in the `defineNodeExtension` block
* below — the per-instance "user changed show/hide type" preference is a clean
* v2 pattern. The rest remains in the v1 bridge.
*/
import {
LGraphCanvas,
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
import { defineExtension } from '@/extension-api'
// ── GAP-1: Interim bridge — LiteGraph node type registration ─────────────────
function registerRerouteType() {
// Declaration-merging interface so the class gains `__outputType`.
interface RerouteNode extends LGraphNode {
__outputType?: string | number
}
class RerouteNode extends LGraphNode {
static override category: string | undefined
static defaultVisibility = false
constructor(title?: string) {
super(title ?? '')
if (!this.properties) this.properties = {}
this.properties.showOutputText = RerouteNode.defaultVisibility
this.properties.horizontal = false
this.addInput('', '*')
this.addOutput(this.properties.showOutputText ? '*' : '', '*')
this.setSize(this.computeSize())
this.isVirtualNode = true
}
override onAfterGraphConfigured() {
requestAnimationFrame(() => {
this.onConnectionsChange(LiteGraph.INPUT, undefined, true)
})
}
// GAP-9: This clone() override would need a v2 `nodeCopied` lifecycle hook.
override clone(): LGraphNode | null {
const cloned = super.clone()
if (!cloned) return cloned
cloned.removeOutput(0)
cloned.addOutput(this.properties.showOutputText ? '*' : '', '*')
cloned.setSize(cloned.computeSize())
return cloned
}
// GAP-7: onConnectionsChange cannot be expressed in v2 — synchronous
// output-type mutation during connection is not supported by v2 event model.
override onConnectionsChange(
type: ISlotType,
_index: number | undefined,
connected: boolean
) {
const { graph } = this
if (!graph) return
// strangler-bridge:Phase-B — direct ComfyApp access; replace with
// dispatch/world signal once the bridge audit lands (D9).
// @ts-expect-error ComfyApp
if (globalThis.app?.configuringGraph) return
if (connected && type === LiteGraph.OUTPUT) {
const types = new Set(
this.outputs[0].links
?.map((l) => graph.links[l]?.type)
?.filter((t) => t && t !== '*') ?? []
)
if (types.size > 1) {
const linksToDisconnect = []
for (const linkId of this.outputs[0].links ?? []) {
linksToDisconnect.push(graph.links[linkId])
}
linksToDisconnect.pop()
for (const link of linksToDisconnect) {
if (!link) continue
const node = graph.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot)
}
}
}
let currentNode: RerouteNode | null = this
let updateNodes: RerouteNode[] = []
let inputType = null
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = graph.links[linkId]
if (!link) return
const node = graph.getNodeById(link.origin_id)
if (!node) return
if (node instanceof RerouteNode) {
if (node === this) {
currentNode.disconnectInput(link.target_slot)
currentNode = null
} else {
currentNode = node
}
} else {
inputNode = currentNode
inputType = node.outputs[link.origin_slot]?.type ?? null
break
}
} else {
currentNode = null
break
}
}
const nodes: RerouteNode[] = [this]
let outputType = null
while (nodes.length) {
currentNode = nodes.pop()!
const outputs = currentNode.outputs?.[0]?.links ?? []
for (const linkId of outputs) {
const link = graph.links[linkId]
if (!link) continue
const node = graph.getNodeById(link.target_id)
if (!node) continue
if (node instanceof RerouteNode) {
nodes.push(node)
updateNodes.push(node)
} else {
const nodeInput = node.inputs[link.target_slot]
const nodeOutType = nodeInput.type
const keep =
!inputType ||
!nodeOutType ||
LiteGraph.isValidConnection(inputType, nodeOutType)
if (!keep) {
node.disconnectInput(link.target_slot)
continue
}
node.onConnectionsChange?.(
LiteGraph.INPUT,
link.target_slot,
keep,
link,
nodeInput
)
outputType = node.inputs[link.target_slot].type
}
}
}
const displayType = inputType || outputType || '*'
const color = LGraphCanvas.link_type_colors[displayType]
let widgetConfig
let widgetType
for (const node of updateNodes) {
node.outputs[0].type = inputType || '*'
node.__outputType = displayType
node.outputs[0].name = node.properties.showOutputText
? `${displayType}`
: ''
node.setSize(node.computeSize())
for (const l of node.outputs[0].links || []) {
const link = graph.links[l]
if (!link) continue
link.color = color
// strangler-bridge:Phase-B — direct ComfyApp access; replace with
// dispatch/world signal once the bridge audit lands (D9).
// @ts-expect-error ComfyApp
if (globalThis.app?.configuringGraph) continue
const targetNode = graph.getNodeById(link.target_id)
if (!targetNode) continue
const targetInput = targetNode.inputs?.[link.target_slot]
if (targetInput?.widget) {
const config = getWidgetConfig(targetInput)
if (!widgetConfig) {
widgetConfig = config[1] ?? {}
widgetType = config[0]
}
const merged = mergeIfValid(targetInput, [config[0], widgetConfig])
if (merged.customConfig) widgetConfig = merged.customConfig
}
}
}
for (const node of updateNodes) {
if (widgetConfig && outputType) {
node.inputs[0].widget = { name: 'value' }
setWidgetConfig(node.inputs[0], [
widgetType ?? `${displayType}`,
widgetConfig
])
} else {
setWidgetConfig(node.inputs[0], undefined)
}
}
if (inputNode?.inputs?.[0]?.link) {
const link = graph.links[inputNode.inputs[0].link]
if (link) link.color = color
}
}
// GAP-8: getExtraMenuOptions has no v2 equivalent.
override getExtraMenuOptions(
_: unknown,
options: (IContextMenuValue | null)[]
): IContextMenuValue[] {
options.unshift(
{
content: (this.properties.showOutputText ? 'Hide' : 'Show') + ' Type',
callback: () => {
this.properties.showOutputText = !this.properties.showOutputText
if (this.properties.showOutputText) {
this.outputs[0].name = `${this.__outputType || this.outputs[0].type}`
} else {
this.outputs[0].name = ''
}
this.setSize(this.computeSize())
// strangler-bridge:Phase-B — direct ComfyApp access; replace with
// dispatch/world signal once the bridge audit lands (D9).
// @ts-expect-error ComfyApp
globalThis.app?.canvas?.setDirty(true, true)
}
},
{
content:
(RerouteNode.defaultVisibility ? 'Hide' : 'Show') +
' Type By Default',
callback: () => {
RerouteNode.setDefaultTextVisibility(!RerouteNode.defaultVisibility)
}
}
)
return []
}
// GAP-10: computeSize override — no v2 surface.
override computeSize(): [number, number] {
return [
this.properties.showOutputText && this.outputs?.length
? Math.max(
75,
LiteGraph.NODE_TEXT_SIZE * this.outputs[0].name.length * 0.6 + 40
)
: 75,
26
]
}
static setDefaultTextVisibility(visible: boolean) {
RerouteNode.defaultVisibility = visible
if (visible) {
localStorage['Comfy.RerouteNode.DefaultVisibility'] = 'true'
} else {
delete localStorage['Comfy.RerouteNode.DefaultVisibility']
}
}
}
RerouteNode.setDefaultTextVisibility(
!!localStorage['Comfy.RerouteNode.DefaultVisibility']
)
LiteGraph.registerNodeType(
'Reroute',
Object.assign(RerouteNode, {
title_mode: LiteGraph.NO_TITLE,
title: 'Reroute',
collapsable: false
})
)
RerouteNode.category = 'utils'
}
// ── v2: app-level registration (GAP-1 bridge) ─────────────────────────────────
defineExtension({
name: 'Comfy.RerouteNode.V2',
setup() {
registerRerouteType()
}
})
// ── v2: what *can* be expressed cleanly ──────────────────────────────────────
// The context-menu "Show/Hide Type" toggle persists a preference to localStorage.
// In a fully realized v2 API this would live here. Today it's inside the
// LiteGraph class because there's no v2 hook for per-node menu items (GAP-8).
//
// If GAP-7 (synchronous connection-type propagation) were solved, the
// onConnectionsChange logic above could become:
//
// defineNodeExtension({
// name: 'Comfy.RerouteNode.V2',
// nodeTypes: ['Reroute'],
// nodeCreated(node) {
// node.on('connected', (e) => propagateType(node, e))
// node.on('disconnected', (e) => propagateType(node, e))
// }
// })
//
// That path requires the connected/disconnected events to be synchronous
// and to carry a mutable output descriptor — a non-trivial API contract.

View File

@@ -0,0 +1,73 @@
/**
* SlotDefaults — rewritten with the v2 extension API.
*
* v1 used `init` + `beforeRegisterNodeDef` + direct `app.ui.settings.addSetting`.
* v2 uses `defineExtension({ setup(ext) })`. The `ExtensionManager` passed to
* `setup` exposes `setting.get/set` but NOT `addSetting` — that gap is noted below.
*
* What this file demonstrates to Simon/Austin:
* 1. App-level extensions (init/setup) map cleanly to `defineExtension`.
* 2. `beforeRegisterNodeDef` has no v2 equivalent — node type metadata is not
* surfaced through the v2 API at registration time.
* 3. `app.ui.settings.addSetting` (declares a new setting with slider + label)
* has no v2 `ExtensionManager` surface.
*
* API GAPS (feedback items for Simon/Austin):
* GAP-4: No `beforeRegisterNodeDef` hook on `ExtensionOptions`. This hook
* fires *once per node type*, before any instance exists, giving access
* to `nodeData` (input/output schema). Needed for type-level analysis
* (e.g. slot type registry). Candidate: `onNodeTypeRegistered(typeDef)`.
* GAP-5: `ExtensionManager.setting` exposes only `get/set`. It does NOT
* expose `addSetting` (declare a new setting with UI metadata, type,
* default, onChange callback). Needed for extensions that contribute
* settings to the settings dialog. Candidate: extend the `setting`
* interface with `add(spec: SettingSpec)`.
* GAP-6: `LiteGraph.registered_slot_in_types` / `slot_types_out` are
* global LiteGraph state mutated here. No v2 abstraction exists for
* the "node suggestions" subsystem. Low priority — this is fine to
* keep calling LiteGraph directly as an implementation detail.
*
* Interim strategy: `setup()` falls back to direct LiteGraph manipulation for
* slot type data. The settings contribution stays as a TODO annotation until
* GAP-5 is resolved.
*/
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { defineExtension } from '@/extension-api'
// ── v2 registration ──────────────────────────────────────────────────────────
/**
* @remarks
* **DEMO — incomplete migration.** Compared to v1
* (`src/extensions/core/slotDefaults.ts`), this v2 port currently only sets
* `LiteGraph.search_filter_enabled = true`. The following v1 features are
* **not yet ported** and stay as feedback items for Simon/Austin:
*
* - Node-type metadata accumulation via `beforeRegisterNodeDef` (GAP-4)
* - Settings-dialog contribution for the suggestion-count slider (GAP-5)
* - LiteGraph slot-type registry mutation (GAP-6, low priority)
*
* Do not rely on this extension for slot-default behavior in PoC bring-up —
* load the v1 `slotDefaults.ts` instead, or wait for the gaps above to land.
*/
defineExtension({
name: 'Comfy.SlotDefaults.V2 (DEMO — incomplete migration)',
init() {
LiteGraph.search_filter_enabled = true
},
setup() {
// GAP-5: In v1, `app.ui.settings.addSetting(spec)` declared a user-facing
// slider in the settings dialog with an onChange callback. In v2,
// `defineExtension({ setup })` takes no arguments — the ExtensionManager
// is not yet plumbed into the setup callback. Until GAP-5 is resolved,
// we cannot register the user-facing setting from a v2 extension.
//
// GAP-4: In v1, `beforeRegisterNodeDef(nodeType, nodeData)` processed each
// node type's input/output schema. In v2 there is no equivalent hook.
// The slot-type accumulator logic from v1 cannot be ported until
// `onNodeTypeRegistered(def)` or equivalent is added to the API.
}
})

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

@@ -0,0 +1,110 @@
/**
* WebcamCapture — rewritten with the v2 extension API.
*
* v1: registers the `WEBCAM` custom widget type via `getCustomWidgets()`
* returning `node.addDOMWidget(name, 'WEBCAM', container)`, then a
* separate `nodeCreated` reaches into `node.widgets` to wire the
* capture button and `serializeValue` override.
*
* v2: registers the `WEBCAM` widget type via `defineWidget({ type, mount })`
* per **Axiom A12** — the mount-lifecycle hook is the sole DOM seam.
* The mount body captures `host` (and the constructed `<video>`) via
* closure; there is no `widget.element` accessor on `WidgetHandle`.
* Cleanup stops the camera stream when the widget is destroyed
* (D-widget-converge §Clarification 1: cleanup = destruction-only).
*
* The `nodeCreated` half of the v1 extension (wiring the capture button +
* serializeValue override) surfaces several gaps already tracked under
* I-COORD.1 — see GAP comments inline.
*/
import { defineNode, defineWidget, type NodeHandle } from '@/extension-api'
// ── defineWidget — Axiom A12 mount-lifecycle seam ───────────────────────────
export default defineWidget({
name: 'Comfy.WebcamCapture.V2.Widget',
type: 'WEBCAM',
mount(host, ctx) {
const container = document.createElement('div')
container.style.background = 'rgba(0,0,0,0.25)'
container.style.textAlign = 'center'
const video = document.createElement('video')
video.style.height = video.style.width = '100%'
let stream: MediaStream | null = null
const loadVideo = async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: false
})
container.replaceChildren(video)
video.srcObject = stream
await video.play()
} catch (error) {
const label = document.createElement('div')
label.style.color = 'red'
label.style.overflow = 'auto'
label.style.maxHeight = '100%'
label.style.whiteSpace = 'pre-wrap'
const message = error instanceof Error ? error.message : String(error)
label.textContent = window.isSecureContext
? `Unable to load webcam, please ensure access is granted:\n${message}`
: `Unable to load webcam. A secure context is required, if you are not accessing ComfyUI on localhost (127.0.0.1) you will have to enable TLS (https)\n\n${message}`
container.replaceChildren(label)
}
}
host.appendChild(container)
void loadVideo()
// Re-bind the video element to the new host on remount (graph ↔ app
// mode swap, subgraph promotion). Mount body is NOT re-invoked per
// D-widget-converge §Clarification 1.
ctx.onAfterRemount((newHost) => {
newHost.appendChild(container)
})
// Destruction-only cleanup: stop the camera stream + release tracks.
return () => {
stream?.getTracks().forEach((t) => t.stop())
stream = null
video.srcObject = null
container.remove()
}
}
})
// ── Companion defineNode — capture button + serializeValue wiring ──────────
//
// The `WebcamCapture` node-side logic still has open v2 surface gaps:
// GAP-2 (I-COORD.1): no type-construction `addWidget('button', …)` on
// NodeHandle — the v1 path adds a button programmatically inside
// `nodeCreated` to drive `capture()`.
// GAP-11 (new): no `widget.serializeValue = async () => …` setter
// on WidgetHandle. The v2 path is `widget.on('beforeSerialize',
// e => e.setSerializedValue(…))`, but the v1 override is *async*
// (uploads to /upload/image and awaits the response); the
// `beforeSerialize` payload shape (D5) does not yet promise async
// resolution. Tracked separately — do not unblock here.
// Until those land, the node-side companion stays a v1 extension. The
// `defineNode` below is a placeholder that registers the type filter so
// downstream tooling can correlate the v2 widget registration with the
// node that consumes it.
defineNode({
name: 'Comfy.WebcamCapture.V2.Node',
nodeTypes: ['WebcamCapture'],
nodeCreated(_node: NodeHandle) {
// Wiring deferred — see GAP-2 / GAP-11 above. The v1 extension's
// nodeCreated body remains the authoritative implementation until
// those gaps close.
}
})

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.
*/
// NodeEntityId/WidgetEntityId/SlotEntityId removed from this re-export per D20
// (id-type-convergence) — they are now @internal. Use `node.id` / `widget.id`
// (string) and `node.equals(other)` for the public surface.
export type {
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)'
)
}