From 21c3cb24231d2115730cc5b4c903ad936f2053e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 15 Sep 2026 16:40:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(sdk):=20=C2=A74.5=20=E7=BA=A2=E7=BA=BF?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=EF=BC=88act/=E9=87=8D=E6=94=BE=E5=8C=85?= =?UTF-8?q?=E8=A3=B9=EF=BC=89+=20beacon-false=20=E4=B8=A2=E6=89=B9?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=20+=20=E7=A9=BA=E9=97=B2=E8=B4=9F=E5=90=91?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20+=20es2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- sdk/build.mjs | 2 +- sdk/src/events.ts | 12 ++++++++++-- sdk/src/index.ts | 17 +++++++++++++++-- sdk/src/transport.ts | 4 +++- sdk/test/events.test.ts | 15 +++++++++++++++ sdk/test/transport.test.ts | 10 ++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/sdk/build.mjs b/sdk/build.mjs index 5ed9fe4..3583922 100644 --- a/sdk/build.mjs +++ b/sdk/build.mjs @@ -2,5 +2,5 @@ import { build } from "esbuild"; await build({ entryPoints: ["src/index.ts"], bundle: true, minify: true, format: "iife", - outfile: "dist/v1.js", target: ["es2018"], + outfile: "dist/v1.js", target: ["es2020"], // 代码依赖 globalThis(es2018 目标名不副实) }); diff --git a/sdk/src/events.ts b/sdk/src/events.ts index 06936af..f681472 100644 --- a/sdk/src/events.ts +++ b/sdk/src/events.ts @@ -10,7 +10,11 @@ export function startPageTracking(t: Transport) { let activeMs = 0; let lastAct = Date.now(); const act = () => { - lastAct = Date.now(); + try { + lastAct = Date.now(); + } catch { + /* 静默(spec §4.5 红线:监听器异常不得冒泡到宿主页) */ + } }; ["pointerdown", "keydown", "wheel", "touchstart"].forEach((e) => document.addEventListener(e, act, { passive: true, capture: true }) @@ -59,7 +63,11 @@ export function startPageTracking(t: Transport) { } }; document.addEventListener("visibilitychange", () => { - if (document.hidden) close(); + try { + if (document.hidden) close(); + } catch { + /* 静默:document.hidden 读取本身也不得冒泡异常(close 内部已自包) */ + } }); window.addEventListener("pagehide", close); } diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 84efd7a..e142d60 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -49,6 +49,19 @@ function dispatch(cmd: string, arg?: unknown) { } } -// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队 +// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队。 +// 宿主队列数据不可信(非可迭代项等)——模块顶层也不得抛(spec §4.5 唯一未保护路径); +// 单个坏条目只跳过自身,不吞掉后续排队的 init/track g.ctk = dispatch; -q.forEach(([c, a]) => dispatch(c, a)); +try { + q.forEach((entry) => { + try { + const [c, a] = entry as Cmd; + dispatch(c, a); + } catch { + /* 静默:坏条目跳过,继续重放 */ + } + }); +} catch { + /* 静默:q 本身不可迭代/无 forEach——放弃重放,入口已接管 */ +} diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts index 113bc66..710c0cb 100644 --- a/sdk/src/transport.ts +++ b/sdk/src/transport.ts @@ -49,10 +49,12 @@ export class Transport { events, }); if (beacon && navigator.sendBeacon) { - 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); diff --git a/sdk/test/events.test.ts b/sdk/test/events.test.ts index d441b26..623e615 100644 --- a/sdk/test/events.test.ts +++ b/sdk/test/events.test.ts @@ -118,6 +118,21 @@ describe("events / startPageTracking", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("30s 无活动不累计:单次活动后空闲 35s → active_ms 恰为 29000 后停止", async () => { + window.history.pushState({}, "", "/pidle"); + const t = mkTransport("site-idle"); + startPageTracking(t); + document.dispatchEvent(new Event("pointerdown")); // 仅此一次活动 + await vi.advanceTimersByTimeAsync(35_000); + + window.dispatchEvent(new Event("pagehide")); + const bodies = await beaconBodies("site-idle"); + expect(bodies).toHaveLength(1); + const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!; + // tick 于 +1s..+29s 时 Date.now()-lastAct<30000 成立(+30s 起等于 30000 不成立) + expect(stay.properties.active_ms).toBe(29000); + }); + it("隐藏→恢复→再隐藏:两段 stay 各自正确(第二段从 0 重新累计)", async () => { window.history.pushState({}, "", "/p6"); const t = mkTransport("site-t6"); diff --git a/sdk/test/transport.test.ts b/sdk/test/transport.test.ts index 689574c..5356ff7 100644 --- a/sdk/test/transport.test.ts +++ b/sdk/test/transport.test.ts @@ -177,6 +177,16 @@ describe("transport", () => { expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃 }); + it("sendBeacon 返回 false(队列满/载荷超限)按本批事件数计 dropped", () => { + vi.stubGlobal("navigator", { sendBeacon: vi.fn().mockReturnValue(false) }); + const t = new Transport(mkCfg({ batchSize: 10 })); + t.push({ event_id: "e1" }); + t.push({ event_id: "e2" }); + t.flush(true); + expect(t.dropped).toBe(2); // 信标被浏览器拒收(64KB 上限等)→ 整批丢 + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => { const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 })); const bad: { event_id: string; self?: unknown } = { event_id: "bad" };