96f7187031
- emojibase (~965 KB) is now fully lazy: plugins/emoji.ts loads compact data + shortcode maps via a memoized dynamic import (rejections reset the memo so a mid-deploy chunk 404 can retry); reaction labels degrade to the raw glyph until loaded. Consumers get FRESH array references on load (the module arrays populate in place — same-ref state updates would skip re-render and leave emoji search empty; reviewer-caught). Verified out of the eager graph. - Service worker precaches hashed assets (workbox precacheAndRoute, 82 entries ~10.8 MB incl. the crypto wasm): repeat visits stop re-downloading the app. index.html is NOT precached — navigations stay network-first so deploys are picked up immediately; the media-auth fetch handler is untouched. - ReactPrism: curated 21-language set — chunk 574 KB → 71 KB. - Timeline inline images get loading="lazy". - Removed dead dompurify (+types); sanitize-html is the real sanitizer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
/// <reference lib="WebWorker" />
|
|
|
|
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<string | PrecacheEntry>;
|
|
};
|
|
|
|
/**
|
|
* 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<string, SessionInfo>();
|
|
|
|
const clientToResolve = new Map<string, (value: SessionInfo | undefined) => void>();
|
|
const clientToSessionPromise = new Map<string, Promise<SessionInfo | undefined>>();
|
|
|
|
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<SessionInfo | undefined> {
|
|
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<SessionInfo | undefined> {
|
|
const client = await self.clients.get(clientId);
|
|
if (!client) return undefined;
|
|
|
|
const sessionPromise = requestSession(client);
|
|
|
|
const timeout = new Promise<undefined>((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);
|
|
}),
|
|
);
|
|
});
|