Socio/core/core-server.ts
2024-02-06 13:26:56 +02:00

783 lines
No EOL
47 KiB
TypeScript

//There has been no great wisdom without an element of madness. /Aristotle/
//And thus i present - Socio.
//libs
import { WebSocketServer } from 'ws'; //https://github.com/websockets/ws https://github.com/websockets/ws/blob/master/doc/ws.md
import * as diff_lib from 'recursive-diff'; //https://www.npmjs.com/package/recursive-diff
//mine
import { QueryIsSelect, ParseQueryTables, SocioStringParse, ParseQueryVerb, sleep, GetAllMethodNamesOf, yaml_parse } from './utils.js';
import { E, LogHandler, err, log, info, done } from './logging.js';
import { UUID, type SocioSecurity } from './secure.js';
import { SocioSession, type SubObj } from './core-session.js';
import { RateLimiter } from './ratelimit.js';
import { CoreMessageKind } from './utils.js';
//types
import type { ServerOptions, WebSocket, AddressInfo } from 'ws';
import type { IncomingMessage } from 'http';
import type { id, PropKey, PropValue, PropAssigner, PropOpts, SocioFiles, ClientID, FS_Util_Response, ServerLifecycleHooks, LoggingOpts, Bit, SessionOpts } from './types.js';
import { ClientMessageKind } from './core-client.js';
import type { RateLimit } from './ratelimit.js';
export type MessageDataObj = { id?: id, sql?: string, endpoint?: string, params?: any, verb?: string, table?: string, unreg_id?: id, prop?: string, prop_val?: PropValue, prop_upd_as_diff?:boolean, data?: any, rate_limit?: RateLimit, files?: SocioFiles, sql_is_endpoint?:boolean };
export type QueryFuncParams = { id?: id, sql: string, params?: any };
export type QueryFunction = (client: SocioSession, id: id, sql: string, params?: any) => Promise<object>;
type SessionsDefaults = { timeouts: boolean, timeouts_check_interval_ms?: number, session_delete_delay_ms?: number, recon_ttl_ms?: number } & SessionOpts;
type DecryptOptions = { decrypt_sql: boolean, decrypt_prop: boolean, decrypt_endpoint: boolean };
type DBOpts = { Query: QueryFunction, Arbiter?: (initiator: { client: SocioSession, sql: string, params: any }, current: { client: SocioSession, hook: SubObj }) => boolean | Promise<boolean>};
type SocioServerOptions = { db: DBOpts, socio_security?: SocioSecurity | null, decrypt_opts?: DecryptOptions, hard_crash?: boolean, session_defaults?: SessionsDefaults, prop_upd_diff?: boolean, [key:string]:any } & LoggingOpts;
type AdminMessageDataObj = {function:string, args?:any[], secure_key:string};
type BasicClientResponse = { id: id | string, data?: any, result?: Bit | string | {success: Bit | string} | object, [key: string]: any };
//NB! some fields in these variables are private for safety reasons, but also bcs u shouldnt be altering them, only if through my defined ways. They are mostly expected to be constants.
//whereas public variables are free for you to alter freely at any time during runtime.
export class SocioServer extends LogHandler {
// private:
#wss: WebSocketServer;
#sessions: Map<ClientID, SocioSession> = new Map(); //Maps are quite more performant than objects. And their keys dont overlap with Object prototype.
//if constructor is given a SocioSecure object, then that will be used to decrypt all incomming messages, if the msg flag is set
#secure: { socio_security: SocioSecurity | null } & DecryptOptions;
//backend props, e.g. strings for colors, that clients can subscribe to and alter
#props: Map<PropKey, { val: PropValue, assigner: PropAssigner, updates: Map<ClientID, { id: id, rate_limiter?: RateLimiter }> } & PropOpts> = new Map();
//rate limits server functions globally
#ratelimits: { [key: string]: RateLimiter | null } = { con: null, upd:null};
#lifecycle_hooks: ServerLifecycleHooks = { con: undefined, discon: undefined, msg: undefined, sub: undefined, unsub: undefined, upd: undefined, auth: undefined, gen_client_id: undefined, grant_perm: undefined, serv: undefined, admin: undefined, blob: undefined, file_upload: undefined, file_download: undefined, endpoint: undefined, gen_prop_name:undefined }; //call the register function to hook on these. They will be called if they exist
//If the hook returns a truthy value, then it is assumed, that the hook handled the msg and the lib will not. Otherwise, by default, the lib handles the msg.
//msg hook receives all incomming msgs to the server.
//upd works the same as msg, but for everytime updates need to be propogated to all the sockets.
//auth func can return any truthy or falsy value, the client will only receive a boolean, so its safe to set it to some credential or id or smth, as this would be accessible and useful to you when checking the session access to tables.
//the grant_perm funtion is for validating that the user has access to whatever tables or resources the sql is working with. A client will ask for permission to a verb (SELECT, INSERT...) and table(s). If you grant access, then the server will persist it for the entire connection.
//the admin function will be called, when a socket attempts to use an ADMIN msg kind. It receives the SocioSession instance, that has id, ip and last seen fields you can use. Also the data it sent, so u can check your own secure key or smth. Return truthy to allow access
//stores active reconnection tokens
#tokens: Set<string> = new Set();
//global flag to send prop obj diffs using the diff lib instead of the full object every time.
#prop_upd_diff = false;
//public:
db!: DBOpts;
session_defaults: SessionsDefaults = { 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 };
prop_reg_timeout_ms!: number;
constructor(opts: ServerOptions | undefined = {}, { db, socio_security = null, 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 }: SocioServerOptions){
super({ ...logging, prefix:'SocioServer'});
//verbose - print stuff to the console using my lib. Doesnt affect the log handlers
//hard_crash will just crash the class instance and propogate (throw) the error encountered without logging it anywhere - up to you to handle.
//both are public and settable at runtime
//private:
this.#wss = new WebSocketServer({ ...opts, clientTracking: true }); //take a look at the WebSocketServer docs - the opts can have a server param, that can be your http server
this.#secure = { socio_security, ...decrypt_opts };
this.#prop_upd_diff = prop_upd_diff;
//public:
if (!db?.Query) return;
this.db = db;
this.session_defaults = Object.assign(this.session_defaults, session_defaults);
this.prop_reg_timeout_ms = prop_reg_timeout_ms;
this.#wss.on('connection', this.#Connect.bind(this)); //https://thenewstack.io/mastering-javascript-callbacks-bind-apply-call/ have to bind 'this' to the function, otherwise it will use the .on()'s 'this', so that this.[prop] are not undefined
this.#wss.on('close', (...stuff) => { this.HandleInfo('WebSocketServer close event', ...stuff) });
this.#wss.on('error', (...stuff) => { this.HandleError(new E('WebSocketServer error event', ...stuff))});
//set up interval timer to check if sessions are timed out.
if (this.session_defaults.timeouts)
setInterval(this.#CheckSessionsTimeouts.bind(this), this.session_defaults.timeouts_check_interval_ms);
const addr: AddressInfo = this.#wss.address() as AddressInfo;
if (this.verbose) 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!');
}
async #Connect(conn: WebSocket, request: IncomingMessage){
try{
//construct the new session with a unique client ID
let client_id: ClientID = (this.#lifecycle_hooks.gen_client_id ? await this.#lifecycle_hooks.gen_client_id() : UUID())?.toString();
while (this.#sessions.has(client_id)) //avoid id collisions
client_id = (this.#lifecycle_hooks.gen_client_id ? await this.#lifecycle_hooks.gen_client_id() : UUID())?.toString();
//get the IP. Gets either from a reverse proxy header (like if u have nginx) or just straight off the http meta
//@ts-ignore
const client_ip = 'x-forwarded-for' in request?.headers ? request.headers['x-forwarded-for'].split(',')[0].trim() : request.socket.remoteAddress;
//create the socio session class and save down the client id ref for convenience later
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);
//pass the object to the connection hook, if it exists. It cant take over
if (this.#lifecycle_hooks.con)
await this.#lifecycle_hooks.con(client, request); //u can get the client_id and client_ip off the client object
//notify the client of their ID
client.Send(ClientMessageKind.CON, client_id);
this.HandleInfo('CON', client_id); //, this.#wss.clients
//set this client websockets event handlers
conn.on('message', (req: Buffer | ArrayBuffer | Buffer[], isBinary: Boolean) => {
if (this.#sessions.has(client_id))//@ts-expect-error
this.#Message.bind(this)(this.#sessions.get(client_id), req, isBinary);
else conn?.close();
});
conn.on('close', (code:number, reason:Buffer) => { this.#SocketClosed.bind(this)(client, {code, reason:reason.toString('utf8')}) });
conn.on('error', (error: Error) => { this.#SocketClosed.bind(this)(client, error) }); //https://github.com/websockets/ws/blob/master/doc/ws.md#event-error-1
} catch (e: err) { this.HandleError(e); }
}
async #SocketClosed(client:SocioSession, event_args:any){
//trigger hook
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);
//Update() only works on session objects, and if we delete this one, then its query subscriptions should also be gone.
//delete the connection object and the subscriptions of this client
this.#sessions.delete(client_id);
this.HandleInfo('Session destroyed on disconnect.', client_id);
}, this.session_defaults.session_delete_delay_ms as number);
}
async #Message(client:SocioSession, req: Buffer | ArrayBuffer | Buffer[], isBinary: Boolean){
try{
//handle binary data and return
if(isBinary){
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 } } as BasicClientResponse);
else client.Send(ClientMessageKind.RES, { id: 'BLOB', result: { success: 0 } } as BasicClientResponse);
}
else client.Send(ClientMessageKind.ERR, { id: 'BLOB', result: 'Server does not handle the BLOB hook.' } as BasicClientResponse);
return;
}
const { kind, data }: { kind: CoreMessageKind; data: MessageDataObj } = yaml_parse(req.toString());
const client_id = client.id; //cache the ID, since its used so much here
//if the socio security instance exists and some specific string fields was recieved and they are to be decrypted, then do so here
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');
}
this.HandleInfo(`recv: [${CoreMessageKind[kind]}] from [${client_id}]`, kind != CoreMessageKind.UP_FILES ? data : `File count: ${data.files?.size}`);
//let the developer handle the msg
if (this.#lifecycle_hooks.msg)
if(await this.#lifecycle_hooks.msg(client, kind, data))
return;
switch (kind) {
case CoreMessageKind.SUB:{
if (this.#lifecycle_hooks.sub)
if (await this.#lifecycle_hooks.sub(client, kind, data))
return;
//if the client happens to want to use an endpoint keyname instead of SQL, retrieve the SQL string from a hook call and procede with that.
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. [#no-endpoint-hook-SUB]');
}
if (data.sql) {
if (QueryIsSelect(data.sql || '')) {
//set up hook
const tables = ParseQueryTables(data.sql || '');
if (tables)
client.RegisterSub(tables, data.id as id, data.sql || '', data?.params, data?.rate_limit);
//send response
client.Send(ClientMessageKind.UPD, {
id: data.id,
result: await this.db.Query(client, data.id || 0, data.sql || '', data?.params),
status: 'success'
} as BasicClientResponse);
} else client.Send(ClientMessageKind.ERR, {
id: data.id,
result: 'Only SELECT queries may be subscribed to! [#reg-not-select]',
status: 'error'
} as BasicClientResponse);
} else client.Send(ClientMessageKind.ERR, {
id: data.id,
result: 'Nothing to subscribed to! [#reg-no-res]',
status: 'error'
} as BasicClientResponse);
break;
}
case CoreMessageKind.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 || '') } } as BasicClientResponse);
break;
}
case CoreMessageKind.SQL:{
//if the client happens to want to use an endpoint keyname instead of SQL, retrieve the SQL string from a hook call and procede with that.
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]');
}
//have to do the query in every case
const res = this.db.Query(client, data.id || 0, data.sql || '', data.params);
client.Send(ClientMessageKind.RES, { id: data.id, result: await res } as BasicClientResponse); //wait for result and send it back
//if the sql wasnt a SELECT, but altered some resource, then need to propogate that to other connection hooks
if (!QueryIsSelect(data.sql || ''))
this.Update(client, data.sql || '', data?.params);
break;
}
case CoreMessageKind.PING:{
client.Send(ClientMessageKind.PONG, { id: data?.id } as BasicClientResponse);
break;
}
case CoreMessageKind.AUTH: {//client requests to authenticate itself with the server
if (client.authenticated) //check if already has auth
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) //bcs its a private class field, give this function the hook to call and params to it. It will set its field and give back the result. NOTE this is safer than adding a setter to a private field
client.Send(ClientMessageKind.AUTH, { id: data.id, result: res == true ? 1 : 0 }) //authenticated can be any truthy or falsy value, but the client will only receive a boolean, so its safe to set this to like an ID or token or smth for your own use
} else {
this.HandleError('AUTH function hook not registered, so client not authenticated. [#no-auth-func]')
client.Send(ClientMessageKind.AUTH, { id: data.id, result: 0 })
}
break;
}
case CoreMessageKind.GET_PERM:{
if (client.HasPermFor(data?.verb, data?.table))//check if already has the perm
client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: { success: 1} });
else if (this.#lifecycle_hooks.grant_perm) {//otherwise try to grant the perm
const granted: boolean = await this.#lifecycle_hooks.grant_perm(client, data);
client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: granted === true ? 1 : 0 }) //the client will only receive a boolean, but still make sure to only return bools as well
}
else {
this.HandleError('grant_perm function hook not registered, so client not granted perm. [#no-grant_perm-func]')
client.Send(ClientMessageKind.GET_PERM, { id: data.id, result: 0 })
}
break;
}
case CoreMessageKind.PROP_SUB:{
this.#CheckPropExists(data?.prop, client, data.id as 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;
//set up hook
this.#props.get(data.prop as PropKey)?.updates.set(client_id, { id: data.id as id, rate_limiter: data?.rate_limit ? new RateLimiter(data.rate_limit) : undefined })
//send response
if (data?.data?.receive_initial_update)
await client.Send(ClientMessageKind.PROP_UPD, {
id: data.id,
prop: data.prop,
prop_val: this.GetPropVal(data.prop as PropKey)
});
client.Send(ClientMessageKind.RES, {
id: data.id,
result: { success:1}
});
break;
}
case CoreMessageKind.PROP_UNSUB:{
this.#CheckPropExists(data?.prop, client, data?.id as 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;
//remove hook
const prop = this.#props.get(data.prop as PropKey);
try {
client.Send(ClientMessageKind.RES, {
id: data?.id,
result: { success: prop?.updates.delete(client_id) ? 1 : 0}
} as BasicClientResponse);
} catch (e: err) {
//send response
client.Send(ClientMessageKind.ERR, {
id: data?.id,
result: e?.msg
} as BasicClientResponse);
throw e; //report on the server as well
}
// check the prop is observationaly_temporary, meaning should be deleted when there no more subs on it
if(prop?.observationaly_temporary && prop.updates.size === 0){
this.UnRegisterProp(data.prop as PropKey);
this.HandleDebug('Temporary Prop UNregistered!', data.prop);
}
break;
}
case CoreMessageKind.PROP_GET:{
this.#CheckPropExists(data?.prop, client, data.id as id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-get]`);
client.Send(ClientMessageKind.RES, {
id: data.id,
result: this.GetPropVal(data.prop as string)
});
break;
}
case CoreMessageKind.PROP_SET:{
this.#CheckPropExists(data?.prop, client, data.id as id, `Prop key [${data?.prop}] does not exist on the backend! [#prop-reg-not-found-set]`);
try {
if (this.#props.get(data.prop as string)?.client_writable) {
//UpdatePropVal does not set the new val, rather it calls the assigner, which is responsible for setting the new value.
const result = this.UpdatePropVal(data.prop as string, data?.prop_val, client.id, data.hasOwnProperty('prop_upd_as_diff') ? data.prop_upd_as_diff : this.#prop_upd_diff); //the assigner inside Update dictates, if this was a successful set.
client.Send(ClientMessageKind.RES, { id: data.id, result: { success: result} }); //resolve this request to true, so the client knows everything went fine.
} else throw new E('Prop is not client_writable.', data);
} catch (e: err) {
//send response
client.Send(ClientMessageKind.ERR, {
id: data.id,
result: e?.msg
});
throw e; //report on the server as well
}
break;
}
case CoreMessageKind.PROP_REG: {
// checks
if (data?.prop && this.#props.has(data.prop)) {
client.Send(ClientMessageKind.ERR, {
id: data.id,
result: `Prop name "${data.prop}" already registered on server! Choose a different name.`
} as BasicClientResponse);
return;
}
// if a name hasnt been supplied, then generate a unique prop name and return it
if (!data?.prop){
data.prop = this.#lifecycle_hooks.gen_prop_name ? await this.#lifecycle_hooks.gen_prop_name() : UUID();
while (this.#props.has(data.prop)) data.prop = UUID();
}
// create the new prop on the server
// @ts-expect-error
this.RegisterProp(data.prop, data?.initial_value || null, {
// @ts-expect-error
...((data?.opts as PropOpts) || {}), observationaly_temporary: true //these as the last to overwrite the data?.opts value. client_writable: true,
});
// notify the client of success with the created prop name
client.Send(ClientMessageKind.RES, {
id: data.id,
result: 1,
prop: data.prop
} as BasicClientResponse);
// check after timeout, if there are no observers, then unreg this prop. In case a user spams regs and nobody subs them
if (this.prop_reg_timeout_ms > 0) //can set this.prop_reg_timeout_ms to 0 or negative to skip this logic
setTimeout(() => {
//it might have already been deleted
if (this.#props.has(data.prop as PropKey)){
// @ts-expect-error
if (this.#props.get(data.prop as PropKey).updates.size === 0){ //if no subs, then delete it
this.UnRegisterProp(data.prop as PropKey);
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 CoreMessageKind.SERV:{
if (this.#lifecycle_hooks.serv)
await this.#lifecycle_hooks.serv(client, data);
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 CoreMessageKind.ADMIN:{
if (this.#lifecycle_hooks.admin)
if (await this.#lifecycle_hooks.admin(client, data)) //you get the client, which has its ID, ipAddr and last_seen fields, that can be used to verify access. Also data should contain some secret key, but thats up to you
client.Send(ClientMessageKind.RES, { id: data?.id, result: await this.#Admin(((data as unknown) as AdminMessageDataObj)?.function, ((data as unknown) as AdminMessageDataObj)?.args) });
else throw new E('A non Admin send an Admin message, but was not executed.', kind, data, client_id);
break;
}
case CoreMessageKind.RECON: {//client attempts to reconnect to its previous session
if (!this.#secure) {
client.Send(ClientMessageKind.ERR, { id: data.id, result: 'Cannot reconnect on this server configuration!', success: 0 });
throw new E(`RECON requires SocioServer to be set up with the Secure class! [#recon-needs-secure]`, { kind, data });
}
if (data?.data?.type == 'GET') {
//@ts-expect-error
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(' ')); //creates string in the format "[iv_base64] [encrypted_text_base64] [auth_tag_base64]" where encrypted_text_base64 is a token of format "[rand] [ip] [client_id] [ms_since_epoch] [rand]"
this.#tokens.add(token);
client.Send(ClientMessageKind.RES, { id: data.id, result: token, success: 1 }); //send the token to the client for one-time use to reconnect to their established client session
}
else if (data?.data?.type == 'POST') {
//check for valid token to begin with
if (!data?.data?.token || !this.#tokens.has(data.data.token)) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Invalid token', success: 0 });
return;
}
this.#tokens.delete(data.data.token); //single use token, so delete
let [iv, token, auth_tag] = data.data.token.split(' '); //split the format into encryption parts
try {
if (iv && token && auth_tag)
token = this.#secure.socio_security?.DecryptString(iv, token, auth_tag); //decrypt the payload
else
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Invalid token', success: 0 });
} catch (e: err) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Invalid token', success: 0 });
return;
}
const [r1, ip, old_c_id, time_stamp, r2] = token.split(' '); //decrypted payload parts
//safety check race conditions
if (!(r1 && ip && old_c_id && time_stamp && r2)) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Invalid token format', success: 0 });
return;
}
if (client.ipAddr !== ip) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'IP address changed between reconnect', success: 0 });
return;
}
else if ((new Date()).getTime() - parseInt(time_stamp) > (this.session_defaults.recon_ttl_ms as number)) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Token has expired', success: 0 });
return;
}
else if (!(this.#sessions.has(old_c_id))) {
client.Send(ClientMessageKind.RECON, { id: data.id, result: 'Old session ID was not found', success: 0 });
return;
}
//recon procedure
const old_client = this.#sessions.get(old_c_id) as SocioSession;
old_client.Restore();//stop the old session deletion, since a reconnect was actually attempted
client.CopySessionFrom(old_client);
//clear the subscriptions on the sockets, since the new instance will define new ones on the new page. Also to avoid ID conflicts
this.#ClearClientSessionSubs(old_c_id);
this.#ClearClientSessionSubs(client.id);
//delete old session for good
old_client.Destroy(() => {
this.#ClearClientSessionSubs(old_c_id);
this.#sessions.delete(old_c_id);
}, this.session_defaults.session_delete_delay_ms as number);
//notify the client
client.Send(ClientMessageKind.RECON, { id: data.id, result: { old_client_id: old_c_id, auth: client.authenticated }, success: 1 });
this.HandleInfo(`RECON ${old_c_id} -> ${client.id} (old client ID -> new/current client ID)`);
}
break;
}
case CoreMessageKind.UP_FILES:{
if (this.#lifecycle_hooks?.file_upload)
client.Send(ClientMessageKind.RES, { id: data.id, result: {success: await this.#lifecycle_hooks.file_upload(client, data?.files, data?.data) ? 1 : 0} });
else {
this.HandleError('file_upload hook not registered. [#no-file_upload-hook]');
client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0} });
}
break;
}
case CoreMessageKind.GET_FILES:{
if (this.#lifecycle_hooks?.file_download) {
const response = await this.#lifecycle_hooks.file_download(client, data?.data) as FS_Util_Response;
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: response.result });
}
else {
this.HandleError('file_download hook not registered. [#no-file_download-hook]');
client.Send(ClientMessageKind.RES, { id: data.id, result: { success: 0} });
}
break;
}
// case CoreMessageKind: { break;}
default: throw new E(`Unrecognized message kind! [#unknown-msg-kind]`, {kind, data});
}
} catch (e: err) { this.HandleError(e); }
}
//this assumes that this.#secure.socio_security is properly assigned
#Decrypt(client:SocioSession, str:string, is_sql:boolean):string{
let markers: string[] | undefined;
//check crypt format "[iv_base64] [encrypted_text_base64] [auth_tag_base64]" where each part is base64 encoded
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 });
//decrypt
str = (this.#secure.socio_security as SocioSecurity).DecryptString(parts[0], parts[1], parts[2]);
str = (this.#secure.socio_security as SocioSecurity).RemoveRandInts(str);
//get markers from string, if they exist. Can be done for SQL, props, endpoints
; ({ str, markers } = SocioStringParse(str)); //forgot what this neat js trick is called.
//perform marker checks
if (markers?.includes('auth'))//requiers auth to execute
if (!client.authenticated)
throw new E(`Client tried to execute an auth query without being authenticated. [#auth-issue]`, { client });
if (is_sql && markers?.includes('perm')) { //SQL requiers perms on tables to execute
const verb = ParseQueryVerb(str);
if (!verb)
throw new E(`Client sent an unrecognized SQL query verb. [#verb-issue]`, { client, str });
const tables = ParseQueryTables(str);
if (!tables)
throw new E(`Client sent an SQL query without table names. [#table-names-not-found]`, { client, 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, verb, tables });
}
return str;
}
async Update(initiator:SocioSession, sql:string, params:object){
//rate limit check
if(this.#ratelimits.upd)
if(this.#ratelimits.upd.CheckLimit())
return;
//hand off to hook
if (this.#lifecycle_hooks.upd)
if (await this.#lifecycle_hooks.upd(this.#sessions, initiator, sql, params))
return;
//or go through each session's every hook and query the DB for its result, then send it to the client
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: Map<number, object> = new Map(); //cache the queries to not spam the DB in this loop
for (const client of this.#sessions.values()){
for (const hook of client.GetSubsForTables(tables)){ //GetSubsForTables always returns array. If empty, then the foreach wont run, so each sql guaranteed to have hooks array
//rate limit check
if (hook.rate_limiter && hook.rate_limiter.CheckLimit()) return;
//Arbiter decides if this query needs be updated. Can do WHERE clause checking yourself here as an optimization for large projects.
if (this.db?.Arbiter)
if (await this.db.Arbiter({ client: initiator, sql, params }, { client, hook }) === false) //if Arbiter returns false, we skip this hook
continue;
if (cache.has(hook.cache_hash))
client.Send(ClientMessageKind.UPD, {
id: hook.id,
result: cache.get(hook.cache_hash),
status: 'success'
});
else
this.db.Query(client, hook.id, hook.sql, hook.params)
.then(res => {
client.Send(ClientMessageKind.UPD, {
id: hook.id,
result: res,
status: 'success'
});
cache.set(hook.cache_hash, res);
})
.catch(err => client.Send(ClientMessageKind.UPD, {
id: hook.id,
result: err,
status: 'error'
}));
};
}
} catch (e:err) { this.HandleError(e) }
}
#CheckPropExists(prop: PropKey | undefined, client: SocioSession, msg_id:id, error_msg: string){
if (!prop || !(this.#props.has(prop))){
client.Send(ClientMessageKind.ERR, {
id: msg_id,
result: error_msg
} as BasicClientResponse);
throw new E(error_msg, prop, client.id);
}
}
RegisterLifecycleHookHandler(f_name:string, handler:Function|null=null){
try{
if (f_name in this.#lifecycle_hooks)
this.#lifecycle_hooks[f_name] = handler;
else throw new E(`Lifecycle hook [${f_name}] does not exist! Settable: ${this.LifecycleHookNames}`);
} catch (e:err) { this.HandleError(e) }
}
UnRegisterLifecycleHookHandler(name = '') {
try{
if (name in this.#lifecycle_hooks)
this.#lifecycle_hooks[name] = null;
else throw new E(`Lifecycle hook [${name}] does not exist!`)
} catch (e:err) { this.HandleError(e) }
}
get LifecycleHookNames(){return Object.keys(this.#lifecycle_hooks)}
RegisterRateLimit(f_name: string, ratelimit: RateLimit | null = 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: err) { this.HandleError(e) }
}
UnRegisterRateLimit(f_name: string) {
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: err) { this.HandleError(e) }
}
get RateLimitNames() { return Object.keys(this.#ratelimits) }
GetClientSession(client_id=''){
return this.#sessions.get(client_id);
}
//assigner defaults to basic setter
RegisterProp(key: PropKey, val: PropValue, { assigner = this.SetPropVal.bind(this), client_writable = true, send_as_diff = undefined, emit_to_sender = false, observationaly_temporary=false }: { assigner?: PropAssigner } & PropOpts = {}){
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);
} catch (e: err) { this.HandleError(e) }
}
UnRegisterProp(key: PropKey){
try {
const prop = this.#props.get(key);
if (!prop) throw new E(`Prop key [${key}] not registered! [#UnRegisterProp-prop-not-found]`);
//drop the prop first, so that it cant be subbed to while informing clients - a rare but potential issue
if (!this.#props.delete(key))
throw new E(`Error deleting prop key [${key}]. [#prop-key-del-error]`);
//inform all subbed clients that this prop has been dropped
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); //the client_id doesnt exist anymore for some reason, so unsubscribe
}
} catch (e: err) { this.HandleError(e) }
}
GetPropVal(key: PropKey){
return this.#props.get(key)?.val;
}
//UpdatePropVal does not set the new val, rather it calls the assigner, which is responsible for setting the new value.
UpdatePropVal(key: PropKey, new_val: PropValue, sender_client_id: ClientID | null, send_as_diff = this.#prop_upd_diff):Bit{//this will propogate the change, if it is assigned, to all subscriptions
const prop = this.#props.get(key);
if (!prop) throw new E(`Prop key [${key}] not registered! [#prop-update-not-found]`);
const old_prop_val = prop.val; //bcs the assigner somehow changes this property. Weird.
//Dont think JS allows such ref pointers to work. But this then keeps the correct val.
//This idea works bcs the mutator of the data should be the first to run this and all other session will get informed here with that sessions diff.
if (prop.assigner(key, new_val, sender_client_id ? this.#sessions.get(sender_client_id) : undefined)) {//if the prop was passed and the value was set successfully, then update all the subscriptions
const new_assigned_prop_val = this.GetPropVal(key); //should be GetPropVal, bcs i cant know how the assigner changed the val. But since it runs once per update, then i can cache this call here right after the assigner.
const prop_val_diff = diff_lib.getDiff(old_prop_val, new_assigned_prop_val);
if (prop_val_diff.length === 0) return 1; //dont do anything further, if the prop val didnt actually change. This is efficient and removes long feedback loops for global props across many users
for (const [client_id, args] of prop.updates.entries()) {
if (args?.rate_limiter && args.rate_limiter?.CheckLimit()) continue; //ratelimit check for this client
if (sender_client_id === client_id && prop.emit_to_sender === false) continue; //prop can be set to not emit an update back to the initiator of this prop set.
//do the thing
if (this.#sessions.has(client_id)){
//prepare object of both cases
const upd_data = { id: args.id, prop:key };
//overload the global Socio Server flag with a per prop flag
if (prop?.send_as_diff && typeof prop?.send_as_diff == 'boolean') send_as_diff = prop.send_as_diff;
//construct either concrete value or diff of it.
if (send_as_diff)
upd_data['prop_val_diff'] = prop_val_diff; //this was already computed for other reasons
else
upd_data['prop_val'] = new_assigned_prop_val;
//send to client
this.#sessions.get(client_id)?.Send(ClientMessageKind.PROP_UPD, upd_data);
}
else {//the client_id doesnt exist anymore for some reason, so unsubscribe
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: PropKey, new_val: PropValue): boolean { //this hard sets the value without checks or updating clients
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;
return true;
} catch (e: err) { this.HandleError(e); return false; }
}
//send some data to all clients by their ID. By default emits to all connected clients
SendToClients(client_ids: string[] = [], data: object = {}, kind: ClientMessageKind = ClientMessageKind.CMD): Promise<void>{
return new Promise((res, rej) => {
try{
const sessions = client_ids.length ? client_ids.map(c_id => this.#sessions.get(c_id)) : this.#sessions.values();
for (const s of sessions)
if (s)
s.Send(kind, data); //these are all sync calls
res();
}
catch (e) {rej(e);}
});
}
//https://stackoverflow.com/a/54875979/8422448
async #Admin(function_name:string = '', args:any[] = []){
try{
if (GetAllMethodNamesOf(this).includes(function_name))
return this[function_name].call(this, ...args); //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call
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:string){
this.#sessions.get(client_id)?.ClearSubs(); //clear query subs
for (const prop of this.#props.values()) { prop.updates.delete(client_id); }; //clear prop subs
}
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);
}
}
}
get session_ids(){return this.#sessions.keys();}
get server_info() { return this.#wss.address(); }
get raw_websocket_server() { return this.#wss; }
}