import { createHash } from 'node:crypto' import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs' import { extname, join, normalize, resolve } from 'node:path' import { execFileSync } from 'node:child_process' import http from 'node:http' const port = Number(process.env.PORT ?? 9174) const publicDir = resolve(process.env.PUBLIC_DIR ?? 'dist') const dataDir = resolve(process.env.LIBREBIBLE_DATA_DIR ?? '../libre-bible-data/packages/json') const stateDir = resolve(process.env.STATE_DIR ?? 'web-state') const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sqlite3')) const deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : '' const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3' const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024) const mimeTypes = new Map([ ['.css', 'text/css; charset=utf-8'], ['.html', 'text/html; charset=utf-8'], ['.ico', 'image/x-icon'], ['.js', 'application/javascript; charset=utf-8'], ['.json', 'application/json; charset=utf-8'], ['.jsonl', 'application/x-ndjson; charset=utf-8'], ['.map', 'application/json; charset=utf-8'], ['.png', 'image/png'], ['.svg', 'image/svg+xml'], ['.txt', 'text/plain; charset=utf-8'], ['.woff2', 'font/woff2'], ]) mkdirSync(stateDir, { recursive: true }) sqlite(` CREATE TABLE IF NOT EXISTS workspace_state ( id INTEGER PRIMARY KEY CHECK (id = 1), state_json TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); `) function sqlite(sql) { const output = execFileSync(sqliteBin, ['-json', dbPath, sql], { encoding: 'utf8', maxBuffer: 1024 * 1024 * 20, }) return output.trim() ? JSON.parse(output) : [] } function quoteSql(value) { return `'${String(value).replaceAll("'", "''")}'` } function sendJson(response, status, value) { const body = JSON.stringify(value) response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }) response.end(body) } function sendText(response, status, value) { response.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': Buffer.byteLength(value), 'Cache-Control': 'no-store', }) response.end(value) } function readBody(request) { return new Promise((resolveBody, reject) => { let size = 0 const chunks = [] request.on('data', (chunk) => { size += chunk.length if (size > maxBodyBytes) { reject(new Error('Request body is too large.')) request.destroy() return } chunks.push(chunk) }) request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8'))) request.on('error', reject) }) } function safeFile(root, requestPath) { const decoded = decodeURIComponent(requestPath) const candidate = normalize(join(root, decoded)) const rootWithSeparator = root.endsWith('\\') || root.endsWith('/') ? root : `${root}/` if (candidate !== root && !candidate.startsWith(rootWithSeparator)) return null return candidate } function serveFile(response, filePath, cache = 'no-store') { if (!existsSync(filePath) || !statSync(filePath).isFile()) return false const stat = statSync(filePath) const contentType = mimeTypes.get(extname(filePath).toLowerCase()) ?? 'application/octet-stream' response.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size, 'Cache-Control': cache, }) createReadStream(filePath).pipe(response) return true } function serveStatic(response, requestPath) { const pathWithoutSlash = requestPath.replace(/^\/+/, '') const staticPath = safeFile(publicDir, pathWithoutSlash || 'index.html') if (staticPath && serveFile(response, staticPath, requestPath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store')) { return true } return serveFile(response, join(publicDir, 'index.html'), 'no-store') } function serveLibreBible(response, requestPath) { const relative = requestPath.replace(/^\/librebible\/?/, '') const filePath = safeFile(dataDir, relative) if (!filePath || !serveFile(response, filePath, 'public, max-age=300')) { sendText(response, 404, 'Not found') } } function deployInfo() { if (!deployInfoPath || !existsSync(deployInfoPath)) return { commit: 'unknown' } try { return JSON.parse(readFileSync(deployInfoPath, 'utf8')) } catch { return { commit: readFileSync(deployInfoPath, 'utf8').trim() || 'unknown' } } } async function handleApi(request, response, url) { if (url.pathname === '/api/health') { sendJson(response, 200, { ok: true, deploy: deployInfo() }) return } if (url.pathname === '/api/workspace' && request.method === 'GET') { const rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;') if (!rows.length) { response.writeHead(204, { 'Cache-Control': 'no-store' }) response.end() return } response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }) response.end(rows[0].state_json) return } if (url.pathname === '/api/workspace' && request.method === 'PUT') { const body = await readBody(request) let parsed try { parsed = JSON.parse(body) } catch { sendJson(response, 400, { error: 'Workspace payload must be valid JSON.' }) return } sqlite( `INSERT INTO workspace_state (id, state_json, updated_at) VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`, ) sendJson(response, 200, { ok: true, checksum: createHash('sha256').update(JSON.stringify(parsed)).digest('hex'), }) return } sendJson(response, 404, { error: 'Unknown API route.' }) } const server = http.createServer((request, response) => { const url = new URL(request.url ?? '/', 'http://localhost') Promise.resolve() .then(() => { if (url.pathname.startsWith('/api/')) return handleApi(request, response, url) if (url.pathname.startsWith('/librebible/')) return serveLibreBible(response, url.pathname) return serveStatic(response, url.pathname) }) .catch((error) => { sendJson(response, 500, { error: String(error.message ?? error) }) }) }) server.listen(port, '127.0.0.1', () => { console.log(`Libre Study web server listening on http://127.0.0.1:${port}`) console.log(`Serving app from ${publicDir}`) console.log(`Serving LibreBible data from ${dataDir}`) console.log(`Persisting workspace to ${dbPath}`) })