#!/usr/bin/env node
// Oven Server
// Main Express server for the unified bake processing service
import 'dotenv/config';
import express from 'express';
import https from 'https';
import http from 'http';
import fs from 'fs';
import { execSync } from 'child_process';
import { gunzipSync, gzipSync } from 'node:zlib';
import { WebSocketServer } from 'ws';
import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs';
import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser, generateNewsOGImage } from './grabber.mjs';
import archiver from 'archiver';
import sharp from 'sharp';
import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs';
import { streamOSImage, getOSBuildStatus, invalidateManifest, purgeOSBuildCache, clearOSBuildLocalCache } from './os-builder.mjs';
import { startOSBaseBuild, getOSBaseBuild, getOSBaseBuildsSummary, cancelOSBaseBuild } from './os-base-build.mjs';
import { startNativeBuild, getNativeBuild, getNativeBuildsSummary, cancelNativeBuild, onNativeBuildProgress } from './native-builder.mjs';
import { startPoller as startNativeGitPoller, getPollerStatus as getNativePollerStatus } from './native-git-poller.mjs';
import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersBuild } from './papers-builder.mjs';
import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { MongoClient } from 'mongodb';
const app = express();
const PORT = process.env.PORT || 3002;
const dev = process.env.NODE_ENV === 'development';
// Track server start time for uptime display
const SERVER_START_TIME = Date.now();
// Get git version at startup (from env var set during deploy, or try git)
let GIT_VERSION = process.env.OVEN_VERSION || 'unknown';
if (GIT_VERSION === 'unknown') {
try {
GIT_VERSION = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
} catch (e) {
// Not a git repo, that's fine
}
}
console.log(`π¦ Oven version: ${GIT_VERSION}`);
console.log(`π Server started at: ${new Date(SERVER_START_TIME).toISOString()}`);
// Activity log buffer for streaming to clients
const activityLogBuffer = [];
const MAX_ACTIVITY_LOG = 100;
let wss = null; // Will be set after server starts
const NATIVE_BUILD_COLLECTION =
process.env.NATIVE_BUILD_COLLECTION || 'oven-native-builds';
const NATIVE_BUILD_STATUS_CACHE_MS = 30000;
let nativeBuildStatusMongoClient = null;
let nativeBuildStatusMongoDb = null;
let nativeBuildStatusCacheAt = 0;
let nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
function addServerLog(type, icon, msg) {
const entry = { time: new Date().toISOString(), type, icon, msg };
activityLogBuffer.unshift(entry);
if (activityLogBuffer.length > MAX_ACTIVITY_LOG) {
activityLogBuffer.pop();
}
// Broadcast to connected clients if wss exists and has clients
if (wss && wss.clients) {
const logMsg = JSON.stringify({ logEntry: entry });
wss.clients.forEach(client => {
if (client.readyState === 1) client.send(logMsg);
});
}
}
function toIsoOrNull(value) {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
async function getNativeBuildStatusMongoDb() {
if (nativeBuildStatusMongoDb) return nativeBuildStatusMongoDb;
const uri = process.env.MONGODB_CONNECTION_STRING;
const dbName = process.env.MONGODB_NAME;
if (!uri || !dbName) return null;
try {
nativeBuildStatusMongoClient = await MongoClient.connect(uri);
nativeBuildStatusMongoDb = nativeBuildStatusMongoClient.db(dbName);
return nativeBuildStatusMongoDb;
} catch (err) {
console.error('[os-releases] MongoDB connect failed:', err.message);
return null;
}
}
async function getNativeBuildStatusData() {
const now = Date.now();
if (now - nativeBuildStatusCacheAt < NATIVE_BUILD_STATUS_CACHE_MS) {
return nativeBuildStatusCache;
}
const db = await getNativeBuildStatusMongoDb();
if (!db) {
nativeBuildStatusCacheAt = now;
nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
return nativeBuildStatusCache;
}
try {
const docs = await db
.collection(NATIVE_BUILD_COLLECTION)
.find({})
.sort({ when: -1 })
.limit(250)
.toArray();
const byName = new Map();
const failedAttempts = [];
for (const doc of docs) {
const name = String(doc.buildName || doc.name || '').trim();
if (!name) continue;
const status = String(doc.status || 'unknown').toLowerCase();
const ref = String(doc.ref || doc.gitHash || '').trim();
const item = {
name,
status,
ref: ref || null,
error: doc.error || null,
commitMsg: doc.commitMsg || null,
buildTs:
toIsoOrNull(doc.finishedAt) ||
toIsoOrNull(doc.startedAt) ||
toIsoOrNull(doc.createdAt) ||
toIsoOrNull(doc.when) ||
null,
};
if (!byName.has(name)) byName.set(name, item);
if ((status === 'failed' || status === 'cancelled') && failedAttempts.length < 8) {
failedAttempts.push(item);
}
}
nativeBuildStatusCacheAt = now;
nativeBuildStatusCache = { byName, failedAttempts };
return nativeBuildStatusCache;
} catch (err) {
console.error('[os-releases] Failed to load native build statuses:', err.message);
nativeBuildStatusCacheAt = now;
nativeBuildStatusCache = { byName: new Map(), failedAttempts: [] };
return nativeBuildStatusCache;
}
}
// Export for use in other modules
export { addServerLog };
// Log server startup
addServerLog('info', 'π₯', 'Oven server starting...');
// OS base-build admin key auth
// Accepts either OS_BUILD_ADMIN_KEY directly in env, or OS_BUILD_ADMIN_KEY_FILE.
let cachedOSBuildAdminKey = null;
let cachedOSBuildAdminMtimeMs = null;
function getConfiguredOSBuildAdminKey() {
const envKey = (process.env.OS_BUILD_ADMIN_KEY || '').trim();
if (envKey) return envKey;
const keyFile = (process.env.OS_BUILD_ADMIN_KEY_FILE || '').trim();
if (!keyFile) return '';
try {
const stat = fs.statSync(keyFile);
if (cachedOSBuildAdminKey && cachedOSBuildAdminMtimeMs === stat.mtimeMs) {
return cachedOSBuildAdminKey;
}
const nextKey = fs.readFileSync(keyFile, 'utf8').trim();
cachedOSBuildAdminKey = nextKey;
cachedOSBuildAdminMtimeMs = stat.mtimeMs;
return nextKey;
} catch {
return '';
}
}
function getOSBuildRequestKey(req) {
const headerKey = (req.get('x-oven-os-key') || '').trim();
if (headerKey) return headerKey;
const auth = (req.get('authorization') || '').trim();
if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
return '';
}
function requireOSBuildAdmin(req, res, next) {
const expectedKey = getConfiguredOSBuildAdminKey();
if (!expectedKey) {
return res.status(503).json({
error: 'OS build admin key not configured. Set OS_BUILD_ADMIN_KEY or OS_BUILD_ADMIN_KEY_FILE.',
});
}
const providedKey = getOSBuildRequestKey(req);
if (!providedKey || providedKey !== expectedKey) {
return res.status(401).json({ error: 'Unauthorized' });
}
return next();
}
// ===== SHARED PROGRESS UI COMPONENTS =====
// Shared CSS for progress indicators across all oven dashboards
const PROGRESS_UI_CSS = `
/* Oven Progress UI - shared across all dashboards */
.oven-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.85);
color: #888;
text-align: center;
padding: 10px;
z-index: 10;
}
.oven-loading .preview-img {
width: 80px;
height: 80px;
image-rendering: pixelated;
border: 1px solid #333;
margin-bottom: 8px;
display: none;
background: #111;
}
.oven-loading .loading-text {
font-size: 12px;
color: #fff;
}
.oven-loading .progress-text {
font-size: 11px;
margin-top: 8px;
color: #88ff88;
font-family: monospace;
max-width: 150px;
word-break: break-word;
}
.oven-loading .progress-bar {
width: 80%;
max-width: 150px;
height: 4px;
background: #333;
border-radius: 2px;
margin: 8px auto 0;
overflow: hidden;
}
.oven-loading .progress-bar-fill {
height: 100%;
background: #88ff88;
width: 0%;
transition: width 0.3s ease;
}
.oven-loading.error {
color: #f44;
}
.oven-loading.success {
color: #4f4;
}
`;
// Shared JavaScript for progress polling and UI updates
const PROGRESS_UI_JS = `
// Shared progress state
let progressPollInterval = null;
// Update any loading indicator with progress data
function updateOvenLoadingUI(container, data, queueInfo) {
if (!container) return;
const loadingText = container.querySelector('.loading-text');
const progressText = container.querySelector('.progress-text');
const progressBar = container.querySelector('.progress-bar-fill');
const previewImg = container.querySelector('.preview-img');
// Check if item is in queue and get position
let queuePosition = null;
if (queueInfo && queueInfo.length > 0 && data.piece) {
const queueItem = queueInfo.find(q => q.piece === data.piece);
if (queueItem) {
queuePosition = queueItem.position;
}
}
// Map stage to friendly text
const stageText = {
'loading': 'π Loading piece...',
'waiting-content': 'β³ Waiting for render...',
'settling': 'βΈοΈ Settling...',
'capturing': 'πΈ Capturing...',
'encoding': 'π Processing...',
'uploading': 'βοΈ Uploading...',
'queued': queuePosition ? 'β³ In queue (#' + queuePosition + ')...' : 'β³ In queue...',
};
if (loadingText && data.stage) {
loadingText.textContent = stageText[data.stage] || data.stage;
}
if (progressText && data.stageDetail) {
progressText.textContent = data.stageDetail;
}
if (progressBar && data.percent != null) {
progressBar.style.width = data.percent + '%';
}
// Show streaming preview
if (previewImg && data.previewFrame) {
previewImg.src = 'data:image/jpeg;base64,' + data.previewFrame;
previewImg.style.display = 'block';
}
}
// Create loading HTML structure
function createOvenLoadingHTML(initialText = 'π₯ Loading...') {
return ' ' +
'' + initialText + ' ' +
'
' +
'';
}
// Start polling /grab-status for progress updates
function startProgressPolling(callback, intervalMs = 150) {
stopProgressPolling();
progressPollInterval = setInterval(async () => {
try {
const res = await fetch('/grab-status');
const data = await res.json();
if (callback && data.progress) {
callback(data);
}
} catch (err) {
// Ignore polling errors
}
}, intervalMs);
}
function stopProgressPolling() {
if (progressPollInterval) {
clearInterval(progressPollInterval);
progressPollInterval = null;
}
}
`;
// Parse JSON bodies
app.use(express.json());
// CORS headers for cross-origin image loading (needed for canvas pixel validation)
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader(
'Access-Control-Expose-Headers',
'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',
);
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
next();
});
// Serve font glyph JSONs locally for Puppeteer captures.
// Font_1 glyph XHR requests from the disk.mjs worker are redirected here
// by the request interceptor to avoid Puppeteer's broken concurrent XHR handling.
const __serverDirname = dirname(fileURLToPath(import.meta.url));
app.get('/local-glyph/*', (req, res) => {
const glyphPath = req.params[0]; // Express auto-decodes URI params
// Sanitize: only allow paths within ac-source/disks/drawings
if (glyphPath.includes('..') || glyphPath.includes('\0')) {
return res.status(400).send('Invalid path');
}
const filePath = join(__serverDirname, 'ac-source', 'disks', 'drawings', glyphPath);
res.sendFile(filePath, (err) => {
if (err) res.status(404).json({ error: 'glyph not found' });
});
});
// Oven TV dashboard β live-updating visual bake monitor
app.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html');
res.send(OVEN_TV_HTML);
});
const OVEN_TV_HTML = `
oven
oven
0/6 active
0 queued
--
--
Waiting for grabs...
`;
// Tools submenu β links to OG images, app screenshots, bundles, status pages
app.get('/tools', (req, res) => {
res.setHeader('Content-Type', 'text/html');
res.send(`
oven / tools
OG Images
App Screenshots
Bundles
Grabs
Status
/status Server status + recent bakes
OS Base Builds
`);
});
// API endpoints
app.get('/health', healthHandler);
// Override status to include grabs
app.get('/status', async (req, res) => {
await cleanupStaleBakes();
res.json({
version: GIT_VERSION,
serverStartTime: SERVER_START_TIME,
uptime: Date.now() - SERVER_START_TIME,
incoming: Array.from(getIncomingBakes().values()),
active: Array.from(getActiveBakes().values()),
recent: getRecentBakes(),
grabs: {
active: getActiveGrabs(),
recent: getRecentGrabs(),
ipfsThumbs: getAllLatestIPFSUploads()
},
osBaseBuilds: getOSBaseBuildsSummary(),
});
});
app.post('/bake', bakeHandler);
app.post('/bake-complete', bakeCompleteHandler);
app.post('/bake-status', bakeStatusHandler);
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Firmware splash renderer (for install-firmware.sh bootsplash swap)
//
// coreboot's CBFS bootsplash.bmp decoder is extremely narrow: it only
// accepts uncompressed 24-bit BGR BMPs with the 54-byte classic header
// (BITMAPFILEHEADER + BITMAPINFOHEADER). sharp does not emit BMP, so we
// render to raw RGB, swap to BGR, pad rows to 4-byte alignment, and
// prepend the header manually.
//
// Default resolution 1366Γ768 matches the Chromebook 14 panel coreboot
// initializes on MrChromebox. Caller can pass ?w=&h= for other panels,
// capped at 1920Γ1200 so an accidental query doesn't burn the process.
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
app.get('/firmware/splash.bmp', async (req, res) => {
try {
const w = Math.max(320, Math.min(1920, parseInt(req.query.w) || 1366));
const h = Math.max(240, Math.min(1200, parseInt(req.query.h) || 768));
// SVG splash β simple black background with centered aesthetic.computer
// wordmark. Keep the markup self-contained (no external fonts/assets)
// so the request stays fast and cacheable at the CDN layer.
const svg = `
aesthetic.computer
native
`;
const { data } = await sharp(Buffer.from(svg))
.resize(w, h)
.raw()
.toBuffer({ resolveWithObject: true });
// RGB (sharp default) β BGR, rows bottom-up, 4-byte aligned.
const rowBytes = w * 3;
const rowPad = (4 - (rowBytes % 4)) % 4;
const paddedRow = rowBytes + rowPad;
const pixelBytes = paddedRow * h;
const fileSize = 54 + pixelBytes;
const header = Buffer.alloc(54);
// BITMAPFILEHEADER (14)
header.write('BM', 0);
header.writeUInt32LE(fileSize, 2);
header.writeUInt32LE(0, 6); // reserved
header.writeUInt32LE(54, 10); // pixel data offset
// BITMAPINFOHEADER (40)
header.writeUInt32LE(40, 14); // header size
header.writeInt32LE(w, 18);
header.writeInt32LE(h, 22); // positive β bottom-up
header.writeUInt16LE(1, 26); // planes
header.writeUInt16LE(24, 28); // bpp
header.writeUInt32LE(0, 30); // BI_RGB (uncompressed)
header.writeUInt32LE(pixelBytes, 34); // image size
header.writeInt32LE(2835, 38); // X res (72 DPI in px/m)
header.writeInt32LE(2835, 42); // Y res
header.writeUInt32LE(0, 46); // palette colors
header.writeUInt32LE(0, 50); // important colors
const pixels = Buffer.alloc(pixelBytes);
for (let y = 0; y < h; y++) {
const srcY = (h - 1 - y) * rowBytes; // flip vertically
const dstY = y * paddedRow;
for (let x = 0; x < w; x++) {
const si = srcY + x * 3;
const di = dstY + x * 3;
pixels[di] = data[si + 2]; // B
pixels[di + 1] = data[si + 1]; // G
pixels[di + 2] = data[si]; // R
}
// row padding bytes left as zero
}
res.set({
'Content-Type': 'image/bmp',
'Content-Length': fileSize,
'Cache-Control': 'public, max-age=3600',
});
res.end(Buffer.concat([header, pixels]));
} catch (err) {
console.error('[firmware/splash.bmp] render failed:', err);
res.status(500).send('render failed');
}
});
// Icon endpoint - small square thumbnails (compatible with grab.aesthetic.computer)
// GET /icon/{width}x{height}/{piece}.png
// Uses 24h Spaces cache to avoid regenerating on every request
app.get('/icon/:size/:piece.png', async (req, res) => {
const { size, piece } = req.params;
const [width, height] = size.split('x').map(n => parseInt(n) || 128);
const w = Math.min(width, 512);
const h = Math.min(height, 512);
try {
const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('icons', piece, w, h, async () => {
const result = await grabPiece(piece, {
format: 'png',
width: w,
height: h,
density: 1,
});
if (!result.success) throw new Error(result.error);
// Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
if (result.cached && result.cdnUrl && !result.buffer) {
// Fetch the buffer from the CDN URL
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
});
if (fromCache && cdnUrl) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.redirect(302, cdnUrl);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', 'MISS');
res.send(buffer);
} catch (error) {
console.error('Icon handler error:', error);
res.status(500).json({ error: error.message });
}
});
// Animated WebP Icon endpoint - small animated square favicons
// GET /icon/{width}x{height}/{piece}.webp
// Uses 7-day Spaces cache since animated icons are expensive to generate
app.get('/icon/:size/:piece.webp', async (req, res) => {
const { size, piece } = req.params;
const [width, height] = size.split('x').map(n => parseInt(n) || 128);
// Keep animated icons small for performance (max 128x128)
const w = Math.min(width, 128);
const h = Math.min(height, 128);
// Query params for customization
const frames = Math.min(parseInt(req.query.frames) || 30, 60); // Default 30 frames, max 60
const fps = Math.min(parseInt(req.query.fps) || 15, 30); // Default 15 fps, max 30
try {
const cacheKey = `${piece}-${w}x${h}-f${frames}-fps${fps}`;
const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('animated-icons', cacheKey, w, h, async () => {
const result = await grabPiece(piece, {
format: 'webp',
width: w,
height: h,
density: 1,
frames: frames,
fps: fps,
});
if (!result.success) throw new Error(result.error);
// Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
if (result.cached && result.cdnUrl && !result.buffer) {
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch cached icon: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
}, 'webp');
if (fromCache && cdnUrl) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
return res.redirect(302, cdnUrl);
}
res.setHeader('Content-Type', 'image/webp');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day for fresh
res.setHeader('X-Cache', 'MISS');
res.send(buffer);
} catch (error) {
console.error('Animated icon handler error:', error);
res.status(500).json({ error: error.message });
}
});
// Preview endpoint - larger social media images (compatible with grab.aesthetic.computer)
// GET /preview/{width}x{height}/{piece}.png
// Uses 24h Spaces cache to avoid regenerating on every request
app.get('/preview/:size/:piece.png', async (req, res) => {
const { size, piece } = req.params;
const [width, height] = size.split('x').map(n => parseInt(n) || 1200);
const w = Math.min(width, 1920);
const h = Math.min(height, 1080);
try {
const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate('previews', piece, w, h, async () => {
const result = await grabPiece(piece, {
format: 'png',
width: w,
height: h,
density: 4,
viewportScale: 1,
});
if (!result.success) throw new Error(result.error);
// Handle case where grabPiece returns from its own cache (cdnUrl but no buffer)
if (result.cached && result.cdnUrl && !result.buffer) {
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch cached preview: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
});
if (fromCache && cdnUrl) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.redirect(302, cdnUrl);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', 'MISS');
res.send(buffer);
} catch (error) {
console.error('Preview handler error:', error);
res.status(500).json({ error: error.message });
}
});
// Product images β static assets for AC hardware/products
// GET /product/{name}.png β redirects to Spaces CDN: products/{name}.png
const PRODUCT_CDN = process.env.ART_CDN_BASE || 'https://art.aesthetic.computer';
app.get('/product/:name.png', (req, res) => {
const { name } = req.params;
if (!/^[a-z0-9-]+$/.test(name)) return res.status(400).json({ error: 'Invalid product name' });
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
res.redirect(302, `${PRODUCT_CDN}/products/${name}.png`);
});
// Grab endpoint - capture screenshots/GIFs from KidLisp pieces
app.post('/grab', grabHandler);
app.get('/grab/:format/:width/:height/:piece', grabGetHandler);
app.post('/grab-ipfs', grabIPFSHandler);
// Grab status endpoint
app.get('/grab-status', (req, res) => {
res.json({
active: getActiveGrabs(),
recent: getRecentGrabs(),
queue: getQueueStatus(),
progress: getCurrentProgress(),
grabProgress: getAllProgress(),
concurrency: getConcurrencyStatus(),
osBaseBuilds: getOSBaseBuildsSummary(),
});
});
// Cleanup stale grabs (grabs stuck for > 5 minutes)
app.post('/grab-cleanup', (req, res) => {
const result = cleanupStaleGrabs();
addServerLog('cleanup', 'π§Ή', `Manual cleanup: ${result.cleaned} stale grabs removed`);
res.json({
success: true,
...result
});
});
// Emergency clear all active grabs (admin only)
app.post('/grab-clear', (req, res) => {
const result = clearAllActiveGrabs();
addServerLog('cleanup', 'ποΈ', `Emergency clear: ${result.cleared} grabs force-cleared`);
res.json({
success: true,
...result
});
});
// Frozen pieces API - get list of frozen pieces
app.get('/api/frozen', (req, res) => {
res.json({
frozen: getFrozenPieces()
});
});
// Clear a piece from the frozen list
app.delete('/api/frozen/:piece', async (req, res) => {
const piece = decodeURIComponent(req.params.piece);
const result = await clearFrozenPiece(piece);
addServerLog('cleanup', 'β
', `Cleared frozen piece: ${piece}`);
res.json(result);
});
// Live collection thumbnail endpoint - redirects to most recent kept WebP
// Use this as the collection imageUri for a dynamic thumbnail
app.get('/keeps/latest', async (req, res) => {
let latest = getLatestKeepThumbnail();
if (!latest) {
latest = await ensureLatestKeepThumbnail();
}
if (!latest) {
return res.status(404).json({
error: 'No keeps have been captured yet',
hint: 'No minted keep thumbnail found in oven or kidlisp records yet'
});
}
// Redirect to IPFS gateway
const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`;
res.redirect(302, gatewayUrl);
});
// Get latest thumbnail for a specific piece
app.get('/keeps/latest/:piece', (req, res) => {
const latest = getLatestIPFSUpload(req.params.piece);
if (!latest) {
return res.status(404).json({
error: `No keeps captured for piece: ${req.params.piece}`,
hint: `Mint ${req.params.piece} with --thumbnail flag to populate this endpoint`
});
}
const gatewayUrl = `${IPFS_GATEWAY}/ipfs/${latest.ipfsCid}`;
res.redirect(302, gatewayUrl);
});
// Get all latest thumbnails as JSON (for debugging/monitoring)
app.get('/keeps/all', (req, res) => {
res.json({
latest: getLatestKeepThumbnail(),
byPiece: getAllLatestIPFSUploads()
});
});
// =============================================================================
// KidLisp.com OG Preview Image Endpoint
// =============================================================================
// Fast static PNG endpoint - redirects instantly to CDN (for social media crawlers)
// Use this URL in og:image and twitter:image meta tags
app.get('/kidlisp-og.png', async (req, res) => {
try {
const layout = req.query.layout || 'mosaic';
// Get cached URL without triggering generation (fast!)
const url = await getLatestOGImageUrl(layout);
if (url) {
// Redirect to CDN - instant response (302 so browsers don't permanently cache stale redirects)
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', 'CDN');
return res.redirect(302, url);
}
// No cached image yet - trigger background regeneration and serve a recent fallback
addServerLog('warn', 'β οΈ', `OG cache miss for ${layout}, triggering regen`);
// Trigger async regeneration (don't await)
regenerateOGImagesBackground().catch(err => {
addServerLog('error', 'β', `Async OG regen failed: ${err.message}`);
});
// Use yesterday's image as fallback (likely exists)
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
const fallbackUrl = `https://art.aesthetic.computer/og/kidlisp/${yesterday}-${layout}.png`;
res.setHeader('Cache-Control', 'public, max-age=300'); // Short cache for fallback
return res.redirect(302, fallbackUrl);
} catch (error) {
console.error('KidLisp OG PNG error:', error);
// Ultimate fallback - yesterday's mosaic
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
return res.redirect(302, `https://art.aesthetic.computer/og/kidlisp/${yesterday}-mosaic.png`);
}
});
// βββ TzKT dapp images βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
app.get('/kidlisp-og/tzkt-cover.jpg', async (req, res) => {
try {
addServerLog('info', 'πΌοΈ', 'TzKT cover (640x360)');
const result = await generateKidlispOGImage('mosaic', true, { noDotCom: true });
const jpg = await sharp(result.buffer).resize(640, 360, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer();
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Content-Length', jpg.length);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(jpg);
} catch (error) {
console.error('TzKT cover error:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/kidlisp-og/tzkt-logo.jpg', async (req, res) => {
try {
addServerLog('info', 'πΌοΈ', 'TzKT logo (200x200)');
// Render $ in Comic Relief via Puppeteer (lightweight, ~3s)
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] });
const page = await browser.newPage();
try {
await page.setViewport({ width: 200, height: 200, deviceScaleFactor: 2 });
await page.setContent(`
$
`, { waitUntil: 'networkidle0' });
await page.evaluate(() => document.fonts.ready);
await new Promise(r => setTimeout(r, 300));
const png = await page.screenshot({ type: 'png' });
const jpg = await sharp(png).resize(200, 200).jpeg({ quality: 90 }).toBuffer();
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Content-Length', jpg.length);
res.setHeader('Cache-Control', 'public, max-age=86400');
res.send(jpg);
} finally { await page.close(); await browser.close(); }
} catch (error) {
console.error('TzKT logo error:', error);
res.status(500).json({ error: error.message });
}
});
// βββ Site-specific OG images βββββββββββββββββββββββββββββββββββββββββββββββββ
app.get('/kidlisp-og/site/:site.png', async (req, res) => {
const site = req.params.site;
if (!['keeps', 'buy'].includes(site)) return res.status(400).json({ error: 'Invalid site', valid: ['keeps', 'buy'] });
try {
addServerLog('info', 'πΌοΈ', `Site OG: ${site}.kidlisp.com`);
// Use raw mosaic (no branding text) as background
const rawUrl = await getLatestOGImageUrl('mosaic-raw');
let mosaicBuffer;
if (rawUrl) {
const resp = await fetch(rawUrl);
if (!resp.ok) throw new Error(`Failed to fetch raw mosaic: ${resp.status}`);
mosaicBuffer = Buffer.from(await resp.arrayBuffer());
} else {
// Fallback: generate mosaic and use its buffer (will have branding but better than nothing)
const result = await generateKidlispOGImage('mosaic', false);
mosaicBuffer = result.buffer;
if (!mosaicBuffer && result.url) {
const resp = await fetch(result.url);
if (!resp.ok) throw new Error(`Failed to fetch mosaic: ${resp.status}`);
mosaicBuffer = Buffer.from(await resp.arrayBuffer());
}
}
const bg = await sharp(mosaicBuffer).blur(6).toBuffer();
const darkOverlay = Buffer.from(` `);
const prefixLetters = site === 'keeps'
? 'keeps'.split('').map(c => `${c} `).join('')
: [['b','#FF6B6B'],['u','#4ECDC4'],['y','#FFE66D']].map(([c,col]) => `${c} `).join('');
const kidlispLetters = [['K','#FF6B6B'],['i','#4ECDC4'],['d','#FFE66D'],['L','#A8E6CF'],['i','#FF8B94'],['s','#F7DC6F'],['p','#BB8FCE']].map(([c,col]) => `${c} `).join('');
const tspans = `${prefixLetters}. ${kidlispLetters}.com `;
const brandingSvg = Buffer.from(`${tspans} `);
const composited = await sharp(bg).composite([{ input: darkOverlay }, { input: brandingSvg }]).png().toBuffer();
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', composited.length);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.send(composited);
} catch (error) {
console.error(`Site OG error (${site}):`, error);
res.status(500).json({ error: error.message });
}
});
// Dynamic OG image for kidlisp.com - rotates daily based on top hits
// Supports multiple layout options: featured, mosaic, filmstrip, code-split
app.get('/kidlisp-og', async (req, res) => {
try {
const layout = req.query.layout || 'featured';
const force = req.query.force === 'true';
// Validate layout
const validLayouts = ['featured', 'mosaic', 'filmstrip', 'code-split'];
if (!validLayouts.includes(layout)) {
return res.status(400).json({
error: 'Invalid layout',
valid: validLayouts,
});
}
addServerLog('info', 'πΌοΈ', `KidLisp OG request: ${layout}${force ? ' (force)' : ''}`);
const result = await generateKidlispOGImage(layout, force);
if (result.cached && result.url) {
// Redirect to CDN URL for cached images
addServerLog('success', 'π¦', `OG cache hit β ${result.url.split('/').pop()}`);
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.redirect(302, result.url);
}
// Fresh generation - return the buffer directly
addServerLog('success', 'π¨', `OG generated: ${layout} (${result.featuredPiece?.code || 'mosaic'})`);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', result.buffer.length);
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24hr cache
res.setHeader('X-Cache', 'MISS');
res.setHeader('X-OG-Layout', layout);
res.setHeader('X-OG-Generated', result.generatedAt);
if (result.featuredPiece) {
res.setHeader('X-OG-Featured', result.featuredPiece.code);
}
res.send(result.buffer);
} catch (error) {
console.error('KidLisp OG error:', error);
addServerLog('error', 'β', `OG error: ${error.message}`);
res.status(500).json({
error: 'Failed to generate OG image',
message: error.message
});
}
});
// OG image cache status endpoint
app.get('/kidlisp-og/status', (req, res) => {
res.json({
...getOGImageCacheStatus(),
availableLayouts: ['featured', 'mosaic', 'filmstrip', 'code-split'],
usage: {
recommended: '/kidlisp-og.png (instant, for og:image tags)',
withLayout: '/kidlisp-og.png?layout=mosaic',
dynamic: '/kidlisp-og (may regenerate on-demand)',
forceRegenerate: '/kidlisp-og?force=true',
},
note: 'Use /kidlisp-og.png for social media meta tags - it redirects instantly to cached CDN images'
});
});
// Preview all OG images (generalized for kidlisp, notepat, etc)
app.get('/og-preview', (req, res) => {
const baseUrl = req.protocol + '://' + req.get('host');
const ogImages = [
{
name: 'KidLisp',
slug: 'kidlisp-og',
prodUrls: [
'https://kidlisp.com',
'https://aesthetic.computer/kidlisp'
],
layouts: ['featured', 'mosaic', 'filmstrip', 'code-split'],
description: 'Dynamic layouts featuring recent KidLisp pieces'
},
{
name: 'Notepat',
slug: 'notepat-og',
prodUrls: [
'https://notepat.com',
'https://aesthetic.computer/notepat'
],
layouts: null, // Single layout
description: 'Split-layout chromatic piano interface'
}
];
res.setHeader('Content-Type', 'text/html');
res.send(`
OG Image Preview
πΌοΈ OG Image Preview Dashboard
About: This page shows all Open Graph (OG) images used for social media previews.
Usage: Use the .png endpoints in meta tags for instant CDN redirects (no timeouts).
Testing: Click production URLs below to verify OG tags are working correctly.
${ogImages.map(og => `
${og.name}
${og.description}
Production URLs:
${og.prodUrls.map(url => `
${url} β `).join(' ')}
OG Endpoint: ${baseUrl}/${og.slug}.png
${og.layouts ? `
Layouts:
${og.layouts.map(layout => `
${layout.charAt(0).toUpperCase() + layout.slice(1)}
`).join('')}
` : `
`}
`).join('')}
β Back to Oven Dashboard
`);
});
// Legacy redirect for old kidlisp preview URL
app.get('/kidlisp-og/preview', (req, res) => {
res.redirect(302, '/og-preview');
});
// Notepat branded OG image for notepat.com
app.get('/notepat-og.png', async (req, res) => {
try {
const force = req.query.force === 'true';
addServerLog('info', 'πΉ', `Notepat OG request${force ? ' (force)' : ''}`);
const result = await generateNotepatOGImage(force);
if (result.cached && result.url) {
// Proxy the image back instead of redirecting (iOS crawlers won't follow 301s on og:image)
addServerLog('success', 'π¦', `Notepat OG cache hit β proxying`);
try {
const cdnResponse = await fetch(result.url);
if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`);
const buffer = Buffer.from(await cdnResponse.arrayBuffer());
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache
res.setHeader('X-Cache', 'HIT');
return res.send(buffer);
} catch (fetchErr) {
// Fall back to redirect if proxy fails
addServerLog('warn', 'β οΈ', `Notepat OG proxy failed, falling back to redirect: ${fetchErr.message}`);
return res.redirect(301, result.url);
}
}
// Fresh generation - return the buffer directly
addServerLog('success', 'π¨', `Notepat OG generated`);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', result.buffer.length);
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7-day cache
res.setHeader('X-Cache', 'MISS');
res.send(result.buffer);
} catch (error) {
console.error('Notepat OG error:', error);
addServerLog('error', 'β', `Notepat OG error: ${error.message}`);
res.status(500).json({
error: 'Failed to generate Notepat OG image',
message: error.message
});
}
});
// =============================================================================
// News OG Image - Dynamic social cards for news.aesthetic.computer articles
// =============================================================================
app.get('/news-og/:code.png', async (req, res) => {
try {
const code = req.params.code;
const force = req.query.force === 'true';
addServerLog('info', 'π°', `News OG request: ${code}${force ? ' (force)' : ''}`);
// Fetch post from MongoDB.
const mongoUri = process.env.MONGODB_CONNECTION_STRING;
const dbName = process.env.MONGODB_NAME;
if (!mongoUri || !dbName) {
return res.status(500).json({ error: 'MongoDB not configured' });
}
const client = new MongoClient(mongoUri);
await client.connect();
const db = client.db(dbName);
const post = await db.collection('news-posts').findOne({ code, status: { $ne: 'dead' } });
if (!post) {
await client.close();
return res.status(404).json({ error: 'Post not found' });
}
// Hydrate handle.
if (post.user) {
const handleDoc = await db.collection('@handles').findOne({ _id: post.user });
post.handle = handleDoc ? `@${handleDoc.handle}` : '@anon';
} else {
post.handle = '@anon';
}
await client.close();
const result = await generateNewsOGImage(post, force);
if (result.cached && result.url) {
addServerLog('success', 'π¦', `News OG cache hit: ${code} β proxying`);
try {
const cdnResponse = await fetch(result.url);
if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`);
const buffer = Buffer.from(await cdnResponse.arrayBuffer());
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=604800');
res.setHeader('X-Cache', 'HIT');
return res.send(buffer);
} catch (fetchErr) {
addServerLog('warn', 'β οΈ', `News OG proxy failed: ${fetchErr.message}`);
return res.redirect(301, result.url);
}
}
addServerLog('success', 'π¨', `News OG generated: ${code}`);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', result.buffer.length);
res.setHeader('Cache-Control', 'public, max-age=604800');
res.setHeader('X-Cache', 'MISS');
res.send(result.buffer);
} catch (error) {
console.error('News OG error:', error);
addServerLog('error', 'β', `News OG error: ${error.message}`);
res.status(500).json({ error: 'Failed to generate News OG image', message: error.message });
}
});
// =============================================================================
// KidLisp Backdrop - Animated WebP for login screens, Auth0, etc.
// =============================================================================
// Fast redirect to CDN-cached 2048px animated webp
app.get('/kidlisp-backdrop.webp', async (req, res) => {
try {
// Get cached URL without triggering generation (fast!)
const url = await getLatestBackdropUrl();
if (url) {
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', 'CDN');
return res.redirect(301, url);
}
// No cached backdrop - generate synchronously (first request will be slow)
addServerLog('warn', 'β οΈ', 'Backdrop cache miss, generating...');
const result = await generateKidlispBackdrop(false);
if (result.url) {
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', 'MISS');
return res.redirect(302, result.url);
}
res.status(503).json({ error: 'Backdrop generation in progress, try again shortly' });
} catch (error) {
console.error('Backdrop error:', error);
res.status(500).json({ error: 'Failed to get backdrop', message: error.message });
}
});
// Dynamic backdrop generation (may regenerate on-demand)
app.get('/kidlisp-backdrop', async (req, res) => {
try {
const force = req.query.force === 'true';
addServerLog('info', 'πΌοΈ', `Backdrop request${force ? ' (force)' : ''}`);
const result = await generateKidlispBackdrop(force);
if (result.url) {
addServerLog('success', 'π¨', `Backdrop: ${result.piece} β ${result.cached ? 'cached' : 'generated'}`);
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Cache', result.cached ? 'HIT' : 'MISS');
res.setHeader('X-Backdrop-Piece', result.piece || 'unknown');
return res.redirect(302, result.url);
}
res.status(500).json({ error: 'Failed to generate backdrop' });
} catch (error) {
console.error('Backdrop error:', error);
addServerLog('error', 'β', `Backdrop error: ${error.message}`);
res.status(500).json({ error: 'Failed to generate backdrop', message: error.message });
}
});
// =============================================================================
// App Store Screenshots - Generate screenshots for Google Play / App Store
// =============================================================================
// App screenshots dashboard
app.get('/app-screenshots', (req, res) => {
const piece = req.query.piece || 'prompt';
const presets = Object.entries(APP_SCREENSHOT_PRESETS);
res.setHeader('Content-Type', 'text/html');
res.send(`
π± App Store Screenshots - Oven
β Back to Oven Dashboard
π Google Play Requirements
PNG or JPEG, max 8MB each
16:9 or 9:16 aspect ratio
Phone: 320-3840px per side, 1080px min for promotion
7" Tablet: 320-3840px per side
10" Tablet: 1080-7680px per side
2-8 screenshots per category required
π± Phone Screenshots
${presets.filter(([k, v]) => v.category === 'phone').map(([key, preset]) => `
π₯ Loading...
${preset.label}
${preset.width} Γ ${preset.height}px
`).join('')}
π± 7-inch Tablet Screenshots
${presets.filter(([k, v]) => v.category === 'tablet7').map(([key, preset]) => `
π₯ Loading...
${preset.label}
${preset.width} Γ ${preset.height}px
`).join('')}
π± 10-inch Tablet Screenshots
${presets.filter(([k, v]) => v.category === 'tablet10').map(([key, preset]) => `
π₯ Loading...
${preset.label}
${preset.width} Γ ${preset.height}px
`).join('')}
`);
});
// Individual app screenshot endpoint
app.get('/app-screenshots/:preset/:piece.png', async (req, res) => {
const { preset, piece } = req.params;
const force = req.query.force === 'true';
const presetConfig = APP_SCREENSHOT_PRESETS[preset];
if (!presetConfig) {
return res.status(400).json({
error: 'Invalid preset',
valid: Object.keys(APP_SCREENSHOT_PRESETS)
});
}
const { width, height } = presetConfig;
try {
addServerLog('capture', 'π±', `App screenshot: ${piece} (${preset} ${width}Γ${height}${force ? ' FORCE' : ''})`);
const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate(
'app-screenshots',
`${piece}-${preset}`,
width,
height,
async () => {
const result = await grabPiece(piece, {
format: 'png',
width,
height,
density: 4, // Pixel art - larger art pixels (4x)
viewportScale: 1, // Capture at exact output size
skipCache: force,
});
if (!result.success) throw new Error(result.error);
// Handle cached result (cdnUrl but no buffer)
if (result.cached && result.cdnUrl && !result.buffer) {
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
},
'png', // ext
force // skipCache - pass force flag to skip CDN cache
);
if (fromCache && cdnUrl && !force) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
return res.redirect(302, cdnUrl);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
// When force=true, prevent caching
res.setHeader('Cache-Control', force ? 'no-store, no-cache, must-revalidate' : 'public, max-age=86400');
res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS');
res.setHeader('X-Screenshot-Preset', preset);
res.setHeader('X-Screenshot-Dimensions', `${width}x${height}`);
res.send(buffer);
} catch (error) {
console.error('App screenshot error:', error);
addServerLog('error', 'β', `App screenshot failed: ${piece} ${preset} - ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// βββ News screenshot endpoint ββββββββββββββββββββββββββββββββββββββββββββββ
// Captures a piece at 16:9 (1200Γ675) for embedding in news.aesthetic.computer
// posts. Returns JSON with the CDN URL so the CLI can emit markdown.
// Usage: GET /news-screenshot/notepat.png
// GET /news-screenshot/notepat.png?force=true
app.get('/news-screenshot/:piece.png', async (req, res) => {
const { piece } = req.params;
const force = req.query.force === 'true';
const width = 1200, height = 675; // 16:9
try {
addServerLog('capture', 'π°', `News screenshot: ${piece} (${width}Γ${height}${force ? ' FORCE' : ''})`);
const { cdnUrl, fromCache, buffer } = await getCachedOrGenerate(
'news-screenshots',
piece,
width,
height,
async () => {
const result = await grabPiece(piece, {
format: 'png',
width,
height,
density: 2,
viewportScale: 1,
skipCache: force,
source: 'news',
});
if (!result.success) throw new Error(result.error);
if (result.cached && result.cdnUrl && !result.buffer) {
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch cached screenshot: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
},
'png',
force,
);
// Return JSON only when explicitly requested; default to serving the image.
if (req.query.json === 'true') {
return res.json({
piece,
url: cdnUrl || `https://oven.aesthetic.computer/news-screenshot/${piece}.png`,
cached: fromCache,
width,
height,
});
}
if (fromCache && cdnUrl && !force) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('Cache-Control', 'public, max-age=604800');
return res.redirect(302, cdnUrl);
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', force ? 'no-store' : 'public, max-age=86400');
res.setHeader('X-Cache', force ? 'REGENERATED' : 'MISS');
res.send(buffer);
} catch (error) {
console.error('News screenshot error:', error);
addServerLog('error', 'β', `News screenshot failed: ${piece} - ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Bulk ZIP download endpoint
app.get('/app-screenshots/download/:piece', async (req, res) => {
const { piece } = req.params;
const presets = Object.entries(APP_SCREENSHOT_PRESETS);
addServerLog('info', 'π¦', `Generating ZIP for ${piece} (${presets.length} screenshots)`);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${piece}-app-screenshots.zip"`);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.pipe(res);
for (const [presetKey, preset] of presets) {
try {
const { cdnUrl, buffer } = await getCachedOrGenerate(
'app-screenshots',
`${piece}-${presetKey}`,
preset.width,
preset.height,
async () => {
const result = await grabPiece(piece, {
format: 'png',
width: preset.width,
height: preset.height,
density: 4, // Pixel art - larger art pixels (4x)
viewportScale: 1, // Capture at exact output size
});
if (!result.success) throw new Error(result.error);
if (result.cached && result.cdnUrl && !result.buffer) {
const response = await fetch(result.cdnUrl);
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`);
return Buffer.from(await response.arrayBuffer());
}
return result.buffer;
}
);
// Get buffer from CDN if we only have URL
let imageBuffer = buffer;
if (!imageBuffer && cdnUrl) {
const response = await fetch(cdnUrl);
if (response.ok) {
imageBuffer = Buffer.from(await response.arrayBuffer());
}
}
if (imageBuffer) {
const filename = `${preset.category}/${piece}-${presetKey}.png`;
archive.append(imageBuffer, { name: filename });
addServerLog('success', 'β
', `Added to ZIP: ${filename}`);
}
} catch (err) {
console.error(`Failed to add ${presetKey} to ZIP:`, err);
addServerLog('error', 'β', `ZIP: Failed ${presetKey} - ${err.message}`);
}
}
archive.finalize();
});
// JSON API for app screenshots status
app.get('/api/app-screenshots/:piece', async (req, res) => {
const { piece } = req.params;
const screenshots = {};
for (const [key, preset] of Object.entries(APP_SCREENSHOT_PRESETS)) {
screenshots[key] = {
...preset,
url: `/app-screenshots/${key}/${piece}.png`,
downloadUrl: `/app-screenshots/${key}/${piece}.png?download=true`,
};
}
res.json({
piece,
presets: screenshots,
zipUrl: `/app-screenshots/download/${piece}`,
dashboardUrl: `/app-screenshots?piece=${piece}`,
});
});
// βββ Pack HTML endpoint (alias: /bundle-html) ββββββββββββββββββββββ
app.get(['/pack-html', '/bundle-html'], async (req, res) => {
const code = req.query.code;
const piece = req.query.piece;
const format = req.query.format || 'html';
const nocache = req.query.nocache === '1' || req.query.nocache === 'true';
const nocompress = req.query.nocompress === '1' || req.query.nocompress === 'true';
const nominify = req.query.nominify === '1' || req.query.nominify === 'true';
const brotli = req.query.brotli === '1' || req.query.brotli === 'true';
const inline = req.query.inline === '1' || req.query.inline === 'true';
const noboxart = req.query.noboxart === '1' || req.query.noboxart === 'true';
const keeplabel = req.query.keeplabel === '1' || req.query.keeplabel === 'true';
const density = parseInt(req.query.density) || null;
const mode = req.query.mode;
// Device mode: simple iframe wrapper (fast path)
if (mode === 'device') {
const pieceCode = code || piece;
if (!pieceCode) return res.status(400).send('Missing code or piece parameter');
return res.set({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=60' }).send(generateDeviceHTML(pieceCode, density));
}
setSkipMinification(nominify);
const isJSPiece = !!piece;
const bundleTarget = piece || code;
if (!bundleTarget) {
return res.status(400).json({ error: "Missing 'code' or 'piece' parameter.", usage: { kidlisp: "/pack-html?code=39j", javascript: "/pack-html?piece=notepat" } });
}
// M4D mode: .amxd binary
if (format === 'm4d') {
try {
const onProgress = (p) => console.log(`[bundler] m4d ${p.stage}: ${p.message}`);
const { binary, filename } = await createM4DBundle(bundleTarget, isJSPiece, onProgress, density);
res.set({ 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-cache' });
return res.send(binary);
} catch (error) {
console.error('M4D bundle failed:', error);
return res.status(500).json({ error: error.message });
}
}
// SSE streaming mode
if (format === 'stream') {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
try {
const onProgress = (p) => sendEvent('progress', p);
const { html, filename, sizeKB } = isJSPiece
? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel)
: await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel);
sendEvent('complete', { filename, content: Buffer.from(html).toString('base64'), sizeKB });
} catch (error) {
console.error('Bundle failed:', error);
sendEvent('error', { error: error.message });
}
return res.end();
}
// Non-streaming modes (json, html download, inline)
try {
const progressLog = [];
const onProgress = (p) => { progressLog.push(p.message); console.log(`[bundler] ${p.stage}: ${p.message}`); };
const result = isJSPiece
? await createJSPieceBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel)
: await createBundle(bundleTarget, onProgress, nocompress, density, brotli, noboxart, keeplabel);
const { html, filename, sizeKB, mainSource, authorHandle, userCode, packDate, depCount } = result;
if (format === 'json' || format === 'base64') {
return res.json({ filename, content: Buffer.from(html).toString('base64'), sizeKB, progress: progressLog, sourceCode: mainSource, authorHandle, userCode, packDate, depCount });
}
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' };
if (!inline) headers['Content-Disposition'] = `attachment; filename="${filename}"`;
return res.set(headers).send(html);
} catch (error) {
console.error('Bundle failed:', error);
return res.status(500).json({ error: error.message });
}
});
// Prewarm the core bundle cache (called by deploy.sh after restart)
app.post(['/pack-prewarm', '/bundle-prewarm'], async (req, res) => {
try {
addServerLog('info', 'π¦', 'Bundle prewarm started...');
const result = await prewarmCache();
addServerLog('success', 'π¦', `Bundle cache ready: ${result.fileCount} files in ${result.elapsed}ms (${result.commit})`);
res.json(result);
} catch (error) {
addServerLog('error', 'β', `Bundle prewarm failed: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
// Cache status
app.get(['/pack-status', '/bundle-status'], (req, res) => {
res.json(getCacheStatus());
});
// ===== OS IMAGE BUILDER =====
// Assembles bootable FedAC OS artifacts with a piece injected into the FEDAC-PIECE partition.
// Requires: pre-baked base image on CDN + e2fsprogs (debugfs) on server.
app.get('/os', async (req, res) => {
const code = req.query.code;
const piece = req.query.piece;
const format = req.query.format || 'download';
const density = parseInt(req.query.density) || 8;
const flavor = (req.query.flavor || 'alpine').toLowerCase();
const nocache = req.query.nocache === '1' || req.query.nocache === 'true';
if (!['alpine', 'fedora', 'native'].includes(flavor)) {
return res.status(400).json({ error: "Invalid flavor. Use 'alpine', 'fedora', or 'native'." });
}
// Native flavor: pre-built bare-metal kernel images on CDN (no dynamic assembly)
if (flavor === 'native') {
const nativePiece = piece || code || 'notepat';
const cdnUrl = `https://releases.aesthetic.computer/os/native-${nativePiece}-latest.img.gz`;
const filename = `${nativePiece}-native.img.gz`;
addServerLog('info', 'πΏ', `OS native redirect: ${nativePiece}`);
if (format === 'stream') {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no' });
res.flushHeaders();
const sendEvent = (type, data) => { res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`); if (typeof res.flush === 'function') res.flush(); };
sendEvent('progress', { stage: 'native', message: 'Native image ready on CDN', percent: 100 });
sendEvent('complete', { message: 'Native OS image ready', downloadUrl: cdnUrl, filename, cached: true, flavor: 'native', elapsed: 0 });
return res.end();
}
return res.redirect(cdnUrl);
}
const isJSPiece = !!piece;
const target = piece || code;
if (!target) {
return res.status(400).json({
error: "Missing 'code' or 'piece' parameter.",
usage: { kidlisp: "/os?code=39j", javascript: "/os?piece=notepat" },
});
}
addServerLog('info', 'πΏ', `OS ISO build started: ${target} (${flavor})${nocache ? ' [nocache]' : ''}`);
// SSE streaming progress mode (for UI)
if (format === 'stream') {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const sendEvent = (type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
try {
const result = await streamOSImage(null, target, isJSPiece, density, (p) => sendEvent('progress', p), flavor, { nocache });
const downloadParam = isJSPiece ? `piece=${encodeURIComponent(target)}` : `code=${encodeURIComponent(target)}`;
// Prefer CDN URL for fast download; fall back to oven direct.
const downloadUrl = result.cdnUrl || `/os?${downloadParam}&density=${density}&flavor=${flavor}`;
sendEvent('complete', {
message: result.cached ? 'OS ISO ready (CDN cached)' : 'OS ISO ready',
downloadUrl,
elapsed: result.elapsed,
filename: result.filename,
timings: result.timings,
cached: result.cached || false,
flavor,
});
} catch (err) {
console.error('[os] SSE build failed:', err);
sendEvent('error', { error: err.message });
}
return res.end();
}
// Direct download mode
try {
const result = await streamOSImage(res, target, isJSPiece, density, (p) => {
console.log(`[os] ${p.stage}: ${p.message}`);
}, flavor, { nocache });
addServerLog('success', 'πΏ', `OS ISO build complete: ${target}/${flavor} (${Math.round(result.elapsed / 1000)}s)`);
} catch (err) {
console.error('[os] Build failed:', err);
addServerLog('error', 'β', `OS build failed: ${err.message}`);
if (!res.headersSent) {
res.status(500).json({ error: err.message });
}
}
});
app.get('/os-status', (req, res) => {
res.json(getOSBuildStatus());
});
// Proxy releases.json with CORS for the web os.mjs piece.
app.get('/os-releases', async (req, res) => {
try {
const r = await fetch(`${RELEASES_BASE}/releases.json`);
if (!r.ok) return res.status(r.status).json({ error: 'Failed to fetch releases' });
const data = await r.json();
const releases = Array.isArray(data?.releases) ? data.releases : [];
const { byName, failedAttempts } = await getNativeBuildStatusData();
const releaseNames = new Set();
for (const rel of releases) {
const name = String(rel?.name || '').trim();
if (!name) continue;
releaseNames.add(name);
const statusMeta = byName.get(name);
if (statusMeta?.status) rel.status = statusMeta.status;
else if (!rel.status) rel.status = 'success';
if (statusMeta?.error && !rel.error) rel.error = statusMeta.error;
if (statusMeta?.buildTs && !rel.last_attempt_ts) rel.last_attempt_ts = statusMeta.buildTs;
}
const failedRows = failedAttempts
.filter((it) => !releaseNames.has(it.name))
.map((it) => ({
name: it.name,
git_hash: it.ref ? it.ref.slice(0, 40) : null,
build_ts: it.buildTs || new Date().toISOString(),
commit_msg: it.commitMsg || it.error || `build ${it.status}`,
status: it.status,
error: it.error || null,
deprecated: true,
attempt_only: true,
size: 0,
}));
data.releases = releases.concat(failedRows);
res.json(data);
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// Flush the cached OS template so the next download gets the fresh one.
app.post('/os-cache-flush', (req, res) => {
templateCache = null;
templateCacheTime = 0;
console.log('[os-image] Template cache flushed');
res.json({ flushed: true });
});
// Personalized FedAC OS .img download for authenticated AC users.
// Downloads the template .img from DO Spaces, patches config.json in-place,
// and streams back. Compatible with Fedora Media Writer, Balena Etcher, dd.
const RELEASES_BASE = 'https://releases-aesthetic-computer.sfo3.digitaloceanspaces.com/os';
const TEMPLATE_IMG_URL = `${RELEASES_BASE}/native-notepat-latest.img`;
const TEMPLATE_GZ_URL = `${RELEASES_BASE}/native-notepat-latest.img.gz`; // legacy fallback
const TEMPLATE_VMLINUZ_URL = `${RELEASES_BASE}/native-notepat-latest.vmlinuz`;
const TEMPLATE_CL_VMLINUZ_URL = `${RELEASES_BASE}/cl-native-notepat-latest.vmlinuz`;
const CONFIG_MARKER_LEGACY = '{"handle":"","piece":"notepat","sub":"","email":""}';
const CONFIG_PAD_SIZE_LEGACY = 4096;
const IDENTITY_MARKER = 'AC_IDENTITY_BLOCK_V1';
const IDENTITY_BLOCK_SIZE = 32768;
// Cache the decompressed template in memory
let templateCache = null;
let templateCacheTime = 0;
const TEMPLATE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
async function getTemplate() {
if (templateCache && Date.now() - templateCacheTime < TEMPLATE_CACHE_TTL) {
return templateCache;
}
// Try the raw .img first, fall back to the older compressed image if needed.
let raw;
const imgRes = await fetch(TEMPLATE_IMG_URL);
if (imgRes.ok) {
console.log('[os-image] Downloading template .img...');
raw = Buffer.from(await imgRes.arrayBuffer());
} else {
console.log('[os-image] No .img found, trying legacy .img.gz fallback...');
const gzRes = await fetch(TEMPLATE_GZ_URL);
if (gzRes.ok) {
const compressed = Buffer.from(await gzRes.arrayBuffer());
console.log(`[os-image] Decompressing ${(compressed.length / 1048576).toFixed(1)}MB...`);
raw = gunzipSync(compressed);
} else {
throw new Error(`Template download failed (no .img or .img.gz available)`);
}
}
templateCache = raw;
templateCacheTime = Date.now();
console.log(`[os-image] Template cached: ${(templateCache.length / 1048576).toFixed(1)}MB`);
return templateCache;
}
function kernelUrlForVariant(variant) {
return variant === 'cl' ? TEMPLATE_CL_VMLINUZ_URL : TEMPLATE_VMLINUZ_URL;
}
async function buildPersonalizedEfiImage({ kernelUrl, configJson }) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const tmpBase = `/tmp/os-image-${id}`;
const kernelPath = `${tmpBase}-BOOTX64.EFI`;
const configPath = `${tmpBase}-config.json`;
const imagePath = `${tmpBase}.img`;
const efiOffsetSectors = 2048;
const efiOffsetBytes = efiOffsetSectors * 512;
let kernelData = null;
try {
const kRes = await fetch(kernelUrl);
if (!kRes.ok) {
throw new Error(`Kernel download failed (${kRes.status})`);
}
kernelData = Buffer.from(await kRes.arrayBuffer());
await fs.promises.writeFile(kernelPath, kernelData);
await fs.promises.writeFile(configPath, configJson);
// Keep headroom for FAT metadata and future kernel size growth.
const minBytes = kernelData.length + Buffer.byteLength(configJson) + (32 * 1024 * 1024);
const imageSizeMiB = Math.max(384, Math.ceil(minBytes / 1048576) + 32);
execSync(`dd if=/dev/zero of="${imagePath}" bs=1M count=${imageSizeMiB} status=none`);
execSync(
`printf 'label: gpt\nstart=${efiOffsetSectors}, type=C12A7328-F81F-11D2-BA4B-00A0C93EC93B\n' | ` +
`sfdisk --force --no-reread "${imagePath}" >/dev/null`,
);
execSync(`mkfs.vfat -F 32 --offset=${efiOffsetSectors} "${imagePath}" >/dev/null`);
execSync(`mmd -i "${imagePath}@@${efiOffsetBytes}" ::EFI ::EFI/BOOT`);
execSync(`mcopy -o -i "${imagePath}@@${efiOffsetBytes}" "${kernelPath}" ::EFI/BOOT/BOOTX64.EFI`);
execSync(`mcopy -o -i "${imagePath}@@${efiOffsetBytes}" "${configPath}" ::config.json`);
return await fs.promises.readFile(imagePath);
} finally {
await Promise.allSettled([
fs.promises.unlink(kernelPath),
fs.promises.unlink(configPath),
fs.promises.unlink(imagePath),
]);
}
}
// User config endpoint for edge worker image patching
app.get('/api/user-config', async (req, res) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
if (!token) return res.status(401).json({ error: 'Authorization required' });
let userInfo;
try {
const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', {
headers: { Authorization: `Bearer ${token}` },
});
if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`);
userInfo = await uiRes.json();
} catch (err) {
return res.status(401).json({ error: `Authentication failed: ${err.message}` });
}
const sub = userInfo.sub || '';
let handle = '';
try {
const handleRes = await fetch(`https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`);
if (handleRes.ok) {
const data = await handleRes.json();
handle = data.handle || '';
}
} catch (_) {}
if (!handle) return res.status(403).json({ error: 'No handle found' });
let claudeToken = '', githubPat = '';
try {
const mongoUri = process.env.MONGODB_CONNECTION_STRING;
const dbName = process.env.MONGODB_NAME;
if (mongoUri) {
const { MongoClient } = await import('mongodb');
const client = new MongoClient(mongoUri);
await client.connect();
const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub });
if (doc) {
claudeToken = doc.claudeCodeToken || '';
githubPat = doc.githubPat || '';
}
await client.close();
}
} catch (err) {
console.warn(`[user-config] Token lookup failed: ${err.message}`);
}
const reqPiece = req.query.piece || 'notepat';
const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken'];
const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat';
const wifiParam = req.query.wifi;
const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false';
const config = { handle, piece: bootPiece, sub, email: userInfo.email || '', token };
if (claudeToken) config.claudeToken = claudeToken;
if (githubPat) config.githubPat = githubPat;
if (!wifiEnabled) config.wifi = false;
res.json(config);
});
app.get('/os-image', async (req, res) => {
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
if (!token) {
return res.status(401).json({ error: 'Authorization required. Log in at aesthetic.computer first.' });
}
let userInfo;
try {
const uiRes = await fetch('https://hi.aesthetic.computer/userinfo', {
headers: { Authorization: `Bearer ${token}` },
});
if (!uiRes.ok) throw new Error(`Auth0 ${uiRes.status}`);
userInfo = await uiRes.json();
} catch (err) {
return res.status(401).json({ error: `Authentication failed: ${err.message}` });
}
let handle = '';
const sub = userInfo.sub || '';
try {
const handleRes = await fetch(
`https://aesthetic.computer/handle?for=${encodeURIComponent(sub)}`
);
if (handleRes.ok) {
const data = await handleRes.json();
handle = data.handle || '';
}
} catch (_) {}
if (!handle) {
return res.status(403).json({ error: 'You need a handle first. Visit aesthetic.computer/handle to claim one.' });
}
const ALLOWED_PIECES = ['notepat', 'prompt', 'chat', 'laer-klokken'];
const reqPiece = req.query.piece || 'notepat';
const bootPiece = ALLOWED_PIECES.includes(reqPiece) ? reqPiece : 'notepat';
const wifiParam = req.query.wifi;
const wifiEnabled = wifiParam !== '0' && wifiParam !== 'false';
const requestedLayout = String(req.query.layout || 'img').toLowerCase();
const variant = String(req.query.variant || '').toLowerCase() === 'cl' ? 'cl' : 'c';
let claudeToken = '', githubPat = '';
try {
const mongoUri = process.env.MONGODB_CONNECTION_STRING;
const dbName = process.env.MONGODB_NAME;
if (mongoUri) {
const { MongoClient } = await import('mongodb');
const client = new MongoClient(mongoUri);
await client.connect();
const doc = await client.db(dbName).collection('@handles').findOne({ _id: sub });
if (doc) {
claudeToken = doc.claudeCodeToken || '';
githubPat = doc.githubPat || '';
}
await client.close();
}
} catch (err) {
console.warn(`[os-image] Token lookup failed: ${err.message}`);
}
console.log(`[os-image] Building personalized image for @${handle} (boot: ${bootPiece}, wifi: ${wifiEnabled}, variant: ${variant}, claude: ${!!claudeToken}, git: ${!!githubPat})`);
const configObj = {
handle,
piece: bootPiece,
sub: userInfo.sub || '',
email: userInfo.email || '',
token: token,
};
if (claudeToken) configObj.claudeToken = claudeToken;
if (githubPat) configObj.githubPat = githubPat;
if (!wifiEnabled) configObj.wifi = false;
const configJson = JSON.stringify(configObj);
let imgData;
let fallbackImage = false;
let fallbackReason = '';
const buildKernelFallback = async (reason) => {
fallbackReason = reason;
console.warn(
`[os-image] ${reason}; generating ${variant} EFI image for @${handle}`,
);
imgData = await buildPersonalizedEfiImage({
kernelUrl: kernelUrlForVariant(variant),
configJson,
});
fallbackImage = true;
};
try {
const template = await getTemplate();
imgData = Buffer.from(template);
} catch (err) {
try {
await buildKernelFallback('template-unavailable');
} catch (fallbackErr) {
return res.status(503).json({
error: `Template not available (${err.message}) and fallback image build failed: ${fallbackErr.message}`,
});
}
}
let identityPatchCount = 0;
let configPatchCount = 0;
if (!fallbackImage) {
const identityMarkerBuf = Buffer.from(IDENTITY_MARKER + '\n');
let idx = imgData.indexOf(identityMarkerBuf);
while (idx !== -1) {
const block = Buffer.alloc(IDENTITY_BLOCK_SIZE, 0);
const header = Buffer.from(IDENTITY_MARKER + '\n' + configJson);
header.copy(block);
block.copy(imgData, idx);
identityPatchCount++;
idx = imgData.indexOf(identityMarkerBuf, idx + IDENTITY_BLOCK_SIZE);
}
const padded = configJson.length >= CONFIG_PAD_SIZE_LEGACY
? configJson.slice(0, CONFIG_PAD_SIZE_LEGACY)
: configJson + ' '.repeat(CONFIG_PAD_SIZE_LEGACY - configJson.length);
const configBytes = Buffer.from(padded);
const legacyMarkerBuf = Buffer.from(CONFIG_MARKER_LEGACY);
idx = imgData.indexOf(legacyMarkerBuf);
while (idx !== -1) {
configBytes.copy(imgData, idx);
configPatchCount++;
idx = imgData.indexOf(legacyMarkerBuf, idx + CONFIG_PAD_SIZE_LEGACY);
}
if (identityPatchCount === 0 && configPatchCount === 0) {
try {
await buildKernelFallback('template-missing-config-placeholder');
} catch (err) {
return res.status(500).json({
error: `Template image missing config placeholder and fallback image build failed: ${err.message}`,
});
}
}
}
if (!fallbackImage) {
console.log(
`[os-image] Patched ${identityPatchCount} identity block(s) and ${configPatchCount} config location(s) for @${handle}`,
);
}
addServerLog('success', 'πΏ', `OS image for @${handle} (${(imgData.length / 1048576).toFixed(1)}MB)`);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('X-AC-OS-Requested-Layout', requestedLayout || 'img');
res.setHeader('X-AC-OS-Layout', 'img');
if (fallbackImage) {
res.setHeader('X-AC-OS-Fallback', 'kernel-efi-image');
res.setHeader('X-AC-OS-Fallback-Reason', fallbackReason);
}
let releaseName = 'native';
try {
const relRes = await fetch(`${RELEASES_BASE}/releases.json`);
if (relRes.ok) {
const relData = await relRes.json();
releaseName = relData?.releases?.[0]?.name || releaseName;
}
} catch (_) {}
const coreName = 'AC-' + releaseName;
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
const ts = `${d.getFullYear()}.${p(d.getMonth()+1)}.${p(d.getDate())}.${p(d.getHours())}.${p(d.getMinutes())}.${p(d.getSeconds())}`;
res.setHeader('Content-Disposition', `attachment; filename="@${handle}-os-${bootPiece}-${coreName}-${ts}.img"`);
res.setHeader('Content-Length', imgData.length);
res.end(imgData);
});
// Background base image jobs (build + upload) for FedOS pipeline.
app.get('/os-base-build', (req, res) => {
res.json(getOSBaseBuildsSummary());
});
app.get('/os-base-build/:jobId', (req, res) => {
const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
const job = getOSBaseBuild(req.params.jobId, { includeLogs, tail });
if (!job) return res.status(404).json({ error: 'Job not found' });
return res.json(job);
});
app.get('/os-base-build/:jobId/stream', (req, res) => {
const jobId = req.params.jobId;
const initial = getOSBaseBuild(jobId, { includeLogs: true, tail: 500 });
if (!initial) return res.status(404).json({ error: 'Job not found' });
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
let sentLogs = 0;
const sendEvent = (type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
const sendSnapshot = () => {
const job = getOSBaseBuild(jobId, { includeLogs: true, tail: 2000 });
if (!job) {
sendEvent('error', { error: 'Job not found' });
return false;
}
const logs = Array.isArray(job.logs) ? job.logs : [];
if (logs.length > sentLogs) {
sendEvent('logs', { logs: logs.slice(sentLogs) });
sentLogs = logs.length;
}
sendEvent('status', {
id: job.id,
status: job.status,
stage: job.stage,
message: job.message,
percent: job.percent,
updatedAt: job.updatedAt,
finishedAt: job.finishedAt,
error: job.error,
upload: job.upload,
});
if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
sendEvent('complete', {
status: job.status,
error: job.error,
upload: job.upload,
});
return false;
}
return true;
};
sendEvent('status', {
id: initial.id,
status: initial.status,
stage: initial.stage,
message: initial.message,
percent: initial.percent,
updatedAt: initial.updatedAt,
});
if (Array.isArray(initial.logs) && initial.logs.length > 0) {
sendEvent('logs', { logs: initial.logs });
sentLogs = initial.logs.length;
}
const timer = setInterval(() => {
if (!sendSnapshot()) {
clearInterval(timer);
res.end();
}
}, 1000);
req.on('close', () => {
clearInterval(timer);
});
});
app.post('/os-base-build', requireOSBuildAdmin, async (req, res) => {
const flavor = (req.body?.flavor || 'alpine').toLowerCase();
if (!['alpine', 'fedora'].includes(flavor)) {
return res.status(400).json({ error: "Invalid flavor. Use 'alpine' or 'fedora'." });
}
const defaultSize = flavor === 'alpine' ? 1 : 4;
const imageSizeGB = Math.max(1, Math.min(32, parseInt(req.body?.imageSizeGB, 10) || defaultSize));
const publish = req.body?.publish !== false;
const requestedWorkBase = typeof req.body?.workBase === 'string' ? req.body.workBase.trim() : '';
const workBase = requestedWorkBase || undefined;
try {
const job = await startOSBaseBuild(
{ imageSizeGB, publish, flavor, workBase },
{
onStart: (j) => addServerLog('info', 'πΏ', `OS base build started: ${j.id} (${flavor}, ${imageSizeGB}GiB${workBase ? `, workBase=${workBase}` : ''})`),
onUploadComplete: async (j) => {
addServerLog('success', 'βοΈ', `OS base upload complete: ${j.upload.imageKey}`);
invalidateManifest(flavor);
addServerLog('info', 'πΏ', `OS manifest cache invalidated (${flavor}) after base upload`);
// Purge all cached per-piece builds for this flavor β the new base image
// changes the image layout, so old cached builds are stale.
const purgeResult = await purgeOSBuildCache(flavor);
addServerLog('info', 'ποΈ', `Purged ${purgeResult.deleted} cached ${flavor} build(s) from CDN`);
},
onSuccess: (j) => addServerLog('success', 'πΏ', `OS base build complete: ${j.id} (${flavor})`),
onError: (j) => addServerLog('error', 'β', `OS base build failed: ${j.id} (${j.error})`),
},
);
return res.status(202).json(job);
} catch (error) {
if (error.code === 'OS_BASE_BUSY') {
return res.status(409).json({ error: error.message, activeJobId: error.activeJobId });
}
return res.status(500).json({ error: error.message });
}
});
app.post('/os-base-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
const result = cancelOSBaseBuild(req.params.jobId);
if (!result.ok) {
return res.status(400).json(result);
}
addServerLog('info', 'π', `OS base build cancel requested: ${req.params.jobId}`);
return res.json(result);
});
// ββ Native OTA Build ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Builds fedac/native kernel + uploads vmlinuz to DO Spaces CDN.
// Auth: same OS_BUILD_ADMIN_KEY used for /os-base-build.
// Auto-triggered by native-git-poller.mjs (polls origin/main every 60s).
// Can also be triggered manually via POST with admin key.
app.get('/native-build', (req, res) => {
res.json({ ...getNativeBuildsSummary(), poller: getNativePollerStatus() });
});
app.get('/native-build/:jobId', (req, res) => {
const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
const job = getNativeBuild(req.params.jobId, { includeLogs, tail });
if (!job) return res.status(404).json({ error: 'Job not found' });
return res.json(job);
});
app.get('/native-build/:jobId/stream', (req, res) => {
const jobId = req.params.jobId;
const initial = getNativeBuild(jobId, { includeLogs: true, tail: 500 });
if (!initial) return res.status(404).json({ error: 'Job not found' });
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
let sentLogs = 0;
const sendEvent = (type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
if (Array.isArray(initial.logs) && initial.logs.length > 0) {
sendEvent('logs', { logs: initial.logs });
sentLogs = initial.logs.length;
}
sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent });
const timer = setInterval(() => {
const job = getNativeBuild(jobId, { includeLogs: true, tail: 2000 });
if (!job) { clearInterval(timer); res.end(); return; }
const logs = Array.isArray(job.logs) ? job.logs : [];
if (logs.length > sentLogs) {
sendEvent('logs', { logs: logs.slice(sentLogs) });
sentLogs = logs.length;
}
sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error });
if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
sendEvent('complete', { status: job.status, error: job.error });
clearInterval(timer);
res.end();
}
}, 1000);
req.on('close', () => clearInterval(timer));
});
app.post('/native-build', requireOSBuildAdmin, async (req, res) => {
try {
const job = await startNativeBuild({
ref: req.body?.ref || 'unknown',
changed_paths: req.body?.changed_paths || '',
variant: req.body?.variant || 'c', // "c", "cl", "nix", "both", or "all"
});
addServerLog('info', 'π¨', `Native OTA build started: ${job.id} (ref=${job.ref}, flags=${job.flags.join(' ') || 'none'})`);
return res.status(202).json(job);
} catch (err) {
if (err.code === 'NATIVE_BUILD_BUSY') {
return res.status(409).json({ error: err.message, activeJobId: err.activeJobId });
}
return res.status(500).json({ error: err.message });
}
});
app.post('/native-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
const result = cancelNativeBuild(req.params.jobId);
if (!result.ok) return res.status(400).json(result);
addServerLog('info', 'π', `Native build cancel requested: ${req.params.jobId}`);
return res.json(result);
});
// ββ Papers PDF Build ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Builds all AC paper PDFs from LaTeX sources using xelatex.
// Auth: same OS_BUILD_ADMIN_KEY used for /native-build.
// Auto-triggered by papers-git-poller.mjs (polls origin/main every 60s).
// Can also be triggered manually via POST with admin key.
app.get('/papers-build', (req, res) => {
res.json({ ...getPapersBuildsSummary(), poller: getPapersPollerStatus() });
});
app.get('/papers-build/:jobId', (req, res) => {
const tail = Math.max(0, Math.min(2000, parseInt(req.query.tail, 10) || 200));
const includeLogs = req.query.logs === '1' || req.query.logs === 'true';
const job = getPapersBuild(req.params.jobId, { includeLogs, tail });
if (!job) return res.status(404).json({ error: 'Job not found' });
return res.json(job);
});
app.get('/papers-build/:jobId/stream', (req, res) => {
const jobId = req.params.jobId;
const initial = getPapersBuild(jobId, { includeLogs: true, tail: 500 });
if (!initial) return res.status(404).json({ error: 'Job not found' });
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
let sentLogs = 0;
const sendEvent = (type, data) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
if (typeof res.flush === 'function') res.flush();
};
if (Array.isArray(initial.logs) && initial.logs.length > 0) {
sendEvent('logs', { logs: initial.logs });
sentLogs = initial.logs.length;
}
sendEvent('status', { id: initial.id, status: initial.status, stage: initial.stage, percent: initial.percent });
const timer = setInterval(() => {
const job = getPapersBuild(jobId, { includeLogs: true, tail: 2000 });
if (!job) { clearInterval(timer); res.end(); return; }
const logs = Array.isArray(job.logs) ? job.logs : [];
if (logs.length > sentLogs) {
sendEvent('logs', { logs: logs.slice(sentLogs) });
sentLogs = logs.length;
}
sendEvent('status', { id: job.id, status: job.status, stage: job.stage, percent: job.percent, error: job.error });
if (job.status === 'success' || job.status === 'failed' || job.status === 'cancelled') {
sendEvent('complete', { status: job.status, error: job.error });
clearInterval(timer);
res.end();
}
}, 1000);
req.on('close', () => clearInterval(timer));
});
app.post('/papers-build', requireOSBuildAdmin, async (req, res) => {
try {
const job = await startPapersBuild({
ref: req.body?.ref || 'unknown',
changed_paths: req.body?.changed_paths || '',
});
addServerLog('info', 'π', `Papers PDF build started: ${job.id} (ref=${job.ref})`);
return res.status(202).json(job);
} catch (err) {
if (err.code === 'PAPERS_BUILD_BUSY') {
return res.status(409).json({ error: err.message, activeJobId: err.activeJobId });
}
return res.status(500).json({ error: err.message });
}
});
app.post('/papers-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => {
const result = cancelPapersBuild(req.params.jobId);
if (!result.ok) return res.status(400).json(result);
addServerLog('info', 'π', `Papers build cancel requested: ${req.params.jobId}`);
return res.json(result);
});
// ββ OS Release Upload ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release.
// Auth: AC token (Bearer) verified against Auth0 userinfo.
// Usage: curl -X POST /os-release-upload \
// -H "Authorization: Bearer " \
// -H "X-Build-Name: swift-egret" \
// -H "X-Git-Hash: abc1234" \
// -H "X-Build-Ts: 2026-03-11T12:00" \
// --data-binary @build/vmlinuz
app.post('/os-release-upload', async (req, res) => {
// Auth: verify AC token
const authHeader = req.headers.authorization || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
if (!token) {
return res.status(401).json({ error: 'Missing Authorization: Bearer ' });
}
// Verify token against Auth0
const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN || 'hi.aesthetic.computer';
let user;
try {
const uiRes = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!uiRes.ok) throw new Error(`Auth0 returned ${uiRes.status}`);
user = await uiRes.json();
} catch (err) {
addServerLog('error', 'π', `OS release upload auth failed: ${err.message}`);
return res.status(401).json({ error: 'Invalid or expired token. Run: ac-login' });
}
const userSub = user.sub || 'unknown';
const userName = user.name || user.nickname || user.email || userSub;
addServerLog('info', 'π¦', `OS release upload from ${userName} (${userSub})`);
// Collect binary body
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const vmlinuz = Buffer.concat(chunks);
if (vmlinuz.length < 1_000_000) {
return res.status(400).json({ error: `File too small (${vmlinuz.length} bytes). Expected vmlinuz ~35-45MB.` });
}
// Metadata from headers
const buildName = req.headers['x-build-name'] || `upload-${Date.now()}`;
const gitHash = req.headers['x-git-hash'] || 'unknown';
const buildTs = req.headers['x-build-ts'] || new Date().toISOString().slice(0, 16);
const commitMsg = req.headers['x-commit-msg'] || '';
const version = `${buildName} ${gitHash}-${buildTs}`;
// SHA256
const crypto = await import('crypto');
const sha256 = crypto.createHash('sha256').update(vmlinuz).digest('hex');
// Upload to DO Spaces
const { S3Client, PutObjectCommand, GetObjectCommand } = await import('@aws-sdk/client-s3');
const accessKeyId = process.env.OS_SPACES_KEY || process.env.ART_SPACES_KEY;
const secretAccessKey = process.env.OS_SPACES_SECRET || process.env.ART_SPACES_SECRET;
if (!accessKeyId || !secretAccessKey) {
return res.status(503).json({ error: 'OS Spaces credentials not configured on server.' });
}
const spacesEndpoint = process.env.OS_SPACES_ENDPOINT || 'https://sfo3.digitaloceanspaces.com';
const spacesBucket = process.env.OS_SPACES_BUCKET || 'releases-aesthetic-computer';
const cdnBase = process.env.OS_SPACES_CDN_BASE || `https://${spacesBucket}.sfo3.digitaloceanspaces.com`;
const s3 = new S3Client({
region: process.env.OS_SPACES_REGION || 'us-east-1',
endpoint: spacesEndpoint,
credentials: { accessKeyId, secretAccessKey },
});
const upload = async (key, body, contentType) => {
await s3.send(new PutObjectCommand({
Bucket: spacesBucket,
Key: key,
Body: body,
ContentType: contentType,
ACL: 'public-read',
}));
};
try {
addServerLog('info', 'βοΈ', `Uploading ${buildName}: ${(vmlinuz.length / 1048576).toFixed(1)}MB, sha=${sha256.slice(0, 12)}...`);
// Upload version + sha256 first (canary), then vmlinuz
// Version file: line 1 = version string, line 2 = kernel size in bytes
const versionWithSize = `${version}\n${vmlinuz.length}`;
await upload('os/native-notepat-latest.version', versionWithSize, 'text/plain');
await upload('os/native-notepat-latest.sha256', sha256, 'text/plain');
await upload('os/native-notepat-latest.vmlinuz', vmlinuz, 'application/octet-stream');
// Update releases.json
let releases = { releases: [] };
try {
const existing = await s3.send(new GetObjectCommand({
Bucket: spacesBucket, Key: 'os/releases.json',
}));
const body = await existing.Body.transformToString();
releases = JSON.parse(body);
} catch { /* first release or missing */ }
releases.releases = releases.releases || [];
const userHandle = req.headers['x-handle'] || user.nickname || user.name || userName;
// Mark all existing builds as deprecated
for (const r of releases.releases) r.deprecated = true;
releases.releases.unshift({
version, name: buildName, sha256, size: vmlinuz.length,
git_hash: gitHash, build_ts: buildTs, commit_msg: commitMsg,
user: userSub, handle: userHandle,
url: `${cdnBase}/os/native-notepat-latest.vmlinuz`,
});
releases.releases = releases.releases.slice(0, 50);
releases.latest = version;
releases.latest_name = buildName;
await upload('os/releases.json', JSON.stringify(releases, null, 2), 'application/json');
// Broadcast new build to all connected WebSocket clients (os.mjs pieces)
if (wss && wss.clients) {
const buildMsg = JSON.stringify({ type: 'os:new-build', releases });
wss.clients.forEach(client => {
if (client.readyState === 1) client.send(buildMsg);
});
}
addServerLog('success', 'π', `OS release published: ${buildName} (${gitHash}) by ${userName}`);
return res.json({
ok: true,
name: buildName,
version,
sha256,
size: vmlinuz.length,
url: `${cdnBase}/os/native-notepat-latest.vmlinuz`,
user: userSub,
});
} catch (err) {
addServerLog('error', 'β', `OS release upload failed: ${err.message}`);
return res.status(500).json({ error: `Upload failed: ${err.message}` });
}
});
app.post('/os-invalidate', async (req, res) => {
const purge = req.body?.purge === true;
const clearLocal = req.body?.local === true || req.body?.clearLocal === true;
const flavor = req.body?.flavor;
invalidateManifest(flavor);
addServerLog('info', 'πΏ', `OS base image manifest cache invalidated${flavor ? ` (${flavor})` : ''}`);
let localResult = null;
if (clearLocal) {
localResult = await clearOSBuildLocalCache(flavor);
addServerLog('info', 'π§Ή', `Cleared ${localResult.deleted} local base-image cache file(s)${flavor ? ` (${flavor})` : ''}`);
}
if (purge) {
const purgeResult = await purgeOSBuildCache(flavor);
addServerLog('info', 'ποΈ', `Purged ${purgeResult.deleted} cached build(s) from CDN${flavor ? ` (${flavor})` : ''}`);
return res.json({
ok: true,
message: clearLocal
? 'Manifest invalidated, local base cache cleared, and CDN build cache purged.'
: 'Manifest + CDN build cache purged.',
purged: purgeResult.deleted,
localCleared: localResult?.deleted || 0,
localDirs: localResult?.dirs || [],
});
}
if (clearLocal) {
return res.json({
ok: true,
message: 'Manifest invalidated and local base-image cache cleared.',
localCleared: localResult.deleted,
localDirs: localResult.dirs,
});
}
res.json({ ok: true, message: 'Manifest cache invalidated β next build will re-fetch.' });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Error handler
app.use((err, req, res, next) => {
console.error('β Server error:', err);
res.status(500).json({ error: 'Internal server error', message: err.message });
});
// Create server and WebSocket
let server;
if (dev) {
// Load local SSL certs in development mode
const httpsOptions = {
key: fs.readFileSync('../ssl-dev/localhost-key.pem'),
cert: fs.readFileSync('../ssl-dev/localhost.pem'),
};
server = https.createServer(httpsOptions, app);
server.listen(PORT, () => {
console.log(`π₯ Oven server running on https://localhost:${PORT} (dev mode)`);
});
} else {
// Production - plain HTTP (Caddy handles SSL)
server = http.createServer(app);
server.listen(PORT, () => {
console.log(`π₯ Oven server running on http://localhost:${PORT}`);
addServerLog('success', 'π₯', `Oven server ready (v${GIT_VERSION.slice(0,8)})`);
// Pre-warm Puppeteer browser so first keep thumbnail bake is fast
setTimeout(() => {
addServerLog('info', 'π', 'Pre-warming grab browser...');
prewarmGrabBrowser().then(() => {
addServerLog('success', 'π', 'Browser pre-warm complete');
}).catch(err => {
addServerLog('error', 'β οΈ', `Browser pre-warm failed: ${err.message}`);
});
}, 5000); // Give server 5s to settle first
// Start background OG image regeneration after a short delay
setTimeout(() => {
addServerLog('info', 'πΌοΈ', 'Starting background OG regeneration...');
regenerateOGImagesBackground().then(() => {
addServerLog('success', 'πΌοΈ', 'OG images ready for social sharing');
}).catch(err => {
addServerLog('error', 'β', `OG regen failed: ${err.message}`);
});
}, 10000); // Wait 10s for server to fully initialize
// Schedule periodic regeneration (every 6 hours)
setInterval(() => {
addServerLog('info', 'πΌοΈ', 'Scheduled OG regeneration starting...');
regenerateOGImagesBackground().catch(err => {
addServerLog('error', 'β', `Scheduled OG regen failed: ${err.message}`);
});
}, 6 * 60 * 60 * 1000); // 6 hours
// Start native OTA git poller β watches for fedac/native/ changes
startNativeGitPoller({ startNativeBuild, addServerLog });
// Start papers PDF git poller β watches for papers/ changes
startPapersGitPoller({ startPapersBuild, addServerLog });
});
}
// WebSocket server
wss = new WebSocketServer({ server, path: '/ws' });
// Wire up grabber notifications to broadcast to all WebSocket clients
setNotifyCallback(() => {
wss.clients.forEach((client) => {
if (client.readyState === 1) { // OPEN
client.send(JSON.stringify({
version: GIT_VERSION,
serverStartTime: SERVER_START_TIME,
uptime: Date.now() - SERVER_START_TIME,
incoming: Array.from(getIncomingBakes().values()),
active: Array.from(getActiveBakes().values()),
recent: getRecentBakes(),
grabs: {
active: getActiveGrabs(),
recent: getRecentGrabs(),
queue: getQueueStatus(),
ipfsThumbs: getAllLatestIPFSUploads()
},
grabProgress: getAllProgress(),
concurrency: getConcurrencyStatus(),
osBaseBuilds: getOSBaseBuildsSummary(),
}));
}
});
});
// Wire up grabber log messages to broadcast to clients
setLogCallback((type, icon, msg) => {
addServerLog(type, icon, msg);
});
// Wire up native build progress to broadcast to all WebSocket clients
onNativeBuildProgress((snapshot) => {
if (wss && wss.clients) {
const msg = JSON.stringify({ type: 'os:build-progress', build: snapshot });
wss.clients.forEach(client => {
if (client.readyState === 1) client.send(msg);
});
}
});
wss.on('connection', async (ws) => {
console.log('π‘ WebSocket client connected');
addServerLog('info', 'π‘', 'Dashboard client connected');
// Clean up stale bakes before sending initial state
await cleanupStaleBakes();
// Send initial state with recent logs
ws.send(JSON.stringify({
version: GIT_VERSION,
serverStartTime: SERVER_START_TIME,
uptime: Date.now() - SERVER_START_TIME,
incoming: Array.from(getIncomingBakes().values()),
active: Array.from(getActiveBakes().values()),
recent: getRecentBakes(),
grabs: {
active: getActiveGrabs(),
recent: getRecentGrabs(),
queue: getQueueStatus(),
ipfsThumbs: getAllLatestIPFSUploads()
},
grabProgress: getAllProgress(),
concurrency: getConcurrencyStatus(),
osBaseBuilds: getOSBaseBuildsSummary(),
frozen: getFrozenPieces(),
recentLogs: activityLogBuffer.slice(0, 50) // Send last 50 log entries
}));
// Subscribe to updates
const unsubscribe = subscribeToUpdates((update) => {
if (ws.readyState === 1) { // OPEN
ws.send(JSON.stringify({
version: GIT_VERSION,
serverStartTime: SERVER_START_TIME,
uptime: Date.now() - SERVER_START_TIME,
incoming: Array.from(getIncomingBakes().values()),
active: Array.from(getActiveBakes().values()),
recent: getRecentBakes(),
grabs: {
active: getActiveGrabs(),
recent: getRecentGrabs(),
queue: getQueueStatus(),
ipfsThumbs: getAllLatestIPFSUploads()
},
grabProgress: getAllProgress(),
concurrency: getConcurrencyStatus(),
osBaseBuilds: getOSBaseBuildsSummary(),
frozen: getFrozenPieces()
}));
}
});
ws.on('close', () => {
console.log('π‘ WebSocket client disconnected');
unsubscribe();
});
});
// Graceful shutdown handling
async function shutdown(signal) {
console.log(`\nπ Received ${signal}, shutting down gracefully...`);
// Close WebSocket connections
wss.clients.forEach(ws => ws.close());
// Close HTTP server
server.close(() => {
console.log('β
HTTP server closed');
});
// Close browser if open
try {
const { closeBrowser } = await import('./grabber.mjs');
await closeBrowser?.();
console.log('β
Browser closed');
} catch (e) {
// Browser close is optional
}
// Exit after a short delay
setTimeout(() => {
console.log('π Goodbye!');
process.exit(0);
}, 500);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));