Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1152 lines 46 kB view raw
1// lith — AC monolith server 2// Wraps Netlify function handlers in Express routes + serves static files. 3 4// Shim awslambda before anything imports @netlify/functions. 5// Netlify's stream() calls awslambda.streamifyResponse() at wrap time, 6// which doesn't exist outside AWS Lambda. This shim adapts the 3-arg 7// streaming function (event, responseStream, context) back to a normal 8// 2-arg handler (event, context) that returns {statusCode, headers, body}. 9import { PassThrough } from "stream"; 10import { Readable } from "stream"; 11 12if (typeof globalThis.awslambda === "undefined") { 13 globalThis.awslambda = { 14 streamifyResponse: (wrappedFn) => { 15 // wrappedFn expects (event, responseStream, context). 16 // It calls the real handler(event, context) internally, then pipes 17 // the body to responseStream via pipeline(). We provide a PassThrough 18 // as the responseStream and return it as the response body. 19 return async (event, context) => { 20 const pt = new PassThrough(); 21 22 // Promise that resolves when HttpResponseStream.from() is called 23 // inside wrappedFn, giving us the response metadata (statusCode, headers). 24 let resolveMetadata; 25 const metadataPromise = new Promise((r) => { resolveMetadata = r; }); 26 pt._resolveMetadata = resolveMetadata; 27 28 // Start the pipeline (don't await — data streams to pt asynchronously) 29 wrappedFn(event, pt, context).catch((err) => { 30 if (!pt.destroyed) pt.destroy(err); 31 }); 32 33 const metadata = await metadataPromise; 34 const webStream = Readable.toWeb(pt); 35 36 return { ...metadata, body: webStream }; 37 }; 38 }, 39 HttpResponseStream: { 40 from: (stream, metadata) => { 41 // Signal metadata to the adapter above 42 if (stream._resolveMetadata) stream._resolveMetadata(metadata || {}); 43 return stream; 44 }, 45 }, 46 }; 47} 48 49import express from "express"; 50import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "fs"; 51import { join, dirname } from "path"; 52import { fileURLToPath } from "url"; 53import { createServer as createHttpsServer } from "https"; 54import { createServer as createHttpServer } from "http"; 55import { resolveFunctionName } from "./route-resolution.mjs"; 56 57const __dirname = dirname(fileURLToPath(import.meta.url)); 58const SYSTEM = join(__dirname, "..", "system"); 59const PUBLIC = join(SYSTEM, "public"); 60const FN_DIR = join(SYSTEM, "netlify", "functions"); 61 62// Load .env from system/ if present (handles special chars in values) 63const envPath = join(SYSTEM, ".env"); 64if (existsSync(envPath)) { 65 for (const line of readFileSync(envPath, "utf-8").split("\n")) { 66 if (!line || line.startsWith("#")) continue; 67 const idx = line.indexOf("="); 68 if (idx === -1) continue; 69 const key = line.slice(0, idx).trim(); 70 const val = line.slice(idx + 1).trim(); 71 if (key && !process.env[key]) process.env[key] = val; 72 } 73} 74 75const PORT = process.env.PORT || 8888; 76const DEV = process.env.NODE_ENV !== "production"; 77 78// Tell functions we're in dev mode (so index.mjs uses cwd instead of /var/task) 79if (DEV) { 80 process.env.CONTEXT = process.env.CONTEXT || "dev"; 81 process.env.NETLIFY_DEV = process.env.NETLIFY_DEV || "true"; 82} 83 84// Set cwd to system/ so relative paths in functions resolve correctly 85process.chdir(SYSTEM); 86 87// SSL certs for local dev (same ones Netlify local context uses) 88const SSL_CERT = join(__dirname, "..", "ssl-dev", "localhost.pem"); 89const SSL_KEY = join(__dirname, "..", "ssl-dev", "localhost-key.pem"); 90const HAS_SSL = existsSync(SSL_CERT) && existsSync(SSL_KEY); 91 92const app = express(); 93const BOOT_TIME = Date.now(); 94 95// --- Response cache for hot GET endpoints --- 96const responseCache = new Map(); // key → { body, headers, statusCode, expires } 97const CACHE_TTLS = { 98 "handle-colors": 60_000, // 1 min (colors rarely change) 99 "version": 30_000, // 30s (git state) 100 "handles": 60_000, // 1 min 101 "mood": 30_000, // 30s 102 "tv": 30_000, // 30s 103 "keeps-config": 300_000, // 5 min (contract addresses) 104 "kidlisp-count": 60_000, // 1 min 105 "playlist": 60_000, // 1 min 106 "clock": 0, // never cache (it's a clock) 107}; 108 109// Clean expired entries every 30s 110setInterval(() => { 111 const now = Date.now(); 112 for (const [k, v] of responseCache) { 113 if (v.expires < now) responseCache.delete(k); 114 } 115}, 30_000); 116 117// --- Function stats & error log --- 118const fnStats = {}; // { fnName: { calls, errors, totalMs, lastCall, lastError } } 119const errorLog = []; // [{ time, fn, status, error, path, method }] 120const requestLog = []; // [{ time, fn, ms, status, path, method }] 121const MAX_ERROR_LOG = 500; 122const MAX_REQUEST_LOG = 1000; 123 124function recordCall(name, ms, status, path, method, error) { 125 if (!fnStats[name]) fnStats[name] = { calls: 0, errors: 0, totalMs: 0, lastCall: null, lastError: null }; 126 const s = fnStats[name]; 127 s.calls++; 128 s.totalMs += ms; 129 s.lastCall = new Date().toISOString(); 130 131 requestLog.unshift({ time: s.lastCall, fn: name, ms: Math.round(ms), status, path, method }); 132 if (requestLog.length > MAX_REQUEST_LOG) requestLog.length = MAX_REQUEST_LOG; 133 134 if (error || status >= 500) { 135 s.errors++; 136 s.lastError = new Date().toISOString(); 137 errorLog.unshift({ time: s.lastError, fn: name, status, error: error || `HTTP ${status}`, path, method }); 138 if (errorLog.length > MAX_ERROR_LOG) errorLog.length = MAX_ERROR_LOG; 139 } 140} 141 142function captureRawBody(req, _res, buf) { 143 if (buf?.length) req.rawBody = Buffer.from(buf); 144} 145 146// --- Body parsing --- 147app.use(express.json({ limit: "50mb", verify: captureRawBody })); 148app.use(express.urlencoded({ extended: true, limit: "50mb", verify: captureRawBody })); 149app.use(express.raw({ type: "*/*", limit: "50mb", verify: captureRawBody })); 150 151// --- CORS (mirrors Netlify _headers) --- 152app.use((req, res, next) => { 153 res.set("Access-Control-Allow-Origin", "*"); 154 res.set( 155 "Access-Control-Allow-Headers", 156 "Content-Type, Authorization, X-Requested-With", 157 ); 158 res.set( 159 "Access-Control-Expose-Headers", 160 "Content-Length, Content-Disposition, X-AC-OS-Requested-Layout, X-AC-OS-Layout, X-AC-OS-Fallback, X-AC-OS-Fallback-Reason, X-Build, X-Patch", 161 ); 162 res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); 163 if (req.method === "OPTIONS") return res.sendStatus(204); 164 next(); 165}); 166// --- Host-based rewrites that Netlify previously handled --- 167app.use((req, _res, next) => { 168 const host = (req.headers.host || "").split(":")[0].toLowerCase(); 169 170 // Preserve branded notepat.com URLs while serving the /notepat piece. 171 if ((host === "notepat.com" || host === "www.notepat.com") && req.path === "/") { 172 req.url = "/notepat" + (req.url === "/" ? "" : req.url.slice(req.path.length)); 173 } 174 175 // api.aesthetic.computer / api.prompt.ac → api-docs function (public API reference) 176 if (host === "api.aesthetic.computer" || host === "api.prompt.ac") { 177 if (req.path === "/" || req.path === "") { 178 req.url = "/api-docs" + (req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : ""); 179 } 180 } 181 182 next(); 183}); 184 185// --- Load Netlify functions --- 186const functions = {}; 187 188// Scripts that call process.exit() at import time — not API functions. 189const SKIP = new Set(["backfill-painting-codes", "test-tv-hits"]); 190 191for (const file of readdirSync(FN_DIR)) { 192 if (!file.endsWith(".mjs") && !file.endsWith(".js")) continue; 193 const name = file.replace(/\.(mjs|js)$/, ""); 194 if (SKIP.has(name)) continue; 195 try { 196 const mod = await import(join(FN_DIR, file)); 197 if (mod.handler) { 198 // Netlify Functions v1: export { handler } 199 functions[name] = mod.handler; 200 } else if (mod.default && typeof mod.default === "function") { 201 // Netlify Functions v2: export default async (req) => { ... } 202 // Wrap v2 handler to match v1 event/context signature 203 const v2fn = mod.default; 204 functions[name] = async (event, context) => { 205 // V2 functions receive a Request-like object; build one from the event 206 const url = event.rawUrl || `http://localhost${event.path || "/"}`; 207 const req = new Request(url, { 208 method: event.httpMethod, 209 headers: event.headers, 210 body: event.httpMethod !== "GET" && event.httpMethod !== "HEAD" ? event.body : undefined, 211 }); 212 req.query = event.queryStringParameters; 213 const resp = await v2fn(req, context); 214 // V2 returns a Web Response object 215 const body = await resp.text(); 216 const headers = {}; 217 resp.headers.forEach((v, k) => { headers[k] = v; }); 218 return { statusCode: resp.status, headers, body }; 219 }; 220 } 221 } catch (err) { 222 console.warn(` skip: ${name} (${err.message})`); 223 } 224} 225 226console.log(`Loaded ${Object.keys(functions).length} functions`); 227 228// --- Netlify event adapter --- 229function toEvent(req) { 230 // Reconstruct body as string (Netlify handlers expect string or null). 231 // Prefer rawBody when available — it preserves the exact bytes the client 232 // sent, which is critical for webhook signature verification (Stripe, etc.). 233 let body = null; 234 if (req.rawBody) { 235 body = Buffer.isBuffer(req.rawBody) 236 ? req.rawBody.toString("utf-8") 237 : String(req.rawBody); 238 } else if (req.body) { 239 const contentType = (req.headers["content-type"] || "").toLowerCase(); 240 body = 241 typeof req.body === "string" 242 ? req.body 243 : Buffer.isBuffer(req.body) 244 ? req.body.toString("utf-8") 245 // Preserve HTML form posts as urlencoded strings so legacy handlers 246 // using URLSearchParams(event.body) continue to work after lith. 247 : contentType.includes("application/x-www-form-urlencoded") 248 ? new URLSearchParams( 249 Object.entries(req.body).flatMap(([key, value]) => 250 Array.isArray(value) 251 ? value.map((item) => [key, item]) 252 : [[key, value]], 253 ), 254 ).toString() 255 : JSON.stringify(req.body); 256 } 257 258 return { 259 httpMethod: req.method, 260 headers: req.headers, 261 body, 262 rawBody: req.rawBody ?? req.body, 263 queryStringParameters: req.query || {}, 264 path: req.path, 265 rawUrl: `${req.protocol}://${req.get("host")}${req.originalUrl}`, 266 isBase64Encoded: false, 267 }; 268} 269 270// --- Function handler --- 271async function handleFunction(req, res) { 272 const name = req.params.fn; 273 const handler = functions[name]; 274 if (!handler) { 275 recordCall(name || "unknown", 0, 404, req.path, req.method, "Function not found"); 276 return res.status(404).send("Function not found: " + name); 277 } 278 279 // Netlify-style background functions: filename ends in `-background`. The 280 // Netlify runtime responds 202 immediately and keeps the handler running 281 // asynchronously. lith must do the same or callers that `await` the 282 // invocation (e.g. keep-prepare → keep-prepare-background) hang until the 283 // full pipeline completes, blocking the client request. 284 if (name.endsWith("-background")) { 285 const event = toEvent(req); 286 const context = { clientContext: {} }; 287 const t0 = Date.now(); 288 res.status(202).send(""); 289 handler(event, context) 290 .then((result) => { 291 recordCall(name, Date.now() - t0, result?.statusCode || 202, req.path, req.method, null); 292 }) 293 .catch((err) => { 294 recordCall(name, Date.now() - t0, 500, req.path, req.method, err?.message); 295 console.error(`fn/${name} background error:`, err); 296 }); 297 return; 298 } 299 300 // Check response cache (GET only, with matching query string) 301 const ttl = CACHE_TTLS[name]; 302 if (ttl && req.method === "GET") { 303 const cacheKey = `${name}:${req.originalUrl}`; 304 const cached = responseCache.get(cacheKey); 305 if (cached && cached.expires > Date.now()) { 306 recordCall(name, 0, cached.statusCode, req.path, req.method, null); 307 if (cached.headers) res.set(cached.headers); 308 res.set("X-Lith-Cache", "HIT"); 309 return res.status(cached.statusCode).send(cached.body); 310 } 311 } 312 313 const t0 = Date.now(); 314 try { 315 const event = toEvent(req); 316 const context = { clientContext: {} }; 317 const result = await handler(event, context); 318 319 const statusCode = result.statusCode || 200; 320 const ms = Date.now() - t0; 321 recordCall(name, ms, statusCode, req.path, req.method, statusCode >= 500 ? result.body : null); 322 323 if (result.headers) res.set(result.headers); 324 if (result.multiValueHeaders) { 325 for (const [k, vals] of Object.entries(result.multiValueHeaders)) { 326 for (const v of vals) res.append(k, v); 327 } 328 } 329 330 // Handle ReadableStream bodies (from streaming functions like ask, keep-mint) 331 if (result.body && typeof result.body === "object" && typeof result.body.getReader === "function") { 332 res.status(statusCode); 333 const reader = result.body.getReader(); 334 const pump = async () => { 335 while (true) { 336 const { done, value } = await reader.read(); 337 if (done) { res.end(); return; } 338 res.write(value); 339 } 340 }; 341 return pump().catch((err) => { 342 console.error(`fn/${name} stream error:`, err); 343 res.end(); 344 }); 345 } 346 347 // Store in cache if cacheable 348 if (ttl && req.method === "GET" && statusCode < 400) { 349 const cacheKey = `${name}:${req.originalUrl}`; 350 responseCache.set(cacheKey, { 351 body: result.isBase64Encoded ? Buffer.from(result.body, "base64") : result.body, 352 headers: result.headers, 353 statusCode, 354 expires: Date.now() + ttl, 355 }); 356 } 357 358 if (result.isBase64Encoded) { 359 res.status(statusCode).send(Buffer.from(result.body, "base64")); 360 } else { 361 res.status(statusCode).send(result.body); 362 } 363 } catch (err) { 364 const ms = Date.now() - t0; 365 recordCall(name, ms, 500, req.path, req.method, err.message); 366 console.error(`fn/${name} error:`, err); 367 res.status(500).send("Internal Server Error"); 368 } 369} 370 371// Resolve function name from URL params 372function resolveFunction(req) { 373 return resolveFunctionName(req.params.fn, req.params.rest, functions); 374} 375 376// --- Function handler (updated to use resolveFunction) --- 377async function handleFunctionResolved(req, res) { 378 req.params.fn = resolveFunction(req); 379 return handleFunction(req, res); 380} 381 382// --- Deploy webhook (POST /lith/deploy?secret=...) --- 383import { execFile } from "child_process"; 384import { createHmac, timingSafeEqual } from "crypto"; 385const DEPLOY_SECRET = process.env.DEPLOY_SECRET || ""; 386const DEPLOY_BRANCHES = (process.env.DEPLOY_BRANCHES || process.env.DEPLOY_BRANCH || "main,master") 387 .split(",") 388 .map((branch) => branch.trim()) 389 .filter(Boolean); 390const DEFAULT_DEPLOY_BRANCH = DEPLOY_BRANCHES[0] || "main"; 391let deployInProgress = false; 392let queuedDeployBranch = null; 393 394function normalizeDeployBranch(branch) { 395 if (typeof branch !== "string") return null; 396 const trimmed = branch.trim(); 397 if (!trimmed) return null; 398 if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) return null; 399 return trimmed; 400} 401 402function branchFromRef(ref) { 403 if (typeof ref !== "string") return null; 404 const prefix = "refs/heads/"; 405 if (!ref.startsWith(prefix)) return null; 406 return normalizeDeployBranch(ref.slice(prefix.length)); 407} 408 409function requestedDeployBranch(req) { 410 const fromRef = branchFromRef(req.body?.ref); 411 if (fromRef) return fromRef; 412 return ( 413 normalizeDeployBranch(req.query.branch) || 414 normalizeDeployBranch(req.headers["x-deploy-branch"]) || 415 DEFAULT_DEPLOY_BRANCH 416 ); 417} 418 419function verifyDeploy(req) { 420 // GitHub HMAC signature (webhook secret) 421 const sig = req.headers["x-hub-signature-256"]; 422 if (sig && DEPLOY_SECRET) { 423 const rawBody = Buffer.isBuffer(req.rawBody) 424 ? req.rawBody 425 : Buffer.from( 426 typeof req.body === "string" ? req.body : JSON.stringify(req.body ?? {}), 427 "utf8", 428 ); 429 const hmac = createHmac("sha256", DEPLOY_SECRET) 430 .update(rawBody) 431 .digest("hex"); 432 const expected = `sha256=${hmac}`; 433 if (sig.length === expected.length && 434 timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) { 435 return true; 436 } 437 } 438 // Fallback: query param or header (manual triggers) 439 const plain = req.query.secret || req.headers["x-deploy-secret"]; 440 return plain === DEPLOY_SECRET; 441} 442 443function runDeploy(branch) { 444 deployInProgress = true; 445 console.log(`[deploy] starting branch=${branch}`); 446 447 execFile( 448 "/opt/ac/lith/webhook.sh", 449 { 450 timeout: 120000, 451 env: { ...process.env, DEPLOY_BRANCH: branch }, 452 }, 453 (err, stdout, stderr) => { 454 deployInProgress = false; 455 456 if (stdout?.trim()) { 457 console.log(`[deploy][${branch}] ${stdout.trim()}`); 458 } 459 if (stderr?.trim()) { 460 console.error(`[deploy][${branch}] ${stderr.trim()}`); 461 } 462 if (err) { 463 console.error(`[deploy] failed for ${branch}:`, err.message); 464 } 465 466 if (queuedDeployBranch) { 467 const nextBranch = queuedDeployBranch; 468 queuedDeployBranch = null; 469 setImmediate(() => runDeploy(nextBranch)); 470 } 471 }, 472 ); 473} 474 475app.post("/lith/deploy", (req, res) => { 476 if (!DEPLOY_SECRET || !verifyDeploy(req)) { 477 return res.status(401).send("Unauthorized"); 478 } 479 480 const githubEvent = req.headers["x-github-event"]; 481 if (githubEvent === "ping") { 482 return res.send("pong"); 483 } 484 if (githubEvent && githubEvent !== "push") { 485 return res.send(`Ignored GitHub event: ${githubEvent}`); 486 } 487 488 const ref = req.body?.ref; 489 const branch = requestedDeployBranch(req); 490 if (!DEPLOY_BRANCHES.includes(branch)) { 491 const detail = ref || branch; 492 return res.send(`Ignored non-deploy branch: ${detail}`); 493 } 494 495 if (deployInProgress) { 496 queuedDeployBranch = branch; 497 return res.status(202).send(`Deploy queued for ${branch}`); 498 } 499 500 runDeploy(branch); 501 res.status(202).send(`Deploy started for ${branch}`); 502}); 503 504// --- Routes --- 505 506app.get(["/lith", "/lith/"], (_req, res) => { 507 res.redirect(302, "/lith/stats"); 508}); 509 510// --- Lith stats API (consumed by silo dashboard) --- 511app.get("/lith/stats", (req, res) => { 512 const uptime = Math.floor((Date.now() - BOOT_TIME) / 1000); 513 const mem = process.memoryUsage(); 514 const sorted = Object.entries(fnStats) 515 .map(([name, s]) => ({ name, ...s, avgMs: s.calls ? Math.round(s.totalMs / s.calls) : 0 })) 516 .sort((a, b) => b.calls - a.calls); 517 518 res.json({ 519 uptime, 520 boot: new Date(BOOT_TIME).toISOString(), 521 functionsLoaded: Object.keys(functions).length, 522 memory: { rss: Math.round(mem.rss / 1048576), heap: Math.round(mem.heapUsed / 1048576) }, 523 totals: { 524 calls: sorted.reduce((s, f) => s + f.calls, 0), 525 errors: sorted.reduce((s, f) => s + f.errors, 0), 526 }, 527 functions: sorted, 528 }); 529}); 530 531app.get("/lith/errors", (req, res) => { 532 const limit = Math.min(parseInt(req.query.limit) || 100, MAX_ERROR_LOG); 533 res.json({ errors: errorLog.slice(0, limit), total: errorLog.length }); 534}); 535 536app.get("/lith/requests", (req, res) => { 537 const limit = Math.min(parseInt(req.query.limit) || 100, MAX_REQUEST_LOG); 538 const fn = req.query.fn; 539 const filtered = fn ? requestLog.filter((r) => r.fn === fn) : requestLog; 540 res.json({ requests: filtered.slice(0, limit), total: filtered.length }); 541}); 542 543// --- Caddy access log summary (for silo dashboard) --- 544app.get("/lith/traffic", async (req, res) => { 545 try { 546 const logPath = "/var/log/caddy/access.log"; 547 const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean); 548 const recent = lines.slice(-500); // last 500 entries 549 const byPath = {}, byHost = {}, byStatus = {}; 550 let total = 0; 551 552 for (const line of recent) { 553 try { 554 const d = JSON.parse(line); 555 const r = d.request || {}; 556 const uri = (r.uri || "/").split("?")[0]; 557 const host = r.host || "unknown"; 558 const status = String(d.status || 0); 559 // Aggregate by first path segment 560 const seg = "/" + (uri.split("/")[1] || ""); 561 byPath[seg] = (byPath[seg] || 0) + 1; 562 byHost[host] = (byHost[host] || 0) + 1; 563 byStatus[status] = (byStatus[status] || 0) + 1; 564 total++; 565 } catch {} 566 } 567 568 const sortDesc = (obj) => Object.entries(obj).sort((a, b) => b[1] - a[1]); 569 res.json({ 570 total, 571 logLines: lines.length, 572 byPath: sortDesc(byPath).slice(0, 30), 573 byHost: sortDesc(byHost).slice(0, 20), 574 byStatus: sortDesc(byStatus), 575 }); 576 } catch (err) { 577 res.json({ total: 0, error: err.message }); 578 } 579}); 580 581// --- Farcaster Frame endpoint for KidLisp pieces --- 582app.get("/frame/:piece", async (req, res) => { 583 const piece = req.params.piece.startsWith("$") ? req.params.piece : `$${req.params.piece}`; 584 const code = piece.slice(1); 585 const base = "https://aesthetic.computer"; 586 const pieceUrl = `${base}/${piece}`; 587 const keepUrl = `https://keep.kidlisp.com/${code}`; 588 589 // Try to get thumbnail from oven cache 590 const thumbUrl = `https://oven.aesthetic.computer/grab/webp/600/400/${piece}`; 591 // Fallback OG image 592 const ogImage = `https://oven.aesthetic.computer/kidlisp-og.png`; 593 594 const frameEmbed = JSON.stringify({ 595 version: "1", 596 imageUrl: thumbUrl, 597 button: { 598 title: `View ${piece}`, 599 action: { 600 type: "launch_frame", 601 url: pieceUrl, 602 name: `KidLisp ${piece}`, 603 splashImageUrl: "https://assets.aesthetic.computer/kidlisp-favicon.gif", 604 splashBackgroundColor: "#000000", 605 }, 606 }, 607 }); 608 609 res.setHeader("Content-Type", "text/html; charset=utf-8"); 610 res.send(`<!DOCTYPE html> 611<html> 612<head> 613 <meta charset="utf-8"> 614 <meta property="og:title" content="${piece} — KidLisp" /> 615 <meta property="og:description" content="A KidLisp piece on Aesthetic Computer" /> 616 <meta property="og:image" content="${thumbUrl}" /> 617 <meta property="og:url" content="${pieceUrl}" /> 618 <meta property="fc:frame" content='${frameEmbed.replace(/'/g, "&#39;")}' /> 619 <meta name="fc:frame" content='${frameEmbed.replace(/'/g, "&#39;")}' /> 620 <title>${piece} — KidLisp</title> 621</head> 622<body> 623 <h1>${piece}</h1> 624 <p><a href="${pieceUrl}">View on Aesthetic Computer</a></p> 625 <p><a href="${keepUrl}">Keep on KidLisp</a></p> 626</body> 627</html>`); 628}); 629 630// --- /api/os-release-upload (ports Netlify edge function os-release-upload.js) --- 631app.post("/api/os-release-upload", async (req, res) => { 632 const { createHmac } = await import("crypto"); 633 634 // Auth: verify AC token 635 const authHeader = req.headers["authorization"] || ""; 636 const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; 637 if (!token) return res.status(401).json({ error: "Missing Authorization: Bearer <ac_token>" }); 638 639 let user; 640 try { 641 const uiRes = await fetch("https://hi.aesthetic.computer/userinfo", { 642 headers: { Authorization: `Bearer ${token}` }, 643 }); 644 if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`); 645 user = await uiRes.json(); 646 } catch (err) { 647 return res.status(401).json({ error: `Auth failed: ${err.message}` }); 648 } 649 650 const userSub = user.sub || "unknown"; 651 const userName = user.name || user.nickname || userSub; 652 653 const accessKey = process.env.DO_SPACES_KEY || process.env.ART_KEY; 654 const secretKey = process.env.DO_SPACES_SECRET || process.env.ART_SECRET; 655 if (!accessKey || !secretKey) return res.status(503).json({ error: "Spaces creds not configured" }); 656 657 const bucket = "releases-aesthetic-computer"; 658 const host = `${bucket}.sfo3.digitaloceanspaces.com`; 659 660 const buildName = req.headers["x-build-name"] || `upload-${Date.now()}`; 661 const gitHash = req.headers["x-git-hash"] || "unknown"; 662 const buildTs = req.headers["x-build-ts"] || new Date().toISOString().slice(0, 16); 663 const commitMsg = req.headers["x-commit-msg"] || ""; 664 const version = `${buildName} ${gitHash}-${buildTs}`; 665 666 function presignUrl(key, contentType, expiresSec = 900) { 667 const expires = Math.floor(Date.now() / 1000) + expiresSec; 668 const stringToSign = `PUT\n\n${contentType}\n${expires}\nx-amz-acl:public-read\n/${bucket}/${key}`; 669 const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); 670 return `https://${host}/${key}?AWSAccessKeyId=${encodeURIComponent(accessKey)}&Expires=${expires}&Signature=${encodeURIComponent(sig)}&x-amz-acl=public-read`; 671 } 672 673 async function s3Put(key, body, contentType) { 674 const dateStr = new Date().toUTCString(); 675 const stringToSign = `PUT\n\n${contentType}\n${dateStr}\nx-amz-acl:public-read\n/${bucket}/${key}`; 676 const sig = createHmac("sha1", secretKey).update(stringToSign).digest("base64"); 677 const putRes = await fetch(`https://${host}/${key}`, { 678 method: "PUT", 679 headers: { Date: dateStr, "Content-Type": contentType, "x-amz-acl": "public-read", Authorization: `AWS ${accessKey}:${sig}` }, 680 body: typeof body === "string" ? body : body, 681 }); 682 if (!putRes.ok) { 683 const text = await putRes.text(); 684 throw new Error(`S3 PUT ${key}: ${putRes.status} ${text.slice(0, 200)}`); 685 } 686 } 687 688 async function loadMachineTokenSecret() { 689 try { 690 const connStr = process.env.MONGODB_CONNECTION_STRING; 691 if (!connStr) return null; 692 const { MongoClient } = await import("mongodb"); 693 const client = new MongoClient(connStr); 694 await client.connect(); 695 const dbName = process.env.MONGODB_NAME || "aesthetic"; 696 const doc = await client.db(dbName).collection("secrets").findOne({ _id: "machine-token" }); 697 await client.close(); 698 return doc?.secret || null; 699 } catch (e) { 700 console.error("[os-release-upload] Failed to load machine-token secret:", e.message); 701 return null; 702 } 703 } 704 705 async function generateDeviceToken(sub, handle) { 706 const secret = await loadMachineTokenSecret(); 707 if (!secret) return null; 708 const payload = { sub, handle, iat: Math.floor(Date.now() / 1000) }; 709 const payloadB64 = Buffer.from(JSON.stringify(payload)).toString("base64url"); 710 const sigB64 = createHmac("sha256", secret).update(payloadB64).digest("base64url"); 711 return `${payloadB64}.${sigB64}`; 712 } 713 714 const isFinalize = req.headers["x-finalize"] === "true"; 715 716 if (isFinalize) { 717 const sha256 = req.headers["x-sha256"] || "unknown"; 718 const size = parseInt(req.headers["x-size"] || "0", 10); 719 try { 720 const versionWithSize = `${version}\n${size}`; 721 await Promise.all([ 722 s3Put("os/native-notepat-latest.version", versionWithSize, "text/plain"), 723 s3Put("os/native-notepat-latest.sha256", sha256, "text/plain"), 724 ]); 725 let releases = { releases: [] }; 726 try { 727 const existing = await fetch(`https://${host}/os/releases.json`); 728 if (existing.ok) releases = await existing.json(); 729 } catch { /* first release */ } 730 const userHandle = req.headers["x-handle"] || user.nickname || user.name || userName; 731 releases.releases = releases.releases || []; 732 for (const r of releases.releases) r.deprecated = true; 733 releases.releases.unshift({ 734 version, name: buildName, sha256, size, git_hash: gitHash, build_ts: buildTs, 735 commit_msg: commitMsg, user: userSub, handle: userHandle, 736 url: `https://${host}/os/native-notepat-latest.vmlinuz`, 737 archive_url: `https://${host}/os/builds/${buildName}.vmlinuz`, 738 }); 739 releases.releases = releases.releases.slice(0, 50); 740 releases.latest = version; 741 releases.latest_name = buildName; 742 const deviceToken = await generateDeviceToken(userSub, userHandle); 743 if (deviceToken) releases.device_token = deviceToken; 744 await s3Put("os/releases.json", JSON.stringify(releases, null, 2), "application/json"); 745 return res.json({ ok: true, name: buildName, version, sha256, size, url: `https://${host}/os/native-notepat-latest.vmlinuz`, user: userSub, userName, deviceToken: !!deviceToken }); 746 } catch (err) { 747 return res.status(500).json({ error: `Finalize failed: ${err.message}` }); 748 } 749 } 750 751 if (req.headers["x-versioned-upload"] === "true") { 752 try { 753 const versionedKey = req.headers["x-versioned-key"] || `os/builds/${buildName}.vmlinuz`; 754 return res.json({ step: "versioned-upload", versioned_put_url: presignUrl(versionedKey, "application/octet-stream", 1800), key: versionedKey, user: userSub }); 755 } catch (err) { 756 return res.status(500).json({ error: `Versioned presign failed: ${err.message}` }); 757 } 758 } 759 760 if (req.headers["x-manifest-upload"] === "true") { 761 try { 762 return res.json({ step: "manifest-upload", manifest_put_url: presignUrl("os/latest-manifest.json", "application/json"), user: userSub }); 763 } catch (err) { 764 return res.status(500).json({ error: `Manifest presign failed: ${err.message}` }); 765 } 766 } 767 768 if (req.headers["x-template-upload"] === "true") { 769 try { 770 return res.json({ step: "template-upload", image_put_url: presignUrl("os/native-notepat-latest.img", "application/octet-stream"), user: userSub }); 771 } catch (err) { 772 return res.status(500).json({ error: `Template presign failed: ${err.message}` }); 773 } 774 } 775 776 // Step 1: Return presigned URL for vmlinuz upload 777 try { 778 return res.json({ step: "upload", vmlinuz_put_url: presignUrl("os/native-notepat-latest.vmlinuz", "application/octet-stream"), version, user: userSub, userName }); 779 } catch (err) { 780 return res.status(500).json({ error: `Presign failed: ${err.message}` }); 781 } 782}); 783 784// --- /api/os-image (ports Netlify edge function os-image.js) --- 785app.get("/api/os-image", async (req, res) => { 786 const authHeader = req.headers["authorization"] || ""; 787 if (!authHeader) return res.status(401).json({ error: "Authorization required. Log in at aesthetic.computer first." }); 788 789 try { 790 const search = new URLSearchParams(req.query || {}).toString(); 791 const ovenUrl = "https://oven.aesthetic.computer/os-image" + (search ? `?${search}` : ""); 792 const ovenRes = await fetch(ovenUrl, { 793 headers: { Authorization: authHeader }, 794 }); 795 res.status(ovenRes.status); 796 res.set("Content-Type", ovenRes.headers.get("content-type") || "application/octet-stream"); 797 if (ovenRes.headers.get("content-disposition")) res.set("Content-Disposition", ovenRes.headers.get("content-disposition")); 798 if (ovenRes.headers.get("content-length")) res.set("Content-Length", ovenRes.headers.get("content-length")); 799 if (ovenRes.headers.get("x-ac-os-requested-layout")) res.set("X-AC-OS-Requested-Layout", ovenRes.headers.get("x-ac-os-requested-layout")); 800 if (ovenRes.headers.get("x-ac-os-layout")) res.set("X-AC-OS-Layout", ovenRes.headers.get("x-ac-os-layout")); 801 if (ovenRes.headers.get("x-ac-os-fallback")) res.set("X-AC-OS-Fallback", ovenRes.headers.get("x-ac-os-fallback")); 802 if (ovenRes.headers.get("x-ac-os-fallback-reason")) res.set("X-AC-OS-Fallback-Reason", ovenRes.headers.get("x-ac-os-fallback-reason")); 803 if (ovenRes.headers.get("x-build")) res.set("X-Build", ovenRes.headers.get("x-build")); 804 if (ovenRes.headers.get("x-patch")) res.set("X-Patch", ovenRes.headers.get("x-patch")); 805 res.set("Access-Control-Allow-Origin", "*"); 806 const { Readable } = await import("stream"); 807 Readable.fromWeb(ovenRes.body).pipe(res); 808 } catch (err) { 809 return res.status(502).json({ error: `Oven unavailable: ${err.message}` }); 810 } 811}); 812 813// --- /api/pack-html, /api/bundle-html, /api/os (proxy to oven) --- 814// netlify.toml used status=200 rewrites for these; on lith we must proxy 815// explicitly since no `pack-html` / `os` netlify function exists. The 816// ableton.mjs offline-amxd builder + `pack` / `m4d` prompt commands all 817// depend on pack-html reaching oven's /pack-html?format=m4d endpoint. 818async function proxyToOven(ovenPath, req, res) { 819 try { 820 const search = new URLSearchParams(req.query || {}).toString(); 821 const ovenUrl = `https://oven.aesthetic.computer${ovenPath}` + (search ? `?${search}` : ""); 822 const fwdHeaders = {}; 823 if (req.headers.authorization) fwdHeaders.Authorization = req.headers.authorization; 824 if (req.headers.accept) fwdHeaders.Accept = req.headers.accept; 825 const ovenRes = await fetch(ovenUrl, { method: "GET", headers: fwdHeaders }); 826 res.status(ovenRes.status); 827 const forward = [ 828 "content-type", "content-disposition", "content-length", "cache-control", 829 "etag", "last-modified", 830 "x-ac-os-requested-layout", "x-ac-os-layout", 831 "x-ac-os-fallback", "x-ac-os-fallback-reason", 832 "x-build", "x-patch", 833 ]; 834 for (const h of forward) { 835 const v = ovenRes.headers.get(h); 836 if (v) res.set(h, v); 837 } 838 res.set("Access-Control-Allow-Origin", "*"); 839 if (!ovenRes.body) return res.end(); 840 const { Readable } = await import("stream"); 841 Readable.fromWeb(ovenRes.body).pipe(res); 842 } catch (err) { 843 console.error(`proxyToOven ${ovenPath} error:`, err.message); 844 if (!res.headersSent) res.status(502).json({ error: `Oven unavailable: ${err.message}` }); 845 } 846} 847app.get("/api/pack-html", (req, res) => proxyToOven("/pack-html", req, res)); 848app.get("/api/bundle-html", (req, res) => proxyToOven("/bundle-html", req, res)); 849app.get("/api/os", (req, res) => proxyToOven("/os", req, res)); 850 851// --- /media/* handler (ports Netlify edge function media.js) --- 852app.all("/media/*rest", async (req, res) => { 853 const parts = req.path.split("/").filter(Boolean); // ["media", ...] 854 parts.shift(); // remove "media" 855 const resourcePath = parts.join("/"); 856 857 if (!resourcePath) return res.status(404).send("Missing media path"); 858 859 // Content type from extension 860 const ext = resourcePath.split(".").pop()?.toLowerCase(); 861 const ctMap = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", zip: "application/zip", mp4: "video/mp4", json: "application/json", mjs: "text/javascript", svg: "image/svg+xml" }; 862 863 // Helper: build a clean event for calling functions internally 864 function mediaEvent(path, query) { 865 return { 866 httpMethod: "GET", 867 headers: req.headers, 868 body: null, 869 queryStringParameters: query, 870 path, 871 rawUrl: `${req.protocol}://${req.get("host")}${path}`, 872 isBase64Encoded: false, 873 }; 874 } 875 876 // /media/tapes/CODE → get-tape function → redirect to DO Spaces 877 if (parts[0] === "tapes" && parts[1]) { 878 const code = parts[1].replace(/\.zip$/, ""); 879 try { 880 const result = await functions["get-tape"](mediaEvent("/api/get-tape", { code }), {}); 881 if (result.statusCode === 200) { 882 const tape = JSON.parse(result.body); 883 const bucket = tape.bucket || "art-aesthetic-computer"; 884 const key = tape.user ? `${tape.user}/${tape.slug}.zip` : `${tape.slug}.zip`; 885 return res.redirect(302, `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`); 886 } 887 } catch {} 888 return res.status(404).send("Tape not found"); 889 } 890 891 // /media/paintings/CODE → get-painting function → redirect 892 if (parts[0] === "paintings" && parts[1]) { 893 const code = parts[1].replace(/\.(png|zip)$/, ""); 894 try { 895 const result = await functions["get-painting"]?.(mediaEvent("/api/get-painting", { code }), {}); 896 if (result?.statusCode === 200) { 897 const painting = JSON.parse(result.body); 898 const bucket = painting.user ? "user-aesthetic-computer" : "art-aesthetic-computer"; 899 const slug = painting.slug?.split(":")[0] || painting.slug; 900 // Slug may already be fully-qualified (e.g. "auth0|.../painting/TS"). 901 // Only prepend user when it's a bare slug, otherwise we double the prefix. 902 const key = painting.user && !slug.startsWith(`${painting.user}/`) 903 ? `${painting.user}/${slug}.png` 904 : `${slug}.png`; 905 return res.redirect(302, `https://${bucket}.sfo3.digitaloceanspaces.com/${key}`); 906 } 907 } catch {} 908 return res.status(404).send("Painting not found"); 909 } 910 911 // /media/@handle/type/slug → resolve user ID → redirect to DO Spaces 912 if (parts[0]?.startsWith("@") || parts[0]?.match(/^ac[a-z0-9]+$/i)) { 913 const userIdentifier = parts[0]; 914 const subPath = parts.slice(1).join("/"); 915 916 // Resolve user ID via user function directly 917 try { 918 const query = userIdentifier.match(/^ac[a-z0-9]+$/i) 919 ? { code: userIdentifier } 920 : { from: userIdentifier }; 921 const event = { 922 httpMethod: "GET", 923 headers: req.headers, 924 body: null, 925 queryStringParameters: query, 926 path: "/user", 927 rawUrl: `${req.protocol}://${req.get("host")}/user`, 928 isBase64Encoded: false, 929 }; 930 const result = await functions["user"](event, {}); 931 if (result.statusCode === 200) { 932 const user = JSON.parse(result.body); 933 const userId = user.sub; 934 if (userId) { 935 const fullPath = `${userId}/${subPath}`; 936 const baseUrl = ext === "mjs" 937 ? "https://user-aesthetic-computer.sfo3.digitaloceanspaces.com" 938 : "https://user.aesthetic.computer"; 939 const encoded = fullPath.split("/").map(encodeURIComponent).join("/"); 940 return res.redirect(302, `${baseUrl}/${encoded}`); 941 } 942 } 943 } catch (err) { 944 console.error("media user resolve error:", err.message); 945 } 946 return res.status(404).send("User media not found"); 947 } 948 949 // Direct file path → proxy to DO Spaces 950 const baseUrl = ext === "mjs" 951 ? "https://user-aesthetic-computer.sfo3.digitaloceanspaces.com" 952 : "https://user.aesthetic.computer"; 953 const encoded = resourcePath.split("/").map(encodeURIComponent).join("/"); 954 return res.redirect(302, `${baseUrl}/${encoded}`); 955}); 956 957// API functions (matches Netlify redirect rules) 958// Bare `/api` serves the public API reference (same function that backs api.aesthetic.computer). 959app.all("/api", directFn("api-docs")); 960app.all("/api/:fn", handleFunctionResolved); 961app.all("/api/:fn/*rest", handleFunctionResolved); 962app.all("/.netlify/functions/:fn", handleFunction); 963 964// Non-/api/ function routes (from netlify.toml) 965function directFn(fnName) { 966 return (req, res) => { req.params = { fn: fnName }; return handleFunction(req, res); }; 967} 968app.all("/handle", directFn("handle")); 969app.all("/user", directFn("user")); 970app.all("/run", directFn("run")); 971app.all("/reload/*rest", directFn("reload")); 972app.all("/session/*rest", directFn("session")); 973app.all("/authorized", directFn("authorized")); 974app.all("/handles", directFn("handles")); 975app.all("/redirect-proxy", directFn("redirect-proxy")); 976app.all("/redirect-proxy-sotce", directFn("redirect-proxy")); 977// Local dev upload fallback (used when S3 credentials are missing). 978app.all("/local-upload/:filename", (req, res) => { 979 if (req.method === "OPTIONS") return res.sendStatus(204); 980 const body = req.rawBody || req.body; 981 if (!body || body.length === 0) { 982 console.error("❌ Local upload: empty body for", req.params.filename); 983 return res.status(400).send("Empty body"); 984 } 985 const dir = join(dirname(fileURLToPath(import.meta.url)), "..", "local-uploads"); 986 mkdirSync(dir, { recursive: true }); 987 const filepath = join(dir, req.params.filename); 988 writeFileSync(filepath, body); 989 console.log("📁 Local upload saved:", filepath, `(${body.length} bytes)`); 990 res.status(200).send("OK"); 991}); 992app.use("/local-uploads", express.static(join(dirname(fileURLToPath(import.meta.url)), "..", "local-uploads"))); 993app.all("/presigned-upload-url/*rest", directFn("presigned-url")); 994app.all("/presigned-download-url/*rest", directFn("presigned-url")); 995app.all("/docs", directFn("docs")); 996app.all("/docs.json", directFn("docs")); 997app.all("/docs/*rest", directFn("docs")); 998app.all("/api-docs", directFn("api-docs")); 999app.all("/api-docs.json", directFn("api-docs")); 1000app.all("/media-collection", directFn("media-collection")); 1001app.all("/media-collection/*rest", directFn("media-collection")); 1002app.all("/device-login", directFn("device-login")); 1003app.all("/device-auth", directFn("device-auth")); 1004app.all("/mcp", directFn("mcp-remote")); 1005app.all("/m4l-plugins", directFn("m4l-plugins")); 1006app.all("/slash", directFn("slash")); 1007app.all("/sotce-blog/*rest", directFn("sotce-blog")); 1008app.all("/profile/*rest", directFn("profile")); 1009 1010// Menu Band crash-log intake → MongoDB collection "menuband-logs". Body is 1011// the raw .ips text; metadata comes from headers. The text-body parser 1012// runs only for this route so other routes' JSON parsing stays untouched. 1013app.post("/menuband-logs", 1014 express.text({ type: "*/*", limit: "5mb" }), 1015 directFn("menuband-logs")); 1016 1017// Static files 1018app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" })); 1019 1020// --- keeps-social: SSR meta tags for social crawlers on keep/buy.kidlisp.com --- 1021const CRAWLER_RE = /twitterbot|facebookexternalhit|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|applebot/i; 1022const OBJKT_GRAPHQL = "https://data.objkt.com/v3/graphql"; 1023 1024async function keepsSocialMiddleware(req, res, next) { 1025 const host = (req.headers.host || "").split(":")[0].toLowerCase(); 1026 const isBuy = host.includes("buy.kidlisp.com"); 1027 const isKeep = host.includes("keep.kidlisp.com"); 1028 if (!isBuy && !isKeep) return next(); 1029 1030 const seg = req.path.replace(/^\/+/, "").split("/")[0]; 1031 if (!seg.startsWith("$") || seg.length < 2) return next(); 1032 1033 const ua = req.headers["user-agent"] || ""; 1034 if (!CRAWLER_RE.test(ua)) return next(); 1035 1036 const code = seg.slice(1); 1037 try { 1038 const [tokenData, ogImage] = await Promise.all([ 1039 fetchKeepsTokenData(code), 1040 resolveKeepsImageUrl(`https://oven.aesthetic.computer/preview/1200x630/$${code}.png`), 1041 ]); 1042 1043 // Get the HTML from the index function 1044 if (!functions["index"]) return next(); 1045 const event = toEvent(req); 1046 const result = await functions["index"](event, { clientContext: {} }); 1047 let html = result.body || ""; 1048 1049 const title = `$${code}`; 1050 const subdomain = isBuy ? "buy" : "keep"; 1051 const description = buildKeepsDescription(tokenData, isBuy); 1052 const permalink = `https://${subdomain}.kidlisp.com/$${code}`; 1053 1054 html = html.replace(/<meta property="og:url"[^>]*\/>/, `<meta property="og:url" content="${permalink}" />`); 1055 html = html.replace(/<meta property="og:title"[^>]*\/>/, `<meta property="og:title" content="${escapeAttr(title)}" />`); 1056 html = html.replace(/<meta property="og:description"[^>]*\/>/, `<meta property="og:description" content="${escapeAttr(description)}" />`); 1057 html = html.replace(/<meta property="og:image" content="[^"]*"[^>]*\/>/, `<meta property="og:image" content="${ogImage}" />`); 1058 html = html.replace(/<meta name="twitter:title"[^>]*\/>/, `<meta name="twitter:title" content="${escapeAttr(title)}" />`); 1059 html = html.replace(/<meta name="twitter:description"[^>]*\/>/, `<meta name="twitter:description" content="${escapeAttr(description)}" />`); 1060 html = html.replace(/<meta name="twitter:image" content="[^"]*"[^>]*\/>/, `<meta name="twitter:image" content="${ogImage}" />`); 1061 1062 res.set("Content-Type", "text/html; charset=utf-8"); 1063 res.set("Cache-Control", "public, max-age=3600"); 1064 return res.status(200).send(html); 1065 } catch (err) { 1066 console.error("[keeps-social] error:", err); 1067 return next(); 1068 } 1069} 1070 1071async function fetchKeepsTokenData(code) { 1072 const contract = "KT1Q1irsjSZ7EfUN4qHzAB2t7xLBPsAWYwBB"; 1073 const query = `query { token(where: { fa_contract: { _eq: "${contract}" } name: { _eq: "$${code}" } }) { token_id name thumbnail_uri } listing_active(where: { fa_contract: { _eq: "${contract}" } token: { name: { _eq: "$${code}" } } } order_by: { price_xtz: asc } limit: 1) { price_xtz seller_address } }`; 1074 const r = await fetch(OBJKT_GRAPHQL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query }) }); 1075 if (!r.ok) return null; 1076 const json = await r.json(); 1077 const tokens = json?.data?.token || []; 1078 if (tokens.length === 0) return null; 1079 return { token: tokens[0], listing: (json?.data?.listing_active || [])[0] || null }; 1080} 1081 1082function buildKeepsDescription(tokenData, isBuy) { 1083 if (!tokenData) return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; 1084 const { listing } = tokenData; 1085 if (listing) { 1086 const xtz = (Number(listing.price_xtz) / 1_000_000).toFixed(2); 1087 return isBuy ? `Buy now — ${xtz} XTZ | KidLisp generative art on Tezos` : `For Sale — ${xtz} XTZ | KidLisp generative art on Tezos`; 1088 } 1089 return isBuy ? "Buy KidLisp generative art on Tezos." : "KidLisp generative art preserved on Tezos."; 1090} 1091 1092function escapeAttr(str) { 1093 return str.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); 1094} 1095 1096async function resolveKeepsImageUrl(url) { 1097 try { 1098 const r = await fetch(url, { method: "HEAD", redirect: "follow" }); 1099 if (r.ok && r.url) return r.url; 1100 } catch (e) { 1101 console.error("[keeps-social] image resolve error:", e); 1102 } 1103 return url; 1104} 1105 1106app.use(keepsSocialMiddleware); 1107 1108// SPA fallback → index function 1109app.use(async (req, res) => { 1110 if (functions["index"]) { 1111 req.params = { fn: "index" }; 1112 return handleFunction(req, res); 1113 } 1114 res.status(404).send("Not found"); 1115}); 1116 1117// --- Start server --- 1118let server; 1119if (DEV && HAS_SSL) { 1120 const opts = { 1121 cert: readFileSync(SSL_CERT), 1122 key: readFileSync(SSL_KEY), 1123 }; 1124 server = createHttpsServer(opts, app).listen(PORT, () => { 1125 console.log(`lith listening on https://localhost:${PORT}`); 1126 }); 1127} else { 1128 server = createHttpServer(app).listen(PORT, () => { 1129 console.log(`lith listening on http://localhost:${PORT}`); 1130 }); 1131} 1132 1133// --- Graceful shutdown --- 1134// On SIGTERM (sent by systemctl restart), stop accepting new connections 1135// and wait for in-flight requests to finish before exiting. 1136const DRAIN_TIMEOUT = 10_000; // 10s max wait 1137 1138function gracefulShutdown(signal) { 1139 console.log(`[lith] ${signal} received, draining connections...`); 1140 server.close(() => { 1141 console.log("[lith] all connections drained, exiting"); 1142 process.exit(0); 1143 }); 1144 // Force exit if connections don't drain in time 1145 setTimeout(() => { 1146 console.warn("[lith] drain timeout, forcing exit"); 1147 process.exit(1); 1148 }, DRAIN_TIMEOUT).unref(); 1149} 1150 1151process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); 1152process.on("SIGINT", () => gracefulShutdown("SIGINT"));