UNPKG

socio

Version:

A WebSocket Real-Time Communication (RTC) API framework.

821 lines (820 loc) 47.3 kB
import { WebSocketServer } from 'ws'; import * as diff_lib from 'recursive-diff'; import { ParseQueryTables, ParseQueryVerb } from './sql-parsing.js'; import { SocioStringParse, GetAllMethodNamesOf, socio_decode, initLifecycleHooks } from './utils.js'; import { E, LogHandler } from './logging.js'; import { UUID } from './secure.js'; import { SocioSession } from './core-session.js'; import { RateLimiter } from './ratelimit.js'; import { ServerMessageKind, ClientMessageKind } from './utils.js'; export class SocioServer extends LogHandler { #wss; #sessions = new Map(); #secure; #cypther_text_cache = new Map(); #props = new Map(); #ratelimits = { con: null, upd: null }; #tokens = new Set(); #prop_upd_diff = false; #global_largest_id = 0; #client_queries = new Map(); db; session_defaults = { timeouts: false, timeouts_check_interval_ms: 1000 * 60, session_timeout_ttl_ms: Infinity, session_delete_delay_ms: 1000 * 5, recon_ttl_ms: 1000 * 60 * 60 }; lifecycle_hooks; prop_reg_timeout_ms; auto_recon_by_ip = false; send_sensitive_error_msgs_to_client; allow_rpc; constructor(opts = {}, { db, socio_security = null, allow_discovery = false, allow_rpc = false, logging = { verbose: false, hard_crash: false }, decrypt_opts = { decrypt_sql: true, decrypt_prop: false, decrypt_endpoint: false }, session_defaults = undefined, prop_upd_diff = false, prop_reg_timeout_ms = 1000 * 10, auto_recon_by_ip = false, send_sensitive_error_msgs_to_client = true, hooks = {}, }) { super({ ...logging, prefix: 'SocioServer' }); this.#wss = new WebSocketServer({ ...opts, clientTracking: true }); this.#secure = { socio_security, ...decrypt_opts, allow_discovery, allow_rpc }; this.#prop_upd_diff = prop_upd_diff; this.lifecycle_hooks = { ...initLifecycleHooks(), ...hooks }; if (!db.hasOwnProperty('allowed_SQL_verbs')) db.allowed_SQL_verbs = ['SELECT', 'INSERT', 'UPDATE']; this.db = db; this.session_defaults = Object.assign(this.session_defaults, session_defaults); this.prop_reg_timeout_ms = prop_reg_timeout_ms; this.auto_recon_by_ip = auto_recon_by_ip; this.send_sensitive_error_msgs_to_client = send_sensitive_error_msgs_to_client; this.#wss.on('connection', this.#Connect.bind(this)); this.#wss.on('close', (...stuff) => { this.HandleInfo('WebSocketServer close event', ...stuff); }); this.#wss.on('error', (...stuff) => { this.HandleError(new E('WebSocketServer error event', ...stuff)); }); if (this.session_defaults.timeouts) setInterval(this.#CheckSessionsTimeouts.bind(this), this.session_defaults.timeouts_check_interval_ms); if (this.verbose) { const addr = this.#wss.address(); this.done(`Created SocioServer on`, addr); if (addr.family == 'ws') this.HandleInfo('WARNING! Your server is using an unsecure WebSocket protocol, setup wss:// instead, when you can!'); if (!socio_security) this.HandleInfo('WARNING! Please use the SocioSecurity class in production to securely de/encrypt Socio strings from clients!'); if (this.send_sensitive_error_msgs_to_client) this.HandleInfo('WARNING! send_sensitive_error_msgs_to_client field IS TRUE, which means server error messages are sent to the client as is. They might include sesitive info. If false, the server will only send a generic error message.'); } } async #Connect(conn, request) { try { let client_id = (this.lifecycle_hooks.gen_client_id ? await this.lifecycle_hooks.gen_client_id() : UUID())?.toString(); while (this.#sessions.has(client_id)) client_id = (this.lifecycle_hooks.gen_client_id ? await this.lifecycle_hooks.gen_client_id() : UUID())?.toString(); const client_ip = 'x-forwarded-for' in request?.headers ? request.headers['x-forwarded-for'].split(',')[0].trim() : request.socket.remoteAddress; const client = new SocioSession(client_id, conn, client_ip, { logging: { verbose: this.verbose }, session_opts: { session_timeout_ttl_ms: this.session_defaults.session_timeout_ttl_ms, max_payload_size: this.session_defaults.max_payload_size } }); this.#sessions.set(client_id, client); if (this.lifecycle_hooks.con) await this.lifecycle_hooks.con(client, request); conn.on('message', (req, isBinary) => { if (this.#sessions.has(client_id)) this.#Message.bind(this)(this.#sessions.get(client_id), req, isBinary); else conn?.close(); }); conn.on('close', (code, reason) => { this.#SocketClosed.bind(this)(client, { code, reason: reason.toString('utf8') }); }); conn.on('error', (error) => { this.#SocketClosed.bind(this)(client, error); }); if (this.auto_recon_by_ip) { for (const [id, ses] of this.#sessions.entries()) { if (id !== client_id && ses.ipAddr === client_ip) { const old_client = this.#sessions.get(id); this.ReconnectClientSession(client, old_client); this.HandleInfo(`AUTO IP RECON | old id: ${id} -> new id: ${client.id} | IP: ${client_ip}`); break; } } } client.Send(ClientMessageKind.CON, client_id); this.HandleInfo('CON', { id: client_id, ip: client_ip }); } catch (e) { this.HandleError(e); } } async #SocketClosed(client, event_args) { if (this.lifecycle_hooks.discon) await this.lifecycle_hooks.discon(client); const client_id = client.id; this.HandleInfo('DISCON', client_id, event_args); client.Destroy(() => { this.#ClearClientSessionSubs(client_id); this.#sessions.delete(client_id); this.HandleInfo('Session destroyed on disconnect.', client_id); }, this.session_defaults.session_delete_delay_ms); } get new_global_id() { return ++this.#global_largest_id; } async #Message(client, req, isBinary) { try { let kind; let data; try { const decoded = socio_decode(req); kind = decoded.kind; data = decoded.data; if (kind === undefined || data === undefined) throw new Error('Not a socio message'); } catch (e) { this.HandleInfo(`recv: BLOB from ${client.id}`); if (this.lifecycle_hooks.blob) { if (await this.lifecycle_hooks.blob(client, req)) client.Send(ClientMessageKind.RES, { id: 'BLOB', result: { success: 1 } }); else client.Send(ClientMessageKind.RES, { id: 'BLOB', result: { success: 0 } }); } else client.Send(ClientMessageKind.RES, { id: 'BLOB', result: { success: 0, error: 'Server does not handle the BLOB hook.' } }); return; } const client_id = client.id; if (typeof data.id === 'number' && data.id > this.#global_largest_id) this.#global_largest_id = data.id; try { if (this.#secure.socio_security) { for (const field of ['sql', 'prop', 'endpoint']) if (data[field] && this.#secure['decrypt_' + field]) data[field] = this.#Decrypt(client, data[field], field === 'sql'); } if (kind !== ServerMessageKind.OK) this.HandleInfo(`recv: [${ServerMessageKind[kind]}] from [${client.name ? client.name + ' | ' : ''}${client_id}]`, kind != ServerMessageKind.UP_FILES ? data : `File count: ${data.files instanceof Map ? data.files.size : Object.keys(data.files || {}).length}`); if (this.lifecycle_hooks.msg) if (await this.lifecycle_hooks.msg(client, kind, data)) return; switch (kind) { case ServerMessageKind.SUB: { if (this.lifecycle_hooks.sub) if (await this.lifecycle_hooks.sub(client, kind, data)) return; if (!this.db.Query) throw new E('This action requires a Database Query function on SocioServer! [#no-db-query-SUB]', { kind, data }); if (data.endpoint && !data.sql) { if (this.lifecycle_hooks.endpoint) data.sql = await this.lifecycle_hooks.endpoint(client, data.endpoint); else throw new E('Client sent endpoint instead of SQL, but its hook is missing, so cant resolve it. [#no-endpoint-hook-SUB]', { kind, data }); } if (!data?.sql) throw new E('SQL or endpoint field missing in request. [#no-sql]', { kind, data }); const query_verb = ParseQueryVerb(data.sql); if (data?.sql && !query_verb) throw new E('Could not parse query verb. [#parse-verb-SUB]', { kind, data, query_verb }); if (query_verb && this.db?.allowed_SQL_verbs && !this.db.allowed_SQL_verbs?.includes(query_verb)) throw new E('Server doesnt allow this query verb. (case-sensitive) [#verb-not-allowed-SUB]', { kind, data, query_verb, allowed: this.db.allowed_SQL_verbs }); if (query_verb === 'SELECT') { const tables = ParseQueryTables(data.sql || ''); if (tables) client.RegisterSub(tables, data.id, data.sql || '', data?.params, data?.rate_limit); const res = await this.db.Query(client, data.id || 0, data.sql || '', data?.params); client.Send(ClientMessageKind.UPD, { id: data.id, result: { success: 1, res } }); } else throw new E('Only SELECT queries may be subscribed to! [#reg-not-select]', { kind, data }); break; } case ServerMessageKind.UNSUB: { if (this.lifecycle_hooks.unsub) if (await this.lifecycle_hooks.unsub(client, kind, data)) return; client.Send(ClientMessageKind.RES, { id: data.id, result: { success: client.UnRegisterSub(data?.unreg_id || '') } }); break; } case ServerMessageKind.SQL: { if (!this.db.Query) throw 'This action requires a Database Query function on SocioServer! [#no-db-query-SQL]'; if (data?.sql_is_endpoint && data.sql) { if (this.lifecycle_hooks.endpoint) data.sql = await this.lifecycle_hooks.endpoint(client, data.sql); else throw new E('Client sent endpoint instead of SQL, but its hook is missing. [#no-endpoint-hook-SQL]'); } if (!data?.sql) throw new E('SQL or endpoint field missing in request. [#no-sql]', { kind, data }); const query_verb = ParseQueryVerb(data.sql); if (data?.sql && !query_verb) throw new E('Could not parse query verb. [#parse-verb-SUB]', { kind, data, query_verb }); if (query_verb && this.db?.allowed_SQL_verbs && !this.db.allowed_SQL_verbs?.includes(query_verb)) throw new E('Server doesnt allow this query verb. (case-sensitive) [#verb-not-allowed-SUB]', { kind, data, query_verb, allowed: this.db.allowed_SQL_verbs }); const res = this.db.Query(client, data.id || 0, data.sql || '', data.params); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res: await res } }); if (query_verb !== 'SELECT') { if (query_verb === 'DROP') { const dropped_table = ParseQueryTables(data.sql); if (dropped_table) { for (const session of this.#sessions.values()) for (const sub of session.GetSubsForTables(dropped_table)) session.UnRegisterSub(sub.id); } else throw new E('Failed to parse table of a client DROP query', { kind, data }); } else this.Update(client, data.sql || '', data?.params); } break; } case ServerMessageKind.PING: { client.Send(ClientMessageKind.PONG, { id: data?.id }); break; } case ServerMessageKind.AUTH: { if (client.authenticated) client.Send(ClientMessageKind.AUTH, { id: data.id, result: { success: 1 } }); else if (this.lifecycle_hooks.auth) { const res = await client.Authenticate(this.lifecycle_hooks.auth, data.params); client.Send(ClientMessageKind.AUTH, { id: data.id, result: { success: 1, res: res === true ? 1 : 0 } }); } else { const error = 'AUTH function hook not registered, so client not authenticated. [#no-auth-func]'; this.HandleError(error); client.Send(ClientMessageKind.AUTH, { id: data.id, result: { success: 0, error } }); } break; } case ServerMessageKind.GET_PERM: { if (client.HasPermFor(data?.verb, data?.table)) client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: { success: 1 } }); else if (this.lifecycle_hooks.grant_perm) { const granted = await this.lifecycle_hooks.grant_perm(client, data); client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: granted === true ? 1 : 0 }); } else { const error = 'grant_perm function hook not registered, so client not granted perm. [#no-grant_perm-func]'; this.HandleError(error); client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: { success: 0, error } }); } break; } case ServerMessageKind.PROP_SUB: { this.#CheckPropExists(data?.prop, client, data.id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-sub]`); if (this.lifecycle_hooks.sub) if (await this.lifecycle_hooks.sub(client, kind, data)) return; this.#props.get(data.prop)?.updates.set(client_id, { id: data.id, rate_limiter: data?.rate_limit ? new RateLimiter(data.rate_limit) : undefined }); if (data?.data?.receive_initial_update) await client.Send(ClientMessageKind.PROP_UPD, { id: data.id, prop: data.prop, prop_val: this.GetPropVal(data.prop) }); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1 } }); break; } case ServerMessageKind.PROP_UNSUB: { this.#CheckPropExists(data?.prop, client, data.id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-unsub]`); if (this.lifecycle_hooks.unsub) if (await this.lifecycle_hooks.unsub(client, kind, data)) return; const prop = this.#props.get(data.prop); const del_success = prop?.updates.delete(client_id) ? 1 : 0; client.Send(ClientMessageKind.RES, { id: data?.id, result: { success: del_success, res: del_success } }); if (prop?.observationaly_temporary && prop.updates.size === 0) { this.UnRegisterProp(data.prop, 'observationaly_temporary'); this.HandleDebug('Temporary Prop UNregistered!', data.prop); } break; } case ServerMessageKind.PROP_GET: { this.#CheckPropExists(data?.prop, client, data.id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-get]`); const prop_val = this.GetPropVal(data?.prop); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: prop_val !== undefined ? 1 : 0, res: prop_val, error: prop_val === undefined ? 'Server couldnt find prop' : '' } }); break; } case ServerMessageKind.PROP_SET: { this.#CheckPropExists(data?.prop, client, data.id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-set]`); if (this.#props.get(data.prop)?.client_writable) { const result = this.UpdatePropVal(data.prop, data?.prop_val, { sender_client_id: client.id, send_as_diff: data.hasOwnProperty('prop_upd_as_diff') ? data.prop_upd_as_diff : this.#prop_upd_diff }); const res = { success: result }; if (result === 2) { res['error'] = '[#no-diff] Server already has identical value.'; res.success = 0; } client.Send(ClientMessageKind.RES, { id: data.id, result: res }); } else throw new E('Prop is not client_writable.', data); break; } case ServerMessageKind.PROP_REG: { if (data?.prop && this.#props.has(data?.prop || '')) { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error: `Prop name "${data.prop}" already registered on server! Choose a different name.` } }); return; } if (!data?.prop) { data.prop = this.lifecycle_hooks.gen_prop_name ? await this.lifecycle_hooks.gen_prop_name(client) : UUID(); while (this.#props.has(data.prop)) data.prop = UUID(); } this.RegisterProp(data.prop, data.initial_value || null, { ...((data?.opts) || {}), observationaly_temporary: true }); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res: { prop: data.prop } }, }); if (this.prop_reg_timeout_ms > 0) setTimeout(() => { if (this.#props.has(data.prop)) { if (this.#props.get(data.prop).updates.size === 0) { this.UnRegisterProp(data.prop, 'prop_reg_timeout'); this.HandleDebug(`Temporary Prop UNregistered, because nobody subbed it before prop_reg_timeout_ms (${this.prop_reg_timeout_ms}ms)!`, data.prop); } } }, this.prop_reg_timeout_ms); break; } case ServerMessageKind.SERV: { if (this.lifecycle_hooks.serv) { const res = await this.lifecycle_hooks.serv(client, data); if (res !== undefined) { client.Send(ClientMessageKind.RES, { id: data?.id, result: { success: 1, res } }); } } else throw new E('Client sent generic data to the server, but the hook for it is not registed. [#no-serv-hook]', client_id); break; } case ServerMessageKind.ADMIN: { if (this.lifecycle_hooks.admin) if (await this.lifecycle_hooks.admin(client, data)) client.Send(ClientMessageKind.RES, { id: data?.id, result: await this.#Admin(data?.function, data?.args) }); else throw new E('A non Admin send an Admin message, but was not executed.', kind, data, client_id); break; } case ServerMessageKind.RECON: { if (!this.#secure.socio_security) { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error: 'Cannot reconnect on this server configuration!' } }); throw new E(`RECON requires SocioServer to be set up with the Secure class! [#recon-needs-secure]`, { kind, data }); } if (data?.type === 'GET') { const token = this.#secure.socio_security.EncryptString([this.#secure.socio_security?.GenRandInt(100_000, 1_000_000), client.ipAddr, client.id, (new Date()).getTime(), this.#secure.socio_security?.GenRandInt(100_000, 1_000_000)].join(' ')); this.#tokens.add(token); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res: token } }); } else if (data?.type === 'USE') { if (!data?.token || !this.#tokens.has(data.token)) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Invalid token' } }); return; } this.#tokens.delete(data.token); let [iv, token, auth_tag] = data.token.split(' '); try { if (iv && token && auth_tag) token = this.#secure.socio_security.DecryptString(iv, token, auth_tag); else client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Invalid token' } }); } catch (e) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Invalid token' } }); return; } const [r1, ip, old_c_id, time_stamp, r2] = token.split(' '); if (!(r1 && ip && old_c_id && time_stamp && r2)) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Invalid token format' } }); return; } if (client.ipAddr !== ip) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'IP address changed between reconnect' } }); return; } else if ((new Date()).getTime() - parseInt(time_stamp) > this.session_defaults.recon_ttl_ms) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Token has expired' } }); return; } else if (!(this.#sessions.has(old_c_id))) { client.Send(ClientMessageKind.RECON, { id: data.id, result: { success: 0, error: 'Old session ID was not found' } }); return; } const old_client = this.#sessions.get(old_c_id); this.ReconnectClientSession(client, old_client, data.id); this.HandleInfo(`RECON | old id: ${old_c_id} -> new id: ${client.id}`); } break; } case ServerMessageKind.UP_FILES: { if (this.lifecycle_hooks?.file_upload) { let files = data?.files; if (files && !(files instanceof Map)) { files = new Map(Object.entries(files)); } const success = await this.lifecycle_hooks.file_upload(client, files, data?.data) ? 1 : 0; client.Send(ClientMessageKind.RES, { id: data.id, result: { success, res: success } }); } else { const error = 'file_upload hook not registered. [#no-file_upload-hook]'; this.HandleError(error); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error } }); } break; } case ServerMessageKind.GET_FILES: { if (this.lifecycle_hooks?.file_download) { const response = await this.lifecycle_hooks.file_download(client, data?.data); if (!response?.result) this.HandleError(new E('file_download hook returned unsuccessful result.', response?.error)); client.Send(ClientMessageKind.RECV_FILES, { id: data.id, files: response.files, result: { success: response.result ? 1 : 0 } }); } else { this.HandleError('file_download hook not registered. [#no-file_download-hook]'); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0 } }); } break; } case ServerMessageKind.IDENTIFY: { const name = data.name; if (Object.values(this.GetSessionsInfo()).some(s => s.name === name)) { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error: 'A session already has this name!' } }); } else { client.name = name; if (this.lifecycle_hooks?.identify) await this.lifecycle_hooks.identify(client, name); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1 } }); } break; } case ServerMessageKind.DISCOVERY: { if (this.#secure.allow_discovery === true) { if (this.lifecycle_hooks?.discovery) if (await this.lifecycle_hooks.discovery(client, data)) return; client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res: this.GetSessionsInfo() } }); } break; } case ServerMessageKind.RPC: { if (this.#secure.allow_rpc !== true) { const error = 'Client tried RPC, but the server hasnt enabled it. [#rpc-not-enabled]'; this.HandleDebug(error, client, data); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error } }); return; } if (this.lifecycle_hooks.rpc) { const res = await this.lifecycle_hooks.rpc(data.target_client, data.f_name, data.args); if (res !== undefined) { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res } }); return; } } if (data.target_client === null) { if (data.f_name in this) { const res = this[data.f_name](...data.args); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res } }); return; } else throw new E(`Client RPC to server, but there is no [${data.f_name}] function on the SocioServer class instance! [#unknown-server-func-rpc]`, { client_id: client.id, data }); } else { const target_c = this.#sessions.get(data.target_client); if (!target_c) { const error = 'Client tried RPC, but the target client doesnt exist. [#rpc-no-target]'; this.HandleDebug(error, client, data); client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error } }); return; } const new_id = this.new_global_id; target_c.Send(ClientMessageKind.RPC, { ...data, id: new_id }); this.#CreateClientQueryPromise(new_id, ServerMessageKind.RPC) .then(res => { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 1, res } }); }) .catch(reason => { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error: reason } }); }); } break; } case ServerMessageKind.OK: { const q = this.#client_queries.get(data.id); if (q) { this.HandleInfo(`recv: [OK ${ServerMessageKind[q.for_msg_kind]}] from [${client.name ? client.name + ' | ' : ''}${client_id}]`, data); q.resolve(data.return); this.#client_queries.delete(data.id); } else throw new E(`Received OK from client for an unknown client query. [#client-query-not-found]`, { sender: client.id, data }); break; } default: { const exhaustiveCheck = kind; throw new E(`Unrecognized message kind! [#unknown-msg-kind]`, { kind, data }); } } } catch (e) { client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0, error: this.send_sensitive_error_msgs_to_client ? String(e) : 'Server had an error with this request.' } }); this.HandleError(e); } } catch (e) { this.HandleError(e); } } #Decrypt(client, str, is_sql) { let socio_string_obj; if (this.#cypther_text_cache.has(str)) socio_string_obj = this.#cypther_text_cache.get(str); else { const parts = str.includes(' ') ? str.split(' ') : []; if (parts.length != 3) throw new E('the cipher text does not contain exactly 3 space seperated parts, therefor is invalid. [#cipher-text-invalid-format]', { client, str }); const cypher_text = str; str = this.#secure.socio_security.DecryptString(parts[0], parts[1], parts[2]); str = this.#secure.socio_security.RemoveRandInts(str); socio_string_obj = SocioStringParse(str); this.#cypther_text_cache.set(cypher_text, socio_string_obj); } if (socio_string_obj.markers?.includes('auth')) if (!client.authenticated) throw new E(`Client tried to execute an auth query without being authenticated. [#auth-issue]`, { client }); if (is_sql && socio_string_obj.markers?.includes('perm')) { const verb = ParseQueryVerb(socio_string_obj.str); if (!verb) throw new E(`Client sent an unrecognized SQL query verb. [#verb-issue]`, { client, str: socio_string_obj.str }); const tables = ParseQueryTables(socio_string_obj.str); if (!tables) throw new E(`Client sent an SQL query without table names. [#table-names-not-found]`, { client, str: socio_string_obj.str }); if (!tables.every((t) => client.HasPermFor(verb, t))) throw new E(`Client tried to execute a perms query without having the required permissions. [#perm-issue]`, { client, str: socio_string_obj.str, verb, tables }); } return socio_string_obj.str; } async Update(initiator, sql, params) { if (this.#ratelimits.upd) if (this.#ratelimits.upd.CheckLimit()) return; if (this.lifecycle_hooks.upd) if (await this.lifecycle_hooks.upd(this.#sessions, initiator, sql, params)) return; if (!this.db.Query) throw 'SocioServer.Update requires a Database Query function on SocioServer! [#no-db-query-UPDATE]'; try { const tables = ParseQueryTables(sql); if (tables.length == 0) throw new E('Update ParseQueryTables didnt find any table names in the SQL. Something must be wrong.', { initiator, sql, params }); const cache = new Map(); for (const client of this.#sessions.values()) { for (const hook of client.GetSubsForTables(tables)) { if (hook.rate_limiter && hook.rate_limiter.CheckLimit()) return; if (this.db?.Arbiter) if (await this.db.Arbiter({ client: initiator, sql, params }, { client, hook }) === false) continue; if (cache.has(hook.cache_hash)) client.Send(ClientMessageKind.UPD, { id: hook.id, result: { success: 1, res: cache.get(hook.cache_hash) } }); else this.db.Query(client, hook.id, hook.sql, hook.params) .then(res => { client.Send(ClientMessageKind.UPD, { id: hook.id, result: { success: 1, res } }); cache.set(hook.cache_hash, res); }) .catch(error => client.Send(ClientMessageKind.UPD, { id: hook.id, result: { success: 0, error }, })); } ; } } catch (e) { this.HandleError(e); } } #CheckPropExists(prop, client, msg_id, error_msg) { if (!prop || !(this.#props.has(prop))) { client.Send(ClientMessageKind.RES, { id: msg_id, result: { success: 0, error: error_msg } }); throw new E(error_msg, prop, client.id); } } RegisterRateLimit(f_name, ratelimit = null) { try { if (f_name in this.#ratelimits) { if (ratelimit) { this.#ratelimits[f_name] = new RateLimiter(ratelimit); } } else throw new E(`Rate Limits hook [${f_name}] is not settable! Settable: ${this.RateLimitNames}`); } catch (e) { this.HandleError(e); } } UnRegisterRateLimit(f_name) { try { if (f_name in this.#ratelimits) this.#ratelimits[f_name] = null; else throw new E(`Rate Limits hook [${f_name}] is not settable! Settable: ${this.RateLimitNames}`); } catch (e) { this.HandleError(e); } } get RateLimitNames() { return Object.keys(this.#ratelimits); } GetClientSession(client_id = '') { return this.#sessions.get(client_id); } RegisterProp(key, val, { assigner = this.SetPropVal.bind(this), client_writable = true, send_as_diff = undefined, emit_to_sender = false, observationaly_temporary = false } = {}, reason = 'server') { try { if (this.#props.has(key)) throw new E(`Prop key [${key}] has already been registered and for client continuity is forbiden to over-write at runtime. [#prop-key-exists]`); else this.#props.set(key, { val, assigner, updates: new Map(), client_writable, send_as_diff, emit_to_sender, observationaly_temporary }); if (observationaly_temporary) this.HandleDebug('Temporary Prop registered!', key); if (this.lifecycle_hooks.reg_prop) this.lifecycle_hooks.reg_prop(key, { reason, opts: { client_writable, send_as_diff, emit_to_sender, observationaly_temporary } }); } catch (e) { this.HandleError(e); } } UnRegisterProp(key, reason = 'server') { try { const prop = this.#props.get(key); if (!prop) throw new E(`Prop key [${key}] not registered! [#UnRegisterProp-prop-not-found]`); if (!this.#props.delete(key)) throw new E(`Error deleting prop key [${key}]. [#prop-key-del-error]`); for (const [client_id, args] of prop.updates.entries()) { if (this.#sessions.has(client_id)) this.#sessions.get(client_id)?.Send(ClientMessageKind.PROP_DROP, { id: args.id, prop: key }); else this.#sessions.delete(client_id); } if (this.lifecycle_hooks.unreg_prop) this.lifecycle_hooks.unreg_prop(key, { reason }); } catch (e) { this.HandleError(e); } } GetPropVal(key, copy_raw = true) { const prop = this.#props.get(key); return prop ? (copy_raw ? structuredClone(prop.val) : prop.val) : undefined; } UpdatePropVal(key, new_val, { sender_client_id = null, send_as_diff = undefined, force = false } = {}) { const prop = this.#props.get(key); if (!prop) throw new E(`Prop key [${key}] not registered! [#prop-update-not-found]`); const old_prop_val = structuredClone(prop.val); if (prop.assigner(key, new_val, sender_client_id ? this.#sessions.get(sender_client_id) : undefined)) { const new_assigned_prop_val = this.GetPropVal(key); const prop_val_diff = diff_lib.getDiff(old_prop_val, new_assigned_prop_val); if (prop_val_diff.length === 0 && force === false) return 2; for (const [client_id, args] of prop.updates.entries()) { if (args?.rate_limiter && args.rate_limiter?.CheckLimit()) continue; if (sender_client_id === client_id && prop.emit_to_sender === false) continue; if (this.#sessions.has(client_id)) { const upd_data = { id: args.id, prop: key }; if (send_as_diff === undefined) { if (prop?.send_as_diff !== undefined) send_as_diff = prop?.send_as_diff; else if (this.#prop_upd_diff !== undefined) send_as_diff = this.#prop_upd_diff; } if (send_as_diff) upd_data['prop_val_diff'] = prop_val_diff; else upd_data['prop_val'] = new_assigned_prop_val; this.#sessions.get(client_id)?.Send(ClientMessageKind.PROP_UPD, upd_data); } else { prop.updates.delete(client_id); this.#sessions.delete(client_id); } } return 1; } this.HandleDebug(`Assigner denied setting the new prop value! [#prop-set-not-valid].`, { key, old_prop_val, new_val, sender_client_id }); return 0; } SetPropVal(key, new_val) { try { const prop = this.#props.get(key); if (prop === undefined) throw new E(`Prop key [${key}] not registered! [#prop-set-not-found]`); prop.val = new_val; if (prop.inspect) prop.inspect(prop.val); return true; } catch (e) { this.HandleError(e); return false; } } InspectProp(key, callback) { const prop = this.#props.get(key); if (prop === undefined) throw new E(`Prop key [${key}] not registered! [#prop-inspect-not-found]`); prop.inspect = callback; } async SendToClients(clients = [], data = {}, kind = ClientMessageKind.CMD) { let sessions = this.#sessions.values(); if (clients.length) sessions = sessions.filter(c => clients.includes(c.id) || (c?.name && clients.includes(c.name))); const proms = []; for (const s of sessions) proms.push(s.Send(kind, data)); return Promise.all(proms); } #CreateClientQueryPromise(id, for_msg_kind) { return new Promise((res, rej) => { const timer = setTimeout(() => { this.#client_queries.delete(id); rej(`${ServerMessageKind[for_msg_kind]} id:${id} timed-out.`); }, 20 * 1000); const resolve = (val) => { clearTimeout(timer); res(val); }; this.#client_queries.set(id, { resolve, for_msg_kind }); }); } async #Admin(function_name = '', args = []) { try { if (GetAllMethodNamesOf(this).includes(function_name)) return this[function_name].call(this, ...args); else return `[${function_name}] is not a name of a function on the SocioServer instance`; } catch (e) { return e; } } get methods() { return GetAllMethodNamesOf(this); } #ClearClientSessionSubs(client_id) { this.#sessions.get(client_id)?.ClearSubs(); for (const prop of this.#props.values()) { prop.updates.delete(client_id); } ; } async #CheckSessionsTimeouts() { const now = (new Date()).getTime(); for (const client of this.#sessions.values()) { if (now >= client.last_seen + client.session_opts.session_timeout_ttl_ms) { await client.Send(ClientMessageKind.TIMEOUT, {}); client.CloseConnection(); this.HandleInfo('Session timed out.', client.id); } } } async ReconnectClientSession(new_session, old_session, client_notify_msg_id) { old_session.Restore(); if (this.lifecycle_hooks.recon) { if (await this.lifecycle_hooks.recon(old_session, new_session)) return; } const new_id = new_session.id, old_id = old_session.id; new_session.CopySessionFrom(old_session); this.#ClearClientSessionSubs(old_id); this.#ClearClientSessionSubs(new_id); old_session.Destroy(() => { this.#ClearClientSessionSubs(old_id); this.#sessions.delete(old_id); }, this.session_defaults.session_delete_delay_ms); const data = { result: { success: 1 }, old_client_id: old_id, auth: new_session.authenticated, name: new_session.name }; if (client_notify_msg_id) data['id'] = client_notify_msg_id; new_session.Send(ClientMessageKind.RECON, data); } GetSessionsInfo() { return Object.fromEntries([...this.#sessions.values()].map(s => [s.id, { name: s.name, ip: s.ipAddr }])); } get prop_ids() { return this.#props.keys(); } get session_ids() { return this.#sessions.keys(); } get server_info() { return this.#wss.address(); } get raw_websocket_server() { return this.#wss; } }