diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 8bb9ffc..79922e6 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -21,8 +21,8 @@ Open `http://localhost:5173` in Chrome or Edge. Firefox and Safari work too, the - `src/lib/git` wraps isomorphic-git. `handle-fs.ts` is the file system adapter that lets it run on the project folder, `engine.ts` the operations, `errors.ts` turns its errors into something readable. The panel lives in `src/lib/ui/git`, one file per tab. - `src/lib/project` holds the open files, the project tree and everything the toolbar calls. - `src/lib/ui` are the Svelte components, `src/routes/editor` is the editor page itself. -- `static/sw.js` is the service worker that resolves TeX Live files on demand: cache first, then the bundled subset, then the jsDelivr mirror, then the fallback server. -- `static/texlive/cache` is the bundled package subset. If you change anything in there, bump `BUNDLE_VERSION` in `static/sw.js` and `WARMED_VERSION` in `src/lib/compiler/offline-cache.ts`, otherwise users keep the old files in their cache. +- `src/service-worker.ts` is the service worker. It keeps every file of the current build in a cache of its own, so a page that was open before a deploy still gets its chunks, and it resolves TeX Live files on demand: cache first, then the bundled subset, then the jsDelivr mirror, then the fallback server. +- `static/texlive/cache` is the bundled package subset. If you change anything in there, bump `BUNDLE_VERSION` in `src/service-worker.ts` and `WARMED_VERSION` in `src/lib/compiler/offline-cache.ts`, otherwise users keep the old files in their cache. - `static/swiftlatex` is the engine. It is a build artifact, not something to edit by hand. ## What helps most diff --git a/src/lib/compiler/offline-cache.ts b/src/lib/compiler/offline-cache.ts index 6dd33ad..a86c46e 100644 --- a/src/lib/compiler/offline-cache.ts +++ b/src/lib/compiler/offline-cache.ts @@ -6,7 +6,7 @@ import { base } from '$app/paths'; // down (the cache writes compete with compile i/o otherwise). const FLAG = 'texbrain-texlive-warmed'; -// matches BUNDLE_VERSION in sw.js: when the bundle changes, the warm set +// matches BUNDLE_VERSION in service-worker.ts: when the bundle changes, the warm set // is evicted and needs one fresh run const WARMED_VERSION = '4'; const CONCURRENCY = 2; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3050e7b..70881b9 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,14 +3,25 @@ import Toast from '$lib/ui/Toast.svelte'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; + import { beforeNavigate } from '$app/navigation'; + import { updated } from '$app/state'; import { preferences } from '$lib/stores/preferences'; import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); + // after a deploy the chunks this tab knows about are gone from the server. + // a full page load on the next navigation picks up the new build instead + // of failing on a missing file + beforeNavigate(({ willUnload, to }) => { + if (updated.current && !willUnload && to?.url) { + location.href = to.url.href; + } + }); + onMount(() => { if (browser && 'serviceWorker' in navigator) { - navigator.serviceWorker.register('/sw.js').catch(() => {}); + navigator.serviceWorker.register('/service-worker.js').catch(() => {}); // keep the service worker's texlive mirror config in sync const unsubscribe = preferences.subscribe((prefs) => { diff --git a/static/sw.js b/src/service-worker.ts similarity index 53% rename from static/sw.js rename to src/service-worker.ts index 2a036a8..69e0a58 100644 --- a/static/sw.js +++ b/src/service-worker.ts @@ -1,3 +1,20 @@ +/// +/// +/// +/// + +// two jobs: keep the app's own files around so a page and its chunks always +// come from the same build (even after a deploy replaced everything on the +// server), and resolve tex live files on demand for the engine + +import { build, files, prerendered, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; + +// one cache per deploy for the app itself, dropped when the next one arrives +const APP_CACHE = `texbrain-app-${version}`; +// these outlive deploys: the offline warmer's copies, the tex live files +// fetched so far, and the mirror setting const CACHE_NAME = 'texbrain-v1'; const TEXLIVE_CACHE = 'texbrain-texlive-v1'; const CONFIG_CACHE = 'texbrain-config-v1'; @@ -21,106 +38,120 @@ const LSR_URL = STATIC_MIRROR + '/ls-R'; // texliveMirror preference. const DEFAULT_MIRROR = 'https://texlive.texlyre.org/'; -const STATIC_ASSETS = [ - '/', - '/editor', - '/favicon.svg', - '/manifest.json' +// the app's chunks, the pages, and the handful of small static files. the +// bundled tex subset and the engine are big and fetched on demand instead +const PRECACHE = [ + ...build, + ...prerendered, + ...files.filter(f => /^\/(favicon\.svg|manifest\.json|texbrain-logo-readme\.svg)$/.test(f)) ]; // extensions kpathsea probes when a request comes without one, // same order as the engine uses for its local lookups const TEX_EXTENSIONS = ['', '.tfm', '.pfb', '.vf', '.sty', '.cls', '.fd', '.def', '.cfg', '.clo', '.tex', '.ltx', '.map', '.enc', '.dfu', '.ldf']; -let mirrorOverride = null; // null = not set, '' = disabled, string = url -let manifestPromise = null; -let lsrPromise = null; - -// cache static assets on install -self.addEventListener('install', (event) => { - event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(STATIC_ASSETS); - }) - ); - self.skipWaiting(); +let mirrorOverride: string | null = null; // null = not set, '' = disabled, string = url +let manifestPromise: Promise> | null = null; +let lsrPromise: Promise> | null = null; + +// caches can be unavailable (private windows in some browsers), the app has +// to keep working without them +async function openCache(name: string): Promise { + try { + return await caches.open(name); + } catch { + return null; + } +} + +sw.addEventListener('install', (event) => { + event.waitUntil((async () => { + const cache = await openCache(APP_CACHE); + if (!cache) return; + // one missing file must not stop the rest from being cached + await Promise.allSettled(PRECACHE.map(url => cache.add(url).catch(() => {}))); + })()); + sw.skipWaiting(); }); // cached copies of bundled files go stale when a deploy changes the bundle, // evict them (files fetched from remote mirrors are left alone) async function evictStaleBundledFiles() { try { - const cfg = await caches.open(CONFIG_CACHE); + const cfg = await openCache(CONFIG_CACHE); + if (!cfg) return; const marker = await cfg.match(BUNDLE_VERSION_URL); if (marker && Number(await marker.text()) === BUNDLE_VERSION) return; const names = await loadManifest(); if (names.size > 0) { - const cache = await caches.open(TEXLIVE_CACHE); - for (const req of await cache.keys()) { - const resp = await cache.match(req); - const id = resp && (resp.headers.get('fileid') || resp.headers.get('pkid')); - if (id && names.has(id)) await cache.delete(req); + const cache = await openCache(TEXLIVE_CACHE); + if (cache) { + for (const req of await cache.keys()) { + const resp = await cache.match(req); + const id = resp && (resp.headers.get('fileid') || resp.headers.get('pkid')); + if (id && names.has(id)) await cache.delete(req); + } } // the offline warmer stores raw copies in the app cache, and the // bundled lookup finds those too, so they must go as well - const appCache = await caches.open(CACHE_NAME); - for (const name of names) { - await appCache.delete(`/texlive/cache/${encodeURIComponent(name)}`); + const appCache = await openCache(CACHE_NAME); + if (appCache) { + for (const name of names) { + await appCache.delete(`/texlive/cache/${encodeURIComponent(name)}`); + } } await cfg.put(BUNDLE_VERSION_URL, new Response(String(BUNDLE_VERSION))); } } catch { /* retried on next activation */ } } -// clean old caches on activate -self.addEventListener('activate', (event) => { - const keep = [CACHE_NAME, TEXLIVE_CACHE, CONFIG_CACHE]; - event.waitUntil( - caches.keys().then((names) => { - return Promise.all( - names.filter((name) => !keep.includes(name)).map((name) => caches.delete(name)) - ); - }).then(() => evictStaleBundledFiles()) - ); - self.clients.claim(); +sw.addEventListener('activate', (event) => { + const keep = [APP_CACHE, CACHE_NAME, TEXLIVE_CACHE, CONFIG_CACHE]; + event.waitUntil((async () => { + try { + const names = await caches.keys(); + await Promise.all(names.filter(n => !keep.includes(n)).map(n => caches.delete(n))); + } catch { /* no cache storage, nothing to clean */ } + await evictStaleBundledFiles(); + await sw.clients.claim(); + })()); }); // receive config from the app (persisted so it survives worker restarts) -self.addEventListener('message', (event) => { +sw.addEventListener('message', (event) => { const data = event.data; if (!data) return; // hard reloads bypass service worker control, the app asks to reclaim it if (data.type === 'claim') { - self.clients.claim(); + sw.clients.claim(); return; } if (data.type !== 'texlive-config') return; mirrorOverride = typeof data.mirror === 'string' ? data.mirror : null; - event.waitUntil( - caches.open(CONFIG_CACHE).then((cache) => { - return cache.put(CONFIG_URL, new Response(JSON.stringify({ mirror: mirrorOverride }))); - }) - ); + event.waitUntil((async () => { + const cache = await openCache(CONFIG_CACHE); + if (cache) await cache.put(CONFIG_URL, new Response(JSON.stringify({ mirror: mirrorOverride }))); + })()); }); -async function getMirror() { +async function getMirror(): Promise { if (mirrorOverride !== null) return mirrorOverride; try { - const cache = await caches.open(CONFIG_CACHE); - const stored = await cache.match(CONFIG_URL); + const cache = await openCache(CONFIG_CACHE); + const stored = cache && await cache.match(CONFIG_URL); if (stored) { const cfg = await stored.json(); if (typeof cfg.mirror === 'string') { mirrorOverride = cfg.mirror; - return mirrorOverride; + return mirrorOverride as string; } } } catch { /* fall through */ } return DEFAULT_MIRROR; } -function looksLikeHtml(buf) { +function looksLikeHtml(buf: ArrayBuffer): boolean { const head = new TextDecoder().decode(new Uint8Array(buf, 0, Math.min(100, buf.byteLength))).toLowerCase(); return head.includes(' 2 && b[0] === 0xf7 && b[1] === 0x59; @@ -144,20 +175,20 @@ function looksValid(buf, name, isPk) { return true; } -function fetchWithTimeout(url, ms) { +function fetchWithTimeout(url: string, ms: number): Promise { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); return fetch(url, { signal: ctrl.signal }).finally(() => clearTimeout(timer)); } // set of filenames available in the bundled subset under /texlive/cache/ -function loadManifest() { +function loadManifest(): Promise> { if (manifestPromise) return manifestPromise; manifestPromise = (async () => { - const names = new Set(); + const names = new Set(); for (const file of ['/texlive/cache-manifest-text.txt', '/texlive/cache-manifest-binary.txt']) { try { - const resp = (await caches.match(file)) || (await fetch(file)); + const resp = (await caches.match(file).catch(() => undefined)) || (await fetch(file)); if (!resp || !resp.ok) continue; const text = await resp.text(); for (const line of text.split('\n')) { @@ -172,7 +203,7 @@ function loadManifest() { return manifestPromise; } -async function resolveBundled(name) { +async function resolveBundled(name: string): Promise { const names = await loadManifest(); for (const ext of TEX_EXTENSIONS) { if (names.has(name + ext)) return name + ext; @@ -181,21 +212,21 @@ async function resolveBundled(name) { } // filename -> directory map built from the mirror's ls-R index -function loadLsr() { +function loadLsr(): Promise> { if (lsrPromise) return lsrPromise; lsrPromise = (async () => { - const map = new Map(); + const map = new Map(); try { - const cache = await caches.open(TEXLIVE_CACHE); - let resp = await cache.match(LSR_URL); + const cache = await openCache(TEXLIVE_CACHE); + let resp = cache ? await cache.match(LSR_URL) : undefined; if (!resp) { resp = await fetchWithTimeout(LSR_URL, 60000); - if (resp.ok) await cache.put(LSR_URL, resp.clone()); + if (resp.ok && cache) await cache.put(LSR_URL, resp.clone()); } if (!resp.ok) throw new Error('ls-R unavailable'); const text = await resp.text(); - let dir = null; + let dir: string | null = null; for (const rawLine of text.split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('%')) continue; @@ -216,7 +247,7 @@ function loadLsr() { return lsrPromise; } -async function resolveStatic(name) { +async function resolveStatic(name: string): Promise<{ path: string; filename: string } | null> { const map = await loadLsr(); for (const ext of TEX_EXTENSIONS) { const dir = map.get(name + ext); @@ -225,8 +256,8 @@ async function resolveStatic(name) { return null; } -function texliveResponse(buf, id, isPk) { - const headers = { +function texliveResponse(buf: ArrayBuffer, id: string, isPk: boolean): Response { + const headers: Record = { 'Content-Type': 'application/octet-stream', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=86400' @@ -240,30 +271,34 @@ function texliveResponse(buf, id, isPk) { // 2. bundled subset shipped with the app // 3. static texlive mirror (jsdelivr) // 4. swiftlatex-compatible texlive server -async function handleTexlive(request, url) { - const cache = await caches.open(TEXLIVE_CACHE); +async function handleTexlive(request: Request, url: URL): Promise { + const cache = await openCache(TEXLIVE_CACHE); const isPk = url.pathname.includes('/pdftex/pk/'); const name = decodeURIComponent(url.pathname.split('/').pop() || ''); - const hit = await cache.match(request); + const hit = cache ? await cache.match(request) : undefined; if (hit) { // copies cached before the checks existed are looked at once more, // a bad one would otherwise break every compile from here on const id = hit.headers.get(isPk ? 'pkid' : 'fileid') || name; if (looksValid(await hit.clone().arrayBuffer(), id, isPk)) return hit; - await cache.delete(request); + await cache!.delete(request); } + const remember = async (resp: Response) => { + if (cache) { + try { await cache.put(request, resp.clone()); } catch { /* storage full or unavailable */ } + } + return resp; + }; + // format files exist as real static assets under /texlive/pdftex/ if (name.endsWith('.fmt')) { try { const resp = await fetch(request); if (resp.ok) { const buf = await resp.arrayBuffer(); - if (!looksLikeHtml(buf)) { - await cache.put(request, texliveResponse(buf.slice(0), name, false)); - return texliveResponse(buf, name, false); - } + if (!looksLikeHtml(buf)) return remember(texliveResponse(buf, name, false)); } } catch { /* fall through */ } } @@ -274,13 +309,10 @@ async function handleTexlive(request, url) { if (resolved) { try { const bundledUrl = `/texlive/cache/${encodeURIComponent(resolved)}`; - const resp = (await caches.match(bundledUrl)) || (await fetch(bundledUrl)); + const resp = (await caches.match(bundledUrl).catch(() => undefined)) || (await fetch(bundledUrl)); if (resp && resp.ok) { const buf = await resp.arrayBuffer(); - if (looksValid(buf, resolved, false)) { - await cache.put(request, texliveResponse(buf.slice(0), resolved, false)); - return texliveResponse(buf, resolved, false); - } + if (looksValid(buf, resolved, false)) return remember(texliveResponse(buf, resolved, false)); } } catch { /* fall through */ } } @@ -294,10 +326,7 @@ async function handleTexlive(request, url) { const resp = await fetchWithTimeout(encodeURI(`${STATIC_MIRROR}/${resolved.path}`), 30000); if (resp.ok) { const buf = await resp.arrayBuffer(); - if (looksValid(buf, resolved.filename, false)) { - await cache.put(request, texliveResponse(buf.slice(0), resolved.filename, false)); - return texliveResponse(buf, resolved.filename, false); - } + if (looksValid(buf, resolved.filename, false)) return remember(texliveResponse(buf, resolved.filename, false)); } } catch { /* fall through */ } } @@ -312,10 +341,7 @@ async function handleTexlive(request, url) { if (resp.ok) { const buf = await resp.arrayBuffer(); const id = resp.headers.get(isPk ? 'pkid' : 'fileid') || name; - if (looksValid(buf, id, isPk)) { - await cache.put(request, texliveResponse(buf.slice(0), id, isPk)); - return texliveResponse(buf, id, isPk); - } + if (looksValid(buf, id, isPk)) return remember(texliveResponse(buf, id, isPk)); } } catch { /* not found or offline */ } } @@ -323,37 +349,61 @@ async function handleTexlive(request, url) { return new Response('', { status: 404, headers: { 'Access-Control-Allow-Origin': '*' } }); } -// network-first with cache fallback -self.addEventListener('fetch', (event) => { - if (event.request.method !== 'GET') return; - if (!event.request.url.startsWith('http')) return; +// the app's own files never change under a given name, so the copy from +// the build they belong to wins, whatever the server has by now +async function handleImmutable(request: Request): Promise { + const cache = await openCache(APP_CACHE); + const hit = cache ? await cache.match(request) : undefined; + if (hit) return hit; + const resp = await fetch(request); + if (resp.ok && cache) { + try { await cache.put(request, resp.clone()); } catch { /* storage unavailable */ } + } + return resp; +} + +// network first, with what we have as the fallback: the page from this +// build for navigations, the last good copy for everything else +async function handleDefault(request: Request, url: URL): Promise { + try { + const response = await fetch(request); + if (response.status === 200 && url.origin === sw.location.origin) { + const cache = await openCache(CACHE_NAME); + if (cache) { + try { await cache.put(request, response.clone()); } catch { /* storage full or unavailable */ } + } + } + return response; + } catch (err) { + const cached = await caches.match(request).catch(() => undefined); + if (cached) return cached; + if (request.mode === 'navigate') { + const page = (await caches.match(url.pathname).catch(() => undefined)) || (await caches.match('/').catch(() => undefined)); + if (page) return page; + } + throw err; + } +} + +sw.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + if (!request.url.startsWith('http')) return; + + const url = new URL(request.url); + const sameOrigin = url.origin === sw.location.origin; - const url = new URL(event.request.url); - if (url.origin === self.location.origin && url.pathname.startsWith('/texlive/pdftex/')) { - event.respondWith(handleTexlive(event.request, url)); + if (sameOrigin && url.pathname.startsWith('/texlive/pdftex/')) { + event.respondWith(handleTexlive(request, url).catch(() => fetch(request))); return; } - event.respondWith( - fetch(event.request) - .then((response) => { - if (response.status === 200) { - const clone = response.clone(); - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, clone); - }); - } - return response; - }) - .catch(() => { - return caches.match(event.request).then((cached) => { - if (cached) return cached; - // navigation requests fall back to shell - if (event.request.mode === 'navigate') { - return caches.match('/'); - } - return new Response('Offline', { status: 503 }); - }); - }) - ); + if (sameOrigin && url.pathname.startsWith('/_app/immutable/')) { + event.respondWith(handleImmutable(request).catch(() => fetch(request))); + return; + } + + // whatever goes wrong inside, the browser gets a real answer, never a + // broken interception + event.respondWith(handleDefault(request, url).catch(() => fetch(request))); }); diff --git a/svelte.config.js b/svelte.config.js index bc2cfc4..986fe04 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -14,6 +14,18 @@ const config = { }), paths: { base: '' + }, + // the layout registers the worker itself, it also has to talk to it. + // the tex live subset and the engine stay out of the worker's file + // list, that is thousands of names it fetches on demand anyway + serviceWorker: { + register: false, + files: (filepath) => !/(^|\/)(texlive|swiftlatex)\//.test(filepath) + }, + // lets an open tab notice a new deploy, so the next navigation loads + // the new build instead of asking for chunks that no longer exist + version: { + pollInterval: 60000 } } };