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",
+ });
+ });
+ },
+};