Update REST endpoint docs (votes.md, sessions.md), WebSocket protocol (websocket.md), OpenAPI spec, and voting guide with the new GET /api/votes, GET /api/sessions/:id/votes, and vote.received event. Made-with: Cursor
433 lines
10 KiB
Markdown
433 lines
10 KiB
Markdown
# WebSocket Protocol
|
|
|
|
## 1. Overview
|
|
|
|
The WebSocket API provides real-time updates for Jackbox gaming sessions. Use it to:
|
|
|
|
- Receive notifications when sessions start, end, or when games are added
|
|
- Track player counts as they are updated
|
|
- Receive live vote updates (upvotes/downvotes) as viewers vote
|
|
- Avoid polling REST endpoints for session state changes
|
|
|
|
The WebSocket server runs on the same host and port as the HTTP API. Connect to `/api/sessions/live` to establish a live connection.
|
|
|
|
---
|
|
|
|
## 2. Connection Setup
|
|
|
|
**URL:** `ws://host:port/api/sessions/live`
|
|
|
|
- Use `ws://` for HTTP and `wss://` for HTTPS
|
|
- No query parameters are required
|
|
- Connection can be established without authentication (auth happens via a message after connect)
|
|
|
|
**JavaScript example:**
|
|
|
|
```javascript
|
|
const host = 'localhost';
|
|
const port = 5000;
|
|
const protocol = 'ws';
|
|
const ws = new WebSocket(`${protocol}://${host}:${port}/api/sessions/live`);
|
|
|
|
ws.onopen = () => {
|
|
console.log('Connected');
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Authentication
|
|
|
|
Authentication is required for subscribing to sessions and for receiving most events. Send your JWT token in an `auth` message after connecting.
|
|
|
|
**Send (client → server):**
|
|
```json
|
|
{ "type": "auth", "token": "<jwt>" }
|
|
```
|
|
|
|
**Success response:**
|
|
```json
|
|
{ "type": "auth_success", "message": "Authenticated successfully" }
|
|
```
|
|
|
|
**Failure responses:**
|
|
```json
|
|
{ "type": "auth_error", "message": "Invalid or expired token" }
|
|
```
|
|
```json
|
|
{ "type": "auth_error", "message": "Token required" }
|
|
```
|
|
|
|
**JavaScript example:**
|
|
|
|
```javascript
|
|
// After opening the connection...
|
|
ws.send(JSON.stringify({
|
|
type: 'auth',
|
|
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
|
}));
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === 'auth_success') {
|
|
console.log('Authenticated');
|
|
} else if (msg.type === 'auth_error') {
|
|
console.error('Auth failed:', msg.message);
|
|
}
|
|
};
|
|
```
|
|
|
|
Obtain a JWT by calling `POST /api/auth/login` with your admin key.
|
|
|
|
---
|
|
|
|
## 4. Message Types — Client to Server
|
|
|
|
| Type | Required Fields | Description |
|
|
|-------------|-----------------|--------------------------------------|
|
|
| `auth` | `token` | Authenticate with a JWT |
|
|
| `subscribe` | `sessionId` | Subscribe to a session's events |
|
|
| `unsubscribe`| `sessionId` | Unsubscribe from a session |
|
|
| `ping` | — | Heartbeat; server responds with `pong` |
|
|
|
|
### auth
|
|
```json
|
|
{ "type": "auth", "token": "<jwt>" }
|
|
```
|
|
|
|
### subscribe
|
|
Must be authenticated. You can subscribe to multiple sessions.
|
|
|
|
```json
|
|
{ "type": "subscribe", "sessionId": 3 }
|
|
```
|
|
|
|
### unsubscribe
|
|
Must be authenticated.
|
|
|
|
```json
|
|
{ "type": "unsubscribe", "sessionId": 3 }
|
|
```
|
|
|
|
### ping
|
|
```json
|
|
{ "type": "ping" }
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Message Types — Server to Client
|
|
|
|
| Type | Description |
|
|
|---------------|------------------------------------------|
|
|
| `auth_success`| Authentication succeeded |
|
|
| `auth_error` | Authentication failed |
|
|
| `subscribed` | Successfully subscribed to a session |
|
|
| `unsubscribed`| Successfully unsubscribed from a session |
|
|
| `pong` | Response to client `ping` |
|
|
| `error` | General error (e.g., not authenticated) |
|
|
| `session.started` | New session created (broadcast to all authenticated clients) |
|
|
| `game.added` | Game added to a session (broadcast to subscribers) |
|
|
| `session.ended` | Session closed (broadcast to subscribers) |
|
|
| `player-count.updated` | Player count changed (broadcast to subscribers) |
|
|
| `vote.received` | Live vote recorded (broadcast to subscribers) |
|
|
|
|
---
|
|
|
|
## 6. Event Reference
|
|
|
|
All server-sent events use this envelope:
|
|
|
|
```json
|
|
{
|
|
"type": "<event-type>",
|
|
"timestamp": "2026-03-15T20:30:00.000Z",
|
|
"data": { ... }
|
|
}
|
|
```
|
|
|
|
### session.started
|
|
|
|
- **Broadcast to:** All authenticated clients (not session-specific)
|
|
- **Triggered by:** `POST /api/sessions` (creating a new session)
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"session": {
|
|
"id": 3,
|
|
"is_active": 1,
|
|
"created_at": "2026-03-15T20:00:00",
|
|
"notes": "Friday game night"
|
|
}
|
|
}
|
|
```
|
|
|
|
### game.added
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `POST /api/sessions/{id}/games` (adding a game)
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"session": {
|
|
"id": 3,
|
|
"is_active": true,
|
|
"games_played": 5
|
|
},
|
|
"game": {
|
|
"id": 42,
|
|
"title": "Quiplash 3",
|
|
"pack_name": "Jackbox Party Pack 7",
|
|
"min_players": 3,
|
|
"max_players": 8,
|
|
"manually_added": false,
|
|
"room_code": "ABCD"
|
|
}
|
|
}
|
|
```
|
|
|
|
### session.ended
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `POST /api/sessions/{id}/close` (closing a session)
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"session": {
|
|
"id": 3,
|
|
"is_active": 0,
|
|
"games_played": 8
|
|
}
|
|
}
|
|
```
|
|
|
|
### player-count.updated
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `PATCH /api/sessions/{sessionId}/games/{sessionGameId}/player-count`
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"sessionId": "3",
|
|
"gameId": "7",
|
|
"playerCount": 6,
|
|
"status": "completed"
|
|
}
|
|
```
|
|
|
|
### vote.received
|
|
|
|
- **Broadcast to:** Clients subscribed to the session
|
|
- **Triggered by:** `POST /api/votes/live` (recording a live vote). Only fires for live votes, NOT chat-import.
|
|
|
|
**Data:**
|
|
```json
|
|
{
|
|
"sessionId": 5,
|
|
"game": {
|
|
"id": 42,
|
|
"title": "Quiplash 3",
|
|
"pack_name": "Party Pack 7"
|
|
},
|
|
"vote": {
|
|
"username": "viewer123",
|
|
"type": "up",
|
|
"timestamp": "2026-03-15T20:29:55.000Z"
|
|
},
|
|
"totals": {
|
|
"upvotes": 14,
|
|
"downvotes": 3,
|
|
"popularity_score": 11
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Error Handling
|
|
|
|
| Type | Message | When |
|
|
|--------------|----------------------------------------|-----------------------------------------|
|
|
| `error` | `Not authenticated` | subscribe/unsubscribe without auth |
|
|
| `error` | `Session ID required` | subscribe without `sessionId` |
|
|
| `error` | `Unknown message type: foo` | Unknown `type` in client message |
|
|
| `error` | `Invalid message format` | Unparseable or non-JSON message |
|
|
| `auth_error` | `Token required` | auth without token |
|
|
| `auth_error` | `Invalid or expired token` | auth with invalid/expired JWT |
|
|
|
|
---
|
|
|
|
## 8. Heartbeat and Timeout
|
|
|
|
- **Client → Server:** Send `{ "type": "ping" }` periodically
|
|
- **Server → Client:** Responds with `{ "type": "pong" }`
|
|
- **Timeout:** If no ping is received for **60 seconds**, the server terminates the connection
|
|
- **Server check:** The server checks for stale connections every **30 seconds**
|
|
|
|
Implement a heartbeat on the client to keep the connection alive:
|
|
|
|
```javascript
|
|
let pingInterval;
|
|
|
|
function startHeartbeat() {
|
|
pingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
}
|
|
}, 30000); // every 30 seconds
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
startHeartbeat();
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
clearInterval(pingInterval);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Reconnection
|
|
|
|
The server does **not** maintain state across disconnects. After reconnecting:
|
|
|
|
1. **Re-authenticate** with an `auth` message
|
|
2. **Re-subscribe** to any sessions you were tracking
|
|
|
|
Implement exponential backoff for reconnection attempts:
|
|
|
|
```javascript
|
|
let reconnectAttempts = 0;
|
|
const maxReconnectAttempts = 10;
|
|
const baseDelay = 1000;
|
|
|
|
function connect() {
|
|
const ws = new WebSocket('ws://localhost:5000/api/sessions/live');
|
|
|
|
ws.onopen = () => {
|
|
reconnectAttempts = 0;
|
|
ws.send(JSON.stringify({ type: 'auth', token: jwt }));
|
|
// After auth_success, re-subscribe to sessions...
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts), 60000);
|
|
reconnectAttempts++;
|
|
setTimeout(connect, delay);
|
|
}
|
|
};
|
|
}
|
|
|
|
connect();
|
|
```
|
|
|
|
---
|
|
|
|
## 10. Complete Example
|
|
|
|
Full session lifecycle from connect to disconnect:
|
|
|
|
```javascript
|
|
const JWT = 'your-jwt-token';
|
|
const WS_URL = 'ws://localhost:5000/api/sessions/live';
|
|
|
|
const ws = new WebSocket(WS_URL);
|
|
let pingInterval;
|
|
let subscribedSessions = new Set();
|
|
|
|
function send(msg) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(msg));
|
|
}
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
console.log('Connected');
|
|
send({ type: 'auth', token: JWT });
|
|
|
|
pingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
send({ type: 'ping' });
|
|
}
|
|
}, 30000);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data);
|
|
|
|
switch (msg.type) {
|
|
case 'auth_success':
|
|
console.log('Authenticated');
|
|
send({ type: 'subscribe', sessionId: 3 });
|
|
break;
|
|
|
|
case 'auth_error':
|
|
console.error('Auth failed:', msg.message);
|
|
break;
|
|
|
|
case 'subscribed':
|
|
subscribedSessions.add(msg.sessionId);
|
|
console.log('Subscribed to session', msg.sessionId);
|
|
break;
|
|
|
|
case 'unsubscribed':
|
|
subscribedSessions.delete(msg.sessionId);
|
|
console.log('Unsubscribed from session', msg.sessionId);
|
|
break;
|
|
|
|
case 'pong':
|
|
// Heartbeat acknowledged
|
|
break;
|
|
|
|
case 'session.started':
|
|
console.log('New session:', msg.data.session);
|
|
break;
|
|
|
|
case 'game.added':
|
|
console.log('Game added:', msg.data.game.title, 'to session', msg.data.session.id);
|
|
break;
|
|
|
|
case 'session.ended':
|
|
console.log('Session ended:', msg.data.session.id);
|
|
subscribedSessions.delete(msg.data.session.id);
|
|
break;
|
|
|
|
case 'player-count.updated':
|
|
console.log('Player count:', msg.data.playerCount, 'for game', msg.data.gameId);
|
|
break;
|
|
|
|
case 'vote.received':
|
|
console.log('Vote:', msg.data.vote.type, 'from', msg.data.vote.username, 'for', msg.data.game.title, '- totals:', msg.data.totals);
|
|
break;
|
|
|
|
case 'error':
|
|
case 'auth_error':
|
|
console.error('Error:', msg.message);
|
|
break;
|
|
|
|
default:
|
|
console.log('Unknown message:', msg);
|
|
}
|
|
};
|
|
|
|
ws.onerror = (err) => console.error('WebSocket error:', err);
|
|
ws.onclose = () => {
|
|
clearInterval(pingInterval);
|
|
console.log('Disconnected');
|
|
};
|
|
|
|
// Later: unsubscribe and close
|
|
function disconnect() {
|
|
subscribedSessions.forEach((sessionId) => {
|
|
send({ type: 'unsubscribe', sessionId });
|
|
});
|
|
ws.close();
|
|
}
|
|
```
|