UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

396 lines (351 loc) 9.89 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../constants.mjs"; import { isInteger, isString } from "../types/is.mjs"; import { BaseWithOptions } from "../types/basewithoptions.mjs"; import { ObservableQueue } from "../types/observablequeue.mjs"; import { Message } from "./webconnect/message.mjs"; import { getGlobalFunction } from "../types/global.mjs"; export { WebConnect }; /** * @private * @type {symbol} */ const receiveQueueSymbol = Symbol("receiveQueue"); /** * @private * @type {symbol} * * hint: this name is used in the tests. if you want to change it, please change it in the tests as well. */ const connectionSymbol = Symbol("connection"); /** * @private * @type {symbol} */ const manualCloseSymbol = Symbol("manualClose"); /** * @private * @see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 * @type {Object} */ const connectionStatusCode = { 1000: "Normal closure", 1001: "Going away", 1002: "Protocol error", 1003: "Unsupported data", 1004: "Reserved", 1005: "No status code", 1006: "Connection closed abnormally", 1007: "Invalid frame payload data", 1008: "Policy violation", 1009: "The Message is too big", 1010: "Mandatory extension", 1011: "Internal server error", 1015: "TLS handshake", }; /** * @private * @this {WebConnect} * @throws {Error} No url defined for websocket datasource. */ function connectServer(resolve, reject) { const self = this; const url = self.getOption("url"); if (!url) { reject(new Error("No url defined for web connect.")); return; } let promiseAlreadyResolved = false; let connectionTimeout = self.getOption("connection.timeout"); if (!isInteger(connectionTimeout) || connectionTimeout < 100) { connectionTimeout = 5000; } // Timeout Handling const timeoutId = setTimeout(() => { if (promiseAlreadyResolved) { return; } promiseAlreadyResolved = true; // Clean up hanging socket attempt if (self[connectionSymbol].socket) { try { self[connectionSymbol].socket.close(); } catch (e) { // ignore } } reject(new Error("Connection timeout")); }, connectionTimeout); let reconnectTimeout = self.getOption("connection.reconnect.timeout"); if (!isInteger(reconnectTimeout) || reconnectTimeout < 1000) reconnectTimeout = 1000; let reconnectAttempts = self.getOption("connection.reconnect.attempts"); if (!isInteger(reconnectAttempts) || reconnectAttempts < 1) reconnectAttempts = 1; let reconnectEnabled = self.getOption("connection.reconnect.enabled"); if (reconnectEnabled !== true) reconnectEnabled = false; self[manualCloseSymbol] = false; self[connectionSymbol].reconnectCounter++; // Cleanup existing socket if ( self[connectionSymbol].socket && self[connectionSymbol].socket.readyState < 2 ) { // Remove listeners to prevent side effects during close self[connectionSymbol].socket.onclose = null; self[connectionSymbol].socket.onerror = null; self[connectionSymbol].socket.onmessage = null; self[connectionSymbol].socket.onopen = null; self[connectionSymbol].socket.close(); } self[connectionSymbol].socket = null; const WebSocket = getGlobalFunction("WebSocket"); if (!WebSocket) { clearTimeout(timeoutId); reject(new Error("WebSocket is not available")); return; } try { self[connectionSymbol].socket = new WebSocket(url); } catch (error) { clearTimeout(timeoutId); if (!promiseAlreadyResolved) { promiseAlreadyResolved = true; reject(error); } return; } self[connectionSymbol].socket.onmessage = function (event) { if (event.data instanceof Blob) { const reader = new FileReader(); reader.addEventListener("loadend", function () { self[receiveQueueSymbol].add(new Message(reader.result)); }); // Correctly pass the Blob, not a Message object reader.readAsText(event.data); } else { self[receiveQueueSymbol].add(Message.fromJSON(event.data)); } }; self[connectionSymbol].socket.onopen = function () { clearTimeout(timeoutId); self[connectionSymbol].reconnectCounter = 0; if (typeof resolve === "function" && !promiseAlreadyResolved) { promiseAlreadyResolved = true; resolve(); } }; // Internal helper to handle reconnects const handleReconnect = () => { if (self[manualCloseSymbol]) { self[manualCloseSymbol] = false; return; } if ( reconnectEnabled && self[connectionSymbol].reconnectCounter < reconnectAttempts ) { setTimeout(() => { // catch potential unhandled promise rejections from the recursive call self.connect().catch(() => {}); }, reconnectTimeout * self[connectionSymbol].reconnectCounter); } }; // Use onclose event instead of overriding the close method self[connectionSymbol].socket.onclose = function (event) { handleReconnect(); }; self[connectionSymbol].socket.onerror = (error) => { if (!promiseAlreadyResolved) { clearTimeout(timeoutId); promiseAlreadyResolved = true; reject(error); } else { // If the connection was already established, treat error as potential disconnect // Usually onclose follows onerror, but we ensure we don't double-handle logic // typically we rely on onclose for reconnect logic. } }; } /** * The RestAPI is a class that enables a REST API server. * * @externalExample ../../example/net/webconnect.mjs * @license AGPLv3 * @since 3.1.0 * @copyright Volker Schukai * @summary The LocalStorage class encapsulates the access to data objects. */ class WebConnect extends BaseWithOptions { /** * * @param {Object} [options] options contains definitions for the webconnect. */ constructor(options) { if (isString(options)) { options = { url: options }; } super(options); this[receiveQueueSymbol] = new ObservableQueue(); this[connectionSymbol] = {}; this[connectionSymbol].socket = null; this[connectionSymbol].reconnectCounter = 0; this[manualCloseSymbol] = false; } /** * * @return {Promise} */ connect() { return new Promise((resolve, reject) => { connectServer.call(this, resolve, reject); }); } /** * @return {boolean} */ isConnected() { return this[connectionSymbol]?.socket?.readyState === 1; } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/net/webconnect"); } /** * @property {string} url=undefined Defines the resource that you wish to fetch. * @property {Object} connection * @property {Object} connection.timeout=5000 Defines the timeout for the connection. * @property {Number} connection.reconnect.timeout The timeout in milliseconds for the reconnect. * @property {Number} connection.reconnect.attempts The maximum number of reconnects. * @property {Bool} connection.reconnect.enabled If the reconnect is enabled. */ get defaults() { return Object.assign({}, super.defaults, { url: undefined, connection: { timeout: 5000, reconnect: { timeout: 1000, attempts: 1, enabled: false, }, }, }); } /** * This method closes the connection. * * @param {Number} [code=1000] The close code. * @param {String} [reason=""] The close reason. * @return {Promise} * @see https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1 */ close(statusCode, reason) { if (!isInteger(statusCode) || statusCode < 1000 || statusCode > 4999) { statusCode = 1000; } if (!isString(reason)) { reason = ""; } return new Promise((resolve, reject) => { try { // Set manual close flag BEFORE calling close() to prevent reconnect this[manualCloseSymbol] = true; if (this[connectionSymbol].socket) { this[connectionSymbol].socket.close(statusCode, reason); } } catch (error) { reject(error); return; } resolve(); }); } /** * Polls the receive queue for new messages. * * @return {Message} */ poll() { return this[receiveQueueSymbol].poll(); } /** * Are there any messages in the receive queue? * * @return {boolean} */ dataReceived() { return !this[receiveQueueSymbol].isEmpty(); } /** * Get Message from the receive queue, but do not remove it. * * @return {Object} */ peek() { return this[receiveQueueSymbol].peek(); } /** * Attach a new observer * * @param {Observer} observer * @return {ProxyObserver} */ attachObserver(observer) { this[receiveQueueSymbol].attachObserver(observer); return this; } /** * Detach a observer * * @param {Observer} observer * @return {ProxyObserver} */ detachObserver(observer) { this[receiveQueueSymbol].detachObserver(observer); return this; } /** * @param {Observer} observer * @return {boolean} */ containsObserver(observer) { return this[receiveQueueSymbol].containsObserver(observer); } /** * @param {Message|Object} message * @return {Promise} */ send(message) { return new Promise((resolve, reject) => { if ( !this[connectionSymbol].socket || this[connectionSymbol].socket.readyState !== 1 ) { reject(new Error("The socket is not ready")); return; } try { this[connectionSymbol].socket.send(JSON.stringify(message)); resolve(); } catch (e) { reject(e); } }); } }