import express from 'express'; import cors from 'cors'; import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 80; const DATA_DIR = process.env.DATA_DIR || path.resolve(__dirname, '../data'); const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); const DESK_FILE = path.join(DATA_DIR, 'desk.json'); // Ensure storage directories exist if (!fs.existsSync(DATA_DIR)) { fs.mkdirSync(DATA_DIR, { recursive: true }); } if (!fs.existsSync(UPLOADS_DIR)) { fs.mkdirSync(UPLOADS_DIR, { recursive: true }); } // Middleware app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Configure Multer for file/camera photo uploads const storage = multer.diskStorage({ destination: (_req, _file, cb) => { cb(null, UPLOADS_DIR); }, filename: (_req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; const cleanExt = ['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext) ? ext : '.jpg'; const uniqueName = `glimpse_${Date.now()}_${Math.random().toString(36).substring(2, 8)}${cleanExt}`; cb(null, uniqueName); } }); const upload = multer({ storage, limits: { fileSize: 25 * 1024 * 1024 } // 25 MB max photo size }); // SSE Client Connection Pool const sseClients = new Set(); function broadcastEvent(eventData) { const payload = `data: ${JSON.stringify(eventData)}\n\n`; for (const client of sseClients) { try { client.res.write(payload); } catch { sseClients.delete(client); } } } // Keep-alive heartbeat for SSE every 20 seconds setInterval(() => { for (const client of sseClients) { try { client.res.write(':keep-alive\n\n'); } catch { sseClients.delete(client); } } }, 20000); // Helper to safely read desk state function readDeskState() { try { if (fs.existsSync(DESK_FILE)) { const raw = fs.readFileSync(DESK_FILE, 'utf-8'); return JSON.parse(raw); } } catch (err) { console.error('Error reading desk state:', err); } return null; } // Helper to safely write desk state (atomic write via temp file) function writeDeskState(state) { const tempFile = `${DESK_FILE}.tmp.${Date.now()}`; try { fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), 'utf-8'); fs.renameSync(tempFile, DESK_FILE); return true; } catch (err) { console.error('Error writing desk state:', err); try { if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile); } catch { // ignore } return false; } } // --- API Endpoints --- // Healthcheck app.get('/api/health', (_req, res) => { res.json({ status: 'healthy', uptime: process.uptime(), timestamp: new Date().toISOString() }); }); // SSE Stream for Real-time Cross-Device Sync app.get('/api/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // Disable Traefik/Nginx buffering res.flushHeaders?.(); const client = { id: Math.random().toString(36).substring(2), res }; sseClients.add(client); res.write(`data: ${JSON.stringify({ type: 'connected', clientId: client.id })}\n\n`); req.on('close', () => { sseClients.delete(client); }); }); // Get current desk state app.get('/api/desk', (_req, res) => { const desk = readDeskState(); if (desk) { res.json({ success: true, desk }); } else { res.json({ success: true, desk: null, initialized: false }); } }); // Save desk state app.post('/api/desk', (req, res) => { const { cards, props, strokes, settings, originClientId } = req.body; if (!Array.isArray(cards)) { return res.status(400).json({ success: false, error: 'Invalid desk state: cards required' }); } const newState = { cards, props: Array.isArray(props) ? props : [], strokes: Array.isArray(strokes) ? strokes : [], settings: settings || {}, updatedAt: Date.now() }; const saved = writeDeskState(newState); if (!saved) { return res.status(500).json({ success: false, error: 'Failed to persist desk state' }); } // Broadcast to all other devices in real-time broadcastEvent({ type: 'desk_updated', desk: newState, originClientId, timestamp: newState.updatedAt }); res.json({ success: true, updatedAt: newState.updatedAt }); }); // Upload photo/file endpoint app.post('/api/upload', upload.single('file'), (req, res) => { if (!req.file) { return res.status(400).json({ success: false, error: 'No file uploaded' }); } const publicUrl = `/uploads/${req.file.filename}`; res.json({ success: true, url: publicUrl, filename: req.file.filename, size: req.file.size }); }); // Serve uploaded photos/media app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d', immutable: true })); // Serve static frontend from dist const distPath = path.resolve(__dirname, '../dist'); if (fs.existsSync(distPath)) { app.use(express.static(distPath)); // SPA Fallback: serve index.html for unknown routes app.use((req, res) => { if (req.path.startsWith('/api') || req.path.startsWith('/uploads')) { return res.status(404).json({ error: 'Not found' }); } res.sendFile(path.join(distPath, 'index.html')); }); } // Start Server app.listen(PORT, '0.0.0.0', () => { console.log(`[Glimpse Backend] Server listening on http://0.0.0.0:${PORT}`); console.log(`[Glimpse Backend] Storage path: ${DATA_DIR}`); });