diff --git a/README.md b/README.md index 98ddc448..953f6462 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,11 @@ contains: `@mml-io/3d-web-user-networking` - Additionally, the server runs MML documents in the `mml-documents` directory which are then connected to by the `web-client`. + - A simple session-based [auth system](#auth) that can be used as a reference to implement arbitrary http-based auth. It can be easily deployed to environments that support Node.js and expose ports to the internet. - + ## Main features @@ -44,3 +45,9 @@ npm run iterate ``` Once the example server is running, open `http://localhost:8080` in your browser. + +## Auth +- When the client page is rendered by the server the server uses a UserAuthenticator implementation to determine if a session should be generated for the incoming http request and if so includes that session token on the client page. +- The client then sends the session token in the first message to the server when it connects via websocket. +- The server can use the session token to authenticate the user and determine what identity (username, avatar etc) the user should have. +- An example implementation of this is provided in the example server, but the interface is extensible enough that a more complex user authenticator can limit which avatar components should be permitted based on external systems. diff --git a/example/local-multi-web-client/src/LocalAvatarClient.ts b/example/local-multi-web-client/src/LocalAvatarClient.ts index 067176f4..3332d99c 100644 --- a/example/local-multi-web-client/src/LocalAvatarClient.ts +++ b/example/local-multi-web-client/src/LocalAvatarClient.ts @@ -121,7 +121,9 @@ export class LocalAvatarClient { localAvatarServer.send(localClientId, characterState); }, animationConfig, - characterDescription, + () => { + return { username: "User", characterDescription }; + }, ); this.scene.add(this.characterManager.group); @@ -143,8 +145,9 @@ export class LocalAvatarClient { this.scene.add(room); this.characterManager.spawnLocalCharacter( - characterDescription, localClientId, + "User", + characterDescription, spawnPosition, spawnRotation, ); diff --git a/example/server/build.ts b/example/server/build.ts index af13373d..9dc40225 100644 --- a/example/server/build.ts +++ b/example/server/build.ts @@ -1,5 +1,7 @@ import * as esbuild from "esbuild"; +import { rebuildOnDependencyChangesPlugin } from "../../utils/rebuildOnDependencyChangesPlugin"; + const buildMode = "--build"; const watchMode = "--watch"; @@ -24,6 +26,7 @@ const buildOptions: esbuild.BuildOptions = { sourcemap: true, platform: "node", target: "es2020", + plugins: mode === watchMode ? [rebuildOnDependencyChangesPlugin] : [], }; switch (mode) { diff --git a/example/server/nodemon.json b/example/server/nodemon.json deleted file mode 100644 index 1506f792..00000000 --- a/example/server/nodemon.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "watch": ["build/", "../packages/*/build/"], - "ignore": [], - "ext": "ts,js,json", - "exec": "node build/index.js" -} diff --git a/example/server/package.json b/example/server/package.json index 5ea96cbb..9c7e7748 100644 --- a/example/server/package.json +++ b/example/server/package.json @@ -9,7 +9,8 @@ "type": "module", "scripts": { "build": "rimraf ./build && tsx ./build.ts --build", - "iterate": "concurrently \"tsx ./build.ts --watch\" \"nodemon\"", + "iterate": "tsx ./build.ts --watch", + "iterate:start": "node ./build/index.js", "start": "NODE_ENV=production node build/index.js", "type-check": "tsc --noEmit", "lint": "eslint \"./{src,test}/**/*.{js,jsx,ts,tsx}\" --max-warnings 0", diff --git a/example/server/src/BasicUserAuthenticator.ts b/example/server/src/BasicUserAuthenticator.ts new file mode 100644 index 00000000..2fbadaed --- /dev/null +++ b/example/server/src/BasicUserAuthenticator.ts @@ -0,0 +1,113 @@ +import crypto from "crypto"; + +import { UserIdentity } from "@mml-io/3d-web-user-networking"; +import type { CharacterDescription, UserData } from "@mml-io/3d-web-user-networking"; +import express from "express"; + +export type AuthUser = { + // clientId is the connection identifier for the user - it is null before the client websocket is connected + clientId: number | null; + // userData is the user's presentation in the world (username and character description) + userData?: UserData; + // sessionToken is the token that is generated by this authenticator and the user uses to authenticate their websocket connection + sessionToken: string; +}; + +export type BasicUserAuthenticatorOptions = { + devAllowUnrecognizedSessions: boolean; +}; + +const defaultOptions: BasicUserAuthenticatorOptions = { + devAllowUnrecognizedSessions: false, +}; + +export class BasicUserAuthenticator { + private usersByClientId = new Map(); + private userBySessionToken = new Map(); + + constructor( + private characterDescription: CharacterDescription, + private options: BasicUserAuthenticatorOptions = defaultOptions, + ) {} + + public generateAuthorizedSessionToken(req: express.Request): string { + const sessionToken = crypto.randomBytes(20).toString("hex"); + const authUser: AuthUser = { + clientId: null, + sessionToken, + }; + + this.userBySessionToken.set(sessionToken, authUser); + return sessionToken; + } + + public onClientConnect( + clientId: number, + sessionToken: string, + userIdentityPresentedOnConnection?: UserIdentity, + ): UserData | null { + console.log(`Client ID: ${clientId} joined with token`); + let user = this.userBySessionToken.get(sessionToken); + if (!user) { + console.error(`Invalid initial user-update for clientId ${clientId}, unknown session`); + + if (this.options.devAllowUnrecognizedSessions) { + console.warn(`Dev mode: allowing unrecognized session token`); + user = { + clientId: null, + sessionToken, + }; + this.userBySessionToken.set(sessionToken, user); + } + return null; + } + + if (user.clientId !== null) { + console.error(`Session token already connected`); + return null; + } + + user.clientId = clientId; + user.userData = { + username: `User ${clientId}`, + characterDescription: this.characterDescription, + }; + if (userIdentityPresentedOnConnection) { + console.warn("Ignoring user-identity on initial connect"); + } + this.usersByClientId.set(clientId, user); + return user.userData; + } + + public getClientIdForSessionToken(sessionToken: string): { id: number } | null { + const user = this.userBySessionToken.get(sessionToken); + if (!user) { + console.error("getClientIdForSessionToken - unknown session"); + return null; + } + if (user.clientId === null) { + console.error("getClientIdForSessionToken - client not connected"); + return null; + } + return { id: user.clientId }; + } + + public onClientUserIdentityUpdate(clientId: number, msg: UserIdentity): UserData | null { + // This implementation does not allow updating user data after initial connect. + + // To allow updating user data after initial connect, return the UserData object that reflects the requested change. + + // Returning null will not update the user data. + return null; + } + + public onClientDisconnect(clientId: number) { + console.log(`Remove user-session for ${clientId}`); + // TODO - expire session token after a period of disconnection + const userData = this.usersByClientId.get(clientId); + if (userData) { + userData.clientId = null; + this.usersByClientId.delete(clientId); + } + } +} diff --git a/example/server/src/example-character-customisation-auth/ExampleEnforcingUserAuthenticator.ts b/example/server/src/example-character-customisation-auth/ExampleEnforcingUserAuthenticator.ts new file mode 100644 index 00000000..f69e31b3 --- /dev/null +++ b/example/server/src/example-character-customisation-auth/ExampleEnforcingUserAuthenticator.ts @@ -0,0 +1,321 @@ +import crypto from "crypto"; + +import type { UserData } from "@mml-io/3d-web-user-networking"; +import { CharacterDescription, UserIdentity } from "@mml-io/3d-web-user-networking"; +import express from "express"; +import { JSDOM } from "jsdom"; + +type UserPermissions = { + allowUsername: boolean; +}; + +export type AuthUser = { + // clientId is the connection identifier for the user - it is null before the client websocket is connected + clientId: number | null; + // userId is the identifier for the user that is generated when the user is authenticated - it could be an external identifier + userId: string; + // userData is the user's presentation in the world (username and character description) + userData: UserData; + // itemSrcs is the set of item sources that the user is using in their character description + itemSrcs: Set; + // sessionToken is the token that is generated by this authenticator and the user uses to authenticate their websocket connection + sessionToken: string; + // permissions is the set of permissions that the user has based on their authentication - it could be used to control the user's actions after loading + permissions: UserPermissions; +}; + +export const botWithHatCharacter: CharacterDescription = { + mmlCharacterString: ` + + + + `, +}; + +export const botCharacter: CharacterDescription = { + mmlCharacterString: ``, +}; + +/* + This user-authenticator is intended to demonstrate that the user's identity and character description can be + updated after the initial connection and the server can control the result. + + It also shows that given some trigger, the server can update the user's character description (i.e. based on the items + being revoked/transferred). +*/ +export class ExampleEnforcingUserAuthenticator { + private usersByClientId = new Map(); + private userByUserId = new Map(); + private userBySessionToken = new Map(); + + // Define some items that can be used in the character description and the rules over their usage + private itemsBySrc = new Map< + string, + | { + // This item can only be used by specific users + restricted: true; + allowedUsers: Set; + } + | { + restricted: false; + } + >([ + ["/assets/models/bot.glb", { restricted: false }], + ["/assets/models/hat.glb", { restricted: true, allowedUsers: new Set() }], + ]); + + constructor( + private options: { + updateUserCharacter: (clientId: number, userData: UserData) => void; + }, + ) {} + + public generateAuthorizedSessionToken(req: express.Request): string | null { + const sessionToken = crypto.randomBytes(20).toString("hex"); + + const resultUserId = crypto.randomBytes(5).toString("hex"); + + let resultCharacterDescription: CharacterDescription = botCharacter; + let resultUsername = "Guest"; + const resultPermissions: UserPermissions = { + allowUsername: false, + }; + + if (req.query.passphrase === "ThatKillsPeople") { + resultPermissions.allowUsername = true; + this.setAllowedUsersforItemSrc("/assets/models/hat.glb", new Set([resultUserId])); + const foundCharacter = botWithHatCharacter; + if (foundCharacter) { + resultCharacterDescription = foundCharacter; + } + } + if (resultPermissions.allowUsername && req.query.username) { + resultUsername = req.query.username as string; + } + + const authorizedCharacterDescription = this.getAuthorizedCharacterDescription( + resultUserId, + resultCharacterDescription, + ); + if (!authorizedCharacterDescription) { + console.error(`The generated character description was unauthorized`); + return null; + } + + const { characterDescription, srcs } = authorizedCharacterDescription; + + console.log(`Generated sessionToken for userId ${resultUserId}`); + const authUser: AuthUser = { + clientId: null, + userData: { username: resultUsername, characterDescription: characterDescription }, + itemSrcs: srcs, + sessionToken, + userId: resultUserId, + permissions: resultPermissions, + }; + + this.userByUserId.set(resultUserId, authUser); + this.userBySessionToken.set(sessionToken, authUser); + return sessionToken; + } + + public setAllowedUsersforItemSrc(itemSrc: string, allowedUsers: Set) { + const item = this.itemsBySrc.get(itemSrc); + if (!item) { + console.error(`Item ${itemSrc} not found`); + return; + } + if (!item.restricted) { + console.error(`Item ${itemSrc} not restricted`); + return; + } + + const impactedUsers = new Set(); + for (const [userId, user] of this.userByUserId) { + if (user.itemSrcs.has(itemSrc)) { + impactedUsers.add(userId); + } + } + + item.allowedUsers = allowedUsers; + + for (const userId of impactedUsers) { + this.checkUserCharacter(userId); + } + } + + // This removes any mml-tags using src attributes that are not permitted for the user + public getAuthorizedCharacterDescription( + userId: string, + characterDescription: CharacterDescription, + ): { characterDescription: CharacterDescription; srcs: Set } | null { + const mmlCharacterString = characterDescription.mmlCharacterString ?? null; + if (mmlCharacterString === null) { + console.error("Character description is not MML string"); + // TODO - support MML urls + return null; + } + + const dom = new JSDOM(mmlCharacterString); + const doc = dom.window.document; + const srcs = new Set(); + for (const element of doc.querySelectorAll("*")) { + const src = element.getAttribute("src"); + if (src) { + if (this.canUseSrc(userId, src)) { + srcs.add(src); + } else { + console.warn(`Remove ${src} from character: Not permitted for user ${userId}`); + element.parentNode?.removeChild(element); + } + } + } + + const authorizedCharacterDescription = { mmlCharacterString: doc.body.innerHTML }; + return { + characterDescription: authorizedCharacterDescription, + srcs, + }; + } + + private checkUserCharacter(userId: string) { + const user = this.userByUserId.get(userId); + if (!user) { + console.error(`User ${userId} not found`); + return; + } + if (!user.clientId) { + console.error(`User ${userId} not connected`); + return; + } + const authorizedCharacterDescription = this.getAuthorizedCharacterDescription( + userId, + user.userData.characterDescription, + ); + if (!authorizedCharacterDescription) { + console.error(`Unauthorized character update for ${userId}`); + return; + } + user.userData = { + characterDescription: authorizedCharacterDescription.characterDescription, + username: user.userData.username, + }; + user.itemSrcs = authorizedCharacterDescription.srcs; + this.options.updateUserCharacter(user.clientId, user.userData); + } + + public onClientConnect( + clientId: number, + sessionToken: string, + userIdentityPresentedOnConnection?: UserIdentity, + ): UserData | null { + console.log(`Client ID: ${clientId} joined with token`); + const user = this.userBySessionToken.get(sessionToken); + if (!user) { + console.error(`Invalid initial user-update for clientId ${clientId}, unknown session`); + return null; + } + + if (user.clientId !== null) { + console.error(`User ${user.userId} already connected`); + return null; + } + + user.clientId = clientId; + if (userIdentityPresentedOnConnection) { + user.userData = { + username: userIdentityPresentedOnConnection.username || user.userData.username, + characterDescription: + userIdentityPresentedOnConnection.characterDescription || + user.userData.characterDescription, + }; + } + this.usersByClientId.set(clientId, user); + return user.userData; + } + + public getClientIdForSessionToken(sessionToken: string): { id: number } | null { + const user = this.userBySessionToken.get(sessionToken); + if (!user) { + console.error("getClientIdForSessionToken - unknown session"); + return null; + } + if (user.clientId === null) { + console.error("getClientIdForSessionToken - client not connected"); + return null; + } + return { id: user.clientId }; + } + + public onClientUserIdentityUpdate( + clientId: number, + newUserIdentity: UserIdentity, + ): UserData | null { + const user = this.usersByClientId.get(clientId); + if (!user) { + console.error(`Invalid user-update for clientId ${clientId}, unknown user`); + return null; + } + + const existingUserIdentity = user.userData; + let newUsername = existingUserIdentity.username; + let newSrcs = user.itemSrcs; + let newCharacterDescription = existingUserIdentity.characterDescription; + + if (newUserIdentity.characterDescription) { + const authorizedCharacterUpdate = this.getAuthorizedCharacterDescription( + user.userId, + newUserIdentity.characterDescription, + ); + if (!authorizedCharacterUpdate) { + console.error(`Unauthorized character update for clientId ${clientId}`); + return null; + } + newCharacterDescription = authorizedCharacterUpdate.characterDescription; + newSrcs = authorizedCharacterUpdate.srcs; + } + + if (newUserIdentity.username !== null) { + if (!user.permissions.allowUsername) { + console.error(`No permissions to change username for ${user.userId}.`); + return null; + } + newUsername = newUserIdentity.username; + } + + const newUserData: UserData = { + username: newUsername, + characterDescription: newCharacterDescription, + }; + user.userData = newUserData; + user.itemSrcs = newSrcs; + return newUserData; + } + + public onClientDisconnect(clientId: number) { + console.log(`Remove user-session for ${clientId}`); + // TODO - expire session token after a period of disconnection + const userData = this.usersByClientId.get(clientId); + if (userData) { + userData.clientId = null; + this.usersByClientId.delete(clientId); + } + } + + private canUseSrc(userId: string, src: string) { + const item = this.itemsBySrc.get(src); + if (!item) { + console.error(`Item ${src} not found. Cannot be used.`); + return false; + } + if (!item.restricted) { + return true; + } + return item.allowedUsers.has(userId); + } +} diff --git a/example/server/src/index.ts b/example/server/src/index.ts index 91bd304a..8d161f5a 100644 --- a/example/server/src/index.ts +++ b/example/server/src/index.ts @@ -4,7 +4,8 @@ import url from "url"; import dolbyio from "@dolbyio/dolbyio-rest-apis-client"; import * as jwtToken from "@dolbyio/dolbyio-rest-apis-client/dist/types/jwtToken"; import { ChatNetworkingServer } from "@mml-io/3d-web-text-chat"; -import { UserNetworkingServer } from "@mml-io/3d-web-user-networking"; +import type { CharacterDescription, UserData } from "@mml-io/3d-web-user-networking"; +import { UserIdentity, UserNetworkingServer } from "@mml-io/3d-web-user-networking"; import cors from "cors"; import dotenv from "dotenv"; import express from "express"; @@ -12,6 +13,8 @@ import enableWs from "express-ws"; import WebSocket from "ws"; import { authMiddleware } from "./auth"; +import { BasicUserAuthenticator } from "./BasicUserAuthenticator"; +import { ExampleEnforcingUserAuthenticator } from "./example-character-customisation-auth/ExampleEnforcingUserAuthenticator"; import { addLocalMultiWebAppRoutes } from "./router/local-multi-web-client-app-routes"; import { MMLDocumentsServer } from "./router/MMLDocumentsServer"; import { addWebAppRoutes } from "./router/web-app-routes"; @@ -24,6 +27,43 @@ const documentsWatchPath = path.resolve(path.join(dirname, "../mml-documents"), const { app } = enableWs(express()); app.enable("trust proxy"); +// TODO - remove example +// This exampleEnforcingUserAuthenticator is an example of how to enforce user-level character customisation +const exampleEnforcingUserAuthenticator = new ExampleEnforcingUserAuthenticator({ + updateUserCharacter: (clientId, userData) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + userNetworkingServer.updateUserCharacter(clientId, userData); + }, +}); + +// Specify the avatar to use here: +const characterDescription: CharacterDescription = { + // Option 1 (Default) - Use a GLB file directly + meshFileUrl: "/assets/models/bot.glb", // This is just an address of a GLB file + // Option 2 - Use an MML Character from a URL + // mmlCharacterUrl: "https://...", + // Option 3 - Use an MML Character from a string + // mmlCharacterString: ` + // + // + // + // `, +}; +const userAuthenticator = new BasicUserAuthenticator(characterDescription, { + /* + This option allows sessions that are reconnecting from a previous run of the server to connect even if the present a + session token that was not generated by this run of the server. + + This is useful for development, but in deployed usage, it is recommended to set this to false. + */ + devAllowUnrecognizedSessions: true, +}); + const DOLBY_APP_KEY = process.env.DOLBY_APP_KEY ?? ""; const DOLBY_APP_SECRET = process.env.DOLBY_APP_SECRET ?? ""; let apiTokenPromise: Promise; @@ -108,18 +148,47 @@ app.ws(`/mml-documents/:filename`, (ws: WebSocket, req: express.Request) => { // Serve assets with CORS allowing all origins app.use("/assets/", cors(), express.static(path.resolve(dirname, "../../assets/"))); -const userNetworkingServer = new UserNetworkingServer(); -app.ws("/network", (ws) => { - userNetworkingServer.connectClient(ws); +const chatNetworkingServer = new ChatNetworkingServer({ + getChatUserIdentity: (sessionToken: string) => { + return userAuthenticator.getClientIdForSessionToken(sessionToken); + }, +}); +app.ws("/chat-network", (ws) => { + chatNetworkingServer.connectClient(ws); }); -const chatNetworkingServer = new ChatNetworkingServer(); -app.ws("/chat-network", (ws, req) => { - chatNetworkingServer.connectClient(ws, parseInt(req.query.id as string, 10)); +const userNetworkingServer = new UserNetworkingServer({ + onClientConnect: ( + clientId: number, + sessionToken: string, + userIdentityPresentedOnConnection?: UserIdentity, + ): UserData | null => { + return userAuthenticator.onClientConnect( + clientId, + sessionToken, + userIdentityPresentedOnConnection, + ); + }, + onClientUserIdentityUpdate: (clientId: number, userIdentity: UserIdentity): UserData | null => { + // Called whenever a user connects or updates their character/identity + return userAuthenticator.onClientUserIdentityUpdate(clientId, userIdentity); + }, + onClientDisconnect: (clientId: number): void => { + userAuthenticator.onClientDisconnect(clientId); + // Disconnect the corresponding chat client to avoid later conflicts of client ids + chatNetworkingServer.disconnectClientId(clientId); + }, }); +app.ws("/network", (ws) => { + userNetworkingServer.connectClient(ws); +}); // Serve the web-client app (including development mode) -addWebAppRoutes(app); +addWebAppRoutes(app, { + generateAuthorizedSessionToken(req: express.Request): string | null { + return userAuthenticator.generateAuthorizedSessionToken(req); + }, +}); // Serve the local-multi-web-client app addLocalMultiWebAppRoutes(app); diff --git a/example/server/src/router/web-app-routes.ts b/example/server/src/router/web-app-routes.ts index fd7cf9fb..bb312d9f 100644 --- a/example/server/src/router/web-app-routes.ts +++ b/example/server/src/router/web-app-routes.ts @@ -6,6 +6,7 @@ import chokidar from "chokidar"; import express from "express"; import enableWs from "express-ws"; import WebSocket from "ws"; + const dirname = url.fileURLToPath(new URL(".", import.meta.url)); const FORK_PAGE_CONTENT = ` @@ -15,7 +16,12 @@ const FORK_PAGE_CONTENT = ` const webClientBuildDir = path.join(dirname, "../../web-client/build/"); const webAvatarBuildDir = path.join(dirname, "../../web-avatar-client/build/"); -export function addWebAppRoutes(app: enableWs.Application) { +export function addWebAppRoutes( + app: enableWs.Application, + options: { + generateAuthorizedSessionToken(req: express.Request): string | null; + }, +) { // Serve frontend statically in production const demoIndexContent = fs.readFileSync(path.join(webClientBuildDir, "index.html"), "utf8"); app.get("/", (req, res) => { @@ -23,7 +29,15 @@ export function addWebAppRoutes(app: enableWs.Application) { res.send(FORK_PAGE_CONTENT); return; } - res.send(demoIndexContent); + + const token = options.generateAuthorizedSessionToken(req); + if (!token) { + res.send("Error: Could not generate token"); + return; + } + + const authorizedDemoIndexContent = demoIndexContent.replace("SESSION.TOKEN.PLACEHOLDER", token); + res.send(authorizedDemoIndexContent); }); app.use("/web-client/", express.static(webClientBuildDir)); diff --git a/example/web-client/public/index.html b/example/web-client/public/index.html index dca580e2..e7b7516a 100644 --- a/example/web-client/public/index.html +++ b/example/web-client/public/index.html @@ -6,6 +6,9 @@ MML 3D Web Experience +
diff --git a/example/web-client/src/index.ts b/example/web-client/src/index.ts index b7bb293c..7fd3dfd9 100644 --- a/example/web-client/src/index.ts +++ b/example/web-client/src/index.ts @@ -1,4 +1,5 @@ import { + AnimationConfig, CameraManager, CharacterDescription, CharacterManager, @@ -12,10 +13,10 @@ import { MMLCompositionScene, TimeManager, TweakPane, - AnimationConfig, } from "@mml-io/3d-web-client-core"; import { ChatNetworkingClient, FromClientChatMessage, TextChatUI } from "@mml-io/3d-web-text-chat"; import { + UserData, UserNetworkingClient, UserNetworkingClientUpdate, WebsocketStatus, @@ -34,7 +35,6 @@ import airAnimationFileUrl from "../../assets/models/anim_air.glb"; import idleAnimationFileUrl from "../../assets/models/anim_idle.glb"; import jogAnimationFileUrl from "../../assets/models/anim_jog.glb"; import sprintAnimationFileUrl from "../../assets/models/anim_run.glb"; -import defaultAvatarMeshFileUrl from "../../assets/models/bot.glb"; import { LoadingScreen } from "./LoadingScreen"; import { Room } from "./Room"; @@ -46,25 +46,6 @@ const animationConfig: AnimationConfig = { sprintAnimationFileUrl, }; -// Specify the avatar to use here: -const characterDescription: CharacterDescription = { - // Option 1 (Default) - Use a GLB file directly - meshFileUrl: defaultAvatarMeshFileUrl, // This is just an address of a GLB file - // Option 2 - Use an MML Character from a URL - // mmlCharacterUrl: "https://...", - // Option 3 - Use an MML Character from a string - // mmlCharacterString: ` - // - // - // - // `, -}; - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const host = window.location.host; const userNetworkAddress = `${protocol}//${host}/network`; @@ -85,6 +66,8 @@ export class App { private mmlCompositionScene: MMLCompositionScene; private networkClient: UserNetworkingClient; private remoteUserStates = new Map(); + // A dictionary holding information about my own user and all remote users + private userProfiles = new Map(); private networkChat: ChatNetworkingClient | null = null; private textChatUI: TextChatUI | null = null; @@ -101,8 +84,9 @@ export class App { private loadingScreen: LoadingScreen; private appWrapper = document.getElementById("app"); + private initialNetworkLoadRef = {}; - constructor() { + constructor(private sessionToken: string) { document.addEventListener("mousedown", () => { if (this.audioListener.context.state === "suspended") { this.audioListener.context.resume(); @@ -138,12 +122,12 @@ export class App { }); resizeObserver.observe(this.element); - const initialNetworkLoadRef = {}; - this.loadingProgressManager.addLoadingAsset(initialNetworkLoadRef, "network", "network"); - this.networkClient = new UserNetworkingClient( - userNetworkAddress, - (url: string) => new WebSocket(url), - (status: WebsocketStatus) => { + this.loadingProgressManager.addLoadingAsset(this.initialNetworkLoadRef, "network", "network"); + this.networkClient = new UserNetworkingClient({ + url: userNetworkAddress, + sessionToken: this.sessionToken, + websocketFactory: (url: string) => new WebSocket(url), + statusUpdateCallback: (status: WebsocketStatus) => { if (status === WebsocketStatus.Disconnected || status === WebsocketStatus.Reconnecting) { // The connection was lost after being established - the connection may be re-established with a different client ID this.characterManager.clear(); @@ -151,23 +135,37 @@ export class App { this.clientId = null; } }, - (clientId: number) => { + assignedIdentity: (clientId: number) => { + console.log(`Assigned ID: ${clientId}`); this.clientId = clientId; if (this.initialLoadCompleted) { // Already loaded - respawn the character this.spawnCharacter(); } else { - this.loadingProgressManager.completedLoadingAsset(initialNetworkLoadRef); + this.loadingProgressManager.completedLoadingAsset(this.initialNetworkLoadRef); } }, - (remoteClientId: number, userNetworkingClientUpdate: null | UserNetworkingClientUpdate) => { + clientUpdate: ( + remoteClientId: number, + userNetworkingClientUpdate: null | UserNetworkingClientUpdate, + ) => { if (userNetworkingClientUpdate === null) { this.remoteUserStates.delete(remoteClientId); } else { this.remoteUserStates.set(remoteClientId, userNetworkingClientUpdate); } }, - ); + clientProfileUpdated: ( + clientId: number, + username: string, + characterDescription: CharacterDescription, + ): void => { + this.updateUserProfile(clientId, { + username, + characterDescription, + }); + }, + }); this.characterManager = new CharacterManager( this.composer, @@ -182,7 +180,9 @@ export class App { this.networkClient.sendUpdate(characterState); }, animationConfig, - characterDescription, + (characterId: number) => { + return this.resolveCharacterData(characterId); + }, ); this.scene.add(this.characterManager.group); @@ -211,15 +211,33 @@ export class App { this.loadingProgressManager.setInitialLoad(true); } + private resolveCharacterData(clientId: number): { + username: string; + characterDescription: CharacterDescription; + } { + const user = this.userProfiles.get(clientId)!; + if (!user) { + throw new Error(`Failed to resolve user for clientId ${clientId}`); + } + + return { + username: user.username, + characterDescription: user.characterDescription, + }; + } + + private updateUserProfile(id: number, userData: UserData) { + console.log(`Update user_profile for id=${id} (username=${userData.username})`); + + this.userProfiles.set(id, userData); + + this.characterManager.respawnIfPresent(id); + } + private sendChatMessageToServer(message: string): void { this.mmlCompositionScene.onChatMessage(message); if (this.clientId === null || this.networkChat === null) return; - const chatMessage: FromClientChatMessage = { - type: "chat", - id: this.clientId, - text: message, - }; - this.networkChat.sendUpdate(chatMessage); + this.networkChat.sendChatMessage(message); } private connectToVoiceChat() { @@ -235,31 +253,39 @@ export class App { } private connectToTextChat() { - if (this.clientId === null) return; + if (this.clientId === null) { + return; + } + const user = this.userProfiles.get(this.clientId); + if (!user) { + throw new Error("User not found"); + } if (this.textChatUI === null) { - this.textChatUI = new TextChatUI( - this.clientId.toString(), - this.sendChatMessageToServer.bind(this), - ); + this.textChatUI = new TextChatUI(user.username, this.sendChatMessageToServer.bind(this)); this.textChatUI.init(); } if (this.networkChat === null) { - this.networkChat = new ChatNetworkingClient( - `${protocol}//${host}/chat-network?id=${this.clientId}`, - (url: string) => new WebSocket(`${url}?id=${this.clientId}`), - (status: WebsocketStatus) => { + this.networkChat = new ChatNetworkingClient({ + url: `${protocol}//${host}/chat-network`, + sessionToken: this.sessionToken, + websocketFactory: (url: string) => new WebSocket(`${url}?id=${this.clientId}`), + statusUpdateCallback: (status: WebsocketStatus) => { if (status === WebsocketStatus.Disconnected || status === WebsocketStatus.Reconnecting) { // The connection was lost after being established - the connection may be re-established with a different client ID } }, - (clientId: number, chatNetworkingUpdate: null | FromClientChatMessage) => { + clientChatUpdate: ( + clientId: number, + chatNetworkingUpdate: null | FromClientChatMessage, + ) => { if (chatNetworkingUpdate !== null && this.textChatUI !== null) { - this.textChatUI.addTextMessage(clientId.toString(), chatNetworkingUpdate.text); + const username = this.userProfiles.get(clientId)?.username || "Unknown"; + this.textChatUI.addTextMessage(username, chatNetworkingUpdate.text); } }, - ); + }); } } @@ -293,9 +319,15 @@ export class App { spawnRotation.setFromQuaternion(urlParams.character.quaternion); cameraPosition = urlParams.camera.position; } + const ownIdentity = this.userProfiles.get(this.clientId); + if (!ownIdentity) { + throw new Error("Own identity not found"); + } + this.characterManager.spawnLocalCharacter( - characterDescription, this.clientId!, + ownIdentity.username, + ownIdentity.characterDescription, spawnPosition, spawnRotation, ); @@ -340,5 +372,5 @@ export class App { } } -const app = new App(); +const app = new App((window as any).SESSION_TOKEN); app.update(); diff --git a/packages/3d-web-client-core/src/character/Character.ts b/packages/3d-web-client-core/src/character/Character.ts index 5c1463da..18ed95ff 100644 --- a/packages/3d-web-client-core/src/character/Character.ts +++ b/packages/3d-web-client-core/src/character/Character.ts @@ -20,16 +20,27 @@ export type CharacterDescription = { meshFileUrl?: string; mmlCharacterUrl?: string; mmlCharacterString?: string; -}; +} & ( + | { + meshFileUrl: string; + } + | { + mmlCharacterUrl: string; + } + | { + mmlCharacterString: string; + } +); export class Character extends Group { private model: CharacterModel | null = null; public color: Color = new Color(); - public tooltip: CharacterTooltip | null = null; + public tooltip: CharacterTooltip; public speakingIndicator: CharacterSpeakingIndicator | null = null; constructor( - private readonly characterDescription: CharacterDescription, + private username: string, + private characterDescription: CharacterDescription, private readonly animationConfig: AnimationConfig, private readonly characterModelLoader: CharacterModelLoader, private readonly characterId: number, @@ -40,11 +51,22 @@ export class Character extends Group { ) { super(); this.tooltip = new CharacterTooltip(); + this.tooltip.setText(this.username, isLocal); this.add(this.tooltip); + this.load().then(() => { + this.modelLoadedCallback(); + }); + } + + updateCharacter(username: string, characterDescription: CharacterDescription) { + this.username = username; + this.characterDescription = characterDescription; this.load(); + this.tooltip.setText(username, this.isLocal); } - private async load(): Promise { + private async load(callback?: () => void): Promise { + const previousModel = this.model; this.model = new CharacterModel( this.characterDescription, this.animationConfig, @@ -54,11 +76,13 @@ export class Character extends Group { this.isLocal, ); await this.model.init(); + if (previousModel && previousModel.mesh) { + this.remove(previousModel.mesh!); + } this.add(this.model.mesh!); if (this.speakingIndicator === null) { this.speakingIndicator = new CharacterSpeakingIndicator(this.composer.postPostScene); } - this.modelLoadedCallback(); } public updateAnimation(targetAnimation: AnimationState) { diff --git a/packages/3d-web-client-core/src/character/CharacterManager.ts b/packages/3d-web-client-core/src/character/CharacterManager.ts index 9c40dca7..2ccda34b 100644 --- a/packages/3d-web-client-core/src/character/CharacterManager.ts +++ b/packages/3d-web-client-core/src/character/CharacterManager.ts @@ -20,7 +20,7 @@ export class CharacterManager { public readonly headTargetOffset = new Vector3(0, 1.3, 0); - private id: number = 0; + private localClientId: number = 0; public remoteCharacters: Map = new Map(); public remoteCharacterControllers: Map = new Map(); @@ -43,18 +43,23 @@ export class CharacterManager { private readonly clientStates: Map, private readonly sendUpdate: (update: CharacterState) => void, private readonly animationConfig: AnimationConfig, - private readonly characterDescription: CharacterDescription, + private readonly characterResolve: (clientId: number) => { + username: string; + characterDescription: CharacterDescription; + }, ) { this.group = new Group(); } public spawnLocalCharacter( - characterDescription: CharacterDescription, id: number, + username: string, + characterDescription: CharacterDescription, spawnPosition: Vector3 = new Vector3(), spawnRotation: Euler = new Euler(), ) { const character = new Character( + username, characterDescription, this.animationConfig, this.characterModelLoader, @@ -77,11 +82,11 @@ export class CharacterManager { rotation: { quaternionY: quaternion.y, quaternionW: quaternion.w }, state: AnimationState.idle, }); - this.id = id; + this.localClientId = id; this.localCharacter = character; this.localController = new LocalController( this.localCharacter, - this.id, + this.localClientId, this.collisionsManager, this.keyInputManager, this.cameraManager, @@ -89,18 +94,19 @@ export class CharacterManager { ); this.localCharacter.position.set(spawnPosition.x, spawnPosition.y, spawnPosition.z); this.localCharacter.rotation.set(spawnRotation.x, spawnRotation.y, spawnRotation.z); - character.tooltip?.setText(`${id}`, true); this.group.add(character); this.localCharacterSpawned = true; } public spawnRemoteCharacter( - characterDescription: CharacterDescription, id: number, + username: string, + characterDescription: CharacterDescription, spawnPosition: Vector3 = new Vector3(), spawnRotation: Euler = new Euler(), ) { const character = new Character( + username, characterDescription, this.animationConfig, this.characterModelLoader, @@ -118,7 +124,6 @@ export class CharacterManager { remoteController.character.position.set(spawnPosition.x, spawnPosition.y, spawnPosition.z); remoteController.character.rotation.set(spawnRotation.x, spawnRotation.y, spawnRotation.z); this.remoteCharacterControllers.set(id, remoteController); - character.tooltip?.setText(`${id}`); this.group.add(character); } @@ -151,11 +156,29 @@ export class CharacterManager { this.speakingCharacters.set(id, value); } + public respawnIfPresent(id: number) { + const characterInfo = this.characterResolve(id); + + if (this.localCharacter && this.localClientId == id) { + this.localCharacter.updateCharacter( + characterInfo.username, + characterInfo.characterDescription, + ); + } + + const remoteCharacter = this.remoteCharacters.get(id); + if (remoteCharacter) { + remoteCharacter.updateCharacter(characterInfo.username, characterInfo.characterDescription); + } + } + public update() { if (this.localCharacter) { this.localCharacter.update(this.timeManager.time, this.timeManager.deltaTime); - if (this.speakingCharacters.has(this.id)) { - this.localCharacter.speakingIndicator?.setSpeaking(this.speakingCharacters.get(this.id)!); + if (this.speakingCharacters.has(this.localClientId)) { + this.localCharacter.speakingIndicator?.setSpeaking( + this.speakingCharacters.get(this.localClientId)!, + ); } this.localController.update(); @@ -176,10 +199,13 @@ export class CharacterManager { character?.speakingIndicator?.setSpeaking(this.speakingCharacters.get(id)!); } const { position } = update; + if (!this.remoteCharacters.has(id) && this.localCharacterSpawned === true) { + const characterInfo = this.characterResolve(id); this.spawnRemoteCharacter( - this.characterDescription!, id, + characterInfo.username, + characterInfo.characterDescription, new Vector3(position.x, position.y, position.z), ); } diff --git a/packages/3d-web-client-core/src/character/CharacterTooltip.ts b/packages/3d-web-client-core/src/character/CharacterTooltip.ts index 16819485..52072874 100644 --- a/packages/3d-web-client-core/src/character/CharacterTooltip.ts +++ b/packages/3d-web-client-core/src/character/CharacterTooltip.ts @@ -22,7 +22,7 @@ const defaultLabelColor = new Color(0x000000); const defaultFontColor = new Color(0xffffff); const defaultLabelAlignment = LabelAlignment.center; const defaultLabelFontSize = 8; -const defaultLabelPadding = 0; +const defaultLabelPadding = 8; const defaultLabelWidth = 0.25; const defaultLabelHeight = 0.1; const defaultLabelCastShadows = true; @@ -84,10 +84,6 @@ export class CharacterTooltip extends Mesh { b: this.props.color.b * 255, a: 1.0, }, - dimensions: { - width: this.props.width * (100 * fontScale), - height: this.props.height * (100 * fontScale), - }, alignment: this.props.alignment, }); @@ -102,7 +98,8 @@ export class CharacterTooltip extends Mesh { } setText(text: string, temporary: boolean = false) { - this.redrawText(text); + const sanitizedText = text.replace(/(\r\n|\n|\r)/gm, ""); + this.redrawText(sanitizedText); this.visible = true; this.targetOpacity = this.visibleOpacity; if (temporary) { diff --git a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingClient.ts b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingClient.ts index b927f492..19be17cc 100644 --- a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingClient.ts +++ b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingClient.ts @@ -5,24 +5,48 @@ import { FromClientChatMessage, FromClientMessage, FromServerMessage, + IDENTITY_MESSAGE_TYPE, PING_MESSAGE_TYPE, + USER_AUTHENTICATE_MESSAGE_TYPE, } from "./ChatNetworkingMessages"; import { ReconnectingWebSocket, WebsocketFactory, WebsocketStatus } from "./ReconnectingWebsocket"; +export type ChatNetworkingClientConfig = { + url: string; + sessionToken: string; + websocketFactory: WebsocketFactory; + statusUpdateCallback: (status: WebsocketStatus) => void; + clientChatUpdate: (id: number, update: null | FromClientChatMessage) => void; +}; + export class ChatNetworkingClient extends ReconnectingWebSocket { - constructor( - url: string, - websocketFactory: WebsocketFactory, - statusUpdateCallback: (status: WebsocketStatus) => void, - private clientChatUpdate: (id: number, update: null | FromClientChatMessage) => void, - ) { - super(url, websocketFactory, statusUpdateCallback); + constructor(private config: ChatNetworkingClientConfig) { + super(config.url, config.websocketFactory, (status: WebsocketStatus) => { + if (status === WebsocketStatus.Connected) { + this.sendMessage({ + type: USER_AUTHENTICATE_MESSAGE_TYPE, + sessionToken: config.sessionToken, + }); + } + config.statusUpdateCallback(status); + }); + } + + public sendChatMessage(message: string) { + this.sendMessage({ type: CHAT_MESSAGE_TYPE, text: message }); + } + + private sendMessage(message: FromClientMessage): void { + this.send(message); } protected handleIncomingWebsocketMessage(message: MessageEvent) { if (typeof message.data === "string") { const parsed = JSON.parse(message.data) as FromServerMessage; switch (parsed.type) { + case IDENTITY_MESSAGE_TYPE: + console.log(`Client ID: ${parsed.id} assigned to self`); + break; case CONNECTED_MESSAGE_TYPE: console.log(`Client ID: ${parsed.id} joined chat`); break; @@ -30,11 +54,11 @@ export class ChatNetworkingClient extends ReconnectingWebSocket { console.log(`Client ID: ${parsed.id} left chat`); break; case PING_MESSAGE_TYPE: { - this.send({ type: "pong" } as FromClientMessage); + this.sendMessage({ type: "pong" }); break; } case CHAT_MESSAGE_TYPE: { - this.clientChatUpdate(parsed.id, parsed); + this.config.clientChatUpdate(parsed.id, parsed); break; } default: @@ -44,8 +68,4 @@ export class ChatNetworkingClient extends ReconnectingWebSocket { console.error("Unhandled message type", message.data); } } - - public sendUpdate(chatMessage: FromClientChatMessage) { - this.send(chatMessage as FromClientChatMessage); - } } diff --git a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingMessages.ts b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingMessages.ts index 2cea8240..a0ce3ea3 100644 --- a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingMessages.ts +++ b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingMessages.ts @@ -1,9 +1,16 @@ +export const IDENTITY_MESSAGE_TYPE = "identity"; +export const USER_AUTHENTICATE_MESSAGE_TYPE = "user_auth"; export const CONNECTED_MESSAGE_TYPE = "connected"; export const DISCONNECTED_MESSAGE_TYPE = "disconnected"; export const PING_MESSAGE_TYPE = "ping"; export const PONG_MESSAGE_TYPE = "pong"; export const CHAT_MESSAGE_TYPE = "chat"; +export type IdentityMessage = { + type: typeof IDENTITY_MESSAGE_TYPE; + id: number; +}; + export type ConnectedMessage = { type: typeof CONNECTED_MESSAGE_TYPE; id: number; @@ -18,20 +25,34 @@ export type FromServerPingMessage = { type: typeof PING_MESSAGE_TYPE; }; -export type FromClientChatMessage = { +export type FromServerChatMessage = { type: typeof CHAT_MESSAGE_TYPE; id: number; text: string; }; export type FromServerMessage = + | IdentityMessage | ConnectedMessage | DisconnectedMessage | FromServerPingMessage - | FromClientChatMessage; + | FromServerChatMessage; export type FromClientPongMessage = { type: typeof PONG_MESSAGE_TYPE; }; -export type FromClientMessage = FromClientPongMessage | FromClientChatMessage; +export type FromClientAuthenticateMessage = { + type: typeof USER_AUTHENTICATE_MESSAGE_TYPE; + sessionToken: string; +}; + +export type FromClientChatMessage = { + type: typeof CHAT_MESSAGE_TYPE; + text: string; +}; + +export type FromClientMessage = + | FromClientPongMessage + | FromClientAuthenticateMessage + | FromClientChatMessage; diff --git a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingServer.ts b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingServer.ts index 209a44df..04c4b671 100644 --- a/packages/3d-web-text-chat/src/chat-network/ChatNetworkingServer.ts +++ b/packages/3d-web-text-chat/src/chat-network/ChatNetworkingServer.ts @@ -1,108 +1,161 @@ import WebSocket from "ws"; import { + CHAT_MESSAGE_TYPE, CONNECTED_MESSAGE_TYPE, + ConnectedMessage, DISCONNECTED_MESSAGE_TYPE, + DisconnectedMessage, + FromClientAuthenticateMessage, FromClientMessage, + FromServerChatMessage, FromServerMessage, + IDENTITY_MESSAGE_TYPE, + IdentityMessage, + PONG_MESSAGE_TYPE, + USER_AUTHENTICATE_MESSAGE_TYPE, } from "./ChatNetworkingMessages"; import { heartBeatRate, pingPongRate } from "./ChatNetworkingSettings"; export type Client = { socket: WebSocket; + id: number | null; + lastPong: number; }; const WebSocketOpenStatus = 1; +export type ChatNetworkingServerOptions = { + getChatUserIdentity: (sessionToken: string) => { id: number } | null; +}; + export class ChatNetworkingServer { - private clients: Map = new Map(); - private clientLastPong: Map = new Map(); + private allClients = new Set(); + private clientsById = new Map(); - constructor() { + constructor(private options: ChatNetworkingServerOptions) { setInterval(this.pingClients.bind(this), pingPongRate); setInterval(this.heartBeat.bind(this), heartBeatRate); } - heartBeat() { + private heartBeat() { const now = Date.now(); - this.clientLastPong.forEach((clientLastPong, id) => { - if (now - clientLastPong > heartBeatRate) { - this.clients.delete(id); - this.clientLastPong.delete(id); - const disconnectMessage = JSON.stringify({ - id, - type: DISCONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - for (const { socket: otherSocket } of this.clients.values()) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(disconnectMessage); - } - } + this.allClients.forEach((client) => { + if (now - client.lastPong > heartBeatRate) { + client.socket.close(); + this.handleDisconnectedClient(client); } }); } - pingClients() { - this.clients.forEach((client) => { - if (client.socket.readyState === WebSocketOpenStatus) { - client.socket.send(JSON.stringify({ type: "ping" } as FromServerMessage)); + private sendToAuthenticated(message: FromServerMessage, exceptClient?: Client) { + const stringified = JSON.stringify(message); + for (const client of this.allClients) { + if ( + (exceptClient === undefined || exceptClient !== client) && + client.id !== null && + client.socket.readyState === WebSocketOpenStatus + ) { + client.socket.send(stringified); } - }); + } } - connectClient(socket: WebSocket, id: number) { - console.log(`Client joined chat with ID: ${id}`); - - if (this.clients.has(id)) { - console.error(`Client ID ${id} already exists`); - socket.close(); + private handleDisconnectedClient(client: Client) { + if (!this.allClients.has(client)) { return; } - - const connectMessage = JSON.stringify({ - id, - type: CONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - for (const { socket: otherSocket } of this.clients.values()) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(connectMessage); - } + this.allClients.delete(client); + if (client.id) { + this.clientsById.delete(client.id); + const disconnectMessage: DisconnectedMessage = { + id: client.id, + type: DISCONNECTED_MESSAGE_TYPE, + }; + this.sendToAuthenticated(disconnectMessage); } + } - this.clients.set(id, { + private pingClients() { + this.sendToAuthenticated({ type: "ping" }); + } + + public connectClient(socket: WebSocket) { + console.log(`Client joined chat.`); + + const client: Client = { + id: null, + lastPong: Date.now(), socket: socket as WebSocket, - }); + }; + this.allClients.add(client); socket.on("message", (message: WebSocket.Data) => { + let parsed; try { - const data = JSON.parse(message as string) as FromClientMessage; - if (data.type === "pong") { - this.clientLastPong.set(id, Date.now()); - } else if (data.type === "chat") { - for (const [otherClientId, otherClient] of this.clients) { - if (otherClientId !== id && otherClient.socket.readyState === WebSocketOpenStatus) { - otherClient.socket.send(JSON.stringify(data)); - } - } - } + parsed = JSON.parse(message as string) as FromClientMessage; } catch (e) { console.error("Error parsing JSON message", message, e); + return; } - }); + if (!client.id) { + if (parsed.type === USER_AUTHENTICATE_MESSAGE_TYPE) { + const { sessionToken } = parsed; + const authResponse = this.options.getChatUserIdentity(sessionToken); + if (authResponse === null) { + // If the user is not authorized, disconnect the client + socket.close(); + return; + } + if (this.clientsById.has(authResponse.id)) { + throw new Error(`Client already connected with ID: ${authResponse.id}`); + } + client.id = authResponse.id; + this.clientsById.set(client.id, client); + socket.send( + JSON.stringify({ type: IDENTITY_MESSAGE_TYPE, id: client.id } as IdentityMessage), + ); + const connectedMessage = { + type: CONNECTED_MESSAGE_TYPE, + id: client.id, + } as ConnectedMessage; + this.sendToAuthenticated(connectedMessage, client); + } else { + console.error(`Unhandled message pre-auth: ${JSON.stringify(parsed)}`); + socket.close(); + } + } else { + switch (parsed.type) { + case PONG_MESSAGE_TYPE: + client.lastPong = Date.now(); + break; - socket.on("close", () => { - console.log("Client disconnected from Chat", id); - this.clients.delete(id); - const disconnectMessage = JSON.stringify({ - id, - type: DISCONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [, { socket: otherSocket }] of this.clients) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(disconnectMessage); + case CHAT_MESSAGE_TYPE: + const asChatMessage: FromServerChatMessage = { + type: CHAT_MESSAGE_TYPE, + id: client.id, + text: parsed.text, + }; + this.sendToAuthenticated(asChatMessage, client); + break; + + default: + console.error(`Unhandled message: ${JSON.stringify(parsed)}`); } } }); + + socket.on("close", () => { + console.log("Client disconnected from Chat", client.id); + this.handleDisconnectedClient(client); + }); + } + + public disconnectClientId(clientId: number) { + const client = this.clientsById.get(clientId); + if (client) { + client.socket.close(); + this.handleDisconnectedClient(client); + } } } diff --git a/packages/3d-web-text-chat/src/chat-ui/TextChatUI.tsx b/packages/3d-web-text-chat/src/chat-ui/TextChatUI.tsx index a71dd6a2..ff70141c 100644 --- a/packages/3d-web-text-chat/src/chat-ui/TextChatUI.tsx +++ b/packages/3d-web-text-chat/src/chat-ui/TextChatUI.tsx @@ -15,7 +15,9 @@ export class TextChatUI { private appRef: React.RefObject = createRef(); public addTextMessage(username: string, message: string) { - if (this.appRef.current) this.appRef.current.addMessage(username, message); + if (this.appRef.current) { + this.appRef.current.addMessage(username, message); + } } private wrapper = document.createElement("div"); diff --git a/packages/3d-web-user-networking/src/UserData.ts b/packages/3d-web-user-networking/src/UserData.ts new file mode 100644 index 00000000..981fa765 --- /dev/null +++ b/packages/3d-web-user-networking/src/UserData.ts @@ -0,0 +1,6 @@ +import { CharacterDescription } from "./UserNetworkingMessages"; + +export type UserData = { + readonly username: string; + readonly characterDescription: CharacterDescription; +}; diff --git a/packages/3d-web-user-networking/src/UserNetworkingClient.ts b/packages/3d-web-user-networking/src/UserNetworkingClient.ts index bc80352c..05fc71fe 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingClient.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingClient.ts @@ -1,23 +1,41 @@ +import { ReconnectingWebSocket, WebsocketFactory, WebsocketStatus } from "./ReconnectingWebSocket"; +import { UserNetworkingClientUpdate, UserNetworkingCodec } from "./UserNetworkingCodec"; import { - CONNECTED_MESSAGE_TYPE, + CharacterDescription, DISCONNECTED_MESSAGE_TYPE, FromClientMessage, FromServerMessage, IDENTITY_MESSAGE_TYPE, PING_MESSAGE_TYPE, -} from "./messages"; -import { ReconnectingWebSocket, WebsocketFactory, WebsocketStatus } from "./ReconnectingWebSocket"; -import { UserNetworkingClientUpdate, UserNetworkingCodec } from "./UserNetworkingCodec"; + USER_AUTHENTICATE_MESSAGE_TYPE, + USER_PROFILE_MESSAGE_TYPE, +} from "./UserNetworkingMessages"; + +export type UserNetworkingClientConfig = { + url: string; + sessionToken: string; + websocketFactory: WebsocketFactory; + statusUpdateCallback: (status: WebsocketStatus) => void; + assignedIdentity: (clientId: number) => void; + clientUpdate: (id: number, update: null | UserNetworkingClientUpdate) => void; + clientProfileUpdated: ( + id: number, + username: string, + characterDescription: CharacterDescription, + ) => void; +}; export class UserNetworkingClient extends ReconnectingWebSocket { - constructor( - url: string, - websocketFactory: WebsocketFactory, - statusUpdateCallback: (status: WebsocketStatus) => void, - private setIdentityCallback: (id: number) => void, - private clientUpdate: (id: number, update: null | UserNetworkingClientUpdate) => void, - ) { - super(url, websocketFactory, statusUpdateCallback); + constructor(private config: UserNetworkingClientConfig) { + super(config.url, config.websocketFactory, (status: WebsocketStatus) => { + if (status === WebsocketStatus.Connected) { + this.sendMessage({ + type: USER_AUTHENTICATE_MESSAGE_TYPE, + sessionToken: config.sessionToken, + }); + } + config.statusUpdateCallback(status); + }); } public sendUpdate(update: UserNetworkingClientUpdate): void { @@ -25,31 +43,36 @@ export class UserNetworkingClient extends ReconnectingWebSocket { this.send(encodedUpdate); } + public sendMessage(message: FromClientMessage): void { + this.send(message); + } + protected handleIncomingWebsocketMessage(message: MessageEvent) { if (typeof message.data === "string") { const parsed = JSON.parse(message.data) as FromServerMessage; switch (parsed.type) { - case IDENTITY_MESSAGE_TYPE: - console.log(`Assigned ID: ${parsed.id}`); - this.setIdentityCallback(parsed.id); - break; - case CONNECTED_MESSAGE_TYPE: - console.log(`Client ID: ${parsed.id} joined`); - break; case DISCONNECTED_MESSAGE_TYPE: console.log(`Client ID: ${parsed.id} left`); - this.clientUpdate(parsed.id, null); + this.config.clientUpdate(parsed.id, null); + break; + case IDENTITY_MESSAGE_TYPE: + console.log(`Client ID: ${parsed.id} assigned to self`); + this.config.assignedIdentity(parsed.id); + break; + case USER_PROFILE_MESSAGE_TYPE: + console.log(`Client ID: ${parsed.id} updated profile`); + this.config.clientProfileUpdated(parsed.id, parsed.username, parsed.characterDescription); break; case PING_MESSAGE_TYPE: { - this.send({ type: "pong" } as FromClientMessage); + this.sendMessage({ type: "pong" } as FromClientMessage); break; } default: - console.warn("unknown message type received", parsed); + console.error("Unhandled message", parsed); } } else if (message.data instanceof ArrayBuffer) { const userNetworkingClientUpdate = UserNetworkingCodec.decodeUpdate(message.data); - this.clientUpdate(userNetworkingClientUpdate.id, userNetworkingClientUpdate); + this.config.clientUpdate(userNetworkingClientUpdate.id, userNetworkingClientUpdate); } else { console.error("Unhandled message type", message.data); } diff --git a/packages/3d-web-user-networking/src/UserNetworkingMessages.ts b/packages/3d-web-user-networking/src/UserNetworkingMessages.ts new file mode 100644 index 00000000..8d9bd9c9 --- /dev/null +++ b/packages/3d-web-user-networking/src/UserNetworkingMessages.ts @@ -0,0 +1,73 @@ +export const DISCONNECTED_MESSAGE_TYPE = "disconnected"; +export const IDENTITY_MESSAGE_TYPE = "identity"; +export const USER_AUTHENTICATE_MESSAGE_TYPE = "user_auth"; +export const USER_PROFILE_MESSAGE_TYPE = "user_profile"; +export const USER_UPDATE_MESSAGE_TYPE = "user_update"; +export const PING_MESSAGE_TYPE = "ping"; +export const PONG_MESSAGE_TYPE = "pong"; + +export type IdentityMessage = { + type: typeof IDENTITY_MESSAGE_TYPE; + id: number; +}; + +export type CharacterDescription = { + meshFileUrl?: string; + mmlCharacterUrl?: string; + mmlCharacterString?: string; +} & ( + | { + meshFileUrl: string; + } + | { + mmlCharacterUrl: string; + } + | { + mmlCharacterString: string; + } +); + +export type UserProfileMessage = { + type: typeof USER_PROFILE_MESSAGE_TYPE; + id: number; + username: string; + characterDescription: CharacterDescription; +}; + +export type DisconnectedMessage = { + type: typeof DISCONNECTED_MESSAGE_TYPE; + id: number; +}; + +export type FromServerPingMessage = { + type: typeof PING_MESSAGE_TYPE; +}; + +export type FromServerMessage = + | IdentityMessage + | UserProfileMessage + | DisconnectedMessage + | FromServerPingMessage; + +export type FromClientPongMessage = { + type: typeof PONG_MESSAGE_TYPE; +}; + +export type UserIdentity = { + characterDescription: CharacterDescription | null; + username: string | null; +}; + +export type UserAuthenticateMessage = { + type: typeof USER_AUTHENTICATE_MESSAGE_TYPE; + sessionToken: string; + // The client can send a UserIdentity to use as the initial user profile and the server can choose to accept it or not + userIdentity?: UserIdentity; +}; + +export type UserUpdateMessage = { + type: typeof USER_UPDATE_MESSAGE_TYPE; + userIdentity: UserIdentity; +}; + +export type FromClientMessage = FromClientPongMessage | UserAuthenticateMessage | UserUpdateMessage; diff --git a/packages/3d-web-user-networking/src/UserNetworkingServer.ts b/packages/3d-web-user-networking/src/UserNetworkingServer.ts index 9e364b34..d52c2297 100644 --- a/packages/3d-web-user-networking/src/UserNetworkingServer.ts +++ b/packages/3d-web-user-networking/src/UserNetworkingServer.ts @@ -1,141 +1,304 @@ import WebSocket from "ws"; +import { heartBeatRate, packetsUpdateRate, pingPongRate } from "./user-networking-settings"; +import { UserData } from "./UserData"; +import { UserNetworkingClientUpdate, UserNetworkingCodec } from "./UserNetworkingCodec"; import { - CONNECTED_MESSAGE_TYPE, DISCONNECTED_MESSAGE_TYPE, FromClientMessage, FromServerMessage, IDENTITY_MESSAGE_TYPE, -} from "./messages"; -import { heartBeatRate, packetsUpdateRate, pingPongRate } from "./user-networking-settings"; -import { UserNetworkingClientUpdate, UserNetworkingCodec } from "./UserNetworkingCodec"; + PONG_MESSAGE_TYPE, + USER_AUTHENTICATE_MESSAGE_TYPE, + USER_PROFILE_MESSAGE_TYPE, + USER_UPDATE_MESSAGE_TYPE as USER_UPDATE_MESSAGE_TYPE, + UserAuthenticateMessage, + UserIdentity, + UserUpdateMessage, +} from "./UserNetworkingMessages"; export type Client = { socket: WebSocket; + id: number; + lastPong: number; update: UserNetworkingClientUpdate; + authenticatedUser: UserData | null; }; const WebSocketOpenStatus = 1; +export type UserNetworkingServerOptions = { + onClientConnect: ( + clientId: number, + sessionToken: string, + userIdentity?: UserIdentity, + ) => UserData | null; + onClientUserIdentityUpdate: (clientId: number, userIdentity: UserIdentity) => UserData | null; + onClientDisconnect: (clientId: number) => void; +}; + export class UserNetworkingServer { - private clients: Map = new Map(); - private clientLastPong: Map = new Map(); + private allClients = new Set(); + private clientsById: Map = new Map(); - constructor() { + constructor(private options: UserNetworkingServerOptions) { setInterval(this.sendUpdates.bind(this), packetsUpdateRate); setInterval(this.pingClients.bind(this), pingPongRate); setInterval(this.heartBeat.bind(this), heartBeatRate); } - heartBeat() { + private heartBeat() { const now = Date.now(); - this.clientLastPong.forEach((clientLastPong, id) => { - if (now - clientLastPong > heartBeatRate) { - this.clients.delete(id); - this.clientLastPong.delete(id); - const disconnectMessage = JSON.stringify({ - id, - type: DISCONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - for (const { socket: otherSocket } of this.clients.values()) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(disconnectMessage); - } - } + this.allClients.forEach((client) => { + if (now - client.lastPong > heartBeatRate) { + client.socket.close(); + this.handleDisconnectedClient(client); } }); } - pingClients() { - this.clients.forEach((client) => { + private pingClients() { + this.clientsById.forEach((client) => { if (client.socket.readyState === WebSocketOpenStatus) { client.socket.send(JSON.stringify({ type: "ping" } as FromServerMessage)); } }); } - getId(): number { + private getId(): number { let id = 1; - while (this.clients.has(id)) id++; + while (this.clientsById.has(id)) { + id++; + } return id; } - connectClient(socket: WebSocket) { + public connectClient(socket: WebSocket) { const id = this.getId(); - console.log(`Client ID: ${id} joined`); + console.log(`Client ID: ${id} joined, waiting for user-identification`); - const connectMessage = JSON.stringify({ + // Create a client but without user-information + const client: Client = { id, - type: CONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - for (const { socket: otherSocket } of this.clients.values()) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(connectMessage); - } - } - - const identityMessage = JSON.stringify({ - id, - type: IDENTITY_MESSAGE_TYPE, - } as FromServerMessage); - socket.send(identityMessage); - - for (const { update } of this.clients.values()) { - socket.send(UserNetworkingCodec.encodeUpdate(update)); - } - - this.clients.set(id, { + lastPong: Date.now(), socket: socket as WebSocket, + authenticatedUser: null, update: { id, position: { x: 0, y: 0, z: 0 }, rotation: { quaternionY: 0, quaternionW: 1 }, state: 0, }, - }); + }; + this.allClients.add(client); + this.clientsById.set(id, client); socket.on("message", (message: WebSocket.Data, _isBinary: boolean) => { if (message instanceof Buffer) { const arrayBuffer = new Uint8Array(message).buffer; const update = UserNetworkingCodec.decodeUpdate(arrayBuffer); update.id = id; - if (this.clients.get(id) !== undefined) { - this.clients.get(id)!.update = update; - } + client.update = update; } else { + let parsed; try { - const data = JSON.parse(message as string) as FromClientMessage; - if (data.type === "pong") { - this.clientLastPong.set(id, Date.now()); - } + parsed = JSON.parse(message as string) as FromClientMessage; } catch (e) { console.error("Error parsing JSON message", message, e); + return; + } + if (!client.authenticatedUser) { + if (parsed.type === USER_AUTHENTICATE_MESSAGE_TYPE) { + if (!this.handleUserAuth(id, parsed)) { + // If the user is not authorized, disconnect the client + socket.close(); + } + } else { + console.error(`Unhandled message pre-auth: ${JSON.stringify(parsed)}`); + socket.close(); + } + } else { + switch (parsed.type) { + case PONG_MESSAGE_TYPE: + client.lastPong = Date.now(); + break; + + case USER_UPDATE_MESSAGE_TYPE: + this.handleUserUpdate(id, parsed as UserUpdateMessage); + break; + + default: + console.error(`Unhandled message: ${JSON.stringify(parsed)}`); + } } } }); socket.on("close", () => { console.log("Client disconnected", id); - this.clients.delete(id); - const disconnectMessage = JSON.stringify({ - id, - type: DISCONNECTED_MESSAGE_TYPE, - } as FromServerMessage); - for (const [clientId, { socket: otherSocket }] of this.clients) { - if (otherSocket.readyState === WebSocketOpenStatus) { - otherSocket.send(disconnectMessage); - } - } + this.handleDisconnectedClient(client); }); } - sendUpdates(): void { - for (const [clientId, client] of this.clients) { + private handleDisconnectedClient(client: Client) { + if (!this.allClients.has(client)) { + return; + } + if (client.authenticatedUser !== null) { + // Only report disconnections of clients that were authenticated + this.options.onClientDisconnect(client.id); + } + this.clientsById.delete(client.id); + this.allClients.delete(client); + const disconnectMessage = JSON.stringify({ + id: client.id, + type: DISCONNECTED_MESSAGE_TYPE, + } as FromServerMessage); + for (const otherClient of this.allClients) { + if ( + otherClient.authenticatedUser !== null && + otherClient.socket.readyState === WebSocketOpenStatus + ) { + otherClient.socket.send(disconnectMessage); + } + } + } + + private handleUserAuth(clientId: number, credentials: UserAuthenticateMessage): boolean { + const userData = this.options.onClientConnect( + clientId, + credentials.sessionToken, + credentials.userIdentity, + ); + if (!userData) { + console.error(`Client-id ${clientId} user_auth unauthorized and ignored`); + return false; + } + + const client = this.clientsById.get(clientId); + if (!client) { + console.error(`Client-id ${clientId}, client not found`); + return false; + } + + console.log("Client authenticated", clientId, userData); + client.authenticatedUser = userData; + + const identityMessage = JSON.stringify({ + id: clientId, + type: IDENTITY_MESSAGE_TYPE, + } as FromServerMessage); + + const userProfileMessage = JSON.stringify({ + id: clientId, + type: USER_PROFILE_MESSAGE_TYPE, + username: userData.username, + characterDescription: userData.characterDescription, + } as FromServerMessage); + + client.socket.send(userProfileMessage); + client.socket.send(identityMessage); + + const userUpdateMessage = UserNetworkingCodec.encodeUpdate(client.update); + + // Send information about all other clients to the freshly connected client and vice versa + for (const otherClient of this.clientsById.values()) { + if ( + otherClient.socket.readyState !== WebSocketOpenStatus || + otherClient.authenticatedUser == null || + otherClient === client + ) { + // Do not send updates for any clients which have not yet authenticated or not yet connected + continue; + } + // Send the character information + client.socket.send( + JSON.stringify({ + id: otherClient.update.id, + type: USER_PROFILE_MESSAGE_TYPE, + username: otherClient.authenticatedUser?.username, + characterDescription: otherClient.authenticatedUser?.characterDescription, + } as FromServerMessage), + ); + client.socket.send(UserNetworkingCodec.encodeUpdate(otherClient.update)); + + otherClient.socket.send(userProfileMessage); + otherClient.socket.send(userUpdateMessage); + } + + console.log("Client authenticated", clientId); + + return true; + } + + public updateUserCharacter(clientId: number, userData: UserData) { + this.internalUpdateUser(clientId, userData); + } + + private internalUpdateUser(clientId: number, userData: UserData) { + // This function assumes authorization has already been done + const client = this.clientsById.get(clientId)!; + + client.authenticatedUser = userData; + this.clientsById.set(clientId, client); + + const newUserData = JSON.stringify({ + id: clientId, + type: USER_PROFILE_MESSAGE_TYPE, + username: userData.username, + characterDescription: userData.characterDescription, + } as FromServerMessage); + + // Broadcast the new userdata to all sockets, INCLUDING the user of the calling socket + // Clients will always render based on the public userProfile. + // This makes it intuitive, as it is "what you see is what other's see" from a user's perspective. + for (const [otherClientId, otherClient] of this.clientsById) { + if (!otherClient.authenticatedUser) { + // Do not send updates for any clients which have no user yet + continue; + } + if (otherClient.socket.readyState === WebSocketOpenStatus) { + otherClient.socket.send(newUserData); + } + } + } + + private handleUserUpdate(clientId: number, message: UserUpdateMessage): void { + const client = this.clientsById.get(clientId); + if (!client) { + console.error(`Client-id ${clientId} user_update ignored, client not found`); + return; + } + + // Verify using the user authenticator what the allowed version of this update is + const authorizedUserData = this.options.onClientUserIdentityUpdate( + clientId, + message.userIdentity, + ); + if (!authorizedUserData) { + // TODO - inform the client about the unauthorized update + console.warn(`Client-id ${clientId} user_update unauthorized and ignored`); + return; + } + + this.internalUpdateUser(clientId, authorizedUserData); + } + + private sendUpdates(): void { + for (const [clientId, client] of this.clientsById) { + if (!client.authenticatedUser) { + // Do not send updates about unauthenticated clients + continue; + } const update = client.update; const encodedUpdate = UserNetworkingCodec.encodeUpdate(update); - for (const [otherClientId, otherClient] of this.clients) { - if (otherClientId !== clientId && otherClient.socket.readyState === WebSocketOpenStatus) { + for (const [otherClientId, otherClient] of this.clientsById) { + if ( + otherClient.authenticatedUser !== null && + otherClientId !== clientId && + otherClient.socket.readyState === WebSocketOpenStatus + ) { otherClient.socket.send(encodedUpdate); } } diff --git a/packages/3d-web-user-networking/src/index.ts b/packages/3d-web-user-networking/src/index.ts index 6c5ad25c..a76eb52e 100644 --- a/packages/3d-web-user-networking/src/index.ts +++ b/packages/3d-web-user-networking/src/index.ts @@ -1,5 +1,6 @@ export * from "./UserNetworkingCodec"; export * from "./UserNetworkingServer"; export * from "./UserNetworkingClient"; +export * from "./UserData"; export * from "./ReconnectingWebSocket"; -export * from "./messages"; +export * from "./UserNetworkingMessages"; diff --git a/packages/3d-web-user-networking/src/messages.ts b/packages/3d-web-user-networking/src/messages.ts deleted file mode 100644 index 6202183f..00000000 --- a/packages/3d-web-user-networking/src/messages.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const CONNECTED_MESSAGE_TYPE = "connected"; -export const DISCONNECTED_MESSAGE_TYPE = "disconnected"; -export const IDENTITY_MESSAGE_TYPE = "identity"; -export const PING_MESSAGE_TYPE = "ping"; -export const PONG_MESSAGE_TYPE = "pong"; - -export type ConnectedMessage = { - type: typeof CONNECTED_MESSAGE_TYPE; - id: number; -}; - -export type IdentityMessage = { - type: typeof IDENTITY_MESSAGE_TYPE; - id: number; -}; - -export type DisconnectedMessage = { - type: typeof DISCONNECTED_MESSAGE_TYPE; - id: number; -}; - -export type FromServerPingMessage = { - type: typeof PING_MESSAGE_TYPE; -}; - -export type FromServerMessage = - | ConnectedMessage - | IdentityMessage - | DisconnectedMessage - | FromServerPingMessage; - -export type FromClientPongMessage = { - type: typeof PONG_MESSAGE_TYPE; -}; - -export type FromClientMessage = FromClientPongMessage; diff --git a/packages/3d-web-user-networking/test/UserNetworking.test.ts b/packages/3d-web-user-networking/test/UserNetworking.test.ts index c3c8731b..2acbc54f 100644 --- a/packages/3d-web-user-networking/test/UserNetworking.test.ts +++ b/packages/3d-web-user-networking/test/UserNetworking.test.ts @@ -5,7 +5,7 @@ import express from "express"; import enableWs from "express-ws"; -import { UserNetworkingClientUpdate } from "../src"; +import { UserData, UserIdentity, UserNetworkingClientUpdate } from "../src"; import { WebsocketStatus } from "../src/ReconnectingWebSocket"; import { UserNetworkingClient } from "../src/UserNetworkingClient"; import { UserNetworkingServer } from "../src/UserNetworkingServer"; @@ -14,7 +14,37 @@ import { createWaitable, waitUntil } from "./test-utils"; describe("UserNetworking", () => { test("should see updates end-to-end", async () => { - const server = new UserNetworkingServer(); + const sessionTokenForOne = "session-token-one"; + const sessionTokenForTwo = "session-token-two"; + + const options = { + onClientConnect: ( + clientId: number, + sessionToken: string, + userIdentity?: UserIdentity, + ): UserData | null => { + if (sessionToken === sessionTokenForOne) { + return { + username: "user1", + characterDescription: { meshFileUrl: "http://example.com/user1.glb" }, + }; + } else if (sessionToken === sessionTokenForTwo) { + return { + username: "user2", + characterDescription: { meshFileUrl: "http://example.com/user2.glb" }, + }; + } + return null; + }, + onClientUserIdentityUpdate: ( + clientId: number, + userIdentity: UserIdentity, + ): UserData | null => { + return null; + }, + onClientDisconnect: (clientId: number): void => {}, + }; + const server = new UserNetworkingServer(options); const { app } = enableWs(express()); app.ws("/user-networking", (ws) => { @@ -30,62 +60,102 @@ describe("UserNetworking", () => { const [user2ConnectPromise, user2ConnectResolve] = await createWaitable(); const user1UserStates: Map = new Map(); - const user1 = new UserNetworkingClient( - serverAddress, - (url) => new WebSocket(url), - (status) => { + const user1Profiles: Map = new Map(); + const user1 = new UserNetworkingClient({ + url: serverAddress, + sessionToken: sessionTokenForOne, + websocketFactory: (url) => new WebSocket(url), + statusUpdateCallback: (status) => { if (status === WebsocketStatus.Connected) { user1ConnectResolve(null); } }, - (clientId: number) => { + assignedIdentity: (clientId: number) => { user1IdentityResolve(clientId); }, - (clientId: number, userNetworkingClientUpdate: null | UserNetworkingClientUpdate) => { + clientUpdate: ( + clientId: number, + userNetworkingClientUpdate: null | UserNetworkingClientUpdate, + ) => { if (userNetworkingClientUpdate === null) { user1UserStates.delete(clientId); } else { user1UserStates.set(clientId, userNetworkingClientUpdate); } }, - ); + clientProfileUpdated: (id, username, characterDescription) => { + user1Profiles.set(id, { username, characterDescription }); + }, + }); await user1ConnectPromise; expect(await user1IdentityPromise).toEqual(1); await waitUntil( - () => (server as any).clients.size === 1, + () => (server as any).allClients.size === 1, "wait for server to see the presence of user 1", ); + await waitUntil( + () => user1Profiles.size === 1, + "wait for user 1 to see their own profile returned from the server", + ); + + expect(user1Profiles.get(1)).toEqual({ + username: "user1", + characterDescription: { meshFileUrl: "http://example.com/user1.glb" }, + }); + const user2UserStates: Map = new Map(); - const user2 = new UserNetworkingClient( - serverAddress, - (url) => new WebSocket(url), - (status) => { + const user2Profiles: Map = new Map(); + const user2 = new UserNetworkingClient({ + url: serverAddress, + sessionToken: sessionTokenForTwo, + websocketFactory: (url) => new WebSocket(url), + statusUpdateCallback: (status) => { if (status === WebsocketStatus.Connected) { user2ConnectResolve(null); } }, - (clientId: number) => { + assignedIdentity: (clientId: number) => { user2IdentityResolve(clientId); }, - (clientId: number, userNetworkingClientUpdate: null | UserNetworkingClientUpdate) => { + clientUpdate: ( + clientId: number, + userNetworkingClientUpdate: null | UserNetworkingClientUpdate, + ) => { if (userNetworkingClientUpdate === null) { user2UserStates.delete(clientId); } else { user2UserStates.set(clientId, userNetworkingClientUpdate); } }, - ); + clientProfileUpdated: (id, username, characterDescription) => { + user2Profiles.set(id, { username, characterDescription }); + }, + }); await user2ConnectPromise; expect(await user2IdentityPromise).toEqual(2); await waitUntil( - () => (server as any).clients.size === 2, + () => (server as any).allClients.size === 2, "wait for server to see the presence of user 2", ); + await waitUntil( + () => user2Profiles.size === 2, + "wait for user 2 to see both profiles returned from the server", + ); + + expect(user2Profiles.get(1)).toEqual({ + username: "user1", + characterDescription: { meshFileUrl: "http://example.com/user1.glb" }, + }); + expect(user2Profiles.get(2)).toEqual({ + username: "user2", + characterDescription: { meshFileUrl: "http://example.com/user2.glb" }, + }); + user1.sendUpdate({ id: 1, position: { x: 1, y: 2, z: 3 }, @@ -139,7 +209,7 @@ describe("UserNetworking", () => { user2.stop(); await waitUntil( - () => (server as any).clients.size === 1, + () => (server as any).allClients.size === 1, "wait for server to see the removal of user 2", ); @@ -151,7 +221,7 @@ describe("UserNetworking", () => { user1.stop(); await waitUntil( - () => (server as any).clients.size === 0, + () => (server as any).allClients.size === 0, "wait for server to see the removal of user 1", ); diff --git a/playground.jpg b/playground.jpg new file mode 100644 index 00000000..50f0d350 Binary files /dev/null and b/playground.jpg differ diff --git a/playground.png b/playground.png deleted file mode 100644 index 502ce33f..00000000 Binary files a/playground.png and /dev/null differ diff --git a/utils/rebuildOnDependencyChangesPlugin.ts b/utils/rebuildOnDependencyChangesPlugin.ts new file mode 100644 index 00000000..38f6c424 --- /dev/null +++ b/utils/rebuildOnDependencyChangesPlugin.ts @@ -0,0 +1,43 @@ +import { spawn } from "child_process"; +import { createRequire } from "node:module"; + +import { PluginBuild } from "esbuild"; +import kill from "tree-kill"; + +let runningProcess: ReturnType | undefined; + +export const rebuildOnDependencyChangesPlugin = { + name: "watch-dependencies", + setup(build: PluginBuild) { + build.onResolve({ filter: /.*/ }, (args) => { + // Include dependent packages in the watch list + if (args.kind === "import-statement") { + if (!args.path.startsWith(".")) { + const require = createRequire(args.resolveDir); + let resolved; + try { + resolved = require.resolve(args.path); + } catch (e) { + return; + } + return { + watchFiles: [resolved], + }; + } + } + }); + build.onEnd(async () => { + console.log("Build finished. (Re)starting process"); + if (runningProcess) { + await new Promise((resolve) => { + kill(runningProcess!.pid!, "SIGTERM", (err) => { + resolve(); + }); + }); + } + runningProcess = spawn("npm", ["run", "iterate:start"], { + stdio: "inherit", + }); + }); + }, +};