Custom app launcher, browser home page, and service monitor. cute.haus
homelab react typescript
0

Configure Feed

Select the types of activity you want to include in your feed.

add favicon fetching for websites

Aly Raffauf (Apr 26, 2026, 12:35 PM EDT) f92c9c88 67c135d3

+143 -27
+36 -27
src/components/ServiceGrid.tsx
··· 45 45 46 46 return ( 47 47 <div className={`grid gap-4 ${gridCols[columns]}`}> 48 - {services.map((service) => ( 49 - <a 50 - key={service.name} 51 - href={service.url} 52 - className="card block p-4 hover:bg-white/[0.07] hover:border-white/20 hover:-translate-y-0.5 transition-all" 53 - > 54 - <div className="flex items-center gap-3"> 55 - {service.icon && ( 56 - <img src={service.icon} alt="" className="w-6 h-6 shrink-0" /> 57 - )} 58 - <h3 className="text-md font-semibold flex-1 truncate"> 59 - {service.name} 60 - </h3> 61 - <span className="shrink-0"> 62 - {statuses[service.name] == null && ( 63 - <Circle className="text-zinc-500" /> 64 - )} 65 - {statuses[service.name] === true && ( 66 - <CircleCheck className="text-emerald-400" /> 67 - )} 68 - {statuses[service.name] === false && ( 69 - <CircleX className="text-rose-400" /> 70 - )} 71 - </span> 72 - </div> 73 - </a> 74 - ))} 48 + {services.map((service) => { 49 + const iconUrl = 50 + service.icon ?? `/api/favicon?url=${encodeURIComponent(service.url)}`; 51 + return ( 52 + <a 53 + key={service.name} 54 + href={service.url} 55 + className="card block p-4 hover:bg-white/[0.07] hover:border-white/20 hover:-translate-y-0.5 transition-all" 56 + > 57 + <div className="flex items-center gap-3"> 58 + <img 59 + src={iconUrl} 60 + alt="" 61 + className="w-6 h-6 shrink-0" 62 + onError={(e) => { 63 + e.currentTarget.style.display = "none"; 64 + }} 65 + /> 66 + <h3 className="text-md font-semibold flex-1 truncate"> 67 + {service.name} 68 + </h3> 69 + <span className="shrink-0"> 70 + {statuses[service.name] == null && ( 71 + <Circle className="text-zinc-500" /> 72 + )} 73 + {statuses[service.name] === true && ( 74 + <CircleCheck className="text-emerald-400" /> 75 + )} 76 + {statuses[service.name] === false && ( 77 + <CircleX className="text-rose-400" /> 78 + )} 79 + </span> 80 + </div> 81 + </a> 82 + ); 83 + })} 75 84 </div> 76 85 ); 77 86 }
+107
src/index.ts
··· 6 6 let hnCache: { stories: any[]; fetchedAt: number } | null = null; 7 7 let lobstersCache: { stories: any[]; fetchedAt: number } | null = null; 8 8 9 + type Favicon = { body: ArrayBuffer; contentType: string }; 10 + type CachedFavicon = Favicon & { fetchedAt: number }; 11 + 12 + const faviconCache = new Map<string, CachedFavicon>(); 13 + const FAVICON_CACHE_MS = 24 * 60 * 60 * 1000; 14 + 15 + const NAMED_HTML_ENTITIES: Record<string, string> = { 16 + lt: "<", 17 + gt: ">", 18 + amp: "&", 19 + quot: '"', 20 + apos: "'", 21 + }; 22 + 23 + function decodeHtmlEntities(text: string): string { 24 + return text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (full, code) => { 25 + if (code.startsWith("#x")) 26 + return String.fromCodePoint(parseInt(code.slice(2), 16)); 27 + if (code.startsWith("#")) 28 + return String.fromCodePoint(parseInt(code.slice(1), 10)); 29 + return NAMED_HTML_ENTITIES[code] ?? full; 30 + }); 31 + } 32 + 33 + async function fetchIcon(iconUrl: string): Promise<Favicon | null> { 34 + try { 35 + const response = await fetch(iconUrl, { 36 + signal: AbortSignal.timeout(5000), 37 + }); 38 + if (!response.ok) return null; 39 + 40 + // For data: URIs the mime type lives in the URL itself (e.g. `data:image/svg+xml,...`). 41 + // Bun's fetch doesn't always copy it onto the response, so pull it out manually. 42 + const dataUriMime = iconUrl.match(/^data:([^;,]+)/)?.[1]; 43 + const contentType = 44 + dataUriMime ?? response.headers.get("content-type") ?? "image/x-icon"; 45 + 46 + return { body: await response.arrayBuffer(), contentType }; 47 + } catch { 48 + return null; 49 + } 50 + } 51 + 52 + async function getFavicon(siteOrigin: string): Promise<Favicon | null> { 53 + const cached = faviconCache.get(siteOrigin); 54 + if (cached && Date.now() - cached.fetchedAt < FAVICON_CACHE_MS) { 55 + return cached; 56 + } 57 + 58 + const freshIcon = await findFavicon(siteOrigin); 59 + if (!freshIcon) return null; 60 + 61 + faviconCache.set(siteOrigin, { ...freshIcon, fetchedAt: Date.now() }); 62 + return freshIcon; 63 + } 64 + 65 + async function findFavicon(siteOrigin: string): Promise<Favicon | null> { 66 + try { 67 + const homepage = await fetch(siteOrigin, { 68 + signal: AbortSignal.timeout(5000), 69 + headers: { "User-Agent": "Mozilla/5.0 watsup-favicon-fetcher" }, 70 + }).then((response) => response.text()); 71 + 72 + const iconLinkPattern = /<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*>/gi; 73 + for (const linkTag of homepage.match(iconLinkPattern) ?? []) { 74 + const hrefMatch = linkTag.match(/href=["']([^"']+)["']/i); 75 + if (!hrefMatch) continue; 76 + 77 + const href = decodeHtmlEntities(hrefMatch[1]); 78 + const iconUrl = new URL(href, siteOrigin).toString(); 79 + const icon = await fetchIcon(iconUrl); 80 + if (icon) return icon; 81 + } 82 + } catch { 83 + // Homepage fetch or parsing failed; fall through to the conventional path. 84 + } 85 + 86 + return fetchIcon(new URL("/favicon.ico", siteOrigin).toString()); 87 + } 88 + 9 89 async function checkStatuses(items: { name: string; url: string }[]) { 10 90 const results = await Promise.all( 11 91 items.map(async (item) => { ··· 147 227 { status: 502 }, 148 228 ); 149 229 } 230 + }, 231 + }, 232 + 233 + "/api/favicon": { 234 + async GET(req) { 235 + const urlParam = new URL(req.url).searchParams.get("url"); 236 + if (!urlParam) return new Response("Missing url", { status: 400 }); 237 + 238 + let siteUrl: URL; 239 + try { 240 + siteUrl = new URL(urlParam); 241 + } catch { 242 + return new Response("Invalid url", { status: 400 }); 243 + } 244 + if (siteUrl.protocol !== "http:" && siteUrl.protocol !== "https:") { 245 + return new Response("Invalid scheme", { status: 400 }); 246 + } 247 + 248 + const icon = await getFavicon(siteUrl.origin); 249 + if (!icon) return new Response("Not found", { status: 404 }); 250 + 251 + return new Response(icon.body, { 252 + headers: { 253 + "Content-Type": icon.contentType, 254 + "Cache-Control": "public, max-age=86400", 255 + }, 256 + }); 150 257 }, 151 258 }, 152 259