81 lines
4.7 KiB
JavaScript
81 lines
4.7 KiB
JavaScript
// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este
|
|
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
|
|
const SHELL = 'shell-v1'
|
|
const OFFLINE = 'offline-v1'
|
|
const SHELL_FILES = ['/', '/app.js', '/style.css', '/book3d.js', '/shelf3d.js', '/animeface.js', '/animeface-cascade.json', '/fonts/caveat.woff2', '/fonts/patrickhand.woff2', '/fonts/kalam.woff2', '/fonts/gochihand.woff2', '/fonts/indieflower.woff2', '/fonts/permanentmarker.woff2', '/fonts/shadows.woff2', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
|
|
|
|
// Se guarda la portada tal como la sirve el servidor y justo las versiones de app.js y style.css que
|
|
// enlaza (?v=…); cache: 'reload' para no copiar lo que tenga la caché HTTP (un proxy puede alargarla horas).
|
|
async function installShell() {
|
|
const c = await caches.open(SHELL)
|
|
const html = await fetch('/', { cache: 'reload' })
|
|
const text = await html.clone().text()
|
|
const linked = [...text.matchAll(/(?:src|href)="(\/(?:app\.js|style\.css)\?v=[^"]+)"/g)].map(m => m[1])
|
|
const files = [...linked, ...SHELL_FILES.filter(f => f !== '/' && !linked.some(l => l.startsWith(f + '?')))]
|
|
for (const f of files) await c.delete(f, { ignoreSearch: true })
|
|
await c.addAll(files.map(u => new Request(u, { cache: 'reload' })))
|
|
await c.put('/', html)
|
|
}
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(installShell().then(() => self.skipWaiting()))
|
|
})
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(caches.keys()
|
|
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE && !k.startsWith('shelf-')) // shelf-*: lo que guarda el estante (shelf3d.js).map(k => caches.delete(k))))
|
|
.then(() => self.clients.claim()))
|
|
})
|
|
|
|
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión. Si hay copia guardada
|
|
// y el servidor no contesta en NET_WAIT ms (red que no llega, servidor caído o colgado), se usa la copia
|
|
// sin esperar a que el navegador dé la conexión por perdida; la petición sigue y actualiza la caché.
|
|
const NET_WAIT = 3000
|
|
const DOWN_FOR = 20e3
|
|
let downUntil = 0
|
|
// La app distingue así una copia guardada de una respuesta real del servidor.
|
|
async function fromCache(res) {
|
|
const headers = new Headers(res.headers)
|
|
headers.set('x-from-cache', '1')
|
|
return new Response(await res.blob(), { status: res.status, statusText: res.statusText, headers })
|
|
}
|
|
// single: guardar solo la última versión (app.js?v=… sustituye a la anterior).
|
|
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback, single = false } = {}) {
|
|
const cached = () => caches.match(req, { ignoreSearch, cacheName }).then(hit => hit || (fallback && caches.match(fallback)))
|
|
const net = fetch(req).then(async res => {
|
|
if (store && res.ok) {
|
|
const c = await caches.open(cacheName)
|
|
if (single) await c.delete(req, { ignoreSearch: true })
|
|
if (store === true || (await c.match(req, { ignoreSearch }))) await c.put(req, res.clone())
|
|
}
|
|
return res
|
|
})
|
|
net.then(() => { downUntil = 0 }, () => {})
|
|
const hit = await cached()
|
|
if (!hit) return net // sin copia: solo queda esperar a la red
|
|
// Si hace poco que el servidor no contestaba, la copia directamente (la red sigue intentándolo).
|
|
if (Date.now() < downUntil) return fromCache(hit)
|
|
let timer
|
|
const timeout = new Promise(r => { timer = setTimeout(() => r(null), NET_WAIT) })
|
|
const res = await Promise.race([net.catch(() => null), timeout])
|
|
clearTimeout(timer)
|
|
if (!res) downUntil = Date.now() + DOWN_FOR
|
|
return res || fromCache(hit)
|
|
}
|
|
|
|
self.addEventListener('fetch', e => {
|
|
const req = e.request
|
|
if (req.method !== 'GET') return
|
|
const url = new URL(req.url)
|
|
if (url.origin !== location.origin) return
|
|
const p = url.pathname
|
|
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
|
|
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL, { ignoreSearch: true, single: true }))
|
|
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
|
|
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
|
|
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
|
|
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
|
|
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
|
|
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) {
|
|
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
|
|
}
|
|
})
|