import type { Transport } from "./transport"; import type { Attribution } from "./types"; import { pageContext } from "./context"; import { navSignals } from "./events"; import { uuid } from "./util"; import { getVisitorId, getSessionId, newSessionId, lastActivityAt, touchActivity, IDLE, } from "./identity"; // PageSession(spec §1.1):一个"页面段"的归属上下文。段 = 初始加载或 SPA 规范化 // path 变化之间的一段。事件入队时经 attribution() 冻结四元组快照(seq 自增), // flush 不再重读身份——会话恢复只影响之后的快照,不追溯已冻结事件(消灭 D02)。 // 红线:任何回调/定时器异常静默,绝不冒泡宿主页(spec §4.5)。 export class PageSession { readonly pageId: string; /** 本段创建时的规范化 path(location.pathname,天然无 query/hash)——SPA 切段比对基准 */ readonly path: string; private sessionId: string; // resume 时换新;attribution() 快照读取 private seqCounter = 0; // resume 判定用墙钟(与 identity 的 IDLE 语义一致,Date.now——多标签经 ctk_act // 共享活动记录;fake timers 可控)。hidden 期间不算活动:touch 只由真实交互触发, // visible/flush 前的检查点只查不刷。 private lastActivity = Date.now(); private _closed = false; private closeListeners: Array<() => void> = []; constructor(private t: Transport) { this.pageId = uuid(); this.path = (() => { try { return location.pathname; } catch { return ""; } })(); this.sessionId = getSessionId(); // 创建即冻结本段起始会话(并刷新共享活动) } get isClosed(): boolean { return this._closed; } /** 归属快照:每次调用 seq 自增并返回冻结的四元组(+V1.1 预留 null 字段位)。 * 返回全新对象,之后的 resume/close 不影响已拿到的快照。closed 段仍可调用 * (尾部停留结算需要本段归属)。 */ attribution(): Attribution { this.seqCounter += 1; return { site_id: this.t.cfg.siteId, visitor_id: getVisitorId(), session_id: this.sessionId, page_id: this.pageId, seq: this.seqCounter, purpose: null, epoch: null, grant_ref: null, }; } /** 活动刷新 + 空闲超时检查:先查后刷(先刷则检查永不出界)。 */ touch(): void { try { this.maybeResume(); this.lastActivity = Date.now(); touchActivity(); } catch { /* 静默(spec §4.5) */ } } /** 恢复检查点(visibilitychange→visible / flush 前):只查不刷—— * hidden 期间经过的时间照算空闲,"变可见"本身不构成活动。 */ maybeResume(): void { try { if (this._closed) return; // 多标签:别的标签的活动(ctk_act)也续本会话——取两者较近的一次 const last = Math.max(this.lastActivity, lastActivityAt()); if (Date.now() - last > IDLE) this.resume("idle_timeout"); } catch { /* 静默(spec §4.5) */ } } /** 页面段关闭(路由切换/pagehide):只置位 + 通知钩子;尾部停留由 stay.ts(T7) * 经 onClose 协作结算。close 幂等(hidden 后紧跟 pagehide 可能连续触发)。 */ close(): void { try { if (this._closed) return; this._closed = true; for (const fn of this.closeListeners) { try { fn(); } catch { /* 静默(spec §4.5) */ } } } catch { /* 静默(spec §4.5) */ } } /** bfcache 复活(pageshow persisted=true):closed 复位,pageId/seq 原样继续。 */ revive(): void { try { this._closed = false; } catch { /* 静默(spec §4.5) */ } } onClose(fn: () => void): void { this.closeListeners.push(fn); } /** 本段 page_view(初始加载 / SPA 新段);bfcache 复活不调此方法(不产生新 PV)。 */ emitPageView(): void { try { if (this._closed) return; const pc = pageContext(); this.push( "page_view", { referrer: pc.referrer, referrer_domain: pc.referrer_domain, utm: pc.utm, ...navSignals(), }, pc ); } catch { /* 静默(spec §4.5) */ } } private resume(reason: string): void { const prev = this.sessionId; this.sessionId = newSessionId(); // 同步 identity 的 ctk_sid(localStorage 多标签一致性) this.lastActivity = Date.now(); this.push("session_resume", { prev_session_id: prev, reason }); } // 事件结构沿 events.ts 形态(event_id/event_name/occurred_at/path/title/properties) // + 顶层归属字段(Attribution 展开——T8 transport 按四元组分组拆子批) private push( event_name: string, properties: Record, pc = pageContext() ): void { const a = this.attribution(); this.t.push({ event_id: uuid(), event_name, occurred_at: new Date().toISOString(), path: pc.path, title: pc.title, properties, site_id: a.site_id, visitor_id: a.visitor_id, session_id: a.session_id, page_id: a.page_id, seq: a.seq, purpose: null, epoch: null, grant_ref: null, }); } } // ---- 模块级单例与宿主 hook 安装(T10 由 index.ts 接线;本模块独立可测) ---- let current: PageSession | null = null; let activeTransport: Transport | null = null; let hooksInstalled = false; export function getPageSession(): PageSession | null { return current; } export function startPageSession(t: Transport): PageSession { activeTransport = t; current?.close(); // 重复 init:先收掉旧段(幂等) current = new PageSession(t); current.emitPageView(); // 初始 PV 属于新段(会话若已空闲,getSessionId 已换新,无需伪造 resume) installHooks(); return current; } /** SPA 路由变化统一入口(pushState/replaceState/popstate 共用): * 仅规范化 path(location.pathname,天然无 query/hash)变化才切段—— * query 变化/hash 变化/同 path 的 replaceState(地址清洗)不切(防重复 PV,文档 4.1.5)。 */ function onRouteChange(): void { try { if (!activeTransport) return; const np = location.pathname; if (current && !current.isClosed && current.path === np) return; current?.close(); current = new PageSession(activeTransport); current.emitPageView(); } catch { /* 静默(spec §4.5) */ } } /** 安装宿主 hook。所有回调引用模块级 current/activeTransport(调用时读取), * 重复 startPageSession 只更新引用不重复安装;监听器跨用例存留也只作用到最新段。 * 红线:每个回调整体 try/catch 静默(spec §4.5)。 */ function installHooks(): void { if (hooksInstalled) return; hooksInstalled = true; try { // wrap pushState/replaceState:宿主调用参数原样透传,orig 自身的异常必须原样 // 冒泡(那是宿主自己的 pushState 调用,吞掉会改变宿主行为)——只静默埋点部分 for (const name of ["pushState", "replaceState"] as const) { const orig = history[name].bind(history); history[name] = (( data: unknown, unused: string, url?: string | URL | null ) => { orig(data, unused, url); try { onRouteChange(); } catch { /* 静默(spec §4.5) */ } }) as typeof history.pushState; } } catch { /* history 不可写等环境异常:SPA 切段降级失效,不冒泡(spec §4.5) */ } try { window.addEventListener("popstate", () => { try { onRouteChange(); } catch { /* 静默 */ } }); // bfcache 恢复(OT02):内存上下文仍在 → 沿用原 page_id 复活,不产生新 PV; // bfcache 停留可能超 30min → 复活后补查 resume window.addEventListener("pageshow", (e) => { try { if ((e as PageTransitionEvent).persisted && current) { current.revive(); current.maybeResume(); } } catch { /* 静默 */ } }); window.addEventListener("pagehide", () => { try { current?.close(); } catch { /* 静默 */ } }); // visible 恢复检查点(hidden → 不检查不刷新:隐藏期间不算活动) document.addEventListener("visibilitychange", () => { try { if (!document.hidden) current?.maybeResume(); } catch { /* 静默 */ } }); // 交互监听沿 events.ts 现有四种(spec §4.3 活动定义)——交互即 touch 检查点 ["pointerdown", "keydown", "wheel", "touchstart"].forEach((name) => document.addEventListener( name, () => { try { current?.touch(); } catch { /* 静默 */ } }, { passive: true, capture: true } ) ); } catch { /* 静默(spec §4.5):addEventListener 不可用(异常环境)——降级无交互检查点 */ } }