cache.ts
1 export type CacheEntry<T> = { 2 value: T 3 expiresAt: number 4 insertedAt: number 5 } 6 7 export const DEFAULT_TIMEOUT_SECONDS = 30 8 export const DEFAULT_CACHE_TTL_MINUTES = 15 9 const DEFAULT_CACHE_MAX_ENTRIES = 100 10 11 export function resolveTimeoutSeconds( 12 value: unknown, 13 fallback: number, 14 ): number { 15 const parsed = 16 typeof value === 'number' && Number.isFinite(value) ? value : fallback 17 return Math.max(1, Math.floor(parsed)) 18 } 19 20 export function resolveCacheTtlMs( 21 value: unknown, 22 fallbackMinutes: number, 23 ): number { 24 const minutes = 25 typeof value === 'number' && Number.isFinite(value) 26 ? Math.max(0, value) 27 : fallbackMinutes 28 return Math.round(minutes * 60_000) 29 } 30 31 export function normalizeCacheKey(value: string): string { 32 return value.trim().toLowerCase() 33 } 34 35 export function readCache<T>( 36 cache: Map<string, CacheEntry<T>>, 37 key: string, 38 ): { value: T; cached: boolean } | null { 39 const entry = cache.get(key) 40 if (!entry) { 41 return null 42 } 43 if (Date.now() > entry.expiresAt) { 44 cache.delete(key) 45 return null 46 } 47 return { value: entry.value, cached: true } 48 } 49 50 export function writeCache<T>( 51 cache: Map<string, CacheEntry<T>>, 52 key: string, 53 value: T, 54 ttlMs: number, 55 ) { 56 if (ttlMs <= 0) { 57 return 58 } 59 if (cache.size >= DEFAULT_CACHE_MAX_ENTRIES) { 60 const oldest = cache.keys().next() 61 if (!oldest.done) { 62 cache.delete(oldest.value) 63 } 64 } 65 cache.set(key, { 66 value, 67 expiresAt: Date.now() + ttlMs, 68 insertedAt: Date.now(), 69 }) 70 } 71 72 export function withTimeout( 73 signal: AbortSignal | undefined, 74 timeoutMs: number, 75 ): AbortSignal { 76 if (timeoutMs <= 0) { 77 return signal ?? new AbortController().signal 78 } 79 const controller = new AbortController() 80 const timer = setTimeout(controller.abort.bind(controller), timeoutMs) 81 if (signal) { 82 signal.addEventListener( 83 'abort', 84 () => { 85 clearTimeout(timer) 86 controller.abort() 87 }, 88 { once: true }, 89 ) 90 } 91 controller.signal.addEventListener( 92 'abort', 93 () => { 94 clearTimeout(timer) 95 }, 96 { once: true }, 97 ) 98 return controller.signal 99 } 100 101 export type ReadResponseTextResult = { 102 text: string 103 truncated: boolean 104 bytesRead: number 105 } 106 107 function resolvePositiveMaxBytes(value: unknown): number | undefined { 108 if (typeof value !== 'number' || value <= 0) { 109 return undefined 110 } 111 return Math.floor(value) 112 } 113 114 function getResponseBodyReader( 115 res: Response, 116 maxBytes: number | undefined, 117 ): ReadableStreamDefaultReader<Uint8Array> | null { 118 if (!maxBytes) { 119 return null 120 } 121 122 const body = (res as unknown as { body?: unknown }).body 123 if (!body || typeof body !== 'object') { 124 return null 125 } 126 if (!('getReader' in body)) { 127 return null 128 } 129 if (typeof (body as { getReader: () => unknown }).getReader !== 'function') { 130 return null 131 } 132 133 return (body as ReadableStream<Uint8Array>).getReader() 134 } 135 136 async function readStreamTextWithLimit( 137 reader: ReadableStreamDefaultReader<Uint8Array>, 138 maxBytes: number, 139 ): Promise<ReadResponseTextResult> { 140 const decoder = new TextDecoder() 141 const parts: string[] = [] 142 let bytesRead = 0 143 let truncated = false 144 145 try { 146 for (;;) { 147 const result = await reader.read() 148 if (result.done) { 149 break 150 } 151 152 const remaining = maxBytes - bytesRead 153 if (remaining <= 0) { 154 truncated = true 155 break 156 } 157 158 let chunk = result.value 159 if (chunk.byteLength > remaining) { 160 chunk = chunk.subarray(0, remaining) 161 truncated = true 162 } 163 164 bytesRead += chunk.byteLength 165 parts.push(decoder.decode(chunk, { stream: true })) 166 167 if (bytesRead >= maxBytes) { 168 truncated = true 169 break 170 } 171 } 172 } catch { 173 // Best-effort: return whatever we decoded so far. 174 } finally { 175 if (truncated) { 176 try { 177 await reader.cancel() 178 } catch { 179 // ignore 180 } 181 } 182 } 183 184 parts.push(decoder.decode()) 185 return { text: parts.join(''), truncated, bytesRead } 186 } 187 188 export async function readResponseText( 189 res: Response, 190 options?: { maxBytes?: number }, 191 ): Promise<ReadResponseTextResult> { 192 const maxBytes = resolvePositiveMaxBytes(options?.maxBytes) 193 const reader = getResponseBodyReader(res, maxBytes) 194 if (reader && maxBytes) { 195 return readStreamTextWithLimit(reader, maxBytes) 196 } 197 198 try { 199 const text = await res.text() 200 return { text, truncated: false, bytesRead: text.length } 201 } catch { 202 return { text: '', truncated: false, bytesRead: 0 } 203 } 204 }