From 109941e0ddf67bd39f4452ea316f19799d2e736e Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 30 May 2026 13:23:19 +0300 Subject: [PATCH] fix(media): deliver session to the service worker on controllerchange and drop the poisoned handshake promise on timeout so authenticated media stops 401ing --- src/sw-session.ts | 65 ++++++++++++++++++++++++++++++++++++++++++----- src/sw.ts | 21 +++++++++++++-- 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055..a9ff268d 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,10 +1,61 @@ +// Bridge the live Matrix session (baseUrl + accessToken) to the service +// worker, which attaches `Authorization: Bearer ` to authenticated +// media requests (/_matrix/client/v1/media/{download,thumbnail}) — see +// src/sw.ts fetch handler. Without this, media GETs hit the homeserver +// unauthenticated and 401. +// +// First-load race: on the very first visit after SW install, +// `navigator.serviceWorker.controller` is null — the worker has registered +// but not yet taken over (it calls `self.clients.claim()` in its activate +// handler). A direct postMessage at that moment silently drops, and there is +// no controller to receive it. So we keep the latest session in +// `pendingSession` and re-attempt when (a) the registration becomes ready and +// (b) on every `controllerchange`. This mirrors the language bridge in +// src/app/utils/pushLanguageBridge.ts, which already covers this exact window. +// Both retry paths no-op once the controller is set and the post has landed, +// so duplicates are cheap. + +type PendingSession = { baseUrl?: string; accessToken?: string }; + +let pendingSession: PendingSession | undefined; +let swControllerWatchInstalled = false; + +const trySendSession = (): void => { + if (pendingSession === undefined) return; + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + const sw = navigator.serviceWorker; + if (!sw.controller) return; + try { + sw.controller.postMessage({ + type: 'setSession', + accessToken: pendingSession.accessToken, + baseUrl: pendingSession.baseUrl, + }); + } catch { + /* ignore — next controllerchange re-posts */ + } +}; + +const installSwControllerWatch = (): void => { + if (swControllerWatchInstalled) return; + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return; + swControllerWatchInstalled = true; + const sw = navigator.serviceWorker; + // `ready` resolves once there is an active registration; our SW then calls + // `self.clients.claim()` in its activate handler, so shortly after `ready` + // the page gains a controller and `controllerchange` fires. We listen to + // both so the first-install window is covered regardless of which fires + // first. + sw.ready.then(trySendSession).catch(() => undefined); + sw.addEventListener('controllerchange', trySendSession); +}; + export function pushSessionToSW(baseUrl?: string, accessToken?: string) { if (!('serviceWorker' in navigator)) return; - if (!navigator.serviceWorker.controller) return; - - navigator.serviceWorker.controller.postMessage({ - type: 'setSession', - accessToken, - baseUrl, - }); + // Remember the latest session even when there is no controller yet, then + // attempt delivery now and again whenever the controller appears. Logout + // passes both args undefined, which the SW reads as "clear session". + pendingSession = { baseUrl, accessToken }; + installSwControllerWatch(); + trySendSession(); } diff --git a/src/sw.ts b/src/sw.ts index d9a7d606..a381ba28 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -69,11 +69,28 @@ async function requestSessionWithTimeout( const sessionPromise = requestSession(client); + let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { - setTimeout(() => resolve(undefined), timeoutMs); + timer = setTimeout(() => resolve(undefined), timeoutMs); }); - return Promise.race([sessionPromise, timeout]); + const result = await Promise.race([sessionPromise, timeout]); + if (timer !== undefined) clearTimeout(timer); + + if (!result) { + // Handshake missed: the client didn't reply within timeoutMs (or replied + // with no session). Drop the cached pending promise + resolver so the NEXT + // media request issues a FRESH requestSession instead of re-racing this + // already-settled (undefined) promise. Without this, a single first-load + // miss poisons the cache and every later media request falls back to an + // unauthenticated fetch → permanent 401 until the page reloads. If the + // real session arrives late, setSession() still stores it in `sessions`, + // so the next request takes the fast cache-hit path above anyway. + clientToResolve.delete(clientId); + clientToSessionPromise.delete(clientId); + } + + return result; } self.addEventListener('install', () => {