- M440 (m440.in, español): búsqueda, populares, actualizados paginados, ficha, capítulos, descargas y seguimiento. La lista de capítulos viene cifrada: la clave se saca de un script ofuscado del sitio ejecutándolo en un proceso aislado con permisos mínimos (m440-key.mjs) y se renueva sola. - Servidor sin internet (connectivity.js): se detecta en segundos; las fuentes fallan al instante, la portada muestra lo descargado en el servidor con buscador, portadas locales de respaldo, descargas y comprobaciones en pausa y reanudación automática al volver la conexión. - App sin conexión con el servidor: el service worker usa la copia guardada si el servidor no contesta en 3 s, las peticiones comprueban si el servidor sigue vivo en vez de quedarse colgadas, aviso arriba e inicio con lo guardado en el dispositivo; al volver, se reconecta sola y envía el progreso pendiente. - Colas: sección «En este dispositivo» con el guardado en curso en directo y cancelación (sin dejar capítulos a medias); también visible sin servidor. - Imágenes: el proxy y el guardado en el dispositivo reintentan con tiempo límite cuando el CDN de la fuente falla. - Reescalado HD: las páginas que superan 16383 px al reescalar (tiras de webtoon) se reducen al límite de WebP en vez de hacer fallar el capítulo. - Entidades HTML con nombre en las sinopsis; pestañas de fuentes cortas en móvil; portadas rotas con icono de respaldo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67 lines
3.4 KiB
JavaScript
67 lines
3.4 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']
|
|
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)).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 })
|
|
}
|
|
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) {
|
|
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 (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))
|
|
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)))
|
|
}
|
|
})
|