12 Commits

Author SHA1 Message Date
Josh Creek 3b67978839 fix(*): Fix google ad styling 2026-01-25 20:04:00 +00:00
Josh Creek 7abd7090f7 fix(*): Fix adsense duplication 2026-01-25 19:58:38 +00:00
Josh Creek ed31649224 fix(*): Manually set partykit url 2026-01-25 19:47:04 +00:00
Josh Creek d982b5968a fix(*): Fix env var not being picked up 2026-01-25 19:41:59 +00:00
Josh Creek e5041d847e fix(*): Ensure new partykit url is respected 2026-01-25 19:38:37 +00:00
Josh Creek c1eeb3ccf0 docs(*): Update server readme for partykit 2026-01-25 19:24:47 +00:00
Josh Creek f3a48ddef2 Merge pull request #37 from jcreek/35-replace-custom-websockets-with-partykit
35 replace custom websockets with partykit
2026-01-25 19:23:52 +00:00
Josh Creek dc5df3c177 refactor(#35): Migrate realtime server to PartyKit and update client socket flow 2026-01-25 19:03:03 +00:00
Josh Creek 31026b9a63 refactor(#35): 3 2024-11-07 20:11:02 +00:00
Josh Creek 209a50eb82 refactor(#35): 2 2024-11-07 20:07:41 +00:00
Josh Creek 2ffec56ba6 refactor(#35): Remove unneeded code 2024-11-07 20:04:30 +00:00
Josh Creek 3ffd948346 refactor(#35): Update room to use partykit 2024-11-07 20:04:10 +00:00
13 changed files with 419 additions and 299 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"
} }
+53 -24
View File
@@ -1,35 +1,61 @@
<script> <script>
import { onMount, tick } from 'svelte';
export let closeModal; export let closeModal;
export let joinRoom; export let joinRoom;
let userName = ''; let userName = '';
let rememberName = false; let rememberName = false;
function handleNameChange(event) { function handleNameChange(event) {
userName = event.target.value; userName = event.target.value;
} }
function handleSubmit() { function handleSubmit() {
if (userName.trim() !== '') { if (userName.trim() !== '') {
joinRoom(userName); joinRoom(userName);
if (rememberName && typeof localStorage !== 'undefined') { if (rememberName && typeof localStorage !== 'undefined') {
localStorage.setItem('userName', userName); localStorage.setItem('userName', userName);
localStorage.setItem('rememberName', true); localStorage.setItem('rememberName', true);
} else if (typeof localStorage !== 'undefined') { } else if (typeof localStorage !== 'undefined') {
localStorage.removeItem('userName'); localStorage.removeItem('userName');
localStorage.removeItem('rememberName'); localStorage.removeItem('rememberName');
}
closeModal();
} }
closeModal();
}
} }
function handleRememberChange(event) { function handleRememberChange(event) {
rememberName = event.target.checked; rememberName = event.target.checked;
} }
let adContainer;
let adPushed = false;
function tryPushAd() {
if (adPushed || !adContainer) return;
if (adContainer.offsetWidth === 0) return;
if (typeof window === 'undefined' || !window.adsbygoogle) return;
window.adsbygoogle = window.adsbygoogle || [];
window.adsbygoogle.push({});
adPushed = true;
}
onMount(async () => {
await tick();
tryPushAd();
if (adPushed || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
tryPushAd();
if (adPushed) observer.disconnect();
});
observer.observe(adContainer);
return () => observer.disconnect();
});
$: userName = typeof localStorage !== 'undefined' ? localStorage.getItem('userName') || '' : ''; $: userName = typeof localStorage !== 'undefined' ? localStorage.getItem('userName') || '' : '';
$: rememberName = typeof localStorage !== 'undefined' ? localStorage.getItem('rememberName') === 'true' : false; $: rememberName = typeof localStorage !== 'undefined' ? localStorage.getItem('rememberName') === 'true' : false;
</script> </script>
<div class="modal-container"> <div class="modal-container">
<div class="modal-overlay"> <div class="modal-overlay">
@@ -57,18 +83,15 @@
<div class="button-container"> <div class="button-container">
<button on:click={handleSubmit}>Save</button> <button on:click={handleSubmit}>Save</button>
</div> </div>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5812114745839139"
crossorigin="anonymous"></script>
<!-- Name Entry - Square Ad --> <!-- Name Entry - Square Ad -->
<ins class="adsbygoogle" <ins
bind:this={adContainer}
class="adsbygoogle"
style="display:block" style="display:block"
data-ad-client="ca-pub-5812114745839139" data-ad-client="ca-pub-5812114745839139"
data-ad-slot="5531796845" data-ad-slot="5531796845"
data-ad-format="auto" data-ad-format="auto"
data-full-width-responsive="true"></ins> data-full-width-responsive="true"></ins>
<script>
(adsbygoogle = window.adsbygoogle || []).push({});
</script>
</div> </div>
</div> </div>
</div> </div>
@@ -101,6 +124,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
width: min(90vw, 22rem);
} }
.input-container { .input-container {
@@ -143,6 +167,11 @@
background-color: #005f8a; background-color: #005f8a;
} }
.adsbygoogle {
align-self: stretch;
width: 100%;
}
p { p {
font-size: 1rem; font-size: 1rem;
font-style: italic; font-style: italic;
+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">
+19 -7
View File
@@ -1,18 +1,30 @@
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import PartySocket from 'partysocket';
import { env } from '$env/dynamic/public';
function generateId() { function generateId() {
const id = uuidv4(); const id = uuidv4();
return id; return id;
} }
const fallbackHost = import.meta.env.DEV
? 'localhost:1999'
: 'websocket-server-party.jcreek.partykit.dev';
const partyHost = env.PUBLIC_PARTYKIT_HOST?.trim() || fallbackHost;
const partyName = env.PUBLIC_PARTYKIT_PARTY || 'main';
function getPartyKitHost() {
return partyHost;
}
function connectToWebSocket(roomId, onMessageReceived) { function connectToWebSocket(roomId, onMessageReceived) {
let socket; const host = getPartyKitHost();
const isDevelopment = import.meta.env.DEV; const socket = new PartySocket({
if (isDevelopment) { host,
socket = new WebSocket(`ws://localhost:8080`); room: roomId ?? 'lobby',
} else { party: partyName,
socket = new WebSocket(`wss://websocket.jcreek.co.uk`); ...(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}`);
+23 -26
View File
@@ -1,34 +1,31 @@
# Web Sockets Server # Realtime Server (PartyKit)
This folder contains the PartyKit server that powers realtime estimation.
## Developing locally
```bash
cd src/server
npm install
npx partykit dev
```
By default the PartyKit dev server runs on `localhost:1999`. The frontend connects to that host when `PUBLIC_PARTYKIT_HOST` is not set.
## Deploying ## Deploying
Use Docker to deploy this. PartyKit manages the server deployment. From this folder:
Navigate to this folder and run `docker build -t estimation-ws-server .` to build the image. ```bash
cd src/server
npx partykit deploy
```
Run it using `docker run -it --rm -p 8080:8080 estimation-ws-server` You will be prompted to authenticate on first deploy. The PartyKit app name is defined in `partykit.json`.
## Deploying to Heroku ## Frontend configuration
### Install the Heroku CLI Set the following env vars in Netlify:
Download and install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-command-line). - `PUBLIC_PARTYKIT_HOST`: the PartyKit host (for example `websocket-server-party.jcreek.partykit.dev`)
- `PUBLIC_PARTYKIT_PARTY`: optional party name (defaults to `main`)
If you haven't already, log in to your Heroku account and follow the prompts to create a new SSH public key.
`heroku login`
### Clone the repository
Use Git to clone `your-app-name`'s source code to your local machine.
`heroku git:clone -a your-app-name`
`cd your-app-name`
### Deploy your changes
Make some changes to the code you just cloned and deploy them to Heroku using Git.
`git add .`
`git commit -am "Your commit message"`
`git push heroku main`
+19 -20
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;
@@ -37,49 +37,48 @@ export class Room {
} else { } else {
this.cardSet = cardSets[0]; this.cardSet = cardSets[0];
} }
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;
} }
+7
View File
@@ -8,5 +8,12 @@ self.addEventListener('activate', (event) => {
}); });
self.addEventListener('fetch', function (event) { self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') {
return;
}
const url = new URL(event.request.url);
if (url.origin !== self.location.origin) {
return;
}
event.respondWith(fetch(event.request)); event.respondWith(fetch(event.request));
}); });