@@ -10,6 +10,7 @@ export interface FetchClearurlsCatalogOptions {
1010 rulesUrl ?: string ;
1111 hashUrl ?: string ;
1212 fetch ?: typeof globalThis . fetch ;
13+ timeoutMs ?: number ;
1314}
1415
1516export interface FetchClearurlsCatalogResult {
@@ -23,19 +24,21 @@ export async function fetchClearurlsCatalog(
2324) : Promise < FetchClearurlsCatalogResult > {
2425 const rulesUrl = options . rulesUrl ?? DEFAULT_CLEARURLS_RULES_URL ;
2526 const hashUrl = options . hashUrl ?? DEFAULT_CLEARURLS_HASH_URL ;
26- const fetchImpl = options . fetch ?? globalThis . fetch ;
27+ const fetchImpl =
28+ options . fetch ?? ( globalThis . fetch ? globalThis . fetch . bind ( globalThis ) : undefined ) ;
29+ const timeoutMs = options . timeoutMs ?? 10_000 ;
2730
2831 if ( ! fetchImpl ) {
2932 throw new Error ( 'fetchClearurlsCatalog requires a fetch implementation' ) ;
3033 }
3134
3235 const [ rulesTextRaw , expectedHashRaw ] = await Promise . all ( [
33- fetchText ( fetchImpl , rulesUrl ) ,
34- fetchText ( fetchImpl , hashUrl )
36+ fetchText ( fetchImpl , rulesUrl , timeoutMs ) ,
37+ fetchText ( fetchImpl , hashUrl , timeoutMs )
3538 ] ) ;
3639 const rulesText = rulesTextRaw . trim ( ) ;
3740 const expectedHash = normalizeSha256 ( expectedHashRaw , 'published hash' ) ;
38- const actualHash = await sha256Hex ( rulesText ) ;
41+ const actualHash = await sha256Hex ( rulesTextRaw ) ;
3942
4043 if ( actualHash !== expectedHash ) {
4144 throw new Error ( `ClearURLs rules hash mismatch: expected ${ expectedHash } , got ${ actualHash } ` ) ;
@@ -63,14 +66,53 @@ export async function fetchClearurlsCatalog(
6366 } ;
6467}
6568
66- async function fetchText ( fetchImpl : typeof globalThis . fetch , url : string ) : Promise < string > {
67- const response = await fetchImpl ( url ) ;
69+ async function fetchText (
70+ fetchImpl : typeof globalThis . fetch ,
71+ url : string ,
72+ timeoutMs : number
73+ ) : Promise < string > {
74+ const { signal, cleanup } = createTimeoutSignal ( timeoutMs ) ;
75+ let response : Response ;
76+ try {
77+ response = await fetchImpl ( url , { signal } ) ;
78+ } catch ( error ) {
79+ if ( isAbortError ( error ) ) {
80+ throw new Error ( `Fetch ${ url } timed out after ${ timeoutMs } ms` ) ;
81+ }
82+ throw error ;
83+ } finally {
84+ cleanup ( ) ;
85+ }
86+
6887 if ( ! response . ok ) {
6988 throw new Error ( `Fetch ${ url } failed: ${ response . status } ${ response . statusText } ` . trim ( ) ) ;
7089 }
7190 return response . text ( ) ;
7291}
7392
93+ function createTimeoutSignal ( timeoutMs : number ) : { cleanup : ( ) => void ; signal : AbortSignal } {
94+ if ( ! Number . isFinite ( timeoutMs ) || timeoutMs <= 0 ) {
95+ throw new Error ( 'fetchClearurlsCatalog timeoutMs must be a positive finite number' ) ;
96+ }
97+
98+ if ( typeof AbortSignal . timeout === 'function' ) {
99+ return { signal : AbortSignal . timeout ( timeoutMs ) , cleanup : ( ) => { } } ;
100+ }
101+
102+ const controller = new AbortController ( ) ;
103+ const timer = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
104+ return {
105+ signal : controller . signal ,
106+ cleanup : ( ) => clearTimeout ( timer )
107+ } ;
108+ }
109+
110+ function isAbortError ( error : unknown ) : boolean {
111+ return (
112+ error instanceof DOMException && ( error . name === 'AbortError' || error . name === 'TimeoutError' )
113+ ) ;
114+ }
115+
74116function normalizeSha256 ( hash : string , label : string ) : string {
75117 const normalized = hash . trim ( ) . toLowerCase ( ) ;
76118 if ( ! / ^ [ 0 - 9 a - f ] { 64 } $ / . test ( normalized ) ) {
0 commit comments