UNPKG

@iprokit/service

Version:

Powering distributed systems with simplicity and speed.

487 lines 17.6 kB
"use strict"; /** * @iProKit/Service * Copyright (c) 2019-2025 Rutvik Katuri / iProTechs * SPDX-License-Identifier: Apache-2.0 */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Socket = void 0; // Import Libs. const events_1 = require("events"); const stream_1 = require("stream"); const net_1 = require("net"); const protocol_1 = __importStar(require("./protocol")); const coordinator_1 = require("./coordinator"); // Symbol Definitions. const pool = Symbol('Pool'); /** * `Client` manages connections to an SCP server. * Once connected, it subscribes to receive broadcasts and handles the executions. * * @emits `connect` when the connection is successfully established. * @emits `<operation>` when a broadcast is received. * @emits `pool:create` when a new socket is created and added to the connection pool. * @emits `pool:acquire` when a socket is acquired from the connection pool. * @emits `pool:drain` when all sockets(message) are removed from the connection pool. * @emits `error` when an error occurs. * @emits `close` when all the connections are closed. */ class Client extends events_1.EventEmitter { /** * Unique identifier of the client. */ identifier; /** * Socket connection pool. */ [pool]; /** * Options of the client. */ #options; /** * Creates an instance of SCP `Client`. * * @param identifier unique identifier of the client. * @param options options of the client. */ constructor(identifier, options) { super(); // Initialize options. this.identifier = identifier; this.#options = options ?? {}; this.#options.maxPoolSize = this.#options.maxPoolSize ?? 10; this.#options.maxMessages = this.#options.maxMessages ?? 100; this.#options.idleTimeout = this.#options.idleTimeout ?? 0; // Initialize variables. this[pool] = new Array(); // Bind listeners. this.onBroadcast = this.onBroadcast.bind(this); } ////////////////////////////// //////// Gets/Sets ////////////////////////////// /** * Remote port of the client. */ get remotePort() { if (this[pool].length === 0) return null; return this[pool][0].socket.remotePort; } /** * Remote address of the client. */ get remoteAddress() { if (this[pool].length === 0) return null; return this[pool][0].socket.remoteAddress; } /** * `true` when all sockets in the pool are connected, `false` otherwise. */ get connected() { if (this[pool].length === 0) return false; return this[pool].every((socket) => socket.connected); } /** * Returns the current state of the connection pool. */ get pool() { if (this[pool].length === 0) return { size: 0, busy: 0, idle: 0 }; const { busy, idle } = this[pool].reduce((counts, socket) => (counts[socket.ioQueue > 0 ? 'busy' : 'idle']++, counts), { busy: 0, idle: 0 }); return { size: this[pool].length, busy, idle }; } ////////////////////////////// //////// Subscribe ////////////////////////////// /** * Subscribes to the server to receive broadcasts. */ async subscribe(socket) { const { incoming, outgoing } = await this.IO('SUBSCRIBE', '', {}, socket); try { // Write: Outgoing stream. outgoing.end(''); await stream_1.promises.finished(outgoing); // Read: Incoming stream. await (0, events_1.once)(incoming, 'rfi'); incoming.resume(); await stream_1.promises.finished(incoming); } catch (error) { // ❗️⚠️❗️ incoming.destroy(); outgoing.destroy(); } } ////////////////////////////// //////// Broadcast ////////////////////////////// /** * Process the `Incoming` broadcast stream. * * @emits `<operation>` when a broadcast is received. */ async onBroadcast(incoming) { try { // No listener was added to the broadcast, Drain the stream. Move on to the next one. if (this.listenerCount(incoming.operation) === 0) { incoming.resume(); await stream_1.promises.finished(incoming); return; } // Read: Incoming stream. let incomingData = new Array(); for await (const chunk of incoming) { incomingData.push(chunk); } this.emit(incoming.operation, ...JSON.parse(Buffer.concat(incomingData).toString())); } catch (error) { // ❗️⚠️❗️ incoming.destroy(); } } ////////////////////////////// //////// Message/Conduct ////////////////////////////// /** * Sends a message to the server and returns a promise resolving to a reply. * * @param operation operation pattern. * @param args arguments to send. */ async message(operation, ...args) { const { incoming, outgoing } = await this.IO('REPLY', operation); let incomingData = new Array(); let outgoingData = JSON.stringify(args); try { // Write: Outgoing stream. outgoing.end(outgoingData); await stream_1.promises.finished(outgoing); // Read: Incoming stream. await (0, events_1.once)(incoming, 'rfi'); for await (const chunk of incoming) { incomingData.push(chunk); } } catch (error) { // ❗️⚠️❗️ incoming.destroy(); outgoing.destroy(); throw error; } if (incoming.parameters.STATUS === 'ERROR') { throw Object.assign(new Error(), JSON.parse(Buffer.concat(incomingData).toString())); } return JSON.parse(Buffer.concat(incomingData).toString()); } /** * Sends a message to the server and returns a promise that resolves to `void`, enabling the coordination of signals. * * @param operation operation pattern. * @param coordinator coordinator that coordinates signals. * @param args arguments to send. */ async conduct(operation, coordinator, ...args) { const { incoming, outgoing } = await this.IO('CONDUCTOR', operation); let outgoingData = JSON.stringify(args); const conductor = new coordinator_1.Conductor(incoming, outgoing); // 🎩🚦🔲 coordinator.manage(conductor); try { // Write: Conductor. await conductor.deliver(outgoingData); // Read: Conductor. await (0, events_1.once)(incoming, 'rfi'); } catch (error) { // ❗️⚠️❗️ conductor.destroy(); throw error; } } ////////////////////////////// //////// Incoming/Outgoing ////////////////////////////// /** * Creates and returns a new `Incoming` and `Outgoing` stream. * * @param mode mode of the remote function. * @param operation operation of the remote function. * @param parameters optional parameters of the remote function. * @param socket optional socket to use. If not provided, a socket will be acquired from the connection pool. */ async IO(mode, operation, parameters, socket) { if (!this.connected && this[pool].length === 0) throw new Error('NO_CONNECTION'); parameters = { ...(parameters ?? {}), CID: this.identifier }; socket = socket ?? this.acquireSocket(); return new Promise((resolve) => socket.createIO(mode, operation, parameters, (outgoing, incoming) => resolve({ outgoing, incoming }))); } ////////////////////////////// //////// Connection Pool ////////////////////////////// /** * Returns the least busy and available socket from the connection pool. * * @emits `pool:acquire` when a socket is acquired from the connection pool. */ acquireSocket() { let socket = this[pool].find((socket) => socket.ioQueue === 0); if (socket) { // 🤸🏽🪩 } else { if (this[pool].length < this.#options.maxPoolSize) { socket = this.createSocket({ maxMessages: this.#options.maxMessages, idleTimeout: this.#options.idleTimeout }, this.remotePort, this.remoteAddress); } else { socket = this[pool].reduce((leastBusy, current) => (current.ioQueue < leastBusy.ioQueue ? current : leastBusy)); } } this.emit('pool:acquire', socket); return socket; } /** * Creates and initializes a new connected socket, then adds it to the connection pool. * * @param options options of the socket. * @param port remote port. * @param host remote host. * @emits `pool:create` when a new socket is created and added to the connection pool. * @emits `pool:drain` when all sockets(message) are removed from the connection pool. * @emits `error` when an error occurs. * @emits `close` when all the connections are closed. */ createSocket(options, port, host) { const socket = new Socket(options); socket.on('error', (error) => { this.emit('error', error, socket); }); socket.on('close', () => { // Find the socket and remove it. const socketIndex = this[pool].findIndex((s) => s === socket); if (socketIndex >= 0) this[pool].splice(socketIndex, 1); // 🏄🏽 if (this[pool].length === 1) { this.emit('pool:drain'); } else if (this[pool].length === 0) { this.emit('close'); } }); socket.connect(port, host); this[pool].push(socket); this.emit('pool:create', socket); return socket; } ////////////////////////////// //////// Connection Management ////////////////////////////// /** * Initiates a connection to the server. * * @param port remote port. * @param host remote host. * @param callback optional callback added as a one-time listener for the `connect` event. */ connect(port, host, callback) { callback && this.once('connect', callback); // Socket is reserved for receiving broadcasts. 📡🏃🏽💨 const socket = this.createSocket({ maxMessages: Infinity, idleTimeout: 0 }, port, host); socket.on('broadcast', this.onBroadcast); socket.on('connect', () => this.subscribe(socket).then(() => { socket.cycleIncoming(); this.emit('connect'); })); return this; } /** * Closes all connections to the server. * * @param callback optional callback added as a one-time listener for the `close` event. */ close(callback) { callback && this.once('close', callback); // 🏁✋🏽 for (const socket of this[pool]) { socket.end(); } return this; } } exports.default = Client; ////////////////////////////// //////// Socket ////////////////////////////// /** * Represents a socket connection used by the SCP `Client`. * * @emits `connect` when a connection is successfully established. * @emits `<mode>` when a new incoming stream is received. * @emits `io:drain` when all callbacks in the I/O queue are executed. */ class Socket extends protocol_1.default { /** * RFI + I/O callback queue. */ #ioQueue; /** * Number of I/O streams processed. */ #ioProcessed; /** * - `IO`: Message/Reply mode. Socket processes queued I/O operations. * - `Incoming`: Continuous incoming mode. Socket repeatedly listens for incoming streams. */ #ioMode; /** * Options of the socket. */ #options; /** * Creates an instance of SCP `Socket`. * * @param options options of the socket. */ constructor(options) { super(new net_1.Socket()); // Initialize options. this.#options = options; // Initialize variables. this.#ioQueue = new Array(); this.#ioProcessed = 0; this.#ioMode = 'IO'; // Add listeners. this.socket.addListener('connect', () => this.emit('connect')); this.socket.addListener('timeout', () => this.end()); this.socket.addListener('end', () => !this.readableEnded && this.resume()); // Underlying socket closed. Forcefully read(), triggering `end` event. 🤪 this.addListener('end', () => this.end()); this.addListener('error', (error) => this.destroy()); // Initialize. this.socket.setTimeout(this.#options.idleTimeout); } ////////////////////////////// //////// Gets/Sets ////////////////////////////// /** * `true` when the socket is connected, `false` otherwise. */ get connected() { return !this.socket.pending && !this.destroyed && this.socket.readyState === 'open'; } /** * Number of I/O streams queued to process. */ get ioQueue() { if (this.#ioMode === 'IO') return this.#ioQueue.length; return Infinity; } ////////////////////////////// //////// Incoming/Outgoing ////////////////////////////// /** * Creates a new `Incoming` and `Outgoing` stream. * * @param mode mode of the remote function. * @param operation operation of the remote function. * @param parameters parameters of the remote function. * @param callback callback executed when the I/O stream is ready. */ createIO(mode, operation, parameters, callback) { // Push the RFI + I/O callback into the queue. this.#ioQueue.push({ mode, operation, parameters, callback }); // This is the first in the queue, let's execute it! if (this.#ioQueue.length === 1) { this.executeIO(); } return this; } /** * Executes one I/O callback at a time in FIFO manner. * Invoked recursively on the `close` event of the current incoming stream. * * @emits `io:drain` when all callbacks in the I/O queue are executed. */ executeIO() { // The first(0th) RFI + I/O callback from the queue. const { mode, operation, parameters, callback: firstCallback } = this.#ioQueue[0]; const incoming = new protocol_1.Incoming(this); incoming.once('close', () => { this.#ioProcessed++; // Remove the first(0th) RFI + I/O callback from the queue. this.#ioQueue.shift(); // 🚨 if (this.#ioProcessed >= this.#options.maxMessages) return this.end(); // 🎡 if (this.#ioQueue.length > 0) { this.executeIO(); } else if (this.#ioQueue.length === 0) { this.emit('io:drain'); } }); const outgoing = new protocol_1.Outgoing(this); outgoing.setRFI(mode, operation, parameters); // Let's execute the I/O callback! firstCallback(outgoing, incoming); } /** * Creates a new `Incoming` stream. * Invoked recursively on the `close` event of the current incoming stream to continuously listen for incoming streams. * * @emits `<mode>` when a new incoming stream is received. */ cycleIncoming() { this.#ioMode = 'Incoming'; // 👂🏽🔁 const incoming = new protocol_1.Incoming(this); incoming.once('rfi', () => this.emit(incoming.mode.toLowerCase(), incoming)); incoming.once('close', () => this.cycleIncoming()); return this; } ////////////////////////////// //////// Connection Management ////////////////////////////// /** * Initiates a connection to the server. * * @param port remote port. * @param host remote host. * @param callback optional callback added as a one-time listener for the `connect` event. */ connect(port, host, callback) { this.socket.connect({ port, host, keepAlive: true }, callback); return this; } } exports.Socket = Socket; //# sourceMappingURL=client.js.map