image cache on cloudflare r2

feat: start storing time series data

dunkirk.sh e1b806f5 3b880555

verified
+1
.gitignore
··· 5 5 .env 6 6 *.log 7 7 bun.lock 8 + data/
data/stats.db

This is a binary file and will not be displayed.

data/stats.db-shm

This is a binary file and will not be displayed.

data/stats.db-wal

This is a binary file and will not be displayed.

+3
package.json
··· 3 3 "module": "src/index.ts", 4 4 "type": "module", 5 5 "private": true, 6 + "scripts": { 7 + "dev": "bun run --watch src/index.ts" 8 + }, 6 9 "devDependencies": { 7 10 "@types/bun": "latest" 8 11 },
+3 -2
src/index.ts
··· 1 1 import sharp from "sharp"; 2 2 import { nanoid } from "nanoid"; 3 + import { recordHit } from "./stats"; 3 4 4 5 // Configuration from env 5 6 const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL!; ··· 82 83 return new Response("Not found", { status: 404 }); 83 84 } 84 85 85 - // Skip exists check - redirect directly, R2 will handle 404s 86 - // This saves ~50-100ms per request 86 + recordHit(imageKey); 87 + 87 88 const r2PublicUrl = `${R2_PUBLIC_URL}/${imageKey}`; 88 89 return Response.redirect(r2PublicUrl, 307); 89 90 },
+64
src/stats.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { mkdirSync, existsSync } from "node:fs"; 3 + import { dirname } from "node:path"; 4 + 5 + const DB_PATH = process.env.STATS_DB_PATH || "./data/stats.db"; 6 + 7 + const dbDir = dirname(DB_PATH); 8 + if (!existsSync(dbDir)) { 9 + mkdirSync(dbDir, { recursive: true }); 10 + } 11 + 12 + const db = new Database(DB_PATH, { create: true }); 13 + 14 + db.exec("PRAGMA journal_mode = WAL"); 15 + db.exec("PRAGMA synchronous = NORMAL"); // Safe with WAL, faster than FULL 16 + db.exec("PRAGMA busy_timeout = 5000"); 17 + 18 + db.exec(` 19 + CREATE TABLE IF NOT EXISTS image_stats ( 20 + image_key TEXT NOT NULL, 21 + bucket_hour INTEGER NOT NULL, 22 + hits INTEGER NOT NULL DEFAULT 1, 23 + PRIMARY KEY (image_key, bucket_hour) 24 + ) WITHOUT ROWID 25 + `); 26 + 27 + db.exec(` 28 + CREATE INDEX IF NOT EXISTS idx_stats_time 29 + ON image_stats(bucket_hour, image_key) 30 + `); 31 + 32 + const incrementStmt = db.prepare(` 33 + INSERT INTO image_stats (image_key, bucket_hour, hits) 34 + VALUES (?1, ?2, 1) 35 + ON CONFLICT(image_key, bucket_hour) DO UPDATE SET hits = hits + 1 36 + `); 37 + 38 + export function recordHit(imageKey: string): void { 39 + const now = Math.floor(Date.now() / 1000); 40 + const bucketHour = now - (now % 3600); 41 + incrementStmt.run(imageKey, bucketHour); 42 + } 43 + 44 + export function getStats(imageKey: string, sinceDays: number = 30) { 45 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 46 + return db 47 + .prepare( 48 + `SELECT bucket_hour, hits FROM image_stats 49 + WHERE image_key = ? AND bucket_hour >= ? 50 + ORDER BY bucket_hour` 51 + ) 52 + .all(imageKey, since); 53 + } 54 + 55 + export function getTopImages(sinceDays: number = 7, limit: number = 10) { 56 + const since = Math.floor(Date.now() / 1000) - sinceDays * 86400; 57 + return db 58 + .prepare( 59 + `SELECT image_key, SUM(hits) as total 60 + FROM image_stats WHERE bucket_hour >= ? 61 + GROUP BY image_key ORDER BY total DESC LIMIT ?` 62 + ) 63 + .all(since, limit); 64 + }