/* Galvanic Corrosion - Rec Room custom server for communities. Copyright (C) 2025 @zombieb (Discord / proxnet Gitea) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ import { Profile } from "../data/profiles.ts"; import Logging from "@proxnet/undead-logging"; import { CompletionMessage, Message, MessageKind, PushNotificationId, SignalMessageType, SignalRMessage, SignalRMessageSchema, TargetResult, TargetResultFailure, TargetResultNotATarget, TargetResultSuccess, TargetResultType } from "./types.ts"; import { SocketTarget } from "./targets/targetbase.ts"; import { PlayerSocketSubscriptionTarget } from "./targets/SubscribeToPlayers.ts"; import Presence from "../data/live/presence.ts"; export class SignalRSocketHandler { #log: Logging = new Logging("SignalMock-"); #socket: WebSocket; #profile: Profile; #Targets: Map = new Map(); #PresenceUpdateId: number; constructor(socket: WebSocket, player: Profile) { this.#socket = socket; this.#profile = player; this.#init(); player.setSocketHandler(this); this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget()); this.#PresenceUpdateId = setInterval(async () => { const pres = await Presence.get(this.#profile); this.sendNotification("PresenceUpdate", await pres.export()); }, 8000); } async #dispatchTarget(target: string, args: unknown): Promise { const targetExec = this.#Targets.get(target); if (!targetExec) return { type: TargetResultType.NotATarget } as TargetResultNotATarget; else { try { return { type: TargetResultType.Success, data: await targetExec.exec(args) } as TargetResultSuccess; } catch (err) { this.#log.w(`Target '${target}' function error: ${err}`); if (err instanceof Error) return { type: TargetResultType.Failure, err: err } as TargetResultFailure; else return { type: TargetResultType.Failure, err: `${err}` } as TargetResultFailure; } } } async #onMessage(message: Message) { if (message.kind == MessageKind.Protocol) { this.sendRaw({}); return; } else { this.#log.d(`CLIENT MESSAGE\n Type: ${message.data.type} (${SignalMessageType[message.data.type]})\n ${JSON.stringify(message.data)}`); if (message.data.type == SignalMessageType.Invocation && message.data.invocationId) { // don't send completion messages for nonblocking invocations const res = await this.#dispatchTarget(message.data.target, message.data.arguments[0]); // rec room only uses the first index if (res.type == TargetResultType.Success) { const signalRes: CompletionMessage = { type: SignalMessageType.Completion, invocationId: message.data.invocationId, result: JSON.stringify(res.data) } this.sendRaw(signalRes); } else if (res.type == TargetResultType.Failure) { const signalRes: CompletionMessage = { type: SignalMessageType.Completion, invocationId: message.data.invocationId, error: res.err instanceof Error ? res.err.message : res.err } this.sendRaw(signalRes); } else { const signalRes: CompletionMessage = { type: SignalMessageType.Completion, invocationId: message.data.invocationId, error: "Target not found" } this.sendRaw(signalRes); } } } } #init() { this.#log.source += this.#profile.getId().toString(); this.#log.i(`Created hub socket`); this.#socket.addEventListener('message', message => { try { const dec = new TextDecoder(); const str = dec.decode(message.data); const data = JSON.parse(str.substring(0, str.length - 1)); const parseResult = SignalRMessageSchema.safeParse(data); if (parseResult.success) this.#onMessage({ kind: MessageKind.Data, data: parseResult.data as SignalRMessage }); else { this.#onMessage({ kind: MessageKind.Protocol }); } } catch (err) { this.#log.e(`Socket error: ${err}`); } }); this.#socket.addEventListener('close', this.destroy(this)); } destroy(sock: SignalRSocketHandler) { return () => { clearInterval(sock.#PresenceUpdateId); sock.sendRaw({ type: 7, error: "Socket closed" }); sock.#socket.close(); sock.#log.i(`Closed hub socket`); } } sendRaw(data: object) { this.#socket.send(`${JSON.stringify(data)}\u001e`); // todo sometime: make this less confusing const type = `Type: ${JSON.stringify(data) == '{}' ? 'Protocol Message' : `${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`}`; this.#log.d(`SERVER MESSAGE\n ${type}\n ${JSON.stringify(data as SignalRMessage)}`); } sendNotification(id: PushNotificationId | string, args: object) { const msg: SignalRMessage = { type: SignalMessageType.Invocation, target: "Notification", arguments: [JSON.stringify({ Id: id, Msg: args })] } this.sendRaw(msg); } }