1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'ResourceHintsAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_HTML_BYTES = 1024 * 1024;
9const MAX_HTML_BYTES = 2 * 1024 * 1024;
10
11
12
13
14
15
16
17
18function isPrivateIPv4(ip) {
19 const parts = ip.split('.').map(Number);
20 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
21 const [a, b] = parts;
22 return a === 10
23 || (a === 172 && b >= 16 && b <= 31)
24 || (a === 192 && b === 168)
25 || a === 127
26 || a === 0
27 || (a === 169 && b === 254);
28}
29
30function isPrivateIPv6(ip) {
31 const normalized = ip.toLowerCase();
32 return normalized === '::1'
33 || normalized.startsWith('fc')
34 || normalized.startsWith('fd')
35 || normalized.startsWith('fe80:');
36}
37
38export async function normalizeAndValidateUrl(rawUrl) {
39 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
40 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
41 throw new Error('Only HTTP and HTTPS URLs are supported');
42 }
43
44 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
45 const url = new URL(withScheme);
46 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
47 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
48
49 const literalType = net.isIP(url.hostname);
50 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
51 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
52
53 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
54 for (const record of records) {
55 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
56 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
57 }
58 return url;
59}
60
61
62
63
64
65async function fetchHtml(initialUrl, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
66 await normalizeAndValidateUrl(initialUrl.href);
67 const controller = new AbortController();
68 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
69 try {
70 const response = await fetch(initialUrl, {
71 redirect: 'manual',
72 signal: controller.signal,
73 headers: {
74 'user-agent': USER_AGENT,
75 accept: 'text/html,application/xhtml+xml,*/*;q=0.1',
76 },
77 });
78
79 if ([301, 302, 303, 307, 308].includes(response.status)) {
80 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
81 const location = response.headers.get('location');
82 if (!location) throw new Error('Redirect without Location header');
83 const nextUrl = new URL(location, initialUrl.href);
84 await normalizeAndValidateUrl(nextUrl.href);
85 return fetchHtml(nextUrl, timeoutSeconds, maxBytes, redirectsRemaining - 1);
86 }
87
88
89 const reader = response.body.getReader();
90 const chunks = [];
91 let total = 0;
92 let truncated = false;
93 while (true) {
94 const { done, value } = await reader.read();
95 if (done) break;
96 if (total + value.length > maxBytes) {
97 chunks.push(value.slice(0, Math.max(0, maxBytes - total)));
98 truncated = true;
99 break;
100 }
101 chunks.push(value);
102 total += value.length;
103 }
104 try { await reader.cancel(); } catch { }
105
106 const html = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks));
107 const headerMap = {};
108 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
109
110 return {
111 ok: response.ok,
112 status: response.status,
113 finalUrl: response.url || initialUrl.href,
114 https: (response.url || initialUrl.href).startsWith('https://'),
115 html,
116 headers: headerMap,
117 truncated,
118 error: null,
119 };
120 } catch (error) {
121 return {
122 ok: false,
123 status: null,
124 finalUrl: initialUrl.href,
125 https: initialUrl.protocol === 'https:',
126 html: '',
127 headers: {},
128 truncated: false,
129 error: error.message,
130 };
131 } finally {
132 clearTimeout(timeout);
133 }
134}
135
136
137
138
139
140function decodeHtmlEntities(value) {
141 if (!value) return '';
142 return value
143 .replace(/&/gi, '&')
144 .replace(/"/gi, '"')
145 .replace(/'|'/gi, "'")
146 .replace(/</gi, '<')
147 .replace(/>/gi, '>')
148 .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
149 .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)))
150 .trim();
151}
152
153function parseAttributes(tag) {
154 const attrs = {};
155
156 const attrPattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("[^"]*"|'[^']*'|[^\s"'>/]+))?/g;
157 for (const match of tag.matchAll(attrPattern)) {
158 if (match[2] === undefined) {
159
160 attrs[match[1].toLowerCase()] = '';
161 } else {
162 const raw = match[2];
163 const unquoted = raw.startsWith('"') || raw.startsWith("'") ? raw.slice(1, -1) : raw;
164 attrs[match[1].toLowerCase()] = decodeHtmlEntities(unquoted);
165 }
166 }
167 return attrs;
168}
169
170function allLinkTags(html) {
171 return [...html.matchAll(/<link\b[^>]*>/gi)].map((m) => ({
172 raw: m[0],
173 attrs: parseAttributes(m[0]),
174 }));
175}
176
177
178
179
180
181const HINT_RELS = new Set(['preload', 'prefetch', 'preconnect', 'dns-prefetch', 'modulepreload', 'prerender']);
182
183function originOf(href, baseUrl) {
184 try {
185 const u = new URL(href, baseUrl);
186 return `${u.protocol}//${u.host}`;
187 } catch {
188 return null;
189 }
190}
191
192function classifyHint(tag, baseUrl) {
193 const rels = (tag.attrs.rel || '').toLowerCase().split(/\s+/).filter(Boolean);
194 const hintRel = rels.find((r) => HINT_RELS.has(r));
195 if (!hintRel) return null;
196
197 const href = tag.attrs.href || '';
198 const as = (tag.attrs.as || '').toLowerCase() || null;
199 const crossorigin = tag.attrs.crossorigin !== undefined ? (tag.attrs.crossorigin || 'anonymous') : null;
200 const origin = href ? originOf(href, baseUrl) : null;
201
202 const issues = [];
203 let recommendation = null;
204
205 if (!href) {
206 issues.push('missing href');
207 recommendation = 'Add an href attribute to this resource hint.';
208 }
209
210 if ((hintRel === 'preconnect' || hintRel === 'dns-prefetch') && !href) {
211 recommendation = 'Add an href with the origin to preconnect to.';
212 }
213
214
215 if (hintRel === 'preconnect' && href && !crossorigin) {
216 issues.push('preconnect without crossorigin may not cache credentials for cross-origin resources like fonts');
217 recommendation = 'Add crossorigin to <link rel="preconnect"> for cross-origin resources that send credentials (e.g., Google Fonts).';
218 }
219
220
221 if (hintRel === 'preload' && href && !as) {
222 issues.push('preload without as attribute');
223 recommendation = 'Add an as attribute to <link rel="preload"> so the browser prioritizes correctly.';
224 }
225
226
227 if (hintRel === 'preload' && as === 'fetch' && !crossorigin) {
228 issues.push('preload as=fetch without crossorigin');
229 recommendation = 'If the preloaded fetch request uses credentials, add crossorigin to <link rel="preload" as="fetch">.';
230 }
231
232 return {
233 rel: hintRel,
234 href: href || '',
235 as,
236 crossorigin,
237 origin,
238 issues,
239 recommendation,
240 };
241}
242
243export function analyzeResourceHints(html, baseUrl) {
244 const linkTags = allLinkTags(html);
245 const hints = [];
246 const seen = new Set();
247
248 for (const tag of linkTags) {
249 const parsed = classifyHint(tag, baseUrl);
250 if (!parsed) continue;
251
252
253 const key = `${parsed.rel}|${parsed.href}`;
254 if (parsed.href && seen.has(key)) {
255 parsed.issues.push('duplicate hint (same rel + href)');
256 if (!parsed.recommendation) parsed.recommendation = 'Remove the duplicate <link> hint; duplicate tags waste head bytes.';
257 } else if (parsed.href) {
258 seen.add(key);
259 }
260 hints.push(parsed);
261 }
262
263 const byType = {};
264 for (const h of hints) {
265 byType[h.rel] = (byType[h.rel] || 0) + 1;
266 }
267
268
269 const issues = [];
270 for (const h of hints) {
271 for (const issue of h.issues) {
272 const line = `${h.rel} ${h.href || '(no href)'}: ${issue}`;
273 if (!issues.includes(line)) issues.push(line);
274 }
275 }
276
277 return { hints, byType, issues };
278}
279
280export function scoreResourceHints(hints, issues, hintCount) {
281
282 let earned = 0;
283 const possible = 60;
284
285 const rels = new Set(hints.map((h) => h.rel));
286 const hasPreconnect = rels.has('preconnect');
287 const hasDnsPrefetch = rels.has('dns-prefetch');
288 const hasPreload = rels.has('preload');
289 const hasModulepreload = rels.has('modulepreload');
290 const hasPrefetch = rels.has('prefetch');
291
292
293 if (hasPreconnect) earned += 20;
294 if (hasPreload) earned += 15;
295 if (hasModulepreload) earned += 5;
296
297
298 if (hasDnsPrefetch) earned += 5;
299 if (hasPrefetch) earned += 5;
300 if (hints.length >= 1) earned += 10;
301
302
303 const issuePenalty = Math.min(issues.length * 10, 40);
304 earned = Math.max(0, earned - issuePenalty);
305
306 if (hintCount === 0) return 0;
307 return Math.round((earned / possible) * 100);
308}
309
310export function gradeFromScore(score) {
311 if (score >= 95) return 'A+';
312 if (score >= 85) return 'A';
313 if (score >= 75) return 'B';
314 if (score >= 65) return 'C';
315 if (score >= 50) return 'D';
316 if (score >= 30) return 'E';
317 return 'F';
318}
319
320export function buildRecommendations(hints, issues, byType) {
321 const recs = new Set();
322
323 for (const h of hints) {
324 if (h.recommendation) recs.add(h.recommendation);
325 }
326
327 if (!('preload' in byType)) {
328 recs.add('Add <link rel="preload"> for late-discovered critical resources (fonts, CSS, JS, images) to improve LCP.');
329 }
330 if (!('preconnect' in byType)) {
331 recs.add('Add <link rel="preconnect" crossorigin> for critical third-party origins (CDNs, font hosts) to warm TCP/TLS connections.');
332 }
333 if (!('dns-prefetch' in byType)) {
334 recs.add('Add <link rel="dns-prefetch"> for third-party hostnames to reduce DNS latency on first request.');
335 }
336
337 if (issues.length === 0 && hints.length > 0) {
338 recs.add('Resource hints look well-structured. Schedule this audit periodically to catch regressions.');
339 }
340
341 return [...recs];
342}
343
344
345
346
347
348export async function auditResourceHints(input) {
349 const startUrl = await normalizeAndValidateUrl(input.startUrl);
350 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
351 const maxHtmlBytes = Math.min(Math.max(Number(input.maxHtmlBytes || DEFAULT_MAX_HTML_BYTES), 16384), MAX_HTML_BYTES);
352
353 const fetchResult = await fetchHtml(startUrl, timeoutSeconds, maxHtmlBytes);
354
355 if (fetchResult.error) {
356 return {
357 inputUrl: input.startUrl,
358 finalUrl: fetchResult.finalUrl,
359 https: fetchResult.https,
360 hintCount: 0,
361 byType: {},
362 hints: [],
363 issues: [],
364 score: 0,
365 grade: 'F',
366 checkedAt: new Date().toISOString(),
367 recommendations: ['The request failed before HTML could be inspected. Verify the URL is reachable and try again.'],
368 error: fetchResult.error,
369 };
370 }
371
372
373 const contentType = fetchResult.headers['content-type'] || '';
374 const isHtml = /text\/html|application\/xhtml+xml/i.test(contentType);
375
376 const { hints, byType, issues } = isHtml
377 ? analyzeResourceHints(fetchResult.html, fetchResult.finalUrl)
378 : { hints: [], byType: {}, issues: ['Response is not HTML; cannot parse resource hints.'] };
379
380 const score = scoreResourceHints(hints, issues, hints.length);
381 const grade = gradeFromScore(score);
382 const recommendations = buildRecommendations(hints, issues, byType);
383
384 return {
385 inputUrl: input.startUrl,
386 finalUrl: fetchResult.finalUrl,
387 https: fetchResult.https,
388 hintCount: hints.length,
389 byType,
390 hints,
391 issues,
392 score,
393 grade,
394 checkedAt: new Date().toISOString(),
395 recommendations,
396 };
397}
398
399
400
401
402
403const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
404
405if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
406 await Actor.init();
407 try {
408 const input = await Actor.getInput();
409 const result = await auditResourceHints(input || {});
410 await Actor.pushData(result);
411 await Actor.setValue('OUTPUT', result);
412 Actor.log.info('Resource hints audit complete', { finalUrl: result.finalUrl, hintCount: result.hintCount, score: result.score, grade: result.grade });
413 } finally {
414 await Actor.exit();
415 }
416}