fix(media): deliver session to the service worker on controllerchange and drop the poisoned handshake promise on timeout so authenticated media stops 401ing
This commit is contained in:
parent
cdd2570ff1
commit
109941e0dd
2 changed files with 77 additions and 9 deletions
|
|
@ -1,10 +1,61 @@
|
||||||
|
// Bridge the live Matrix session (baseUrl + accessToken) to the service
|
||||||
|
// worker, which attaches `Authorization: Bearer <token>` 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) {
|
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
if (!navigator.serviceWorker.controller) return;
|
// Remember the latest session even when there is no controller yet, then
|
||||||
|
// attempt delivery now and again whenever the controller appears. Logout
|
||||||
navigator.serviceWorker.controller.postMessage({
|
// passes both args undefined, which the SW reads as "clear session".
|
||||||
type: 'setSession',
|
pendingSession = { baseUrl, accessToken };
|
||||||
accessToken,
|
installSwControllerWatch();
|
||||||
baseUrl,
|
trySendSession();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/sw.ts
21
src/sw.ts
|
|
@ -69,11 +69,28 @@ async function requestSessionWithTimeout(
|
||||||
|
|
||||||
const sessionPromise = requestSession(client);
|
const sessionPromise = requestSession(client);
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const timeout = new Promise<undefined>((resolve) => {
|
const timeout = new Promise<undefined>((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', () => {
|
self.addEventListener('install', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue