/** * Twin Messaging Factory * * Creates emit/on/to methods for any twin type. * Provides a consistent messaging API across all instance types. * * API Design: * - emit(type, payload): Fire-and-forget broadcast to subscribers * - to(targetId).emit(type, payload): Fire-and-forget to specific twin * - to(targetId).emit(type, payload, callback): Request-response to specific twin * - on(type, callback): Listen for messages, with optional respond callback * * Backwards Compatibility: * - Messages are still SENT with prefix (e.g., 'edgeInstance:hello') so old receivers * using exact match continue to work during rolling upgrades. * - Incoming messages are matched by bare event type after stripping known prefixes, * so cross-approach communication (instance → peripheral) works without prefix agreement. * - Old callback signature (data) => {...} works - the respond parameter is optional */ import { TwinMessageResult, TwinMessageResultStatus } from './types/twin.types'; import { TwinTransport } from './services/webrtc/types'; // Backward compatibility: messages may arrive with legacy type prefixes // (e.g., 'edgeInstance:hello', 'peripheralInstance:twin-123:print'). // Strip known prefixes to extract the bare event type for matching. // This allows cross-approach communication (e.g., instance.to(peripheralTwinId) // reaching peripheral.on()) and prepares for future prefix removal. const KNOWN_PREFIX_PATTERN = /^(edgeInstance|peripheralInstance:[^:]+):/; const stripTypePrefix = (messageType: string): string => messageType.replace(KNOWN_PREFIX_PATTERN, ''); export interface TwinMessagingMethods { /** Send a fire-and-forget event to subscribers */ emit: (type: string, payload: any) => void; /** * Listen for events, with optional respond callback for request-response. * Returns an unsubscribe function that removes this listener; calling it more * than once is a no-op. */ on: (type: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void) => () => void; /** * Remove a listener previously registered with on(), matched by (type, callback) * identity. Returns true if a listener was removed. */ off: (type: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void) => boolean; /** Target a specific twin for messaging */ to: (targetTwinId: string) => { /** Send event or action to the targeted twin */ emit: { (type: string, payload: any): void; (type: string, payload: any, callback: (response: TwinMessageResult) => void): Promise; }; }; } const REQUEST_TIMEOUT = 10000; /** * Creates messaging methods (emit, on, to) for a twin instance. * This provides a consistent API across all instance types (Edge, Screen, Peripheral, etc.) */ export function createTwinMessaging(transport: TwinTransport, typePrefix: string): TwinMessagingMethods { const { sendMessage: transportSendMessage, onMessage: transportOnMessage, offMessage: transportOffMessage, twinId, } = transport; // Tracks the wrapper closure registered on the transport for each (type, callback) // pair, so off() can unregister by the caller's original callback identity — // previously the wrappers were unreachable and listeners could never be removed // (TECH-1334). const listenerWrappers = new Map< string, Map<(message: any, respond?: (result: TwinMessageResult) => void) => void, (payload: any) => void> >(); // Registry for pending requests - enables request-response pattern const pendingRequests = new Map< string, { resolve: (value: TwinMessageResult) => void; reject: (reason: TwinMessageResult) => void; timeoutId: ReturnType; } >(); const generateRequestId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; // Backward compatibility: still send with prefix so old receivers // (that use exact match) continue to work during rolling upgrades. const buildMessage = (type: string, data: any) => ({ type: `${typePrefix}:${type}`, sourceTwinId: twinId, data, }); // Send fire-and-forget event const sendMessage = (targetTwinId: string, type: string, payload: any): void => { transportSendMessage(targetTwinId, buildMessage(type, payload)); }; // Send request and wait for response (used by to().emit() with callback) const sendRequest = ( targetTwinId: string, type: string, payload: any, callback: (response: TwinMessageResult) => void, ): Promise => { const requestId = generateRequestId(); return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { pendingRequests.delete(requestId); const error: TwinMessageResult = { status: TwinMessageResultStatus.Error, message: `Request timed out after ${REQUEST_TIMEOUT}ms`, }; reject(error); }, REQUEST_TIMEOUT); pendingRequests.set(requestId, { resolve, reject, timeoutId }); // Send with requestId embedded transportSendMessage(targetTwinId, buildMessage(type, { ...payload, requestId })); }) .then((result) => { callback(result); return result; }) .catch((error) => { callback(error); throw error; }); }; // Set up response listener - handles all incoming responses for this twin transportOnMessage(twinId, (payload: any) => { const messageType = payload.data?.type || payload.type; // Match with or without prefix for backward compatibility if (stripTypePrefix(messageType) === 'response') { const responseData = payload.data?.data || payload.data; const requestId = responseData?.requestId; const pending = pendingRequests.get(requestId); if (pending) { clearTimeout(pending.timeoutId); pendingRequests.delete(requestId); // Extract result without requestId const { requestId: _, ...result } = responseData; pending.resolve(result as TwinMessageResult); } } }); // emit: Fire-and-forget broadcast to subscribers const emit = (type: string, payload: any): void => { sendMessage(twinId, type, payload); }; // on: Listen for events, with respond callback for request-response. // Returns an unsubscribe function; off(type, callback) works too. const on = ( type: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void, ): (() => void) => { const wrapper = (payload: any) => { const messageType = payload.data?.type || payload.type; // Match with or without prefix for backward compatibility if (stripTypePrefix(messageType) === type) { const messageData = payload.data?.data || payload.data; const requestId = messageData?.requestId; const sourceTwinId = payload.sourceTwinId; // Create respond callback if this is a request (has requestId and source) const respond = requestId && sourceTwinId ? (result: TwinMessageResult) => { transportSendMessage(sourceTwinId, buildMessage('response', { ...result, requestId })); } : undefined; // Pass message without requestId (strip it from the payload) // Preserve null/undefined if original messageData was falsy if (!messageData) { callback(messageData, respond); } else { const { requestId: _, ...cleanData } = messageData; callback(cleanData, respond); } } }; transportOnMessage(twinId, wrapper); let wrappersForType = listenerWrappers.get(type); if (!wrappersForType) { wrappersForType = new Map(); listenerWrappers.set(type, wrappersForType); } wrappersForType.set(callback, wrapper); return () => { // Only clear the registry entry if it still points at THIS wrapper — a later // on(type, sameCallback) re-registration must not be unregistered by a stale // disposer. Removing the wrapper from the transport is idempotent either way. const currentWrappers = listenerWrappers.get(type); if (currentWrappers?.get(callback) === wrapper) { currentWrappers.delete(callback); if (currentWrappers.size === 0) { listenerWrappers.delete(type); } } transportOffMessage(twinId, wrapper); }; }; // off: Remove a listener registered with on(), by (type, callback) identity. const off = ( type: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void, ): boolean => { const wrappersForType = listenerWrappers.get(type); const wrapper = wrappersForType?.get(callback); if (!wrappersForType || !wrapper) { return false; } wrappersForType.delete(callback); if (wrappersForType.size === 0) { listenerWrappers.delete(type); } transportOffMessage(twinId, wrapper); return true; }; // to: Target a specific twin for messaging const to = (targetTwinId: string) => { function emit(type: string, payload: any): void; function emit( type: string, payload: any, callback: (response: TwinMessageResult) => void, ): Promise; function emit( type: string, payload: any, callback?: (response: TwinMessageResult) => void, ): void | Promise { if (callback) { return sendRequest(targetTwinId, type, payload, callback); } else { sendMessage(targetTwinId, type, payload); } } return { emit }; }; return { emit, on, off, to }; }