Skip to content

Commit 54d052f

Browse files
authored
Cache rss requests in service (#38)
* cache rss service * add mem cache
1 parent 9bfebc5 commit 54d052f

File tree

3 files changed

+340
-38
lines changed

3 files changed

+340
-38
lines changed

packages/rss/service/src/cache.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { redis } from "./storage.js";
2+
import { FeedFormat } from "./types.js";
3+
4+
// Cache keys
5+
const CACHE_PREFIX = "feed:cache:";
6+
const CACHE_METADATA_PREFIX = "feed:cache:metadata:";
7+
8+
// Cache TTL in seconds (10 minutes by default)
9+
const DEFAULT_CACHE_TTL = 10 * 60;
10+
11+
// Cache metadata interface
12+
interface CacheMetadata {
13+
lastModified: string; // ISO date string
14+
etag: string;
15+
}
16+
17+
/**
18+
* Get cached feed content
19+
*/
20+
export async function getCachedFeed(
21+
format: FeedFormat,
22+
): Promise<{ content: string; metadata: CacheMetadata } | null> {
23+
try {
24+
// Get cached content
25+
const cacheKey = `${CACHE_PREFIX}${format}`;
26+
const content = await redis.get(cacheKey);
27+
28+
if (!content) {
29+
return null;
30+
}
31+
32+
// Get metadata
33+
const metadataKey = `${CACHE_METADATA_PREFIX}${format}`;
34+
const metadataStr = await redis.get(metadataKey);
35+
36+
if (!metadataStr) {
37+
// If we have content but no metadata, return with default metadata
38+
return {
39+
content,
40+
metadata: {
41+
lastModified: new Date().toISOString(),
42+
etag: `"${Date.now().toString(36)}"`,
43+
},
44+
};
45+
}
46+
47+
return {
48+
content,
49+
metadata: JSON.parse(metadataStr),
50+
};
51+
} catch (error) {
52+
console.error("Cache retrieval error:", error);
53+
return null;
54+
}
55+
}
56+
57+
/**
58+
* Cache feed content with metadata
59+
*/
60+
export async function cacheFeed(
61+
format: FeedFormat,
62+
content: string,
63+
ttl: number = DEFAULT_CACHE_TTL,
64+
): Promise<CacheMetadata> {
65+
try {
66+
const timestamp = Date.now();
67+
const etag = `"${timestamp.toString(36)}"`;
68+
const lastModified = new Date().toISOString();
69+
70+
const metadata: CacheMetadata = {
71+
lastModified,
72+
etag,
73+
};
74+
75+
// Cache the content
76+
const cacheKey = `${CACHE_PREFIX}${format}`;
77+
await redis.set(cacheKey, content);
78+
await redis.expire(cacheKey, ttl);
79+
80+
// Cache the metadata
81+
const metadataKey = `${CACHE_METADATA_PREFIX}${format}`;
82+
await redis.set(metadataKey, JSON.stringify(metadata));
83+
await redis.expire(metadataKey, ttl);
84+
85+
return metadata;
86+
} catch (error) {
87+
console.error("Cache storage error:", error);
88+
throw error;
89+
}
90+
}
91+
92+
/**
93+
* Invalidate all feed caches
94+
*/
95+
export async function invalidateCache(): Promise<void> {
96+
try {
97+
const formats: FeedFormat[] = ["rss", "atom", "json", "raw"];
98+
99+
for (const format of formats) {
100+
const cacheKey = `${CACHE_PREFIX}${format}`;
101+
const metadataKey = `${CACHE_METADATA_PREFIX}${format}`;
102+
103+
await redis.del(cacheKey);
104+
await redis.del(metadataKey);
105+
}
106+
107+
console.log("All feed caches invalidated");
108+
} catch (error) {
109+
console.error("Cache invalidation error:", error);
110+
throw error;
111+
}
112+
}
113+
114+
/**
115+
* Check if a request can use a cached response based on conditional headers
116+
*/
117+
export function isCacheValid(
118+
requestHeaders: Headers,
119+
metadata: CacheMetadata,
120+
): boolean {
121+
// Check If-None-Match header (ETag)
122+
const ifNoneMatch = requestHeaders.get("If-None-Match");
123+
if (ifNoneMatch && ifNoneMatch === metadata.etag) {
124+
return true;
125+
}
126+
127+
// Check If-Modified-Since header
128+
const ifModifiedSince = requestHeaders.get("If-Modified-Since");
129+
if (ifModifiedSince) {
130+
const modifiedSinceDate = new Date(ifModifiedSince);
131+
const lastModifiedDate = new Date(metadata.lastModified);
132+
133+
if (
134+
!isNaN(modifiedSinceDate.getTime()) &&
135+
!isNaN(lastModifiedDate.getTime()) &&
136+
lastModifiedDate <= modifiedSinceDate
137+
) {
138+
return true;
139+
}
140+
}
141+
142+
return false;
143+
}

packages/rss/service/src/middleware/protection.ts

Lines changed: 96 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,14 @@ const RATE_LIMIT = {
1313
max: 100, // limit each IP to 100 requests per windowMs
1414
};
1515

16+
// Memory cache for frequent requests to reduce Redis calls
17+
// This cache is shared across all requests to the same server instance
18+
const memCache = new Map<string, { count: number; expires: number }>();
19+
1620
/**
1721
* Rate limiting middleware for public endpoints
1822
* Uses Redis to track request counts across multiple instances
23+
* Optimized to use Redis pipeline for better performance
1924
*/
2025
export async function rateLimiter(
2126
c: Context,
@@ -33,17 +38,55 @@ export async function rateLimiter(
3338
const key = `ratelimit:${ip}`;
3439

3540
try {
36-
// Get current request count
37-
const requests = await redis.incr(key);
38-
39-
// Set expiry on first request
40-
if (requests === 1) {
41-
await redis.expire(key, RATE_LIMIT.windowMs / 1000);
41+
let requests: number;
42+
let ttl: number;
43+
44+
// Check memory cache first to avoid Redis calls for frequent requests
45+
const now = Date.now();
46+
const cached = memCache.get(key);
47+
48+
if (cached && cached.expires > now) {
49+
// Use cached values if they haven't expired
50+
requests = cached.count + 1;
51+
ttl = Math.floor((cached.expires - now) / 1000);
52+
53+
// Update the cache with incremented count
54+
memCache.set(key, {
55+
count: requests,
56+
expires: cached.expires,
57+
});
58+
} else {
59+
// Cache miss or expired, use Redis pipeline to batch commands for better performance
60+
// This reduces network roundtrips to Redis
61+
const pipeline = redis.pipeline();
62+
pipeline.incr(key);
63+
pipeline.ttl(key);
64+
65+
const results = await pipeline.exec();
66+
67+
if (!results || results.length < 2) {
68+
// If pipeline fails, allow the request to proceed
69+
console.error("Rate limiting pipeline error: Invalid results");
70+
await next();
71+
return undefined;
72+
}
73+
74+
requests = results[0] as number;
75+
ttl = results[1] as number;
76+
77+
// Set expiry on first request
78+
if (requests === 1 || ttl < 0) {
79+
await redis.expire(key, RATE_LIMIT.windowMs / 1000);
80+
ttl = RATE_LIMIT.windowMs / 1000;
81+
}
82+
83+
// Update memory cache with values from Redis
84+
memCache.set(key, {
85+
count: requests,
86+
expires: now + ttl * 1000,
87+
});
4288
}
4389

44-
// Get TTL for headers
45-
const ttl = await redis.ttl(key);
46-
4790
// Set rate limit headers
4891
c.header("X-RateLimit-Limit", RATE_LIMIT.max.toString());
4992
c.header(
@@ -74,8 +117,8 @@ export async function rateLimiter(
74117
}
75118

76119
/**
77-
* Security headers middleware
78-
* Adds essential security headers to responses
120+
* Security and caching headers middleware
121+
* Adds essential security and performance-related headers to responses
79122
*/
80123
export async function securityHeaders(
81124
c: Context,
@@ -90,30 +133,64 @@ export async function securityHeaders(
90133
// Enforce HTTPS for secure feed access
91134
c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
92135

136+
// Add performance-related headers for GET requests to feed endpoints
137+
const path = c.req.path;
138+
if (
139+
c.req.method === "GET" &&
140+
(path.endsWith(".xml") || path.endsWith(".json") || path === "/")
141+
) {
142+
// Only add Cache-Control if not already set by the route handler
143+
if (!c.res.headers.has("Cache-Control")) {
144+
// Public feeds can be cached by browsers and proxies
145+
c.header("Cache-Control", "public, max-age=600"); // 10 minutes
146+
}
147+
148+
// Add Vary header to ensure proper caching based on these request headers
149+
c.header("Vary", "Accept, Accept-Encoding");
150+
151+
// Add a default ETag if not already set
152+
if (!c.res.headers.has("ETag")) {
153+
// Generate a simple ETag based on the current time (not ideal but better than nothing)
154+
// In production, this should be based on content hash
155+
c.header("ETag", `"${Date.now().toString(36)}"`);
156+
}
157+
}
158+
93159
return c.res;
94160
}
95161

96162
/**
97163
* Request timeout middleware
98164
* Adds timeout protection for long-running requests
165+
* Optimized to avoid unnecessary Promise overhead
99166
*/
100167
export async function requestTimeout(
101168
c: Context,
102169
next: Next,
103170
): Promise<Response | undefined> {
104171
const TIMEOUT = 30000; // 30 seconds
105172

106-
const timeoutPromise = new Promise((_, reject) => {
107-
setTimeout(() => {
108-
reject(new Error("Request timeout"));
109-
}, TIMEOUT);
110-
});
173+
// Use AbortController for more efficient timeout handling
174+
const controller = new AbortController();
175+
const timeoutId = setTimeout(() => {
176+
controller.abort();
177+
}, TIMEOUT);
111178

112179
try {
113-
await Promise.race([next(), timeoutPromise]);
180+
// Store the signal in the context for downstream middleware
181+
c.set("abortSignal", controller.signal);
182+
183+
// Execute the next middleware with timeout
184+
await next();
185+
186+
// Clear the timeout if the request completes successfully
187+
clearTimeout(timeoutId);
188+
114189
return c.res;
115190
} catch (error) {
116-
if (error instanceof Error && error.message === "Request timeout") {
191+
clearTimeout(timeoutId);
192+
193+
if (error instanceof Error && error.name === "AbortError") {
117194
return c.json(
118195
{
119196
error: "Request Timeout",
@@ -122,6 +199,7 @@ export async function requestTimeout(
122199
408,
123200
);
124201
}
202+
125203
throw error;
126204
}
127205
}

0 commit comments

Comments
 (0)