@synet/realtime
Version:
Realtime Communication server/client implementations
324 lines (323 loc) • 13.5 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GunRealtimeChannel = void 0;
const realtime_1 = require("@synet/patterns/realtime");
const node_crypto_1 = __importDefault(require("node:crypto"));
/**
* GUN implementation of RealtimeChannel
*/
class GunRealtimeChannel {
/**
* Create a new GUN channel
* @param gunNode The GUN node to use for this channel
* @param topic The topic this channel is subscribed to
* @param options Channel configuration options
*/
constructor(gunNode, topic, options = {}) {
this.gunNode = gunNode;
this.topic = topic;
this.options = options;
this.id = node_crypto_1.default.randomUUID();
this.state = realtime_1.ChannelState.DISCONNECTED;
this.eventHandlers = new Map();
this.unsubscribers = [];
this.recentlyProcessedEvents = new Set();
// Initialize connection
this.connect();
}
/**
* Get the current state of the channel
*/
getState() {
return this.state;
}
/**
* Connect to the channel
*/
async connect() {
if (this.state === realtime_1.ChannelState.CONNECTED) {
return;
}
this.state = realtime_1.ChannelState.CONNECTING;
try {
// No explicit connection needed for GUN,
// it's automatically connected when created
this.state = realtime_1.ChannelState.CONNECTED;
}
catch (error) {
this.state = realtime_1.ChannelState.ERROR;
throw error;
}
}
/**
* Subscribe to events on this channel
* @param selector Event selector (type or pattern)
* @param handler Function to call when event is received
*/
on(selector, handler) {
// Create a handler key based on the selector
const selectorKey = typeof selector === 'string'
? selector
: JSON.stringify(selector);
//console.log('SelectorKey: ', selectorKey);
// Get or create handler set for this selector
//
if (!this.eventHandlers.has(selectorKey)) {
this.eventHandlers.set(selectorKey, new Set());
}
const handlers = this.eventHandlers.get(selectorKey);
// Add the handler
if (handlers) {
handlers.add(handler);
//console.log(`Added handler for selector ${selectorKey}:`, handler);
// Set up GUN subscription if this is the first handler for this selector
if (handlers.size === 1) {
//console.log(`Setting up GUN subscription for ${this.topic} with selector:`, selector);
this.setupGunSubscription(selector);
}
}
// Return unsubscribe function
return () => {
const handlers = this.eventHandlers.get(selectorKey);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.eventHandlers.delete(selectorKey);
}
}
};
}
/**
* Set up a GUN subscription for events matching the selector
*/
setupGunSubscription(selector) {
console.log(`Setting up GUN subscription for ${this.topic} with selector:`, selector);
this.gunNode.map().on((data, key) => {
if (!data)
return;
try {
const event = this.gunDataToEvent(data, key);
// If the event is currently resolving references, defer notification.
// The notification will happen via notifyHandlersWithResolvedData once resolution is complete.
if (event._isResolvingReferences) {
// console.log(`Event ${event.id} is resolving references, deferring initial notification.`);
return;
}
// Process events that are fully formed (no pending resolutions)
if (event.id && this.recentlyProcessedEvents.has(event.id)) {
console.log(`Skipping duplicate event (non-deferred) with ID: ${event.id}`);
return;
}
if (this.eventMatchesSelector(event, selector)) {
// console.log(`Event (non-deferred) matches selector ${typeof selector === 'string' ? selector : JSON.stringify(selector)}`);
if (event.id) {
this.recentlyProcessedEvents.add(event.id);
setTimeout(() => { this.recentlyProcessedEvents.delete(event.id); }, 5000); // Consistent timeout
}
const handlers = this.eventHandlers.get(typeof selector === 'string' ? selector : JSON.stringify(selector));
if (handlers) {
// console.log(`Notifying ${handlers.size} handlers (non-deferred) for selector ${selector}`);
for (const handler of handlers) {
handler(event);
}
}
}
}
catch (error) {
console.error('Error processing GUN event in setupGunSubscription:', error);
}
});
}
/**
* Convert GUN data to a RealtimeEvent, resolving any references
*/
gunDataToEvent(data, key) {
// Handle cases where data might not be a typical GUN event structure
if (!data || typeof data !== 'object' || !('type' in data && 'id' in data)) {
let eventType = key;
if (eventType.endsWith('/data'))
eventType = eventType.replace('/data', '');
const parts = eventType.split('/');
if (parts.length > 1)
eventType = parts[parts.length - 1];
const constructedEvent = {
id: (typeof data === 'object' && data?.id) || node_crypto_1.default.randomUUID(),
source: (typeof data === 'object' && data?.source) || 'gun',
type: eventType,
timestamp: new Date(),
data: data,
};
return constructedEvent;
}
const event = { ...data }; // Use 'any' for internal flags
event._isResolvingReferences = false; // Initialize flag
if (typeof event.timestamp === 'string') {
event.timestamp = new Date(event.timestamp);
}
const resolutionPromises = [];
// Helper to create a resolution promise
const createResolutionPromise = (field, referencePath) => {
event._isResolvingReferences = true;
// Set initial placeholder for the field being resolved
event[field] = { _placeholder: true, _reference: referencePath, _originalField: field };
return this.resolveGunReference(referencePath)
.then(resolvedValue => {
if (resolvedValue !== null) {
event[field] = resolvedValue;
}
else {
console.warn(`Failed to resolve ${field} reference ${referencePath} for event ${event.id}`);
// Keep placeholder or set an error state for the field
event[field] = { _placeholder: true, _reference: referencePath, _error: 'resolution_failed', _originalField: field };
}
})
.catch(error => {
console.error(`Error resolving ${field} reference ${referencePath} for event ${event.id}:`, error);
event[field] = { _placeholder: true, _reference: referencePath, _error: 'resolution_error', _originalField: field };
});
};
if (event.data && typeof event.data === 'object' && event.data['#']) {
resolutionPromises.push(createResolutionPromise('data', event.data['#']));
}
if (event.metadata && typeof event.metadata === 'object' && event.metadata['#']) {
resolutionPromises.push(createResolutionPromise('metadata', event.metadata['#']));
}
if (event._isResolvingReferences) {
Promise.all(resolutionPromises)
.then(() => {
event._isResolvingReferences = undefined; // Clean up the flag
// console.log(`All references resolved for event ${event.id}, notifying handlers.`);
this.notifyHandlersWithResolvedData(event); // Notify with the (now) fully resolved event
})
.catch(err => {
console.error(`Error during bulk resolution for event ${event.id}:`, err);
event._isResolvingReferences = undefined;
});
}
// This event is returned synchronously. If _isResolvingReferences is true,
// setupGunSubscription should ignore it for immediate notification.
return event;
}
/**
* Resolve a GUN reference to get the actual data
*/
resolveGunReference(reference) {
return new Promise((resolve) => {
// Split the reference path
const path = reference.split('/');
// Navigate to the reference in the gun database
let node = this.gunNode.back(-1); // Go to root of GUN graph
// Navigate through the path
for (const segment of path) {
node = node.get(segment);
}
// Get the data
node.once((data) => {
console.log('data:', data);
resolve(data);
});
// Add timeout to prevent hanging
setTimeout(() => {
resolve(null); // Resolve with null if can't get data in time
}, 5000);
});
}
/**
* Notify handlers with an event that has resolved data
*/
notifyHandlersWithResolvedData(resolvedEvent) {
if (resolvedEvent.id && this.recentlyProcessedEvents.has(resolvedEvent.id)) {
console.log(`Skipping duplicate event (post-resolution) with ID: ${resolvedEvent.id}`);
return;
}
if (resolvedEvent.id) {
this.recentlyProcessedEvents.add(resolvedEvent.id);
setTimeout(() => { this.recentlyProcessedEvents.delete(resolvedEvent.id); }, 5000); // Consistent timeout
}
// console.log('Notifying handlers with resolved data:', resolvedEvent);
for (const [selectorKey, handlers] of this.eventHandlers.entries()) {
let currentSelector;
try {
currentSelector = selectorKey === '*' ? '*' : JSON.parse(selectorKey);
}
catch {
currentSelector = selectorKey;
}
if (this.eventMatchesSelector(resolvedEvent, currentSelector)) {
for (const handler of handlers) {
handler(resolvedEvent);
}
}
}
}
/**
* Check if an event matches a selector
*/
eventMatchesSelector(event, selector) {
if (selector === '*') {
return true;
}
if (typeof selector === 'string') {
return event.type === selector;
}
// Object selector with source and/or type
const sourceMatch = !selector.source || event.source === selector.source;
const typeMatch = !selector.type || event.type === selector.type;
return sourceMatch && typeMatch;
}
/**
* Send an event through this channel
* @param event The event to send
*/
async emit(event) {
if (this.state !== realtime_1.ChannelState.CONNECTED) {
await this.connect();
}
// Ensure event has required fields and convert Date to ISO string
const completeEvent = {
...event,
id: event.id || node_crypto_1.default.randomUUID(),
// Convert Date to string to avoid GUN serialization issues
timestamp: (event.timestamp instanceof Date)
? event.timestamp.toISOString()
: event.timestamp || new Date().toISOString()
};
// Convert any nested Date objects to strings
this.serializeDates(completeEvent);
// Store the event in GUN with the type as the key
const key = event.type;
this.gunNode.get(key).put(completeEvent);
}
/**
* Recursively serialize Date objects to ISO strings to avoid GUN serialization issues
*/
serializeDates(obj) {
if (!obj || typeof obj !== 'object')
return;
for (const key of Object.keys(obj)) {
const value = obj[key];
if (value instanceof Date) {
obj[key] = value.toISOString();
}
else if (typeof value === 'object' && value !== null) {
this.serializeDates(value);
}
}
}
/**
* Close the channel
*/
async close() {
// Run all unsubscribe functions
for (const unsubscribe of this.unsubscribers) {
unsubscribe();
}
this.unsubscribers = [];
this.eventHandlers.clear();
this.state = realtime_1.ChannelState.DISCONNECTED;
}
}
exports.GunRealtimeChannel = GunRealtimeChannel;