Initial commit

This commit is contained in:
2025-07-25 19:00:06 -04:00
commit e604c7a437
52 changed files with 96098 additions and 0 deletions

26
src/server/ContentBase.ts Normal file
View File

@@ -0,0 +1,26 @@
import { type ServerBase } from "./server.ts";
import KV from "./persistence/kv.ts";
export class ServerContentBase {
protected server: ServerBase;
protected kv: KV;
constructor(server: ServerBase, id: string, kv?: boolean) {
this.server = server;
this.kv = new KV(id, kv);
}
async kvInit() {
await this.kv.init();
}
/**
* Event method - ran when server starts (listens on address)
*
* Override me!
*/
start() {
return;
}
}

View File

@@ -0,0 +1,5 @@
import { ServerContentBase } from "../ContentBase.ts";
export class AvatarContentBase extends ServerContentBase {
}

View File

@@ -0,0 +1,19 @@
enum _OutfitType {
None = -1,
Hat,
Hair = 2,
Ear,
Eye = 10,
Beard = 20,
Shoulder = 100,
Shirt,
Waist,
Neck,
TeamJersey,
Wrist = 200,
TeamWrist = 203
}
// figure out the order in which ids go into AvatarItemDesc
// then create a function in `base.ts` (next to this file) that can turn enums corresponding to avatar items into a full AvatarItemDesc string
// - that may require codegen. i can probably do it though. i know you can.

23
src/server/baseevent.ts Normal file
View File

@@ -0,0 +1,23 @@
type Callback<T> = (event: T) => void;
export class EventManager<Events extends { [K in keyof Events]: unknown }> {
#listeners: {
[K in keyof Events]?: Set<Callback<Events[K]>>
} = {};
on<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
if (!this.#listeners[eventName])
this.#listeners[eventName] = new Set();
this.#listeners[eventName]!.add(callback);
}
off<K extends keyof Events>(eventName: K, callback: Callback<Events[K]>): void {
this.#listeners[eventName]?.delete(callback);
if (this.#listeners[eventName]?.size === 0)
delete this.#listeners[eventName];
}
emit<K extends keyof Events>(eventName: K, payload: Events[K]): void {
this.#listeners[eventName]?.forEach((callback) => callback(payload));
}
}

View File

@@ -0,0 +1,2 @@
// deno-lint-ignore no-explicit-any
export type CommandExec = (...args: any[]) => unknown | Promise<unknown>;

View File

@@ -0,0 +1,58 @@
import z from "zod";
import { CommandExec } from "./cmdtypes.ts";
export interface CommandOptions {
key: string[],
subcommands?: Command[],
exec?: CommandExec,
zod?: z.ZodTuple,
help?: string
}
export default class Command {
subCmds: Command[];
key: string[];
exec: CommandExec | null;
validate: z.ZodTuple | null;
help: string | null;
#argsLength: number;
constructor(options: CommandOptions) {
this.subCmds = options.subcommands || [];
this.key = options.key;
this.exec = options.exec || null;
this.validate = options.zod || null;
this.help = options.help || null;
this.#argsLength = options.zod ? options.zod.def.items.length : 0;
}
getKey() {
return this.key;
}
dispatch(...args: unknown[]): unknown | Promise<unknown> {
const root = args[0] as string | undefined;
if (!root && this.exec)
return this.exec(...args);
else if (!root) return new Error('No execution target for this root');
const cmd = this.subCmds.find(cmd => cmd.getKey().includes(root));
if (cmd) {
const newArgs = args.slice(1);
if (cmd.#argsLength && newArgs.length !== cmd.#argsLength && cmd.help) return new Error(cmd.help);
if (cmd.validate) {
const res = cmd.validate.safeParse(newArgs);
if (res.success) return cmd.dispatch(...res.data);
else if (cmd.help) return new Error(cmd.help);
else if (cmd.#argsLength) return new Error(`'${root}' validation error: expected ${cmd.#argsLength} args, got ${newArgs.length}`);
else return new Error(`'${root}' validation error`);
}
}
if (this.exec)
return this.exec(...args);
else return new Error(`'${root}': Subcommand not found (args: "${args.join(" ")}")`);
}
}

View File

@@ -0,0 +1,39 @@
import { ServerContentBase } from "../ContentBase.ts";
import type Command from "./command.ts";
export class CommandsBase extends ServerContentBase {
#cmds: Set<Command> = new Set();
addRootCommand(cmd: Command) {
this.#cmds.add(cmd);
}
removeRootCommandByKey(key: string) {
for (const cmd of this.#cmds) if (cmd.getKey().includes(key))
this.#cmds.delete(cmd);
}
removeRootCommand(cmd: Command) {
this.#cmds.delete(cmd);
}
async dispatch(...args: string[]): Promise<unknown> {
const root = args[0];
if (typeof root !== 'string') return new Error("Root command must be of primitive type 'string'");
else {
const cmd = this.#cmds.values().toArray().find(cmd => cmd.getKey().includes(root));
if (cmd) {
const val = await cmd.dispatch(...args.slice(1));
if (val == null) return "null";
else if (typeof val == 'string') return `"${val}"`;
else if (typeof val == 'undefined') return "undefined";
else if (val instanceof Error) return val;
else if (typeof val == 'object') return JSON.stringify(val);
else return String(val);
}
else return new Error(`'${root.trim()}': Command not found (args: "${args.join(' ')}")`);
}
}
}

View File

@@ -0,0 +1,81 @@
import z from "zod";
import Command from "../commands/command.ts";
import { ServerContentBase } from "../ContentBase.ts";
export default class GameConfigsBase extends ServerContentBase {
#kvKey = 'gameconfigs';
#usageError = new Error("Usage: <key: string> [<value: string>]");
async getGameConfig(key: string) {
return (await this.kv.getKv().get<string>([this.#kvKey, key])).value;
}
async setGameConfig(key: string, value: string) {
await this.kv.getKv().set([this.#kvKey, key], value);
}
async delGameConfig(key: string) {
await this.kv.getKv().delete([this.#kvKey, key]);
}
async getAllGameConfigs() {
return (await Array.fromAsync(
this.kv.getKv().list<string>({ prefix: [this.#kvKey] })
)).map(val =>
({ Key: val.key[1] as string, Value: val.value })
).filter(gc => gc.Key && gc.Value); // ensure both the key and value exist
}
override start(): void {
this.server.Commands.addRootCommand(new Command({
key: ['gameconfigs', 'gameconfig', 'gc'],
subcommands: [
new Command({
key: ['get', 'g'],
exec: async (key: string) => {
if (!key) return this.#usageError;
return await this.getGameConfig(key);
},
zod: z.tuple([
z.string()
]),
help: 'Get a saved GameConfig: <key: string>'
}),
new Command({
key: ['set', 's'],
exec: async (key: string, value: string) => {
if (!key || !value) return this.#usageError;
return await this.setGameConfig(key, value);
},
zod: z.tuple([
z.string(),
z.string()
]),
help: 'Set a new GameConfig: <key: string>, <value: string>'
}),
new Command({
key: ['del', 'rem', 'd', 'remove'],
exec: async (key: string) => {
if (!key) return this.#usageError;
return await this.delGameConfig(key);
},
zod: z.tuple([
z.string()
]),
help: 'Remove a saved GameConfig: <key: string>'
}),
new Command({
key: ['list', 'l', 'getall'],
exec: async () => {
const configs = await this.getAllGameConfigs();
return configs;
},
help: 'List all saved GameConfigs'
}),
]
}));
}
}

View File

@@ -0,0 +1,24 @@
class KV {
#kv: Deno.Kv | null = null;
#initKv: boolean
#id: string;
constructor(id: string, kv?: boolean) {
this.#id = id;
this.#initKv = kv ? kv : false;
}
async init() {
if (this.#initKv) this.#kv = await Deno.openKv(`./persist/${this.#id}`);
}
getKv() {
if (this.#kv) return this.#kv;
else throw new Error("KV not yet open");
}
}
export default KV;

View File

@@ -0,0 +1,182 @@
import z from "zod";
import Command from "../commands/command.ts";
import { ServerContentBase } from "../ContentBase.ts";
import { transformStringToEnum } from "../../util/validators.ts";
import { sign } from "@hono/hono/jwt";
export enum PlatformType {
All = -1,
Steam,
Oculus,
PlayStation,
Xbox,
WindowsPlatformless,
IOS,
GooglePlay
}
export enum DeviceClass {
Unknown,
VR,
Screen,
Mobile,
VRLow,
Quest2
}
interface DbCachedLogin {
accountId: number,
lastLoginTime: Date,
requirePassword: boolean
}
export interface CachedLogin extends DbCachedLogin {
platformId: string
platform: PlatformType
}
export const steamAuthTicketSchema = z.object({
Ticket: z.string().min(256),
AppId: z.literal("471710")
});
export enum ProfileRole {
Developer = "developer",
Moderator = "moderator",
Screenshare = "screenshare",
User = "user"
}
interface TokenFormat {
iss: string,
exp: number,
sub: number,
role: string
}
export class PlatformsManager extends ServerContentBase {
static platformsKey = "platforms";
#constructPlatformKey(...keys: (string | number | undefined)[]) {
return [PlatformsManager.platformsKey, ...keys.filter(val => typeof val == 'string')];
}
async getToken(accountId: number, role: string) {
const secret = Deno.env.get('SECRET');
if (!secret) throw new Error("No SECRET in env. Did you forget to set it?");
const token: TokenFormat = {
sub: accountId,
role,
iss: "https://wsi.proxnet.dev/auth/",
exp: Math.round(Date.now() / 1000) + 21_600
}
return await sign(JSON.parse(JSON.stringify(token)), secret);
}
async updateLastLoginTime(platform: PlatformType, platformId: string, accountId: number) {
const key = this.#constructPlatformKey(platform, platformId);
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
if (set.value) {
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
if (!existing) return;
existing.lastLoginTime = new Date();
await this.kv.getKv().set(key, set.value);
}
}
async addCachedLogin(platform: PlatformType, platformId: string, accountId: number) {
const key = this.#constructPlatformKey(platform, platformId);
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
if (set.value) {
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
if (!existing) {
set.value.add({ accountId, lastLoginTime: new Date(), requirePassword: false });
await this.kv.getKv().set(key, set.value);
}
return set.value.values().toArray();
} else {
const newSet = new Set<DbCachedLogin>([{ accountId, lastLoginTime: new Date(), requirePassword: false }]);
await this.kv.getKv().set(key, newSet);
return newSet.values().toArray();
};
}
async getCachedLogins(platform: PlatformType, platformId: string, format: true): Promise<CachedLogin[] | null>
async getCachedLogins(platform: PlatformType, platformId: string, format: false): Promise<DbCachedLogin[] | null>
async getCachedLogins(platform: PlatformType, platformId: string, format?: boolean) {
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(this.#constructPlatformKey(platform, platformId));
if (set.value && format) return set.value.values().toArray().map(val => ({
platform,
platformId,
accountId: val.accountId,
lastLoginTime: val.lastLoginTime,
requirePassword: val.requirePassword
} as CachedLogin));
else if (set.value) return set.value.values().toArray();
else return null;
}
async deleteCachedLogin(platform: PlatformType, platformId: string, accountId: number) {
const key = this.#constructPlatformKey(platform, platformId);
const set = await this.kv.getKv().get<Set<DbCachedLogin>>(key);
if (set.value) {
const existing = set.value.values().toArray().find(val => val.accountId == accountId);
if (existing) {
set.value.delete(existing);
await this.kv.getKv().set(key, set.value);
}
return set.value.values().toArray();
} else return null;
}
override start() {
this.server.Commands.addRootCommand(new Command({
key: ['platforms', 'pm', 'platformmanager', 'platformanager'],
subcommands: [
new Command({
key: ['get', 'g', 'list', 'l'],
exec: async (type: PlatformType, platformId: string) => {
return await this.getCachedLogins(type, platformId, false);
},
zod: z.tuple([
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
z.string().min(4)
]),
help: 'List all cachedlogins for platformId: <type: PlatformType, platformId: string>'
}),
new Command({
key: ['set', 's', 'add', 'a'],
exec: async (type: PlatformType, platformId: string, accountId: number) => {
return await this.addCachedLogin(type, platformId, accountId);
},
zod: z.tuple([
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
z.string(),
z.string().transform(Number)
]),
help: 'Add a cachedlogin for platformId: <type: PlatformType>, <platformId: string>, <accountId: number>'
}),
new Command({
key: ['del', 'd', 'rem', 'remove', 'r'],
exec: async (type: PlatformType, platformId: string, accountId: number) => {
return await this.deleteCachedLogin(type, platformId, accountId);
},
zod: z.tuple([
z.string().transform(transformStringToEnum<PlatformType>(PlatformType)),
z.string(),
z.string().transform(Number)
]),
help: 'Remove a cachedlogin for platformId: <type: PlatformType>, <platformId: string>, <accountId: number>'
})
]
}));
}
}

View File

@@ -0,0 +1,5 @@
class ProfileContentManager {
}
export default ProfileContentManager;

View File

@@ -0,0 +1,24 @@
const NameDictionary = {
Adjectives: [
"Alpha", "Zen", "Ruby", "Pixel", "Captain",
"Luna", "Quantum", "Emerald", "Serene", "Sushi",
"Mountain", "Phoenix", "Electric", "Songbird", "Tech",
"Silver", "Midnight", "Tango", "Cosmic", "Jazz",
"Velvet", "Neon", "Ghostly", "Ballet", "Delta",
"Echo", "Solar", "Pirate", "Harmonic",
"Cyber", "Melody", "Quasar", "Crimson", "Enigma",
"Stardust", "Techno", "Lunar", "Rogue", "Dream"
],
Nouns: [
"Wolf", "Master", "Red", "Pirate", "Adventure",
"Lovegood", "Coder", "Enigma", "Seeker", "Samurai",
"Mover", "Fire", "Echo", "Soul", "Titan",
"Shadow", "Mystic", "Tornado", "Crafter", "Journey",
"Vortex", "Nebula", "Gazer", "Blossom", "Dynamo",
"Eagle", "Symphony", "Willow", "Pioneer", "Hawk",
"Scribe", "Mistress", "Quest", "Comet", "Explorer",
"Strider", "Trance", "Lullaby", "Dancer"
],
};
export default NameDictionary;

View File

@@ -0,0 +1,5 @@
import type Profile from "../profile.ts";
export interface ProfileUpdateEvent {
profile: Profile
}

View File

@@ -0,0 +1,7 @@
import { type ProfileRole } from "../../platforms/base.ts";
import type Profile from "../profile.ts";
export interface RoleUpdateEvent {
profile: Profile,
newRole: ProfileRole
}

View File

@@ -0,0 +1,135 @@
import { ServerContentBase } from "../ContentBase.ts";
import NameDictionary from "./dict.ts";
import Profile from "./profile.ts";
import { SelfAccount, type RecNetAccount } from "./types/profile.ts";
import Command from "./../commands/command.ts";
import z from "zod";
import { ProfileRole } from "../platforms/base.ts";
const profiles: Map<number, Profile> = new Map();
class ProfileManagerBase extends ServerContentBase {
static profilesKey = "profiles";
static profileByNameKey = "profileName";
/*async exists(id: number) {
return (await this.kv.getKv().get([ ProfileManagerBase.profilesKey, id ])).value !== null
}*/
async #getUnusedId() {
let id = Math.round(Math.random() * 2_147_483_647);
if (await this.get(id)) id = await this.#getUnusedId();
return id;
}
async #getUnusedUsername() {
const adjective = NameDictionary.Adjectives[Math.floor(Math.random() * NameDictionary.Adjectives.length)];
const noun = NameDictionary.Nouns[Math.floor(Math.random() * NameDictionary.Nouns.length)];
const discriminator = Math.round(Math.random() * 1000);
let username = `${adjective}${noun}#${discriminator}`;
if (await this.getByUsername(username)) username = await this.#getUnusedUsername();
return username;
}
async create(username?: string) {
const id = await this.#getUnusedId();
const newUsername = username? username : await this.#getUnusedUsername();
const newProfile: RecNetAccount = {
accountId: id,
username: newUsername,
displayName: newUsername,
profileImage: "DefaultProfileImage.png",
createdAt: new Date()
}
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, id ], newProfile);
await this.kv.getKv().set([ ProfileManagerBase.profilesKey, newUsername ], id);
return this.get(id);
}
async get(id: number) {
const profile = profiles.get(id);
if (!profile) {
const info = await this.kv.getKv().get<SelfAccount>([ ProfileManagerBase.profilesKey, id ]);
if (!info.value) return null;
const prof = new Profile(info.value, this.kv, this.server);
profiles.set(id, prof);
return prof;
}
return profile;
}
async getMany(...ids: number[]) {
return (await Promise.all(ids.map(id => this.get(id)))).filter(prof => prof !== null);
}
async getAll() {
const keys = this.kv.getKv().list({ prefix: [ ProfileManagerBase.profilesKey ] });
const awaitedKeys = await Array.fromAsync(keys);
return awaitedKeys.map(entry => entry.key).map(val => val[1]).filter(val => typeof val == 'number');
}
async getByUsername(username: string) {
const id = (await this.kv.getKv().get<number>([ ProfileManagerBase.profilesKey, username ])).value;
if (typeof id == 'number') return this.get(id);
else return id;
}
override start() {
this.server.Commands.addRootCommand(new Command({
key: ['account', 'profile', 'acc', 'prof'],
subcommands: [
new Command({
key: ['get', 'g', 'fetch', 'f'],
exec: async (id: number) => {
const prof = await this.get(id);
if (!prof) return prof;
else return await prof.export();
},
zod: z.tuple([
z.string().transform(Number)
]),
help: 'Fetch a profile: <id: number>'
}),
new Command({
key: ['getall', 'listall', 'fetchall', 'all', 'a'],
exec: async () => {
const ids = await this.getAll();
return ids;
},
zod: z.tuple([]),
help: 'Fetch all profile IDs'
}),
new Command({
key: ['getrole', 'gr'],
exec: async (id: number) => {
const profile = await this.get(id);
if (!profile) return new Error("No such profile");
else return await profile.getRole();
},
zod: z.tuple([
z.string().transform(Number)
]),
help: 'Set the profile role: <id: number>'
}),
new Command({
key: ['setrole', 'sr'],
exec: async (id: number, role: ProfileRole) => {
const profile = await this.get(id);
if (!profile) return new Error("No such profile");
else return await profile.setRole(role);
},
zod: z.tuple([
z.string().transform(Number),
z.string()
]),
help: 'Set the profile role: <id: number, role: "developer" | "moderator" | "screenshare" | "user">'
})
]
}));
}
}
export default ProfileManagerBase;

View File

@@ -0,0 +1,99 @@
import KV from "../persistence/kv.ts";
import { ProfileRole } from "../platforms/base.ts";
import { type ServerBase } from "../server.ts";
import { type SignalRSocketHandler } from "../socket/signalr/socket.ts";
import ProfileManagerBase from "./manager.ts";
import { recNetAccountSchema, SelfAccount, type RecNetAccount } from "./types/profile.ts";
class Profile {
#id: number;
#kv: KV;
#socket: SignalRSocketHandler | null = null;
#server: ServerBase;
#selfAcc: SelfAccount;
constructor(acc: SelfAccount, kv: KV, server: ServerBase) {
this.#id = acc.accountId;
this.#selfAcc = acc;
this.#kv = kv;
this.#server = server;
}
async #saveSelfAcc() {
await this.#kv.getKv().set(this.#constructProfilePropertyKey(), this.#selfAcc);
this.#server.emit('profile.update', { profile: this });
}
#constructProfilePropertyKey(...keys: (string | undefined)[]) {
return [ ProfileManagerBase.profilesKey, this.#id, ...keys.filter(val => typeof val == 'string') ];
}
getUsername() {
return this.#selfAcc.username;
}
async setUsername(username: string) {
this.#kv.getKv().delete([ ProfileManagerBase.profilesKey, this.#selfAcc.username ]);
this.#kv.getKv().set([ ProfileManagerBase.profilesKey, username ], this.getId());
this.#selfAcc.username = username;
await this.#saveSelfAcc();
}
getDisplayName() {
return this.#selfAcc.displayName;
}
async setDisplayName(displayName: string) {
this.#selfAcc.displayName = displayName;
await this.#saveSelfAcc();
}
async getBio(){
const key = this.#constructProfilePropertyKey('bio');
const val = await this.#kv.getKv().get<string>(key);
if (!val.value) return null;
else return val.value;
}
async setBio(bio: string) {
const key = this.#constructProfilePropertyKey('bio');
await this.#kv.getKv().set(key, bio);
}
async getRole(): Promise<ProfileRole> {
const val = (await this.#kv.getKv().get<ProfileRole>(this.#constructProfilePropertyKey('role'))).value;
if (!val) return ProfileRole.User;
else return val;
}
async setRole(role: ProfileRole) {
await this.#kv.getKv().set(this.#constructProfilePropertyKey('role'), role);
this.#server.emit('profile.roleupdate', { profile: this, newRole: role });
}
getId() {
return this.#id;
}
getSocketHandler() {
return this.#socket;
}
setSocketHandler(handler: SignalRSocketHandler | null) {
this.#socket = handler;
}
export(): RecNetAccount | null {
const val = recNetAccountSchema.safeParse(this.#selfAcc);
if (val.success) return val.data;
else return null;
}
selfExport(): SelfAccount {
return this.#selfAcc;
}
}
export default Profile;

View File

@@ -0,0 +1,37 @@
import z from "zod";
import { ProfileRole } from "../../platforms/base.ts";
export const recNetAccountSchema = z.object({
accountId: z.number(),
profileImage: z.string(),
isJunior: z.optional(z.boolean()),
username: z.string(),
displayName: z.string(),
createdAt: z.union([ z.date(), z.string().transform((arg, ctx) => {
const d = new Date(arg);
if (isNaN(d.getTime())) {
ctx.addIssue("createdAt must be an ISO date");
return new Date();
}
else return d;
}) ])
});
export const selfAccountSchema = recNetAccountSchema.extend({
email: z.optional(z.string()),
phone: z.optional(z.string()),
juniorState: z.optional(z.int()),
availableUsernameChanges: z.optional(z.int())
});
export type RecNetAccount = z.infer<typeof recNetAccountSchema>;
export type SelfAccount = z.infer<typeof selfAccountSchema>;
export const profileTokenSchema = z.object({
iss: z.string(),
exp: z.number().min(Date.now()),
iat: z.number().min(Date.now()),
sub: z.number(),
role: z.enum(ProfileRole)
});
export type ProfileToken = z.infer<typeof profileTokenSchema>;

23
src/server/server.ts Normal file
View File

@@ -0,0 +1,23 @@
import { EventManager } from "./baseevent.ts";
import { CommandsBase } from "./commands/commands.ts";
import GameConfigsBase from "./gameconfigs/base.ts";
import { PlatformsManager } from "./platforms/base.ts";
import { type ProfileUpdateEvent } from "./profiles/events/ProfileUpdate.ts";
import { type RoleUpdateEvent } from "./profiles/events/RoleUpdate.ts";
import ProfileManagerBase from "./profiles/manager.ts";
interface ServerEvents {
'profile.roleupdate': RoleUpdateEvent,
'profile.update': ProfileUpdateEvent
}
class ServerBase extends EventManager<ServerEvents> {
Profiles = new ProfileManagerBase(this, 'profiles', true);
GameConfigs = new GameConfigsBase(this, 'gameconfigs', true);
Commands = new CommandsBase(this, 'commands');
Platforms = new PlatformsManager(this, 'platforms', true);
}
const Server = new ServerBase();
export { ServerBase };
export default Server;

View File

@@ -0,0 +1,66 @@
import Logging, { LoggingListeners } from "@proxnet/undead-logging";
import { ConsoleEvent } from "./types.ts";
import { type ConsoleItem, ConsoleItemSchema } from "./zod.ts";
import Server from "../../server.ts";
import { getSourceAddress } from "../../../util/net.ts";
import { consoleSockets } from "../../../main.ts";
import chalk from "npm:chalk@^5.3.0";
export default class SocketConsoleHandler {
#socket: WebSocket;
#log = new Logging('Console');
#logCb = (msg: string) => {
this.send(ConsoleEvent.Message, msg);
}
constructor(socket: WebSocket, req: Request, serveInfo: Deno.ServeHandlerInfo<Deno.NetAddr>) {
this.#socket = socket;
this.#socket.onmessage = ev => {
this.#onMsg(ev);
};
this.#socket.onerror = () => {
this.#log.e(`Socket from ${getSourceAddress(req, serveInfo.remoteAddr)} closed due to error`);
this.destroy();
}
this.#socket.onclose = () => {
this.#log.n(`${getSourceAddress(req, serveInfo.remoteAddr)} closed a socket`);
this.destroy();
}
this.#socket.onopen = () => {
this.#log.n(`${getSourceAddress(req, serveInfo.remoteAddr)} opened a socket`);
}
LoggingListeners.onmsg('basic', this.#logCb);
}
destroy() {
LoggingListeners.offmsg('basic', this.#logCb);
consoleSockets.delete(this);
this.#socket.close();
}
send(ev: ConsoleEvent, d: string) {
if (ev == ConsoleEvent.Command) this.#socket.send(JSON.stringify({ e: ev, d }));
else if (ev == ConsoleEvent.Message) this.#socket.send(JSON.stringify({ e: ev, m: d }));
else if (ev == ConsoleEvent.Close) this.#socket.send(JSON.stringify({ e: ev }));
}
async #onMsg(ev: MessageEvent) {
try {
const parsed = JSON.parse(ev.data) as ConsoleItem;
const zodd = ConsoleItemSchema.safeParse(parsed);
if (!zodd.success) this.destroy();
else if (zodd.data.e == ConsoleEvent.Command) {
const data = await Server.Commands.dispatch(...zodd.data.d.split(' '));
if (data instanceof Error) throw data;
this.send(ConsoleEvent.Message, chalk.gray(`> ${chalk.yellow(data)}`));
}
else if (zodd.data.e == ConsoleEvent.Close) this.destroy();
} catch (err) {
this.#log.e(err);
}
}
}

View File

@@ -0,0 +1,5 @@
export enum ConsoleEvent {
Message,
Command,
Close
}

View File

@@ -0,0 +1,25 @@
import z from "zod";
import { ConsoleEvent } from "./types.ts";
export const ConsoleCommandSchema = z.object({
e: z.literal(ConsoleEvent.Command),
d: z.string()
});
export const ConsoleMessageSchema = z.object({
e: z.literal(ConsoleEvent.Message),
m: z.string()
});
export const ConsoleCloseSchema = z.object({
e: z.literal(ConsoleEvent.Close),
r: z.string()
});
export const ConsoleItemSchema = z.discriminatedUnion('e', [
ConsoleCommandSchema,
ConsoleMessageSchema,
ConsoleCloseSchema
]);
export type ConsoleItem = z.infer<typeof ConsoleItemSchema>;

View File

@@ -0,0 +1,176 @@
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 type Profile from "../../profiles/profile.ts";
import { detailedLog } from "../../../main.ts";
const logmessages = true;
export class SignalRSocketHandler {
#log: Logging = new Logging("SignalMock-");
#socket: WebSocket;
#profile: Profile;
#Targets: Map<string, SocketTarget> = new Map();
#killed = false;
constructor(socket: WebSocket, player: Profile) {
this.#socket = socket;
this.#profile = player;
this.#init();
player.setSocketHandler(this);
//this.#Targets.set('SubscribeToPlayers', new PlayerSocketSubscriptionTarget(this));
for (const target of this.#Targets.values()) target.onInit();
}
async #dispatchTarget<T = unknown>(target: string, args: unknown): Promise<TargetResult> {
if (this.#killed) {
const error = "Tried to dispatch socket target on dead socket";
this.#log.w(error);
return { type: TargetResultType.Failure, err: error };
}
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<T>;
} 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 {
if (logmessages) 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, true));
}
destroy(handler: SignalRSocketHandler, internal: boolean | undefined = false) {
return (ev: CloseEvent) => {
handler.#killed = true;
let errorReason = "Socket closed by server";
this.#log.d(`Socket close code: ${ev.code}`);
if (ev.reason.includes('Bye!')) errorReason = "Socket closed by client request";
handler.sendRaw({ type: 7, error: errorReason });
if (!internal) handler.#socket.close();
handler.#log.i(`Closed socket`);
handler.#profile.setSocketHandler(null);
for (const target of handler.#Targets.values()) target.onDestroy();
}
}
sendRaw(data: object) {
this.#socket.send(`${JSON.stringify(data)}\u001e`);
if (logmessages) {
const isHandshake = JSON.stringify(data) == '{}';
if (isHandshake) this.#log.d(detailedLog([`SERVER MESSAGE`,
`Type: Handshake`
]));
else this.#log.d(detailedLog([`SERVER MESSAGE`,
`Type: ${(data as SignalRMessage).type} (${SignalMessageType[(data as SignalRMessage).type]})`,
`Content: ${JSON.stringify(data)}`
]));
}
}
sendNotification(id: PushNotificationId | string, args?: object) {
const msg: SignalRMessage = {
type: SignalMessageType.Invocation,
target: "Notification",
arguments: [JSON.stringify({
Id: id,
Msg: args ? args : {}
})]
}
this.sendRaw(msg);
}
}

View File

@@ -0,0 +1,41 @@
/* Galvanic Corrosion - Rec Room custom server for communities.
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
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 <https://www.gnu.org/licenses/>. */
import type { SignalRSocketHandler } from "../socket.ts";
export class SocketTarget {
socket: SignalRSocketHandler;
constructor(socket: SignalRSocketHandler) {
this.socket = socket;
}
onInit() {
return;
}
onDestroy() {
return;
}
// deno-lint-ignore require-await
async exec(_args: unknown) {
throw new Error("Execution for this target is not set.");
}
}

View File

@@ -0,0 +1,190 @@
/* Galvanic Corrosion - Rec Room custom server for communities.
<https://gitea.proxnet.dev/zombieb/galvanic-corrosion>
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 <https://www.gnu.org/licenses/>. */
import { z } from "zod";
export enum MessageKind {
Protocol,
Data
}
export interface MessageBase {
kind: MessageKind
}
export interface DataMessage extends MessageBase {
kind: MessageKind.Data,
data: SignalRMessage
}
export interface ProtocolMessage extends MessageBase {
kind: MessageKind.Protocol
}
export type Message = ProtocolMessage | DataMessage;
export type SignalRMessage =
| InvocationMessage
| StreamItemMessage
| CompletionMessage
| PingMessage
| CloseMessage;
export enum SignalMessageType {
Handshake,
Invocation,
StreamItem,
Completion,
StreamInvocation,
CancelInvocation,
Ping,
Close
}
export interface BaseMessage {
type: SignalMessageType;
}
export interface InvocationMessage extends BaseMessage {
type: SignalMessageType.Invocation;
target: string;
arguments: unknown[];
invocationId?: string;
}
export interface StreamItemMessage extends BaseMessage {
type: SignalMessageType.StreamItem;
invocationId: string;
item: unknown;
}
export interface CompletionMessage extends BaseMessage {
type: SignalMessageType.Completion;
invocationId: string;
result?: unknown;
error?: string;
}
export interface PingMessage extends BaseMessage {
type: SignalMessageType.Ping;
}
export interface CloseMessage extends BaseMessage {
type: SignalMessageType.Close;
error?: string;
}
export const BaseMessageSchema = z.object({
type: z.enum(SignalMessageType),
});
export const InvocationMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Invocation),
target: z.string(),
arguments: z.array(z.unknown()),
invocationId: z.string().optional(),
});
export const StreamItemMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.StreamItem),
invocationId: z.string(),
item: z.unknown(),
});
export const CompletionMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Completion),
invocationId: z.string(),
result: z.unknown().optional(),
error: z.string().optional(),
});
export const PingMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Ping),
});
export const CloseMessageSchema = BaseMessageSchema.extend({
type: z.literal(SignalMessageType.Close),
error: z.optional(z.string()),
});
export const SignalRMessageSchema = z.discriminatedUnion("type", [
InvocationMessageSchema,
StreamItemMessageSchema,
CompletionMessageSchema,
PingMessageSchema,
CloseMessageSchema,
]);
export enum TargetResultType {
Success,
Failure,
NotATarget
}
export interface TargetResultBase {
type: TargetResultType
}
export interface TargetResultSuccess<T = unknown> extends TargetResultBase {
type: TargetResultType.Success,
data: T
}
export interface TargetResultFailure extends TargetResultBase {
type: TargetResultType.Failure
err: string | Error
}
export interface TargetResultNotATarget extends TargetResultBase {
type: TargetResultType.NotATarget
}
export type TargetResult = TargetResultSuccess | TargetResultFailure | TargetResultNotATarget;
export enum PushNotificationId {
RelationshipChanged = 1,
MessageReceived,
MessageDeleted,
PresenceHeartbeatResponse,
RefreshLogin,
Logout,
SubscriptionUpdateProfile = 11,
SubscriptionUpdatePresence,
SubscriptionUpdateGameSession,
SubscriptionUpdateRoom = 15,
SubscriptionUpdateRoomPlaylist,
ModerationQuitGame = 20,
ModerationUpdateRequired,
ModerationKick,
ModerationKickAttemptFailed,
ModerationRoomBan,
ServerMaintenance,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate,
GiftPackageRewardSelectionReceived,
ProfileJuniorStatusUpdate = 40,
RelationshipsInvalid = 50,
StorefrontBalanceAdd = 60,
StorefrontBalanceUpdate,
StorefrontBalancePurchase,
ConsumableMappingAdded = 70,
ConsumableMappingRemoved,
PlayerEventCreated = 80,
PlayerEventUpdated,
PlayerEventDeleted,
PlayerEventResponseChanged,
PlayerEventResponseDeleted,
PlayerEventStateChanged,
ChatMessageReceived = 90,
CommunityBoardUpdate = 95,
CommunityBoardAnnouncementUpdate,
InventionModerationStateChanged = 100,
FreeGiftButtonItemsAdded = 110,
LocalRoomKeyCreated = 120,
LocalRoomKeyDeleted
}