Add screenshot server

This commit is contained in:
Andras Schmelczer 2026-02-03 19:21:51 +00:00
parent c6f869e95f
commit 25865acd44
8 changed files with 1247 additions and 0 deletions

View file

@ -0,0 +1,88 @@
import { createHash } from 'crypto';
import { existsSync, mkdirSync, statSync, createReadStream, writeFileSync } from 'fs';
import { join } from 'path';
import type { Readable } from 'stream';
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
export class ScreenshotCache {
private dir: string;
constructor(dir: string) {
this.dir = dir;
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
/**
* Build a cache key by quantizing view params and hashing.
* - lat/lng quantized to 2 decimal places
* - zoom quantized to integer
* - filters sorted alphabetically
*/
buildKey(params: Record<string, string>): string {
const normalized: Record<string, string> = {};
// Parse and quantize the view param (lat,lng,zoom)
if (params.v) {
const parts = params.v.split(',');
if (parts.length === 3) {
const lat = parseFloat(parts[0]).toFixed(2);
const lng = parseFloat(parts[1]).toFixed(2);
const zoom = Math.round(parseFloat(parts[2])).toString();
normalized.v = `${lat},${lng},${zoom}`;
} else {
normalized.v = params.v;
}
}
// Sort filters
if (params.f) {
const segments = params.f.split(',').sort();
normalized.f = segments.join(',');
}
if (params.poi) {
const cats = params.poi.split(',').sort();
normalized.poi = cats.join(',');
}
if (params.tab) {
normalized.tab = params.tab;
}
const input = JSON.stringify(normalized);
const hash = createHash('sha256').update(input).digest('hex').substring(0, 16);
return hash;
}
getPath(key: string): string {
return join(this.dir, `${key}.png`);
}
/**
* Returns a readable stream if a fresh cached file exists, null otherwise.
*/
get(key: string): Readable | null {
const path = this.getPath(key);
if (!existsSync(path)) return null;
try {
const stat = statSync(path);
const age = Date.now() - stat.mtimeMs;
if (age > MAX_AGE_MS) return null;
return createReadStream(path);
} catch {
return null;
}
}
/**
* Write screenshot bytes to cache.
*/
set(key: string, data: Buffer): void {
const path = this.getPath(key);
writeFileSync(path, data);
}
}

View file

@ -0,0 +1,86 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
const VIEWPORT = { width: 1200, height: 630 };
const NAVIGATION_TIMEOUT = 15_000;
const TILE_BUFFER_MS = 500;
const MAX_CONCURRENT = 2;
let browser: Browser | null = null;
let concurrency = 0;
const queue: Array<{ resolve: () => void }> = [];
async function ensureBrowser(): Promise<Browser> {
if (!browser || !browser.isConnected()) {
browser = await chromium.launch({
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--use-gl=angle',
'--use-angle=swiftshader',
],
});
}
return browser;
}
async function acquireSlot(): Promise<void> {
if (concurrency < MAX_CONCURRENT) {
concurrency++;
return;
}
return new Promise<void>((resolve) => {
queue.push({ resolve });
});
}
function releaseSlot(): void {
concurrency--;
const next = queue.shift();
if (next) {
concurrency++;
next.resolve();
}
}
export async function takeScreenshot(url: string): Promise<Buffer> {
await acquireSlot();
let context: BrowserContext | null = null;
try {
const instance = await ensureBrowser();
context = await instance.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 1,
});
const page = await context.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: NAVIGATION_TIMEOUT });
// Wait for the frontend to signal readiness
try {
await page.waitForFunction('window.__og_ready === true', {
timeout: NAVIGATION_TIMEOUT,
});
} catch {
// Proceed anyway — partial screenshot is better than nothing
}
// Extra buffer for map tiles to finish rendering
await page.waitForTimeout(TILE_BUFFER_MS);
const screenshot = await page.screenshot({ type: 'png' });
return Buffer.from(screenshot);
} finally {
if (context) {
await context.close().catch(() => {});
}
releaseSlot();
}
}
export async function closeBrowser(): Promise<void> {
if (browser) {
await browser.close().catch(() => {});
browser = null;
}
}

View file

@ -0,0 +1,73 @@
import express from 'express';
import { ScreenshotCache } from './cache.js';
import { takeScreenshot, closeBrowser } from './screenshot.js';
const PORT = parseInt(process.env.PORT || '8002', 10);
const NARROWIT_URL = process.env.NARROWIT_URL || 'http://localhost:8001';
const CACHE_DIR = process.env.CACHE_DIR || '/cache';
const cache = new ScreenshotCache(CACHE_DIR);
const app = express();
app.get('/health', (_req, res) => {
res.status(200).send('ok');
});
app.get('/screenshot', async (req, res) => {
try {
const params: Record<string, string> = {};
for (const key of ['v', 'f', 'poi', 'tab']) {
const val = req.query[key];
if (typeof val === 'string' && val) {
params[key] = val;
}
}
const cacheKey = cache.buildKey(params);
// Check cache first
const cached = cache.get(cacheKey);
if (cached) {
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('X-Cache', 'HIT');
cached.pipe(res);
return;
}
// Build the URL for the frontend in screenshot mode
const qs = new URLSearchParams(params);
qs.set('screenshot', '1');
const url = `${NARROWIT_URL}/?${qs}`;
console.log(`Taking screenshot: ${url}`);
const png = await takeScreenshot(url);
// Cache it
cache.set(cacheKey, png);
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400');
res.setHeader('X-Cache', 'MISS');
res.send(png);
} catch (err) {
console.error('Screenshot failed:', err);
res.status(500).json({ error: 'Screenshot failed' });
}
});
const server = app.listen(PORT, () => {
console.log(`OG screenshot service listening on port ${PORT}`);
console.log(` NARROWIT_URL: ${NARROWIT_URL}`);
console.log(` CACHE_DIR: ${CACHE_DIR}`);
});
// Graceful shutdown
for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, async () => {
console.log(`Received ${signal}, shutting down...`);
server.close();
await closeBrowser();
process.exit(0);
});
}