atmosphere explorer pds.ls
tool typescript atproto

favicon fetching through worker

handle.invalid 21ae96cb 8f534482

verified
+126 -11
+1
.gitignore
··· 5 5 public/oauth-client-metadata.json 6 6 public/opensearch.xml 7 7 public/_worker.js 8 + .wrangler
+12 -5
src/components/favicon.tsx
··· 6 6 wrapper?: (children: JSX.Element) => JSX.Element; 7 7 }) => { 8 8 const [loaded, setLoaded] = createSignal(false); 9 + const [src, setSrc] = createSignal(""); 9 10 const domain = () => (props.reverse ? props.domain.split(".").reverse().join(".") : props.domain); 11 + 12 + const workerUrl = () => `/favicon?domain=${encodeURIComponent(domain())}`; 13 + const directUrl = () => `https://${domain()}/favicon.ico`; 10 14 11 15 const content = ( 12 16 <Switch> 13 17 <Match when={domain() === "tangled.sh" || domain() === "tangled.org"}> 14 18 <span class="iconify i-tangled size-4" /> 15 19 </Match> 16 - <Match when={["bsky.app", "bsky.chat"].includes(domain())}> 17 - <img src="https://web-cdn.bsky.app/static/apple-touch-icon.png" class="size-4" /> 18 - </Match> 19 20 <Match when={true}> 20 21 <Show when={!loaded()}> 21 22 <span class="iconify lucide--globe size-4 text-neutral-400 dark:text-neutral-500" /> 22 23 </Show> 23 24 <img 24 - src={`https://${domain()}/favicon.ico`} 25 + src={src() || workerUrl()} 25 26 class="size-4" 26 27 classList={{ hidden: !loaded() }} 27 28 onLoad={() => setLoaded(true)} 28 - onError={() => setLoaded(false)} 29 + onError={() => { 30 + if (!src()) { 31 + setSrc(directUrl()); 32 + } else { 33 + setLoaded(false); 34 + } 35 + }} 29 36 /> 30 37 </Match> 31 38 </Switch>
+13 -6
src/components/navbar.tsx
··· 33 33 const HoverFavicon = (props: { domain: string; hovered: boolean; children: JSX.Element }) => { 34 34 const [hasHovered, setHasHovered] = createSignal(false); 35 35 const [loaded, setLoaded] = createSignal(false); 36 + const [src, setSrc] = createSignal(""); 36 37 37 38 createEffect(() => { 38 39 props.domain; 39 40 setHasHovered(false); 40 41 setLoaded(false); 42 + setSrc(""); 41 43 }); 42 44 43 45 createEffect(() => { 44 46 if (props.hovered) setHasHovered(true); 45 47 }); 48 + 49 + const workerUrl = () => `/favicon?domain=${encodeURIComponent(props.domain)}`; 50 + const directUrl = () => `https://${props.domain}/favicon.ico`; 46 51 47 52 return ( 48 53 <div class="relative flex h-5 w-3.5 shrink-0 items-center justify-center sm:w-4"> ··· 58 63 </Match> 59 64 <Match when={true}> 60 65 <img 61 - src={ 62 - ["bsky.app", "bsky.chat"].includes(props.domain) ? 63 - "https://web-cdn.bsky.app/static/apple-touch-icon.png" 64 - : `https://${props.domain}/favicon.ico` 65 - } 66 + src={src() || workerUrl()} 66 67 class="size-4" 67 68 classList={{ hidden: !props.hovered || !loaded() }} 68 69 onLoad={() => setLoaded(true)} 69 - onError={() => setLoaded(false)} 70 + onError={() => { 71 + if (!src()) { 72 + setSrc(directUrl()); 73 + } else { 74 + setLoaded(false); 75 + } 76 + }} 70 77 /> 71 78 </Match> 72 79 </Switch>
+100
src/worker.js
··· 486 486 } 487 487 } 488 488 489 + const MAX_FAVICON_SIZE = 100 * 1024; // 100KB 490 + 491 + async function handleFavicon(searchParams) { 492 + const domain = searchParams.get("domain"); 493 + if (!domain) { 494 + return new Response("Missing domain param", { status: 400 }); 495 + } 496 + 497 + let faviconUrl = null; 498 + try { 499 + const pageRes = await fetch(`https://${domain}/`, { 500 + signal: AbortSignal.timeout(5000), 501 + headers: { "User-Agent": "PDSls-Favicon/1.0" }, 502 + redirect: "follow", 503 + }); 504 + 505 + if (pageRes.ok && (pageRes.headers.get("content-type") ?? "").includes("text/html")) { 506 + let bestHref = null; 507 + let bestPriority = -1; 508 + 509 + const rewriter = new HTMLRewriter().on("link", { 510 + element(el) { 511 + const rel = (el.getAttribute("rel") ?? "").toLowerCase(); 512 + if (!rel.includes("icon")) return; 513 + const href = el.getAttribute("href"); 514 + if (!href) return; 515 + 516 + // Prefer apple-touch-icon > icon with sizes > icon > shortcut icon 517 + let priority = 0; 518 + if (rel === "apple-touch-icon") priority = 3; 519 + else if (rel === "icon" && el.getAttribute("sizes")) priority = 2; 520 + else if (rel === "icon") priority = 1; 521 + 522 + if (priority > bestPriority) { 523 + bestPriority = priority; 524 + bestHref = href; 525 + } 526 + }, 527 + }); 528 + 529 + const transformed = rewriter.transform(pageRes); 530 + await transformed.text(); 531 + 532 + if (bestHref) { 533 + try { 534 + faviconUrl = new URL(bestHref, `https://${domain}/`).href; 535 + } catch { 536 + faviconUrl = null; 537 + } 538 + } 539 + } 540 + } catch {} 541 + 542 + if (!faviconUrl) { 543 + faviconUrl = `https://${domain}/favicon.ico`; 544 + } 545 + 546 + try { 547 + const iconRes = await fetch(faviconUrl, { 548 + signal: AbortSignal.timeout(5000), 549 + redirect: "follow", 550 + }); 551 + 552 + if (!iconRes.ok) { 553 + return new Response("Favicon not found", { status: 404 }); 554 + } 555 + 556 + const contentType = iconRes.headers.get("content-type") ?? ""; 557 + if (!contentType.includes("image") && !contentType.includes("icon")) { 558 + return new Response("Not an image", { status: 404 }); 559 + } 560 + 561 + const contentLength = parseInt(iconRes.headers.get("content-length") ?? "0", 10); 562 + if (contentLength > MAX_FAVICON_SIZE) { 563 + return new Response("Favicon too large", { status: 413 }); 564 + } 565 + 566 + const body = await iconRes.arrayBuffer(); 567 + if (body.byteLength > MAX_FAVICON_SIZE) { 568 + return new Response("Favicon too large", { status: 413 }); 569 + } 570 + 571 + return new Response(body, { 572 + headers: { 573 + "Content-Type": contentType, 574 + "Cache-Control": "public, max-age=86400", 575 + "Access-Control-Allow-Origin": "*", 576 + }, 577 + }); 578 + } catch { 579 + return new Response("Failed to fetch favicon", { status: 502 }); 580 + } 581 + } 582 + 489 583 export default { 490 584 async fetch(request, env) { 491 585 const url = new URL(request.url); ··· 493 587 if (url.pathname === "/og-image") { 494 588 return handleOgImage(url.searchParams).catch( 495 589 (err) => new Response(`Failed to generate image: ${err?.message ?? err}`, { status: 500 }), 590 + ); 591 + } 592 + 593 + if (url.pathname === "/favicon") { 594 + return handleFavicon(url.searchParams).catch( 595 + (err) => new Response(`Failed to fetch favicon: ${err?.message ?? err}`, { status: 500 }), 496 596 ); 497 597 } 498 598