feat: enhance website icon loading mechanism; implement icon loading state management and error handling

This commit is contained in:
shuaiplus
2026-05-09 23:00:56 +08:00
parent 5809e3eebc
commit 7afb496eb0
3 changed files with 74 additions and 86 deletions
+55 -22
View File
@@ -142,6 +142,17 @@ function normalizeIconHost(rawHost: string): string | null {
} }
const ICON_UPSTREAM_TIMEOUT_MS = 2500; const ICON_UPSTREAM_TIMEOUT_MS = 2500;
const BITWARDEN_DEFAULT_GLOBE_ICON_BYTES = 500;
const BITWARDEN_DEFAULT_GLOBE_ICON_SHA256 = 'aaa64871332ad5b7d28fe8874efb19c2d9cc2f1e6de75d52b080b438225a0783';
type IconSource = {
url: string;
rejectImage?: {
byteLength: number;
sha256: string;
};
headers?: HeadersInit;
};
async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> { async function fetchIconSource(source: { url: string; headers?: HeadersInit }): Promise<Response> {
const controller = new AbortController(); const controller = new AbortController();
@@ -161,48 +172,70 @@ async function fetchIconSource(source: { url: string; headers?: HeadersInit }):
} }
} }
async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function iconResponse(body: BodyInit | null, contentType: string | null): Response {
return new Response(body, {
status: 200,
headers: {
'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> { async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
const normalizedHost = normalizeIconHost(host); const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost); const encodedHost = encodeURIComponent(normalizedHost);
const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' }; const requestHeaders = { 'User-Agent': 'NodeWarden/1.0' };
const upstreamSources: Array<{ url: string; headers?: HeadersInit }> = [ const upstreamSources: IconSource[] = [
{
url: `https://favicon.im/zh/${encodedHost}?larger=true&throw-error-on-404=true`,
headers: requestHeaders,
},
{ {
url: `https://icons.bitwarden.net/${encodedHost}/icon.png`, url: `https://icons.bitwarden.net/${encodedHost}/icon.png`,
headers: requestHeaders, rejectImage: {
}, byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
{ sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
url: `https://favicon.im/${encodedHost}`, },
headers: requestHeaders,
},
{
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
headers: requestHeaders, headers: requestHeaders,
}, },
]; ];
try { for (const source of upstreamSources) {
for (const source of upstreamSources) { try {
const resp = await fetchIconSource(source); const resp = await fetchIconSource(source);
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!contentType.startsWith('image/')) continue;
return new Response(resp.body, { if (!source.rejectImage) {
status: 200, return iconResponse(resp.body, resp.headers.get('Content-Type'));
headers: { }
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); const contentLength = Number(resp.headers.get('Content-Length') || '');
} catch { if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon(); return iconResponse(resp.body, resp.headers.get('Content-Type'));
}
const bytes = await resp.arrayBuffer();
if (bytes.byteLength === 0) continue;
if (bytes.byteLength === source.rejectImage.byteLength && (await sha256Hex(bytes)) === source.rejectImage.sha256) continue;
return iconResponse(bytes, resp.headers.get('Content-Type'));
} catch {
continue;
}
} }
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} }
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse { export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
+14 -13
View File
@@ -3,9 +3,11 @@ import type { ComponentChildren } from 'preact';
import { Globe } from 'lucide-preact'; import { Globe } from 'lucide-preact';
import type { Cipher } from '@/lib/types'; import type { Cipher } from '@/lib/types';
import { import {
beginWebsiteIconLoad,
getWebsiteIconImageUrl, getWebsiteIconImageUrl,
getWebsiteIconStatus, getWebsiteIconStatus,
preloadWebsiteIcon, markWebsiteIconErrored,
markWebsiteIconLoaded,
subscribeWebsiteIconStatus, subscribeWebsiteIconStatus,
} from '@/lib/website-icon-cache'; } from '@/lib/website-icon-cache';
import { demoBrandIconUrl } from '@/lib/demo-brand-icons'; import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
@@ -26,6 +28,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true)); const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle')); const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : '')); const [imageUrl, setImageUrl] = useState(() => (host ? getWebsiteIconImageUrl(host) : ''));
const [isLoadOwner, setIsLoadOwner] = useState(false);
const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : ''; const demoIconUrl = SHOULD_LOAD_DEMO_BRAND_ICONS && host ? demoBrandIconUrl(host) : '';
useEffect(() => { useEffect(() => {
@@ -33,12 +36,14 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
setShouldLoad(true); setShouldLoad(true);
setStatus('idle'); setStatus('idle');
setImageUrl(''); setImageUrl('');
setIsLoadOwner(false);
return; return;
} }
const nextStatus = getWebsiteIconStatus(host); const nextStatus = getWebsiteIconStatus(host);
setShouldLoad(nextStatus === 'loaded'); setShouldLoad(nextStatus === 'loaded');
setStatus(nextStatus); setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host)); setImageUrl(getWebsiteIconImageUrl(host));
setIsLoadOwner(false);
return subscribeWebsiteIconStatus(host, (next) => { return subscribeWebsiteIconStatus(host, (next) => {
setStatus(next); setStatus(next);
setImageUrl(getWebsiteIconImageUrl(host)); setImageUrl(getWebsiteIconImageUrl(host));
@@ -77,16 +82,8 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
useEffect(() => { useEffect(() => {
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return; if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
if (demoIconUrl) return; if (demoIconUrl) return;
if (!host || !src || !shouldLoad || status === 'loaded' || status === 'error') return; if (!host || !src || !shouldLoad || status !== 'idle') return;
let disposed = false; setIsLoadOwner(beginWebsiteIconLoad(host, src));
void preloadWebsiteIcon(host, src).then((nextStatus) => {
if (disposed) return;
setStatus(nextStatus);
setImageUrl(getWebsiteIconImageUrl(host));
});
return () => {
disposed = true;
};
}, [demoIconUrl, host, src, shouldLoad, status]); }, [demoIconUrl, host, src, shouldLoad, status]);
if (demoIconUrl) { if (demoIconUrl) {
@@ -107,16 +104,20 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>; return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
} }
const shouldRenderIconImage = !!imageUrl && (status === 'loaded' || (status === 'loading' && isLoadOwner));
return ( return (
<span className="list-icon-stack" ref={nodeRef}> <span className="list-icon-stack" ref={nodeRef}>
{status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>} {status !== 'loaded' && <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>}
{status === 'loaded' && imageUrl && ( {shouldRenderIconImage && (
<img <img
className="list-icon loaded" className={`list-icon${status === 'loaded' ? ' loaded' : ''}`}
src={imageUrl} src={imageUrl}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={() => markWebsiteIconLoaded(host, imageUrl)}
onError={() => markWebsiteIconErrored(host)}
/> />
)} )}
</span> </span>
+5 -51
View File
@@ -1,10 +1,7 @@
type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error'; type WebsiteIconStatus = 'idle' | 'loading' | 'loaded' | 'error';
const ICON_LOAD_TIMEOUT_MS = 5000;
interface WebsiteIconRecord { interface WebsiteIconRecord {
status: WebsiteIconStatus; status: WebsiteIconStatus;
promise: Promise<WebsiteIconStatus> | null;
imageUrl: string | null; imageUrl: string | null;
listeners: Set<(status: WebsiteIconStatus) => void>; listeners: Set<(status: WebsiteIconStatus) => void>;
} }
@@ -16,7 +13,6 @@ function ensureRecord(host: string): WebsiteIconRecord {
if (!record) { if (!record) {
record = { record = {
status: 'idle', status: 'idle',
promise: null,
imageUrl: null, imageUrl: null,
listeners: new Set(), listeners: new Set(),
}; };
@@ -55,7 +51,6 @@ export function subscribeWebsiteIconStatus(host: string, listener: (status: Webs
export function markWebsiteIconLoaded(host: string, imageUrl?: string): void { export function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
if (!host) return; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
record.promise = null;
if (imageUrl) { if (imageUrl) {
record.imageUrl = imageUrl; record.imageUrl = imageUrl;
} }
@@ -65,56 +60,15 @@ export function markWebsiteIconLoaded(host: string, imageUrl?: string): void {
export function markWebsiteIconErrored(host: string): void { export function markWebsiteIconErrored(host: string): void {
if (!host) return; if (!host) return;
const record = ensureRecord(host); const record = ensureRecord(host);
record.promise = null;
record.imageUrl = null; record.imageUrl = null;
notifyRecord(host, 'error'); notifyRecord(host, 'error');
} }
function blobToDataUrl(blob: Blob): Promise<string> { export function beginWebsiteIconLoad(host: string, src: string): boolean {
return new Promise((resolve, reject) => { if (!host || !src) return false;
const reader = new FileReader();
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '');
reader.onerror = () => reject(reader.error || new Error('Failed to read icon'));
reader.readAsDataURL(blob);
});
}
export function preloadWebsiteIcon(host: string, src: string): Promise<WebsiteIconStatus> {
if (!host) return Promise.resolve('error');
const record = ensureRecord(host); const record = ensureRecord(host);
if (record.status === 'loaded' || record.status === 'error') { if (record.status !== 'idle') return false;
return Promise.resolve(record.status); record.imageUrl = src;
}
if (record.promise) {
return record.promise;
}
notifyRecord(host, 'loading'); notifyRecord(host, 'loading');
record.promise = (async () => { return true;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), ICON_LOAD_TIMEOUT_MS);
try {
const resp = await fetch(src, {
cache: 'force-cache',
signal: controller.signal,
});
if (!resp.ok) throw new Error('Icon unavailable');
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) throw new Error('Icon response is not an image');
const blob = await resp.blob();
if (!blob.size) throw new Error('Icon response is empty');
const imageUrl = await blobToDataUrl(blob);
if (!imageUrl) throw new Error('Icon response is empty');
markWebsiteIconLoaded(host, imageUrl);
return 'loaded';
} catch {
markWebsiteIconErrored(host);
return 'error';
} finally {
window.clearTimeout(timeout);
}
})();
return record.promise;
} }