288 lines
9.1 KiB
TypeScript
288 lines
9.1 KiB
TypeScript
|
|
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<string, unknown>,
|
|||
|
|
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 不可用(异常环境)——降级无交互检查点 */
|
|||
|
|
}
|
|||
|
|
}
|