diff --git a/sdk/demo/index.html b/sdk/demo/index.html
new file mode 100644
index 0000000..bece85a
--- /dev/null
+++ b/sdk/demo/index.html
@@ -0,0 +1,5 @@
+
CTK Demo 1
+
+
+Demo 页 1
去第 2 页
diff --git a/sdk/demo/page2.html b/sdk/demo/page2.html
new file mode 100644
index 0000000..fb7565b
--- /dev/null
+++ b/sdk/demo/page2.html
@@ -0,0 +1,5 @@
+CTK Demo 2
+
+
+Demo 页 2
返回第 1 页
diff --git a/sdk/src/events.ts b/sdk/src/events.ts
new file mode 100644
index 0000000..06936af
--- /dev/null
+++ b/sdk/src/events.ts
@@ -0,0 +1,65 @@
+import type { Transport } from "./transport";
+import { pageContext } from "./context";
+import { uuid } from "./util";
+
+// 有效停留(spec §4.3):页面可见且 30s 内有交互活动才按秒累计 activeMs;
+// 隐藏(visibilitychange→hidden)或离开(pagehide)时发 page_stay 并 flush(true)。
+// close 可能连续触发(hidden 后紧跟 pagehide):activeMs 清零后不再重发 stay。
+// 红线:任何回调异常不得冒泡到宿主页(spec §4.5)。
+export function startPageTracking(t: Transport) {
+ let activeMs = 0;
+ let lastAct = Date.now();
+ const act = () => {
+ lastAct = Date.now();
+ };
+ ["pointerdown", "keydown", "wheel", "touchstart"].forEach((e) =>
+ document.addEventListener(e, act, { passive: true, capture: true })
+ );
+ setInterval(() => {
+ try {
+ if (!document.hidden && Date.now() - lastAct < 30_000) activeMs += 1000;
+ } catch {
+ /* 静默 */
+ }
+ }, 1000);
+ // emit 内 pageContext() 只取一次(pc)复用于 path/title——避免多次读取间
+ // location 瞬变导致同一事件内字段不一致;page_view 另将 pc 传入复用
+ const emit = (
+ event_name: string,
+ properties: Record = {},
+ pc = pageContext()
+ ) => {
+ t.push({
+ event_id: uuid(),
+ event_name,
+ occurred_at: new Date().toISOString(),
+ path: pc.path,
+ title: pc.title,
+ properties,
+ });
+ };
+ const pc = pageContext();
+ emit(
+ "page_view",
+ { referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm },
+ pc
+ );
+ const close = () => {
+ try {
+ // path 必须与 page_view 同源同清洗(pageContext().path 含 query),
+ // 否则带 UTM 进落地页时服务端"page_stay 合并进相邻 page_view"匹配失败
+ if (activeMs > 0) {
+ const path = pageContext().path;
+ emit("page_stay", { active_ms: activeMs, path });
+ }
+ activeMs = 0;
+ t.flush(true);
+ } catch {
+ /* 静默 */
+ }
+ };
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) close();
+ });
+ window.addEventListener("pagehide", close);
+}
diff --git a/sdk/src/index.ts b/sdk/src/index.ts
new file mode 100644
index 0000000..84efd7a
--- /dev/null
+++ b/sdk/src/index.ts
@@ -0,0 +1,54 @@
+import { uuid } from "./util";
+import { mergeConfig } from "./config";
+import { Transport } from "./transport";
+import { startPageTracking } from "./events";
+import { buildEnvContext, pageContext } from "./context";
+import type { CtkConfig } from "./config";
+
+// IIFE 打包入口(build.mjs):模块加载即接管全局 ctk 命令队列并重放。
+// 红线:dispatch 内任何异常静默,绝不冒泡到宿主页(spec §4.5)。
+type Cmd = [string, unknown?];
+type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
+const g = globalThis as unknown as {
+ ctk?: CtkFn;
+ __ctk_ctx?: unknown;
+};
+const q: Cmd[] = (g.ctk?.q as Cmd[] | undefined) || [];
+let transport: Transport | null = null;
+
+function dispatch(cmd: string, arg?: unknown) {
+ try {
+ if (cmd === "init" && !transport) {
+ const cfg = mergeConfig((arg ?? {}) as CtkConfig);
+ // 首触归因:批次 context 除设备环境外必须带 referrer_domain/utm/path——
+ // 服务端只从 context 读首触,缺了则首触/会话来源恒空
+ const pc = pageContext();
+ g.__ctk_ctx = {
+ ...buildEnvContext(),
+ referrer_domain: pc.referrer_domain,
+ utm: pc.utm,
+ path: pc.path,
+ };
+ transport = new Transport(cfg);
+ transport.push({
+ event_id: uuid(),
+ event_name: "sdk_loaded",
+ occurred_at: new Date().toISOString(),
+ path: pc.path,
+ title: pc.title,
+ properties: { sdk_version: "0.1.0" },
+ });
+ startPageTracking(transport);
+ } else if (cmd === "track" && transport) {
+ transport.push(arg as object);
+ } else if (cmd === "flush" && transport) {
+ transport.flush();
+ }
+ } catch {
+ /* 任何异常静默,绝不冒泡到宿主页(spec §4.5) */
+ }
+}
+
+// 必须先替换全局入口再重放队列:重放期间宿主新调用直接进 dispatch,不再排队
+g.ctk = dispatch;
+q.forEach(([c, a]) => dispatch(c, a));
diff --git a/sdk/src/transport.ts b/sdk/src/transport.ts
index 4683a8f..113bc66 100644
--- a/sdk/src/transport.ts
+++ b/sdk/src/transport.ts
@@ -7,8 +7,8 @@ export type TransportConfig = Required;
// 批量传输:满 batchSize 或 flushMs 到期即发;4xx(除 429)不重试;
// 指数退避 + 抖动重试;重试耗尽按本批事件数计 dropped;queueMax 溢出丢最旧。
// 红线:任何异常不得冒泡到宿主页(定时器/事件回调内的异常无人捕获)。
-// 审查留档:events 目前均为 SDK 自产可序列化对象;若未来引入受控外的 events
-// 来源,需处理 stringify 抛错时已换出批次的计数(当前静默丢弃不计 dropped)。
+// 审查留档(Task 14 结论):index.ts 的 track 分支已引入宿主可控对象——
+// stringify 抛错(如循环引用)时已换出的批次按事件数计 dropped。
export class Transport {
private q: object[] = [];
private timer: ReturnType | null = null;
@@ -33,9 +33,11 @@ export class Transport {
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,
@@ -55,7 +57,9 @@ export class Transport {
}
this.send(body, events.length);
} catch {
- // 定时器/事件回调内的任何异常不得冒泡到宿主页
+ // 定时器/事件回调内的任何异常不得冒泡到宿主页;
+ // stringify 抛错时批次已换出(宿主可控对象),按事件数计丢弃
+ this.dropped += n;
}
}
diff --git a/sdk/test/events.test.ts b/sdk/test/events.test.ts
new file mode 100644
index 0000000..d441b26
--- /dev/null
+++ b/sdk/test/events.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { Transport } from "../src/transport";
+import { mergeConfig } from "../src/config";
+import { startPageTracking } from "../src/events";
+
+// 有效停留(spec §4.3):可见 + 30s 内有活动才按秒累计。
+// 注意:startPageTracking 在 document/window 上注册的监听跨用例存留(匿名函数无法摘除),
+// 故每个用例用独立 siteId,并按 body.site_id 过滤断言,避免旧 tracker 的 close 串扰计数。
+// happy-dom 的 navigator.sendBeacon 会发真实网络请求——beforeEach 里整体 stubGlobal 替换
+// (Task 14 注释警示),凡 close→flush(true) 路径均不得触网。
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+let fetchMock: ReturnType;
+let beacon: ReturnType;
+
+function stubNavigator() {
+ beacon = vi.fn().mockReturnValue(true);
+ vi.stubGlobal("navigator", {
+ userAgent:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ language: "zh-CN",
+ hardwareConcurrency: 8,
+ sendBeacon: beacon,
+ });
+}
+
+function mkTransport(siteId: string) {
+ return new Transport(
+ mergeConfig({ siteId, endpoint: "/api/collect/batch", batchSize: 10, flushMs: 60000 })
+ );
+}
+
+/** 从 beacon 调用里取指定 site 的请求体(过滤掉旧用例 tracker 的串扰批次) */
+async function beaconBodies(siteId: string) {
+ const out = [];
+ for (const [, blob] of beacon.mock.calls as unknown as [string, Blob][]) {
+ const body = JSON.parse(await blob.text());
+ if (body.site_id === siteId) out.push(body);
+ }
+ return out as { site_id: string; events: { event_name: string; path: string; properties: Record }[] }[];
+}
+
+describe("events / startPageTracking", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.useFakeTimers();
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
+ vi.stubGlobal("fetch", fetchMock);
+ stubNavigator();
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers(); // 旧 tracker 的 1s interval 挂在各自 fake clock 上,随钟一起废弃
+ delete (document as { hidden?: boolean }).hidden; // 还原 happy-dom 原型 getter
+ delete (document as { referrer?: string }).referrer;
+ });
+
+ it("page_view 含 path/title/referrer_domain/utm(path 同源清洗)", async () => {
+ document.title = "落地页";
+ Object.defineProperty(document, "referrer", {
+ value: "https://news.example/article/1",
+ configurable: true,
+ });
+ window.history.pushState({}, "", "/land?utm_source=ns&utm_medium=cpc&email=a@b.c");
+ const t = mkTransport("site-pv");
+ startPageTracking(t);
+ t.flush();
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body);
+ const pv = body.events.find((e: { event_name: string }) => e.event_name === "page_view");
+ expect(pv.event_id).toMatch(UUID_RE);
+ expect(pv.occurred_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ expect(pv.path).toBe("/land?utm_source=ns&utm_medium=cpc"); // email 参数被清洗,utm 保留
+ expect(pv.title).toBe("落地页");
+ expect(pv.properties).toEqual({
+ referrer: "https://news.example/article/1",
+ referrer_domain: "news.example",
+ utm: { utm_source: "ns", utm_medium: "cpc" },
+ });
+ });
+
+ it("有效停留:可见 + pointerdown 后 3s → close 发 page_stay(active_ms=3000)", async () => {
+ window.history.pushState({}, "", "/p4");
+ const t = mkTransport("site-t4");
+ startPageTracking(t);
+ document.dispatchEvent(new Event("pointerdown"));
+ await vi.advanceTimersByTimeAsync(3000); // 1s interval 走 3 拍
+
+ window.dispatchEvent(new Event("pagehide")); // 触发 close
+ expect(fetchMock).not.toHaveBeenCalled(); // close 走 flush(true) → sendBeacon 而非 fetch
+ const bodies = await beaconBodies("site-t4");
+ expect(bodies).toHaveLength(1);
+ expect(bodies[0].events.map((e) => e.event_name)).toEqual(["page_view", "page_stay"]);
+ const stay = bodies[0].events[1];
+ expect(stay.properties.active_ms).toBe(3000);
+ expect(stay.path).toBe("/p4"); // 顶层 path 与 page_view 同源
+ expect(stay.properties.path).toBe("/p4"); // properties.path 供服务端合并相邻 page_view
+ });
+
+ it("hidden 后不累计:visibilitychange→close 发 page_stay 并 flush(true);再次 close 不重发", async () => {
+ window.history.pushState({}, "", "/p5");
+ const t = mkTransport("site-t5");
+ startPageTracking(t);
+ document.dispatchEvent(new Event("pointerdown"));
+ await vi.advanceTimersByTimeAsync(2000); // 累计 2000
+
+ Object.defineProperty(document, "hidden", { value: true, configurable: true });
+ document.dispatchEvent(new Event("visibilitychange")); // close 第 1 次
+ await vi.advanceTimersByTimeAsync(5000); // hidden 期间不累计(若累计则下步会多出一批)
+ window.dispatchEvent(new Event("pagehide")); // close 第 2 次(双触发)
+
+ const bodies = await beaconBodies("site-t5");
+ expect(bodies).toHaveLength(1); // 第 2 次 close:activeMs=0 不发 stay,空队列 no-op
+ const stay = bodies[0].events.find((e) => e.event_name === "page_stay")!;
+ expect(stay.properties.active_ms).toBe(2000); // 恰为隐藏前累计值
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("隐藏→恢复→再隐藏:两段 stay 各自正确(第二段从 0 重新累计)", async () => {
+ window.history.pushState({}, "", "/p6");
+ const t = mkTransport("site-t6");
+ startPageTracking(t);
+ document.dispatchEvent(new Event("pointerdown"));
+ await vi.advanceTimersByTimeAsync(2000); // 段 1:2000ms
+ Object.defineProperty(document, "hidden", { value: true, configurable: true });
+ document.dispatchEvent(new Event("visibilitychange")); // close → 第 1 批
+
+ delete (document as { hidden?: boolean }).hidden; // 恢复可见(还原原型 getter)
+ document.dispatchEvent(new Event("visibilitychange")); // 非 hidden → 不 close
+ document.dispatchEvent(new Event("pointerdown")); // 新活动
+ await vi.advanceTimersByTimeAsync(3000); // 段 2:3000ms
+ Object.defineProperty(document, "hidden", { value: true, configurable: true });
+ document.dispatchEvent(new Event("visibilitychange")); // close → 第 2 批
+
+ const bodies = await beaconBodies("site-t6");
+ expect(bodies).toHaveLength(2);
+ const s1 = bodies[0].events.find((e) => e.event_name === "page_stay")!;
+ const s2 = bodies[1].events.find((e) => e.event_name === "page_stay")!;
+ expect(s1.properties.active_ms).toBe(2000);
+ expect(s2.properties.active_ms).toBe(3000); // 从 0 重新起算,非 5000
+ expect(bodies[1].events).toHaveLength(1); // 第 2 批只有 page_stay
+ });
+});
diff --git a/sdk/test/index.test.ts b/sdk/test/index.test.ts
new file mode 100644
index 0000000..533e674
--- /dev/null
+++ b/sdk/test/index.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+
+// index.ts 是 IIFE 打包入口:模块加载即读取全局队列、替换 g.ctk 并重放。
+// 隔离手段:vi.resetModules() + 动态 import,每用例拿到全新模块级 transport 状态。
+// startPageTracking 注册的 document/window 监听跨用例存留,用独立 siteId 过滤批次。
+
+type Cmd = [string, unknown?];
+type CtkFn = ((cmd: string, arg?: unknown) => void) & { q?: Cmd[] };
+const g = globalThis as unknown as {
+ ctk?: CtkFn;
+ __ctk_ctx?: Record;
+};
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
+
+let fetchMock: ReturnType;
+
+/** 模拟宿主snippet:先以桩函数排队,再动态加载 index 模块(触发重放) */
+async function loadSdk(q: Cmd[] = []) {
+ vi.resetModules();
+ const stub = (cmd: string, arg?: unknown) => {
+ q.push([cmd, arg]);
+ };
+ stub.q = q;
+ g.ctk = stub;
+ await import("../src/index");
+}
+
+function bodiesOf(siteId: string) {
+ return fetchMock.mock.calls
+ .map((c) => JSON.parse(c[1].body as string))
+ .filter((b: { site_id: string }) => b.site_id === siteId);
+}
+
+describe("index 入口", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ vi.useFakeTimers();
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 } as Response);
+ vi.stubGlobal("fetch", fetchMock);
+ // happy-dom 的 sendBeacon 会发真实网络请求——整体替换(Task 14 注释警示)
+ vi.stubGlobal("navigator", {
+ userAgent:
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+ language: "zh-CN",
+ hardwareConcurrency: 8,
+ sendBeacon: vi.fn().mockReturnValue(true),
+ });
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ delete g.__ctk_ctx;
+ delete (document as { hidden?: boolean }).hidden;
+ delete (document as { referrer?: string }).referrer;
+ });
+
+ it("init 后 sdk_loaded 入队(含 sdk_version)", async () => {
+ await loadSdk([["init", { siteId: "site-1", endpoint: "/e", flushMs: 60000 }]]);
+ g.ctk!("flush");
+
+ const bodies = bodiesOf("site-1");
+ expect(bodies).toHaveLength(1);
+ const loaded = bodies[0].events.find((e: { event_name: string }) => e.event_name === "sdk_loaded");
+ expect(loaded).toBeTruthy();
+ expect(loaded.properties.sdk_version).toBe("0.1.0");
+ expect(loaded.event_id).toMatch(UUID_RE);
+ });
+
+ it("批次 context 含设备环境 + referrer_domain/utm/path(首触归因链路)", async () => {
+ Object.defineProperty(document, "referrer", {
+ value: "https://news.example/a",
+ configurable: true,
+ });
+ window.history.pushState({}, "", "/land?utm_source=ns&utm_campaign=m1");
+ await loadSdk([["init", { siteId: "site-2", endpoint: "/e", flushMs: 60000 }]]);
+
+ // __ctk_ctx 四类键齐全:设备环境 + referrer_domain + utm + path
+ const ctx = g.__ctk_ctx!;
+ expect(ctx.device_type).toBe("PC");
+ expect(ctx.os).toBe("Windows");
+ expect(ctx.browser).toBe("Chrome");
+ expect(ctx.language).toBe("zh-CN");
+ expect(typeof ctx.timezone).toBe("string");
+ expect(ctx.screen).toMatch(/^\d+x\d+$/);
+ expect(ctx.referrer_domain).toBe("news.example");
+ expect(ctx.utm).toEqual({ utm_source: "ns", utm_campaign: "m1" });
+ expect(ctx.path).toBe("/land?utm_source=ns&utm_campaign=m1");
+
+ g.ctk!("flush"); // 原样透传进批次 body.context
+ const bodies = bodiesOf("site-2");
+ expect(bodies).toHaveLength(1);
+ expect(bodies[0].context).toEqual(ctx);
+ });
+
+ it("localStorage 抛异常:15s 定时 flush 无未捕获异常,身份降级内存后事件照常发出", async () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("denied");
+ },
+ setItem: () => {
+ throw new Error("denied");
+ },
+ removeItem: () => {
+ throw new Error("denied");
+ },
+ clear: () => {
+ throw new Error("denied");
+ },
+ });
+ const unhandled: unknown[] = [];
+ const onRej = (e: unknown) => unhandled.push(e);
+ // tsconfig 无 @types/node,经 globalThis 取 process(vitest node worker 内必在)
+ const proc = (
+ globalThis as {
+ process?: {
+ on(ev: "unhandledRejection", cb: (e: unknown) => void): void;
+ off(ev: "unhandledRejection", cb: (e: unknown) => void): void;
+ };
+ }
+ ).process;
+ proc?.on("unhandledRejection", onRej);
+ await loadSdk([["init", { siteId: "site-7", endpoint: "/e", flushMs: 15000 }]]);
+
+ let syncErr: unknown;
+ try {
+ await vi.advanceTimersByTimeAsync(15000); // 定时 flush 到期
+ } catch (e) {
+ syncErr = e; // fake timers 会把定时回调内的同步异常抛到 advance 调用方
+ }
+ proc?.off("unhandledRejection", onRej);
+ expect(syncErr).toBeUndefined();
+ expect(unhandled).toEqual([]);
+
+ const bodies = bodiesOf("site-7");
+ expect(bodies).toHaveLength(1);
+ expect(bodies[0].visitor_id).toMatch(UUID_RE); // 内存降级身份仍可用
+ expect(bodies[0].session_id).toMatch(UUID_RE);
+ expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
+ "sdk_loaded",
+ "page_view",
+ ]);
+ });
+
+ it("init 前队列重放:排队的 track 事件入队,g.ctk 已替换为 dispatch", async () => {
+ const trackEv = {
+ event_id: "e-custom",
+ event_name: "cta_click",
+ occurred_at: new Date().toISOString(),
+ path: "/x",
+ title: "t",
+ properties: { k: 1 },
+ };
+ await loadSdk([
+ ["init", { siteId: "site-8", endpoint: "/e", flushMs: 60000 }],
+ ["track", trackEv],
+ ]);
+
+ expect(typeof g.ctk).toBe("function");
+ expect(g.ctk!.q).toBeUndefined(); // 桩(带 q)已被 dispatch 替换
+ g.ctk!("flush");
+ let bodies = bodiesOf("site-8");
+ expect(bodies).toHaveLength(1);
+ expect(bodies[0].events.map((e: { event_name: string }) => e.event_name)).toEqual([
+ "sdk_loaded",
+ "page_view",
+ "cta_click",
+ ]);
+ expect(bodies[0].events[2].properties).toEqual({ k: 1 });
+
+ // 重放结束后宿主新调用直接进 dispatch(不再排队丢失)
+ g.ctk!("track", { ...trackEv, event_id: "e-2", event_name: "cta_click2" });
+ g.ctk!("flush");
+ bodies = bodiesOf("site-8");
+ expect(bodies).toHaveLength(2);
+ expect(bodies[1].events.map((e: { event_name: string }) => e.event_name)).toEqual(["cta_click2"]);
+ });
+});
diff --git a/sdk/test/transport.test.ts b/sdk/test/transport.test.ts
index 7a0887b..689574c 100644
--- a/sdk/test/transport.test.ts
+++ b/sdk/test/transport.test.ts
@@ -176,6 +176,22 @@ describe("transport", () => {
await vi.advanceTimersByTimeAsync(100);
expect(t.dropped).toBe(1); // maxRetry=0 耗尽 → 按事件数丢弃
});
+
+ it("stringify 抛错(track 引入宿主可控对象):已换出批次按事件数计 dropped", () => {
+ const t = new Transport(mkCfg({ batchSize: 10, flushMs: 60000 }));
+ const bad: { event_id: string; self?: unknown } = { event_id: "bad" };
+ bad.self = bad; // 循环引用 → JSON.stringify 抛 TypeError
+ t.push(bad);
+ t.push({ event_id: "ok" });
+ expect(() => t.flush()).not.toThrow();
+ expect(t.dropped).toBe(2); // 换出的 2 条全部落丢,不能静默不计
+ expect(fetchMock).not.toHaveBeenCalled();
+
+ t.push({ event_id: "e3" }); // 队列已换出,后续事件照常
+ t.flush();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).events).toEqual([{ event_id: "e3" }]);
+ });
});
describe("mergeConfig", () => {