diff --git a/README.md b/README.md index 7a1433c..50e9a66 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,18 @@ A web app to allow scrum teams to easily perform estimations quickly and without There are two components to this: 1. Frontend, built using SvelteKit -2. WebSockets server, built using Node.js +2. Realtime server, built using PartyKit (WebSocket-compatible) ## Frontend -For full functionality, ensure there is a copy of the WebSockets server running. +For full functionality, ensure there is a PartyKit server running. + +### Environment variables + +The frontend uses PartyKit defaults unless overridden: + +- `PUBLIC_PARTYKIT_HOST`: PartyKit host (defaults to `localhost:1999` in dev, `websocket.jcreek.co.uk` in production) +- `PUBLIC_PARTYKIT_PARTY`: Party name (defaults to `main`) ### Developing @@ -44,12 +51,14 @@ This favicon was [generated](https://favicon.io/favicon-generator/) using the fo ### Developing -Once you've installed dependencies with `npm install`, start a development server: +Install dependencies in `src/server`, then run the PartyKit dev server: ```bash -npm run dev +cd src/server +npm install +npx partykit dev ``` ### Deploying -See the README in `src/server` for details. +See the README in `src/server` for PartyKit deployment details. diff --git a/package.json b/package.json index 8e9a0ef..d9c6a48 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@fireworks-js/svelte": "^2.10.7", "@joeattardi/emoji-button": "^4.6.4", + "partysocket": "1.0.2", "uuid": "^9.0.0", "ws": "^8.13.0" } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 93328e8..dfafc09 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,10 +1,8 @@
diff --git a/src/routes/estimation/estimation.js b/src/routes/estimation/estimation.js index 5e6677a..1f0c038 100644 --- a/src/routes/estimation/estimation.js +++ b/src/routes/estimation/estimation.js @@ -1,18 +1,29 @@ import { v4 as uuidv4 } from 'uuid'; +import PartySocket from 'partysocket'; function generateId() { const id = uuidv4(); return id; } -function connectToWebSocket(roomId, onMessageReceived) { - let socket; - const isDevelopment = import.meta.env.DEV; - if (isDevelopment) { - socket = new WebSocket(`ws://localhost:8080`); - } else { - socket = new WebSocket(`wss://websocket.jcreek.co.uk`); +const partyHost = import.meta.env.PUBLIC_PARTYKIT_HOST; +const partyName = import.meta.env.PUBLIC_PARTYKIT_PARTY || 'main'; + +function getPartyKitHost() { + if (partyHost) { + return partyHost; } + return import.meta.env.DEV ? 'localhost:1999' : 'websocket.jcreek.co.uk'; +} + +function connectToWebSocket(roomId, onMessageReceived) { + const host = getPartyKitHost(); + const socket = new PartySocket({ + host, + room: roomId ?? 'lobby', + party: partyName, + ...(import.meta.env.DEV ? { protocol: 'ws' } : {}) + }); socket.addEventListener('open', () => { console.log(`Connected to WebSocket server from roomId: ${roomId}`); diff --git a/src/server/package.json b/src/server/package.json index 86a3583..7957c56 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -23,6 +23,10 @@ "map-stream": "0.0.7", "tsc-watch": "^5.0.3", "typescript": "^4.6.3", - "ws": "^8.12.1" + "ws": "^8.12.1", + "partysocket": "1.0.2" + }, + "devDependencies": { + "partykit": "0.0.111" } } diff --git a/src/server/party/index.ts b/src/server/party/index.ts new file mode 100644 index 0000000..c355fc1 --- /dev/null +++ b/src/server/party/index.ts @@ -0,0 +1,31 @@ +import type * as Party from "partykit/server"; + +export default class Server implements Party.Server { + constructor(readonly room: Party.Room) {} + + onConnect(conn: Party.Connection, ctx: Party.ConnectionContext) { + // A websocket just connected! + console.log( + `Connected: + id: ${conn.id} + room: ${this.room.id} + url: ${new URL(ctx.request.url).pathname}` + ); + + // let's send a message to the connection + conn.send("hello from server"); + } + + onMessage(message: string, sender: Party.Connection) { + // let's log the message + console.log(`connection ${sender.id} sent message: ${message}`); + // as well as broadcast it to all the other connections in the room... + this.room.broadcast( + `${sender.id}: ${message}`, + // ...except for the connection it came from + [sender.id] + ); + } +} + +Server satisfies Party.Worker; diff --git a/src/server/partykit.json b/src/server/partykit.json new file mode 100644 index 0000000..53be2fd --- /dev/null +++ b/src/server/partykit.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://www.partykit.io/schema.json", + "name": "websocket-server-party", + "main": "websocket.ts", + "compatibilityDate": "2024-11-07" +} diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index b7bab42..f672dfd 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -1,10 +1,11 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true - } - } \ No newline at end of file + "compilerOptions": { + "target": "ES2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 02c0e70..b39184d 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -2,169 +2,238 @@ import type * as Party from 'partykit/server'; import { Room } from './classes/Room.js'; import { User } from './classes/User.js'; import { JoinRoomMessage } from './classes/messages/JoinRoomMessage.js'; -import { ChangeEstimateMessage } from './classes/messages/ChangeEstimateMessage.js'; import { SelectEstimateMessage } from './classes/messages/SelectEstimateMessage.js'; +type StoredRoom = { + id: string; + cardSetName: string; +}; + export default class EstimationParty implements Party.Server { room: Room | null = null; + private nudgeTimeout: ReturnType | null = null; constructor(public party: Party.Room) {} async onStart() { - const savedRoom = (await this.party.storage.get('room')) as Room; - this.room = savedRoom ? new Room(savedRoom.id, savedRoom.cardSet.name) : null; - } - - async onConnect(connection: Party.Connection) { - if (this.room) { - const users = this.room.getUsers(); - connection.send( - JSON.stringify({ - type: 'sync', - roomId: this.room.id, - users, - cardSetName: this.room.cardSet.name - }) - ); + const savedRoom = (await this.party.storage.get('room')) as StoredRoom | undefined; + if (savedRoom?.cardSetName) { + this.room = new Room(savedRoom.id ?? this.party.id, savedRoom.cardSetName); } } - ws.on('message', (message) => { - const data = JSON.parse(message.toString()); + async onConnect(_connection: Party.Connection) {} - if (data.type === 'create-room') { - const roomId = data.roomId; - const cardSetName = data.cardSetName; - createRoom(roomId, cardSetName); - } else if (data.type === 'join-room') { - const joinRoomMessage: JoinRoomMessage = data as JoinRoomMessage; - const { roomId, userId, name } = joinRoomMessage; - room = getRoomById(roomId); + async onClose(connection: Party.Connection) { + if (!this.room) { + return; + } - // Create room if it doesn't already exist - if (!room) { - createRoom(roomId, data.cardSetName); - } + const user = this.room.getUserByConnection(connection); + if (!user) { + return; + } - // Create the user in the room - const user = new User(userId, name); - room!.addUser(ws, user); + this.room.removeUser(connection); + await this.updateRoomState(); + this.party.broadcast(JSON.stringify({ type: 'user-left', userId: user.userId })); - broadcastToRoom(roomId, { type: 'user-joined', message: `${name} has joined the room` }); - } else if (data.type === 'select-estimate') { - const selectEstimateMessage: SelectEstimateMessage = data as SelectEstimateMessage; - const { userId, estimate } = selectEstimateMessage; + if (this.room.getUsers().length === 0) { + this.clearNudgeTimeout(); + await this.party.storage.delete('room'); + this.room = null; + } + } - if (room) { - const user = room.getUserByWebSocket(ws); - if (user) { + async onMessage(message: string | ArrayBuffer | ArrayBufferView, connection: Party.Connection) { + if (typeof message !== 'string') { + console.log('Unsupported message payload type'); + return; + } + const data = JSON.parse(message); + + switch (data.type) { + case 'create-room': + this.room = new Room(this.party.id, data.cardSetName ?? ''); + await this.updateRoomState(); + break; + + case 'join-room': + { + this.room = await this.getOrCreateRoom(data.cardSetName); + if (!this.room) { + break; + } + const joinRoomMessage: JoinRoomMessage = data as JoinRoomMessage; + const { userId, name } = joinRoomMessage; + const user = new User(userId, name); + this.room.addUser(connection, user); + await this.updateRoomState(); + this.party.broadcast( + JSON.stringify({ type: 'user-joined', message: `${name} has joined the room` }) + ); + } + break; + + case 'select-estimate': + { + this.room = await this.getOrCreateRoom(); + if (!this.room) { + break; + } + const selectEstimateMessage: SelectEstimateMessage = data as SelectEstimateMessage; + const { userId, estimate } = selectEstimateMessage; + const user = this.room.getUserByConnection(connection); + if (!user) { + break; + } user.estimate = estimate; - broadcastToRoom(room.id, { - type: 'estimate-selected', - userId, - name: user.name, - estimate - }); + await this.updateRoomState(); + this.party.broadcast( + JSON.stringify({ + type: 'estimate-selected', + userId, + name: user.name, + estimate + }) + ); - const users = room.getUsers(); - let timeout = undefined; - - const allEstimates = room.getAllEstimates(); + const users = this.room.getUsers(); + const allEstimates = this.room.getAllEstimates(); if (allEstimates.size === users.length) { - // If all users have estimated, clear the timeout - clearTimeout(timeout); - timeout = undefined; - - broadcastToRoom(room.id, { - type: 'estimation-closed', - groupedEstimates: groupEstimates(users) - }); + this.clearNudgeTimeout(); + this.party.broadcast( + JSON.stringify({ + type: 'estimation-closed', + groupedEstimates: this.groupEstimates() + }) + ); } else if (allEstimates.size === users.length - 1) { - // The last user has 30 seconds to add their estimation, otherwise send the nudge message - let notEstimatedUser: User | undefined = undefined; - for (let i = 0; i < users.length; i++) { - if (!allEstimates.has(users[i].name)) { - notEstimatedUser = users[i]; - break; - } - } - if (notEstimatedUser !== undefined) { - const ws = room.getWebSocketByUser(notEstimatedUser); - if (ws) { - // Cancel the old timeout if it's still running - if (timeout) { - clearTimeout(timeout); - } - // Set a new timeout - timeout = setTimeout(() => { - if (!room!.getAllEstimates().has(notEstimatedUser!.name)) { - ws.send(JSON.stringify({ type: 'nudge' })); - } - }, 30000); - } + const notEstimatedUser = users.find((candidate) => !allEstimates.has(candidate.name)); + if (notEstimatedUser) { + this.scheduleNudge(notEstimatedUser); } } } - } - } else if (data.type === 'restart-estimation') { - if (room) { - room.clearEstimates(); - broadcastToRoom(room.id, { type: 'estimation-restarted' }); - } - } else if (data.type === 'get-user-estimates') { - if (room) { - const users = room.getUsers(); - broadcastToRoom(room.id, { type: 'user-estimates', users, selectedCardSet: room.cardSet }); - } - } else if (data.type === 'trigger-emoji') { - const { cardId, emoji } = data; - if (room) { - broadcastToRoom(room.id, { - type: 'trigger-emoji', - cardId, - emoji - }); - } - } else if (data.type === 'ping') { - if (room) { - broadcastToRoom(room.id, 'pong'); - } - } else if (data.type === 'kick-user') { - console.log('kick user message received'); - if (room) { - const userIdToKick = data.userId; + break; - const ws = room.getWebSocketByUserId(userIdToKick); - console.log(userIdToKick, ws); - if (ws) { - console.log('closing ws'); - ws.close(); + case 'restart-estimation': + if (this.room) { + this.clearNudgeTimeout(); + this.room.clearEstimates(); + await this.updateRoomState(); + this.party.broadcast(JSON.stringify({ type: 'estimation-restarted' })); } + break; - // Remove them from the room even if the active websocket can't be found or can't be closed - room.removeUser(userIdToKick); - } - } - }); - - ws.on('close', () => { - if (room) { - const user = room.getUserByWebSocket(ws); - if (user) { - room.removeUser(ws); - - if (room.getUsers().length === 0) { - rooms.delete(room); - broadcastToRoom(room.id, { - type: 'room-deleted', - message: 'Room deleted' - }); + case 'get-user-estimates': + if (this.room) { + const users = this.room.getUsers(); + connection.send( + JSON.stringify({ type: 'user-estimates', users, selectedCardSet: this.room.cardSet }) + ); } + break; - broadcastToRoom(room.id, { - type: 'user-left', - userId: user.userId - }); - } + case 'trigger-emoji': + if (this.room) { + const { cardId, emoji } = data; + this.party.broadcast(JSON.stringify({ type: 'trigger-emoji', cardId, emoji })); + } + break; + + case 'ping': + connection.send(JSON.stringify({ type: 'pong' })); + break; + + case 'kick-user': + if (this.room) { + const userIdToKick = data.userId; + const connectionToKick = this.room.getConnectionByUserId(userIdToKick); + if (connectionToKick) { + connectionToKick.close(); + this.room.removeUser(connectionToKick); + } + await this.updateRoomState(); + this.party.broadcast(JSON.stringify({ type: 'user-left', userId: userIdToKick })); + } + break; + + default: + console.log('Unknown message type:', data.type); } - }); + } + + // Helper to broadcast the current room state to all users in the room + private async updateRoomState() { + if (this.room) { + await this.party.storage.put('room', { + id: this.room.id, + cardSetName: this.room.cardSet.name + }); + } + } + + // Group user estimates into a map of estimates -> list of user names + private groupEstimates() { + const users = this.room ? this.room.getUsers() : []; + const estimateGroups: { [key: number | string]: string[] } = {}; + users.forEach((user) => { + if (user.estimate !== null) { + if (estimateGroups[user.estimate]) { + estimateGroups[user.estimate].push(user.name); + } else { + estimateGroups[user.estimate] = [user.name]; + } + } + }); + return estimateGroups; + } + + private async getOrCreateRoom(cardSetName?: string): Promise { + if (this.room) { + return this.room; + } + + const savedRoom = (await this.party.storage.get('room')) as StoredRoom | undefined; + if (savedRoom?.cardSetName) { + this.room = new Room(savedRoom.id ?? this.party.id, savedRoom.cardSetName); + return this.room; + } + + this.room = new Room(this.party.id, cardSetName ?? ''); + await this.updateRoomState(); + return this.room; + } + + private scheduleNudge(user: User) { + this.clearNudgeTimeout(); + + if (!this.room) { + return; + } + + const connection = this.room.getConnectionByUser(user); + if (!connection) { + return; + } + + this.nudgeTimeout = setTimeout(() => { + if (!this.room) { + return; + } + + const allEstimates = this.room.getAllEstimates(); + if (!allEstimates.has(user.name)) { + connection.send(JSON.stringify({ type: 'nudge' })); + } + }, 30000); + } + + private clearNudgeTimeout() { + if (this.nudgeTimeout) { + clearTimeout(this.nudgeTimeout); + this.nudgeTimeout = null; + } + } +}