mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance website icon loading mechanism; implement icon loading state management and error handling
This commit is contained in:
+52
-19
@@ -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`,
|
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();
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
} catch {
|
|
||||||
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
export function buildWebBootstrapResponse(env: Env): WebBootstrapResponse {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user