import { uuid } from "./util"; import { getVisitorId, getSessionId } from "./identity"; import type { CtkConfig } from "./config"; export type TransportConfig = Required; // 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试; // 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。 // 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。 // 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象—— // stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。 export class Transport { private q: object[] = []; private timer: ReturnType | null = null; private retry = 0; dropped = 0; constructor(public cfg: TransportConfig) {} push(ev: object) { if (this.q.length >= this.cfg.queueMax) { this.q.shift(); this.dropped++; } this.q.push(ev); if (this.q.length >= this.cfg.batchSize) this.flush(); else if (!this.timer) this.timer = setTimeout(() => this.flush(), this.cfg.flushMs); } flush(beacon = false) { if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (!this.q.length) return; let n = 0; try { const events = this.q; this.q = []; n = events.length; // 换出后先记数:stringify 抛错时本批已丢,须计 dropped const body = JSON.stringify({ batch_id: uuid(), site_id: this.cfg.siteId, visitor_id: getVisitorId(), session_id: getSessionId(), sent_at: new Date().toISOString(), // 由 index.ts(Task 15)注入;未注入时 undefined——JSON 序列化自然缺键,照发 context: (globalThis as { __ctk_ctx?: unknown }).__ctk_ctx, events, }); if (beacon && navigator.sendBeacon) { // 返回 false=浏览器拒收(队列满/载荷超 64KB 等)→ 整批丢,须计 dropped const ok = navigator.sendBeacon( this.cfg.endpoint, new Blob([body], { type: "text/plain" }) ); if (!ok) this.dropped += n; return; } this.send(body, events.length); } catch { // 定时器/事件回调内的任何异常不得冒泡到宿主页; // stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃 this.dropped += n; } } private async send(body: string, count: number) { try { const res = await fetch(this.cfg.endpoint, { method: "POST", keepalive: true, headers: { "Content-Type": "text/plain" }, body, }); if (res.status >= 400 && res.status < 500 && res.status !== 429) { this.retry = 0; return; // 4xx 参数/权限错不重试(403 重试只会掩盖配置错误) } if (!res.ok) throw new Error(String(res.status)); this.retry = 0; } catch { if (this.retry++ < this.cfg.maxRetry) { const delay = 500 * 2 ** this.retry * (0.8 + Math.random() * 0.4); setTimeout(() => { this.send(body, count).catch(() => {}); }, delay); // 重放同一 body 字符串——同 event_id 重放,服务端幂等去重 } else { this.retry = 0; this.dropped += count; // 按事件数计丢弃 } } } }