|
| 1 | +/** |
| 2 | + * CF Cache Status - Background Script |
| 3 | + * |
| 4 | + * Intercepts HTTP responses to extract CDN cache headers and updates |
| 5 | + * the toolbar badge with the cache status (HIT/MISS/etc). |
| 6 | + * |
| 7 | + * Supports: Cloudflare, CloudFront, Fastly, Akamai, Bunny CDN, Varnish |
| 8 | + */ |
| 9 | + |
| 10 | +// ============================================================================= |
| 11 | +// State Management |
| 12 | +// ============================================================================= |
| 13 | + |
| 14 | +/** Stores cache header data per tab ID */ |
| 15 | +const tabData = new Map(); |
| 16 | + |
| 17 | +/** Tracks tabs with pending navigations to capture only the first main request */ |
| 18 | +const pendingNavigations = new Set(); |
| 19 | + |
| 20 | +// ============================================================================= |
| 21 | +// Configuration |
| 22 | +// ============================================================================= |
| 23 | + |
| 24 | +/** HTTP headers to capture from responses */ |
| 25 | +const TRACKED_HEADERS = [ |
| 26 | + // Cloudflare |
| 27 | + 'cf-cache-status', 'cf-ray', 'cf-pop', |
| 28 | + // CloudFront |
| 29 | + 'x-amz-cf-id', 'x-amz-cf-pop', |
| 30 | + // Fastly |
| 31 | + 'x-served-by', 'x-cache-hits', 'x-timer', |
| 32 | + // Akamai |
| 33 | + 'x-akamai-request-id', |
| 34 | + // Bunny CDN |
| 35 | + 'cdn-cache', 'cdn-pullzone', 'cdn-requestid', |
| 36 | + // Generic (CloudFront, Fastly, Akamai, Varnish, KeyCDN, etc.) |
| 37 | + 'x-cache', 'x-cache-status', 'x-varnish', 'x-edge-location', 'via', |
| 38 | + // Standard cache headers |
| 39 | + 'age', 'cache-control', 'expires', 'etag', 'last-modified', 'vary', 'pragma', |
| 40 | + // Response metadata |
| 41 | + 'server', 'content-type' |
| 42 | +]; |
| 43 | + |
| 44 | +/** Badge colors for each cache status */ |
| 45 | +const STATUS_COLORS = { |
| 46 | + 'HIT': { badge: '#22c55e', text: '#fff' }, // Green |
| 47 | + 'MISS': { badge: '#ef4444', text: '#fff' }, // Red |
| 48 | + 'EXPIRED': { badge: '#eab308', text: '#000' }, // Yellow |
| 49 | + 'STALE': { badge: '#eab308', text: '#000' }, // Yellow |
| 50 | + 'REVALIDATED': { badge: '#eab308', text: '#000' }, // Yellow |
| 51 | + 'REFRESH': { badge: '#eab308', text: '#000' }, // Yellow |
| 52 | + 'BYPASS': { badge: '#6b7280', text: '#fff' }, // Gray |
| 53 | + 'DYNAMIC': { badge: '#6b7280', text: '#fff' }, // Gray |
| 54 | + 'ERROR': { badge: '#ef4444', text: '#fff' }, // Red |
| 55 | + 'NONE': { badge: '#6b7280', text: '#fff' } // Gray (no CDN) |
| 56 | +}; |
| 57 | + |
| 58 | +// ============================================================================= |
| 59 | +// CDN Detection |
| 60 | +// ============================================================================= |
| 61 | + |
| 62 | +/** |
| 63 | + * Detects the CDN provider based on response headers. |
| 64 | + * @param {Object} headers - Lowercase header name to value mapping |
| 65 | + * @returns {string|null} CDN identifier or null if not detected |
| 66 | + */ |
| 67 | +function detectCDN(headers) { |
| 68 | + if (headers['cf-cache-status'] || headers['cf-ray']) return 'cloudflare'; |
| 69 | + if (headers['x-amz-cf-id'] || headers['x-amz-cf-pop']) return 'cloudfront'; |
| 70 | + if (headers['x-served-by'] || headers['x-timer']) return 'fastly'; |
| 71 | + if (headers['x-akamai-request-id']) return 'akamai'; |
| 72 | + if (headers['cdn-cache'] || headers['cdn-pullzone']) return 'bunny'; |
| 73 | + if (headers['x-varnish']) return 'varnish'; |
| 74 | + |
| 75 | + // Check x-cache with via header for additional hints |
| 76 | + if (headers['x-cache']) { |
| 77 | + const via = (headers['via'] || '').toLowerCase(); |
| 78 | + if (via.includes('cloudfront')) return 'cloudfront'; |
| 79 | + if (via.includes('varnish')) return 'varnish'; |
| 80 | + if (via.includes('akamai')) return 'akamai'; |
| 81 | + return 'cdn'; // Generic CDN |
| 82 | + } |
| 83 | + |
| 84 | + return null; |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Parses cache status from headers based on the detected CDN. |
| 89 | + * @param {Object} headers - Lowercase header name to value mapping |
| 90 | + * @param {string} cdn - CDN identifier from detectCDN() |
| 91 | + * @returns {string|null} Normalized cache status (HIT, MISS, etc.) or null |
| 92 | + */ |
| 93 | +function parseCacheStatus(headers, cdn) { |
| 94 | + // Cloudflare uses its own header |
| 95 | + if (cdn === 'cloudflare') { |
| 96 | + return headers['cf-cache-status']?.toUpperCase() || null; |
| 97 | + } |
| 98 | + |
| 99 | + // Bunny CDN uses cdn-cache header |
| 100 | + if (cdn === 'bunny') { |
| 101 | + const status = headers['cdn-cache']; |
| 102 | + if (status) { |
| 103 | + const lower = status.toLowerCase(); |
| 104 | + if (lower.includes('hit')) return 'HIT'; |
| 105 | + if (lower.includes('miss')) return 'MISS'; |
| 106 | + } |
| 107 | + return null; |
| 108 | + } |
| 109 | + |
| 110 | + // Most CDNs use x-cache header (CloudFront, Fastly, Akamai, Varnish, etc.) |
| 111 | + const xCache = headers['x-cache'] || headers['x-cache-status']; |
| 112 | + if (xCache) { |
| 113 | + const lower = xCache.toLowerCase(); |
| 114 | + if (lower.includes('hit')) return 'HIT'; |
| 115 | + if (lower.includes('miss')) return 'MISS'; |
| 116 | + if (lower.includes('refresh')) return 'REFRESH'; |
| 117 | + if (lower.includes('error')) return 'ERROR'; |
| 118 | + if (lower.includes('pass')) return 'BYPASS'; |
| 119 | + if (lower.includes('expired')) return 'EXPIRED'; |
| 120 | + } |
| 121 | + |
| 122 | + return null; |
| 123 | +} |
| 124 | + |
| 125 | +// ============================================================================= |
| 126 | +// Badge Management |
| 127 | +// ============================================================================= |
| 128 | + |
| 129 | +/** |
| 130 | + * Updates the toolbar badge text and color for a tab. |
| 131 | + * Sets both globally and per-tab (Safari has inconsistent per-tab support). |
| 132 | + * @param {number} tabId - Browser tab ID |
| 133 | + * @param {string|null} status - Cache status (HIT, MISS, etc.) |
| 134 | + * @param {string|null} cdn - CDN identifier |
| 135 | + */ |
| 136 | +function updateBadge(tabId, status, cdn) { |
| 137 | + const displayStatus = status || 'NONE'; |
| 138 | + const colors = STATUS_COLORS[displayStatus] || STATUS_COLORS['NONE']; |
| 139 | + |
| 140 | + // Map status to shortened badge text |
| 141 | + const badgeTextMap = { |
| 142 | + 'HIT': 'HIT', 'MISS': 'MISS', 'EXPIRED': 'EXP', 'STALE': 'STL', |
| 143 | + 'REVALIDATED': 'REV', 'BYPASS': 'BYP', 'DYNAMIC': 'DYN', |
| 144 | + 'REFRESH': 'REF', 'ERROR': 'ERR' |
| 145 | + }; |
| 146 | + const badgeText = status ? (badgeTextMap[status] || status.substring(0, 3)) : ''; |
| 147 | + |
| 148 | + // Set badge globally (Safari doesn't always support per-tab badges) |
| 149 | + browser.action.setBadgeText({ text: badgeText }); |
| 150 | + browser.action.setBadgeBackgroundColor({ color: colors.badge }); |
| 151 | + |
| 152 | + // Also try per-tab for browsers that support it |
| 153 | + try { |
| 154 | + browser.action.setBadgeText({ text: badgeText, tabId }); |
| 155 | + browser.action.setBadgeBackgroundColor({ color: colors.badge, tabId }); |
| 156 | + } catch (e) { |
| 157 | + // Per-tab badges not supported |
| 158 | + } |
| 159 | +} |
| 160 | + |
| 161 | +/** |
| 162 | + * Clears the badge, setting it globally and per-tab. |
| 163 | + * @param {number} tabId - Browser tab ID |
| 164 | + */ |
| 165 | +function clearBadge(tabId) { |
| 166 | + browser.action.setBadgeText({ text: '' }); |
| 167 | + try { |
| 168 | + browser.action.setBadgeText({ text: '', tabId }); |
| 169 | + } catch (e) { |
| 170 | + // Per-tab badges not supported |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +// ============================================================================= |
| 175 | +// Event Listeners |
| 176 | +// ============================================================================= |
| 177 | + |
| 178 | +// Track navigation start to capture only the first main document request |
| 179 | +browser.webNavigation.onBeforeNavigate.addListener((details) => { |
| 180 | + if (details.frameId === 0) { // Main frame only |
| 181 | + pendingNavigations.add(details.tabId); |
| 182 | + tabData.delete(details.tabId); // Clear stale data |
| 183 | + } |
| 184 | +}); |
| 185 | + |
| 186 | +// Capture response headers for main document requests |
| 187 | +browser.webRequest.onHeadersReceived.addListener( |
| 188 | + (details) => { |
| 189 | + // Only process main frame requests we're expecting |
| 190 | + if (details.type !== 'main_frame' || details.frameId !== 0) return; |
| 191 | + if (!pendingNavigations.has(details.tabId)) return; |
| 192 | + |
| 193 | + pendingNavigations.delete(details.tabId); |
| 194 | + |
| 195 | + // Extract tracked headers |
| 196 | + const headers = {}; |
| 197 | + for (const header of details.responseHeaders || []) { |
| 198 | + const name = header.name.toLowerCase(); |
| 199 | + if (TRACKED_HEADERS.includes(name)) { |
| 200 | + headers[name] = header.value; |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + // Detect CDN and parse status |
| 205 | + const cdn = detectCDN(headers); |
| 206 | + const status = parseCacheStatus(headers, cdn); |
| 207 | + |
| 208 | + // Store data for popup |
| 209 | + tabData.set(details.tabId, { |
| 210 | + url: details.url, |
| 211 | + headers, |
| 212 | + status, |
| 213 | + cdn, |
| 214 | + timestamp: Date.now() |
| 215 | + }); |
| 216 | + |
| 217 | + updateBadge(details.tabId, status, cdn); |
| 218 | + }, |
| 219 | + { urls: ['<all_urls>'] }, |
| 220 | + ['responseHeaders'] |
| 221 | +); |
| 222 | + |
| 223 | +// Clean up data when tab closes |
| 224 | +browser.tabs.onRemoved.addListener((tabId) => { |
| 225 | + tabData.delete(tabId); |
| 226 | + pendingNavigations.delete(tabId); |
| 227 | +}); |
| 228 | + |
| 229 | +// Update badge when switching tabs |
| 230 | +browser.tabs.onActivated.addListener((activeInfo) => { |
| 231 | + const data = tabData.get(activeInfo.tabId); |
| 232 | + if (data) { |
| 233 | + updateBadge(activeInfo.tabId, data.status, data.cdn); |
| 234 | + } else { |
| 235 | + clearBadge(activeInfo.tabId); |
| 236 | + } |
| 237 | +}); |
| 238 | + |
| 239 | +// Update badge when window focus changes |
| 240 | +browser.windows.onFocusChanged.addListener(async (windowId) => { |
| 241 | + if (windowId === browser.windows.WINDOW_ID_NONE) return; |
| 242 | + |
| 243 | + try { |
| 244 | + const tabs = await browser.tabs.query({ active: true, windowId }); |
| 245 | + if (tabs?.[0]) { |
| 246 | + const data = tabData.get(tabs[0].id); |
| 247 | + if (data) { |
| 248 | + updateBadge(tabs[0].id, data.status, data.cdn); |
| 249 | + } else { |
| 250 | + clearBadge(tabs[0].id); |
| 251 | + } |
| 252 | + } |
| 253 | + } catch (e) { |
| 254 | + // Ignore errors during window switching |
| 255 | + } |
| 256 | +}); |
| 257 | + |
| 258 | +// Handle messages from popup requesting tab data |
| 259 | +browser.runtime.onMessage.addListener((message, sender, sendResponse) => { |
| 260 | + if (message.type === 'getTabData') { |
| 261 | + sendResponse(tabData.get(message.tabId) || null); |
| 262 | + } |
| 263 | + return true; |
| 264 | +}); |
0 commit comments