commit ad789c45172d8774b0fedc5523fcacb30f408308 Author: ZZY <2450266535@qq.com> Date: Thu Jun 27 14:53:43 2024 +0800 init 一个游戏服务器的后端demo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1419b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +package-lock.json +dist/* +.*/ +.env \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..f499d4f --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ws-server", + "version": "1.0.0", + "main": "server.ts", + "scripts": { + "build": "tsc && node dist/server.js", + "start": "ts-node ./src/main", + "test": "ts-node ./src/main", + "err": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.2", + "ws": "^8.17.0" + }, + "devDependencies": { + "@tsconfig/node20": "^20.1.4", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.2", + "@types/ws": "^8.5.10", + "ts-node": "^10.9.2", + "typescript": "^5.5.2" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..528250d --- /dev/null +++ b/server.ts @@ -0,0 +1,15 @@ +import { IncomingMessage } from "http"; +import { RawData, WebSocket } from "ws"; +import RoomProto from "./src/RoomProto/RoomProto"; +import Manager from "./src/RoomProto/Manager"; +import SecurityManager from "./src/RoomProto/utils/secure"; + +const manager = new Manager("server"); + +export default function connectHandle(ws: WebSocket, req: IncomingMessage) { + const ip = req.socket.remoteAddress; + console.log(`Client connected ${ip}`); + + let globalId: string = SecurityManager.generateId(); + let func = new RoomProto(globalId, ws, manager); +} diff --git a/src/RoomProto/Manager.ts b/src/RoomProto/Manager.ts new file mode 100644 index 0000000..f338be9 --- /dev/null +++ b/src/RoomProto/Manager.ts @@ -0,0 +1,87 @@ +import WebSocket from "ws"; +import { UserContainer, UserGroup } from "./utils/user"; +import { User, userIdType } from "./utils/user"; +import SecurityManager from "./utils/secure"; + +export default class Manager { + private security = new SecurityManager(); + private users = new UserContainer(); + private sessions = new Map(); + private regions = new Map(); + constructor(private serverName: string) { + this.regions.set(this.serverName, new UserGroup(this.users)); + } + + generateId(): string { + return SecurityManager.generateId(); + } + + createSession(name:string, + maxUsers: number, + sessionId: string = SecurityManager.generateId()): UserGroup { + var group = new UserGroup(this.users, name, maxUsers); + this.sessions.set(sessionId, group); + return group; + } + + getSession(sessionId: string) { + return this.sessions.get(sessionId); + } + + waitForSession(sessionId: string, userId: userIdType): boolean | undefined { + if (this.addSession(sessionId, userId) === false) { + return false; + } + let session = this.sessions.get(sessionId); + if (session?.getNowUsers() !== session?.getMaxUsers()) { + return undefined; + } + return true; + } + + addSession(sessionId: string, userId: userIdType): boolean { + let session = this.sessions.get(sessionId); + if (session === undefined) { + return false; + } + return session.addUser(userId); + } + + removeSession(sessionId: string): boolean { + return this.sessions.delete(sessionId); + } + + generateUser(name: string, fingerprint: string, ws: WebSocket, + userId: string = SecurityManager.generateId()) { + const user = new User(name, + this.security.generateToken(userId, fingerprint), ws); + this.users.addUser(userId, user); + return { userId, user }; + } + + getUser(id: userIdType) { + return this.users.getUser(id); + } + + addToRegion(id: userIdType, token: string, regionId: string): boolean { + if (token === undefined || token === null) { + return false; + } + if (this.security.verifyToken(token) == null) { + return false; + } + return this.regions.get(regionId)?.addUser(id) !== undefined; + } + + getIterRegion(regionId: string): IterableIterator<[userIdType,User]> | undefined { + return this.regions.get(regionId)?.getUsersIterator(); + } + + getIterRegions(): IterableIterator<[string,UserGroup]> { + return this.regions.entries(); + } + + removeUser(id: userIdType) { + this.users.removeUser(id); + } +} \ No newline at end of file diff --git a/src/RoomProto/RoomProto.ts b/src/RoomProto/RoomProto.ts new file mode 100644 index 0000000..206ec0e --- /dev/null +++ b/src/RoomProto/RoomProto.ts @@ -0,0 +1,42 @@ +import WebSocket from "ws"; +import { RPMessage } from "./utils/type"; +import BaseProto from "./type/BaseProto"; +import Manager from "./Manager"; +import UserProto from "./type/UserProto"; +import RegionProto from "./type/RegionProto"; +import MsgProto from "./type/MsgProto"; +import SessionProto from "./type/SessionProto"; + +export default class RoomProto extends BaseProto { + user = new UserProto(this.id, this.ws, this.manager); + region = new RegionProto(this.id, this.ws, this.manager); + constructor(id: string, ws: WebSocket, manager: Manager) { + super(id, ws, manager); + this.setupMsgListener(); + ws.on('close', this.onDisconnect.bind(this)); + ws.on('error', console.error); + } + + override handleMessage(msg: RPMessage) { + console.log(msg); + switch (msg.type) { + case "user": + this.user.handleMessage(msg); + break; + case "region": + this.region.handleMessage(msg); + break; + case "session": + new SessionProto(this.id, this.ws, this.manager).handleMessage(msg); + break; + case "msg": + new MsgProto(this.id, this.ws, this.manager).handleMessage(msg); + break; + default: + return this.ws.send(JSON.stringify({ + code: '0001', + data: 'Unknown Type' + })); + } + } +} \ No newline at end of file diff --git a/src/RoomProto/type/BaseProto.ts b/src/RoomProto/type/BaseProto.ts new file mode 100644 index 0000000..e59fc7d --- /dev/null +++ b/src/RoomProto/type/BaseProto.ts @@ -0,0 +1,44 @@ +import WebSocket, { RawData } from "ws"; +import { RPMessage } from "../utils/type"; +import Manager from "../Manager"; + +export default abstract class ProtocolBase { + + constructor(protected id: string, + protected ws: WebSocket, + protected manager: Manager) { + } + + abstract handleMessage(message: RPMessage): void; + + protected setupMsgListener() { + this.ws.on('message', (message: RawData) => { + try { + const json = JSON.parse(message.toString()); + this.handleMessage(json); + } catch (error) { + console.error('Error parsing message:', error); + this.sendError('0002', 'Invalid message format'); + } + }); + } + + protected send(data: any) { + this.ws.send(JSON.stringify(data)); + } + + protected sendError(code: string, message: string) { + this.send({ code, data: {"_": message} }); + } + + protected sendUnknownCommand() { + this.sendError('0003', 'Unknown command'); + } + + public onDisconnect() { + // 处理断开连接的逻辑 + if (this.id === null) return; + this.manager.removeUser(this.id); + console.log(`Client disconnected ${this.id}`); + } +} \ No newline at end of file diff --git a/src/RoomProto/type/MsgProto.ts b/src/RoomProto/type/MsgProto.ts new file mode 100644 index 0000000..2e4a2c7 --- /dev/null +++ b/src/RoomProto/type/MsgProto.ts @@ -0,0 +1,20 @@ +import { RPMessage } from "../utils/type"; +import BaseProto from "./BaseProto"; + +export default class MsgProto extends BaseProto { + override handleMessage(msg: RPMessage) { + switch (msg.cmd) { + case "echo": + this.sendRes("echo", "0000", msg.data); + break; + default: + this.sendUnknownCommand(); + break; + } + } + + private sendRes(cmd: string, code: string, data: any) { + console.log(cmd, data); + this.send({ type:'msg', cmd, code, data}); + } +} \ No newline at end of file diff --git a/src/RoomProto/type/RegionProto.ts b/src/RoomProto/type/RegionProto.ts new file mode 100644 index 0000000..0d2476a --- /dev/null +++ b/src/RoomProto/type/RegionProto.ts @@ -0,0 +1,81 @@ +import { RPMessage } from "../utils/type"; +import BaseProto from "./BaseProto"; + +export default class RegionProto extends BaseProto { + override handleMessage(msg: RPMessage): void { + if (msg.uid === undefined) { + return this.sendError("0010", "uid is required"); + } + + switch (msg.cmd) { + case "list": + this.list(msg.uid, msg.data); + break; + case "inspect": + this.inspect(msg.uid, msg.data); + break; + case "add": + this.add(msg.uid, msg.token, msg.data); + break; + case "remove": + this.remove(msg.uid, msg.data); + break; + case "create": + this.create(msg.uid, msg.data); + break; + default: + return this.sendUnknownCommand(); + } + } + + private add(uid: string, token: string | undefined, data: any) { + if (token === undefined) { + return this.sendError("0010", "token is required"); + } + if (this.manager.addToRegion(uid, token, data.regionId)) { + this.sendRes("add", "0000", undefined); + } else { + this.sendError("0011", "region not found"); + } + } + + private remove(uid: string, data: any) { + throw new Error("Method not implemented."); + } + + private list(uid: string, data: any) { + const usersData = []; + for (const [regionId, UserGroup] of this.manager.getIterRegions()) { + usersData.push({ id: regionId, name: UserGroup.getName(), + nowUsers: UserGroup.getNowUsers(), + maxUsers: UserGroup.getMaxUsers(), + }); + } + this.sendRes("list", "0000", { "_": usersData }); + } + + private inspect(uid: string, data: any) { + if (data?.regionId === undefined) { + return this.sendError("0010", "regionId is required"); + } + + const region = this.manager.getIterRegion(data.regionId); + if (region === undefined) { + return this.sendError("0011", "region not found"); + } + const usersData = []; + for (const [userId, User] of region) { + usersData.push({ id: userId, name: User.getName(), + }); + } + console.debug("inspect: ", usersData); + this.sendRes("inspect", "0000", { "_": usersData }); + } + + private create(uid: string, data: any) { + } + + private sendRes(cmd: string, code: string, data: any) { + this.send({ type:'region', cmd, code, data}); + } +} \ No newline at end of file diff --git a/src/RoomProto/type/SessionProto.ts b/src/RoomProto/type/SessionProto.ts new file mode 100644 index 0000000..a97dd64 --- /dev/null +++ b/src/RoomProto/type/SessionProto.ts @@ -0,0 +1,136 @@ +import { RPMessage } from "../utils/type"; +import BaseProto from "./BaseProto"; + +export default class SessionProto extends BaseProto { + override handleMessage(msg: RPMessage): void { + if (msg.uid === undefined) { + return this.sendError("0010", "uid is required"); + } + + switch (msg.cmd) { + case "create": + this.create(msg.uid, msg.token, msg.data); + break; + case "ackCreate": + this.ackCreate(msg.uid, msg.code, msg.data); + break; + case "exit": + this.exit(msg.uid, msg.token, msg.data); + break; + case "sendAll": + this.sendAll(msg.uid, msg.token, msg.data); + break; + case "res": + this.res(msg.cmd, msg.code, msg.data); + break; + default: + this.sendUnknownCommand(); + } + } + res(cmd: string, code: string | undefined, data: any) { + throw new Error("Method not implemented."); + } + + ackCreate(uid: string, code: string | undefined, data: any) { + let session = this.manager.getSession(data.sessionId); + if (code !== "0000") { + this.manager.removeSession(data.sessionId); + return session?.sendToAll(JSON.stringify({ + type: "session", + cmd: "ackCreate", + code: "0000", + data: { res: false, sessionId: data.sessionId } + })); + } + + let res = this.manager.waitForSession(data.sessionId, uid); + if (res === false) { + this.manager.removeSession(data.sessionId); + return session?.sendToAll(JSON.stringify({ + type: "session", + cmd: "ackCreate", + code: "0000", + data: { res: false, sessionId: data.sessionId } + })); + } else if (res === undefined) { + return; + } + session?.sendToAll(JSON.stringify({ + type: "session", + cmd: "ackCreate", + code: "0000", + data: { + res: true, + sessionId: data.sessionId, + } + })); + } + + create(uid: string, token: string | undefined, data: any) { + const usersId = data._ as string[] + if (usersId === undefined) { + return this.sendError("0011", "usersId is required"); + } + let sessionid = this.manager.generateId(); + for (let id of usersId) { + console.debug("id: ", id); + let user = this.manager.getUser(id) + if (user === undefined) { + return this.sendError("0011", "user not found"); + } + user.send(JSON.stringify({ + type: "session", + cmd: "ackCreate", + code: "0000", + data: { + sessionId: sessionid, + reqUserId: uid, + reqUserName: user.getName(), + } + })); + } + this.manager.getUser(uid)?.send(JSON.stringify({ + type: "session", + cmd: "create", + code: "0000", + })); + + var group = this.manager.createSession(uid, usersId.length + 1, sessionid); + this.manager.addSession(sessionid, uid); + group.on('GroupUserRemove', () => { + group.sendToAll(JSON.stringify({ + type: "session", + cmd: "exit", + code: "0000", + data: { "_": "sessionDelete" } + })); + this.manager.removeSession(sessionid); + }); + } + + private sendRes(cmd: string, code: string, data: any) { + this.send({ type:'session', cmd, code, data}); + } + + sendAll(uid: string, token: string | undefined, data: any) { + var session = this.manager.getSession(data.sessionId); + if (session == null) { + this.sendRes("sendAll", "0404", { "msg": "session not found" }); + return; + } + session.sendToAll(JSON.stringify({ + type: "session", + cmd: "sendAll", + code: "0000", + data: { + sessionId: data.sessionId, + reqUserId: uid, + reqUserName: this.manager.getUser(uid)?.getName(), + msg: data.msg, + } + })); + } + exit(uid: string, token: string | undefined, data: any) { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/RoomProto/type/UserProto.ts b/src/RoomProto/type/UserProto.ts new file mode 100644 index 0000000..c99d846 --- /dev/null +++ b/src/RoomProto/type/UserProto.ts @@ -0,0 +1,73 @@ +import { RPMessage } from "../utils/type"; +import BaseProto from "./BaseProto"; + +export default class UserProto extends BaseProto { + override handleMessage(msg: RPMessage) { + if (msg.cmd === "init") { + this.init(msg.data); + return; + } + + if (msg.uid === undefined) { + return this.sendError("0010", "uid is required"); + } + switch (msg.cmd) { + case "login": + this.login(msg.uid, msg.data); + break; + case "logout": + this.logout(msg.uid, msg.data); + break; + case "rename": + this.rename(msg.uid, msg.data); + break; + case "exit": + this.exit(msg.uid, msg.token??"token", msg.data); + break; + default: + return this.sendUnknownCommand(); + } + } + private exit(uid: string, token: string, data: any) { + let user = this.manager.getUser(uid); + if (user === undefined) { + return; + } + if (token != user.getToken()) { + return; + } + this.manager.removeUser(uid); + } + + private rename(uid: string, data: any) { + let user = this.manager.getUser(uid); + if (user === undefined) { + return; + } + user.setName(data._ ? data._: user.getName()); + this.sendRes('rename', '0000', {"_": data}); + } + + private sendRes(cmd: string, code: string, data: any) { + this.send({ type:'user', cmd, code, data}); + } + + private login(uid: string, data: any) { + throw new Error("Method not implemented."); + } + private logout(uid: string, data: any) { + throw new Error("Method not implemented."); + } + private init(data: any) { + const fingerPrint = data?.fingerprint || "Godot Game"; + const userName = data?.name || 'unknown name'; + const { user } = this.manager.generateUser(userName, fingerPrint, + this.ws, this.id); + console.debug(`init user : ${this.id} ${userName}`); + this.sendRes('init', '0000', { + userId: this.id, + userName, + token: user.getToken(), + }); + } +} \ No newline at end of file diff --git a/src/RoomProto/utils/secure.ts b/src/RoomProto/utils/secure.ts new file mode 100644 index 0000000..1df16f6 --- /dev/null +++ b/src/RoomProto/utils/secure.ts @@ -0,0 +1,42 @@ +import { randomBytes, randomUUID } from "crypto"; +import jwt from "jsonwebtoken"; + +export default class SecurityManager { + private secretKey: string; // 用于JWT签名的密钥,请确保在生产环境中妥善保管 + + constructor(secret?: string) { + this.secretKey = secret ?? SecurityManager.generateSecretKey(); + } + + // 生成ID + static generateId(): string { + return randomUUID(); + } + + static generateSecretKey(byteLen: number = 32): string { + return randomBytes(byteLen).toString('hex'); + } + + // 生成Token + generateToken(userId: string, fingerprint: string, additionalInfo?: any): string { + const payload = { + userId, + fingerprint, + ...additionalInfo, + iat: Math.floor(Date.now() / 1000), // 发行时间 + exp: Math.floor(Date.now() / 1000) + (60 * 60), // 过期时间,例如1小时后 + }; + return jwt.sign(payload, this.secretKey); + } + + // 验证Token + verifyToken(token: string): any | null { + try { + const decoded = jwt.verify(token, this.secretKey); + return decoded; + } catch (error) { + console.error("Token verification failed:", error); + return null; + } + } +} \ No newline at end of file diff --git a/src/RoomProto/utils/type.ts b/src/RoomProto/utils/type.ts new file mode 100644 index 0000000..4beb2b7 --- /dev/null +++ b/src/RoomProto/utils/type.ts @@ -0,0 +1,24 @@ +type user_id = string; +type token = string; + +export type user = { + id: user_id; + name: string; + token: token; +} + +export interface JsonHead { + type: string; + action?: string; + id?: string; + token?: string; +} + +export interface RPMessage { + type: string; + cmd: string; + uid?: string; + token?: string; + data?: any; + code?: string; +} \ No newline at end of file diff --git a/src/RoomProto/utils/user.ts b/src/RoomProto/utils/user.ts new file mode 100644 index 0000000..e3eddcb --- /dev/null +++ b/src/RoomProto/utils/user.ts @@ -0,0 +1,152 @@ +import WebSocket from "ws"; +import EventEmitter from "events"; + +export type userIdType = string; +type userNameType = string; +type userTokenType = string; + +export class User { + private name: userNameType; + private token: userTokenType; + private ws: WebSocket; + + constructor(name: userNameType, token: userTokenType, ws: WebSocket) { + this.name = name; + this.ws = ws; + this.token = token; + } + + dispose() { + this.ws.close(); + } + + handleMessage(message: string) { + console.log(`${this.getName()} 收到消息: ${message}`); + // 这里可以是更复杂的逻辑处理消息 + } + + send(message: string) { + this.ws.send(message); + } + + getName(): userNameType { + return this.name; + } + + setName(name: userNameType) { + this.name = name; + } + + getToken(): userTokenType { + return this.token; + } + + getWs(): WebSocket { + return this.ws; + } +} + +export class UserContainer extends EventEmitter { + private users; + constructor() { + super(); + this.users = new Map(); + } + + addUser(id: userIdType ,user: User) { + this.users.set(id, user); + } + + removeUser(id: userIdType) { + this.users.get(id)?.dispose(); + this.emit("userRemove", id); + this.users.delete(id); + } + + getUser(id: userIdType) { + return this.users.get(id); + } +} + +export class UserGroup extends EventEmitter { + private users; + constructor(private userContainer: UserContainer, + private name: string = "default", + private maxUsers: number = 15, + private garbageType: "auto" | "manual" = "auto") { + super(); + this.users = new Map(); + userContainer.on('userRemove', this.removeUser.bind(this)); + }; + + addUser(userId: userIdType): boolean { + if (this.users.size === this.maxUsers) { + console.error(`房间 ${this.name} 已满员,无法添加新用户。`); + return false; + } + const user = this.userContainer.getUser(userId); + + if (user) { + this.users.set(userId, user); + return true; + } else { + console.error(`尝试添加的用户ID ${userId} 对应的用户实例不存在。`); + return false; + } + } + + removeUser(user: userIdType): boolean { + var res = this.users.delete(user); + this.emit('GroupUserRemove', user, this.users.size); + return res; + } + + sendToAll(message: string) { + for (const user of this.users.values()) { + user.send(message); + } + } + + setName(name: string) { + this.name = name; + } + + getName() { + return this.name; + } + + setMaxUsers(maxUsers: number) { + this.maxUsers = maxUsers; + } + + getMaxUsers() { + return this.maxUsers; + } + + getNowUsers() { + return this.users.size; + } + + sendToAllExcept(message: string, excludeUserId?: userIdType) { + for (const [userId, user] of this.users.entries()) { + if (excludeUserId !== userId) { + user.send(message); + } + } + } + + sendTo(message: string, userId: userIdType) { + const user = this.users.get(userId); + if (user) { + user.send(message); + } + } + + getUsersIterator(): IterableIterator<[userIdType, User]> { + return this.users.entries(); + } + + hasUser(userId: userIdType):boolean { + return this.users.has(userId); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b837c84 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,17 @@ +// server.js +import { WebSocketServer } from 'ws'; +import connectHandle from '../server'; + +import { config } from "dotenv"; +config(); + +const port = parseInt(process.env.PORT || "8077", 10); +const wss = new WebSocketServer({ port }); + +wss.on('connection', (socket, req) => { + connectHandle(socket, req); +}); + +wss.on('listening', () => { + console.log(`WebSocket server is listening on ${port}`); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7916e61 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + // "skipLibCheck": true + }, + "include": [ + "src/**/*", + "server.ts" +, "src/controller/user.ts", "src/controller/session.ts", "src/controller/region.ts", "src/controller/group.ts", "server.ts" ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file