// Production server for Shelfless. // Serves the built SPA (dist/) and reverse-proxies ABS API paths to the Audiobookshelf // server given by ABS_URL — so the browser talks to a single origin (no CORS) and the // ABS address is fixed by the deployment, not entered per browser. // // ABS_URL=http://127.0.0.1:13378 PORT=8080 node server/index.mjs // // Only Node built-ins are used (no dependencies to install on the container). import http from 'node:http' import https from 'node:https' import { createReadStream, existsSync, statSync } from 'node:fs' import { join, extname, normalize } from 'node:path' import { fileURLToPath } from 'node:url' const PORT = Number(process.env.PORT || 8080) const HOST = process.env.HOST || '0.0.0.0' const ABS_URL = process.env.ABS_URL || 'http://127.0.0.1:13378' const ROOT = fileURLToPath(new URL('..', import.meta.url)) const DIST = join(ROOT, 'dist') // Paths that must be forwarded to Audiobookshelf. const PROXY_PREFIXES = ['/api', '/login', '/logout', '/public', '/status', '/hls', '/feed', '/socket.io'] const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.webmanifest': 'application/manifest+json', '.txt': 'text/plain; charset=utf-8', } const target = new URL(ABS_URL) const proxyLib = target.protocol === 'https:' ? https : http function isProxied(url) { return PROXY_PREFIXES.some((p) => url === p || url.startsWith(p + '/') || url.startsWith(p + '?')) } function proxy(req, res) { const headers = { ...req.headers, host: target.host } delete headers.origin delete headers.referer const proxyReq = proxyLib.request( { protocol: target.protocol, hostname: target.hostname, port: target.port || (target.protocol === 'https:' ? 443 : 80), method: req.method, path: req.url, headers, }, (proxyRes) => { res.writeHead(proxyRes.statusCode || 502, proxyRes.headers) proxyRes.pipe(res) }, ) proxyReq.on('error', (e) => { res.statusCode = 502 res.end(`Upstream error: ${e.message}`) }) req.pipe(proxyReq) } function sendFile(res, filePath, status = 200) { res.writeHead(status, { 'Content-Type': MIME[extname(filePath)] || 'application/octet-stream', // Hashed asset files are immutable; index.html should not be cached. 'Cache-Control': filePath.includes(`${join('dist', 'assets')}`) ? 'public, max-age=31536000, immutable' : 'no-cache', }) createReadStream(filePath).pipe(res) } // Resolve a request path to an existing static file inside dist/, or null. function resolveStaticFile(url) { const urlPath = decodeURIComponent((url || '/').split('?')[0]) const safe = normalize(urlPath).replace(/^(\.\.[/\\])+/, '') const filePath = join(DIST, safe) if (!filePath.startsWith(DIST)) return null if (existsSync(filePath) && statSync(filePath).isFile()) return filePath return null } const INDEX_HTML = join(DIST, 'index.html') const server = http.createServer((req, res) => { const url = req.url || '/' // 1) Known ABS paths → forward upstream. if (isProxied(url)) return proxy(req, res) // 2) An existing static asset of the SPA → serve it. const file = resolveStaticFile(url) if (file) return sendFile(res, file) // 3) A browser navigating to a client-side route → serve the SPA shell. const accept = String(req.headers.accept || '') if (req.method === 'GET' && accept.includes('text/html') && existsSync(INDEX_HTML)) { return sendFile(res, INDEX_HTML) } // 4) Anything else (any other API/data request) → forward to ABS, so the real // server never needs to be exposed on its own domain. return proxy(req, res) }) server.listen(PORT, HOST, () => { console.log(`Shelfless running on http://${HOST}:${PORT} → ABS ${ABS_URL}`) })