Merge pull request #37 from jcreek/35-replace-custom-websockets-with-partykit

35 replace custom websockets with partykit
This commit is contained in:
Josh Creek
2026-01-25 19:23:52 +00:00
committed by GitHub
10 changed files with 335 additions and 249 deletions
+14 -5
View File
@@ -4,11 +4,18 @@ A web app to allow scrum teams to easily perform estimations quickly and without
There are two components to this: There are two components to this:
1. Frontend, built using SvelteKit 1. Frontend, built using SvelteKit
2. WebSockets server, built using Node.js 2. Realtime server, built using PartyKit (WebSocket-compatible)
## Frontend ## 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 ### Developing
@@ -44,12 +51,14 @@ This favicon was [generated](https://favicon.io/favicon-generator/) using the fo
### Developing ### 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 ```bash
npm run dev cd src/server
npm install
npx partykit dev
``` ```
### Deploying ### Deploying
See the README in `src/server` for details. See the README in `src/server` for PartyKit deployment details.
+1
View File
@@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@fireworks-js/svelte": "^2.10.7", "@fireworks-js/svelte": "^2.10.7",
"@joeattardi/emoji-button": "^4.6.4", "@joeattardi/emoji-button": "^4.6.4",
"partysocket": "1.0.2",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"ws": "^8.13.0" "ws": "^8.13.0"
} }
+32 -14
View File
@@ -1,10 +1,8 @@
<script> <script>
import { onMount } from 'svelte';
import { connectToWebSocket, sendMessage, generateId } from './estimation/estimation.js'; import { connectToWebSocket, sendMessage, generateId } from './estimation/estimation.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
let roomId = ''; let roomId = '';
let socket;
const cardSets = [ const cardSets = [
{ name: 'Fibonacci', values: [1, 2, 3, 5, 8, 13, 21, '?'], example: '(1, 2, 3, 5)' }, { name: 'Fibonacci', values: [1, 2, 3, 5, 8, 13, 21, '?'], example: '(1, 2, 3, 5)' },
{ {
@@ -18,9 +16,39 @@
let selectedCardSet = cardSets[0]; let selectedCardSet = cardSets[0];
function startARoom() { function waitForSocketOpen(socket) {
if (socket.readyState === WebSocket.OPEN) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const handleOpen = () => {
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('error', handleError);
resolve();
};
const handleError = (event) => {
socket.removeEventListener('open', handleOpen);
socket.removeEventListener('error', handleError);
reject(event);
};
socket.addEventListener('open', handleOpen);
socket.addEventListener('error', handleError);
});
}
async function startARoom() {
const roomId = generateId(); const roomId = generateId();
sendMessage(socket, { roomId: roomId, type: 'create-room', cardSetName: selectedCardSet.name }); const socket = connectToWebSocket(roomId, onMessageReceived);
try {
await waitForSocketOpen(socket);
sendMessage(socket, { roomId: roomId, type: 'create-room', cardSetName: selectedCardSet.name });
} catch (error) {
console.error('Failed to connect to PartyKit:', error);
alert('Unable to create the room right now. Please try again.');
return;
}
goto(`/estimation/${roomId}`); goto(`/estimation/${roomId}`);
} }
@@ -33,16 +61,6 @@
} }
function onMessageReceived(message) {} function onMessageReceived(message) {}
onMount(() => {
socket = connectToWebSocket(null, onMessageReceived);
setInterval(() => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'ping' }));
}
}, 45000); // send a ping every 45 seconds
});
</script> </script>
<div class="container"> <div class="container">
+18 -7
View File
@@ -1,18 +1,29 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import PartySocket from 'partysocket';
function generateId() { function generateId() {
const id = uuidv4(); const id = uuidv4();
return id; return id;
} }
function connectToWebSocket(roomId, onMessageReceived) { const partyHost = import.meta.env.PUBLIC_PARTYKIT_HOST;
let socket; const partyName = import.meta.env.PUBLIC_PARTYKIT_PARTY || 'main';
const isDevelopment = import.meta.env.DEV;
if (isDevelopment) { function getPartyKitHost() {
socket = new WebSocket(`ws://localhost:8080`); if (partyHost) {
} else { return partyHost;
socket = new WebSocket(`wss://websocket.jcreek.co.uk`);
} }
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', () => { socket.addEventListener('open', () => {
console.log(`Connected to WebSocket server from roomId: ${roomId}`); console.log(`Connected to WebSocket server from roomId: ${roomId}`);
+18 -19
View File
@@ -1,5 +1,5 @@
import { WebSocket } from 'ws'; import type * as Party from 'partykit/server';
import { User } from './User'; import { User } from './User.js';
class CardSet { class CardSet {
name: string; name: string;
@@ -27,7 +27,7 @@ export const cardSets: Array<CardSet> = [
export class Room { export class Room {
id: string; id: string;
cardSet: CardSet; cardSet: CardSet;
users: Map<WebSocket, User>; users: Map<Party.Connection, User>;
constructor(id: string, cardSetName: string) { constructor(id: string, cardSetName: string) {
this.id = id; this.id = id;
@@ -41,45 +41,44 @@ export class Room {
this.users = new Map(); this.users = new Map();
} }
addUser(ws: WebSocket, user: User) { addUser(connection: Party.Connection, user: User) {
this.users.set(ws, user); this.users.set(connection, user);
} }
removeUser(ws: WebSocket) { removeUser(connection: Party.Connection) {
this.users.delete(ws); this.users.delete(connection);
} }
getUsers() { getUsers() {
return Array.from(this.users.values()); return Array.from(this.users.values());
} }
getUserByWebSocket(ws: WebSocket) { getUserByConnection(connection: Party.Connection): User | undefined {
return this.users.get(ws); return this.users.get(connection);
} }
getWebSocketByUser(user: User): WebSocket | null { getConnectionByUser(user: User): Party.Connection | null {
for (const [ws, u] of this.users) { for (const [connection, u] of this.users) {
if (u === user) { if (u === user) {
return ws; return connection;
} }
} }
return null; return null;
} }
getWebSocketByUserId(userId: String): WebSocket | null { getConnectionByUserId(userId: string): Party.Connection | null {
for (const [ws, u] of this.users) { for (const [connection, u] of this.users) {
if (u.userId === userId) { if (u.userId === userId) {
return ws; return connection;
} }
} }
return null; return null;
} }
broadcast(message: any) { broadcast(message: any) {
this.users.forEach((user, client) => { const messageString = JSON.stringify(message);
if (client.readyState === WebSocket.OPEN) { this.users.forEach((user, connection) => {
client.send(JSON.stringify(message)); connection.send(messageString);
}
}); });
} }
+5 -1
View File
@@ -23,6 +23,10 @@
"map-stream": "0.0.7", "map-stream": "0.0.7",
"tsc-watch": "^5.0.3", "tsc-watch": "^5.0.3",
"typescript": "^4.6.3", "typescript": "^4.6.3",
"ws": "^8.12.1" "ws": "^8.12.1",
"partysocket": "1.0.2"
},
"devDependencies": {
"partykit": "0.0.111"
} }
} }
+31
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://www.partykit.io/schema.json",
"name": "websocket-server-party",
"main": "websocket.ts",
"compatibilityDate": "2024-11-07"
}
+10 -9
View File
@@ -1,10 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"module": "ES2022", "module": "nodenext",
"esModuleInterop": true, "moduleResolution": "nodenext",
"forceConsistentCasingInFileNames": true, "esModuleInterop": true,
"strict": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "strict": true,
} "skipLibCheck": true
} }
}
+199 -193
View File
@@ -1,233 +1,239 @@
import express from 'express'; import type * as Party from 'partykit/server';
import { WebSocketServer, WebSocket } from 'ws'; import { Room } from './classes/Room.js';
import { Room, cardSets } from './classes/Room.js';
import { User } from './classes/User.js'; import { User } from './classes/User.js';
import { JoinRoomMessage } from './classes/messages/JoinRoomMessage.js'; import { JoinRoomMessage } from './classes/messages/JoinRoomMessage.js';
import { ChangeEstimateMessage } from './classes/messages/ChangeEstimateMessage.js';
import { SelectEstimateMessage } from './classes/messages/SelectEstimateMessage.js'; import { SelectEstimateMessage } from './classes/messages/SelectEstimateMessage.js';
const app = express(); type StoredRoom = {
const port = process.env.PORT || 8080; id: string;
cardSetName: string;
};
function onSocketPreError(e: Error) { export default class EstimationParty implements Party.Server {
console.log(e); room: Room | null = null;
} private nudgeTimeout: ReturnType<typeof setTimeout> | null = null;
function onSocketPostError(e: Error) { constructor(public party: Party.Room) {}
console.log(e);
}
function groupEstimates(users: Array<User>) { async onStart() {
let estimateGroups: { [key: number | string]: string[] } = {}; const savedRoom = (await this.party.storage.get('room')) as StoredRoom | undefined;
users.forEach((user) => { if (savedRoom?.cardSetName) {
if (user.estimate !== null) { this.room = new Room(savedRoom.id ?? this.party.id, savedRoom.cardSetName);
if (estimateGroups[user.estimate]) {
estimateGroups[user.estimate].push(user.name);
} else {
estimateGroups[user.estimate] = [user.name];
}
} }
});
return estimateGroups;
}
console.log(`Attempting to run server on port ${port}`);
const server = app.listen(port, () => {
console.log(`Listening on port ${port}`);
});
const wss = new WebSocketServer({ noServer: true });
const rooms = new Set<Room>();
server.on('upgrade', (req, socket, head) => {
socket.on('error', onSocketPreError);
// perform auth
if (!!req.headers['BadAuth']) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
} }
wss.handleUpgrade(req, socket, head, (ws) => { async onConnect(_connection: Party.Connection) {}
socket.removeListener('error', onSocketPreError);
wss.emit('connection', ws, req);
});
});
wss.on('connection', (ws: WebSocket, req) => { async onClose(connection: Party.Connection) {
let room: Room | null = null; if (!this.room) {
return;
}
ws.on('error', onSocketPostError); const user = this.room.getUserByConnection(connection);
if (!user) {
return;
}
function createRoom(roomId: string, cardSetName: string) { this.room.removeUser(connection);
room = new Room(roomId, cardSetName); await this.updateRoomState();
rooms.add(room); this.party.broadcast(JSON.stringify({ type: 'user-left', userId: user.userId }));
if (this.room.getUsers().length === 0) {
this.clearNudgeTimeout();
await this.party.storage.delete('room');
this.room = null;
}
} }
ws.on('message', (message) => { async onMessage(message: string | ArrayBuffer | ArrayBufferView, connection: Party.Connection) {
const data = JSON.parse(message.toString()); if (typeof message !== 'string') {
console.log('Unsupported message payload type');
return;
}
const data = JSON.parse(message);
if (data.type === 'create-room') { switch (data.type) {
const roomId = data.roomId; case 'create-room':
const cardSetName = data.cardSetName; this.room = new Room(this.party.id, data.cardSetName ?? '');
createRoom(roomId, cardSetName); await this.updateRoomState();
} else if (data.type === 'join-room') { break;
const joinRoomMessage: JoinRoomMessage = data as JoinRoomMessage;
const { roomId, userId, name } = joinRoomMessage;
room = getRoomById(roomId);
// Create room if it doesn't already exist case 'join-room':
if (!room) { {
createRoom(roomId, data.cardSetName); 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;
// Create the user in the room case 'select-estimate':
const user = new User(userId, name); {
room!.addUser(ws, user); this.room = await this.getOrCreateRoom();
if (!this.room) {
broadcastToRoom(roomId, { type: 'user-joined', message: `${name} has joined the room` }); break;
} else if (data.type === 'select-estimate') { }
const selectEstimateMessage: SelectEstimateMessage = data as SelectEstimateMessage; const selectEstimateMessage: SelectEstimateMessage = data as SelectEstimateMessage;
const { userId, estimate } = selectEstimateMessage; const { userId, estimate } = selectEstimateMessage;
const user = this.room.getUserByConnection(connection);
if (room) { if (!user) {
const user = room.getUserByWebSocket(ws); break;
if (user) { }
user.estimate = estimate; user.estimate = estimate;
broadcastToRoom(room.id, { await this.updateRoomState();
type: 'estimate-selected', this.party.broadcast(
userId, JSON.stringify({
name: user.name, type: 'estimate-selected',
estimate userId,
}); name: user.name,
estimate
})
);
const users = room.getUsers(); const users = this.room.getUsers();
let timeout = undefined; const allEstimates = this.room.getAllEstimates();
const allEstimates = room.getAllEstimates();
if (allEstimates.size === users.length) { if (allEstimates.size === users.length) {
// If all users have estimated, clear the timeout this.clearNudgeTimeout();
clearTimeout(timeout); this.party.broadcast(
timeout = undefined; JSON.stringify({
type: 'estimation-closed',
broadcastToRoom(room.id, { groupedEstimates: this.groupEstimates()
type: 'estimation-closed', })
groupedEstimates: groupEstimates(users) );
});
} else if (allEstimates.size === users.length - 1) { } else if (allEstimates.size === users.length - 1) {
// The last user has 30 seconds to add their estimation, otherwise send the nudge message const notEstimatedUser = users.find((candidate) => !allEstimates.has(candidate.name));
let notEstimatedUser: User | undefined = undefined; if (notEstimatedUser) {
for (let i = 0; i < users.length; i++) { this.scheduleNudge(notEstimatedUser);
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);
}
} }
} }
} }
} break;
} 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;
const ws = room.getWebSocketByUserId(userIdToKick); case 'restart-estimation':
console.log(userIdToKick, ws); if (this.room) {
if (ws) { this.clearNudgeTimeout();
console.log('closing ws'); this.room.clearEstimates();
ws.close(); 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 case 'get-user-estimates':
room.removeUser(userIdToKick); if (this.room) {
} const users = this.room.getUsers();
} connection.send(
}); JSON.stringify({ type: 'user-estimates', users, selectedCardSet: this.room.cardSet })
);
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'
});
} }
break;
broadcastToRoom(room.id, { case 'trigger-emoji':
type: 'user-left', if (this.room) {
userId: user.userId 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<Room | null> {
if (this.room) {
return this.room;
} }
});
});
function broadcastToRoom(roomId: string, message: any) { const savedRoom = (await this.party.storage.get('room')) as StoredRoom | undefined;
const room = getRoomById(roomId); if (savedRoom?.cardSetName) {
if (room) { this.room = new Room(savedRoom.id ?? this.party.id, savedRoom.cardSetName);
room.broadcast(message); return this.room;
}
this.room = new Room(this.party.id, cardSetName ?? '');
await this.updateRoomState();
return this.room;
} }
}
function getUsersInRoom(roomId: string) { private scheduleNudge(user: User) {
const room = getRoomById(roomId); this.clearNudgeTimeout();
if (room) {
return room.getUsers(); 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);
} }
return [];
}
function getRoomById(roomId: string) { private clearNudgeTimeout() {
for (const room of rooms) { if (this.nudgeTimeout) {
if (room.id === roomId) { clearTimeout(this.nudgeTimeout);
return room; this.nudgeTimeout = null;
} }
} }
return null;
} }