@synet/realtime
Version:
Realtime Communication server/client implementations
357 lines (356 loc) • 14.3 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.NatsRealtimeChannel = void 0;
const nats_1 = require("nats");
const node_crypto_1 = __importDefault(require("node:crypto"));
const realtime_1 = require("@synet/patterns/realtime");
/**
* NATS implementation of RealtimeChannel
*/
class NatsRealtimeChannel {
constructor(url, options = {}) {
this.url = url;
this.options = options;
this.id = node_crypto_1.default.randomUUID();
this.natsConnection = null;
this.state = realtime_1.ChannelState.DISCONNECTED;
this.eventHandlers = new Map();
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.connectionPromise = null;
this.subscription = null;
this.stringCodec = (0, nats_1.StringCodec)();
// Extract topic from url query parameters
const urlObj = new URL(url);
this.topic = urlObj.searchParams.get("topic") || "default";
this.clientInbox = `client.${this.id}`;
}
/**
* Connect to the NATS channel
*/
connect() {
// If already connecting or connected, return the existing promise
if (this.connectionPromise) {
return this.connectionPromise;
}
if (this.state === realtime_1.ChannelState.CONNECTED) {
return Promise.resolve();
}
this.state = realtime_1.ChannelState.CONNECTING;
// Fix: Remove async from the executor function and use proper Promise chaining
this.connectionPromise = new Promise((resolve, reject) => {
const natsConfig = {
servers: this.url,
user: this.options.transportOptions?.user,
pass: this.options.transportOptions?.password,
token: this.options.transportOptions?.token,
reconnect: this.options.reconnect?.enabled !== false,
maxReconnectAttempts: this.options.transportOptions?.maxReconnects ||
this.options.reconnect?.maxAttempts ||
-1,
reconnectTimeWait: this.options.transportOptions?.reconnectTimeWait ||
this.options.reconnect?.initialDelayMs ||
1000,
};
// Connect to NATS server using proper Promise chaining
(0, nats_1.connect)(natsConfig)
.then(async (nc) => {
this.natsConnection = nc;
// Set up connection status handlers
this.monitorConnectionStatus();
// Set up subscriptions and send connection message
return this.setupSubscriptions()
.then(() => this.sendConnectionMessage())
.then(() => {
// Update state
this.state = realtime_1.ChannelState.CONNECTED;
this.reconnectAttempts = 0;
resolve();
});
})
.catch((error) => {
this.state = realtime_1.ChannelState.ERROR;
this.connectionPromise = null;
reject(error);
});
});
return this.connectionPromise;
}
/**
* Monitor NATS connection status changes
*/
monitorConnectionStatus() {
// Start an async task to monitor status - don't await it
(async () => {
try {
if (!this.natsConnection)
return;
const status = this.natsConnection.status();
for await (const s of status) {
switch (s.type) {
case "reconnecting":
this.state = realtime_1.ChannelState.CONNECTING;
console.log(`Reconnecting to NATS server (attempt ${this.reconnectAttempts++})...`);
break;
case "reconnect":
this.state = realtime_1.ChannelState.CONNECTED;
this.reconnectAttempts = 0;
console.log("Reconnected to NATS server");
// Re-establish subscriptions after reconnect
this.setupSubscriptions().catch((err) => console.error("Error setting up subscriptions after reconnect:", err));
break;
case "disconnect":
this.state = realtime_1.ChannelState.DISCONNECTED;
console.log("Disconnected from NATS server");
break;
case "error":
this.state = realtime_1.ChannelState.ERROR;
console.error("NATS connection error:", s.data);
break;
case "pingTimer":
// This is a normal health check - only log in debug mode
if (this.options.transportOptions?.debug) {
console.log("NATS ping check active");
}
break;
case "staleConnection":
// Connection might be stale - prepare for possible disconnect
console.warn("NATS connection may be stale, monitoring for disconnect...");
this.state = realtime_1.ChannelState.CONNECTING; // Mark as potentially problematic
break;
case "update":
// Server discovered or removed
break;
case "ldm":
// LDM mode change
break;
default:
console.log(`NATS status update: ${s.type}`);
break;
}
}
}
catch (err) {
console.error("Error monitoring NATS status:", err);
}
})();
}
async setupSubscriptions() {
if (!this.natsConnection)
return;
try {
// Subscribe to general topic
const subjectPrefix = this.options.transportOptions?.subjectPrefix || "topic";
const topicSubject = `${subjectPrefix}.${this.topic}`;
this.subscription = await this.natsConnection.subscribe(topicSubject);
// Process messages in background
(async () => {
if (!this.subscription)
return;
for await (const msg of this.subscription) {
try {
const event = JSON.parse(this.stringCodec.decode(msg.data));
this.processIncomingEvent(event);
}
catch (error) {
console.error("Error processing topic message:", error);
}
}
})().catch((err) => console.error("Error in topic subscription:", err));
// Subscribe to personal inbox
const inboxSub = await this.natsConnection.subscribe(this.clientInbox);
// Process inbox messages in background
(async () => {
for await (const msg of inboxSub) {
try {
const event = JSON.parse(this.stringCodec.decode(msg.data));
this.processIncomingEvent(event);
}
catch (error) {
console.error("Error processing inbox message:", error);
}
}
})().catch((err) => console.error("Error in inbox subscription:", err));
}
catch (error) {
console.error("Error setting up subscriptions:", error);
throw error;
}
}
async sendConnectionMessage() {
if (!this.natsConnection)
return;
try {
const connectionMessage = {
clientId: this.id,
topic: this.topic,
clientInbox: this.clientInbox,
timestamp: new Date(),
};
// Send with request-reply to ensure server processes it
const resp = await this.natsConnection.request("control.connect", this.stringCodec.encode(JSON.stringify(connectionMessage)), { timeout: 5000 });
// Process confirmation
const confirmation = JSON.parse(this.stringCodec.decode(resp.data));
if (this.options.transportOptions?.debug) {
console.log("Connection established:", confirmation);
}
}
catch (error) {
console.error("Error sending connection message:", error);
throw error;
}
}
/**
* Subscribe to events
*/
on(selector, handler) {
let eventType;
if (typeof selector === "string") {
eventType = selector;
}
else if (selector.type && selector.source) {
eventType = `${selector.source}.${selector.type}`;
}
else if (selector.type) {
eventType = selector.type;
}
else if (selector.source) {
eventType = `${selector.source}.*`;
}
else {
eventType = "*";
}
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, new Set());
}
const handlers = this.eventHandlers.get(eventType);
if (handlers) {
handlers.add(handler);
}
return () => {
const handlers = this.eventHandlers.get(eventType);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
this.eventHandlers.delete(eventType);
}
}
};
}
/**
* Send an event to the channel
*/
async emit(event) {
// If connecting, wait for the connection to complete
if (this.state === realtime_1.ChannelState.CONNECTING && this.connectionPromise) {
await this.connectionPromise;
}
// If disconnected or error, try to establish connection
else if (this.state !== realtime_1.ChannelState.CONNECTED) {
await this.connect();
}
// At this point we should be connected, but double-check
if (!this.natsConnection || this.state !== realtime_1.ChannelState.CONNECTED) {
throw new Error(`Cannot emit event: channel not connected (state: ${this.state})`);
}
try {
// Add client ID if not present
const completeEvent = {
...event,
clientId: event.clientId || this.id,
};
// Publish to topic
const subjectPrefix = this.options.transportOptions?.subjectPrefix || "topic";
const subject = `${subjectPrefix}.${this.topic}`;
this.natsConnection.publish(subject, this.stringCodec.encode(JSON.stringify(completeEvent)));
}
catch (error) {
console.error("Error emitting event:", error);
throw error;
}
}
/**
* Close the channel
*/
async close() {
if (this.state === realtime_1.ChannelState.DISCONNECTED || !this.natsConnection) {
return;
}
this.state = realtime_1.ChannelState.DISCONNECTING;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
try {
// Unsubscribe
if (this.subscription) {
this.subscription.unsubscribe();
}
// Notify server about disconnection
const disconnectMessage = {
clientId: this.id,
topic: this.topic,
timestamp: new Date(),
};
this.natsConnection.publish("control.disconnect", this.stringCodec.encode(JSON.stringify(disconnectMessage)));
// Close NATS connection
await this.natsConnection.drain();
await this.natsConnection.close();
this.natsConnection = null;
this.state = realtime_1.ChannelState.DISCONNECTED;
}
catch (error) {
console.error("Error closing channel:", error);
this.state = realtime_1.ChannelState.ERROR;
}
}
getState() {
return this.state;
}
processIncomingEvent(event) {
// Process exact type handlers
const exactHandlers = this.eventHandlers.get(event.type);
if (exactHandlers) {
for (const handler of exactHandlers) {
try {
handler(event);
}
catch (error) {
console.error(`Error in event handler for ${event.type}:`, error);
}
}
}
// Process wildcard handlers
const wildcardHandlers = this.eventHandlers.get("*");
if (wildcardHandlers) {
for (const handler of wildcardHandlers) {
try {
handler(event);
}
catch (error) {
console.error(`Error in wildcard handler for ${event.type}:`, error);
}
}
}
}
async manualReconnect() {
// Clear existing connection resources
if (this.natsConnection) {
try {
await this.natsConnection.close();
}
catch (err) {
console.error("Error closing existing connection:", err);
}
this.natsConnection = null;
}
this.reconnectAttempts = 0;
this.connectionPromise = null;
// Establish fresh connection
return this.connect();
}
}
exports.NatsRealtimeChannel = NatsRealtimeChannel;