From 29af893917713860198c73801491248a475ef459 Mon Sep 17 00:00:00 2001 From: Nino Date: Sun, 1 Nov 2026 12:41:22 -0600 Subject: [PATCH] =?UTF-8?q?Animaci=C3=B3n=20de=20hoja=20de=20papel=20al=20?= =?UTF-8?q?pasar=20p=C3=A1gina=20y=20URLs=20versionadas=20para=20saltar=20?= =?UTF-8?q?la=20cach=C3=A9=20del=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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= para que ni el proxy ni el navegador den una copia vieja. Co-Authored-By: Claude Opus 5.5 --- app/public/app.js | 205 +++++++++++++++++++++++++++++++++++++++--- app/public/index.html | 1 + app/public/style.css | 16 ++++ app/public/sw.js | 20 ++++- app/server.js | 9 ++ 5 files changed, 237 insertions(+), 14 deletions(-) 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) }