Skip to content

Nodekit Pack Pattern

github-actions[bot] edited this page Oct 14, 2026 · 5 revisions

Nodekit pack pattern

@nynxz/zenkit-nodekit is how a ComfyUI node pack puts Vue components inside node bodies. A widget is one .vue file; its filename decides the io type the Python schema refers to. The pack works on plain ComfyUI and picks up extras (popout panels, slot links, the shared viewer) when ZenKit is installed.

This page follows what ComfyUI-NynxzNodes and ComfyUI-ZenCut actually do. See also Nodekit media and Nodekit panels and canvas.

Minimal pack

Five frontend files, two Python files and one command give you one node with a Vue body.

// pack.json
{ "namespace": "zencut", "displayName": "ZenCut", "category": "ZenCut" }
// frontend/framework.ts — the pack's one import of nodekit
import { createNodekit } from '@nynxz/zenkit-nodekit'
import manifest from '../pack.json'

// Everything identity-free (defineNode, readMediaDrop, openZenPanel, the types, …)…
export * from '@nynxz/zenkit-nodekit'
// …and what createNodekit binds to this pack.
export const {
  NAMESPACE,
  DISPLAY_NAME,
  nodeId,
  typeId,
  route,
  routeUrl,
  settingId,
  registerNodes,
  discoverNodes,
  discoverWidgets,
  widgetTypes,
  mountWidget,
  addNodeHeaderButton,
  mediaLibrary,
  thumbUrl,
  uploadOrReuse,
  mediaInfo,
} = createNodekit(manifest)
// frontend/main.ts
import '@nynxz/zenkit-ui/style.css'
import '@nynxz/zenkit-ui/comfy-bridge.css'
import { DISPLAY_NAME, discoverWidgets, registerNodes, widgetTypes } from '@/framework'

const widgets = import.meta.glob('./widgets/*.vue', { eager: true })
registerNodes(discoverWidgets(widgets))
console.log(`[${DISPLAY_NAME}] widgets: ${widgetTypes(widgets).join(', ')}`)
<!-- frontend/widgets/ZenCut.vue  ->  io type ZENCUT_ZEN_CUT -->
<script lang="ts">
import type { NodeDef, WidgetOptions } from '@/framework'
export const widgetOptions: WidgetOptions = {
  name: 'edit',
  fill: true,
  minHeight: 560,
  default: {},
}
export const nodeDef: Omit<NodeDef, 'widgets'> = { is: 'zencut.Edit', minSize: [980, 680] }
</script>

<script setup lang="ts">
const props = defineProps<{
  widget?: { value: unknown; callback?: (v: unknown) => void }
  node?: unknown
}>()
</script>

<template><div>…</div></template>
# nodes/cut/node.py
from comfy_api.latest import io
from .._zenkit import CATEGORY, node_id
from .._zenkit.io_types import widget_type

EditType = widget_type("ZenCut", dict)            # -> ZENCUT_ZEN_CUT

class ZenCutEdit(io.ComfyNode):
    @classmethod
    def define_schema(cls):
        return io.Schema(node_id=node_id("Edit"), display_name="ZenCut", category=CATEGORY,
                         inputs=[EditType.Input("edit")],
                         outputs=[io.Video.Output(display_name="video")])
# nodes/__init__.py
from ._zenkit import comfy_entrypoint  # noqa: F401
# __init__.py
from .nodes import comfy_entrypoint  # noqa: F401
WEB_DIRECTORY = "./web"
pnpm exec zenkit-nodekit sync   # writes nodes/_zenkit/ from pack.json; commit it

Plus vite.config.mts, tsconfig.json and package.json, covered under Build setup.

Project layout

Both real packs use the same shape:

pack.json                   identity: namespace, displayName, category
package.json                nodekit (dev) + zenkit-ui (dep)
.pnpmfile.cjs               optional: link a local ZenKit checkout (ZENKIT_DEV)
vite.config.mts             zenPluginConfig({ srcDir: './frontend', outDir: 'web' })
tsconfig.json
__init__.py                 V3: re-exports comfy_entrypoint; WEB_DIRECTORY = "./web"
nodes/__init__.py           from ._zenkit import comfy_entrypoint
nodes/_zenkit/              generated by `zenkit-nodekit sync` (identity, io types, discovery,
                            media routes, jobs); committed, never edited
frontend/framework.ts       createNodekit(manifest), re-exports
frontend/main.ts            glob widgets -> registerNodes
frontend/widgets/*.vue      one file = one widget io type
frontend/nodes/*.ts         optional: explicit defineNode() modules
web/                        build output (gitignored, built in CI)

Note: zenPluginConfig defaults to src/ and js/. Both real packs use frontend/ and web/ by passing srcDir and outDir.

pack.json and identity

pack.json holds the pack's identity. createNodekit(manifest) derives every id from it, so node ids, io types, routes and setting keys agree.

Field Used for
namespace Node-id prefix, io-type prefix, route prefix, extension name. Dots nest (acme.labs).
displayName Console messages
category Root menu category, exposed as CATEGORY
python Optional settings for zenkit-nodekit sync (below)
Identity member namespace: "nynxz" gives
NAMESPACE, DISPLAY_NAME, CATEGORY the manifest values
ROUTE_PREFIX /nynxz (acme.labs gives /acme/labs)
nodeId('Image.Compare') nynxz.Image.Compare
typeId('LoraStack'), typeId('LORA_STACK') NYNXZ_LORA_STACK
route('media') /nynxz/media (no api.apiURL prefix)
settingId('SaveImage.BaseDirectory') nynxz.SaveImage.BaseDirectory

createNodekit is a factory rather than a configure() call because widget modules call nodeId() while they are being evaluated, before main.ts runs. Keep it in one framework.ts and import from @/framework everywhere else.

The Python half reads the same file through zenkit-nodekit sync, which writes it into nodes/_zenkit/identity.py as NAMESPACE, CATEGORY, WIDGET_IO_PREFIX, node_id(), route(), type_id() and setting_id(). Python never spells the namespace out; after editing pack.json, run sync again (and sync --check in CI catches forgetting to).

createNodekit

createNodekit(pack: PackManifest): Nodekit

Nodekit is the Identity above plus these bound helpers:

Member Signature Purpose
registerNodes (defs: NodeDef[], extensionName?: string) => void Register every def as one ComfyUI extension (name defaults to NAMESPACE)
discoverWidgets (modules: Record<string, unknown>) => NodeDef[] Turn a widgets/*.vue glob into defs by filename
discoverNodes (modules: Record<string, unknown>) => NodeDef[] Flatten a glob of modules that default-export a NodeDef or NodeDef[]
widgetTypes (modules) => string[] The io types a widget glob registers, sorted
mountWidget (node, opts: MountOptions) => { widget: DOMWidget } Mount a component in a node body yourself (details)
addNodeHeaderButton (node, widgetEl, opts) => NodeHeaderButtonHandle A button in the node title bar (details)
routeUrl (path: string) => string The full URL of a pack route (route(path) through ComfyUI's api.apiURL, so base-path safe): what to fetch
mediaLibrary MediaLibrary Lists ComfyUI's media folders through the pack's /<ns>/media route (details).

Everything else (defineNode, useDragSurface, viewUrl, openZenPanel, hasZenPanels, openViewer, registerSlotLink, the media-ref helpers, the types) needs no identity, and framework.ts re-exports it with export *. The identity-bound helpers exist only on the createNodekit result, so the two never collide.

Widgets by filename

discoverWidgets turns each file in the glob into one NodeDef:

  1. The file name becomes the component name: widgets/LoraStack.vue gives LoraStack.
  2. typeId(name) gives the io type: NYNXZ_LORA_STACK.
  3. The def is { ...nodeDef, widgets: [{ type, component: default, ...widgetOptions }] }.

Files are processed in path order. A file without a default export, or a second file mapping to the same io type, is skipped with a console warning.

One file gives one node def. When a second node reuses the same widget type, declare it with defineNode in a separate glob (NynxzNodes uses frontend/nodes/*.ts):

// frontend/nodes/previewImage.ts
import { defineNode, typeId } from '@/framework'
import SavePreview, { widgetOptions } from '@/widgets/SavePreview.vue'

export default defineNode({
  is: 'nynxz.Preview.Image',
  minSize: [300, 320],
  hideOutputImages: true,
  widgets: [{ ...widgetOptions, type: typeId('SavePreview'), component: SavePreview }],
})
// frontend/main.ts
const widgets = import.meta.glob('./widgets/*.vue', { eager: true })
const extraNodes = import.meta.glob('./nodes/*.ts', { eager: true })
registerNodes([...discoverWidgets(widgets), ...discoverNodes(extraNodes)])

The convention is frontend/nodes/*.ts, but any glob of modules that default-export defineNode(...) (or an array of them) works.

Widget io type naming

The io type is a plain string shared by both halves. TypeScript derives it from the filename; Python derives it from the same component name.

Component name io type (namespace nynxz)
LoraStack NYNXZ_LORA_STACK
H3Studio NYNXZ_H3_STUDIO
SavePreview NYNXZ_SAVE_PREVIEW

Rule: NAMESPACE.toUpperCase() with . and - replaced by _, then _, then the name in SCREAMING_SNAKE_CASE (([a-z0-9])([A-Z]) and ([A-Z]+)([A-Z][a-z]) get an underscore).

Python helper

nodes/_zenkit/io_types.py (vendored by zenkit-nodekit sync) is the Python half. Its prefix comes from pack.json, and type_id() applies the same rule as typeId(), dots and dashes included.

from .._zenkit.io_types import widget_type

LoraStackType = widget_type("LoraStack", list)   # -> NYNXZ_LORA_STACK
inputs = [LoraStackType.Input("stack", default=[])]
Helper Use
widget_type(name, value_type, doc=None) Declare a socketless widget input bound to widgets/<name>.vue. Use .Input(id, default=…) in the schema.
inline_widget(inp, name) Keep a normal input's socket type but draw it with widgets/<name>.vue (sets extra_dict["widgetType"]). Do not use io.WidgetInput(widget_type=…): that replaces the socket type too.
advanced(inp) Mark an input advanced via extra_dict (concrete Input classes don't forward extra_dict as a kwarg).
widget_io_type(name) The raw string

The socketless input drops tooltip, because ComfyUI would show it over the whole node body.

Pack-specific helpers build on these. NynxzNodes keeps its color_input() (a STRING drawn by widgets/ColorField.vue) in its own nodes/_widgets.py, as inline_widget(io.String.Input(...), "ColorField").

Note: the io type is a bare string contract. If the two sides disagree, no widget renders and the prompt fails with "Required input is missing". zenkit-nodekit check-widgets diffs every widget_type("X" / inline_widget(…, "X") call in the node package against frontend/widgets/*.vue, both ways; put it in pnpm check.

The widget component contract

registerNodes mounts each widget with mountWidget, which creates a Vue app with two props:

Prop Type Notes
widget DOMWidget widget.value is what serialises into the workflow and the prompt
node the LiteGraph node For node.id, node.graph, node.properties, …

widget.value is not reactive. Keep state in Vue reactivity and write it through, calling widget.callback so ComfyUI notices. ZenCut's useEdit:

export function useEdit(widget: DOMWidget | undefined) {
  const edit = reactive(toEdit(widget?.value))
  if (widget)
    effectScope(true).run(() =>
      watch(
        edit,
        () => {
          const value = JSON.parse(JSON.stringify(edit))
          widget.value = value
          try {
            widget.callback?.(value)
          } catch {
            /* no callback */
          }
        },
        { deep: true },
      ),
    )
  return edit
}

Note: the real packs pass props.widget into a composable (which also avoids vue/no-mutating-props), and they declare a local interface DOMWidget { value; callback? } with widget optional rather than importing nodekit's DOMWidget. Either works at runtime.

The widget's input name and default come from the Python schema unless widgetOptions overrides them.

widgetOptions and nodeDef

A widget file can export two extra things from a plain <script lang="ts"> block (module scope, beside <script setup>):

// NynxzNodes frontend/widgets/ImageCompare.vue
export const widgetOptions: WidgetOptions = {
  name: 'compare_view',
  minHeight: 200,
  fill: true,
  serialize: false, // preview URLs go stale on restart
  default: { beforeImages: [], afterImages: [] },
}
export const nodeDef: Omit<NodeDef, 'widgets'> = {
  is: 'nynxz.Image.Compare',
  minSize: [440, 360],
  output: {
    widget: 'compare_view',
    from: (o) => ({
      beforeImages: (o.a_images ?? []).map(viewUrl),
      afterImages: (o.b_images ?? []).map(viewUrl),
    }),
  },
}

WidgetOptions

WidgetOptions is NodeWidgetDef without type and component.

Option Type Default Description
name string the Python input id Widget name. output.widget must match it.
minHeight number — Minimum body height in px
minWidth number — Narrowest the node may be resized to
fill boolean false Stretch to fill the node body (stays user-resizable) instead of growing to fit content
dragThrough boolean false Visual-only body: presses fall through to drag the node. Interactive children need pointer-events: auto.
serialize boolean true false for transient values such as run results
default unknown the Python schema default Initial value

NodeDef

Field Type Description
is string | string[] comfyClass / V3 node_id this applies to. An array shares one def across node classes. Optional: widgets register globally by io type.
minSize [number, number] Floor applied on create: setSize(max(current, min))
hideOutputImages boolean Hide ComfyUI's default image strip
widgets NodeWidgetDef[] Widgets to mount. Each io type registers once (first declaration wins).
output { widget: string; from: (output) => unknown } On execute, map the node's ui output into the widget via its callback. Python side: io.NodeOutput(..., ui=...).
slotLinks SlotLinkDef[] { input?, output?, spawn }: middle-click that slot to spawn and wire spawn. ZenKit only.
settings Record<string, unknown>[] ComfyUI settings merged into the extension. Build ids with settingId().

defineNode(def) returns def unchanged; it exists for typing.

Note: output never assigns widget.value. DOM widgets ignore serialize: false in ComfyUI, so writing the value would persist run results into the workflow. nodekit calls widget.callback(value) instead; your component handles it.

Python registration (V3)

The vendored package provides the entry point; the node package re-exports it:

# nodes/__init__.py
from ._zenkit import comfy_entrypoint  # noqa: F401

comfy_entrypoint calls setup() (the media routes, then the pack's own route modules) and returns an extension whose get_node_list() is load_nodes(). A pack that needs its own ComfyExtension calls setup() and load_nodes() from it instead.

load_nodes imports every module under nodes/ whose name does not start with _, collects the io.ComfyNode subclasses defined there, skips abstract bases, logs (does not raise) import errors, and de-duplicates by node_id.

V3 gotcha: expose comfy_entrypoint and never also define NODE_CLASS_MAPPINGS. ComfyUI checks for NODE_CLASS_MAPPINGS first and returns early, so comfy_entrypoint is never called and nothing registers.

Python helpers (zenkit-nodekit sync)

nodekit ships the Python half too, as plain modules that the zenkit-nodekit bin vendors into the pack. Vendoring rather than a pip dependency keeps a pack installable from the ComfyUI registry with nothing extra; the generated files are committed like any other source.

zenkit-nodekit sync            # write nodes/_zenkit/ from pack.json; only changed files are touched
zenkit-nodekit sync --check    # exit 1 when a file is stale, missing or no longer generated
zenkit-nodekit check-widgets   # exit 1 when a widget type has no .vue, or a .vue no widget type

Run from the pack root, or pass --root <dir>. Every generated file starts with a header naming nodekit's version; editing one is pointless, since the next sync overwrites it and --check fails until then. Upgrading nodekit makes --check fail until you sync, which is the point: both halves move together.

Module Public API
__init__ comfy_entrypoint(), setup(), load_nodes(), NODE_PACKAGE, and everything from identity
identity NAMESPACE, DISPLAY_NAME, CATEGORY, ROUTE_PREFIX, WIDGET_IO_PREFIX, LOG_PREFIX, node_id(), type_id(), route(), setting_id()
io_types widget_type(), inline_widget(), advanced(), widget_io_type(), widget_input()
autodiscover load_nodes(package, paths)
media_api register(routes), path_of(ref), resolve(ref), media_info(path), list_media(), find_input(), media_kind()
jobs Job(name, total=0, …) with update() / done() / fail() (a context manager), emit()
server routes (ComfyUI's route table, None outside ComfyUI), route(), send(event, payload, client_id=None), request_allowed(request)

Importing a module never registers anything; setup() does, once.

pack.json python

All optional:

{
  "namespace": "nynxz",
  "displayName": "Nynxz's Custom Nodes",
  "category": "Nynxz",
  "python": {
    "package": "nodes",
    "vendor": "_zenkit",
    "routes": ["server.api"],
    "logPrefix": "[NynxzNodes]",
    "widgets": "frontend/widgets"
  }
}
Field Default Meaning
package "nodes" The node package (it must have an __init__.py)
vendor "_zenkit" The generated package's name inside it. Must start with _, so autodiscovery skips it.
routes [] The pack's own route modules, relative to package. setup() imports each; a failure is logged, never fatal.
logPrefix "[<displayName>]" Prefix of the generated code's log lines
widgets "frontend/widgets" Where check-widgets looks for components

The pack's own routes

List the module in python.routes and write it against _zenkit.server. Nothing in _zenkit needs editing:

# nodes/_routes.py  (pack.json: "python": { "routes": ["_routes"] })
from aiohttp import web
from ._zenkit.server import route, routes

if routes is not None:  # None outside ComfyUI

    @routes.get(route("studio/state"))   # -> /<ns>/studio/state
    async def studio_state(request):
        return web.json_response({...})

An underscore name keeps autodiscovery from also importing it as a node module. Modules that autodiscovery imports anyway (NynxzNodes' */api.py) keep working as they are.

Guarding routes that change state

ComfyUI's origin middleware blocks cross-site pages, but not DNS rebinding, and it never checks the content type. Every route that changes state (POST, PUT, PATCH, DELETE, or a GET with side effects beyond a cache) starts with request_allowed, which returns None or the 403 to send:

from ._zenkit.server import request_allowed, route, routes

if routes is not None:

    @routes.post(route("studio/state"))
    async def studio_save(request):
        if (denied := request_allowed(request)) is not None:
            return denied
        body = await request.json()
        ...
Check Rule
Host While ComfyUI listens only on loopback (the default), the Host must be localhost, 127.0.0.1 or [::1]. --listen <addr> also allows that address and this machine's own name; a wildcard --listen (0.0.0.0, ::) skips it. Behind a reverse proxy, list the names it serves ComfyUI under in Settings ▸ ZenKit ▸ Security ▸ Allowed host names (comma-separated); the proxy must pass the Host through.
Origin When present, its host and port must equal the Host header's.
Content type A non-GET request with a body must send application/json (a cross-site form can't without a CORS preflight). Send headers: { 'Content-Type': 'application/json' } from the frontend.

A refused request gets 403 {"error": "forbidden: …"} and one warning in the log per path. The media routes run the same check, so a rebound page can't list or probe ComfyUI's folders either.

Media refs in Python

media_api.path_of(ref) is the containment check for loading a ref a widget stored: it accepts only input/…, output/… and temp/…, refuses anything that resolves outside that folder, and raises ValueError with a message fit for the user when the file is missing. resolve(ref) is the same but returns None.

Job progress

from .._zenkit.jobs import Job

with Job("Export batch", total=len(files)) as job:   # "start"
    for i, f in enumerate(files, 1):
        export(f)
        job.update(current=i, message=f.name)          # "progress"
# "done" on a clean exit; "error" with the exception's text if the block raised

Each event is ZenKit's zenkit.job with the job's full state (see Jobs): ids are <namespace>:<time>:<n>, source is DISPLAY_NAME. Without ZenKit, or outside ComfyUI, the events go nowhere. Run the work in a task or thread so the route that starts it returns.

Build setup

vite.config.mts

import { zenPluginConfig } from '@nynxz/zenkit-nodekit/vite'

export default zenPluginConfig({
  configUrl: import.meta.url,
  srcDir: './frontend',
  outDir: 'web', // matches WEB_DIRECTORY in __init__.py
})
Option Default Description
name package.json name Pack name; also the data-extension attribute on the injected <style>
configUrl required Always import.meta.url
srcDir './src' Frontend source; @ aliases to it
entry ${srcDir}/main.ts Entry file
outDir 'js' Must match WEB_DIRECTORY
alias {} Extra aliases, merged after @
sharedRuntime false Import vue, @nynxz/zenkit-ui, @nynxz/zenkit-client from /zenkit/runtime/ instead of bundling. Makes the pack require ComfyUI-ZenKit. Node packs leave it off.

What the preset does:

  • Externalises @comfy/app and @comfy/api and rewrites them to ComfyUI's /scripts/app.js and /scripts/api.js.
  • Folds all CSS into main.js as an injected <style> (ComfyUI only loads .js from WEB_DIRECTORY).
  • Sets resolve.dedupe: ['vue']. Two Vue copies silently break reactivity; this happens as soon as a @nynxz/zenkit-* package is consumed through a link.
  • Defines process.env.NODE_ENV = "production", outputs one unminified main.js.
  • Names code split off by a dynamic import() chunks/*.mjs: ComfyUI imports every .js under WEB_DIRECTORY at startup, which would load it all up front.

tsconfig.json

What works in both packs:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "noEmit": true,
    "paths": { "@/*": ["./frontend/*"] },
    "typeRoots": ["node_modules/@types"],
  },
  // `files`, not `include`: `include` entries are filtered by `exclude`, so node_modules would drop it.
  "files": ["./node_modules/@nynxz/zenkit-nodekit/comfy.d.ts"],
  "include": ["frontend/**/*.ts", "frontend/**/*.vue"],
  "exclude": ["node_modules", "web"],
}

Add frontend/vite-env.d.ts with /// <reference types="vite/client" /> for import.meta.glob.

Note: listing comfy.d.ts under include while excluding node_modules drops it silently. Use files. Extending @nynxz/zenkit-nodekit/tsconfig.pack.json is optional; neither real pack does.

package.json

{
  "type": "module",
  "scripts": {
    "build": "vite build",
    "dev": "vite build --watch",
    "typecheck": "vue-tsc --noEmit",
    "sync": "zenkit-nodekit sync",
    "check": "zenkit-nodekit sync --check && zenkit-nodekit check-widgets && vue-tsc --noEmit && vite build"
  },
  "dependencies": { "@nynxz/zenkit-ui": "^0.3.0" },
  "devDependencies": {
    "@nynxz/zenkit-nodekit": "^0.3.0",
    "vite": "^6.3.5",
    "@vitejs/plugin-vue": "^5.2.3",
    "vue-tsc": "^3.3.11"
  },
  "peerDependencies": { "vue": "3.5.42" }
}

Installing ZenKit

Install both packages from npm:

pnpm add @nynxz/zenkit-ui@^0.3.0
pnpm add -D @nynxz/zenkit-nodekit@^0.3.0

Developing against a local ZenKit

To work on ZenKit and a pack at once, add this .pnpmfile.cjs to the pack. When ZENKIT_DEV points at a ZenKit checkout it rewrites the two dependencies to link: and skips the lockfile; without it, installs come from npm as usual.

const dir = process.env.ZENKIT_DEV;
const local = dir && {
  "@nynxz/zenkit-ui": `link:${dir}/packages/ui`,
  "@nynxz/zenkit-nodekit": `link:${dir}/packages/nodekit`,
};
function readPackage(pkg) {
  if (!local) return pkg;
  for (const field of ["dependencies", "devDependencies"])
    for (const name of Object.keys(pkg[field] ?? {})) if (local[name]) pkg[field][name] = local[name];
  return pkg;
}
module.exports = {
  hooks: {
    readPackage,
    updateConfig: (config) => (local ? { ...config, useLockfile: false, optimisticRepeatInstall: false } : config),
  },
};
ZENKIT_DEV=../ZenKit pnpm install                                          # link the checkout
pnpm install --frozen-lockfile --config.optimistic-repeat-install=false    # back to npm
  • A link resolves through the package's exports, which point at dist/. Run pnpm build in ZenKit first, and again after changing it, or the pack sees stale code.
  • The lockfile is not written while linked, so it keeps describing the npm versions.

Shipping

web/ is gitignored, and pyproject.toml lists it under [tool.comfy] includes = ["web"] so the registry package still contains it. NynxzNodes' publish workflow builds web/ and force-adds it before comfy node publish (comfy-cli only archives tracked files). Use node_modules (no trailing slash) in .gitignore, because under a pnpm workspace it can be a symlink.

Frontend-only nodes

A node with no Python class (NynxzNodes' Group Switcher) registers its own LiteGraph type and calls mountWidget itself. See Nodekit panels and canvas.

Gotchas

Gotcha Fix
NODE_CLASS_MAPPINGS defined alongside comfy_entrypoint Remove it; nothing registers otherwise
widget.value changed but nothing updates It isn't reactive. Hold state in ref/reactive, write through, call widget.callback
Run results persisted into the workflow Use nodeDef.output + serialize: false, never assign widget.value
No widget, "Required input is missing" TS filename and Python widget_type name disagree; zenkit-nodekit check-widgets
Python routes or io types use an old namespace pack.json changed without zenkit-nodekit sync; sync --check in CI
Second node needs the same widget defineNode in another glob; one def per widget file
Two Vue copies Keep dedupe: ['vue'] (the preset does this)
comfy.d.ts types missing Put it in files, not include
Linked ZenKit changes not picked up pnpm build in ZenKit

Clone this wiki locally