2026-09-15 16:07:18 +08:00
|
|
|
|
import { uuid } from "./util";
|
|
|
|
|
|
import { getVisitorId, getSessionId } from "./identity";
|
|
|
|
|
|
import type { CtkConfig } from "./config";
|
|
|
|
|
|
|
|
|
|
|
|
export type TransportConfig = Required<CtkConfig>;
|
|
|
|
|
|
|
|
|
|
|
|
// 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试;
|
|
|
|
|
|
// 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。
|
|
|
|
|
|
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
|
2026-09-15 16:13:37 +08:00
|
|
|
|
// 审查留档:events 目前均为 SDK 自产可序列化对象;若未来引入受控外的 events
|
|
|
|
|
|
// 来源,需处理 stringify 抛错时已换出批次的计数(当前静默丢弃不计 dropped)。
|
2026-09-15 16:07:18 +08:00
|
|
|
|
export class Transport {
|
|
|
|
|
|
private q: object[] = [];
|
|
|
|
|
|
private timer: ReturnType<typeof setTimeout> | 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;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const events = this.q;
|
|
|
|
|
|
this.q = [];
|
|
|
|
|
|
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) {
|
|
|
|
|
|
navigator.sendBeacon(
|
|
|
|
|
|
this.cfg.endpoint,
|
|
|
|
|
|
new Blob([body], { type: "text/plain" })
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.send(body, events.length);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 定时器/事件回调内的任何异常不得冒泡到宿主页
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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; // 按事件数计丢弃
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|