171 lines
4.1 KiB
TypeScript
171 lines
4.1 KiB
TypeScript
export type CacheEntry<T> = {
|
|
value: T;
|
|
expiresAt: number;
|
|
insertedAt: number;
|
|
};
|
|
|
|
export const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
export const DEFAULT_CACHE_TTL_MINUTES = 15;
|
|
const DEFAULT_CACHE_MAX_ENTRIES = 100;
|
|
|
|
export function resolveTimeoutSeconds(value: unknown, fallback: number): number {
|
|
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
return Math.max(1, Math.floor(parsed));
|
|
}
|
|
|
|
export function resolveCacheTtlMs(value: unknown, fallbackMinutes: number): number {
|
|
const minutes =
|
|
typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : fallbackMinutes;
|
|
return Math.round(minutes * 60_000);
|
|
}
|
|
|
|
export function normalizeCacheKey(value: string): string {
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
export function readCache<T>(
|
|
cache: Map<string, CacheEntry<T>>,
|
|
key: string,
|
|
): { value: T; cached: boolean } | null {
|
|
const entry = cache.get(key);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
if (Date.now() > entry.expiresAt) {
|
|
cache.delete(key);
|
|
return null;
|
|
}
|
|
return { value: entry.value, cached: true };
|
|
}
|
|
|
|
export function writeCache<T>(
|
|
cache: Map<string, CacheEntry<T>>,
|
|
key: string,
|
|
value: T,
|
|
ttlMs: number,
|
|
) {
|
|
if (ttlMs <= 0) {
|
|
return;
|
|
}
|
|
if (cache.size >= DEFAULT_CACHE_MAX_ENTRIES) {
|
|
const oldest = cache.keys().next();
|
|
if (!oldest.done) {
|
|
cache.delete(oldest.value);
|
|
}
|
|
}
|
|
cache.set(key, {
|
|
value,
|
|
expiresAt: Date.now() + ttlMs,
|
|
insertedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
export function withTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
if (timeoutMs <= 0) {
|
|
return signal ?? new AbortController().signal;
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(controller.abort.bind(controller), timeoutMs);
|
|
if (signal) {
|
|
signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
controller.signal.addEventListener(
|
|
"abort",
|
|
() => {
|
|
clearTimeout(timer);
|
|
},
|
|
{ once: true },
|
|
);
|
|
return controller.signal;
|
|
}
|
|
|
|
export type ReadResponseTextResult = {
|
|
text: string;
|
|
truncated: boolean;
|
|
bytesRead: number;
|
|
};
|
|
|
|
export async function readResponseText(
|
|
res: Response,
|
|
options?: { maxBytes?: number },
|
|
): Promise<ReadResponseTextResult> {
|
|
const maxBytesRaw = options?.maxBytes;
|
|
const maxBytes =
|
|
typeof maxBytesRaw === "number" && Number.isFinite(maxBytesRaw) && maxBytesRaw > 0
|
|
? Math.floor(maxBytesRaw)
|
|
: undefined;
|
|
|
|
const body = (res as unknown as { body?: unknown }).body;
|
|
if (
|
|
maxBytes &&
|
|
body &&
|
|
typeof body === "object" &&
|
|
"getReader" in body &&
|
|
typeof (body as { getReader: () => unknown }).getReader === "function"
|
|
) {
|
|
const reader = (body as ReadableStream<Uint8Array>).getReader();
|
|
const decoder = new TextDecoder();
|
|
let bytesRead = 0;
|
|
let truncated = false;
|
|
const parts: string[] = [];
|
|
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
if (!value || value.byteLength === 0) {
|
|
continue;
|
|
}
|
|
|
|
let chunk = value;
|
|
if (bytesRead + chunk.byteLength > maxBytes) {
|
|
const remaining = Math.max(0, maxBytes - bytesRead);
|
|
if (remaining <= 0) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
chunk = chunk.subarray(0, remaining);
|
|
truncated = true;
|
|
}
|
|
|
|
bytesRead += chunk.byteLength;
|
|
parts.push(decoder.decode(chunk, { stream: true }));
|
|
|
|
if (truncated || bytesRead >= maxBytes) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort: return whatever we decoded so far.
|
|
} finally {
|
|
if (truncated) {
|
|
try {
|
|
await reader.cancel();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
parts.push(decoder.decode());
|
|
return { text: parts.join(""), truncated, bytesRead };
|
|
}
|
|
|
|
try {
|
|
const text = await res.text();
|
|
return { text, truncated: false, bytesRead: text.length };
|
|
} catch {
|
|
return { text: "", truncated: false, bytesRead: 0 };
|
|
}
|
|
}
|