igneum/tools/site-serve.mjs

46 lines
3.9 KiB
JavaScript

#!/usr/bin/env node
// Local preview of the site with its API functions: serves site/ the way Vercel does (clean URLs, the rewrites and
// headers of site/vercel.json) and runs site/api/*.mjs in-process with a small req/res shim. DATABASE_URL comes from
// the environment or ~/.config/igneum/env, like the observer. Options through the environment:
// PORT (default 4173), LIVE_TABLE_PREFIX (a test observer's tables), EXPLORER_EVM_RPC (an EVM JSON-RPC for balances)
// node tools/site-serve.mjs
import { createServer } from 'node:http';
import { readFileSync, existsSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, dirname, extname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const site = join(dirname(fileURLToPath(import.meta.url)), '..', 'site');
const PORT = Number(process.env.PORT || 4173);
if (!process.env.DATABASE_URL) {
try { const m = /^DATABASE_URL=(.*)$/m.exec(readFileSync(`${homedir()}/.config/igneum/env`, 'utf8')); if (m) process.env.DATABASE_URL = m[1].trim().replace(/^['"]|['"]$/g, ''); } catch { }
}
const cfg = JSON.parse(readFileSync(join(site, 'vercel.json'), 'utf8'));
const rewrites = (cfg.rewrites || []).map(r => ({ re: new RegExp('^' + r.source.replace(/:[a-z]+/g, '[^/]+') + '$'), to: r.destination }));
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.ico': 'image/x-icon', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.webp': 'image/webp', '.txt': 'text/plain', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json' };
const handlers = new Map();
async function api(name) {
if (!handlers.has(name)) handlers.set(name, (await import(pathToFileURL(join(site, 'api', name + '.mjs')).href)).default);
return handlers.get(name);
}
function shim(res) {
const r = { status(c) { res.statusCode = c; return r; }, setHeader: (k, v) => res.setHeader(k, v), json(o) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(o)); }, end: (b) => res.end(b) };
return r;
}
createServer(async (req, res) => {
const url = new URL(req.url, 'http://x'); let p = decodeURIComponent(url.pathname);
for (const rw of rewrites) if (rw.re.test(p)) { p = rw.to; break; }
if (p.startsWith('/api/')) {
const name = p.slice(5).replace(/[^a-z0-9_-]/gi, '');
if (!existsSync(join(site, 'api', name + '.mjs'))) { res.statusCode = 404; return res.end('no such function'); }
// IGNEUM_LIVE_FIXTURE=<path>: answer /api/live from a recorded reply with its clock moved to now (captures and tests off the database)
if (name === 'live' && process.env.IGNEUM_LIVE_FIXTURE) { const f = JSON.parse(readFileSync(process.env.IGNEUM_LIVE_FIXTURE, 'utf8')); const shift = Date.now() - new Date(f.now).getTime(); f.now = new Date().toISOString(); for (const b of f.blocks || []) b.ts += shift; res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify(f)); }
try { const h = await api(name); return await h({ method: req.method, url: req.url, headers: req.headers, query: Object.fromEntries(url.searchParams), socket: req.socket }, shim(res)); }
catch (e) { res.statusCode = 500; return res.end(String(e.stack || e)); }
}
const candidates = [join(site, p), join(site, p + '.html'), join(site, p, 'index.html')];
const file = candidates.find(f => existsSync(f) && statSync(f).isFile());
if (!file || !file.startsWith(site)) { res.statusCode = 404; res.setHeader('content-type', 'text/html; charset=utf-8'); return res.end(readFileSync(join(site, '404.html'))); }
res.setHeader('content-type', TYPES[extname(file)] || 'application/octet-stream');
res.end(readFileSync(file));
}).listen(PORT, '127.0.0.1', () => console.log(`site preview on http://127.0.0.1:${PORT} (prefix '${process.env.LIVE_TABLE_PREFIX || ''}', evm rpc ${process.env.EXPLORER_EVM_RPC || 'none'})`));