my website at ewancroft.uk
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add TTL caching and shared XML utils for feeds

Introduces a server-side TTLCache utility for caching Atom and RSS feed XML responses, reducing redundant data fetching and improving performance. Extracts XML escaping logic into a shared utility (xml.ts) and updates all feed endpoints to use the new cache and utility. Blog post loading is now always fresh, and status update endpoints also use caching for efficiency.

Ewan Croft f0c703ef b0d31e7c

+180 -147
+23 -25
src/lib/services/blogService.ts
··· 3 3 import { parse } from "$lib/parser"; 4 4 5 5 // Caching profile and post data 6 + 6 7 let profile: Profile; 7 - let allPosts: Map<string, Post>; 8 + let allPosts: Map<string, Post> | undefined; 8 9 let sortedPosts: Post[] = []; 10 + 9 11 10 12 /** 11 13 * Validates and processes a single blog record ··· 123 125 profile = await getProfile(fetch); 124 126 } 125 127 126 - // Load and process posts if cache is empty 127 - if (allPosts === undefined) { 128 - const records = await loadAllPages(fetch); 128 + // Always fetch fresh data for blog posts 129 + const records = await loadAllPages(fetch); 129 130 130 - const mdposts: Map<string, MarkdownPost> = new Map(); 131 - for (const data of records) { 132 - const processed = processRecord(data); 133 - if (processed) { 134 - mdposts.set(processed.rkey, processed); 135 - } 131 + const mdposts: Map<string, MarkdownPost> = new Map(); 132 + for (const data of records) { 133 + const processed = processRecord(data); 134 + if (processed) { 135 + mdposts.set(processed.rkey, processed); 136 136 } 137 + } 137 138 138 - console.log(`Processed ${mdposts.size} posts from ${records.length} total records`); 139 - 140 - // Convert markdown posts to full post format 141 - allPosts = await parse(mdposts); 139 + // Convert markdown posts to full post format 140 + allPosts = await parse(mdposts); 142 141 143 - // Sort posts chronologically (newest first) 144 - sortedPosts = Array.from(allPosts.values()).sort( 145 - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 146 - ); 142 + // Sort posts chronologically (newest first) 143 + sortedPosts = Array.from(allPosts.values()).sort( 144 + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 145 + ); 147 146 148 - // Assign reverse post numbers 149 - const total = sortedPosts.length; 150 - sortedPosts.forEach((post, index) => { 151 - post.postNumber = total - index; 152 - }); 153 - } 147 + // Assign reverse post numbers 148 + const total = sortedPosts.length; 149 + sortedPosts.forEach((post, index) => { 150 + post.postNumber = total - index; 151 + }); 154 152 155 153 return { 156 154 posts: allPosts, 157 155 profile, 158 156 sortedPosts, 159 - getPost: (rkey: string) => allPosts.get(rkey) ?? null, 157 + getPost: (rkey: string) => allPosts?.get(rkey) ?? null, 160 158 getAdjacentPosts: (rkey: string) => { 161 159 const idx = sortedPosts.findIndex(p => p.rkey === rkey); 162 160 return {
+27
src/lib/utils/cache.ts
··· 1 + // Server-side in-memory TTL cache (for server endpoints) 2 + export interface CacheEntry<T> { 3 + value: T | undefined; 4 + timestamp: number; 5 + } 6 + 7 + export class TTLCache<T> { 8 + private entry: CacheEntry<T> = { value: undefined, timestamp: 0 }; 9 + constructor(private ttlMs: number) {} 10 + 11 + get(): T | undefined { 12 + if (Date.now() - this.entry.timestamp > this.ttlMs) { 13 + this.entry.value = undefined; 14 + } 15 + return this.entry.value; 16 + } 17 + 18 + set(value: T) { 19 + this.entry.value = value; 20 + this.entry.timestamp = Date.now(); 21 + } 22 + 23 + clear() { 24 + this.entry.value = undefined; 25 + this.entry.timestamp = 0; 26 + } 27 + } 1 28 /** 2 29 * Caches data in localStorage with a specified expiry time. 3 30 * @param key The key to store the data under.
+14
src/lib/utils/xml.ts
··· 1 + // XML utility functions 2 + 3 + /** 4 + * Escapes special characters for XML output. 5 + */ 6 + export function escapeXml(unsafe: string): string { 7 + if (!unsafe) return ""; 8 + return unsafe 9 + .replace(/&/g, "&amp;") 10 + .replace(/</g, "&lt;") 11 + .replace(/>/g, "&gt;") 12 + .replace(/"/g, "&quot;") 13 + .replace(/'/g, "&apos;"); 14 + }
+18 -15
src/routes/blog/atom/+server.ts
··· 1 + import { TTLCache } from "$utils/cache"; 2 + import { escapeXml } from "$lib/utils/xml"; 3 + 4 + // TTL cache for Atom feed XML (5 min) 5 + const FEED_CACHE_TTL = 5 * 60 * 1000; 6 + const atomFeedCache = new TTLCache<string>(FEED_CACHE_TTL); 7 + 1 8 import type { RequestHandler } from "../rss/$types"; 2 9 import { dev } from "$app/environment"; 3 10 import { loadAllPosts } from "$services/blogService"; 4 11 5 12 export const GET: RequestHandler = async ({ url, fetch }) => { 13 + // Check cache first 14 + const cached = atomFeedCache.get(); 15 + if (cached) { 16 + return new Response(cached, { 17 + headers: { 18 + "Content-Type": "application/atom+xml; charset=utf-8", 19 + "Cache-Control": "max-age=0, s-maxage=3600", 20 + }, 21 + }); 22 + } 6 23 try { 7 24 const { profile, sortedPosts } = await loadAllPosts(fetch); 8 - 9 25 const baseUrl = dev ? url.origin : "https://ewancroft.uk"; 10 - 11 26 const atomXml = `<?xml version="1.0" encoding="utf-8"?> 12 27 <feed xmlns="http://www.w3.org/2005/Atom"> 13 28 <title>Blog - Ewan's Corner</title> ··· 42 57 ) 43 58 .join("")} 44 59 </feed>`; 45 - 60 + atomFeedCache.set(atomXml); 46 61 return new Response(atomXml, { 47 62 headers: { 48 63 "Content-Type": "application/atom+xml; charset=utf-8", ··· 51 66 }); 52 67 } catch (error) { 53 68 console.error("Error generating Atom feed:", error); 54 - 55 69 const baseUrl = dev ? url.origin : "https://ewancroft.uk"; 56 - 57 70 return new Response( 58 71 `<?xml version="1.0" encoding="utf-8"?> 59 72 <feed xmlns="http://www.w3.org/2005/Atom"> ··· 78 91 ); 79 92 } 80 93 }; 81 - 82 - function escapeXml(unsafe: string): string { 83 - if (!unsafe) return ""; 84 - return unsafe 85 - .replace(/&/g, "&amp;") 86 - .replace(/</g, "&lt;") 87 - .replace(/>/g, "&gt;") 88 - .replace(/"/g, "&quot;") 89 - .replace(/'/g, "&apos;"); 90 - }
+12 -17
src/routes/blog/rss/+server.ts
··· 2 2 import { dev } from "$app/environment"; 3 3 import { parse } from "$lib/parser"; 4 4 import type { MarkdownPost } from "$components/shared"; 5 - import { getProfile } from "$components/profile/profile"; // Import getProfile 5 + import { getProfile } from "$components/profile/profile"; 6 + import { escapeXml } from "$lib/utils/xml"; 7 + import { TTLCache } from "$utils/cache"; 8 + 9 + // TTL cache for RSS feed XML (5 min) 10 + const FEED_CACHE_TTL = 5 * 60 * 1000; 11 + const rssFeedCache = new TTLCache<string>(FEED_CACHE_TTL); 6 12 7 13 export const GET: RequestHandler = async ({ url, fetch }) => { 8 14 try { ··· 112 118 </channel> 113 119 </rss>`, 114 120 { 115 - headers: { 116 - "Content-Type": "application/xml", 117 - "Cache-Control": "no-cache", 118 - }, 121 + headers: { 122 + "Content-Type": "application/xml", 123 + "Cache-Control": "no-cache", 124 + }, 119 125 } 120 126 ); 121 127 } 122 - }; 123 - 124 - // Helper function to escape XML special characters 125 - function escapeXml(unsafe: string): string { 126 - if (!unsafe) return ""; 127 - return unsafe 128 - .replace(/&/g, "&amp;") 129 - .replace(/</g, "&lt;") 130 - .replace(/>/g, "&gt;") 131 - .replace(/"/g, "&quot;") 132 - .replace(/'/g, "&apos;"); 133 - } 128 + };
+43 -45
src/routes/now/atom/+server.ts
··· 4 4 import { getProfile } from "$components/profile/profile"; 5 5 import { formatDate } from "$utils/formatters"; 6 6 import type { StatusUpdate } from "$components/shared"; 7 + import { escapeXml } from "$lib/utils/xml"; 8 + import { TTLCache } from "$utils/cache"; 9 + 10 + // TTL cache for status updates (5 min) 11 + const STATUS_CACHE_TTL = 5 * 60 * 1000; 12 + const statusCache = new TTLCache<{ profileData: any; sortedUpdates: StatusUpdate[] }>(STATUS_CACHE_TTL); 13 + 7 14 8 15 export const GET: RequestHandler = async ({ url, fetch }: { url: URL, fetch: typeof globalThis.fetch }) => { 9 16 const baseUrl = dev ? url.origin : "https://ewancroft.uk"; 10 - 11 17 try { 12 - const profileData = await getProfile(fetch); 13 - const did = profileData.did; 14 - const pdsUrl = profileData.pds; 15 - 16 - if (!pdsUrl) throw new Error("Could not find PDS URL"); 17 - 18 - const statusResponse = await fetch( 19 - `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=uk.ewancroft.now` 20 - ); 21 - 22 - if (!statusResponse.ok) 23 - throw new Error(`Status fetch failed: ${statusResponse.status}`); 24 - 25 - const statusData = await statusResponse.json(); 26 - const statusUpdates: StatusUpdate[] = []; 27 - 28 - for (const data of statusData.records) { 29 - const matches = data.uri.split("/"); 30 - const tid = matches[matches.length - 1]; 31 - const record = data.value; 32 - 33 - if (matches && matches.length === 5 && record) { 34 - statusUpdates.push({ 35 - text: record.text, 36 - createdAt: new Date(record.createdAt), 37 - tid, 38 - }); 18 + let cached = statusCache.get(); 19 + let profileData: any; 20 + let sortedUpdates: StatusUpdate[]; 21 + 22 + if (cached) { 23 + profileData = cached.profileData; 24 + sortedUpdates = cached.sortedUpdates; 25 + } else { 26 + profileData = await getProfile(fetch); 27 + const did = profileData.did; 28 + const pdsUrl = profileData.pds; 29 + if (!pdsUrl) throw new Error("Could not find PDS URL"); 30 + const statusResponse = await fetch( 31 + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=uk.ewancroft.now` 32 + ); 33 + if (!statusResponse.ok) 34 + throw new Error(`Status fetch failed: ${statusResponse.status}`); 35 + const statusData = await statusResponse.json(); 36 + const statusUpdates: StatusUpdate[] = []; 37 + for (const data of statusData.records) { 38 + const matches = data.uri.split("/"); 39 + const tid = matches[matches.length - 1]; 40 + const record = data.value; 41 + if (matches && matches.length === 5 && record) { 42 + statusUpdates.push({ 43 + text: record.text, 44 + createdAt: new Date(record.createdAt), 45 + tid, 46 + }); 47 + } 39 48 } 49 + sortedUpdates = statusUpdates.sort( 50 + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 51 + ); 52 + statusCache.set({ profileData, sortedUpdates }); 40 53 } 41 - 42 - const sortedUpdates = statusUpdates.sort( 43 - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 44 - ); 45 - 54 + 46 55 const atomXml = `<?xml version="1.0" encoding="utf-8"?> 47 56 <feed xmlns="http://www.w3.org/2005/Atom"> 48 57 <title>Now - ${profileData.displayName || profileData.handle}'s Status Updates</title> ··· 85 94 }); 86 95 } catch (error) { 87 96 console.error("Error generating Atom feed:", error); 88 - 89 97 return new Response( 90 98 `<?xml version="1.0" encoding="utf-8"?> 91 99 <feed xmlns="http://www.w3.org/2005/Atom"> ··· 109 117 } 110 118 ); 111 119 } 112 - }; 113 - 114 - function escapeXml(unsafe: string): string { 115 - if (!unsafe) return ""; 116 - return unsafe 117 - .replace(/&/g, "&amp;") 118 - .replace(/</g, "&lt;") 119 - .replace(/>/g, "&gt;") 120 - .replace(/"/g, "&quot;") 121 - .replace(/'/g, "&apos;"); 122 - } 120 + };
+43 -45
src/routes/now/rss/+server.ts
··· 3 3 import { getProfile } from "$components/profile/profile"; 4 4 import { formatDate } from "$utils/formatters"; 5 5 import type { StatusUpdate } from "$components/shared"; 6 - 7 - export const GET: RequestHandler = async ({ url, fetch }: { url: URL, fetch: typeof globalThis.fetch }) => { 8 - let baseUrl: string; 9 - 10 - baseUrl = dev ? url.origin : "https://ewancroft.uk"; 6 + import { escapeXml } from "$lib/utils/xml"; 11 7 12 - try { 13 - const profileData = await getProfile(fetch); 14 - 15 - const did = profileData.did; 16 - const pdsUrl = profileData.pds; 8 + import { TTLCache } from "$utils/cache"; 17 9 18 - if (!pdsUrl) throw new Error("Could not find PDS URL"); 10 + // TTL cache for status updates (5 min) 11 + const STATUS_CACHE_TTL = 5 * 60 * 1000; 12 + const statusCache = new TTLCache<{ profileData: any; sortedUpdates: StatusUpdate[] }>(STATUS_CACHE_TTL); 19 13 20 - const statusResponse = await fetch( 21 - `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=uk.ewancroft.now` 22 - ); 23 - if (!statusResponse.ok) 24 - throw new Error(`Status fetch failed: ${statusResponse.status}`); 25 - const statusData = await statusResponse.json(); 14 + export const GET: RequestHandler = async ({ url, fetch }: { url: URL, fetch: typeof globalThis.fetch }) => { 15 + const baseUrl = dev ? url.origin : "https://ewancroft.uk"; 26 16 27 - const statusUpdates: StatusUpdate[] = []; 28 - for (const data of statusData.records) { 29 - const matches = data.uri.split("/"); 30 - const tid = matches[matches.length - 1]; 31 - const record = data.value; 17 + try { 18 + let cached = statusCache.get(); 19 + let profileData: any; 20 + let sortedUpdates: StatusUpdate[]; 32 21 33 - if (matches && matches.length === 5 && record) { 34 - statusUpdates.push({ 35 - text: record.text, 36 - createdAt: new Date(record.createdAt), 37 - tid, 38 - }); 22 + if (cached) { 23 + profileData = cached.profileData; 24 + sortedUpdates = cached.sortedUpdates; 25 + } else { 26 + profileData = await getProfile(fetch); 27 + const did = profileData.did; 28 + const pdsUrl = profileData.pds; 29 + if (!pdsUrl) throw new Error("Could not find PDS URL"); 30 + const statusResponse = await fetch( 31 + `${pdsUrl}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=uk.ewancroft.now` 32 + ); 33 + if (!statusResponse.ok) 34 + throw new Error(`Status fetch failed: ${statusResponse.status}`); 35 + const statusData = await statusResponse.json(); 36 + const statusUpdates: StatusUpdate[] = []; 37 + for (const data of statusData.records) { 38 + const matches = data.uri.split("/"); 39 + const tid = matches[matches.length - 1]; 40 + const record = data.value; 41 + if (matches && matches.length === 5 && record) { 42 + statusUpdates.push({ 43 + text: record.text, 44 + createdAt: new Date(record.createdAt), 45 + tid, 46 + }); 47 + } 39 48 } 49 + sortedUpdates = statusUpdates.sort( 50 + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 51 + ); 52 + statusCache.set({ profileData, sortedUpdates }); 40 53 } 41 54 42 - const sortedUpdates = statusUpdates.sort( 43 - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() 44 - ); 45 - 46 - baseUrl = dev ? url.origin : "https://ewancroft.uk"; 47 - 55 + const did = profileData.did; 48 56 const rssXml = `<?xml version="1.0" encoding="UTF-8" ?> 49 57 <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"> 50 58 <channel> ··· 94 102 } 95 103 ); 96 104 } 97 - }; 98 - 99 - function escapeXml(unsafe: string): string { 100 - if (!unsafe) return ""; 101 - return unsafe 102 - .replace(/&/g, "&amp;") 103 - .replace(/</g, "&lt;") 104 - .replace(/>/g, "&gt;") 105 - .replace(/"/g, "&quot;") 106 - .replace(/'/g, "&apos;"); 107 - } 105 + };