Implement persistent backend with Docker storage, photo uploads, and real-time SSE sync
This commit is contained in:
1 parent
157fc83a47
commit
7f4fed3c8c
10 files changed
+1583
-75
No files matched your search
+214
@@ -0,0 +1,214 @@
|
||||
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.get('*', (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}`);
|
||||
});
|
||||
Reference in new issue
Block a user