SSE Code Generation Specification
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
- Single source of truth - Events defined once, generated for both C++ and TypeScript
- Type safety - Event payloads are typed on both ends
- Native EventSource - TypeScript client uses browser’s EventSource API (not polling)
- 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:
- Reads
events.json - Generates
Events.hppfor C++ - Generates
events.tsfor 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:
- Remove
startEventLoop()andstopEventLoop()frombridge.ts - Update SDK exports to include
connectSSE,disconnectSSE,onEvent - Update any code using the polling loop to use the SSE client
Testing Requirements
- Unit tests for SSEGenerator (parse events.json, generate correct output)
- Integration test that:
- Starts a mock SSE server
- Connects the generated client
- Emits events from C++ side
- Verifies TypeScript receives correct typed data
- Reconnection test - verify exponential backoff works
Downstream Impact: Flora
Once NUXP generates proper SSE code, Flora can:
- Delete
client/adapters/SSEEventAdapter.ts(replaced by NUXP’s generated client) - Add Flora-specific events to
events.json(click, deleted, paste, etc.) - 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[]" }
}
}
]
}