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>
76 lines
2.6 KiB
JavaScript
76 lines
2.6 KiB
JavaScript
// 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, {
|
|
credentials: 'same-origin',
|
|
...options,
|
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function listActiveParties() {
|
|
const data = await apiFetch('/api/parties');
|
|
return data.parties || [];
|
|
}
|
|
|
|
export function createParty(itemId) {
|
|
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
|
}
|
|
|
|
export function joinParty(joinCode) {
|
|
return apiFetch(`/api/parties/${encodeURIComponent(joinCode)}/join`, { method: 'POST' });
|
|
}
|
|
|
|
export function leaveParty(partyId) {
|
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
|
}
|
|
|
|
export function getParty(partyId) {
|
|
return apiFetch(`/api/parties/${partyId}`);
|
|
}
|
|
|
|
/**
|
|
* 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 socket = window.io();
|
|
|
|
// 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 });
|
|
});
|
|
|
|
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) {
|
|
socket.emit('playstate', { isPaused, positionTicks });
|
|
},
|
|
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
|
next() {
|
|
socket.emit('next');
|
|
},
|
|
leave() {
|
|
socket.emit('leave');
|
|
socket.disconnect();
|
|
}
|
|
};
|
|
}
|