Files
openclaw/src/agents/tools/web-shared.ts
2026-02-16 21:13:48 -05:00

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 };
}
}