UNPKG

@iocium/favicon-fetcher

Version:

Favicon and BIMI logo fetcher for Cloudflare Workers and browser-compatible environments

138 lines (119 loc) 4.98 kB
export type Service = 'google' | 'duckduckgo' | 'bitwarden' | 'yandex' | 'fastmail' | 'nextdns' | 'iconHorse' | 'bimi' | 'iocium' | 'faviconis' | 'faviconim'; /** * Result returned by fetchFavicon including content and metadata. */ export interface FaviconResult { /** Source URL used to fetch the favicon/logo */ url: string; /** Content-Type of the returned image */ contentType: string | null; /** Raw image data as an ArrayBuffer */ content: ArrayBuffer; /** HTTP response status */ status: number; } /** * FaviconFetcher allows downloading favicons or BIMI logos from a hostname using known services. */ export class FaviconFetcher { /** * @param hostname The domain name to fetch the favicon/logo for. * @param options Optional configuration including headers, iconHorse API key, and BIMI DNS settings. */ constructor( private hostname: string, private options?: { /** API key for icon.horse Pro access (used only when service is 'iconHorse') */ iconHorseApiKey?: string; /** Optional DNS-over-HTTPS server URL for BIMI lookups (defaults to Cloudflare) */ dohServerUrl?: string; /** Optional custom headers to send with fetch (except protected headers like X-API-Key) */ headers?: Record<string, string>; /** Optional enable or provide a CORS proxy prefix for browser fetch compatibility */ useCorsProxy?: boolean | string; } ) { if (!hostname) throw new Error('Hostname is required'); } private static serviceUrls: Record<Exclude<Service, 'bimi'>, (hostname: string) => string> = { google: (hostname) => `https://www.google.com/s2/favicons?domain=${hostname}`, duckduckgo: (hostname) => `https://icons.duckduckgo.com/ip3/${hostname}.ico`, bitwarden: (hostname) => `https://icons.bitwarden.net/${hostname}/icon.png`, yandex: (hostname) => `https://favicon.yandex.net/favicon/${hostname}`, fastmail: (hostname) => `https://www.fastmailcdn.com/avatar/${hostname}`, iconHorse: (hostname) => `https://icon.horse/icon/${hostname}`, nextdns: (hostname) => `https://favicons.nextdns.io/${hostname}@2x.png`, iocium: (hostname) => `https://icons.iocium.net/icon/${hostname}`, faviconis: (hostname) => `https://favicon.is/${hostname}`, faviconim: (hostname) => `https://favicon.im/${hostname}` }; /** * Fetches the favicon or BIMI logo for the configured hostname using the specified service. * * @param service The provider to use (default is 'google'). * @returns A FaviconResult containing the image, status, and metadata. * @throws If the fetch fails or BIMI DNS record is missing/invalid. */ public async fetchFavicon( service: Service = "google" ): Promise<FaviconResult> { if (service === "bimi") { const dohUrl = this.options?.dohServerUrl || 'https://cloudflare-dns.com/dns-query'; const bimiDomain = `default._bimi.${this.hostname}`; const dnsUrl = `${dohUrl}?name=${bimiDomain}&type=TXT`; const dnsResp = await fetch(dnsUrl, { headers: { Accept: "application/dns-json", }, }); if (!dnsResp.ok) { throw new Error(`BIMI DNS query failed: ${dnsResp.statusText}`); } const dnsData = await dnsResp.json(); const txtRecords: string[] = dnsData?.Answer?.map((a: any) => a.data.replace(/^"|"$/g, '')) || []; const lRecord = txtRecords.find(txt => txt.includes('l=')); const logoUrlMatch = lRecord?.match(/l=([^;]+)/); if (!logoUrlMatch) { throw new Error('No BIMI l= logo URL found in TXT record'); } const logoUrl = logoUrlMatch[1]; const response = await fetch(logoUrl); if (!response.ok) { throw new Error(`Failed to fetch BIMI logo: ${response.statusText}`); } const content = await response.arrayBuffer(); return { url: logoUrl, contentType: response.headers.get('content-type'), content, status: response.status }; } const urlFn = FaviconFetcher.serviceUrls[service]; const url = urlFn(this.hostname); const corsProxy = this.options?.useCorsProxy === true ? 'https://corsproxy.io/?' : typeof this.options?.useCorsProxy === 'string' ? this.options.useCorsProxy : ''; const fetchUrl = corsProxy + url; const headers: Record<string, string> = { ...(this.options?.headers || {}) }; // Enforce icon.horse API key protection if (service === "iconHorse" && this.options?.iconHorseApiKey) { headers["X-API-Key"] = this.options.iconHorseApiKey; } const response = await fetch(fetchUrl, { headers }); if (!response.ok) { throw new Error(`Failed to fetch favicon from ${service}: ${response.statusText}`); } const content = await response.arrayBuffer(); return { url, contentType: response.headers.get('content-type'), content, status: response.status }; } }