Skip to content

Commit d965397

Browse files
adiologydevclaude
andcommitted
feat: add ALLOWED_ORIGINS, hybrid cache, and TypeBox config
CORS configuration: - Add ALLOWED_ORIGINS env var for controlling CORS origins - Separate from ALLOWED_DOMAINS which controls source image URLs - Both default to allowing all when empty Hybrid cache mode: - Add "hybrid" cache mode combining memory (L1) and disk (L2) - Memory provides fast access, disk provides persistence - Cache hits from disk are promoted to memory - Writes go to both caches simultaneously TypeBox configuration: - Refactor config.ts to use TypeBox schemas for validation - Validate configuration on startup with helpful error messages - Type-safe configuration with proper defaults 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ca8f523 commit d965397

4 files changed

Lines changed: 155 additions & 46 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,14 +231,15 @@ Configure via environment variables:
231231
PORT=3000
232232

233233
# Caching
234-
CACHE_MODE=disk # disk, memory, or none
234+
CACHE_MODE=disk # disk, memory, hybrid, or none
235235
CACHE_DIR=./cache
236236
CACHE_TTL=86400 # 24 hours
237237
MAX_CACHE_SIZE=1073741824 # 1GB
238238
MAX_MEMORY_CACHE_ITEMS=1000
239239

240240
# Security
241-
ALLOWED_DOMAINS= # Comma-separated allowlist (empty = allow all)
241+
ALLOWED_DOMAINS= # Comma-separated source image domains (empty = allow all)
242+
ALLOWED_ORIGINS= # Comma-separated CORS origins (empty = allow all)
242243
MAX_IMAGE_SIZE=10485760 # 10MB
243244
REQUEST_TIMEOUT=30000 # 30 seconds
244245

src/config.ts

Lines changed: 103 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,122 @@
1-
export type CacheMode = "disk" | "memory" | "none";
1+
import { type Static, Type } from "@sinclair/typebox";
2+
import { Value } from "@sinclair/typebox/value";
23

3-
function parseCacheMode(value: string | undefined): CacheMode {
4-
const mode = (value || "disk").toLowerCase();
5-
if (mode === "memory" || mode === "none" || mode === "disk") {
6-
return mode;
7-
}
8-
return "disk";
9-
}
4+
// Schema definitions
5+
const CacheModeSchema = Type.Union([
6+
Type.Literal("disk"),
7+
Type.Literal("memory"),
8+
Type.Literal("hybrid"),
9+
Type.Literal("none"),
10+
]);
1011

11-
export const config = {
12-
port: parseInt(process.env.PORT || "3000", 10),
12+
const ImageFormatSchema = Type.Union([
13+
Type.Literal("webp"),
14+
Type.Literal("avif"),
15+
Type.Literal("png"),
16+
Type.Literal("jpg"),
17+
Type.Literal("jpeg"),
18+
Type.Literal("gif"),
19+
]);
20+
21+
const ConfigSchema = Type.Object({
22+
// Server
23+
port: Type.Number({ default: 3000, minimum: 1, maximum: 65535 }),
1324

1425
// Cache settings
15-
cacheMode: parseCacheMode(process.env.CACHE_MODE),
16-
cacheDir: process.env.CACHE_DIR || "./cache",
17-
cacheTTL: parseInt(process.env.CACHE_TTL || "86400", 10), // 24 hours in seconds
18-
maxCacheSize: parseInt(process.env.MAX_CACHE_SIZE || "1073741824", 10), // 1GB
19-
maxMemoryCacheItems: parseInt(
20-
process.env.MAX_MEMORY_CACHE_ITEMS || "1000",
21-
10,
22-
), // Max items in memory cache
26+
cacheMode: Type.Optional(CacheModeSchema),
27+
cacheDir: Type.String({ default: "./cache" }),
28+
cacheTTL: Type.Number({ default: 86400, minimum: 0 }), // 24 hours in seconds
29+
maxCacheSize: Type.Number({ default: 1073741824, minimum: 0 }), // 1GB
30+
maxMemoryCacheItems: Type.Number({ default: 1000, minimum: 1 }),
2331

2432
// Security
25-
allowedDomains: (process.env.ALLOWED_DOMAINS || "")
26-
.split(",")
27-
.map((d) => d.trim().toLowerCase())
28-
.filter(Boolean),
29-
blockedDomains: ["localhost", "127.0.0.1", "0.0.0.0", "::1"],
30-
maxImageSize: parseInt(process.env.MAX_IMAGE_SIZE || "10485760", 10), // 10MB
31-
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || "30000", 10), // 30s
33+
allowedDomains: Type.Array(Type.String(), { default: [] }),
34+
allowedOrigins: Type.Array(Type.String(), { default: [] }),
35+
blockedDomains: Type.Array(Type.String(), {
36+
default: ["localhost", "127.0.0.1", "0.0.0.0", "::1"],
37+
}),
38+
maxImageSize: Type.Number({ default: 10485760, minimum: 0 }), // 10MB
39+
requestTimeout: Type.Number({ default: 30000, minimum: 0 }), // 30s
3240

3341
// Image defaults
34-
defaultQuality: 80,
35-
defaultFormat: "webp" as const,
36-
maxWidth: 4096,
37-
maxHeight: 4096,
42+
defaultQuality: Type.Number({ default: 80, minimum: 1, maximum: 100 }),
43+
defaultFormat: Type.Optional(ImageFormatSchema),
44+
maxWidth: Type.Number({ default: 4096, minimum: 1 }),
45+
maxHeight: Type.Number({ default: 4096, minimum: 1 }),
3846

3947
// Cache headers
40-
browserCacheTTL: parseInt(process.env.BROWSER_CACHE_TTL || "31536000", 10), // 1 year
48+
browserCacheTTL: Type.Number({ default: 31536000, minimum: 0 }), // 1 year
4149

4250
// OG image defaults
51+
ogDefaultWidth: Type.Number({ default: 1200, minimum: 1 }),
52+
ogDefaultHeight: Type.Number({ default: 630, minimum: 1 }),
53+
ogDefaultBg: Type.String({ default: "1a1a2e" }),
54+
ogDefaultFg: Type.String({ default: "ffffff" }),
55+
56+
// Custom templates directory
57+
templatesDir: Type.String({ default: "./templates" }),
58+
});
59+
60+
// Helper to parse comma-separated list
61+
function parseList(value: string | undefined): string[] {
62+
return (value || "")
63+
.split(",")
64+
.map((s) => s.trim())
65+
.filter(Boolean);
66+
}
67+
68+
// Helper to parse comma-separated list with lowercase
69+
function parseListLower(value: string | undefined): string[] {
70+
return (value || "")
71+
.split(",")
72+
.map((s) => s.trim().toLowerCase())
73+
.filter(Boolean);
74+
}
75+
76+
// Parse environment variables into raw config object
77+
const rawConfig = {
78+
port: parseInt(process.env.PORT || "3000", 10),
79+
cacheMode: (process.env.CACHE_MODE || "disk").toLowerCase(),
80+
cacheDir: process.env.CACHE_DIR || "./cache",
81+
cacheTTL: parseInt(process.env.CACHE_TTL || "86400", 10),
82+
maxCacheSize: parseInt(process.env.MAX_CACHE_SIZE || "1073741824", 10),
83+
maxMemoryCacheItems: parseInt(
84+
process.env.MAX_MEMORY_CACHE_ITEMS || "1000",
85+
10,
86+
),
87+
allowedDomains: parseListLower(process.env.ALLOWED_DOMAINS),
88+
allowedOrigins: parseList(process.env.ALLOWED_ORIGINS),
89+
blockedDomains: ["localhost", "127.0.0.1", "0.0.0.0", "::1"],
90+
maxImageSize: parseInt(process.env.MAX_IMAGE_SIZE || "10485760", 10),
91+
requestTimeout: parseInt(process.env.REQUEST_TIMEOUT || "30000", 10),
92+
defaultQuality: 80,
93+
defaultFormat: "webp",
94+
maxWidth: 4096,
95+
maxHeight: 4096,
96+
browserCacheTTL: parseInt(process.env.BROWSER_CACHE_TTL || "31536000", 10),
4397
ogDefaultWidth: 1200,
4498
ogDefaultHeight: 630,
4599
ogDefaultBg: "1a1a2e",
46100
ogDefaultFg: "ffffff",
47-
48-
// Custom templates directory
49101
templatesDir: process.env.TEMPLATES_DIR || "./templates",
50-
} as const;
102+
};
103+
104+
// Validate and apply defaults
105+
if (!Value.Check(ConfigSchema, rawConfig)) {
106+
const errors = [...Value.Errors(ConfigSchema, rawConfig)];
107+
console.error("Invalid configuration:");
108+
for (const error of errors) {
109+
console.error(` ${error.path}: ${error.message}`);
110+
}
111+
process.exit(1);
112+
}
113+
114+
// Export validated config
115+
export const config = rawConfig as Static<typeof ConfigSchema> & {
116+
cacheMode: CacheMode;
117+
defaultFormat: "webp" | "avif" | "png" | "jpg" | "jpeg" | "gif";
118+
};
51119

120+
// Export types
121+
export type CacheMode = Static<typeof CacheModeSchema>;
52122
export type Config = typeof config;

src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import { imageRoutes } from "./routes/image";
66
import { ogRoutes } from "./routes/og";
77
import { startCacheCleanup } from "./services/cache";
88

9-
// Ensure cache directory exists (only for disk mode)
10-
if (config.cacheMode === "disk") {
9+
// Ensure cache directory exists (for disk and hybrid modes)
10+
if (config.cacheMode === "disk" || config.cacheMode === "hybrid") {
1111
await Bun.$`mkdir -p ${config.cacheDir}`.quiet();
1212
}
1313

1414
const app = new Elysia()
15-
.use(cors())
15+
.use(
16+
cors({
17+
origin: config.allowedOrigins.length > 0 ? config.allowedOrigins : true,
18+
}),
19+
)
1620
.onError(({ error, code, set }) => {
1721
// Don't log Elysia's built-in NOT_FOUND errors (these are normal 404s)
1822
if (code === "NOT_FOUND") {
@@ -57,6 +61,8 @@ function getCacheInfo(): string {
5761
return `disk (${config.cacheDir})`;
5862
case "memory":
5963
return `memory (max ${config.maxMemoryCacheItems} items)`;
64+
case "hybrid":
65+
return `hybrid (memory + ${config.cacheDir})`;
6066
case "none":
6167
return "disabled";
6268
}

src/services/cache.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,18 @@ export async function getCached(key: string): Promise<Buffer | null> {
154154
return getDiskCached(key);
155155
case "memory":
156156
return getMemoryCached(key);
157+
case "hybrid": {
158+
// Try memory first (L1), then disk (L2)
159+
const memoryHit = getMemoryCached(key);
160+
if (memoryHit) return memoryHit;
161+
162+
const diskHit = await getDiskCached(key);
163+
if (diskHit) {
164+
// Promote to memory cache
165+
setMemoryCache(key, diskHit);
166+
}
167+
return diskHit;
168+
}
157169
case "none":
158170
return null;
159171
default:
@@ -172,6 +184,10 @@ export async function setCache(
172184
case "memory":
173185
setMemoryCache(key, data);
174186
return;
187+
case "hybrid":
188+
// Write to both memory (L1) and disk (L2)
189+
setMemoryCache(key, data);
190+
return setDiskCache(key, data);
175191
case "none":
176192
return;
177193
}
@@ -197,14 +213,7 @@ export function getCacheHeaders(format: ImageFormat): Record<string, string> {
197213
// Background cache cleanup - runs periodically to remove expired entries
198214
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
199215

200-
export async function cleanupCache(): Promise<number> {
201-
if (config.cacheMode === "none") return 0;
202-
203-
if (config.cacheMode === "memory") {
204-
return memoryCache.cleanup();
205-
}
206-
207-
// Disk cleanup
216+
async function cleanupDiskCache(): Promise<number> {
208217
const maxAgeMinutes = Math.floor(config.cacheTTL / 60);
209218
let deleted = 0;
210219

@@ -228,6 +237,23 @@ export async function cleanupCache(): Promise<number> {
228237
return deleted;
229238
}
230239

240+
export async function cleanupCache(): Promise<number> {
241+
switch (config.cacheMode) {
242+
case "none":
243+
return 0;
244+
case "memory":
245+
return memoryCache.cleanup();
246+
case "disk":
247+
return cleanupDiskCache();
248+
case "hybrid": {
249+
// Clean both caches
250+
const memoryDeleted = memoryCache.cleanup();
251+
const diskDeleted = await cleanupDiskCache();
252+
return memoryDeleted + diskDeleted;
253+
}
254+
}
255+
}
256+
231257
export function startCacheCleanup(intervalMs: number = 3600000): void {
232258
if (cleanupInterval) return;
233259
if (config.cacheMode === "none") return;
@@ -261,6 +287,12 @@ export function getCacheStats(): {
261287
return { mode: "memory", items: memoryCache.size() };
262288
case "disk":
263289
return { mode: "disk", directory: config.cacheDir };
290+
case "hybrid":
291+
return {
292+
mode: "hybrid",
293+
items: memoryCache.size(),
294+
directory: config.cacheDir,
295+
};
264296
case "none":
265297
return { mode: "none" };
266298
}

0 commit comments

Comments
 (0)