Fuente M440, modo sin conexión (servidor sin internet y app sin servidor), cola del dispositivo en Colas y arreglo del reescalado de tiras altas

- 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>
This commit is contained in:
2026-11-01 09:52:19 -06:00
co-authored by Claude Opus 5.5
parent d3e196a2f3
commit 86999b5725
11 changed files with 712 additions and 78 deletions
+99 -26
View File
@@ -1,4 +1,4 @@
// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo)
// Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440
// con usuarios y historial de lectura persistente en SQLite.
import http from 'node:http'
import fs from 'node:fs'
@@ -12,6 +12,8 @@ import { setupClips } from './clips.js'
import { setupCovers } from './covers.js'
import { setupLibrary } from './library.js'
import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
@@ -177,6 +179,9 @@ const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual.
const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}`
// Conexión a internet del servidor (app/connectivity.js). Sin ella, las fuentes fallan al instante.
const net = setupConnectivity({ httpError: (status, message) => new HttpError(status, message) })
async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname)
for (const [k, v] of Object.entries(params)) {
@@ -184,7 +189,8 @@ async function md(pathname, params = {}, ttl = 5 * 60e3) {
else if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
return cached(url.toString(), ttl, async () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } }).catch(() => { throw new HttpError(502, 'No se pudo conectar con MangaDex') })
net.guard()
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30e3) }).catch(() => { net.failed(); throw new HttpError(502, 'No se pudo conectar con MangaDex') })
if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json()
})
@@ -313,17 +319,24 @@ const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
// las páginas del capítulo vienen en el HTML como <img data-src>.
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()]
if (named) return named
return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
const ENTITIES = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿',
hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™',
aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', ccedil: 'ç', agrave: 'à', egrave: 'è', ograve: 'ò',
Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', Ccedil: 'Ç',
}
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, e) => {
if (e[0] !== '#') return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? all
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : all
}).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
for (let i = 0; ; i++) {
net.guard()
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
.catch(() => null)
.catch(() => { net.failed(); return null })
if (res?.ok) return asJson ? res.json() : res.text()
if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404')
const down = !res || res.status >= 500
@@ -426,6 +439,13 @@ async function lcPageUrls(chapterId) {
}
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
// ---------- M440 (app/m440.js) ----------
const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net })
const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl)
// Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex.
const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : 'md')
const spanishOnly = id => sourceOf(id) !== 'md'
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga).
async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
@@ -435,7 +455,14 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
if (isCover && covers.sendCached(res, u)) return
const r = await fetchImage(url)
// Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3
// intentos con tiempo límite, para no dejar al navegador esperando para siempre.
net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos
const r = await withRetry(async () => {
const r = await fetchImage(url, AbortSignal.timeout(60e3))
if (r.status >= 500) throw new HttpError(502, `Imagen ${r.status}`)
return r
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) })
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (isCover) {
const buf = Buffer.from(await r.arrayBuffer())
@@ -452,8 +479,12 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
}
function fetchImage(url, signal) {
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
net.guard()
const host = new URL(url).hostname
const isMd = /(^|\.)mangadex\.(org|network)$/.test(host)
const referer = isMd ? null : /(^|\.)m440\.in$/.test(host) ? MI_BASE + '/' : LC_BASE + '/'
return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(referer ? { Referer: referer } : {}) } })
.catch(e => { if (e.message !== 'Host no permitido') net.failed(); throw e })
}
// ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
@@ -481,8 +512,9 @@ async function downloadBuffer(url) {
const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }
async function fetchMangaFull(mangaId, lang) {
return mangaId.startsWith('lc~')
? lcManga(mangaId)
const src = sourceOf(mangaId)
return src === 'lc' ? lcManga(mangaId)
: src === 'mi' ? mi.manga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
}
@@ -491,7 +523,7 @@ const dlCurrent = new Map()
async function downloadChapter(dl, manga, c, dir) {
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 })
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : getPageUrls(c.id, false, 0)))
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : manga.source === 'mi' ? mi.pageUrls(c.id) : getPageUrls(c.id, false, 0)))
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length })
const entries = []
// 4 imágenes en paralelo.
@@ -589,6 +621,12 @@ async function runDownload(dl) {
await downloadChapter(dl, manga, c, dir)
} catch (e) {
if (isCancelled(dl.manga_id)) break
// Se cayó internet: no cuenta como fallo; la descarga queda en cola y sigue al volver la conexión.
if (!(await net.check())) {
dlCurrent.delete(dl.manga_id)
dlUpdate(dl.manga_id, { status: 'queued', error: null })
return
}
failed++
lastError = `Cap. ${c.num ?? '?'}: ${e.message}`
console.warn('Descarga', manga.title, lastError)
@@ -596,7 +634,7 @@ async function runDownload(dl) {
const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n
dlUpdate(dl.manga_id, { done, failed, error: lastError })
// MangaDex limita /at-home/server a 40 peticiones por minuto.
await sleep(manga.source === 'lc' ? 500 : 1600)
await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500)
}
dlCurrent.delete(dl.manga_id)
if (isCancelled(dl.manga_id)) {
@@ -612,12 +650,15 @@ async function pumpDownloads() {
pumping = true
try {
for (;;) {
if (!net.online()) break // se reanuda con net.whenOnline
const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get()
if (!dl) break
requeue.delete(dl.manga_id)
try {
await runDownload(dl)
if (!net.online()) break
} catch (e) {
if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break }
console.error('Descarga fallida', dl.manga_id, e)
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
}
@@ -676,6 +717,15 @@ function downloadCover(mangaId, folder, metaCover) {
const u = rawFromProxy(c)
return u ? coverUrl(u, mangaId) : c || null
}
// Sin conexión: la búsqueda va sobre lo descargado en el servidor.
const fold = s => String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
function localSearch(text) {
const words = fold(text).split(/\s+/).filter(Boolean)
return db.prepare(`SELECT manga_id, title, folder, done, json_extract(meta, '$.manga.cover') meta_cover FROM downloads
WHERE done > 0 ORDER BY updated_at DESC`).all()
.filter(d => words.every(w => fold(d.title).includes(w)))
.map(d => ({ id: d.manga_id, source: sourceOf(d.manga_id), title: d.title, cover: downloadCover(d.manga_id, d.folder, d.meta_cover), sub: `${d.done} cap. descargado${d.done === 1 ? '' : 's'}` }))
}
const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
const downloadInfo = (mangaId, size = false) => {
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
@@ -702,8 +752,8 @@ async function readBody(req) {
}
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
}
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
// Manga en el historial o en seguidos del usuario (sus portadas se reescalan).
const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ?
@@ -809,16 +859,34 @@ async function route(req, res) {
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m'))
if (p === '/api/img' && method === 'GET') {
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
try {
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'))
} catch (e) {
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
if (mid && !res.headersSent && e.status !== 403) {
const dl = downloadInfo(mid)
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
if (file && fs.existsSync(file)) {
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=300' })
return fs.createReadStream(file).pipe(res)
}
}
throw e
}
}
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/categories' && method === 'GET') {
return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : await mdCategories())
return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' ? [] : await mdCategories())
}
if (p === '/api/search' && method === 'GET') {
const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500)
const tag = q.get('tag') || ''
if (!net.online()) return json(res, 200, localSearch(q.get('q') || ''))
if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page))
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page))
const anyLang = q.get('anyLang') === '1'
const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null
@@ -833,7 +901,7 @@ async function route(req, res) {
} catch (e) {
// Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados.
const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1])
if (!dl) throw e
if (!dl) throw net.online() ? e : new HttpError(503, 'El servidor no tiene conexión a internet y este manga no está descargado en el servidor.')
const saved = JSON.parse(dl.meta)
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id))
manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` }
@@ -843,16 +911,17 @@ async function route(req, res) {
const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const hdAvailable = !!upscale.hdFileOf(m[1])
const hd = hdAvailable && q.get('hd') !== '0'
const local = localChapter(m[1], hd)
if (!local && !net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet y este capítulo no está descargado en el servidor.')
const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`)
: m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
: m[1].startsWith('lc~') ? await lcPages(m[1]) : m[1].startsWith('mi~') ? await miPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable })
}
@@ -879,6 +948,7 @@ async function route(req, res) {
}
// --- Descargas ---
if (p === '/api/status' && method === 'GET') return json(res, 200, net.status())
if (p === '/api/queues' && method === 'GET') {
const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at`).all()
@@ -901,7 +971,7 @@ async function route(req, res) {
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null
if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido')
@@ -930,7 +1000,7 @@ async function route(req, res) {
}
if (p === '/api/progress' && method === 'POST') {
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos')
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(spanishOnly(b.mangaId) ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos')
const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now()
@@ -963,10 +1033,12 @@ async function route(req, res) {
}
const security = setupSecurity({ db })
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net })
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
const covers = setupCovers({ DATA_DIR, upscale })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
@@ -987,7 +1059,8 @@ http.createServer(async (req, res) => {
await route(req, res)
} catch (err) {
const status = err instanceof HttpError ? err.status : 500
if (status >= 500) console.error(req.method, req.url, err)
// Sin internet los 503 son lo esperado: no llenar el log de trazas.
if (status >= 500 && !(status === 503 && !net.online())) console.error(req.method, req.url, err)
// Los errores internos no se detallan al cliente (rutas, nombres de archivo…): solo en el log.
if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' })
else res.end()