Files
fangke/sdk/src/transport.ts
T
浅念 21c3cb2423 fix(sdk): §4.5 红线补全(act/重放包裹)+ beacon-false 丢批计数 + 空闲负向测试 + es2020
- events.ts:act 回调与 visibilitychange 监听体包 try/catch(字面红线)
- transport.ts:sendBeacon 返回 false(64KB 超限等)按批事件数计 dropped
  ——与 stringify 丢包计数对称的缺口
- index.ts:顶层队列重放双层包裹,单个坏条目只跳过自身不吞后续 init/track
- 测试:30s 空闲负向(active_ms=29000 后停止)、beacon-false 丢批;44→46
- build.mjs target es2018→es2020(代码依赖 globalThis,目标诚实化)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-15 16:40:35 +08:00

95 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
// 返回 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; // 按事件数计丢弃
}
}
}
}