socio
Version:
A WebSocket Real-Time Communication (RTC) API framework.
821 lines (820 loc) • 47.3 kB
JavaScript
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; }
}