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;
+ }
+ }
+}