Monorepo for Aesthetic.Computer
aesthetic.computer
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, "'")}' />
619 <meta name="fc:frame" content='${frameEmbed.replace(/'/g, "'")}' />
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
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"));