Skip to the content.

SSE Code Generation Specification

Captain NUXP

Overview

NUXP’s codegen currently generates C++ and TypeScript for SDK suite wrappers. This spec extends codegen to also generate Server-Sent Events (SSE) infrastructure, ensuring type safety between C++ event emission and TypeScript event reception.

Goals

  1. Single source of truth - Events defined once, generated for both C++ and TypeScript
  2. Type safety - Event payloads are typed on both ends
  3. Native EventSource - TypeScript client uses browser’s EventSource API (not polling)
  4. Auto-reconnection - Generated client handles disconnects with exponential backoff

Input Format: events.json

Location: codegen/src/config/events.json

{
  "endpoint": "/events/stream",
  "events": [
    {
      "name": "selection",
      "description": "Fired when the document selection changes",
      "payload": {
        "count": { "type": "number", "description": "Number of selected items" },
        "selectedIds": { "type": "number[]", "description": "Array of art handle IDs" }
      }
    },
    {
      "name": "document",
      "description": "Fired on document lifecycle events (open, close, switch)",
      "payload": {
        "type": { "type": "string", "enum": ["opened", "closed", "activated"], "description": "Event type" },
        "documentName": { "type": "string", "description": "Name of the document" }
      }
    },
    {
      "name": "layers",
      "description": "Fired when layers are added, removed, or reordered",
      "payload": {
        "layerCount": { "type": "number", "description": "Current number of layers" }
      }
    },
    {
      "name": "artChanged",
      "description": "Fired when art objects are modified",
      "payload": {
        "artIds": { "type": "number[]", "description": "IDs of changed art objects" },
        "changeType": { "type": "string", "enum": ["created", "modified", "deleted"], "description": "Type of change" }
      }
    },
    {
      "name": "version",
      "description": "Sent on initial connection with plugin version info",
      "payload": {
        "version": { "type": "string", "description": "Plugin version string" },
        "build": { "type": "string", "description": "Build timestamp" }
      }
    }
  ]
}

Type Mapping

JSON Type C++ Type TypeScript Type
"string" std::string string
"number" int or double number
"boolean" bool boolean
"number[]" std::vector<int> number[]
"string[]" std::vector<std::string> string[]
"object" nlohmann::json Record<string, unknown>

Generated Output: C++

File: generated/Events.hpp

#pragma once
// Auto-generated by NUXP Codegen - DO NOT EDIT

#include "SSE.hpp"
#include <nlohmann/json.hpp>
#include <string>
#include <vector>

namespace Events {

/**
 * Fired when the document selection changes
 * @param count Number of selected items
 * @param selectedIds Array of art handle IDs
 */
inline void EmitSelection(int count, const std::vector<int>& selectedIds) {
    nlohmann::json data;
    data["count"] = count;
    data["selectedIds"] = selectedIds;
    SSE::Broadcast("selection", data);
}

/**
 * Fired on document lifecycle events (open, close, switch)
 * @param type Event type: "opened", "closed", or "activated"
 * @param documentName Name of the document
 */
inline void EmitDocument(const std::string& type, const std::string& documentName) {
    nlohmann::json data;
    data["type"] = type;
    data["documentName"] = documentName;
    SSE::Broadcast("document", data);
}

/**
 * Fired when layers are added, removed, or reordered
 * @param layerCount Current number of layers
 */
inline void EmitLayers(int layerCount) {
    nlohmann::json data;
    data["layerCount"] = layerCount;
    SSE::Broadcast("layers", data);
}

/**
 * Fired when art objects are modified
 * @param artIds IDs of changed art objects
 * @param changeType Type of change: "created", "modified", or "deleted"
 */
inline void EmitArtChanged(const std::vector<int>& artIds, const std::string& changeType) {
    nlohmann::json data;
    data["artIds"] = artIds;
    data["changeType"] = changeType;
    SSE::Broadcast("artChanged", data);
}

/**
 * Sent on initial connection with plugin version info
 * @param version Plugin version string
 * @param build Build timestamp
 */
inline void EmitVersion(const std::string& version, const std::string& build) {
    nlohmann::json data;
    data["version"] = version;
    data["build"] = build;
    SSE::Broadcast("version", data);
}

} // namespace Events

Generated Output: TypeScript

File: generated/events.ts

/**
 * SSE Event Types and Client
 * Auto-generated by NUXP Codegen - DO NOT EDIT
 */

import { getApiUrl, sdkConfig } from './config';

// ============================================================================
// Event Payload Types
// ============================================================================

/** Fired when the document selection changes */
export interface SelectionEvent {
  /** Number of selected items */
  count: number;
  /** Array of art handle IDs */
  selectedIds: number[];
}

/** Fired on document lifecycle events (open, close, switch) */
export interface DocumentEvent {
  /** Event type */
  type: 'opened' | 'closed' | 'activated';
  /** Name of the document */
  documentName: string;
}

/** Fired when layers are added, removed, or reordered */
export interface LayersEvent {
  /** Current number of layers */
  layerCount: number;
}

/** Fired when art objects are modified */
export interface ArtChangedEvent {
  /** IDs of changed art objects */
  artIds: number[];
  /** Type of change */
  changeType: 'created' | 'modified' | 'deleted';
}

/** Sent on initial connection with plugin version info */
export interface VersionEvent {
  /** Plugin version string */
  version: string;
  /** Build timestamp */
  build: string;
}

// ============================================================================
// Event Type Union and Map
// ============================================================================

/** All possible event names */
export type EventName = 'selection' | 'document' | 'layers' | 'artChanged' | 'version';

/** Maps event names to their payload types */
export interface EventPayloadMap {
  selection: SelectionEvent;
  document: DocumentEvent;
  layers: LayersEvent;
  artChanged: ArtChangedEvent;
  version: VersionEvent;
}

/** Callback type for a specific event */
export type EventCallback<T extends EventName> = (data: EventPayloadMap[T]) => void;

/** Wildcard callback receives event name and data */
export type WildcardCallback = (event: { type: EventName; data: EventPayloadMap[EventName] }) => void;

// ============================================================================
// SSE Client Class
// ============================================================================

const RECONNECT_BASE_DELAY = 3000;
const MAX_RECONNECT_ATTEMPTS = 10;

/**
 * Server-Sent Events client for receiving plugin events.
 * Uses native EventSource with automatic reconnection.
 */
class SSEClient {
  private eventSource: EventSource | null = null;
  private listeners: Map<string, Set<Function>> = new Map();
  private reconnectAttempts = 0;
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
  private manuallyDisconnected = false;

  /**
   * Connect to the SSE endpoint
   */
  connect(): void {
    if (this.eventSource || sdkConfig.useMock) {
      return;
    }

    this.manuallyDisconnected = false;
    const url = getApiUrl('/events/stream');

    try {
      this.eventSource = new EventSource(url);

      this.eventSource.onopen = () => {
        console.log('[SSE] Connected');
        this.reconnectAttempts = 0;
      };

      this.eventSource.onerror = () => {
        console.error('[SSE] Connection error');
        this.handleDisconnect();
      };

      // Register handlers for each event type
      this.registerHandler('selection');
      this.registerHandler('document');
      this.registerHandler('layers');
      this.registerHandler('artChanged');
      this.registerHandler('version');
    } catch (error) {
      console.error('[SSE] Failed to create EventSource:', error);
      this.handleDisconnect();
    }
  }

  /**
   * Disconnect from the SSE endpoint
   */
  disconnect(): void {
    this.manuallyDisconnected = true;

    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }

    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }

    this.reconnectAttempts = 0;
  }

  /**
   * Subscribe to a specific event type
   */
  on<T extends EventName>(event: T, callback: EventCallback<T>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);

    return () => this.off(event, callback);
  }

  /**
   * Subscribe to all events
   */
  onAll(callback: WildcardCallback): () => void {
    if (!this.listeners.has('*')) {
      this.listeners.set('*', new Set());
    }
    this.listeners.get('*')!.add(callback);

    return () => {
      this.listeners.get('*')?.delete(callback);
    };
  }

  /**
   * Unsubscribe from an event
   */
  off<T extends EventName>(event: T, callback: EventCallback<T>): void {
    this.listeners.get(event)?.delete(callback);
  }

  /**
   * Check if connected
   */
  get isConnected(): boolean {
    return this.eventSource?.readyState === EventSource.OPEN;
  }

  private registerHandler(eventType: EventName): void {
    if (!this.eventSource) return;

    this.eventSource.addEventListener(eventType, (event: MessageEvent) => {
      try {
        const data = JSON.parse(event.data);
        this.dispatch(eventType, data);
      } catch (error) {
        console.error(`[SSE] Error parsing ${eventType} event:`, error);
      }
    });
  }

  private dispatch(eventType: EventName, data: any): void {
    // Call specific listeners
    this.listeners.get(eventType)?.forEach((cb) => {
      try {
        cb(data);
      } catch (error) {
        console.error(`[SSE] Error in ${eventType} listener:`, error);
      }
    });

    // Call wildcard listeners
    this.listeners.get('*')?.forEach((cb) => {
      try {
        (cb as WildcardCallback)({ type: eventType, data });
      } catch (error) {
        console.error('[SSE] Error in wildcard listener:', error);
      }
    });
  }

  private handleDisconnect(): void {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }

    if (this.manuallyDisconnected) {
      return;
    }

    if (this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
      this.reconnectAttempts++;
      const delay = RECONNECT_BASE_DELAY * Math.min(this.reconnectAttempts, 5);

      console.log(`[SSE] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);

      this.reconnectTimer = setTimeout(() => {
        this.reconnectTimer = null;
        this.connect();
      }, delay);
    } else {
      console.error('[SSE] Max reconnect attempts reached');
    }
  }
}

// ============================================================================
// Singleton Instance and Exports
// ============================================================================

/** Global SSE client instance */
export const sseClient = new SSEClient();

/** Connect to SSE endpoint */
export const connectSSE = () => sseClient.connect();

/** Disconnect from SSE endpoint */
export const disconnectSSE = () => sseClient.disconnect();

/** Subscribe to a specific event */
export const onEvent = <T extends EventName>(event: T, callback: EventCallback<T>) =>
  sseClient.on(event, callback);

/** Subscribe to all events */
export const onAllEvents = (callback: WildcardCallback) => sseClient.onAll(callback);

Integration with Existing Codegen

Changes to codegen/src/index.ts

Add after suite generation:

// Generate SSE events
if (!options.cppOnly) {
  await generateSSETypeScript(tsOutputDir, eventsConfig);
}
if (!options.tsOnly) {
  await generateSSECpp(cppOutputDir, eventsConfig);
}

New file: codegen/src/generator/SSEGenerator.ts

This generator:

  1. Reads events.json
  2. Generates Events.hpp for C++
  3. Generates events.ts for TypeScript

Usage Example

C++ (Plugin side)

#include "generated/Events.hpp"

// In selection changed notifier:
void OnSelectionChanged() {
    std::vector<int> selectedIds = GetSelectedArtIds();
    Events::EmitSelection(selectedIds.size(), selectedIds);
}

// In document opened notifier:
void OnDocumentOpened(const std::string& docName) {
    Events::EmitDocument("opened", docName);
}

TypeScript (Frontend side)

import { connectSSE, onEvent, type SelectionEvent } from '@/sdk/generated/events';

// Connect on app mount
connectSSE();

// Type-safe event handling
onEvent('selection', (data: SelectionEvent) => {
  console.log(`Selected ${data.count} items:`, data.selectedIds);
});

onEvent('document', (data) => {
  // data is typed as DocumentEvent
  if (data.type === 'activated') {
    console.log(`Switched to: ${data.documentName}`);
  }
});

Migration from Polling

The current bridge.ts has startEventLoop() that polls /events. After implementing this spec:

  1. Remove startEventLoop() and stopEventLoop() from bridge.ts
  2. Update SDK exports to include connectSSE, disconnectSSE, onEvent
  3. Update any code using the polling loop to use the SSE client

Testing Requirements

  1. Unit tests for SSEGenerator (parse events.json, generate correct output)
  2. Integration test that:
    • Starts a mock SSE server
    • Connects the generated client
    • Emits events from C++ side
    • Verifies TypeScript receives correct typed data
  3. Reconnection test - verify exponential backoff works

Downstream Impact: Flora

Once NUXP generates proper SSE code, Flora can:

  1. Delete client/adapters/SSEEventAdapter.ts (replaced by NUXP’s generated client)
  2. Add Flora-specific events to events.json (click, deleted, paste, etc.)
  3. Use typed event handlers throughout the codebase

Flora’s custom events would extend NUXP’s base events:

{
  "extends": "nuxp/events.json",
  "events": [
    {
      "name": "click",
      "description": "Flora click tool position",
      "payload": {
        "x": { "type": "number" },
        "y": { "type": "number" },
        "artboardIndex": { "type": "number" }
      }
    },
    {
      "name": "plantDeleted",
      "description": "Plant was deleted from document",
      "payload": {
        "uuids": { "type": "string[]" }
      }
    }
  ]
}