Files
fangke/sdk/src/transport.ts
T

93 lines
3.2 KiB
TypeScript
Raw Normal View History

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 溢出丢最旧。
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象——
// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。
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;
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) {
navigator.sendBeacon(
this.cfg.endpoint,
new Blob([body], { type: "text/plain" })
);
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; // 按事件数计丢弃
}
}
}
}