Skip to content

Commit 8a7e08f

Browse files
committed
perf: fix storage caching layer with proper TTL, Cache-Control, and non-blocking writes
- Fix `setMeta({ ttl })` bug: TTL was only set on meta key, not data key. Replace all 12 occurrences with `{ ttl }` parameter on `setItem`/`setItemRaw`. - Add Cache-Control headers to all cached routes (img, og, favicon, screenshot, fonts). - Use `event.waitUntil` for non-blocking cache writes in hot paths. - Fix img route cache key mismatch: data and Content-Type now stored/read from consistent keys using `setItemRaw` + `setMeta`/`getMeta`. - Replace extension-guessing cache lookup with `getRouterParam` path extraction. - Remove FS driver from production cache config (no TTL support), keep for dev. - Extract all TTL magic numbers into `server/utils/constants.ts`.
1 parent 9070535 commit 8a7e08f

16 files changed

Lines changed: 657 additions & 423 deletions

File tree

nitro.config.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import { defineNitroConfig } from "nitro/config";
2-
import { env, isBun, isDeno, isNode } from "std-env";
2+
import { env } from "std-env";
33

44
import pkg from "./package.json";
55

6-
// Detect if filesystem is available (Node.js, Bun, or Deno runtime)
7-
const hasFilesystem = isNode || isBun || isDeno;
8-
96
export default defineNitroConfig({
107
serverDir: "./server/",
118
experimental: {
@@ -35,14 +32,11 @@ export default defineNitroConfig({
3532
},
3633
storage: {
3734
cache: {
38-
driver: env.REDIS_URL ? "redis" : hasFilesystem ? "fs" : "memory",
35+
// FS driver does not support TTL, only use redis or memory
36+
driver: env.REDIS_URL ? "redis" : "memory",
3937
...(env.REDIS_URL && {
4038
url: env.REDIS_URL,
4139
}),
42-
...(hasFilesystem &&
43-
!env.REDIS_URL && {
44-
base: "./.cache",
45-
}),
4640
},
4741
},
4842
devStorage: {

package.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,15 @@
3838
},
3939
"devDependencies": {
4040
"@sparticuz/chromium": "143.0.4",
41-
"@types/node": "25.5.0",
41+
"@types/node": "25.5.2",
4242
"@types/ua-parser-js": "0.7.39",
4343
"@vercel/og": "0.11.1",
4444
"cheerio": "1.2.0",
4545
"ioredis": "5.10.1",
4646
"ipx": "4.0.0-alpha.1",
4747
"nitro": "3.0.260311-beta",
48-
"playwright": "1.58.2",
49-
"prettier": "3.8.1",
48+
"playwright": "1.59.1",
5049
"ua-parser-js": "2.0.9",
51-
"vite-plus": "0.1.13"
50+
"vite-plus": "0.1.15"
5251
}
5352
}

pnpm-lock.yaml

Lines changed: 572 additions & 337 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/routes/favicon.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useStorage } from "nitro/storage";
44
import { hash } from "ohash";
55
import sharp from "sharp";
66

7+
import { FONT_FILE_TTL } from "../utils/constants";
8+
79
export interface FaviconQuery {
810
url: string;
911
size?: string;
@@ -327,17 +329,19 @@ export default defineHandler(async (event) => {
327329
if (cached) {
328330
event.res.headers.set("X-Cache", "HIT");
329331
event.res.headers.set("Content-Type", "image/png");
332+
event.res.headers.set("Cache-Control", `public, max-age=${FONT_FILE_TTL}`);
330333
return Buffer.from(cached);
331334
}
332335

333336
// CORS is handled by global routeRules
334337
const favicon = await getFavicon(url, size);
335338

336-
await storage.setItemRaw(cacheKey, new Uint8Array(favicon));
337-
await storage.setMeta(cacheKey, { ttl: 2592000 }); // 30 days
339+
// Non-blocking cache write (30 days TTL)
340+
event.waitUntil(storage.setItemRaw(cacheKey, new Uint8Array(favicon), { ttl: FONT_FILE_TTL }));
338341

339342
event.res.headers.set("X-Cache", "MISS");
340343
event.res.headers.set("Content-Type", "image/png");
344+
event.res.headers.set("Cache-Control", `public, max-age=${FONT_FILE_TTL}`);
341345

342346
return favicon;
343347
});

server/routes/fonts/[...].ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { defineHandler, HTTPError, getRouterParam } from "nitro/h3";
22
import { useStorage } from "nitro/storage";
33
import { hash } from "ohash";
44

5+
import { FONT_FILE_TTL } from "../../utils/constants";
6+
57
export const allowedProviders = ["google", "bunny", "fontshare", "fontsource"];
68

79
export const providerDomains: Record<string, string[]> = {
@@ -94,6 +96,7 @@ export default defineHandler(async (event) => {
9496
event.res.headers.set("X-Cache", "HIT");
9597
const contentType = getFontContentType(fontpath);
9698
event.res.headers.set("Content-Type", contentType);
99+
event.res.headers.set("Cache-Control", `public, max-age=${FONT_FILE_TTL}`);
97100
return Buffer.from(cached);
98101
}
99102

@@ -112,14 +115,14 @@ export default defineHandler(async (event) => {
112115

113116
const data = Buffer.from(await response.arrayBuffer());
114117

115-
// Store raw binary data (30 days)
116-
await storage.setItemRaw(cacheKey, new Uint8Array(data));
117-
await storage.setMeta(cacheKey, { ttl: 2592000 }); // 30 days
118+
// Non-blocking cache write (30 days TTL)
119+
event.waitUntil(storage.setItemRaw(cacheKey, new Uint8Array(data), { ttl: FONT_FILE_TTL }));
118120

119121
// CORS is handled by global routeRules
120122
const contentType = response.headers.get("content-type") || getFontContentType(fontpath);
121123
event.res.headers.set("X-Cache", "MISS");
122124
event.res.headers.set("Content-Type", contentType);
125+
event.res.headers.set("Cache-Control", `public, max-age=${FONT_FILE_TTL}`);
123126

124127
return data;
125128
});

server/routes/img/[...].ts

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { createIPX, ipxHttpStorage, ipxFSStorage, createIPXFetchHandler } from "ipx";
2-
import { defineHandler } from "nitro/h3";
2+
import { defineHandler, getRouterParam } from "nitro/h3";
33
import { useStorage } from "nitro/storage";
44
import { hash } from "ohash";
55
import { env } from "std-env";
66

7+
import { IMAGE_TTL, OG_IMAGE_TTL } from "../../utils/constants";
8+
79
// Parse allowed domains from environment
810
export const allowedDomains = env.ALLOWED_DOMAINS
911
? env.ALLOWED_DOMAINS.split(",")
@@ -17,75 +19,54 @@ export const ipx = createIPX({
1719
httpStorage: ipxHttpStorage({
1820
domains: allowedDomains,
1921
allowAllDomains: allowedDomains.length === 0,
20-
maxAge: 86400,
22+
maxAge: IMAGE_TTL,
2123
}),
22-
maxAge: 86400,
24+
maxAge: IMAGE_TTL,
2325
});
2426

25-
export function contentTypeToExtension(contentType: string): string {
26-
const type = contentType.toLowerCase();
27-
if (type.includes("png")) return "png";
28-
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
29-
if (type.includes("webp")) return "webp";
30-
if (type.includes("gif")) return "gif";
31-
if (type.includes("avif")) return "avif";
32-
if (type.includes("heif")) return "heif";
33-
if (type.includes("tiff")) return "tiff";
34-
return "png";
35-
}
27+
const fetchHandler = createIPXFetchHandler(ipx);
3628

3729
export default defineHandler(async (event) => {
30+
const path = getRouterParam(event, "_") || "";
31+
const ipxPath = "/" + path;
3832
const url = new URL(event.req.url);
39-
const ipxPath = url.pathname.replace(/^\/img/, "") || "/";
4033
url.pathname = ipxPath;
4134

42-
// Generate cache key using ohash
43-
const cacheKeyBase = `img:${hash({ path: ipxPath, search: url.search })}`;
35+
const cacheKey = `img:${hash({ path: ipxPath, search: url.search })}`;
4436
const storage = useStorage("cache");
4537

46-
// Try common image extensions for cache lookup
47-
const extensions = ["png", "jpg", "jpeg", "webp", "gif", "avif", "heif", "tiff", "tif"];
48-
let cached: Uint8Array | null = null;
49-
let cacheKey = "";
50-
51-
for (const ext of extensions) {
52-
const key = `${cacheKeyBase}.${ext}`;
53-
const data = await storage.getItemRaw<Uint8Array>(key);
54-
if (data) {
55-
cached = data;
56-
cacheKey = key;
57-
break;
58-
}
59-
}
60-
38+
// Cache lookup
39+
const cached = await storage.getItemRaw<Uint8Array>(cacheKey);
6140
if (cached) {
41+
const cachedMeta = await storage.getMeta(cacheKey);
42+
const contentType =
43+
typeof cachedMeta?.contentType === "string" ? cachedMeta.contentType : "image/png";
44+
event.res.headers.set("Content-Type", contentType);
6245
event.res.headers.set("X-Cache", "HIT");
63-
return Buffer.from(cached);
46+
event.res.headers.set("Cache-Control", `public, max-age=${IMAGE_TTL}`);
47+
return cached;
6448
}
6549

66-
cacheKey = cacheKeyBase;
67-
68-
// Use IPX to process the image
69-
const fetchHandler = createIPXFetchHandler(ipx);
50+
// Process with IPX
7051
const response = await fetchHandler(new Request(url, event.req));
71-
const arrayBuffer = await response.arrayBuffer();
72-
const buffer = Buffer.from(arrayBuffer);
52+
const data = await response.bytes();
7353

74-
// Get extension from response Content-Type
75-
const contentType = response.headers.get("content-type") || "image/png";
76-
const extension = contentTypeToExtension(contentType);
77-
cacheKey = `${cacheKey}.${extension}`;
78-
79-
// Store raw binary data (7 days)
80-
await storage.setItemRaw(cacheKey, new Uint8Array(buffer));
81-
await storage.setMeta(cacheKey, { ttl: 604800 }); // 7 days
82-
83-
// Copy headers from IPX response
54+
// Copy IPX response headers
8455
response.headers.forEach((value, key) => {
8556
event.res.headers.set(key, value);
8657
});
8758

8859
event.res.headers.set("X-Cache", "MISS");
60+
event.res.headers.set("Cache-Control", `public, max-age=${IMAGE_TTL}`);
61+
62+
// Non-blocking cache write: data with TTL, Content-Type in metadata
63+
const contentType = response.headers.get("content-type") || "image/png";
64+
event.waitUntil(
65+
Promise.all([
66+
storage.setItemRaw(cacheKey, data, { ttl: OG_IMAGE_TTL }),
67+
storage.setMeta(cacheKey, { contentType }, { ttl: OG_IMAGE_TTL }),
68+
]),
69+
);
8970

90-
return buffer;
71+
return data;
9172
});

server/routes/og.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { defineHandler, getQuery } from "nitro/h3";
33
import { useStorage } from "nitro/storage";
44
import { hash } from "ohash";
55

6+
import { OG_IMAGE_TTL } from "../utils/constants";
7+
68
export interface OGQuery {
79
title?: string;
810
description?: string;
@@ -35,6 +37,7 @@ export default defineHandler(async (event) => {
3537
if (cached) {
3638
event.res.headers.set("X-Cache", "HIT");
3739
event.res.headers.set("Content-Type", "image/png");
40+
event.res.headers.set("Cache-Control", `public, max-age=${OG_IMAGE_TTL}`);
3841
return Buffer.from(cached);
3942
}
4043

@@ -108,10 +111,11 @@ export default defineHandler(async (event) => {
108111
const imageResponse = new ImageResponse(element, { width, height });
109112
const buffer = Buffer.from(await imageResponse.arrayBuffer());
110113

111-
await storage.setItemRaw(cacheKey, new Uint8Array(buffer));
114+
await storage.setItemRaw(cacheKey, new Uint8Array(buffer), { ttl: OG_IMAGE_TTL });
112115

113116
event.res.headers.set("X-Cache", "MISS");
114117
event.res.headers.set("Content-Type", "image/png");
118+
event.res.headers.set("Cache-Control", `public, max-age=${OG_IMAGE_TTL}`);
115119

116120
return buffer;
117121
});

server/routes/screenshot.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useStorage } from "nitro/storage";
44
import { hash } from "ohash";
55
import { chromium as playwright, type Browser, type Page, type BrowserContext } from "playwright";
66

7+
import { IMAGE_TTL } from "../utils/constants";
8+
79
export interface ScreenshotQuery {
810
url: string;
911
width?: string;
@@ -143,6 +145,7 @@ export default defineHandler(async (event) => {
143145
event.res.headers.set("X-Cache", "HIT");
144146
const contentType = format === "jpeg" ? "image/jpeg" : "image/png";
145147
event.res.headers.set("Content-Type", contentType);
148+
event.res.headers.set("Cache-Control", `public, max-age=${IMAGE_TTL}`);
146149
return Buffer.from(cached);
147150
}
148151

@@ -171,14 +174,14 @@ export default defineHandler(async (event) => {
171174
...(format === "jpeg" && { quality }),
172175
});
173176

174-
// Store raw binary data (24 hours)
175-
await storage.setItemRaw(cacheKey, new Uint8Array(screenshot));
176-
await storage.setMeta(cacheKey, { ttl: 86400 }); // 24 hours
177+
// Non-blocking cache write (24 hours TTL)
178+
event.waitUntil(storage.setItemRaw(cacheKey, new Uint8Array(screenshot), { ttl: IMAGE_TTL }));
177179

178180
// CORS is handled by global routeRules
179181
const contentType = format === "jpeg" ? "image/jpeg" : "image/png";
180182
event.res.headers.set("X-Cache", "MISS");
181183
event.res.headers.set("Content-Type", contentType);
184+
event.res.headers.set("Cache-Control", `public, max-age=${IMAGE_TTL}`);
182185

183186
return screenshot;
184187
} finally {

server/routes/webfonts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineHandler, getQuery } from "nitro/h3";
22
import { useStorage } from "nitro/storage";
33
import { hash } from "ohash";
44

5+
import { FONT_META_TTL } from "../utils/constants";
56
import { getWebfontsList } from "../utils/fonts/webfonts";
67

78
export interface WebfontsQuery {
@@ -32,8 +33,7 @@ export default defineHandler(async (event) => {
3233

3334
const response = await getWebfontsList(provider, { family, subset, category }, sort);
3435

35-
await storage.setItem(cacheKey, response);
36-
await storage.setMeta(cacheKey, { ttl: 3600 }); // 1 hour
36+
await storage.setItem(cacheKey, response, { ttl: FONT_META_TTL }); // 1 hour
3737

3838
event.res.headers.set("X-Cache", "MISS");
3939

server/utils/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/** Font metadata cache: 1 hour */
2+
export const FONT_META_TTL = 3600;
3+
4+
/** Image proxy and screenshot cache: 1 day */
5+
export const IMAGE_TTL = 86400;
6+
7+
/** OG image cache: 7 days */
8+
export const OG_IMAGE_TTL = 604800;
9+
10+
/** Favicon and font file cache: 30 days */
11+
export const FONT_FILE_TTL = 2592000;

0 commit comments

Comments
 (0)