feat(sdk): 批量传输(批量/退避/信标/丢包计数)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
export interface CtkConfig {
|
||||
siteId: string;
|
||||
endpoint: string;
|
||||
batchSize?: number;
|
||||
flushMs?: number;
|
||||
maxRetry?: number;
|
||||
queueMax?: number;
|
||||
}
|
||||
|
||||
export function mergeConfig(opts: CtkConfig): Required<CtkConfig> {
|
||||
return { batchSize: 10, flushMs: 15000, maxRetry: 3, queueMax: 200, ...opts };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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 溢出丢最旧。
|
||||
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
|
||||
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; // 按事件数计丢弃
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user