21c3cb2423
- 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>
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
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; // 按事件数计丢弃
|
||
}
|
||
}
|
||
}
|
||
}
|