@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
396 lines (351 loc) • 9.89 kB
JavaScript
/**
* 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);
}
});
}
}