Files
2026-02-16 22:56:21 -08:00

36 KiB

Creating View Extensions for ComfyUI_Viewer

This guide covers how to create view extensions for ComfyUI_Viewer. Extensions can add new content types, interactive UIs, and custom visualizations.

Table of Contents

Simple Frontend Views (JavaScript + Parser)

Embedded Web Applications (Full React/Vue/Svelte Apps)

General


Extension System Overview

The Content Viewer has a dual extension system:

  • Frontend Views - JavaScript modules that render content in the browser iframe
  • Backend Parsers - Python modules that process objects (tensors, custom types) into displayable data

Extensions can be added in two ways:

  1. Directly - Add files to the web/views/ and modules/parsers/ directories
  2. As a Package - Create a standalone GitHub repository that users can download and install automatically

Creating a View Extension Package

View Extension packages are standalone GitHub repositories that can be distributed independently. Users download the repository as a .zip file, place it in ComfyUI_Viewer/extensions/, and the extension is automatically installed on ComfyUI startup.

Project Structure

Your extension repository should follow this structure:

ComfyUI_Viewer_MyAwesomeView/
├── modules/
│   └── parsers/
│       └── my_awesome_view_parser.py    # Backend parser (Python)
├── nodes/
│   └── my_awesome_view_nodes.py         # Custom ComfyUI nodes (optional)
├── web/
│   └── views/
│       ├── my_awesome_view.js           # Frontend view (JavaScript)
│       └── my_awesome_view_scripts/     # Additional scripts/assets (optional)
│           ├── library.min.js
│           └── styles.css
├── requirements.txt                      # Python dependencies (optional)
└── README.md                            # Documentation

Naming Convention

  • Repository name: ComfyUI_Viewer_<ExtensionName>_Extension (e.g., ComfyUI_Viewer_ImageSearch_Extension, ComfyUI_Viewer_AudioPlayer_Extension)
  • Parser file: <extension_name>_parser.py (must end in _parser.py for auto-discovery to separate from other module files you may have)
  • View file: <extension_name>.js (any .js file in web/views/)
  • Nodes file: <extension_name>_nodes.py (exports NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS)

Installation Process

When users place your .zip file in ComfyUI_Viewer/extensions/ and restart ComfyUI, the auto-installer:

  1. Extracts files from your package to the correct locations:

    • modules/parsers/*.py → ComfyUI_Viewer/modules/parsers/
    • web/views/* → ComfyUI_Viewer/web/views/
    • nodes/*.py → ComfyUI_Viewer/nodes/
  2. Installs dependencies from requirements.txt using ComfyUI's Python interpreter.

  3. Creates a log file in extensions/logs/ to track successful installation. If this file is removed, the extension will be reinstalled on next startup.

GitHub Distribution

To enable easy installation via zip download:

  1. Host your extension as a public GitHub repository
  2. Users download by clicking Code → Download ZIP on GitHub
  3. GitHub automatically names the zip <RepoName>-main.zip or <RepoName>-master.zip
  4. The installer handles the GitHub folder structure.

Example workflow for users:

1. Go to https://github.com/YourUsername/ComfyUI_Viewer_MyAwesomeView
2. Click "Code" → "Download ZIP"
3. Place the downloaded .zip in ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/
4. Restart ComfyUI
5. Extension is installed (hopefully)!

Extension Example

Here's a minimal "Hello World" extension that demonstrates the core concepts:

  • Input: Receives a message string from the workflow
  • Display: Shows the message with a text input for a reply
  • Output: Sends the user's reply back to the workflow

1. Parser (modules/parsers/hello_parser.py)

"""
Hello Parser - Minimal example of a ComfyUI_Viewer extension parser.
"""

import json
from .base_parser import BaseParser


class HelloParser(BaseParser):
    """Parser for hello/reply messages."""
    
    PARSER_NAME = "hello"
    PARSER_PRIORITY = 50
    INPUT_MARKER = "$WAS_HELLO$"
    OUTPUT_MARKER = "$WAS_HELLO_OUTPUT$"
    
    @classmethod
    def detect_input(cls, content) -> bool:
        """Detect strings starting with 'Hello'."""
        return isinstance(content, str) and content.lower().startswith("hello")
    
    @classmethod
    def handle_input(cls, content, logger=None) -> dict:
        """Wrap the hello message for the frontend view."""
        data = {"message": content}
        
        return {
            "display_content": cls.INPUT_MARKER + json.dumps(data),
            "output_values": [content],
            "content_hash": f"hello_{hash(content) & 0xFFFFFFFF}",
        }
    
    @classmethod
    def detect_output(cls, content: str) -> bool:
        """Check for our output marker."""
        return isinstance(content, str) and content.startswith(cls.OUTPUT_MARKER)
    
    @classmethod
    def parse_output(cls, content: str, logger=None) -> dict:
        """Parse the reply from the frontend."""
        json_str = content[len(cls.OUTPUT_MARKER):]
        data = json.loads(json_str)
        reply = data.get("reply", "")
        
        return {
            "output_values": [reply],
            "display_text": f"Reply: {reply}",
            "content_hash": f"hello_out_{hash(reply) & 0xFFFFFFFF}",
        }

2. View (web/views/hello.js)

// Hello View - Minimal example of a ComfyUI_Viewer extension view.
import { BaseView, escapeHtml } from "./base_view.js";

class HelloView extends BaseView {
  static id = "hello";
  static displayName = "Hello";
  static isUI = false;
  static priority = 50;
  
  static INPUT_MARKER = "$WAS_HELLO$";
  static OUTPUT_MARKER = "$WAS_HELLO_OUTPUT$";

  static detect(content) {
    if (typeof content === "string" && content.startsWith(this.INPUT_MARKER)) {
      return 100;
    }
    return 0;
  }

  static getContentMarker() {
    return this.INPUT_MARKER;
  }

  static render(content, theme) {
    const jsonStr = content.slice(this.INPUT_MARKER.length);
    const data = JSON.parse(jsonStr);
    
    return `
      <div style="padding: 24px; font-family: sans-serif;">
        <h2 style="color: ${theme.fg};">Message Received:</h2>
        <p style="color: ${theme.fg}; font-size: 18px;">${escapeHtml(data.message)}</p>
        
        <h3 style="color: ${theme.fg}; margin-top: 24px;">Your Reply:</h3>
        <input type="text" id="reply-input" placeholder="Type your reply..." 
               style="width: 100%; padding: 8px; font-size: 16px;">
        <button id="send-btn" style="margin-top: 12px; padding: 8px 16px; 
                background: ${theme.accent}; color: white; border: none; cursor: pointer;">
          Send Reply
        </button>
      </div>
      <script>
        document.getElementById('send-btn').addEventListener('click', () => {
          const reply = document.getElementById('reply-input').value;
          window.parent.postMessage({
            type: 'hello-output',
            data: { reply: reply },
            nodeId: window.WAS_NODE_ID
          }, '*');
        });
      </script>
    `;
  }

  static getMessageTypes() {
    return ["hello-output"];
  }

  static handleMessage(messageType, data, node, app, iframeSource) {
    if (messageType !== "hello-output") return false;

    const outputString = this.OUTPUT_MARKER + JSON.stringify(data.data);
    
    const viewStateWidget = node.widgets?.find(w => w.name === "view_state");
    if (viewStateWidget) {
      const viewState = JSON.parse(viewStateWidget.value || "{}");
      viewState.hello_output = outputString;
      viewStateWidget.value = JSON.stringify(viewState);
      node.setDirtyCanvas?.(true, true);
      return true;
    }
    return false;
  }
}

export default HelloView;

3. README (README.md)

# ComfyUI_Viewer_Hello

A minimal "Hello World" extension for ComfyUI_Viewer demonstrating the extension system.

## Installation

1. Download this repository as a ZIP file
2. Place the ZIP in `ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/`
3. Restart ComfyUI
4. Add `"hello.js"` to `web/views/view_manifest.js`

## Usage

1. Connect any string starting with "Hello" to the Content Viewer
2. Type a reply in the text field
3. Click "Send Reply" to output your reply from the node

How It Works

  1. Parser detects input: detect_input() returns True for strings starting with "Hello"
  2. Parser wraps content: handle_input() adds the $WAS_HELLO$ marker so the view can detect it
  3. View renders UI: render() displays the message and a reply input field
  4. User sends reply: Button click sends postMessage to parent with type hello-output
  5. View handles message: handleMessage() stores the reply in view_state.hello_output
  6. Parser parses output: On next execution, parse_output() extracts the reply as the node's output

Automatic View Registration

Extension views are automatically discovered by the view loader. When the installer extracts your view file to web/views/, it updates extension_views.json which the view loader reads at runtime. No manual manifest registration is required.

Extension Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                        ComfyUI                              │
├─────────────────────────────────────────────────────────────┤
│  Input Data          Backend Parser         Frontend View   │
│  (tensors, objects)  (Python)               (JavaScript)    │
│                                                             │
│       ──────────────►──────────────────────►                │
│                      │                      │               │
│                      ▼                      ▼               │
│               display_content          render()             │
└─────────────────────────────────────────────────────────────┘
  • Backend Parsers (modules/parsers/*_parser.py) - Process Python objects (tensors, images, custom types) into JSON/strings for the frontend
  • Frontend Views (web/views/*.js) - Detect content patterns and render HTML in the sandboxed iframe

Adding a Frontend View

Frontend views handle content rendering in the browser. They detect content patterns and produce HTML.

Quick Start

  1. Create your view file in web/views/:
// web/views/myview.js
import { BaseView, escapeHtml } from "./base_view.js";

class MyView extends BaseView {
  static id = "myview";           // Unique identifier
  static displayName = "My View"; // Shown in UI
  static priority = 50;           // Higher = checked first

  // Return score > 0 if this view should handle the content
  static detect(content) {
    if (content.includes("MY_SPECIAL_MARKER")) {
      return 100; // High confidence
    }
    return 0; // Cannot handle
  }

  // Render content to HTML for the iframe
  static render(content, theme) {
    return `<div style="color: ${theme.fg}; background: ${theme.bg};">
      <h1>My Custom View</h1>
      <pre>${escapeHtml(content)}</pre>
    </div>`;
  }

  // Optional: Add custom CSS
  static getStyles(theme) {
    return `
      body { padding: 16px; }
      h1 { color: ${theme.accent}; }
    `;
  }
}

export default MyView;
  1. Register in the manifest - Add your file to web/views/view_manifest.js:
export const VIEW_FILES = [
  "canvas.js",
  "html.js",
  // ... existing views ...
  "myview.js",  // Add your view here
];
  1. Restart ComfyUI - Your view will be automatically loaded

BaseView Interface

All views extend BaseView and can override these static properties and methods:

Property/Method Required Description
static id ✓ Unique string identifier
static displayName ✓ Human-readable name for UI
static priority ✓ Detection priority (higher = checked first)
static isUI Set to true for interactive UI views (hides Edit/Clear/Download buttons)
static detect(content) ✓ Return score (0-100+) for content matching
static render(content, theme) ✓ Return HTML string for iframe body
static getStyles(theme) Return CSS string for iframe
static getScripts() Return <script> tags for iframe
static loadScripts(basePath) Async load external scripts
static isReady() Return false if scripts still loading
static getContentMarker() Return marker prefix (e.g., $MY_VIEW$)
static getMessageTypes() Return array of postMessage types to handle
static handleMessage(type, data, node, app, source) Handle iframe messages
static getStateFromWidget(node) Extract saved state from node
static injectState(content, state) Inject state into content before render
static usesBaseStyles() Return false to skip base iframe CSS

Theme Tokens

The theme object passed to render() and getStyles() contains:

{
  bg: "#1a1a1a",        // Background color
  fg: "#cccccc",        // Foreground text color
  border: "#333333",    // Border color
  accent: "#4a9eff",    // Accent/link color
  // ... additional tokens
}

UI Views

Views that are interactive applications (rather than content displays) should set static isUI = true. This tells the Content Viewer to hide the Edit, Clear, and Download buttons in the controls bar, leaving only the Fullscreen button.

class MyInteractiveApp extends BaseView {
  static id = "my_app";
  static displayName = "My App";
  static priority = 100;
  static isUI = true;  // Hides Edit/Clear/Download buttons
  
  // ... rest of implementation
}

When to use isUI = true:

  • Interactive galleries or browsers (e.g., Image Search)
  • Drawing/compositing tools (e.g., Canvas)
  • Games or simulations
  • Any view where editing the raw content doesn't make sense

Built-in UI views:

  • Canvas - Infinite canvas image compositor
  • Image Search (extension) - Interactive image gallery

Adding a Backend Parser

Backend parsers process Python objects (tensors, custom types) into content the frontend can display. They are required when:

  • Input data is not a string (e.g., IMAGE tensors, model objects)
  • You need to convert Python objects to JSON/strings for the frontend view
  • Your view needs to output data back to the workflow (e.g., Canvas outputs IMAGE tensors)

Parser Discovery

Parsers are auto-discovered from *_parser.py files in modules/parsers/. Simply create a file ending in _parser.py and define a class extending BaseParser.

Minimal Example: Dictionary Parser

This example shows a simple parser that handles Python dictionaries:

# modules/parsers/dict_parser.py
"""Minimal parser example - handles Python dictionaries."""

import json
import hashlib
from .base_parser import BaseParser


class DictParser(BaseParser):
    """Parser for dictionary input."""
    
    PARSER_NAME = "dict"
    PARSER_PRIORITY = 50
    OUTPUT_MARKER = "$WAS_DICT$"
    
    @classmethod
    def detect_input(cls, content) -> bool:
        """Return True if content is a dictionary."""
        return isinstance(content, dict)
    
    @classmethod
    def handle_input(cls, content, logger=None) -> dict:
        """Convert dictionary to JSON for frontend display."""
        json_str = json.dumps(content, indent=2, default=str)
        content_hash = hashlib.md5(json_str.encode()).hexdigest()[:8]
        
        return {
            "display_content": cls.OUTPUT_MARKER + json_str,
            "output_values": [content],
            "content_hash": f"dict_{content_hash}",
        }
    
    @classmethod
    def detect_output(cls, content: str) -> bool:
        """Check if content has our marker."""
        return isinstance(content, str) and content.startswith(cls.OUTPUT_MARKER)
    
    @classmethod
    def parse_output(cls, content: str, logger=None) -> dict:
        """Parse JSON back to dictionary."""
        json_str = content[len(cls.OUTPUT_MARKER):]
        data = json.loads(json_str)
        
        return {
            "output_values": [data],
            "display_text": f"Dict with {len(data)} keys",
            "content_hash": f"dict_out_{hash(json_str[:50]) & 0xFFFFFFFF}",
        }

BaseParser Interface Reference

Method Required Description
PARSER_NAME ✓ Unique string identifier (should match frontend view id)
PARSER_PRIORITY ✓ Detection priority (higher = checked first)
OUTPUT_MARKER String prefix for output content detection
detect_input(content) Return True if parser handles this input type
handle_input(content, logger) Process input, return {display_content, output_values, content_hash}
detect_output(content) Return True if content has this parser's output marker
parse_output(content, logger) Convert frontend output to backend types
detect_state(state_data) Return True if parser handles this state data
parse_state(state_data, logger) Parse view state from frontend
detect_display_content(content) Return True if parser should prepare display content
prepare_display(content, logger) Prepare content for display
get_default_outputs(content, output_types, logger) Return default output values

Parser + View Integration

For a complete extension, you need both a backend parser and frontend view:

  1. Parser (modules/parsers/myext_parser.py):

    • detect_input() → Returns True for your data type
    • handle_input() → Converts Python objects to JSON with marker prefix
    • parse_output() → Converts frontend data back to Python types (if needed)
  2. View (web/views/myext.js):

    • detect() → Checks for marker prefix or content pattern
    • render() → Generates HTML from the JSON data
    • handleMessage() → Sends data back to backend (if needed)

Marker Convention: Use $WAS_VIEWNAME$ prefix (e.g., $WAS_AUDIO$) so the frontend view can reliably detect content meant for it.

Multi-View Detection

When multiple parsers return True from detect_input() for the same content, the system creates a multi-view payload:

# Example: IMAGE tensor detected by both CanvasParser and ObjectParser
# Result: User sees a view switcher to toggle between Canvas and Object views

This allows users to choose how they want to visualize the data:

  • Canvas view for compositing and editing
  • Object view for inspecting tensor metrics and statistics

Embedding Full Web Applications

For complex interactive tools that require a full web framework (React, Vue, Svelte, etc.), you can serve a complete web application from your extension using the /app functionality. This approach allows you to embed sophisticated applications (video editors, image galleries, drawing tools, etc.) directly into ComfyUI_Viewer.

Architecture Overview

Extension Package/
├── apps/
│   └── my_app/              # Built web app (HTML/JS/CSS)
│       ├── index.html
│       ├── assets/
│       │   ├── index-abc123.js
│       │   └── index-def456.css
│       └── ...
├── routes/
│   └── my_app_routes.py     # API routes to serve the app
├── nodes/
│   └── my_app_nodes.py      # ComfyUI nodes (optional)
├── modules/
│   └── parsers/
│       └── my_app_parser.py # Handles input/output data flow
└── web/
    └── views/
        └── my_app.js        # Minimal view that creates iframe

Key Components

1. API Route Registration (Python)

Create a routes file to register API endpoints for serving your built app:

# routes/my_app_routes.py
from aiohttp import web
from server import PromptServer
import os
import mimetypes
import logging

logger = logging.getLogger("WAS.MyApp.Routes")

def _get_my_app_dir():
    """Get path to the built app static files."""
    return os.path.join(os.path.dirname(__file__), "..", "apps", "my_app")

@PromptServer.instance.routes.get('/was/my_app/app/{path:.*}')
async def serve_my_app(request):
    """Serve the built app static files."""
    path = request.match_info.get('path', 'index.html')
    if not path:
        path = 'index.html'
    
    app_dir = _get_my_app_dir()
    file_path = os.path.join(app_dir, path)
    
    # Security: Prevent path traversal
    real_app_dir = os.path.realpath(app_dir)
    real_file_path = os.path.realpath(file_path)
    if not real_file_path.startswith(real_app_dir):
        return web.json_response({'error': 'Access denied'}, status=403)
    
    if not os.path.exists(real_file_path) or not os.path.isfile(real_file_path):
        return web.json_response({'error': 'File not found'}, status=404)
    
    # Determine content type
    content_type, _ = mimetypes.guess_type(real_file_path)
    if content_type is None:
        content_type = 'application/octet-stream'
    
    # Ensure correct MIME types for web assets
    if real_file_path.endswith('.js'):
        content_type = 'application/javascript'
    elif real_file_path.endswith('.css'):
        content_type = 'text/css'
    
    with open(real_file_path, 'rb') as f:
        data = f.read()
    
    return web.Response(
        body=data,
        content_type=content_type,
        headers={'Content-Length': str(len(data))}
    )

logger.info("[My App] API routes registered")

Important Notes:

  • Routes are automatically discovered and loaded from the routes/ directory at ComfyUI startup
  • The routes/ directory is separate from nodes/ to keep API endpoints organized
  • nodes/ is only for ComfyUI nodes that appear in the graph

If your app uses a bundler (Vite, Webpack, etc.), configure the base path to match your API route:

// vite.config.ts
export default {
  base: '/was/my_app/app/',  // Must match your API route
  // ...
}

This ensures all asset paths (JS, CSS, images) resolve correctly when served from ComfyUI.

2. Frontend View (JavaScript)

Create a minimal view that embeds your app in an iframe:

// web/views/my_app.js
import { BaseView } from "./base_view.js";

class MyAppView extends BaseView {
  static id = "my_app";
  static displayName = "My App";
  static priority = 100;
  static isUI = true;  // Hide Edit/Clear/Download buttons
  
  static INPUT_MARKER = "$WAS_MY_APP$";
  static OUTPUT_MARKER = "$WAS_MY_APP_OUTPUT$";

  static detect(content) {
    if (typeof content === "string" && content.startsWith(this.INPUT_MARKER)) {
      return 100;
    }
    return 0;
  }

  static getContentMarker() {
    return this.INPUT_MARKER;
  }

  /**
   * Build the app URL with embedding params and theme
   */
  static _buildAppUrl(theme) {
    const origin = window.location.origin;
    const params = new URLSearchParams({ embedded: 'true' });
    
    // Pass ComfyUI theme to the app
    if (theme) {
      if (theme.bg) params.set('theme_bg', theme.bg);
      if (theme.fg) params.set('theme_fg', theme.fg);
      if (theme.accent) params.set('theme_accent', theme.accent);
      // ... add more theme tokens as needed
    }
    
    return `${origin}/was/my_app/app/index.html?${params.toString()}`;
  }

  static render(content, theme) {
    const appUrl = this._buildAppUrl(theme);
    
    return `
      <div style="width: 100%; height: 100%; position: relative;">
        <iframe
          id="my-app-iframe"
          src="${appUrl}"
          style="width: 100%; height: 100%; border: none;"
          allow="clipboard-write"
        ></iframe>
      </div>
    `;
  }

  static usesBaseStyles() {
    return false;  // App provides its own styles
  }

  /**
   * Handle messages from the embedded app
   */
  static getMessageTypes() {
    return ["my-app-output"];
  }

  static handleMessage(messageType, data, node, app, iframeSource) {
    if (messageType !== "my-app-output") return false;

    const outputString = this.OUTPUT_MARKER + JSON.stringify(data);
    
    const viewStateWidget = node.widgets?.find(w => w.name === "view_state");
    if (viewStateWidget) {
      const viewState = JSON.parse(viewStateWidget.value || "{}");
      viewState.my_app_output = outputString;
      viewStateWidget.value = JSON.stringify(viewState);
      node.setDirtyCanvas?.(true, true);
      return true;
    }
    return false;
  }
}

export default MyAppView;

3. Communication Pattern (PostMessage)

Your embedded app communicates with ComfyUI using the PostMessage API:

Sending data to ComfyUI:

// Inside your React/Vue/etc app
window.parent.postMessage({
  type: 'my-app-output',
  data: { result: 'some data' }
}, '*');

Receiving data from ComfyUI:

// Inside your React/Vue/etc app
useEffect(() => {
  const handleMessage = (event: MessageEvent) => {
    if (event.data.type === 'comfyui-import-data') {
      // Handle data from ComfyUI workflow
      const data = event.data.data;
      // Update your app state...
    }
  };
  
  window.addEventListener('message', handleMessage);
  return () => window.removeEventListener('message', handleMessage);
}, []);

// Notify parent that app is ready
useEffect(() => {
  if (window.parent !== window) {
    window.parent.postMessage({ type: 'my-app-ready' }, '*');
  }
}, []);

4. Theme Integration

To match ComfyUI's theme, parse the URL parameters in your app:

// Inside your React/Vue/etc app
const params = new URLSearchParams(window.location.search);
const isEmbedded = params.get('embedded') === 'true';

if (isEmbedded) {
  // Apply ComfyUI theme
  const themeBg = params.get('theme_bg') || '#1a1a1a';
  const themeFg = params.get('theme_fg') || '#cccccc';
  const themeAccent = params.get('theme_accent') || '#4a9eff';
  
  // Set CSS variables or update your theme store
  document.documentElement.style.setProperty('--bg-color', themeBg);
  document.documentElement.style.setProperty('--text-color', themeFg);
  document.documentElement.style.setProperty('--accent-color', themeAccent);
}

5. Parser Integration

Your parser handles the data flow between ComfyUI nodes and your app:

# modules/parsers/my_app_parser.py
import json
from .base_parser import BaseParser

class MyAppParser(BaseParser):
    PARSER_NAME = "my_app"
    PARSER_PRIORITY = 100
    INPUT_MARKER = "$WAS_MY_APP$"
    OUTPUT_MARKER = "$WAS_MY_APP_OUTPUT$"
    
    @classmethod
    def detect_input(cls, content) -> bool:
        # Detect your input data type
        return isinstance(content, str) and content.startswith("MY_APP_DATA:")
    
    @classmethod
    def handle_input(cls, content, logger=None) -> dict:
        # Prepare data for the app
        data = {"input": content}
        return {
            "display_content": cls.INPUT_MARKER + json.dumps(data),
            "output_values": [content],
            "content_hash": f"my_app_{hash(content) & 0xFFFFFFFF}",
        }
    
    @classmethod
    def detect_output(cls, content: str) -> bool:
        return isinstance(content, str) and content.startswith(cls.OUTPUT_MARKER)
    
    @classmethod
    def parse_output(cls, content: str, logger=None) -> dict:
        # Parse output from the app
        json_str = content[len(cls.OUTPUT_MARKER):]
        data = json.loads(json_str)
        result = data.get("result", "")
        
        return {
            "output_values": [result],
            "display_text": f"Result: {result}",
            "content_hash": f"my_app_out_{hash(result) & 0xFFFFFFFF}",
        }

Complete Workflow: Building and Deploying Your App

Step 1: Develop Your Web Application

Build your application using your preferred framework (React, Vue, Svelte, etc.):

# Example with Vite + React
npm create vite@latest my-app -- --template react
cd my-app
npm install

# Configure base path in vite.config.ts
export default {
  base: '/was/my_app/app/',  // Must match your API route
  build: {
    outDir: 'dist',
    assetsDir: 'assets',
  }
}

# Build for production
npm run build

Your dist/ folder will contain:

dist/
├── index.html
├── assets/
│   ├── index-abc123.js
│   ├── index-def456.css
│   └── ...
└── ...

Step 2: Create Your Extension Package

Create the extension folder structure:

ComfyUI_Viewer_MyApp_Extension/
├── apps/
│   └── my_app/              # Copy your built app here
│       ├── index.html
│       └── assets/
├── nodes/
│   └── my_app_nodes.py      # API routes + ComfyUI nodes
├── modules/
│   └── parsers/
│       └── my_app_parser.py # Input/output handling
├── web/
│   └── views/
│       └── my_app.js        # Iframe wrapper view
└── README.md

Copy your built app:

cp -r my-app/dist/* ComfyUI_Viewer_MyApp_Extension/apps/my_app/

Step 3: Create ComfyUI Nodes (Optional)

Add custom nodes to generate input data for your app:

# nodes/my_app_nodes.py
from aiohttp import web
from server import PromptServer
import os
import mimetypes

# ... (API route code from section 1 above)

# Optional: Add ComfyUI nodes
class MyAppInputNode:
    @classmethod
    def INPUT_TYPES(cls):
        return {
            "required": {
                "data": ("STRING", {"default": ""}),
            }
        }
    
    RETURN_TYPES = ("STRING",)
    FUNCTION = "process"
    CATEGORY = "WAS/View"
    
    def process(self, data):
        # Prepare data for your app
        return (f"MY_APP_DATA:{data}",)

NODE_CLASS_MAPPINGS = {
    "CV My App Input": MyAppInputNode,
}

NODE_DISPLAY_NAME_MAPPINGS = {
    "CV My App Input": "CV My App Input",
}

Step 4: Test Locally

  1. Copy extension to ComfyUI:

    cp -r ComfyUI_Viewer_MyApp_Extension ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/
    
  2. Restart ComfyUI - The extension installer will:

    • Copy files to correct locations
    • Register your nodes
    • Make your app available at /was/my_app/app/
  3. Test in workflow:

    • Add your input node (if created)
    • Connect to Content Viewer
    • Run workflow - your app should load in the viewer

Step 5: Package for Distribution

Create a GitHub repository with your extension:

cd ComfyUI_Viewer_MyApp_Extension
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/YourUsername/ComfyUI_Viewer_MyApp_Extension
git push -u origin main

Users install by:

  1. Downloading ZIP from GitHub
  2. Placing in ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/
  3. Restarting ComfyUI

Step 6: Update Your App

When you update your app:

# Rebuild your app
cd my-app
npm run build

# Copy to extension
cp -r dist/* ../ComfyUI_Viewer_MyApp_Extension/apps/my_app/

# Commit and push
cd ../ComfyUI_Viewer_MyApp_Extension
git add apps/my_app/
git commit -m "Update app to v1.1"
git push

Users get updates by re-downloading the ZIP and replacing their installation.

Best Practices

  1. Security: Always validate and sanitize paths in API routes to prevent path traversal attacks
  2. MIME Types: Ensure correct Content-Type headers for JS/CSS/WASM files
  3. Base Path: Configure your bundler's base path to match your API route
  4. Theme Sync: Pass ComfyUI theme via URL params for seamless integration
  5. Embedded Detection: Use ?embedded=true to conditionally show/hide UI elements
  6. PostMessage: Use typed messages with clear naming conventions (e.g., my-app-output)
  7. State Persistence: Store output in view_state widget for workflow persistence
  8. Error Handling: Gracefully handle iframe load failures and communication errors

Deployment

When distributing your extension:

  1. Build your app using your bundler (Vite, Webpack, etc.)
  2. Copy built files to apps/my_app/ in your extension package
  3. Include in zip - Users download and extract to ComfyUI_Viewer/extensions/
  4. Auto-install - Extension installer copies files to correct locations

Troubleshooting

Content not updating

  • Ensure the connected node is executing (queue the workflow)
  • Try disconnecting and reconnecting the input

View not detecting content

  • Check the browser console (F12) for [WAS Viewer] messages
  • Verify your view's detect() returns a score > 0 for your content
  • Check priority - higher priority views are tested first

Canvas images not loading

  • Ensure images are in ComfyUI's input directory
  • Search for a folder that looks like was_viewer_032bb95c where the last part is a unique hash for that node's canvas content
  • Check for CORS errors in browser console

Custom view not appearing

  • Verify the file is listed in view_manifest.js
  • Check for JavaScript errors in browser console
  • Ensure export default YourViewClass is present

Extension not installing

  • Check extensions/logs/ for installation logs
  • Ensure zip file structure matches expected layout
  • Verify file names follow naming conventions