diff --git a/deploy/varnish/default.vcl.template b/deploy/varnish/default.vcl.template index 507e6ac..adf68a9 100644 --- a/deploy/varnish/default.vcl.template +++ b/deploy/varnish/default.vcl.template @@ -31,10 +31,16 @@ sub vcl_recv { # omijamy cache dla healthchecków / wewnętrznych nagłówków if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); } - # Specjalna obsługa WebSocket i socket.io - if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") { + # WebSocket wymaga surowego tunelu TCP, ale zwykły transport polling + # Socket.IO jest normalnym HTTP i nie może trafiać do pipe. Puszczenie całego + # /socket.io/ przez pipe może pozostawić połączenie HTTP przypięte do backendu + # po uśpieniu mobilnej przeglądarki i utrudniać późniejszy refresh/nawigację. + if (req.http.Upgrade ~ "(?i)websocket") { return (pipe); } + if (req.url ~ "^/socket.io/") { + return (pass); + } # metody inne niż GET/HEAD bez cache if (req.method != "GET" && req.method != "HEAD") { return (pass); } @@ -103,7 +109,7 @@ sub vcl_recv { sub vcl_pipe { if (req.http.Upgrade) { set bereq.http.Upgrade = req.http.Upgrade; - set bereq.http.Connection = req.http.Connection; + set bereq.http.Connection = "Upgrade"; } } diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index effbd8a..00002a4 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -221,6 +221,7 @@ def handle_join(data): global active_users room = str(data["room"]) username = data.get("username", "Gość") + silent = bool(data.get("silent", False)) sid = request.sid # Trzymamy obecność per SID, a nie tylko per nazwa użytkownika. Dzięki temu @@ -245,10 +246,20 @@ def handle_join(data): emit( "joined_confirmation", - {"room": room, "list_title": list_title}, + { + "room": room, + "list_title": list_title, + "users": sorted(set(room_users.values())), + "silent": silent, + }, to=sid, ) - return {"ok": True, "room": room, "already_joined": already_joined} + return { + "ok": True, + "room": room, + "already_joined": already_joined, + "silent": silent, + } @socketio.on("disconnect") diff --git a/shopping_app/static/js/live.js b/shopping_app/static/js/live.js index aae69cc..985b20b 100644 --- a/shopping_app/static/js/live.js +++ b/shopping_app/static/js/live.js @@ -1,4 +1,10 @@ -const socket = io(); +const socket = io({ + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 500, + reconnectionDelayMax: 5000, + timeout: 8000 +}); /*──────────────── placeholder pustej listy ────────────────*/ function toggleEmptyPlaceholder() { @@ -121,7 +127,7 @@ function setupList(listId, username) { } if (typeof window.recoverListSocket === 'function') { - window.recoverListSocket({ force: true }); + window.recoverListSocket(); } }); } diff --git a/shopping_app/static/js/sockets.js b/shopping_app/static/js/sockets.js index ce07d58..e3476e5 100644 --- a/shopping_app/static/js/sockets.js +++ b/shopping_app/static/js/sockets.js @@ -1,17 +1,29 @@ let didReceiveFirstFullList = false; -// Karta może zostać zamrożona lub transport może umrzeć bez natychmiastowej -// zmiany socket.connected. Po wznowieniu najpierw weryfikujemy połączenie ACK-em. -// Reconnect robimy dopiero wtedy, gdy transport faktycznie nie odpowiada. -const WAKE_RECONNECT_DEBOUNCE_MS = 200; -const WAKE_WATCHDOG_INTERVAL_MS = 10000; -const WAKE_WATCHDOG_GAP_MS = 25000; -let recoveryTimer = null; -let recoveryForceRequested = false; -let recoveryInProgress = false; -let lastWatchdogTick = Date.now(); +// Stan dołączenia do aktualnego pokoju. Pierwsze udane wejście ma pokazać +// użytkownikowi potwierdzenie i listę obecnych osób. Każdy kolejny join po +// reconnectcie jest cichy po stronie tej karty. let joinedSocketId = null; let joinedRoomId = null; +let didCompleteInitialJoin = false; +let currentRoomUsers = []; + +// Safari i inne mobilne przeglądarki potrafią zamrozić kartę razem z transportem. +// Po wznowieniu najpierw robimy krótki request z ACK. Jeżeli stary transport +// faktycznie nie odpowiada, zamykamy wyłącznie transport Engine.IO i pozwalamy +// wbudowanemu mechanizmowi Socket.IO zestawić nowe połączenie. Nie wywołujemy +// socket.disconnect(), bo jest to ręczne rozłączenie i może wejść w konflikt +// z automatycznym reconnectem. +const WAKE_CHECK_DEBOUNCE_MS = 250; +const WAKE_ACK_TIMEOUT_MS = 4000; +const WAKE_WATCHDOG_INTERVAL_MS = 5000; +const WAKE_WATCHDOG_GAP_MS = 15000; +const WAKE_RETRY_CONNECT_MS = 1200; + +let wakeCheckTimer = null; +let wakeCheckInProgress = false; +let reconnectFallbackTimer = null; +let lastWatchdogTick = Date.now(); function hasActiveListRoom() { return Boolean(window.LIST_ID && window.usernameForReconnect); @@ -20,7 +32,6 @@ function hasActiveListRoom() { function disableCheckboxes(disable) { document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => { if (disable) { - // Zapamiętaj stan biznesowy tylko przy pierwszym nałożeniu blokady transportu. if (cb.dataset.socketDisabledBefore === undefined) { cb.dataset.socketDisabledBefore = cb.disabled ? '1' : '0'; } @@ -41,99 +52,119 @@ function ensureCurrentListJoined() { const roomId = String(window.LIST_ID); const socketId = socket.id || null; - // join_list może przyjść zarówno z setupList(), jak i z eventu connect. - // Ten klucz sprawia, że dla jednego fizycznego połączenia wysyłamy go tylko raz. if (joinedSocketId === socketId && joinedRoomId === roomId) return false; joinedSocketId = socketId; joinedRoomId = roomId; + socket.emit('join_list', { room: window.LIST_ID, - username: window.usernameForReconnect + username: window.usernameForReconnect, + // Pierwsze wejście jest widoczne. Reconnect tej samej strony jest cichy. + silent: didCompleteInitialJoin }); return true; } window.ensureCurrentListJoined = ensureCurrentListJoined; -function requestCurrentList({ verify = false } = {}) { +function requestCurrentList() { if (!socket.connected || !window.LIST_ID) return; + socket.emit('request_full_list', { list_id: window.LIST_ID }); +} - if (!verify) { - socket.emit('request_full_list', { list_id: window.LIST_ID }); +function forceCloseStaleTransport() { + wakeCheckInProgress = false; + joinedSocketId = null; + joinedRoomId = null; + + if (window.LIST_ID) disableCheckboxes(true); + + try { + const engine = socket.io && socket.io.engine; + const transport = engine && engine.transport; + + // Zamknięcie samego transportu zachowuje automatyczny reconnect Managera. + // Transport.close() emituje close od razu, zamiast czekać na opróżnienie + // bufora starego, zamrożonego połączenia. + if (transport && transport.readyState !== 'closed') { + transport.close(); + } else if (!socket.connected) { + socket.connect(); + } + } catch (err) { + console.warn('Nie udało się zamknąć starego transportu Socket.IO', err); + if (!socket.connected) socket.connect(); + } + + clearTimeout(reconnectFallbackTimer); + reconnectFallbackTimer = setTimeout(() => { + if (document.hidden || !hasActiveListRoom() || socket.connected) return; + socket.connect(); + }, WAKE_RETRY_CONNECT_MS); +} + +function runWakeCheck() { + if (document.hidden || !hasActiveListRoom()) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + + if (!socket.connected) { + disableCheckboxes(true); + socket.connect(); return; } - socket.timeout(4000).emit('request_full_list', { list_id: window.LIST_ID }, (err, response) => { - if (!err && response && response.ok === true) return; - recoverListSocket({ force: true }); - }); + const engine = socket.io && socket.io.engine; + if (!engine || engine.readyState !== 'open' || !engine.transport || engine.transport.readyState !== 'open') { + forceCloseStaleTransport(); + return; + } + + if (wakeCheckInProgress) return; + wakeCheckInProgress = true; + + // request_full_list pełni jednocześnie rolę health-checka i synchronizacji. + socket.timeout(WAKE_ACK_TIMEOUT_MS).emit( + 'request_full_list', + { list_id: window.LIST_ID }, + (err, response) => { + wakeCheckInProgress = false; + + if (!err && response && response.ok === true) { + // Jeśli połączenie jest zdrowe, nie wykonujemy reconnectu i nie emitujemy + // ponownego join_list. Samo przełączenie karty pozostaje więc całkowicie ciche. + return; + } + + forceCloseStaleTransport(); + } + ); } -function recoverListSocket({ force = false } = {}) { - recoveryForceRequested = recoveryForceRequested || force; - clearTimeout(recoveryTimer); - - recoveryTimer = setTimeout(() => { - const shouldForce = recoveryForceRequested; - recoveryForceRequested = false; - - if (document.hidden) return; - - if (!socket.connected) { - if (window.LIST_ID) disableCheckboxes(true); - socket.connect(); - return; - } - - if (shouldForce && hasActiveListRoom()) { - if (recoveryInProgress) return; - recoveryInProgress = true; - disableCheckboxes(true); - joinedSocketId = null; - joinedRoomId = null; - - // Twardy reconnect tylko po realnym błędzie/timeout ACK, nie po samym - // przełączeniu karty lub powrocie aplikacji na pierwszy plan. - socket.disconnect(); - setTimeout(() => socket.connect(), 0); - return; - } - - if (hasActiveListRoom()) { - // Jeśli transport żyje, synchronizacja wystarczy. Timeout sam uruchomi reconnect. - requestCurrentList({ verify: true }); - } - }, WAKE_RECONNECT_DEBOUNCE_MS); +function recoverListSocket() { + clearTimeout(wakeCheckTimer); + wakeCheckTimer = setTimeout(runWakeCheck, WAKE_CHECK_DEBOUNCE_MS); } -// Udostępnione dla akcji w live.js (np. timeout checkboxa). window.recoverListSocket = recoverListSocket; -// Powrót po zablokowaniu telefonu, zmianie aplikacji lub karty. +// Najważniejszy przypadek: powrót po zablokowaniu telefonu / zmianie aplikacji. document.addEventListener('visibilitychange', () => { if (!document.hidden) recoverListSocket(); }); -// BFCache może przywrócić stronę ze starym transportem Socket.IO. -window.addEventListener('pageshow', () => { - recoverListSocket(); +// pageshow bez persisted występuje także przy zwykłym pierwszym ładowaniu strony. +// Reconnect jest potrzebny wyłącznie przy rzeczywistym powrocie z BFCache. +window.addEventListener('pageshow', event => { + if (event.persisted) recoverListSocket(); }); -window.addEventListener('focus', () => { - recoverListSocket(); -}); +window.addEventListener('online', recoverListSocket); -window.addEventListener('online', () => { - recoverListSocket(); -}); +// Dla środowisk emitujących zdarzenie resume (np. webview/PWA wrapper). +document.addEventListener('resume', recoverListSocket); -document.addEventListener('resume', () => { - recoverListSocket(); -}); - -// Watchdog łapie także wznowienia, przy których przeglądarka nie wyśle poprawnie -// visibilitychange/pageshow (np. po agresywnym uśpieniu procesu lub karty). +// Fallback dla przeglądarek, które po wybudzeniu nie wyślą visibilitychange. setInterval(() => { const now = Date.now(); const gap = now - lastWatchdogTick; @@ -145,7 +176,8 @@ setInterval(() => { }, WAKE_WATCHDOG_INTERVAL_MS); socket.on('connect', function () { - recoveryInProgress = false; + wakeCheckInProgress = false; + clearTimeout(reconnectFallbackTimer); joinedSocketId = null; joinedRoomId = null; @@ -156,19 +188,36 @@ socket.on('connect', function () { }); socket.on('disconnect', function () { + wakeCheckInProgress = false; joinedSocketId = null; joinedRoomId = null; if (window.LIST_ID) disableCheckboxes(true); }); socket.on('connect_error', function () { + wakeCheckInProgress = false; if (window.LIST_ID) disableCheckboxes(true); }); socket.off('joined_confirmation'); -socket.on('joined_confirmation', function () { - // Potwierdzenie dołączenia nie jest komunikatem dla użytkownika. Po zwykłym - // reconnectcie karta ma wrócić do działania bez sztucznego "ponownie dołączono". +socket.on('joined_confirmation', function (data) { + const isInitialVisibleJoin = !didCompleteInitialJoin && !data.silent; + didCompleteInitialJoin = true; + + if (Array.isArray(data.users)) { + currentRoomUsers = data.users; + window.currentRoomUsers = data.users; + } + + if (isInitialVisibleJoin) { + const title = data.list_title ? `: ${data.list_title}` : ''; + showToast(`Dołączono do listy${title}`, 'success'); + + if (currentRoomUsers.length > 0) { + showToast(`Obecni: ${currentRoomUsers.join(', ')}`, 'info'); + } + } + requestCurrentList(); }); @@ -181,10 +230,11 @@ socket.on('user_left', function (data) { }); socket.on('user_list', function (data) { - if (data.users.length > 0) { - const userList = data.users.join(', '); - showToast(`Obecni: ${userList}`, 'info'); - } + // Stan obecności przechowujemy, ale nie pokazujemy dodatkowego toasta przy + // każdej zmianie. Pierwsze wejście pokazuje pełną listę z joined_confirmation, + // a później wystarczają komunikaty user_joined / user_left. + currentRoomUsers = Array.isArray(data.users) ? data.users : []; + window.currentRoomUsers = currentRoomUsers; }); socket.on('receipt_added', function (data) { @@ -227,6 +277,7 @@ socket.on("items_reordered", data => { socket.on('full_list', function (data) { const itemsContainer = document.getElementById('items'); + if (!itemsContainer) return; const oldItems = Array.from( itemsContainer.querySelectorAll('li[data-name], li[id^="item-"]') @@ -237,7 +288,7 @@ socket.on('full_list', function (data) { window.currentItems = data.items; updateListSmoothly(data.items); disableCheckboxes(false); - recoveryInProgress = false; + wakeCheckInProgress = false; if (typeof window.syncSortModeUI === 'function') { window.syncSortModeUI(); } @@ -255,4 +306,4 @@ socket.on('item_marked_not_purchased', data => { socket.on('item_unmarked_not_purchased', data => { socket.emit('request_full_list', { list_id: window.LIST_ID }); -}); \ No newline at end of file +});