Files
manga-reader/app/public/sw.js
T
ninoandClaude Opus 5.5 29af893917 Animación de hoja de papel al pasar página y URLs versionadas para saltar la caché del proxy
- Lector por páginas: animaciones Pasar hoja (la hoja se curva al levantarla, con sombra), deslizar y
  fundido; hacia atrás la página anterior vuelve y se coloca encima.
- app.js, style.css y sw.js se sirven con ?v=<hash> para que ni el proxy ni el navegador den una copia vieja.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:41:22 -06:00

81 lines
4.3 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', '/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).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/')) {
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
}
})