1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'CompressionHeadersAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8
9
10
11const ALREADY_COMPRESSED = [
12 /^image\/(jpeg|png|gif|webp|avif|heic|heif)/i,
13 /^video\//i,
14 /^audio\//i,
15 /^application\/zip/i,
16 /^application\/(x-)?gzip/i,
17 /^application\/x-bzip2/i,
18 /^application\/x-7z-compressed/i,
19 /^application\/x-tar/i,
20 /^application\/x-rar/i,
21 /^application\/pdf/i,
22 /^application\/wasm/i,
23 /^font\/(woff|woff2)/i,
24 /^application\/octet-stream/i,
25];
26
27
28
29
30
31
32
33
34function isPrivateIPv4(ip) {
35 const parts = ip.split('.').map(Number);
36 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
37 const [a, b] = parts;
38 return a === 10
39 || (a === 172 && b >= 16 && b <= 31)
40 || (a === 192 && b === 168)
41 || a === 127
42 || a === 0
43 || (a === 169 && b === 254);
44}
45
46function isPrivateIPv6(ip) {
47 const normalized = ip.toLowerCase();
48 return normalized === '::1'
49 || normalized.startsWith('fc')
50 || normalized.startsWith('fd')
51 || normalized.startsWith('fe80:');
52}
53
54export async function normalizeAndValidateUrl(rawUrl) {
55 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
56 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
57 throw new Error('Only HTTP and HTTPS URLs are supported');
58 }
59
60 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
61 const url = new URL(withScheme);
62 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
63 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
64
65 const literalType = net.isIP(url.hostname);
66 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
67 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
68
69 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
70 for (const record of records) {
71 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
72 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
73 }
74 return url;
75}
76
77
78
79
80
81
82async function fetchHeaders(initialUrl, timeoutSeconds, useHead, redirectsRemaining = 3) {
83 await normalizeAndValidateUrl(initialUrl.href);
84 const controller = new AbortController();
85 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
86 try {
87 const response = await fetch(initialUrl, {
88 method: useHead ? 'HEAD' : 'GET',
89 redirect: 'manual',
90 signal: controller.signal,
91 headers: {
92 'user-agent': USER_AGENT,
93 accept: 'text/html,application/xhtml+xml,application/json,*/*;q=0.1',
94
95 'accept-encoding': 'gzip, deflate, br, zstd',
96 },
97 });
98
99 if ([301, 302, 303, 307, 308].includes(response.status)) {
100 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
101 const location = response.headers.get('location');
102 if (!location) throw new Error('Redirect without Location header');
103 const nextUrl = new URL(location, initialUrl.href);
104 await normalizeAndValidateUrl(nextUrl.href);
105 return fetchHeaders(nextUrl, timeoutSeconds, useHead, redirectsRemaining - 1);
106 }
107
108 try { await response.arrayBuffer(); } catch { }
109
110 const headerMap = {};
111 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
112
113 return {
114 ok: response.ok,
115 status: response.status,
116 finalUrl: new URL(response.url || initialUrl.href).href,
117 https: (response.url || initialUrl.href).startsWith('https://'),
118 headers: headerMap,
119 error: null,
120 };
121 } catch (error) {
122 return {
123 ok: false,
124 status: null,
125 finalUrl: initialUrl.href,
126 https: initialUrl.protocol === 'https:',
127 headers: {},
128 error: error.message,
129 };
130 } finally {
131 clearTimeout(timeout);
132 }
133}
134
135
136
137
138
139const KNOWN_COMPRESSIONS = new Set(['gzip', 'br', 'deflate', 'zstd']);
140
141
142
143export function parseContentEncoding(value) {
144 if (!value || !value.trim()) return [];
145
146 const raw = value.trim().toLowerCase();
147 if (raw === 'identity') return [];
148 return raw.split(',').map((t) => t.trim()).filter(Boolean);
149}
150
151export function resourceClassFromContentType(contentType) {
152 if (!contentType) return 'other';
153 const base = contentType.split(';')[0].trim().toLowerCase();
154 if (/^text\/html$|^application\/xhtml\+xml$/.test(base)) return 'html';
155 if (ALREADY_COMPRESSED.some((re) => re.test(base))) return 'already-compressed';
156 if (/^text\/css$|^text\/javascript$|^application\/javascript$|^application\/json$|^application\/xml$|^text\/xml$|^text\/plain$/.test(base)) {
157 return 'static-asset';
158 }
159
160 if (/^image\//.test(base)) return 'already-compressed';
161 return 'other';
162}
163
164
165export function analyzeCompressionHeaders(headerMap) {
166 const contentTypeRaw = headerMap['content-type'] || '';
167 const contentType = contentTypeRaw;
168 const resourceClass = resourceClassFromContentType(contentType);
169
170 const contentEncodingRaw = headerMap['content-encoding'] || '';
171 const compressions = parseContentEncoding(contentEncodingRaw);
172 const contentEncoding = compressions.length ? compressions.join(', ') : null;
173
174 const transferEncodingRaw = headerMap['transfer-encoding'] || '';
175 const transferEncoding = transferEncodingRaw ? transferEncodingRaw.trim() : null;
176
177 const varyRaw = headerMap['vary'] || '';
178 const vary = varyRaw ? varyRaw.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean) : [];
179 const varyAcceptEncoding = vary.some((v) => v === 'accept-encoding' || v === '*');
180
181 const contentLengthRaw = headerMap['content-length'];
182 let contentLength = null;
183 if (contentLengthRaw !== undefined) {
184 const n = Number(contentLengthRaw);
185 if (!Number.isNaN(n) && n >= 0) contentLength = Math.floor(n);
186 }
187
188 return {
189 contentType,
190 resourceClass,
191 compressions,
192 contentEncoding,
193 transferEncoding,
194 vary,
195 varyAcceptEncoding,
196 contentLength,
197 };
198}
199
200
201
202
203
204function evalContentEncoding(summary) {
205 if (!summary.compressions.length) {
206 if (summary.resourceClass === 'already-compressed') {
207 return { status: 'good', note: 'No Content-Encoding on already-compressed content (image/video/font/archive). Correct — do not re-compress.' };
208 }
209 if (summary.resourceClass === 'html' || summary.resourceClass === 'static-asset') {
210 return {
211 status: 'warn',
212 note: 'No Content-Encoding. Text-based resources should be compressed (gzip or brotli) to reduce transfer size.',
213 recommendation: 'Enable Content-Encoding: gzip or br at the edge or origin for text-based responses (HTML, CSS, JS, JSON, XML, plain text).',
214 };
215 }
216 return { status: 'info', note: 'No Content-Encoding.' };
217 }
218
219 const unknown = summary.compressions.filter((c) => !KNOWN_COMPRESSIONS.has(c));
220 if (unknown.length) {
221 return {
222 status: 'warn',
223 note: `Content-Encoding: ${summary.contentEncoding}. Unknown encoding(s): ${unknown.join(', ')}.`,
224 recommendation: 'Use a standard Content-Encoding (gzip, br, deflate, zstd) to avoid client incompatibility.',
225 };
226 }
227
228
229 const unique = new Set(summary.compressions);
230 const doubled = summary.compressions.length - unique.size;
231 if (doubled > 0) {
232 return {
233 status: 'warn',
234 note: `Content-Encoding: ${summary.contentEncoding}. Duplicate compression algorithm detected; double-compression wastes CPU and can break clients.`,
235 recommendation: 'Apply each compression algorithm at most once. If the origin compressed the resource, the edge should not re-compress it again.',
236 };
237 }
238
239 if (summary.resourceClass === 'already-compressed') {
240 return {
241 status: 'warn',
242 note: `Content-Encoding: ${summary.contentEncoding} on already-compressed content (${summary.contentType.split(';')[0]}). Re-compressing binary formats wastes CPU and rarely saves bytes.`,
243 recommendation: 'Exclude already-compressed content types (images, videos, fonts, archives, PDF, WASM) from compression at the edge and origin.',
244 };
245 }
246
247 const notes = [];
248 if (summary.compressions.includes('br')) notes.push('brotli (good text compression)');
249 if (summary.compressions.includes('zstd')) notes.push('zstd (fast modern compression)');
250 if (summary.compressions.includes('gzip')) notes.push('gzip (widely compatible)');
251 if (summary.compressions.includes('deflate')) notes.push('deflate (raw)');
252 return {
253 status: 'good',
254 note: `Content-Encoding: ${summary.contentEncoding}. ${notes.join('; ')}.`,
255 };
256}
257
258function evalVary(summary) {
259 if (!summary.compressions.length) {
260
261 if (!summary.vary.length) return { status: 'info', note: 'No Vary header (no Content-Encoding present, so no negotiation needed).' };
262 if (summary.varyAcceptEncoding) {
263 return {
264 status: 'info',
265 note: 'Vary includes Accept-Encoding, but no Content-Encoding is present. Harmless but unnecessary.',
266 };
267 }
268 return { status: 'info', note: `Vary: ${summary.vary.join(', ')}` };
269 }
270
271
272 if (!summary.varyAcceptEncoding) {
273 return {
274 status: 'warn',
275 note: 'Content-Encoding is set, but Vary does not include Accept-Encoding. Intermediary caches may serve a compressed response to a client that does not support it.',
276 recommendation: 'Add Accept-Encoding to the Vary header whenever Content-Encoding is negotiated, so caches do not serve brotli/gzip to clients that only accept identity.',
277 };
278 }
279 return { status: 'good', note: 'Vary includes Accept-Encoding. Content negotiation for compression is correctly signalled.' };
280}
281
282function evalTransferEncoding(summary) {
283 if (!summary.transferEncoding) {
284 return { status: 'info', note: 'No Transfer-Encoding header.' };
285 }
286 const te = summary.transferEncoding.toLowerCase();
287
288 const teTokens = te.split(',').map((t) => t.trim());
289 const hasCompressionToken = teTokens.some((t) => /gzip|deflate|br|zstd|compress/.test(t));
290 if (hasCompressionToken) {
291 return {
292 status: 'warn',
293 note: `Transfer-Encoding includes compression: ${te}. Transfer-Encoding compression is largely unsupported in HTTP/2+ and some intermediaries.`,
294 recommendation: 'Prefer Content-Encoding for compression. Avoid Transfer-Encoding: gzip except in legacy HTTP/1.1 pipelines.',
295 };
296 }
297 if (/chunked/.test(te)) {
298 return { status: 'info', note: `Transfer-Encoding: ${te}. Chunked streaming is standard for dynamic responses.` };
299 }
300 return { status: 'info', note: `Transfer-Encoding: ${summary.transferEncoding}` };
301}
302
303function evalCompressionChoice(summary) {
304 if (!summary.compressions.length) return { status: 'info', note: 'No compression applied — cannot evaluate algorithm choice.' };
305
306
307 const hasBr = summary.compressions.includes('br');
308 const hasGzip = summary.compressions.includes('gzip');
309 const hasZstd = summary.compressions.includes('zstd');
310
311 if (summary.resourceClass === 'html' || summary.resourceClass === 'static-asset') {
312 if (hasBr) return { status: 'good', note: 'Brotli is applied to text-based content — best compression for HTML, CSS, JS.' };
313 if (hasZstd) return { status: 'good', note: 'zstd is applied — fast modern compression with good ratio.' };
314 if (hasGzip) return { status: 'info', note: 'gzip is applied — universally compatible but brotli offers ~15-25% better compression for text. Zstd offers faster decompression.' };
315 return { status: 'info', note: `Compression: ${summary.contentEncoding}` };
316 }
317
318 return { status: 'info', note: `Compression: ${summary.contentEncoding} on ${summary.resourceClass}.` };
319}
320
321const HEADER_CHECKS = [
322 { name: 'content-encoding', title: 'Content-Encoding', weight: 40, fn: evalContentEncoding },
323 { name: 'vary', title: 'Vary: Accept-Encoding', weight: 30, fn: evalVary },
324 { name: 'compression-choice', title: 'Compression Algorithm', weight: 15, fn: evalCompressionChoice },
325 { name: 'transfer-encoding', title: 'Transfer-Encoding', weight: 15, fn: evalTransferEncoding },
326];
327
328export function buildHeaderReports(summary) {
329 const reports = [];
330 let earned = 0;
331 let possible = 0;
332 for (const check of HEADER_CHECKS) {
333 const evaluation = check.fn(summary);
334
335 if (evaluation.status !== 'info') possible += check.weight;
336 if (evaluation.status === 'good') earned += check.weight;
337 else if (evaluation.status === 'warn') earned += Math.round(check.weight * 0.5);
338 reports.push({
339 name: check.title,
340 header: check.name,
341 status: evaluation.status,
342 note: evaluation.note,
343 weight: check.weight,
344 recommendation: evaluation.recommendation || null,
345 });
346 }
347 return { reports, earned, possible };
348}
349
350export function scoreAudit(earned, possible) {
351 if (possible === 0) return 0;
352 return Math.min(100, Math.max(0, Math.round((earned / possible) * 100)));
353}
354
355export function gradeFromScore(score) {
356 if (score >= 95) return 'A+';
357 if (score >= 85) return 'A';
358 if (score >= 75) return 'B';
359 if (score >= 65) return 'C';
360 if (score >= 50) return 'D';
361 if (score >= 30) return 'E';
362 return 'F';
363}
364
365export function buildRecommendations(reports, summary) {
366 const recs = new Set();
367 for (const r of reports) {
368 if (r.recommendation) recs.add(r.recommendation);
369 }
370
371
372 if (!summary.compressions.length && (summary.resourceClass === 'html' || summary.resourceClass === 'static-asset')) {
373 recs.add('Enable compression (gzip or brotli) for this text-based resource. Brotli at level 4-6 typically beats gzip by 15-25%.');
374 }
375 if (summary.compressions.length && summary.resourceClass === 'already-compressed') {
376 recs.add('Remove the Content-Encoding for already-compressed content types. Configure the edge/server to skip compression for images, fonts, videos, archives, PDF, and WASM.');
377 }
378 if (summary.compressions.length && !summary.varyAcceptEncoding) {
379 recs.add('Add Accept-Encoding to the Vary header to prevent caches from serving a compressed variant to clients that did not request it.');
380 }
381 if (summary.compressions.length > 1) {
382 recs.add('Multiple Content-Encoding values detected. Ensure layered compression is intentional (some CDNs chain br→identity); duplicate compression should be removed.');
383 }
384 if (recs.size === 0) {
385 recs.add('Compression headers look well-configured for this content type. Run this audit after edge/CDN changes to catch regressions.');
386 }
387 return [...recs];
388}
389
390
391
392
393
394export async function auditCompressionHeaders(input) {
395 const startUrl = await normalizeAndValidateUrl(input.startUrl);
396 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
397 const useHead = Boolean(input.useHeadRequest);
398
399 const fetchResult = await fetchHeaders(startUrl, timeoutSeconds, useHead);
400
401 if (fetchResult.error) {
402 return {
403 inputUrl: input.startUrl,
404 finalUrl: fetchResult.finalUrl,
405 https: fetchResult.https,
406 status: null,
407 contentType: '',
408 resourceClass: 'other',
409 contentEncoding: null,
410 transferEncoding: null,
411 compressions: [],
412 varyAcceptEncoding: false,
413 vary: [],
414 contentLength: null,
415 headers: [],
416 issues: [],
417 score: 0,
418 grade: 'F',
419 checkedAt: new Date().toISOString(),
420 recommendations: ['The request failed before headers could be inspected. Verify the URL is reachable and try again.'],
421 error: fetchResult.error,
422 };
423 }
424
425 const summary = analyzeCompressionHeaders(fetchResult.headers);
426 const { reports, earned, possible } = buildHeaderReports(summary);
427 const score = scoreAudit(earned, possible);
428 const grade = gradeFromScore(score);
429 const recommendations = buildRecommendations(reports, summary);
430
431 const issues = reports
432 .filter((r) => r.status === 'warn' || r.status === 'missing')
433 .map((r) => `${r.name}: ${r.note}`);
434
435 return {
436 inputUrl: input.startUrl,
437 finalUrl: fetchResult.finalUrl,
438 https: fetchResult.https,
439 status: fetchResult.status,
440 contentType: summary.contentType,
441 resourceClass: summary.resourceClass,
442 contentEncoding: summary.contentEncoding,
443 transferEncoding: summary.transferEncoding,
444 compressions: summary.compressions,
445 varyAcceptEncoding: summary.varyAcceptEncoding,
446 vary: summary.vary,
447 contentLength: summary.contentLength,
448 headers: reports,
449 issues,
450 score,
451 grade,
452 checkedAt: new Date().toISOString(),
453 recommendations,
454 };
455}
456
457
458
459
460
461const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
462
463if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
464 await Actor.init();
465 try {
466 const input = await Actor.getInput();
467 const result = await auditCompressionHeaders(input || {});
468 await Actor.pushData(result);
469 await Actor.setValue('OUTPUT', result);
470 Actor.log.info('Compression headers audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
471 } finally {
472 await Actor.exit();
473 }
474}