Files
fangke/sdk/src/page.ts
T
2026-09-18 12:45:59 +08:00

288 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 不可用(异常环境)——降级无交互检查点 */
}
}