///
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
export type {};
declare const self: ServiceWorkerGlobalScope & {
// Replaced at build time by vite-plugin-pwa (injectManifest) with the list of
// hashed build assets to precache. See vite.config.js VitePWA injectManifest.
__WB_MANIFEST: Array;
};
/**
* PRECACHE (workbox-precaching). `self.__WB_MANIFEST` is replaced at build time
* by vite-plugin-pwa with the list of hashed build assets
* (assets/**\/*.{js,css,wasm}; see vite.config.js injectManifest.globPatterns).
*
* DEPLOY-SAFETY INVARIANTS (do not break):
* 1. index.html / navigations are NEVER precached or precache-routed. The
* manifest globs only `assets/**` (content-hashed), so index.html (served
* from the app root) is absent from it and navigation requests fall through
* to the network — a new deploy is picked up immediately, no stale SPA
* shell. We deliberately do NOT register a navigation route /
* createHandlerBoundToURL fallback.
* 2. precacheAndRoute only matches its own manifest URLs (same-origin hashed
* assets). It never matches the media-auth paths handled by the fetch
* listener below — those are cross-origin homeserver URLs absent from the
* manifest — so the existing media fetch behaviour is fully preserved. It
* is registered before that listener; for a media request the precache
* route finds no match and does not call respondWith, so the media handler
* still runs.
* 3. Assets are content-hashed, so a changed asset ships under a new filename;
* PrecacheController drops entries no longer in the current manifest on
* activate, so the precache self-updates each deploy without unbounded
* growth.
*/
precacheAndRoute(self.__WB_MANIFEST);
type SessionInfo = {
accessToken: string;
baseUrl: string;
};
/**
* Store session per client (tab)
*/
const sessions = new Map();
const clientToResolve = new Map void>();
const clientToSessionPromise = new Map>();
async function cleanupDeadClients() {
const activeClients = await self.clients.matchAll();
const activeIds = new Set(activeClients.map((c) => c.id));
Array.from(sessions.keys()).forEach((id) => {
if (!activeIds.has(id)) {
sessions.delete(id);
clientToResolve.delete(id);
clientToSessionPromise.delete(id);
}
});
}
function setSession(clientId: string, accessToken: any, baseUrl: any) {
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
sessions.set(clientId, { accessToken, baseUrl });
} else {
// Logout or invalid session
sessions.delete(clientId);
}
const resolveSession = clientToResolve.get(clientId);
if (resolveSession) {
resolveSession(sessions.get(clientId));
clientToResolve.delete(clientId);
clientToSessionPromise.delete(clientId);
}
}
function requestSession(client: Client): Promise {
const promise =
clientToSessionPromise.get(client.id) ??
new Promise((resolve) => {
clientToResolve.set(client.id, resolve);
client.postMessage({ type: 'requestSession' });
});
if (!clientToSessionPromise.has(client.id)) {
clientToSessionPromise.set(client.id, promise);
}
return promise;
}
async function requestSessionWithTimeout(
clientId: string,
timeoutMs = 3000,
): Promise {
const client = await self.clients.get(clientId);
if (!client) return undefined;
const sessionPromise = requestSession(client);
const timeout = new Promise((resolve) => {
setTimeout(() => resolve(undefined), timeoutMs);
});
return Promise.race([sessionPromise, timeout]);
}
self.addEventListener('install', () => {
self.skipWaiting();
});
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
(async () => {
await self.clients.claim();
await cleanupDeadClients();
})(),
);
});
/**
* Receive session updates from clients
*/
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const client = event.source as Client | null;
if (!client) return;
const { type, accessToken, baseUrl } = event.data || {};
if (type === 'setSession') {
setSession(client.id, accessToken, baseUrl);
cleanupDeadClients();
}
});
/**
* N105: handle clicks on service-worker-owned notifications. This fires even
* when the originating tab was closed (a page-level `Notification.onclick`
* would not). Focus an existing app window and forward the target path so it
* can route there; if no window is open, open the app.
*/
self.addEventListener('notificationclick', (event: NotificationEvent) => {
event.notification.close();
const data = event.notification.data ?? {};
const path = typeof data.path === 'string' ? data.path : undefined;
event.waitUntil(
(async () => {
const windowClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
const client = windowClients.find((c): c is WindowClient => 'focus' in c);
if (client) {
await client.focus();
if (path) client.postMessage({ type: 'notificationClick', path });
return;
}
// No app window open — open one at the app root (the client routes from
// there). Router config (hash vs browser) is page-owned, so we don't try
// to deep-link the URL here.
if (self.clients.openWindow) {
await self.clients.openWindow(self.registration.scope);
}
})(),
);
});
const MEDIA_PATHS = ['/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail'];
function mediaPath(url: string): boolean {
try {
const { pathname } = new URL(url);
return MEDIA_PATHS.some((p) => pathname.startsWith(p));
} catch {
return false;
}
}
function validMediaRequest(url: string, baseUrl: string): boolean {
return MEDIA_PATHS.some((p) => {
const validUrl = new URL(p, baseUrl);
return url.startsWith(validUrl.href);
});
}
function fetchConfig(token: string): RequestInit {
return {
headers: {
Authorization: `Bearer ${token}`,
},
cache: 'default',
};
}
self.addEventListener('fetch', (event: FetchEvent) => {
const { url, method } = event.request;
if (method !== 'GET' || !mediaPath(url)) return;
const { clientId } = event;
if (!clientId) return;
const session = sessions.get(clientId);
if (session) {
if (validMediaRequest(url, session.baseUrl)) {
event.respondWith(fetch(url, fetchConfig(session.accessToken)));
}
return;
}
event.respondWith(
requestSessionWithTimeout(clientId).then((s) => {
if (s && validMediaRequest(url, s.baseUrl)) {
return fetch(url, fetchConfig(s.accessToken));
}
return fetch(event.request);
}),
);
});