diff --git a/.changeset/media-401-sw-retry.md b/.changeset/media-401-sw-retry.md new file mode 100644 index 000000000..49013e6aa --- /dev/null +++ b/.changeset/media-401-sw-retry.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Retry 401 image and URL requests in service worker by refreshing cached auth tokens diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index ad51d2a4f..af673adc8 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -33,8 +33,22 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '$utils/matrix import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { ModalWide } from '$styles/Modal.css'; import { validBlurHash } from '$utils/blurHash'; +import { createDebugLogger } from '$utils/debugLogger'; import * as css from './style.css'; +const debugLog = createDebugLogger('ImageContent'); + +const addCacheBuster = (inputUrl: string): string => { + try { + const parsed = new URL(inputUrl); + parsed.searchParams.set('_sable_retry', String(Date.now())); + return parsed.toString(); + } catch { + const join = inputUrl.includes('?') ? '&' : '?'; + return `${inputUrl}${join}_sable_retry=${Date.now()}`; + } +}; + type RenderViewerProps = { src: string; alt: string; @@ -89,36 +103,96 @@ export const ImageContent = as<'div', ImageContentProps>( const [viewer, setViewer] = useState(false); const [blurred, setBlurred] = useState(markedAsSpoiler ?? false); const [isHovered, setIsHovered] = useState(false); + const [didForceRemoteRetry, setDidForceRemoteRetry] = useState(false); const [srcState, loadSrc] = useAsyncCallback( - useCallback(async () => { - if (url.startsWith('http')) return url; + useCallback( + async (forceRemote = false) => { + if (url.startsWith('http')) return forceRemote ? addCacheBuster(url) : url; - const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); - if (!mediaUrl) throw new Error('Invalid media URL'); - if (encInfo) { - const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) => - decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) - ); - return URL.createObjectURL(fileContent); - } - return mediaUrl; - }, [mx, url, useAuthentication, mimeType, encInfo]) + const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication); + if (!mediaUrl) throw new Error('Invalid media URL'); + const resolvedUrl = forceRemote ? addCacheBuster(mediaUrl) : mediaUrl; + if (encInfo) { + const fileContent = await downloadEncryptedMedia(resolvedUrl, (encBuf) => + decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo) + ); + return URL.createObjectURL(fileContent); + } + return resolvedUrl; + }, + [mx, url, useAuthentication, mimeType, encInfo] + ) ); const handleLoad = () => { setLoad(true); + if (didForceRemoteRetry) { + debugLog.info('network', 'Image loaded after retry', { + forcedRemoteRetry: true, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + } }; const handleError = () => { setLoad(false); setError(true); + if (didForceRemoteRetry) { + debugLog.warn('network', 'Image still failed after retry', { + forcedRemoteRetry: true, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + } }; const handleRetry = () => { setError(false); - loadSrc(); + const forceRemote = !didForceRemoteRetry; + debugLog.info('network', 'Image retry requested', { + forceRemote, + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + if (forceRemote) { + setDidForceRemoteRetry(true); + loadSrc(true) + .then(() => { + debugLog.info('network', 'Forced remote retry source resolved', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + }) + .catch((err) => { + debugLog.warn('network', 'Forced remote retry source failed', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + loadSrc() + .then(() => { + debugLog.info('network', 'Standard retry source resolved', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + }); + }) + .catch((err) => { + debugLog.warn('network', 'Standard retry source failed', { + encrypted: !!encInfo, + isHttpUrl: url.startsWith('http'), + error: err instanceof Error ? err.message : String(err), + }); + }); }; + useEffect(() => { + setDidForceRemoteRetry(false); + }, [url]); + useEffect(() => { if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 3ac780f74..a29ed629c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { IPreviewUrlResponse } from '$types/matrix-sdk'; +import { IPreviewUrlResponse, MatrixError } from '$types/matrix-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -20,9 +20,9 @@ const linkStyles = { color: color.Success.Main }; // Inner cache keyed by URL only (not ts) — the same URL shows the same preview // regardless of which message referenced it. Promises are evicted after settling // so a later render can retry after network recovery. -const previewRequestCache = new WeakMap>>(); +const previewRequestCache = new WeakMap>>(); -const getClientCache = (mx: any): Map> => { +const getClientCache = (mx: any): Map> => { let clientCache = previewRequestCache.get(mx); if (!clientCache) { clientCache = new Map(); @@ -31,6 +31,24 @@ const getClientCache = (mx: any): Map> => { return clientCache; }; +const normalizePreviewUrl = (input: string): string => { + const trimmed = input.trim().replace(/^<+/, '').replace(/>+$/, ''); + + try { + const parsed = new URL(trimmed); + parsed.pathname = parsed.pathname.replace(/(?:%60|`)+$/gi, ''); + return parsed.toString(); + } catch { + // Keep the original-ish value; URL preview fetch will fail gracefully. + return trimmed.replace(/(?:%60|`)+$/gi, ''); + } +}; + +const isIgnorablePreviewError = (error: unknown): boolean => { + if (!(error instanceof MatrixError)) return false; + return error.httpStatus === 404 || error.httpStatus === 502; +}; + const openMediaInNewTab = async (url: string | undefined) => { if (!url) { console.warn('Attempted to open an empty url'); @@ -45,25 +63,35 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s ({ url, ts, mediaType, ...props }, ref) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const previewUrl = normalizePreviewUrl(url); const isDirect = !!mediaType; const [previewStatus, loadPreview] = useAsyncCallback( - useCallback(() => { + useCallback(async () => { if (isDirect) return Promise.resolve(null); const clientCache = getClientCache(mx); - const cached = clientCache.get(url); + const cached = clientCache.get(previewUrl); if (cached !== undefined) return cached; - const urlPreview = mx.getUrlPreview(url, ts); - clientCache.set(url, urlPreview); - urlPreview.finally(() => clientCache.delete(url)); + + const urlPreview = (async () => { + try { + return await mx.getUrlPreview(previewUrl, ts); + } catch (error) { + if (isIgnorablePreviewError(error)) return null; + throw error; + } + })(); + + clientCache.set(previewUrl, urlPreview); + urlPreview.finally(() => clientCache.delete(previewUrl)); return urlPreview; - }, [url, ts, mx, isDirect]) + }, [previewUrl, ts, mx, isDirect]) ); useEffect(() => { loadPreview(); - }, [url, loadPreview]); + }, [previewUrl, loadPreview]); if (previewStatus.status === AsyncStatus.Error) return null; @@ -117,14 +145,14 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s style={linkStyles} truncate as="a" - href={url} + href={previewUrl} target="_blank" rel="noreferrer" size="T200" priority="300" > {typeof siteName === 'string' && `${siteName} | `} - {safeDecodeUrl(url)} + {safeDecodeUrl(previewUrl)} {title && ( @@ -216,13 +244,13 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number; mediaType?: s style={linkStyles} truncate as="a" - href={url} + href={previewUrl} target="_blank" rel="noreferrer" size="T200" priority="300" > - {safeDecodeUrl(url)} + {safeDecodeUrl(previewUrl)} ); diff --git a/src/app/pages/auth/login/loginUtil.ts b/src/app/pages/auth/login/loginUtil.ts index 81371a614..624e39a25 100644 --- a/src/app/pages/auth/login/loginUtil.ts +++ b/src/app/pages/auth/login/loginUtil.ts @@ -156,6 +156,8 @@ export const useLoginComplete = (data?: CustomLoginResponse) => { userId: loginRes.user_id, deviceId: loginRes.device_id, accessToken: loginRes.access_token, + ...(loginRes.refresh_token != null && { refreshToken: loginRes.refresh_token }), + ...(loginRes.expires_in_ms != null && { expiresInMs: loginRes.expires_in_ms }), }; setSessions({ type: 'PUT', session: newSession }); setActiveSessionId(loginRes.user_id); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 1a653e950..d8b3a3dae 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -201,11 +201,21 @@ export function ClientRoot({ children }: ClientRootProps) { } await clearMismatchedStores(); log.log('initClient for', activeSession.userId); - const newMx = await initClient(activeSession); + const newMx = await initClient(activeSession, (newAccessToken, newRefreshToken) => { + setSessions({ + type: 'PUT', + session: { + ...activeSession, + accessToken: newAccessToken, + ...(newRefreshToken !== undefined && { refreshToken: newRefreshToken }), + }, + }); + pushSessionToSW(activeSession.baseUrl, newAccessToken, activeSession.userId); + }); loadedUserIdRef.current = activeSession.userId; - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); return newMx; - }, [activeSession, activeSessionId, setActiveSessionId]) + }, [activeSession, activeSessionId, setActiveSessionId, setSessions]) ); const mx = loadState.status === AsyncStatus.Success ? loadState.data : undefined; @@ -232,7 +242,7 @@ export function ClientRoot({ children }: ClientRootProps) { activeSession.userId, '— reloading client' ); - pushSessionToSW(activeSession.baseUrl, activeSession.accessToken); + pushSessionToSW(activeSession.baseUrl, activeSession.accessToken, activeSession.userId); if (mx?.clientRunning) { stopClient(mx); } diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 093093ba2..c21043966 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -267,7 +267,10 @@ export const clearMismatchedStores = async (): Promise => { ); }; -const buildClient = async (session: Session): Promise => { +const buildClient = async ( + session: Session, + onTokenRefresh?: (newAccessToken: string, newRefreshToken?: string) => void +): Promise => { const storeName = getSessionStoreName(session); const indexedDBStore = new IndexedDBStore({ @@ -278,6 +281,11 @@ const buildClient = async (session: Session): Promise => { const legacyCryptoStore = new IndexedDBCryptoStore(global.indexedDB, storeName.crypto); + // Pre-allocate a slot for the MatrixClient reference used by tokenRefreshFunction. + // The refresh function is only ever invoked after startClient, so mxRef is always + // assigned before it is called. + let mxRef!: MatrixClient; + const mx = createClient({ baseUrl: session.baseUrl, accessToken: session.accessToken, @@ -288,13 +296,32 @@ const buildClient = async (session: Session): Promise => { timelineSupport: true, cryptoCallbacks: cryptoCallbacks as any, verificationMethods: ['m.sas.v1'], + ...(session.refreshToken && { + refreshToken: session.refreshToken, + tokenRefreshFunction: async (oldRefreshToken: string) => { + const res = await mxRef.refreshToken(oldRefreshToken); + onTokenRefresh?.(res.access_token, res.refresh_token); + return { + accessToken: res.access_token, + refreshToken: res.refresh_token ?? oldRefreshToken, + expiry: + typeof res.expires_in_ms === 'number' + ? new Date(Date.now() + res.expires_in_ms) + : undefined, + }; + }, + }), }); + mxRef = mx; await indexedDBStore.startup(); return mx; }; -export const initClient = async (session: Session): Promise => { +export const initClient = async ( + session: Session, + onTokenRefresh?: (newAccessToken: string, newRefreshToken?: string) => void +): Promise => { const storeName = getSessionStoreName(session); debugLog.info('sync', 'Initializing Matrix client', { userId: session.userId, @@ -338,7 +365,7 @@ export const initClient = async (session: Session): Promise => { let mx: MatrixClient; try { - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); } catch (err) { if (!isMismatch(err)) { debugLog.error('sync', 'Failed to build client', { error: err }); @@ -347,7 +374,7 @@ export const initClient = async (session: Session): Promise => { log.warn('initClient: mismatch on buildClient — wiping and retrying:', err); debugLog.warn('sync', 'Client build mismatch - wiping stores and retrying', { error: err }); await wipeAllStores(); - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); } try { @@ -361,7 +388,7 @@ export const initClient = async (session: Session): Promise => { debugLog.warn('sync', 'Crypto init mismatch - wiping stores and retrying', { error: err }); mx.stopClient(); await wipeAllStores(); - mx = await buildClient(session); + mx = await buildClient(session, onTokenRefresh); await mx.initRustCrypto({ cryptoDatabasePrefix: storeName.rustCryptoPrefix }); } @@ -538,13 +565,40 @@ export const startClient = async (mx: MatrixClient, config?: StartClientConfig): const v = slidingConfig?.probeTimeoutMs; return typeof v === 'number' && !Number.isNaN(v) && v > 0 ? Math.round(v) : 5000; })(); - const supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + + // Cache successful probe results for 10 minutes to avoid a full network round-trip + // (300–5000ms) on every cold start. Only positive results are cached; a failed probe + // is always re-attempted next session to recover from transient outages. + const SS_PROBE_CACHE_KEY = `sable_ss_probe_v1_${resolvedProxyBaseUrl}`; + const SS_PROBE_CACHE_TTL_MS = 10 * 60 * 1000; + + let supported: boolean; + let probeFromCache = false; + try { + const raw = localStorage.getItem(SS_PROBE_CACHE_KEY); + const cached = raw ? (JSON.parse(raw) as { result: boolean; ts: number }) : null; + if (cached?.result === true && Date.now() - cached.ts < SS_PROBE_CACHE_TTL_MS) { + supported = true; + probeFromCache = true; + } else { + supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + if (supported) { + localStorage.setItem(SS_PROBE_CACHE_KEY, JSON.stringify({ result: true, ts: Date.now() })); + } else { + localStorage.removeItem(SS_PROBE_CACHE_KEY); + } + } + } catch { + supported = await SlidingSyncManager.probe(mx, resolvedProxyBaseUrl, probeTimeoutMs); + } + log.log('startClient sliding probe result', { userId: mx.getUserId(), requestedEnabled: slidingRequested, hasSlidingProxy, proxyBaseUrl: resolvedProxyBaseUrl, supported, + probeFromCache, }); if (!supported) { log.warn('Sliding Sync unavailable, falling back to classic sync for', mx.getUserId()); diff --git a/src/index.tsx b/src/index.tsx index 4f2e57245..686ed0bf7 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -83,6 +83,12 @@ if ('serviceWorker' in navigator) { pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; + // Push the session synchronously before React renders so the SW has a fresh + // token before any fetch events arrive. If navigator.serviceWorker.controller + // is already set (normal page reload), this eliminates the race where + // preloadedSession (potentially stale) would be used for early thumbnail fetches. + sendSessionToSW(); + navigator.serviceWorker .register(swUrl) .then(sendSessionToSW) @@ -157,10 +163,20 @@ window.addEventListener('error', (event) => { // Increment retry count and reload sessionStorage.setItem(CHUNK_RETRY_KEY, String(retryCount + 1)); log.warn(`Chunk load failed, reloading (attempt ${retryCount + 1}/${MAX_CHUNK_RETRIES})`); - window.location.reload(); - // Prevent default error handling since we're reloading + // Prevent default error handling synchronously before any async work event.preventDefault(); + + // If the SW is not yet controlling the page (dead after force-kill), wait for + // it to activate so the retry is served from the precache, not the network. + // This eliminates the white-screen flash caused by a blank reload mid-SW-init. + if ('serviceWorker' in navigator && !navigator.serviceWorker.controller) { + navigator.serviceWorker.ready + .then(() => window.location.reload()) + .catch(() => window.location.reload()); + } else { + window.location.reload(); + } } else { // Max retries exceeded, clear counter and let error bubble up sessionStorage.removeItem(CHUNK_RETRY_KEY); diff --git a/src/sw.ts b/src/sw.ts index bd09cd8d3..d904dbf58 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -69,9 +69,12 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + new Response(JSON.stringify(sessionWithTimestamp), { + headers: { 'Content-Type': 'application/json' }, + }) ); } catch { // Ignore — caches may be unavailable in some environments. @@ -91,13 +94,28 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 60 seconds to avoid using stale tokens. + // On iOS, the SW can be killed before persistSession completes, leaving a stale + // token in cache. By rejecting old sessions, we force the SW to wait for a fresh + // token from the live page via requestSession. + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 60000; // 60 seconds + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { + age, + accessToken: s.accessToken.slice(0, 8), + }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -111,6 +129,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -555,6 +575,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -604,12 +632,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -628,6 +668,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -637,6 +710,67 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + const attemptedTokens = new Set([token]); + const retrySessions: SessionInfo[] = []; + const seenSessions = new Set(); + + const addRetrySession = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push(session); + }; + + if (clientId) addRetrySession(sessions.get(clientId)); + getMatchingSessions(url).forEach((session) => addRetrySession(session)); + addRetrySession(preloadedSession); + addRetrySession(await loadPersistedSession()); + (await getLiveWindowSessions(url, clientId)).forEach((session) => addRetrySession(session)); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.accessToken); + response = await fetch(url, { ...fetchConfig(candidate.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + } + } + /* eslint-enable no-await-in-loop */ + + return response; +} + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -667,37 +801,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -705,17 +826,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request', @@ -902,7 +1036,5 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { ); }); -if (self.__WB_MANIFEST) { - precacheAndRoute(self.__WB_MANIFEST); -} +precacheAndRoute(self.__WB_MANIFEST); cleanupOutdatedCaches();