diff --git a/app/public/app.js b/app/public/app.js
index 1887c63..374db29 100644
--- a/app/public/app.js
+++ b/app/public/app.js
@@ -204,7 +204,8 @@ const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${p
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
const OFF_CACHE = 'offline-v1'
-if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
+// Con versión en la URL: si un proxy guarda sw.js en caché, cada versión nueva tiene otra URL.
+if (OFFLINE_OK) navigator.serviceWorker.register(`/sw.js?v=${document.querySelector('meta[name="sw-version"]')?.content || ''}`).catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set(), cancelled: new Set(), abort: null }
@@ -1061,6 +1062,7 @@ async function viewManga(id, langOverride) {
// ---------- Lector ----------
const READ_MODES = { paged: 'Una página', double: 'Doble página', vertical: 'Vertical' }
+const PAGE_ANIMS = { flip: 'Pasar hoja', slide: 'Deslizar', fade: 'Fundido', none: 'Sin animación' }
// Ajustes por defecto según el tipo de obra (se usan hasta que el usuario cambie algo en ese manga).
function defaultPrefs(manga) {
if (['Manhwa', 'Manhua', 'Webtoon'].includes(manga.type)) return { mode: 'vertical', fit: 'width', dir: 'ltr' }
@@ -1074,7 +1076,8 @@ async function viewReader(mangaId, chapterId, startPage) {
app.innerHTML = '
Cargando capítulo…
'
const lang = store.get(`lang:${mangaId}`, me.lang)
// Filtro, brillo y ahorro de datos son globales (navegador); modo, ajuste y dirección van por manga (servidor).
- const globalSettings = { saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) }
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
+ const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', ...store.get('readerSettings', {}) }
const [info, chap] = await Promise.all([
api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`),
api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`),
@@ -1123,6 +1126,7 @@ async function viewReader(mangaId, chapterId, startPage) {
+
${manga.source !== 'md' || chap.local ? '' : ''}
@@ -1200,11 +1204,16 @@ async function viewReader(mangaId, chapterId, startPage) {
im.src = pages[i]
}
}
- function showPaged() {
- let stage = $('.stage', box)
- if (!stage) { box.innerHTML = ''; stage = $('.stage', box) }
+ // dir: 1 hacia delante, -1 hacia atrás, 0 sin animación (salto con la barra, cambio de modo…).
+ function showPaged(dir = 0) {
+ finishTurn()
+ const old = $('.stage', box)
+ const stage = document.createElement('div')
+ stage.className = 'stage'
stage.classList.toggle('two', shown.length === 2)
stage.innerHTML = shown.map(i => ``).join('')
+ if (old && dir && settings.anim !== 'none' && zoom.s === 1) turnPage(old, stage, dir)
+ else { box.replaceChildren(stage) }
stage.querySelectorAll('img').forEach(img => img.addEventListener('load', () => {
const i = Number(img.dataset.i)
if (isWide(img) && !wide.has(i)) {
@@ -1216,24 +1225,191 @@ async function viewReader(mangaId, chapterId, startPage) {
window.scrollTo(0, 0)
preload()
}
+ // --- Animación al pasar página. La página nueva se pone debajo y la vieja (o una hoja) se anima encima;
+ // al terminar solo queda la nueva. Si se pasa otra página antes de acabar, la animación se completa ya.
+ let turning = null // { anims, cleanup }
+ function finishTurn() {
+ if (!turning) return
+ const t = turning
+ turning = null
+ t.anims.forEach(a => { try { a.cancel() } catch {} })
+ t.cleanup()
+ }
+ // Animación hecha a mano (requestAnimationFrame) con la misma forma que las de la Web Animations API
+ // que usa finishTurn: cancel() y la promesa finished.
+ function frameAnim(duration, step) {
+ let raf = 0, settle
+ const finished = new Promise((res, rej) => { settle = { res, rej } })
+ const t0 = performance.now()
+ const tick = now => {
+ const g = Math.min(1, (now - t0) / duration)
+ step(g)
+ if (g < 1) raf = requestAnimationFrame(tick)
+ else settle.res()
+ }
+ step(0)
+ raf = requestAnimationFrame(tick)
+ return { finished, cancel() { cancelAnimationFrame(raf); settle.rej(new Error('cancelada')) } }
+ }
+ // Hoja de papel que se dobla: tiras verticales anidadas, cada una girada un poco respecto a la anterior,
+ // así que la hoja forma una curva. Cada tira enseña su trozo de la página por delante y del reverso
+ // (la página siguiente en doble página, o papel en blanco) por detrás.
+ function paperCurl(r, frontSrc, backSrc, spineLeft) {
+ const N = Math.max(10, Math.min(22, Math.round(r.w / 24)))
+ const w = r.w / N
+ const sgn = spineLeft ? -1 : 1 // hacia dónde gira: el borde libre se levanta hacia quien mira
+ const el = document.createElement('div')
+ el.className = 'curl'
+ const strips = []
+ let parent = el
+ for (let i = 0; i < N; i++) {
+ const fc = spineLeft ? i : N - 1 - i // columna de la página (desde la izquierda) de esta tira
+ const bc = N - 1 - fc // en el reverso, la hoja está dada la vuelta
+ const s = document.createElement('div')
+ s.className = 'strip'
+ Object.assign(s.style, { width: `${w}px`, height: `${r.h}px`, [spineLeft ? 'left' : 'right']: '0', transformOrigin: spineLeft ? 'left center' : 'right center' })
+ const img = (src, col) => ``
+ s.innerHTML = `
${img(frontSrc, fc)}
${backSrc ? img(backSrc, bc) : ''}
`
+ parent.append(s)
+ strips.push({ s, f: s.firstChild.lastChild, b: s.children[1].lastChild })
+ parent = s
+ }
+ const step = spineLeft ? w : -w
+ // Ángulo (0 = en su sitio, 180 = pasada) de cada tira en el avance g (0 a 1). El borde libre va por
+ // delante del lomo y la curva es máxima a mitad de camino. reverse: la hoja vuelve a su sitio (el
+ // mismo gesto al revés, con el borde libre llegando primero).
+ function angles(g, reverse) {
+ const e = g < 0.5 ? 4 * g ** 3 : 1 - (-2 * g + 2) ** 3 / 2
+ const bend = Math.sin(Math.PI * g) * 125
+ return strips.map((t, i) => {
+ const a = Math.min(180, Math.max(0, 180 * e + bend * (((i + 0.5) / N) ** 1.4 - 0.45)))
+ return reverse ? 180 - a : a
+ })
+ }
+ // Devuelve cuánto está levantada la hoja (0 a 1), para la sombra de debajo.
+ function set(g, reverse) {
+ let prev = 0, lift = 0
+ angles(g, reverse).forEach((a, i) => {
+ const t = strips[i]
+ t.s.style.transform = `${i ? `translateX(${step}px) ` : ''}rotateY(${(sgn * (a - prev)).toFixed(2)}deg)`
+ prev = a
+ const c = Math.cos((a * Math.PI) / 180)
+ t.f.style.opacity = ((1 - c) * 0.3).toFixed(3) // se oscurece al inclinarse
+ t.b.style.opacity = ((1 + c) * 0.3).toFixed(3)
+ lift = Math.max(lift, Math.sin((a * Math.PI) / 180))
+ })
+ return lift
+ }
+ // Tramo de g en el que se ve algo de la hoja sobre la página (alguna tira por debajo de 90°): con una
+ // sola página, lo que pasa del lomo queda fuera de la pantalla y no vale la pena esperarlo.
+ function span(reverse) {
+ const seen = g => angles(g, reverse).some(a => a < 88)
+ let g0 = 0, g1 = 1
+ while (g0 < 1 && !seen(g0)) g0 += 0.01
+ while (g1 > g0 && !seen(g1)) g1 -= 0.01
+ return [Math.max(0, g0 - 0.02), Math.min(1, g1 + 0.04)]
+ }
+ return { el, set, span }
+ }
+ function turnPage(old, stage, dir) {
+ // "Delante" es hacia la izquierda en izq→der (la hoja derecha pasa a la izquierda) y al revés en der→izq.
+ const sign = (settings.dir === 'rtl' ? -1 : 1) * dir
+ const fitWidth = box.classList.contains('fit-width')
+ let kind = settings.anim
+ const ready = im => im.complete && im.naturalWidth > 0
+ const oldImgs = [...old.querySelectorAll('img')]
+ // La hoja necesita las páginas ya cargadas y la misma disposición (1↔1 o 2↔2); si no, deslizar.
+ if (kind === 'flip' && (fitWidth || !oldImgs.every(ready))) kind = 'slide'
+ if (fitWidth && kind === 'slide') kind = 'fade' // páginas largas con scroll: un fundido queda mejor
+ old.classList.add('leaving')
+ box.prepend(stage) // la nueva debajo (antes en el DOM), la vieja encima
+ const double = oldImgs.length === 2 && shown.length === 2
+ // Una página hacia atrás: la hoja que vuelve es la nueva, que tiene que estar ya cargada.
+ if (kind === 'flip' && !double && dir < 0 && !ready(stage.querySelector('img'))) kind = 'slide'
+ const anims = []
+ const extra = []
+ const cleanup = () => { old.remove(); extra.forEach(e => e.remove()) }
+ const ease = 'cubic-bezier(.2,.7,.2,1)'
+ if (kind === 'fade') {
+ anims.push(old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' }))
+ } else if (kind === 'slide') {
+ anims.push(old.animate([{ transform: 'translateX(0)' }, { transform: `translateX(${-sign * 100}%)` }], { duration: 260, easing: ease, fill: 'forwards' }))
+ anims.push(stage.animate([{ transform: `translateX(${sign * 100}%)` }, { transform: 'translateX(0)' }], { duration: 260, easing: ease }))
+ } else {
+ // Pasar hoja: la hoja se dobla como papel (se levanta primero el borde libre y el lomo la sigue).
+ // Medidas relativas al contenedor.
+ const B = box.getBoundingClientRect()
+ const rel = im => { const r = im.getBoundingClientRect(); return { l: r.left - B.left, t: r.top - B.top, w: r.width, h: r.height } }
+ const byX = imgs => [...imgs].sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left)
+ const place = (el, r) => Object.assign(el.style, { left: `${r.l}px`, top: `${r.t}px`, width: `${r.w}px`, height: `${r.h}px` })
+ let rect, frontSrc, backSrc = null, spineLeft, reverse = false
+ if (double) {
+ // Doble página: gira la hoja del lado hacia el que se avanza (derecha si sign > 0); su reverso es
+ // la página nueva del otro lado del lomo, y la página vieja de ese lado sigue a la vista (copia
+ // encima de la nueva) hasta que la hoja cae.
+ const oldX = byX(oldImgs)
+ const newX = byX(stage.querySelectorAll('img'))
+ const front = sign > 0 ? oldX.at(-1) : oldX[0]
+ const stay = sign > 0 ? oldX[0] : oldX.at(-1)
+ rect = rel(front)
+ frontSrc = front.src
+ backSrc = (sign > 0 ? newX[0] : newX.at(-1)).src
+ spineLeft = sign > 0
+ const keep = document.createElement('img')
+ keep.className = 'leafkeep'
+ keep.src = stay.src
+ place(keep, rel(stay))
+ box.append(keep)
+ extra.push(keep)
+ old.style.visibility = 'hidden' // la hoja y la copia ocupan su lugar
+ } else {
+ // Una página: el lomo está a la izquierda en izq→der y a la derecha en der→izq. Hacia delante, la
+ // página actual se levanta y se va; hacia atrás, la anterior vuelve y se coloca encima (el mismo
+ // gesto al revés, con el borde libre por delante), con la actual debajo hasta que la tapa.
+ const img = dir > 0 ? oldImgs[0] : stage.querySelector('img')
+ rect = rel(img)
+ frontSrc = img.src
+ spineLeft = settings.dir !== 'rtl'
+ reverse = dir < 0
+ if (dir > 0) old.style.visibility = 'hidden'
+ }
+ // Sombra que la hoja levantada proyecta sobre lo que hay debajo.
+ const drop = document.createElement('i')
+ drop.className = `curldrop ${spineLeft ? 'l' : 'r'}`
+ place(drop, rect)
+ const curl = paperCurl(rect, frontSrc, backSrc, spineLeft)
+ place(curl.el, rect)
+ box.append(drop, curl.el)
+ extra.push(drop, curl.el)
+ const [g0, g1] = double ? [0, 1] : curl.span(reverse)
+ anims.push(frameAnim(double ? 700 : 560, t => {
+ const lift = curl.set(g0 + (g1 - g0) * t, reverse)
+ drop.style.opacity = (lift * 0.8).toFixed(3)
+ }))
+ }
+ turning = { anims, cleanup }
+ Promise.all(anims.map(a => a.finished)).then(() => { if (turning?.anims === anims) { turning = null; cleanup() } }, () => {})
+ }
function showEnd() {
+ finishTurn()
atEnd = true
resetZoom()
box.innerHTML = endBlock
reader.classList.remove('hide-ui')
}
- function goTo(n, fromScroll = false, force = false) {
+ function goTo(n, fromScroll = false, force = false, dir = 0) {
if (isPaged()) {
if (n >= total) return showEnd()
n = Math.min(Math.max(n, 0), total - 1)
const g = groupOf(n)
const changed = atEnd || g[0] !== shown[0] || g.length !== shown.length
+ const wasEnd = atEnd
atEnd = false
shown = g
page = g[0]
setPageLabel()
- if (changed || force || !$('.stage img', box)) showPaged()
+ if (changed || force || !$('.stage img', box)) showPaged(changed && !wasEnd ? dir : 0)
if (changed) scheduleSave()
return
}
@@ -1245,10 +1421,10 @@ async function viewReader(mangaId, chapterId, startPage) {
if (!fromScroll) box.querySelectorAll('img')[page]?.scrollIntoView()
if (changed) scheduleSave()
}
- const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1) }
+ const forward = () => { if (!atEnd) goTo(shown.at(-1) + 1, false, false, 1) }
const backward = () => {
if (atEnd) { box.innerHTML = ''; goTo(total - 1, false, true); return }
- if (page > 0) goTo(groupOf(page - 1)[0])
+ if (page > 0) goTo(groupOf(page - 1)[0], false, false, -1)
}
function render() {
@@ -1296,7 +1472,7 @@ async function viewReader(mangaId, chapterId, startPage) {
// --- Zoom (modos por página): pellizco, doble toque/clic en el centro, Ctrl+rueda y teclas + - 0.
const zoom = { s: 1, x: 0, y: 0 }
- const stageEl = () => $('.stage', box)
+ const stageEl = () => $('.stage:not(.leaving)', box)
function applyZoom() {
const st = stageEl()
if (st) st.style.transform = zoom.s === 1 ? '' : `translate(${zoom.x}px, ${zoom.y}px) scale(${zoom.s})`
@@ -1384,6 +1560,11 @@ async function viewReader(mangaId, chapterId, startPage) {
gesture = { type: 'pan', x: p.x, y: p.y }
} else if (pts.size === 0) {
if (e.type === 'pointerup' && tap && !tap.moved && Date.now() - tap.t < 350) onTap(e.clientX, e.clientY)
+ else if (e.type === 'pointerup' && tap?.moved && zoom.s === 1 && e.pointerType !== 'mouse') {
+ // Deslizar el dedo en horizontal pasa página (hacia la izquierda = adelante en izq→der).
+ const dx = e.clientX - tap.x, dy = e.clientY - tap.y
+ if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - tap.t < 800) ((dx < 0) === (settings.dir === 'ltr') ? forward : backward)()
+ }
gesture = null
tap = null
}
@@ -1403,7 +1584,7 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#zoomreset').addEventListener('click', resetZoom)
$('#ropts').addEventListener('click', () => { const p = $('.rpanel', reader); p.hidden = !p.hidden; $('#ropts').classList.toggle('on', !p.hidden) })
- const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false })
+ const saveGlobal = () => store.set('readerSettings', { saver: settings.saver, filter: settings.filter, brightness: settings.brightness, hd: settings.hd !== false, anim: settings.anim })
const savePrefs = () => {
api(`/api/prefs/${mangaId}`, { method: 'POST', body: { mode: settings.mode, fit: settings.fit, dir: settings.dir } })
.then(() => toast('Guardado para este manga')).catch(err => toast(err.message))
@@ -1417,6 +1598,8 @@ async function viewReader(mangaId, chapterId, startPage) {
$('#fit').addEventListener('change', e => { settings.fit = e.target.value; savePrefs(); render() })
$('#dir').addEventListener('change', e => { settings.dir = e.target.value; box.dataset.dir = settings.dir; slider.dir = settings.dir; savePrefs() })
$('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveGlobal(); applyFilter() })
+ $('#anim').value = settings.anim
+ $('#anim').addEventListener('change', e => { settings.anim = e.target.value; saveGlobal() })
$('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveGlobal(); applyFilter() })
$('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveGlobal(); applyFilter() })
$('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveGlobal(); history.replaceState(null, '', readUrl(mangaId, chapterId, lastShown())); router() })
diff --git a/app/public/index.html b/app/public/index.html
index 95d915c..baef472 100644
--- a/app/public/index.html
+++ b/app/public/index.html
@@ -2,6 +2,7 @@
+
Manga Reader
diff --git a/app/public/style.css b/app/public/style.css
index 2f9d7a8..e6ca657 100644
--- a/app/public/style.css
+++ b/app/public/style.css
@@ -352,3 +352,19 @@ body.noscroll { overflow: hidden; }
background: #3a2a12; color: #ffd9a0; border-bottom: 1px solid #5a4220; font-size: .9rem; }
#netbanner svg { flex: none; width: 18px; height: 18px; }
body.reading #netbanner { display: none; }
+
+/* Animación al pasar página (modos por página) */
+.paged { position: relative; perspective: 2200px; }
+.paged .stage.leaving { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: #000; }
+.paged.fit-width .stage.leaving { bottom: auto; }
+.paged .curl { position: absolute; z-index: 3; transform-style: preserve-3d; pointer-events: none; }
+.paged .curl .strip { position: absolute; top: 0; transform-style: preserve-3d; will-change: transform; }
+.paged .curl .sf { position: absolute; top: 0; bottom: 0; left: -0.5px; right: -0.5px; overflow: hidden; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
+.paged .curl .sf img { position: absolute; top: 0; max-width: none; max-height: none; object-fit: fill; display: block; }
+.paged .curl .back { transform: rotateY(180deg); }
+.paged .curl .back.paper { background: #e9e5dc; }
+.paged .curl .sf i { position: absolute; inset: 0; background: #000; opacity: 0; }
+.paged .curldrop { position: absolute; z-index: 2; pointer-events: none; opacity: 0; }
+.paged .curldrop.l { background: linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
+.paged .curldrop.r { background: linear-gradient(270deg, rgba(0,0,0,.55), rgba(0,0,0,.2) 35%, rgba(0,0,0,0) 75%); }
+.paged .leafkeep { position: absolute; z-index: 2; pointer-events: none; max-width: none; max-height: none; object-fit: fill; }
diff --git a/app/public/sw.js b/app/public/sw.js
index 39ac123..cf9a4c3 100644
--- a/app/public/sw.js
+++ b/app/public/sw.js
@@ -4,8 +4,20 @@ 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(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)).then(() => self.skipWaiting()))
+ e.waitUntil(installShell().then(() => self.skipWaiting()))
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
@@ -25,11 +37,13 @@ async function fromCache(res) {
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 } = {}) {
+// 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
@@ -54,7 +68,7 @@ self.addEventListener('fetch', e => {
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 (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 }))
diff --git a/app/server.js b/app/server.js
index 72cff4b..db78a0f 100644
--- a/app/server.js
+++ b/app/server.js
@@ -765,10 +765,19 @@ const requireUser = req => {
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
+// index.html enlaza app.js y style.css con ?v=: si un proxy delante cambia el
+// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
+// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
+const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10)
+const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
+ .replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
+ .replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
+ .replace('', ``)
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
+ if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML)
fs.createReadStream(file).pipe(res)
}