Skip to content

Commit 78215d8

Browse files
authored
feat(frontend-main): Link preview cards on posts (#479)
* feat: link og preview card * fix: lint errors * feat: preview images from medium and x.com * refactor: introduce HTTP response utilities and improve error handling in edge functions * fix: add hostname extraction error handling * refactor: modularize Open Graph metadata extraction and enhance URL validation + SSRF protection * chore: update .gitignore to include deno.lock and remove the deno.lock file * feat: implement caching strategy for Open Graph metadata fetching in edge functions - Introduced a dual-layer caching mechanism using Cache API and Netlify CDN. - Enhanced URL normalization for consistent cache keys. - Updated response handling to include cache date and miss headers for debugging. - Increased fetch timeout to 10 seconds and set cache TTL to 7 days. - Added cache utility functions in a new module for better code organization.
1 parent 45fceef commit 78215d8

File tree

15 files changed

+920
-22
lines changed

15 files changed

+920
-22
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ packages/**/coverage
1717

1818
# Local Netlify folder
1919
.netlify
20+
21+
# netlify edge functions
22+
deno.lock

netlify.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ path = "/post/*"
1010
function = "og-image"
1111
path = "/og-image/*"
1212

13+
[[edge_functions]]
14+
function = "link-meta"
15+
path = "/link-meta"
16+
1317
# Redirect all routes to index.html
14-
# Note: Edge functions take precedence over redirects, so /og-image/* and /post/*
18+
# Note: Edge functions take precedence over redirects.
1519
# will be handled by edge functions before this redirect rule applies
1620
[[redirects]]
1721
from = "/*"
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Generic cache utilities for Netlify Edge Functions.
3+
*
4+
* This module provides a dual-layer caching strategy:
5+
* 1. Runtime Cache API (`caches.open()`) - Prevents edge function re-execution
6+
* for cached responses, reducing compute time and external API calls.
7+
* 2. Netlify CDN Cache - Enabled via Cache-Control headers in responses.
8+
* Netlify's CDN caches responses at the edge, reducing latency globally.
9+
*
10+
* According to Netlify Cache API docs (https://docs.netlify.com/build/caching/cache-api/):
11+
* - Use `caches.open(name)` to open a named cache instance
12+
* - Responses must have Cache-Control headers with max-age >= 1 second
13+
* - Only responses with status 200-299 can be cached
14+
* - Responses are automatically invalidated on site redeploy
15+
* - Cache operations must be within request handler scope
16+
* - Responses must be cloned before caching if used elsewhere
17+
*
18+
* According to Netlify caching overview (https://docs.netlify.com/build/caching/caching-overview/):
19+
* - Edge Functions are NOT cached by default
20+
* - Setting Cache-Control headers enables CDN caching
21+
* - Query parameters are automatically factored into cache keys
22+
* - Cache-Control max-age determines how long responses are cached
23+
*
24+
* The Cache API provides immediate cache hits within the same edge function execution,
25+
* while Netlify's CDN cache provides global distribution and reduces edge function
26+
* invocations across different edge locations.
27+
*/
28+
29+
export interface CacheOptions {
30+
/**
31+
* Name of the cache instance to use. Defaults to 'default'.
32+
* Using a meaningful name helps organize cached responses.
33+
*/
34+
cacheName?: string;
35+
/**
36+
* Prefix for cache keys to avoid collisions.
37+
*/
38+
keyPrefix?: string;
39+
/**
40+
* Whether to add debug headers (X-Cache, X-Cache-Date).
41+
*/
42+
debugHeaders?: boolean;
43+
}
44+
45+
/**
46+
* Normalizes a URL for consistent cache keys:
47+
* - Lowercases hostname
48+
* - Removes trailing slashes (except for root)
49+
* - Preserves path, query, and hash
50+
*/
51+
export function normalizeUrl(url: URL): string {
52+
const normalized = new URL(url);
53+
normalized.hostname = normalized.hostname.toLowerCase();
54+
if (normalized.pathname !== '/' && normalized.pathname.endsWith('/')) {
55+
normalized.pathname = normalized.pathname.slice(0, -1);
56+
}
57+
return normalized.href;
58+
}
59+
60+
/**
61+
* Creates a cache key with optional prefix.
62+
*/
63+
export function createCacheKey(key: string, prefix?: string): string {
64+
return prefix ? `${prefix}${key}` : key;
65+
}
66+
67+
/**
68+
* Retrieves a cached response if available.
69+
* Adds X-Cache: HIT header if debugHeaders is enabled.
70+
*
71+
* According to Netlify Cache API docs, cache operations must be within
72+
* the request handler scope. This function should be called from within
73+
* your edge function handler.
74+
*
75+
* @param cacheKey - The cache key to look up (can be a Request object or URL string)
76+
* @param options - Cache options
77+
* @returns Cached response or null if not found
78+
*/
79+
export async function getCachedResponse(
80+
cacheKey: string | Request,
81+
options: CacheOptions = {},
82+
): Promise<Response | null> {
83+
const { cacheName = 'default', debugHeaders = true } = options;
84+
85+
try {
86+
const cache = await caches.open(cacheName);
87+
const cachedResponse = await cache.match(cacheKey);
88+
if (cachedResponse) {
89+
if (debugHeaders) {
90+
// Add cache hit header for debugging
91+
const clonedResponse = cachedResponse.clone();
92+
clonedResponse.headers.set('X-Cache', 'HIT');
93+
return clonedResponse;
94+
}
95+
return cachedResponse;
96+
}
97+
} catch (error) {
98+
// Cache API might not be available in all environments
99+
console.warn('Cache API not available:', error);
100+
}
101+
return null;
102+
}
103+
104+
/**
105+
* Stores a response in the cache.
106+
* Clones the response to avoid consuming the original body.
107+
*
108+
* According to Netlify Cache API docs:
109+
* - Only responses with status 200-299 can be cached
110+
* - Responses must have Cache-Control headers with max-age >= 1 second
111+
* - Cache operations must be within the request handler scope
112+
* - Responses are automatically invalidated on site redeploy
113+
*
114+
* @param cacheKey - The cache key to store under (can be a Request object or URL string)
115+
* @param response - The response to cache (must have status 200-299 and Cache-Control headers)
116+
* @param options - Cache options
117+
*/
118+
export async function storeInCache(
119+
cacheKey: string | Request,
120+
response: Response,
121+
options: CacheOptions = {},
122+
): Promise<void> {
123+
const { cacheName = 'default' } = options;
124+
125+
// Only cache successful responses (200-299 status codes)
126+
if (!response.ok || response.status < 200 || response.status >= 300) {
127+
return;
128+
}
129+
130+
// Verify Cache-Control header exists with max-age >= 1 second
131+
const cacheControl = response.headers.get('Cache-Control');
132+
if (!cacheControl || !cacheControl.includes('max-age=')) {
133+
console.warn('Response missing Cache-Control header with max-age, skipping cache');
134+
return;
135+
}
136+
137+
try {
138+
const cache = await caches.open(cacheName);
139+
// Clone response before caching since Response body can only be read once
140+
// The clone ensures the original response remains readable
141+
const responseToCache = response.clone();
142+
await cache.put(cacheKey, responseToCache);
143+
} catch (error) {
144+
// Cache API might not be available in all environments
145+
console.warn('Failed to store in cache:', error);
146+
}
147+
}
148+
149+
/**
150+
* Wraps a response with cache miss headers if debugHeaders is enabled.
151+
*/
152+
export function markCacheMiss(
153+
response: Response,
154+
options: CacheOptions = {},
155+
): Response {
156+
const { debugHeaders = true } = options;
157+
if (debugHeaders) {
158+
response.headers.set('X-Cache', 'MISS');
159+
response.headers.set('X-Cache-Date', new Date().toISOString());
160+
}
161+
return response;
162+
}
163+
164+
/**
165+
* Wraps a response with cache date header if debugHeaders is enabled.
166+
*/
167+
export function addCacheDateHeader(
168+
response: Response,
169+
options: CacheOptions = {},
170+
): Response {
171+
const { debugHeaders = true } = options;
172+
if (debugHeaders) {
173+
response.headers.set('X-Cache-Date', new Date().toISOString());
174+
}
175+
return response;
176+
}

netlify/edge-functions/lib/http.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* HTTP response utilities for edge functions.
3+
*/
4+
5+
export interface ResponseOptions {
6+
status?: number;
7+
cacheMaxAge?: number;
8+
}
9+
10+
export const CACHE_SUCCESS_SECONDS = 300; // 5 minutes
11+
export const CACHE_ERROR_SECONDS = 60; // 1 minute
12+
export const CACHE_IMMUTABLE_SECONDS = 31536000; // 1 year
13+
14+
/**
15+
* Creates a JSON response with configurable status and cache headers.
16+
*/
17+
export function createJsonResponse<T>(
18+
data: T,
19+
options: ResponseOptions = {},
20+
): Response {
21+
const { status = 200, cacheMaxAge = CACHE_SUCCESS_SECONDS } = options;
22+
23+
return new Response(JSON.stringify(data), {
24+
status,
25+
headers: {
26+
'Content-Type': 'application/json',
27+
'Cache-Control': `public, max-age=${cacheMaxAge}`,
28+
},
29+
});
30+
}
31+
32+
/**
33+
* Creates a text/HTML response with configurable status and cache headers.
34+
*/
35+
export function createTextResponse(
36+
body: string,
37+
contentType: string,
38+
options: ResponseOptions = {},
39+
): Response {
40+
const { status = 200, cacheMaxAge = CACHE_SUCCESS_SECONDS } = options;
41+
42+
const cacheControl = cacheMaxAge === CACHE_IMMUTABLE_SECONDS
43+
? 'public, max-age=31536000, immutable'
44+
: `public, max-age=${cacheMaxAge}`;
45+
46+
return new Response(body, {
47+
status,
48+
headers: {
49+
'Content-Type': contentType,
50+
'Cache-Control': cacheControl,
51+
},
52+
});
53+
}
54+
55+
/**
56+
* Creates an error response with JSON body.
57+
* Returns 200 status by default for graceful degradation.
58+
*/
59+
export function createErrorResponse(
60+
error: string,
61+
url?: string,
62+
status: number = 200,
63+
): Response {
64+
return createJsonResponse(
65+
{ error, ...(url && { url }) },
66+
{ status, cacheMaxAge: CACHE_ERROR_SECONDS },
67+
);
68+
}
69+
70+
/**
71+
* Creates a client error response (400 status).
72+
*/
73+
export function createClientErrorResponse(error: string): Response {
74+
return createJsonResponse({ error }, { status: 400 });
75+
}
76+
77+
/**
78+
* Creates a not found response (404 status).
79+
*/
80+
export function createNotFoundResponse(message: string = 'Not Found'): Response {
81+
return new Response(message, { status: 404 });
82+
}
83+
84+
/**
85+
* Creates an internal server error response (500 status).
86+
*/
87+
export function createInternalErrorResponse(message: string = 'Internal Server Error'): Response {
88+
return new Response(message, { status: 500 });
89+
}

0 commit comments

Comments
 (0)