mirror of
https://github.com/jcreek/EstimationPoker.git
synced 2026-07-15 20:13:46 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b67978839 | |||
| 7abd7090f7 | |||
| ed31649224 | |||
| d982b5968a | |||
| e5041d847e | |||
| c1eeb3ccf0 | |||
| f3a48ddef2 | |||
| dc5df3c177 | |||
| 31026b9a63 | |||
| 209a50eb82 | |||
| 2ffec56ba6 | |||
| 3ffd948346 |
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://www.partykit.io/schema.json",
|
||||||
|
"name": "websocket-server-party",
|
||||||
|
"main": "websocket.ts",
|
||||||
|
"compatibilityDate": "2024-11-07"
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user