@iobroker/ws-server
Version:
ioBroker server-side web sockets
472 lines (471 loc) • 17.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SocketIO = exports.Socket = void 0;
const node_querystring_1 = require("node:querystring");
const ws_1 = require("ws");
/** Maximum message size which server accepts */
const MAX_PAYLOAD = 524_288_000;
const MESSAGE_TYPES = {
MESSAGE: 0,
PING: 1,
PONG: 2,
CALLBACK: 3,
};
const DEBUG = false;
class Socket {
ws;
id; // session ID
// this variable is used by @iobroker/socket-classes to store the auth flag
_secure = false;
// this variable is used by @iobroker/socket-classes to store the sessionID by authentication
_sessionID;
// this variable is used by @iobroker/socket-classes to store ACL
_acl = null;
// this variable is used by @iobroker/socket-classes to store subscribe settings
subscribe = undefined;
// this variable is used by @iobroker/socket-classes to store authentication pending
_authPending;
// this variable is used by @iobroker/socket-classes
_name;
// this variable is used by @iobroker/socket-classes
_lastActivity;
// this variable is used by @iobroker/socket-classes
_sessionTimer;
// this variable is used by @iobroker/socket-classes
_sessionExpiresAt;
// used by cloud
_apiKeyOk;
// used by cloud
_subSockets;
// used by cloud
__apiVersion;
___socket; // store the main socket under ___socket
conn;
connection;
/** Query object from URL */
query;
#handlers = {};
#messageId = 0;
#pingInterval;
#lastPong = Date.now();
#customHandler = false;
/**
*
* @param ws WebSocket object from ws package
* @param options Options
* @param options.sessionID session ID
* @param options.query query object from URL
* @param options.remoteAddress IP address of the client
* @param options.pathname path of the request URL for different handlers on one server
* @param options.cookie cookie string
* @param options.authorization headers.authorization string
*/
constructor(ws, options) {
this.ws = ws;
this._name = options.query.name;
this.query = options.query;
this.connection = { remoteAddress: options.remoteAddress };
this.id = options.sessionID;
// simulate interface of socket.io
this.conn = {
request: {
sessionID: options.sessionID,
pathname: options.pathname,
query: options.query,
headers: { cookie: options.cookie, authorization: options.authorization },
},
authorization: options.authorization,
};
this.#pingInterval = setInterval(() => {
if (Date.now() - this.#lastPong > 5000) {
ws.send(JSON.stringify([MESSAGE_TYPES.PING]));
}
if (Date.now() - this.#lastPong > 15000) {
this.close();
}
}, 5000);
ws.onmessage = (event) => {
if (this.#customHandler) {
// do not process any messages
return;
}
this.#lastPong = Date.now();
if (!event?.data || typeof event.data !== 'string') {
console.error(`Received invalid event: ${JSON.stringify(event?.data)}`);
return;
}
let messageArray;
try {
messageArray = JSON.parse(event.data);
}
catch {
console.error(`Received invalid event: ${JSON.stringify(event)}`);
return;
}
const type = messageArray[0];
const id = messageArray[1];
const name = messageArray[2];
const args = messageArray[3];
if (type === MESSAGE_TYPES.CALLBACK) {
if (DEBUG) {
console.log(name);
}
if (this.#handlers[name]) {
this.#withCallback(name, false, id, ...args);
}
if (this.#handlers['*']) {
this.#withCallback(name, true, id, ...args);
}
}
else if (type === MESSAGE_TYPES.MESSAGE) {
if (DEBUG) {
console.log(name);
}
if (this.#handlers[name]) {
if (args) {
setImmediate(() => this.#handlers[name]?.forEach(cb => cb.apply(this, args)));
}
else {
setImmediate(() => this.#handlers[name]?.forEach(cb => cb.call(this)));
}
}
// If the handler for all message exists, call it
if (this.#handlers['*']) {
if (args) {
args.unshift(name);
setImmediate(() => this.#handlers['*']?.forEach(cb => cb.apply(this, args)));
}
else {
setImmediate(() => this.#handlers['*']?.forEach(cb => cb.call(this, name)));
}
}
}
else if (type === MESSAGE_TYPES.PING) {
ws.send(JSON.stringify([MESSAGE_TYPES.PONG]));
}
else if (type === MESSAGE_TYPES.PONG) {
// lastPong saved
}
else {
console.log(`Received unknown event type: ${type}`);
}
};
}
/**
* Do not start ping/pong, do not process any messages and do not send any, as it will be processed by custom handler
*/
enableCustomHandler(onCloseForced) {
if (!this.#customHandler) {
if (this.#pingInterval) {
clearInterval(this.#pingInterval);
this.#pingInterval = null;
}
this.#customHandler = true;
const names = Object.keys(this.#handlers);
for (const name of names) {
delete this.#handlers[name];
}
if (onCloseForced) {
// Inform custom handler, that ws socket was closed
this.#handlers.disconnect = [onCloseForced];
}
}
}
/**
* Install handler on event
*/
on(name, cb) {
if (this.#customHandler) {
throw new Error('Cannot use on() with custom handler');
}
if (cb) {
this.#handlers[name] ||= [];
this.#handlers[name].push(cb);
}
}
/**
* Remove handler from event
*/
off(name, cb) {
if (this.#customHandler) {
throw new Error('Cannot use off() with custom handler');
}
if (name && this.#handlers[name] && cb) {
const pos = this.#handlers[name].indexOf(cb);
if (pos !== -1) {
this.#handlers[name].splice(pos, 1);
if (!this.#handlers[name].length) {
delete this.#handlers[name];
}
}
}
else if (name && this.#handlers[name]) {
delete this.#handlers[name];
}
else if (!name) {
Object.keys(this.#handlers).forEach(name => {
delete this.#handlers[name];
});
}
}
emit(name, ...args) {
if (this.#customHandler) {
throw new Error('Cannot use emit() with custom handler');
}
this.#messageId++;
if (this.#messageId >= 0xffffffff) {
this.#messageId = 1;
}
if (!args?.length) {
this.ws.send(JSON.stringify([MESSAGE_TYPES.MESSAGE, this.#messageId, name]));
}
else {
this.ws.send(JSON.stringify([MESSAGE_TYPES.MESSAGE, this.#messageId, name, args]));
}
}
#responseWithCallback(name, id, ...args) {
// error cannot be converted normally, so try to use internal function for it
if (args && args[0] instanceof Error) {
args[0] = args[0].toString();
}
if (!args?.length) {
return this.ws.send(JSON.stringify([MESSAGE_TYPES.CALLBACK, id, name]));
}
this.ws.send(JSON.stringify([MESSAGE_TYPES.CALLBACK, id, name, args]));
}
#withCallback(name, wildcards, id, ...args) {
if (!args?.length) {
setImmediate(() => {
if (wildcards) {
this.#handlers['*']?.forEach(cb => cb.call(this, name, (...responseArgs) => this.#responseWithCallback(name, id, ...responseArgs)));
}
else {
this.#handlers[name]?.forEach(cb => cb.call(this, (...responseArgs) => this.#responseWithCallback(name, id, ...responseArgs)));
}
});
}
else {
setImmediate(() => {
if (wildcards) {
args.unshift(name);
this.#handlers['*']?.forEach(cb => cb.apply(this, [
...args,
(...responseArgs) => this.#responseWithCallback(name, id, ...responseArgs),
]));
}
else {
this.#handlers[name]?.forEach(cb => cb.apply(this, [
...args,
(...responseArgs) => this.#responseWithCallback(name, id, ...responseArgs),
]));
}
});
}
}
close() {
if (this.#pingInterval) {
clearInterval(this.#pingInterval);
this.#pingInterval = null;
}
this.#handlers.disconnect?.forEach(cb => cb.apply(this));
// delete all handlers
Object.keys(this.#handlers).forEach(name => (this.#handlers[name] = undefined));
try {
this.ws.close();
}
catch {
// ignore
}
}
// Indirectly used in cloud
disconnect() {
this.close();
}
}
exports.Socket = Socket;
class SocketIO {
/** This attribute is used to detect ioBroker socket */
ioBroker = true;
engine;
#handlers = {};
#socketsList = [];
#run = [];
sockets;
constructor(server) {
const wss = new ws_1.WebSocketServer({
server,
verifyClient: (info, done) => {
let finished = false;
if (this.#run.length) {
this.#run.forEach(cb => cb(info.req, err => {
if (err) {
info.req._wsNotAuth = true;
}
if (done && !finished) {
finished = true;
done(true);
}
}));
}
else if (done && !finished) {
finished = true;
done(true);
}
},
perMessageDeflate: {
zlibDeflateOptions: {
chunkSize: 1024,
memLevel: 9,
level: 9,
},
zlibInflateOptions: {
chunkSize: 16 * 1024,
},
clientNoContextTakeover: true,
serverNoContextTakeover: true,
},
maxPayload: MAX_PAYLOAD,
});
wss.on('connection', (ws, request) => {
if (DEBUG) {
console.log('connected');
}
if (!request) {
console.error('Unexpected behaviour: request is NULL!');
}
if (request?._wsNotAuth) {
const ip = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
this.#handlers.error?.forEach(cb => cb('error', `authentication failed for ${ip}`));
ws.send(JSON.stringify([MESSAGE_TYPES.MESSAGE, 401, 'reauthenticate']));
setTimeout(() => ws?.close(), 500);
}
else {
let query;
try {
if (request) {
const queryString = (request.url || '').split('?')[1];
query = (0, node_querystring_1.parse)(queryString || '');
}
}
catch {
query = null;
}
if (query && query.sid) {
const socket = new Socket(ws, {
// @ts-expect-error sessionID could exists
sessionID: request.sessionID || query.sid || '',
query,
remoteAddress: request.socket.remoteAddress || '',
pathname: (request.url || '').split('?')[0],
cookie: request.headers.cookie,
authorization: request.headers.authorization || request.headers.Authorization,
});
this.#socketsList.push(socket);
this.sockets.engine.clientsCount = this.#socketsList.length;
ws.onclose = () => {
if (DEBUG) {
console.log('closed');
}
let i;
for (i = 0; i < this.#socketsList.length; i++) {
if (this.#socketsList[i].ws === ws) {
this.#socketsList[i].close();
this.#socketsList.splice(i, 1);
this.sockets.engine.clientsCount = this.#socketsList.length;
return;
}
}
};
ws.onerror = error => {
if (this.#handlers.error) {
this.#handlers.error.forEach(cb => cb('error', error));
}
else {
console.error(`Web socket error: ${JSON.stringify(error)}`);
}
ws?.close();
};
// install handlers
if (this.#handlers.connection?.length) {
// we have a race condition here.
// If the user is not admin, it will be requested for him the rights and no handlers will be installed.
// So we must be sure that all event handlers are installed before sending ___ready___.
let timeout = setTimeout(() => {
timeout = null;
socket.emit('___ready___');
console.warn('Sent ready, but not all handlers installed!');
}, 1500); // TODO, This parameter must be configurable
this.#handlers.connection.forEach((cb) => cb(socket, (customHandler) => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
// If not custom handler, send ready
if (!customHandler) {
// say to a client we are ready
socket.emit('___ready___');
}
}
}));
}
else {
socket.emit('___ready___');
}
}
else {
if (request) {
const ip = request.headers['x-forwarded-for'] || request.socket.remoteAddress;
this.#handlers.error?.forEach(cb => cb('error', `No sid found from ${ip}`));
}
else {
this.#handlers.error?.forEach(cb => cb('error', 'No sid found'));
}
ws.send(JSON.stringify([MESSAGE_TYPES.MESSAGE, 501, 'error', ['invalid sid']]));
setTimeout(() => ws?.close(), 500);
}
}
});
wss.on('error', (error) => {
if (this.#handlers.error) {
this.#handlers.error.forEach(cb => cb('error', error));
}
else {
console.error(`Web socket server error: ${error}`);
}
});
this.sockets = {
connected: this.#socketsList, // for socket.io 2.0 compatibility
sockets: this.#socketsList, // for socket.io 4.0 compatibility
emit: (name, ...args) => this.#socketsList.forEach(socket => socket.emit(name, ...args)),
engine: {
clientsCount: 0,
},
};
this.engine = this.sockets.engine;
}
on(name, cb) {
if (cb) {
this.#handlers = this.#handlers || {};
this.#handlers[name] = this.#handlers[name] || [];
this.#handlers[name].push(cb);
}
}
off(name, cb) {
if (this.#handlers && this.#handlers[name]) {
const pos = this.#handlers[name].indexOf(cb);
if (pos !== -1) {
this.#handlers[name].splice(pos, 1);
if (!this.#handlers[name].length) {
delete this.#handlers[name];
}
}
}
}
use(cb) {
this.#run.push(cb);
return this;
}
close() {
this.#socketsList.forEach(socket => socket.close());
}
}
exports.SocketIO = SocketIO;