import { randomUUID } from 'node:crypto'; import type { AgentBrowserPayloadChunk, AgentBrowserPayloadRef, } from '../../shared/agent-browser'; import { AgentBrowserFault } from './fault'; type PayloadContentType = AgentBrowserPayloadRef['contentType']; interface PayloadEntry { data: Buffer; contentType: PayloadContentType; expiresAt: number; } export interface PayloadStoreOptions { maxBytes?: number; maxEntryBytes?: number; ttlMs?: number; now?: () => number; } const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_ENTRY_BYTES = 32 * 1024 * 1024; const DEFAULT_TTL_MS = 60_000; const DEFAULT_CHUNK_BYTES = 256 * 1024; const MAX_CHUNK_BYTES = 1024 * 1024; export class AgentBrowserPayloadStore { private readonly entries = new Map(); private readonly maxBytes: number; private readonly maxEntryBytes: number; private readonly ttlMs: number; private readonly now: () => number; private totalBytes = 0; constructor(options: PayloadStoreOptions = {}) { this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; this.maxEntryBytes = options.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES; this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; this.now = options.now ?? Date.now; } putJson(value: unknown): AgentBrowserPayloadRef { let encoded: Buffer; try { encoded = Buffer.from(JSON.stringify(value) ?? 'null'); } catch { throw new AgentBrowserFault( 'CDP_PROTOCOL_ERROR', 'CDP 返回了无法序列化的数据。', false, ); } return this.put(encoded, 'application/json'); } put(data: Buffer, contentType: PayloadContentType): AgentBrowserPayloadRef { this.purgeExpired(); if (data.byteLength > this.maxEntryBytes || data.byteLength > this.maxBytes) { throw new AgentBrowserFault( 'PAYLOAD_TOO_LARGE', `CDP 数据超过 ${this.maxEntryBytes} 字节的单项上限。`, false, ); } while (this.totalBytes + data.byteLength > this.maxBytes) { const oldest = this.entries.keys().next().value as string | undefined; if (!oldest) break; this.delete(oldest); } const handle = randomUUID(); const expiresAt = this.now() + this.ttlMs; this.entries.set(handle, { data: Buffer.from(data), contentType, expiresAt, }); this.totalBytes += data.byteLength; return { kind: 'payload', handle, byteLength: data.byteLength, contentType, expiresAt: new Date(expiresAt).toISOString(), }; } read(handle: string, offset = 0, maxBytes = DEFAULT_CHUNK_BYTES): AgentBrowserPayloadChunk { this.purgeExpired(); const entry = this.entries.get(handle); if (!entry) { throw new AgentBrowserFault( 'PAYLOAD_NOT_FOUND', 'CDP 数据已过期或不存在。', false, ); } if (!Number.isInteger(offset) || offset < 0 || offset > entry.data.byteLength) { throw new AgentBrowserFault('INVALID_REQUEST', 'Payload offset 无效。', false); } if (!Number.isInteger(maxBytes) || maxBytes < 1) { throw new AgentBrowserFault('INVALID_REQUEST', 'Payload maxBytes 无效。', false); } const requestedBytes = Math.min( maxBytes, MAX_CHUNK_BYTES, ); let nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength); const isText = entry.contentType !== 'application/octet-stream'; if (isText && offset > 0 && (entry.data[offset] & 0xc0) === 0x80) { throw new AgentBrowserFault( 'INVALID_REQUEST', '文本 Payload offset 必须位于 UTF-8 字符边界。', false, ); } if (isText && nextOffset < entry.data.byteLength) { while (nextOffset > offset && (entry.data[nextOffset] & 0xc0) === 0x80) { nextOffset -= 1; } if (nextOffset === offset) { nextOffset = Math.min(offset + requestedBytes, entry.data.byteLength); while ( nextOffset < entry.data.byteLength && (entry.data[nextOffset] & 0xc0) === 0x80 ) { nextOffset += 1; } } } const chunk = entry.data.subarray(offset, nextOffset); return { handle, offset, nextOffset, byteLength: entry.data.byteLength, contentType: entry.contentType, data: isText ? chunk.toString('utf8') : chunk.toString('base64'), encoding: isText ? 'utf8' : 'base64', done: nextOffset >= entry.data.byteLength, }; } delete(handle: string): void { const entry = this.entries.get(handle); if (!entry) return; this.entries.delete(handle); this.totalBytes -= entry.data.byteLength; } clear(): void { this.entries.clear(); this.totalBytes = 0; } private purgeExpired(): void { const now = this.now(); for (const [handle, entry] of this.entries) { if (entry.expiresAt <= now) this.delete(handle); } } }