/ packages / web-core / src / cache.ts
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  }