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 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> {
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> {
const normalizedHost = normalizeIconHost(host);
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const encodedHost = encodeURIComponent(normalizedHost);
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`,
headers: requestHeaders,
},
{
url: `https://favicon.im/${encodedHost}`,
headers: requestHeaders,
},
{
url: `https://icons.duckduckgo.com/ip3/${encodedHost}.ico`,
rejectImage: {
byteLength: BITWARDEN_DEFAULT_GLOBE_ICON_BYTES,
sha256: BITWARDEN_DEFAULT_GLOBE_ICON_SHA256,
},
headers: requestHeaders,
},
];
try {
for (const source of upstreamSources) {
for (const source of upstreamSources) {
try {
const resp = await fetchIconSource(source);
if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
return new Response(resp.body, {
status: 200,
headers: {
'Content-Type': resp.headers.get('Content-Type') || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
},
});
}
if (!source.rejectImage) {
return iconResponse(resp.body, resp.headers.get('Content-Type'));
}
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
} catch {
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
const contentLength = Number(resp.headers.get('Content-Length') || '');
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength !== source.rejectImage.byteLength) {
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 {