Skip to content

Commit f226287

Browse files
committed
Initial commit: Cache Status Safari extension
A Safari extension that displays CDN cache status (HIT/MISS) for the current page with detailed header information. Features: - Toolbar badge with color-coded cache status (HIT=green, MISS=red, etc.) - Popup showing cache headers, edge location, and response metadata - Edge location mapping (IATA codes to city names) - Multi-CDN support: Cloudflare, CloudFront, Fastly, Akamai, Bunny CDN, Varnish - Dark mode support - iOS Settings-inspired UI design Technical implementation: - Uses webNavigation API to detect page navigations - Uses webRequest API to capture response headers for main document only - Background script detects CDN provider and parses cache status - Popup displays headers in grouped sections (Cache, Response)
0 parents  commit f226287

35 files changed

Lines changed: 2150 additions & 0 deletions

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# macOS
2+
.DS_Store
3+
.AppleDouble
4+
.LSOverride
5+
6+
# Xcode
7+
build/
8+
DerivedData/
9+
*.xcuserstate
10+
*.xcworkspace/xcuserdata/
11+
*.xcodeproj/xcuserdata/
12+
*.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
13+
14+
# Claude Code
15+
.claude/
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSExtension</key>
6+
<dict>
7+
<key>NSExtensionPointIdentifier</key>
8+
<string>com.apple.Safari.web-extension</string>
9+
<key>NSExtensionPrincipalClass</key>
10+
<string>$(PRODUCT_MODULE_NAME).SafariWebExtensionHandler</string>
11+
</dict>
12+
</dict>
13+
</plist>
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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+
});
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Cache Status",
4+
"version": "1.0",
5+
"description": "Shows CDN cache status (HIT/MISS) for the current page. Supports Cloudflare, CloudFront, Fastly, Akamai, and more.",
6+
"icons": {
7+
"16": "images/icon.svg",
8+
"32": "images/icon.svg",
9+
"48": "images/icon.svg",
10+
"128": "images/icon.svg"
11+
},
12+
"action": {
13+
"default_popup": "popup.html",
14+
"default_icon": {
15+
"16": "images/icon.svg",
16+
"32": "images/icon.svg",
17+
"48": "images/icon.svg",
18+
"128": "images/icon.svg"
19+
}
20+
},
21+
"background": {
22+
"service_worker": "background.js"
23+
},
24+
"permissions": [
25+
"webRequest",
26+
"webNavigation",
27+
"activeTab",
28+
"storage"
29+
],
30+
"host_permissions": [
31+
"<all_urls>"
32+
]
33+
}

0 commit comments

Comments
 (0)