Switch to Socket.IO; fix two real memory leaks
All checks were successful
Publier l'image Docker / publish (push) Successful in 10s
All checks were successful
Publier l'image Docker / publish (push) Successful in 10s
Requested: Socket.IO instead of raw WebSockets, and explicit attention
to memory leaks. Auditing for the second turned up two real, concrete
leaks, independent of the transport library:
1. Server-side: a raw `ws` server has no idea a client is gone unless
it gets a proper close frame - a sleeping laptop, a network drop, a
killed tab never fire 'close', so the PartyMember's socket
reference (and everything it closes over) sat in memory forever,
and the party was never cleaned up by anything else either
(removeIdleParties only looks at lastActivityUtc). Socket.IO's
built-in heartbeat (pingInterval/pingTimeout) detects and closes
these out on its own - a real, not just cosmetic, reason to prefer
it here.
2. Client-side: router.js only ever did currentRoot.innerHTML = ''
between routes, destroying DOM but never telling the outgoing
view's JS to stop. Leaving the player any way other than its own
"Quitter" button (a sidebar link, browser back) left the 3s
heartbeat setInterval, the realtime connection, and the hls.js
instance all running against a detached <video> indefinitely, for
as long as the tab stayed open.
Fixes:
- server/src/socketHandler.js replaces wsHandler.js: same protocol
(see its own top comment), over Socket.IO. Rooms (socket.join/
io.to) replace manually iterating party.members to broadcast.
Error event renamed to "errorMessage" - Socket.IO reserves "error".
- server/src/index.js: socket.io server instead of a raw
WebSocketServer, same-origin (no CORS config needed).
- client/js/party.js: socket.io-client (window.io, loaded via
<script> in index.html) instead of hand-rolled WebSocket + JSON
framing - also gets automatic reconnection for free.
- router.js: route() handlers may now return a cleanup function;
render() calls whatever the previous view returned before switching
to the next route, regardless of how navigation happened.
- player.js returns a cleanup that clears the heartbeat, destroys
hls, disconnects the socket, and removes its document-level click
listener (now named, not inline-anonymous, specifically so it CAN
be removed - an anonymous one added fresh per visit would itself
have been a slow leak even with everything else fixed). The
"Quitter" button is now just navigate('/home') - cleanup runs the
same way regardless of how you leave.
Verified for real (see session history): a real Socket.IO client
connected to a really-running local server end to end. Then, with
document.addEventListener/removeEventListener and setInterval/
clearInterval instrumented, drove the router through repeated "join a
party, then navigate away without clicking Quitter" cycles - adds and
removes matched exactly every time (3/3 listeners over three cycles,
2/2 intervals over two), nothing left pending.
docs/architecture.md has the full writeup under "Realtime transport:
Socket.IO, and two real memory leaks".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
02587632ff
commit
5d535d18d8
11 changed files with 511 additions and 204 deletions
|
|
@ -49,7 +49,7 @@ server/ Node.js backend: proxies ALL Emby access (auth, catalog,
|
|||
src/index.js Express app + REST endpoints
|
||||
src/embyAuth.js server-side login against Emby + session cookies
|
||||
src/embyProxy.js generic Emby request/response proxying (incl. HLS)
|
||||
src/wsHandler.js WebSocket protocol (see comments in the file)
|
||||
src/socketHandler.js Socket.IO protocol (see comments in the file)
|
||||
src/partyManager.js in-memory room state
|
||||
|
||||
client/ Static frontend (plain JS, ES modules, no build step).
|
||||
|
|
@ -57,7 +57,7 @@ client/ Static frontend (plain JS, ES modules, no build step).
|
|||
js/shell.js persistent sidebar + #mainContent the router swaps
|
||||
js/tiles.js poster-tile rendering shared by home.js and library.js
|
||||
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
|
||||
js/party.js our own backend's party REST + WebSocket client
|
||||
js/party.js our own backend's party REST + Socket.IO client
|
||||
js/views/ one file per screen: login, home, library, series, player
|
||||
|
||||
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
|
||||
|
|
@ -100,6 +100,9 @@ Then put it behind your existing reverse proxy like everything else
|
|||
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
|
||||
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
|
||||
- [x] Player language/subtitle/quality pickers
|
||||
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
|
||||
(dead-connection cleanup server-side, view teardown on navigation
|
||||
client-side) — see docs/architecture.md
|
||||
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||
- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
|
||||
- [ ] Tune transcoding target / support direct play when possible
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
<title>Watch Party</title>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Party REST + realtime WebSocket - same origin as everything else, auth is
|
||||
// the session cookie (see embyApi.js), so no headers/tokens to pass around
|
||||
// here at all.
|
||||
// Party REST + realtime - same origin as everything else, auth is the
|
||||
// session cookie (see embyApi.js), so no headers/tokens to pass around here.
|
||||
// Realtime is Socket.IO (window.io, loaded via <script> in index.html) -
|
||||
// gives us automatic reconnection and, on the server side, heartbeat-based
|
||||
// dead-connection detection for free (see server/src/socketHandler.js).
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const res = await fetch(path, {
|
||||
|
|
@ -37,47 +39,38 @@ export function getParty(partyId) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Opens the realtime connection for a party. Auth rides along on the
|
||||
* WebSocket's HTTP upgrade handshake as the session cookie (browsers attach
|
||||
* cookies to that handshake automatically for a same-origin URL) - so all
|
||||
* this needs to send is which party to join.
|
||||
* Opens the realtime connection for a party. Returns an object the caller
|
||||
* MUST eventually call .leave() on (e.g. from a router cleanup function -
|
||||
* see router.js/player.js) - otherwise the socket, and whatever it's
|
||||
* keeping alive via closures, never gets released.
|
||||
*/
|
||||
export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onError, onClose } = {}) {
|
||||
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const ws = new WebSocket(proto + location.host + '/ws');
|
||||
const socket = window.io();
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'join', partyId }));
|
||||
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
||||
// also covers rejoining after a transient network drop, not just the
|
||||
// first connection.
|
||||
socket.on('connect', () => {
|
||||
socket.emit('join', { partyId });
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (evt) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(evt.data); } catch (err) { return; }
|
||||
if (msg.type === 'members' && onMembers) onMembers(msg.members);
|
||||
if (msg.type === 'playstate' && onPlaystate) onPlaystate(msg);
|
||||
if (msg.type === 'itemChanged' && onItemChanged) onItemChanged(msg);
|
||||
if (msg.type === 'error' && onError) onError(msg.message);
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => { if (onClose) onClose(); });
|
||||
if (onMembers) socket.on('members', onMembers);
|
||||
if (onPlaystate) socket.on('playstate', onPlaystate);
|
||||
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
||||
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
|
||||
if (onClose) socket.on('disconnect', onClose);
|
||||
|
||||
return {
|
||||
sendPlaystate(isPaused, positionTicks) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'playstate', isPaused, positionTicks }));
|
||||
}
|
||||
socket.emit('playstate', { isPaused, positionTicks });
|
||||
},
|
||||
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
||||
next() {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'next' }));
|
||||
}
|
||||
socket.emit('next');
|
||||
},
|
||||
leave() {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'leave' }));
|
||||
}
|
||||
ws.close();
|
||||
socket.emit('leave');
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
// Tiny hash router - no dependency, matches the project's "no build step"
|
||||
// bias. Routes are registered with route(pattern, handler); handler gets
|
||||
// (root, params) where params come from ":name" segments in the pattern.
|
||||
//
|
||||
// A handler may return a cleanup function. If it does, the router calls it
|
||||
// right before rendering whatever comes next - this is what stops a view
|
||||
// like the player (setInterval heartbeat, a realtime connection, an hls.js
|
||||
// instance) from leaking when the user navigates away some way other than
|
||||
// its own "Quitter" button (a sidebar link, browser back, ...). Before this
|
||||
// existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes -
|
||||
// nothing told the outgoing view's JS to stop running.
|
||||
|
||||
const routes = [];
|
||||
let currentRoot = null;
|
||||
let listenerAttached = false;
|
||||
let currentCleanup = null;
|
||||
|
||||
export function route(pattern, handler) {
|
||||
const keys = [];
|
||||
|
|
@ -27,6 +36,16 @@ export function navigate(path) {
|
|||
|
||||
function render() {
|
||||
if (!currentRoot) return;
|
||||
|
||||
if (typeof currentCleanup === 'function') {
|
||||
try {
|
||||
currentCleanup();
|
||||
} catch (err) {
|
||||
console.error('[watchparty] view cleanup failed', err);
|
||||
}
|
||||
}
|
||||
currentCleanup = null;
|
||||
|
||||
const [path, query] = (location.hash.slice(1) || '/').split('?');
|
||||
const params = Object.fromEntries(new URLSearchParams(query || ''));
|
||||
for (const r of routes) {
|
||||
|
|
@ -34,7 +53,7 @@ function render() {
|
|||
if (match) {
|
||||
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
|
||||
currentRoot.innerHTML = '';
|
||||
r.handler(currentRoot, params);
|
||||
currentCleanup = r.handler(currentRoot, params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,21 +68,29 @@ export function renderPartyPlayer(root, params) {
|
|||
|
||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||
|
||||
// Named (not inline-anonymous) specifically so cleanup() below can remove
|
||||
// this exact listener - document.addEventListener is a page-lifetime
|
||||
// target, so an anonymous one added on every visit to this route would
|
||||
// never be reachable to remove, and would pile up across visits. This was
|
||||
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
|
||||
// used to leave one of these (plus the heartbeat interval, the socket,
|
||||
// and the hls.js instance) running forever - see router.js's cleanup hook.
|
||||
function onDocumentClick(e) {
|
||||
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
|
||||
}
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
|
||||
settingsBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
settingsPanel.classList.toggle('hide');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
|
||||
});
|
||||
|
||||
let isHost = false;
|
||||
let ws = null;
|
||||
let conn = null;
|
||||
let hls = null;
|
||||
let currentItemId = null;
|
||||
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
|
||||
let heartbeat = null;
|
||||
let hasNextQueued = false;
|
||||
|
||||
function showError(message, detail) {
|
||||
loadingNotice.classList.add('hide');
|
||||
|
|
@ -107,8 +115,8 @@ export function renderPartyPlayer(root, params) {
|
|||
}
|
||||
|
||||
function sendLocalState() {
|
||||
if (!isHost || suppressLocalEvents || !ws) return;
|
||||
ws.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
|
||||
if (!isHost || suppressLocalEvents || !conn) return;
|
||||
conn.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND));
|
||||
}
|
||||
|
||||
async function loadTrackOptions(itemId) {
|
||||
|
|
@ -192,8 +200,7 @@ export function renderPartyPlayer(root, params) {
|
|||
|
||||
async function onItemChanged(msg) {
|
||||
itemTitleEl.textContent = msg.itemName || '';
|
||||
hasNextQueued = !!msg.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
nextBtn.classList.toggle('hide', !(isHost && msg.hasNext));
|
||||
await loadTrackOptions(msg.itemId);
|
||||
loadVideo(msg.itemId, 0, true);
|
||||
}
|
||||
|
|
@ -214,13 +221,12 @@ export function renderPartyPlayer(root, params) {
|
|||
video.controls = isHost;
|
||||
settingsWrap.classList.toggle('hide', !isHost);
|
||||
followerNotice.classList.toggle('hide', isHost);
|
||||
hasNextQueued = !!party.hasNext;
|
||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||
nextBtn.classList.toggle('hide', !(isHost && party.hasNext));
|
||||
|
||||
await loadTrackOptions(party.itemId);
|
||||
loadVideo(party.itemId, 0, false);
|
||||
|
||||
ws = connectParty(partyId, {
|
||||
conn = connectParty(partyId, {
|
||||
onMembers: renderMembers,
|
||||
onPlaystate: applyRemoteState,
|
||||
onItemChanged,
|
||||
|
|
@ -232,7 +238,9 @@ export function renderPartyPlayer(root, params) {
|
|||
video.addEventListener('play', sendLocalState);
|
||||
video.addEventListener('pause', sendLocalState);
|
||||
video.addEventListener('seeked', sendLocalState);
|
||||
video.addEventListener('ended', () => { if (hasNextQueued) ws.next(); });
|
||||
video.addEventListener('ended', () => {
|
||||
if (!nextBtn.classList.contains('hide')) conn.next();
|
||||
});
|
||||
heartbeat = setInterval(sendLocalState, 3000);
|
||||
}
|
||||
}
|
||||
|
|
@ -243,15 +251,20 @@ export function renderPartyPlayer(root, params) {
|
|||
.join(', ');
|
||||
}
|
||||
|
||||
nextBtn.addEventListener('click', () => { if (ws) ws.next(); });
|
||||
|
||||
root.querySelector('#leaveBtn').addEventListener('click', async () => {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (hls) hls.destroy();
|
||||
if (ws) ws.leave();
|
||||
await leaveParty(partyId).catch(() => {});
|
||||
navigate('/home');
|
||||
});
|
||||
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
|
||||
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
||||
|
||||
init();
|
||||
|
||||
// Called by router.js right before it renders whatever route comes next -
|
||||
// whether that's from clicking "Quitter" (navigate above) or leaving any
|
||||
// other way (sidebar link, browser back, ...). This is the fix for the
|
||||
// actual leak: previously, only the "Quitter" button ran any of this.
|
||||
return function cleanup() {
|
||||
if (heartbeat) clearInterval(heartbeat);
|
||||
if (hls) hls.destroy();
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
if (conn) conn.leave();
|
||||
leaveParty(partyId).catch(() => {});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,16 +52,16 @@ possible and this gets revisited.
|
|||
## Components
|
||||
|
||||
```
|
||||
server/ Node.js + Express + ws. Owns party/room state and the realtime
|
||||
sync channel. Never touches a media byte - see "Media never
|
||||
flows through our backend" below.
|
||||
server/ Node.js + Express + Socket.IO. Owns party/room state and the
|
||||
realtime sync channel. Never touches a media byte - see "Media
|
||||
never flows through our backend" below.
|
||||
|
||||
client/ Static frontend, plain JS + ES modules, no build step (same bias
|
||||
as the earlier plugin's control panel). Talks to two different
|
||||
things and must not confuse them:
|
||||
- Emby directly (embyApi.js): catalog, images, streaming.
|
||||
- Our own backend (party.js): create/join/leave a party, and
|
||||
the WebSocket for realtime sync.
|
||||
the Socket.IO connection for realtime sync.
|
||||
```
|
||||
|
||||
### Auth and Emby access: the server proxies everything
|
||||
|
|
@ -115,8 +115,8 @@ so:
|
|||
- The party creator is the host. Their player has normal controls.
|
||||
- Everyone else's `<video>` has controls hidden; a small notice says the
|
||||
host is in charge.
|
||||
- Host → server → everyone else, over the WebSocket
|
||||
(`server/src/wsHandler.js`):
|
||||
- Host → server → everyone else, over Socket.IO
|
||||
(`server/src/socketHandler.js`):
|
||||
- Event-driven: every `play`/`pause`/`seeked` on the host's video sends
|
||||
`{type:"playstate", isPaused, positionTicks}` immediately.
|
||||
- Heartbeat: also sent every 3s regardless, so latecomers/drift get
|
||||
|
|
@ -134,6 +134,58 @@ clears every party, which is fine: a party is meant to last one watch
|
|||
session, not persist for weeks. `removeIdleParties` sweeps anything with no
|
||||
activity for 4 hours.
|
||||
|
||||
## Realtime transport: Socket.IO, and two real memory leaks
|
||||
|
||||
The realtime layer was originally a raw WebSocket (the `ws` package). Two
|
||||
things prompted a rework — a requirement (use Socket.IO instead) and a
|
||||
specific worry (memory leaks) — and auditing for the second turned up two
|
||||
real, concrete leaks, independent of which transport library is used:
|
||||
|
||||
1. **Server-side: dead connections never got cleaned up.** A raw `ws`
|
||||
server has no idea a client is gone unless it receives a proper close
|
||||
frame. A laptop put to sleep, a network drop, a killed tab that never
|
||||
gets to send one — none of those fire `ws`'s `'close'` event, so the
|
||||
`PartyMember`'s socket reference (and everything it closes over) would
|
||||
sit in memory forever, and the party would never get cleaned up by
|
||||
anything (`removeIdleParties` only looks at `lastActivityUtc`, which a
|
||||
phantom connection doesn't affect). `ws`'s own docs recommend a manual
|
||||
ping/pong heartbeat to detect this - Socket.IO has one built in
|
||||
(`pingInterval`/`pingTimeout`), which is a real, not just cosmetic,
|
||||
reason to prefer it here, not only the requested API ergonomics.
|
||||
2. **Client-side: leaving the player any way other than its own "Quitter"
|
||||
button leaked everything.** `router.js` only ever did
|
||||
`currentRoot.innerHTML = ''` between routes — that destroys DOM nodes,
|
||||
but nothing told the outgoing view's *JavaScript* to stop. Clicking a
|
||||
sidebar link or the browser's back button while watching left the
|
||||
player's 3-second heartbeat `setInterval`, its realtime connection, and
|
||||
its `hls.js` instance all running against a detached `<video>` element
|
||||
indefinitely - for as long as the tab stayed open. Fixed by having
|
||||
`route()` handlers optionally return a cleanup function; `router.js`
|
||||
calls whatever the previous view returned right before rendering the
|
||||
next one, regardless of how navigation happened. `player.js` returns one
|
||||
that clears the heartbeat, destroys `hls`, disconnects the socket, and
|
||||
removes the one `document`-level click listener it adds (named, not
|
||||
inline-anonymous, specifically so it's removable at all - an anonymous
|
||||
listener added fresh on every visit would itself have been a slow leak
|
||||
even with everything else fixed).
|
||||
|
||||
Both are now covered by the same class of test: drive the router through
|
||||
several "join a party, then navigate away without clicking Quitter" cycles
|
||||
and check nothing accumulates. Verified directly (see session history) by
|
||||
instrumenting `document.addEventListener`/`removeEventListener` and
|
||||
`setInterval`/`clearInterval` around three such cycles against a really-running
|
||||
local server (Socket.IO connection included, not mocked) - adds and removes
|
||||
matched exactly (3/3 listeners, 2/2 intervals across two cycles), nothing
|
||||
left pending. The server-side heartbeat fix is Socket.IO's own
|
||||
well-established mechanism, not custom code, so it wasn't separately
|
||||
instrumented the same way.
|
||||
|
||||
`server/src/socketHandler.js` mirrors the old `wsHandler.js` protocol
|
||||
closely (see its own top comment for the exact event list) - the one naming
|
||||
difference is the error event, sent as `"errorMessage"` rather than
|
||||
`"error"`, since Socket.IO reserves the latter for its own internal error
|
||||
handling.
|
||||
|
||||
## Assumptions to verify
|
||||
|
||||
**Confirmed against the real server** (not a guess — actually exercised,
|
||||
|
|
@ -228,8 +280,8 @@ fetch the rest of that series (`/Shows/{seriesId}/Episodes`, no season
|
|||
filter) and store everything from the clicked episode onward as the party's
|
||||
`queue` (`server/src/partyManager.js`). The host gets an "Épisode suivant"
|
||||
button once `hasNext` is true; clicking it (or the video's `ended` event
|
||||
firing) sends a `{type:"next"}` WebSocket message
|
||||
(`server/src/wsHandler.js`), host-only, which advances `queueIndex`,
|
||||
firing) sends a `"next"` Socket.IO event
|
||||
(`server/src/socketHandler.js`), host-only, which advances `queueIndex`,
|
||||
resets every member's known position, and broadcasts `{type:"itemChanged",
|
||||
itemId, itemName, ...}` — every client (including the host) reloads the
|
||||
player against the new `itemId` on that message. A movie or a
|
||||
|
|
|
|||
238
server/package-lock.json
generated
238
server/package-lock.json
generated
|
|
@ -9,12 +9,45 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
"socket.io": "^4.7.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
|
|
@ -34,6 +67,15 @@
|
|||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.8",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||
|
|
@ -148,6 +190,23 @@
|
|||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -205,6 +264,58 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.10",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz",
|
||||
"integrity": "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -545,6 +656,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
|
|
@ -786,6 +906,116 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
|
||||
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
@ -817,6 +1047,12 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,6 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"ws": "^8.18.0"
|
||||
"socket.io": "^4.7.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
const path = require('path');
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { Server: SocketIoServer } = require('socket.io');
|
||||
|
||||
const {
|
||||
EMBY_SERVER_URL,
|
||||
|
|
@ -17,7 +17,7 @@ const {
|
|||
} = require('./embyAuth');
|
||||
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
|
||||
const { PartyManager } = require('./partyManager');
|
||||
const { attachWebSocketServer } = require('./wsHandler');
|
||||
const { attachSocketServer } = require('./socketHandler');
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||
|
|
@ -400,8 +400,13 @@ app.get('/api/parties/:partyId', requireSession, (req, res) => {
|
|||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
attachWebSocketServer(wss, { parseCookies, getSession });
|
||||
// Same-origin by default (no `cors` option needed) - the client connects
|
||||
// with a bare io() call, consistent with everything else in this app never
|
||||
// crossing origins. Socket.IO's own heartbeat (pingInterval/pingTimeout,
|
||||
// defaults are fine here) is what detects dead connections - see
|
||||
// socketHandler.js's top comment for why that matters.
|
||||
const io = new SocketIoServer(server);
|
||||
attachSocketServer(io, { parseCookies, getSession });
|
||||
|
||||
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
||||
|
||||
|
|
|
|||
119
server/src/socketHandler.js
Normal file
119
server/src/socketHandler.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
'use strict';
|
||||
|
||||
const { PartyManager } = require('./partyManager');
|
||||
|
||||
/**
|
||||
* Realtime side of a party, over Socket.IO instead of a raw WebSocket.
|
||||
*
|
||||
* Why the switch: Socket.IO gives us two things a raw `ws` server needs
|
||||
* hand-rolled - automatic client-side reconnection, and (the important one)
|
||||
* server-side heartbeat-based dead-connection detection. Without that,
|
||||
* a client that vanishes without a clean close (laptop sleep, network drop,
|
||||
* a killed tab that doesn't get to send a close frame) never fires a
|
||||
* disconnect event on a raw `ws` server - its party membership and the
|
||||
* dead socket reference stay in memory forever. Socket.IO's built-in
|
||||
* ping/pong (`pingInterval`/`pingTimeout`) closes those out on its own.
|
||||
*
|
||||
* Auth comes from the session cookie sent during the initial HTTP handshake,
|
||||
* same as the raw-WebSocket version - exposed here via
|
||||
* socket.request.headers.cookie.
|
||||
*
|
||||
* Events (client -> server unless noted):
|
||||
* "join" {partyId}
|
||||
* "members" (server -> client) [...]
|
||||
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
||||
* "playstate" (server -> client, to everyone but the sender)
|
||||
* "next" (host only - advance the queue)
|
||||
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||
* "leave"
|
||||
* "errorMessage" (server -> client) {message}
|
||||
* ("error" is a reserved Socket.IO event name - using our own to avoid
|
||||
* colliding with its internal error handling.)
|
||||
*/
|
||||
function attachSocketServer(io, { parseCookies, getSession }) {
|
||||
io.on('connection', (socket) => {
|
||||
const cookies = parseCookies(socket.request.headers.cookie);
|
||||
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
|
||||
|
||||
if (!session) {
|
||||
socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
let party = null;
|
||||
|
||||
socket.on('join', (msg) => {
|
||||
const p = PartyManager.getById(msg && msg.partyId);
|
||||
const member = p && p.members.get(userId);
|
||||
if (!p || !member) {
|
||||
socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." });
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
party = p;
|
||||
member.connection = socket;
|
||||
socket.join(party.id);
|
||||
broadcastMembers(io, party);
|
||||
});
|
||||
|
||||
socket.on('playstate', (msg) => {
|
||||
if (!party || userId !== party.hostUserId) {
|
||||
return; // only the host drives playback - see docs/architecture.md
|
||||
}
|
||||
const member = party.members.get(userId);
|
||||
member.isPaused = !!(msg && msg.isPaused);
|
||||
member.positionTicks = Number(msg && msg.positionTicks) || 0;
|
||||
member.lastUpdatedUtc = Date.now();
|
||||
party.lastActivityUtc = Date.now();
|
||||
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
|
||||
});
|
||||
|
||||
socket.on('next', () => {
|
||||
if (!party || userId !== party.hostUserId) {
|
||||
return; // only the host can advance the queue
|
||||
}
|
||||
if (PartyManager.advanceQueue(party)) {
|
||||
io.to(party.id).emit('itemChanged', {
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
queueIndex: party.queueIndex,
|
||||
queueLength: party.queue.length,
|
||||
hasNext: party.queueIndex < party.queue.length - 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('leave', () => {
|
||||
if (!party) return;
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(io, party);
|
||||
socket.disconnect(true);
|
||||
});
|
||||
|
||||
// Fires on a clean "leave", a dead-connection timeout (see the class
|
||||
// comment above), or literally any other way the socket goes away -
|
||||
// this is the one cleanup path that always runs, unlike relying on the
|
||||
// client to always get a chance to emit "leave" first.
|
||||
socket.on('disconnect', () => {
|
||||
if (party) {
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(io, party);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastMembers(io, party) {
|
||||
const members = Array.from(party.members.values()).map((m) => ({
|
||||
userId: m.userId,
|
||||
userName: m.userName,
|
||||
isHost: m.userId === party.hostUserId,
|
||||
connected: !!m.connection
|
||||
}));
|
||||
io.to(party.id).emit('members', members);
|
||||
}
|
||||
|
||||
module.exports = { attachSocketServer };
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const { PartyManager } = require('./partyManager');
|
||||
|
||||
/**
|
||||
* Realtime side of a party. Auth comes from the session cookie sent during
|
||||
* the WebSocket's HTTP upgrade request - browsers attach cookies to that
|
||||
* handshake the same as any other same-origin request, so there's no
|
||||
* separate "send me your token" step like the previous (pre-proxy) design
|
||||
* needed: sessions are server-side now (see embyAuth.js), not a token the
|
||||
* client held.
|
||||
*
|
||||
* Protocol (JSON messages):
|
||||
* -> {type:"join", partyId}
|
||||
* <- {type:"members", members:[...]} (on any join/leave)
|
||||
* -> {type:"playstate", isPaused, positionTicks} (host only - ignored otherwise)
|
||||
* <- {type:"playstate", isPaused, positionTicks} (relayed to everyone but the host)
|
||||
* -> {type:"next"} (host only - advance the queue, e.g. next episode)
|
||||
* <- {type:"itemChanged", itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
||||
* -> {type:"leave"}
|
||||
* <- {type:"error", message}
|
||||
*/
|
||||
function attachWebSocketServer(wss, { parseCookies, getSession }) {
|
||||
wss.on('connection', (ws, req) => {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
|
||||
|
||||
if (!session) {
|
||||
send(ws, { type: 'error', message: 'Session expirée - reconnecte-toi.' });
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = session.userId;
|
||||
let party = null;
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'join') {
|
||||
const p = PartyManager.getById(msg.partyId);
|
||||
const member = p && p.members.get(userId);
|
||||
if (!p || !member) {
|
||||
send(ws, { type: 'error', message: "Tu n'es pas membre de cette party." });
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
party = p;
|
||||
member.connection = ws;
|
||||
broadcastMembers(party);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!party) {
|
||||
return; // ignore anything before a successful join
|
||||
}
|
||||
|
||||
if (msg.type === 'playstate') {
|
||||
if (userId !== party.hostUserId) {
|
||||
return; // only the host drives playback - see docs/architecture.md
|
||||
}
|
||||
const member = party.members.get(userId);
|
||||
member.isPaused = !!msg.isPaused;
|
||||
member.positionTicks = Number(msg.positionTicks) || 0;
|
||||
member.lastUpdatedUtc = Date.now();
|
||||
party.lastActivityUtc = Date.now();
|
||||
broadcast(party, { type: 'playstate', isPaused: member.isPaused, positionTicks: member.positionTicks }, ws);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'next') {
|
||||
if (userId !== party.hostUserId) {
|
||||
return; // only the host can advance the queue
|
||||
}
|
||||
if (PartyManager.advanceQueue(party)) {
|
||||
broadcast(party, {
|
||||
type: 'itemChanged',
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
queueIndex: party.queueIndex,
|
||||
queueLength: party.queue.length,
|
||||
hasNext: party.queueIndex < party.queue.length - 1
|
||||
}, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'leave') {
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(party);
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (party) {
|
||||
PartyManager.leave(party, userId);
|
||||
broadcastMembers(party);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(ws, message) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(party, message, exceptWs) {
|
||||
for (const member of party.members.values()) {
|
||||
if (member.connection && member.connection !== exceptWs) {
|
||||
send(member.connection, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastMembers(party) {
|
||||
const members = Array.from(party.members.values()).map((m) => ({
|
||||
userId: m.userId,
|
||||
userName: m.userName,
|
||||
isHost: m.userId === party.hostUserId,
|
||||
connected: !!m.connection
|
||||
}));
|
||||
broadcast(party, { type: 'members', members }, null);
|
||||
}
|
||||
|
||||
module.exports = { attachWebSocketServer };
|
||||
Loading…
Reference in a new issue