#!/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'); } 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'})`));