···9898 let allStories = []; // Store all stories for filtering
9999 let showVerifiedOnly = false; // Default to showing all stories
100100101101- // Cache for ETags and data to support conditional requests
102102- const etagCache = {
101101+ // Cache for ETags and data to support conditional requests with sessionStorage persistence
102102+ const etagCache = JSON.parse(sessionStorage.getItem('etagCache') || JSON.stringify({
103103 stories: null,
104104 totalStories: null,
105105 verifiedUsers: null,
106106- };
106106+ }));
107107108108 // Cache for actual response data
109109- const responseCache = {
109109+ const responseCache = JSON.parse(sessionStorage.getItem('responseCache') || JSON.stringify({
110110 stories: null,
111111 totalStories: null,
112112 verifiedUsers: null,
113113- };
113113+ }));
114114+115115+ // Helper function to persist cache state
116116+ function persistCaches() {
117117+ sessionStorage.setItem('etagCache', JSON.stringify(etagCache));
118118+ sessionStorage.setItem('responseCache', JSON.stringify(responseCache));
119119+ }
114120115121 // Fetch stories data
116122 function fetchStories() {
···142148 .then((response) => {
143149 // Store the new ETag if available
144150 const etag = response.headers.get("ETag");
145145- if (etag) etagCache.totalStories = etag;
151151+ if (etag) {
152152+ etagCache.totalStories = etag;
153153+ persistCaches();
154154+ }
146155147156 // If 304 Not Modified, use cached data
148157 if (response.status === 304) {
149158 console.log("Total stories not modified, using cached data");
150150- return responseCache.totalStories; // Use cached data
159159+ return Promise.resolve(responseCache.totalStories); // Use cached data
151160 }
152161153162 return response.json();
···156165 if (data) {
157166 // Store in cache for future 304 responses
158167 responseCache.totalStories = data;
168168+ persistCaches();
159169160170 if (typeof data.count !== "undefined") {
161171 totalStoriesCount = data.count;
···177187 verifiedUsersOptions.headers["If-None-Match"] = etagCache.verifiedUsers;
178188 }
179189190190+ // Add Accept-Encoding header if browser supports it
191191+ if ('Accept-Encoding' in navigator) {
192192+ verifiedUsersOptions.headers["Accept-Encoding"] = "gzip, deflate, br";
193193+ }
194194+180195 fetch("/api/stats/verified-users", verifiedUsersOptions)
181196 .then((response) => {
182197 // Store the new ETag if available
183198 const etag = response.headers.get("ETag");
184184- if (etag) etagCache.verifiedUsers = etag;
199199+ if (etag) {
200200+ etagCache.verifiedUsers = etag;
201201+ persistCaches();
202202+ }
185203186204 // If 304 Not Modified, use cached data
187205 if (response.status === 304) {
188188- console.log("Verified users stats not modified, using cached data");
189189- return responseCache.verifiedUsers; // Use cached data
206206+ console.log("Verified users not modified, using cached data");
207207+ return Promise.resolve(responseCache.verifiedUsers); // Use cached data
190208 }
191209192210 return response.json();
···195213 if (data) {
196214 // Store in cache for future 304 responses
197215 responseCache.verifiedUsers = data;
216216+ persistCaches();
198217199218 verifiedUserStats = data;
200219 updateTopStats(data); // Update UI with the new stats
···214233 storiesOptions.headers["If-None-Match"] = etagCache.stories;
215234 }
216235236236+ // Add Accept-Encoding header if browser supports it
237237+ if ('Accept-Encoding' in navigator) {
238238+ storiesOptions.headers["Accept-Encoding"] = "gzip, deflate, br";
239239+ }
240240+217241 fetch("/api/stories", storiesOptions)
218242 .then((response) => {
219243 // Store the new ETag if available
220244 const etag = response.headers.get("ETag");
221221- if (etag) etagCache.stories = etag;
245245+ if (etag) {
246246+ etagCache.stories = etag;
247247+ persistCaches();
248248+ }
222249223250 if (!response.ok) {
224251 // Allow 304 Not Modified
225252 if (response.status === 304) {
226253 console.log("Stories not modified, using cached data");
227227- return responseCache.stories; // Use cached data
254254+ return Promise.resolve(responseCache.stories); // Use cached data
228255 }
229256 throw new Error("Network response was not ok");
230257 }
···234261 if (data) {
235262 // Store in cache for future 304 responses
236263 responseCache.stories = data;
264264+ persistCaches();
237265238266 // Store all stories for filtering
239267 allStories = data;
+65-16
public/item.html
···505505 const urlParams = new URLSearchParams(window.location.search);
506506 const storyId = urlParams.get("id");
507507508508- // Cache for snapshot data and ETags
509509- const snapshotDataCache = {};
510510- const snapshotEtagCache = {};
508508+ // Cache for snapshot data and ETags with localStorage persistence
509509+ const snapshotDataCache = JSON.parse(localStorage.getItem('snapshotDataCache') || '{}');
510510+ const snapshotEtagCache = JSON.parse(localStorage.getItem('snapshotEtagCache') || '{}');
511511+512512+ // Helper functions for ETag management
513513+ const etagManager = {
514514+ save: () => {
515515+ localStorage.setItem('snapshotEtagCache', JSON.stringify(snapshotEtagCache));
516516+ localStorage.setItem('snapshotDataCache', JSON.stringify(snapshotDataCache));
517517+ },
518518+ clear: () => {
519519+ localStorage.removeItem('snapshotEtagCache');
520520+ localStorage.removeItem('snapshotDataCache');
521521+ Object.keys(snapshotDataCache).forEach(key => delete snapshotDataCache[key]);
522522+ Object.keys(snapshotEtagCache).forEach(key => delete snapshotEtagCache[key]);
523523+ }
524524+ };
511525512526 // Check if we have a valid story ID
513527 if (!storyId) {
···524538 }
525539526540 function fetchStoryData(storyId) {
527527- fetch(`/api/story/${storyId}`)
541541+ const options = {
542542+ headers: {}
543543+ };
544544+545545+ // Add If-None-Match header if we have an ETag for this story
546546+ const storyKey = `story_${storyId}`;
547547+ if (snapshotEtagCache[storyKey]) {
548548+ options.headers["If-None-Match"] = snapshotEtagCache[storyKey];
549549+ }
550550+551551+ fetch(`/api/story/${storyId}`, options)
528552 .then((response) => {
553553+ // Store the new ETag if available
554554+ const etag = response.headers.get("ETag");
555555+ if (etag) {
556556+ snapshotEtagCache[storyKey] = etag;
557557+ etagManager.save();
558558+ }
559559+560560+ // If 304 Not Modified, use cached data
561561+ if (response.status === 304) {
562562+ console.log("Story not modified, using cached data");
563563+ return snapshotDataCache[storyKey];
564564+ }
565565+529566 if (!response.ok) {
530567 throw new Error(`HTTP error ${response.status}`);
531568 }
532532- return response.json();
569569+570570+ return response.json().then(data => {
571571+ // Cache the data
572572+ snapshotDataCache[storyKey] = data;
573573+ etagManager.save();
574574+ return data;
575575+ });
533576 })
534577 .then((story) => {
535578 displayStoryData(story);
···689732 headers: {},
690733 };
691734692692- // Add If-None-Match header if we have an ETag for this story
693693- if (snapshotEtagCache[storyId]) {
694694- options.headers["If-None-Match"] =
695695- snapshotEtagCache[storyId];
735735+ // Create snapshot-specific cache key
736736+ const snapshotKey = `snapshots_${storyId}`;
737737+738738+ // Add If-None-Match header if we have an ETag for this story's snapshots
739739+ if (snapshotEtagCache[snapshotKey]) {
740740+ options.headers["If-None-Match"] = snapshotEtagCache[snapshotKey];
696741 }
697742698743 fetch(`/api/story/${storyId}/snapshots`, options)
699744 .then((response) => {
700745 // Store the new ETag if available
701746 const etag = response.headers.get("ETag");
702702- if (etag) snapshotEtagCache[storyId] = etag;
747747+ if (etag) {
748748+ snapshotEtagCache[snapshotKey] = etag;
749749+ etagManager.save();
750750+ }
703751704752 if (!response.ok) {
705705- // Allow 304 Not Modified
706706- if (response.status === 304) {
753753+ // Handle 304 Not Modified - use cached data
754754+ if (response.status === 304 && snapshotDataCache[snapshotKey]) {
707755 console.log(
708756 `Story ${storyId} snapshots not modified, using cached data`,
709757 );
710758 // Use the cached data for this story ID
711711- if (snapshotDataCache[storyId]) {
712712- return snapshotDataCache[storyId];
713713- }
759759+ if (snapshotDataCache[snapshotKey]) {
760760+ return snapshotDataCache[snapshotKey];
761761+ }
714762 // If we don't have cached data, re-fetch
715763 throw new Error(
716764 "Cache miss on 304, re-fetching",
···730778 }
731779732780 // Cache the snapshots data for future use
733733- snapshotDataCache[storyId] = snapshots;
781781+ snapshotDataCache[snapshotKey] = snapshots;
782782+ etagManager.save();
734783735784 // Show the chart
736785 noGraph.style.display = "none";
+33-5
src/index.ts
···174174 // Register this dynamic query for potential cache warming
175175 queryCache.register(cacheKey, queryFn, 600);
176176177177+ // Check client ETag before executing query
178178+ const clientETag = req.headers.get("if-none-match");
179179+177180 // Execute the query with caching
178181 const data = await queryCache.get(cacheKey, queryFn, 600);
179182···184187 });
185188 }
186189187187- // Create response with cached headers
188188- const headers = createCacheHeaders(cacheKey, 600);
189189- const response = new Response(JSON.stringify(data), { headers });
190190+ // Create response with cached headers based on actual data content
191191+ const headers = createCacheHeaders(cacheKey, 600, data);
190192193193+ // Return 304 if client's ETag matches our data-based ETag
194194+ if (clientETag && clientETag === headers.ETag) {
195195+ return new Response(null, {
196196+ status: 304,
197197+ headers: {
198198+ ETag: headers.ETag,
199199+ "Cache-Control": headers["Cache-Control"] as string,
200200+ },
201201+ });
202202+ }
203203+204204+ const response = new Response(JSON.stringify(data), { headers });
191205 return compressResponse(req, response);
192206 } catch (error) {
193207 if (!isProduction) {
···393407 // Execute the query with caching
394408 const data = await queryCache.get(cacheKey, queryFn, 3600);
395409396396- // Use cached headers for better performance
397397- const headers = createCacheHeaders(cacheKey, 3600);
410410+ // Check client ETag before sending response
411411+ const clientETag = req.headers.get("if-none-match");
412412+413413+ // Create response with cached headers based on actual data
414414+ const headers = createCacheHeaders(cacheKey, 3600, data);
415415+416416+ // Return 304 if client's ETag matches our data-based ETag
417417+ if (clientETag && clientETag === headers.ETag) {
418418+ return new Response(null, {
419419+ status: 304,
420420+ headers: {
421421+ ETag: headers.ETag,
422422+ "Cache-Control": headers["Cache-Control"] as string,
423423+ },
424424+ });
425425+ }
398426399427 // Create response with optimized headers
400428 const response = new Response(JSON.stringify(data), { headers });
+25-19
src/libs/cache.ts
···1313export function createCacheHeaders(
1414 key: string,
1515 maxAge = 300,
1616+ data?: unknown,
1617): Record<string, string> {
1717- // Generate stable ETag based on version and cache key
1818- // Only changes when version changes or when cache TTL expires (divided by TTL)
1919- const etag = `"${version}-${key}-${Math.floor(Date.now() / (maxAge * 1000))}"`;
1818+ // Generate stable ETag based on version, cache key, and data hash if available
1919+ let etag: string;
2020+2121+ if (data) {
2222+ // Generate based on actual data content for stronger validation
2323+ const dataStr = JSON.stringify(data);
2424+ const dataHash = Bun.hash(dataStr).toString(36).slice(0, 12);
2525+ etag = `"${version}-${key}-${dataHash}"`;
2626+ } else {
2727+ // Fallback to time-based for headers without data
2828+ etag = `"${version}-${key}-${Math.floor(Date.now() / (maxAge * 1000))}"`;
2929+ }
20302131 return {
2232 "Content-Type": "application/json",
···628638 const defaultToPriority = cacheKey === "leaderboard_stories" || isPriority;
629639 queryCache.register(cacheKey, queryFn, ttl, defaultToPriority);
630640631631- // Pre-create cache headers to avoid recreating them on each request
632632- const cacheHeaders = createCacheHeaders(cacheKey, ttl);
633633-634641 // Prepare common response headers
635642 const errorHeaders = {
636643 "Content-Type": "application/json",
···649656 const requestStart = isProduction ? 0 : performance.now();
650657651658 try {
652652- // Check client ETag before executing query
659659+ // Get data from cache or execute query first
660660+ const data = await queryCache.get(cacheKey, queryFn, ttl);
661661+662662+ // Generate data-based ETag for better validation
663663+ const headers = createCacheHeaders(cacheKey, ttl, data);
664664+665665+ // Check client ETag after we have our data
653666 const clientETag = request.headers.get("if-none-match");
654654- const cacheHeaders = createCacheHeaders(cacheKey, ttl);
655655-656656- // If client ETag matches, return 304
657657- if (clientETag && clientETag === cacheHeaders.ETag) {
667667+668668+ // Return 304 if client's ETag matches our data-based ETag
669669+ if (clientETag && clientETag === headers.ETag) {
658670 return new Response(null, {
659671 status: 304,
660672 headers: {
661661- ETag: cacheHeaders.ETag,
662662- "Cache-Control": cacheHeaders["Cache-Control"] as string,
673673+ ETag: headers.ETag,
674674+ "Cache-Control": headers["Cache-Control"] as string,
663675 },
664676 });
665677 }
666666-667667- // Get data from cache or execute query
668668- const data = await queryCache.get(cacheKey, queryFn, ttl);
669669-670670- // Create response with proper caching headers and timing info
671671- const headers = { ...cacheHeaders };
672678673679 // Add server timing header in development
674680 if (!isProduction && requestStart > 0) {