feat: Implement ETag-based conditional caching for API and HTML, add immutable caching for images, and introduce concurrency control with resizing for picture syncing.
This commit is contained in:
@@ -61,12 +61,11 @@ class CategorySyncer extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await this._syncFromDb();
|
await this._syncFromDb();
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
console.log(`✅ Sync completed successfully in ${duration}ms.`);
|
|
||||||
|
|
||||||
// Log next sync time
|
// Log completion and next sync time
|
||||||
const syncInterval = parseInt(process.env.SYNC_INTERVAL_MS) || 60000;
|
const syncInterval = parseInt(process.env.SYNC_INTERVAL_MS) || 60000;
|
||||||
const minutes = Math.round(syncInterval / 60000);
|
const minutes = Math.round(syncInterval / 60000);
|
||||||
console.log(`⏰ Next sync in ${minutes} minute${minutes !== 1 ? 's' : ''}`);
|
console.log(`✅ Sync completed in ${duration}ms. Next sync in ${minutes} minute${minutes !== 1 ? 's' : ''}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Sync failed:', err);
|
console.error('❌ Sync failed:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -43,4 +43,4 @@ process.on('SIGINT', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start Express server
|
// Start Express server
|
||||||
startServer();
|
startServer(categorySyncer);
|
||||||
|
|||||||
@@ -9,10 +9,48 @@ class PictureSyncer {
|
|||||||
return PictureSyncer.instance;
|
return PictureSyncer.instance;
|
||||||
}
|
}
|
||||||
this.cacheBaseDir = process.env.CACHE_LOCATION || '.';
|
this.cacheBaseDir = process.env.CACHE_LOCATION || '.';
|
||||||
|
|
||||||
|
// Track syncing state per group
|
||||||
|
this.isSyncing = new Map(); // groupName -> boolean
|
||||||
|
this.queuedSyncs = new Map(); // groupName -> { imageIds, groupName }
|
||||||
|
|
||||||
PictureSyncer.instance = this;
|
PictureSyncer.instance = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
async syncImages(imageIds, groupName) {
|
async syncImages(imageIds, groupName) {
|
||||||
|
// Check if already syncing this group
|
||||||
|
if (this.isSyncing.get(groupName)) {
|
||||||
|
if (this.queuedSyncs.has(groupName)) {
|
||||||
|
console.log(`🚫 Image sync for '${groupName}' already in progress and queued. Ignoring.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`⏳ Image sync for '${groupName}' already in progress. Queuing.`);
|
||||||
|
this.queuedSyncs.set(groupName, { imageIds, groupName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._doSync(imageIds, groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _doSync(imageIds, groupName) {
|
||||||
|
this.isSyncing.set(groupName, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._performSync(imageIds, groupName);
|
||||||
|
} finally {
|
||||||
|
this.isSyncing.set(groupName, false);
|
||||||
|
|
||||||
|
// Process queued sync for this group if any
|
||||||
|
if (this.queuedSyncs.has(groupName)) {
|
||||||
|
console.log(`🔄 Processing queued image sync for '${groupName}'...`);
|
||||||
|
const queued = this.queuedSyncs.get(groupName);
|
||||||
|
this.queuedSyncs.delete(groupName);
|
||||||
|
setImmediate(() => this.syncImages(queued.imageIds, queued.groupName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _performSync(imageIds, groupName) {
|
||||||
const groupDir = path.join(this.cacheBaseDir, 'img', groupName);
|
const groupDir = path.join(this.cacheBaseDir, 'img', groupName);
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -74,8 +112,12 @@ class PictureSyncer {
|
|||||||
for (const record of result.recordset) {
|
for (const record of result.recordset) {
|
||||||
if (record.bBild) {
|
if (record.bBild) {
|
||||||
const filePath = path.join(dir, `${record.kBild}.avif`);
|
const filePath = path.join(dir, `${record.kBild}.avif`);
|
||||||
// Convert to AVIF using sharp
|
// Resize to 130x130 and convert to AVIF using sharp
|
||||||
await sharp(record.bBild)
|
await sharp(record.bBild)
|
||||||
|
.resize(130, 130, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
.avif({ quality: 80 })
|
.avif({ quality: 80 })
|
||||||
.toFile(filePath);
|
.toFile(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
95
server.js
95
server.js
@@ -6,27 +6,86 @@ import fs from 'fs/promises';
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
export function startServer() {
|
export function startServer(categorySyncer) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.SERVER_PORT || 3000;
|
const PORT = process.env.SERVER_PORT || 3000;
|
||||||
const HOST = process.env.SERVER_HOST || '0.0.0.0';
|
const HOST = process.env.SERVER_HOST || '0.0.0.0';
|
||||||
const CACHE_DIR = process.env.CACHE_LOCATION || './cache';
|
const CACHE_DIR = process.env.CACHE_LOCATION || './cache';
|
||||||
|
|
||||||
// Serve category tree JSON
|
// Cache for ETags and data
|
||||||
app.get('/api/categories', async (req, res) => {
|
const cache = {
|
||||||
|
categories: { etag: null, data: null },
|
||||||
|
html: { etag: null, data: null }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to calculate ETag for categories
|
||||||
|
async function updateCategoriesCache() {
|
||||||
try {
|
try {
|
||||||
const treePath = path.join(CACHE_DIR, 'category_tree.json');
|
const treePath = path.join(CACHE_DIR, 'category_tree.json');
|
||||||
const data = await fs.readFile(treePath, 'utf-8');
|
const data = await fs.readFile(treePath, 'utf-8');
|
||||||
res.json(JSON.parse(data));
|
const crypto = await import('crypto');
|
||||||
|
cache.categories.etag = crypto.createHash('md5').update(data).digest('hex');
|
||||||
|
cache.categories.data = data;
|
||||||
|
} catch (err) {
|
||||||
|
// Silently skip if file doesn't exist yet (will be created on first sync)
|
||||||
|
if (err.code !== 'ENOENT') {
|
||||||
|
console.error('Error updating categories cache:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to calculate ETag for HTML
|
||||||
|
async function updateHtmlCache() {
|
||||||
|
try {
|
||||||
|
const htmlPath = path.join(__dirname, 'index.html');
|
||||||
|
const data = await fs.readFile(htmlPath, 'utf-8');
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
cache.html.etag = crypto.createHash('md5').update(data).digest('hex');
|
||||||
|
cache.html.data = data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating HTML cache:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize caches on startup
|
||||||
|
updateHtmlCache();
|
||||||
|
updateCategoriesCache();
|
||||||
|
|
||||||
|
// Update categories cache when sync completes
|
||||||
|
if (categorySyncer) {
|
||||||
|
categorySyncer.on('synced', ({ changed }) => {
|
||||||
|
if (changed) {
|
||||||
|
updateCategoriesCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve category tree JSON (with ETag for conditional caching)
|
||||||
|
app.get('/api/categories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if client has cached version
|
||||||
|
if (req.headers['if-none-match'] === cache.categories.etag) {
|
||||||
|
return res.status(304).end(); // Not Modified
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cache headers with ETag
|
||||||
|
res.set('Cache-Control', 'public, max-age=60, must-revalidate');
|
||||||
|
res.set('ETag', cache.categories.etag);
|
||||||
|
|
||||||
|
res.json(JSON.parse(cache.categories.data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: 'Failed to load category tree' });
|
res.status(500).json({ error: 'Failed to load category tree' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve category images
|
// Serve category images (long cache - images rarely change)
|
||||||
app.get('/img/cat/:id.avif', (req, res) => {
|
app.get('/img/cat/:id.avif', (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const imagePath = path.join(CACHE_DIR, 'img', 'categories', `${id}.avif`);
|
const imagePath = path.join(CACHE_DIR, 'img', 'categories', `${id}.avif`);
|
||||||
|
|
||||||
|
// Cache images for 1 year (immutable content)
|
||||||
|
res.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
|
||||||
res.sendFile(path.resolve(imagePath), (err) => {
|
res.sendFile(path.resolve(imagePath), (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.status(404).send('Image not found');
|
res.status(404).send('Image not found');
|
||||||
@@ -34,16 +93,24 @@ export function startServer() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve index.html
|
// Serve index.html (with ETag for conditional caching)
|
||||||
app.get('/', (req, res) => {
|
app.get('/', async (req, res) => {
|
||||||
const htmlPath = path.join(__dirname, 'index.html');
|
try {
|
||||||
console.log('Attempting to serve:', htmlPath);
|
// Check if client has cached version
|
||||||
res.sendFile(htmlPath, (err) => {
|
if (req.headers['if-none-match'] === cache.html.etag) {
|
||||||
if (err) {
|
return res.status(304).end(); // Not Modified
|
||||||
console.error('Error serving index.html:', err);
|
|
||||||
res.status(500).send('Error loading page');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// Set cache headers with ETag
|
||||||
|
res.set('Cache-Control', 'public, max-age=300, must-revalidate');
|
||||||
|
res.set('ETag', cache.html.etag);
|
||||||
|
res.set('Content-Type', 'text/html');
|
||||||
|
|
||||||
|
res.send(cache.html.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error serving index.html:', err);
|
||||||
|
res.status(500).send('Error loading page');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user