interface BogamSession { visitedPages: string[]; lastPageTransitionContext: PageTransitionContext | null; nextPageTransitionContext: PageTransitionContext | null; sessionId: string; windowId: string; } interface PageTransitionContext { clickedTarget: string; textBeforeClickedTarget: string; textAfterClickedTarget: string; } type TrailRequestMsg = { type: 'bogam:trail:request'; id: string; fromUrl: string }; type TrailResponseMsg = { type: 'bogam:trail:response'; id: string; visitedPages: string[]; context: PageTransitionContext | null; }; type UserActionLogItem = { href: string; timestamp: number; windowId: string; context: PageTransitionContext | null; }; const SS_SESSION_KEY = 'bogam-session'; const SS_WINDOW_ID_KEY = 'bogam-session:window-id'; const SS_ACTION_LOG_KEY = 'bogam:ss:actions'; const LS_ACTION_LOG_KEY = 'bogam:ls:actions'; const BC_TRAIL_CHANNEL = 'bogam-session:trail'; const CONTEXT_CHAR_LIMIT = 50; const SS_ACTION_LOG_MAX = 10; const LS_ACTION_LOG_MAX = 20; const TIME_TO_FALLBACK_FROM_BC_TO_SS_IN_MS = 15000; const TTL_PAGE_TRANSITION_CONTEXT_DATA = 1000 * 60 * 5; export class SessionTracker { private static instance: SessionTracker | null = null; static getInstance(): SessionTracker { return (SessionTracker.instance ??= new SessionTracker()); } private session: BogamSession = this.newSession(); private bc: BroadcastChannel | null = null; private listeners = new AbortController(); private constructor() {} private readJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw ? (JSON.parse(raw) as T) : fallback; } catch { return fallback; } } private writeJSON(key: string, value: unknown): void { localStorage.setItem(key, JSON.stringify(value)); } init(): void { this.bcOpenTrailChannel(); this.resumeOrStartSession(); this.bindEvents(); this.bcCloseOnPageHide(); } skipRecordingHistory(): void { sessionStorage.setItem('bogam-session:skip-recording-history', '1'); } private newSession(): BogamSession { return { visitedPages: [], lastPageTransitionContext: null, nextPageTransitionContext: null, sessionId: this.uid('s-'), windowId: this.uid('w-'), }; } private resumeOrStartSession(): void { this.session.windowId = this.getOrCreateWindowId(); const raw = sessionStorage.getItem(SS_SESSION_KEY); if (raw) { try { const restored = JSON.parse(raw) as BogamSession; restored.windowId = this.session.windowId; this.session = restored; } catch { this.session = this.newSession(); this.session.windowId = this.getOrCreateWindowId(); } } else { this.save(); } const nav = performance.getEntriesByType('navigation')[0] as | PerformanceNavigationTiming | undefined; const sameRef = !!document.referrer && new URL(document.referrer).origin === location.origin; if ((nav?.type ?? 'navigate') === 'navigate' && !sameRef) { this.session = this.newSession(); this.session.windowId = this.getOrCreateWindowId(); this.save(); } this.loadLastPageTransitionContext(); this.bcRequestTrailOnce(); if (document.visibilityState === 'hidden') { const retryOnVisible = () => { document.removeEventListener('visibilitychange', retryOnVisible); if (document.visibilityState === 'visible') this.bcRequestTrailOnce(); }; document.addEventListener('visibilitychange', retryOnVisible, { once: true }); } this.recordPageVisit(); this.save(); } private save(): void { sessionStorage.setItem(SS_SESSION_KEY, JSON.stringify(this.session)); } private bcOpenTrailChannel(): void { this.bc = new BroadcastChannel(BC_TRAIL_CHANNEL); this.bc.addEventListener( 'message', (e: MessageEvent) => { const msg = e.data as TrailRequestMsg; if (!msg || msg.type !== 'bogam:trail:request') return; const action = this.findRecentSSAction(msg.fromUrl, TTL_PAGE_TRANSITION_CONTEXT_DATA); if (!action || action.windowId !== this.session.windowId) return; const reply: TrailResponseMsg = { type: 'bogam:trail:response', id: msg.id, visitedPages: this.session.visitedPages, context: action.context, }; this.bc?.postMessage(reply); }, { signal: this.listeners.signal } ); } private bcRequestTrailOnce(): void { const global = this.findRecentLSAction(location.href); if (global?.context) { this.session.lastPageTransitionContext = global.context; this.save(); } if (!this.bc) { this.adoptLocalUserActionIfAny(); return; } const id = this.uuid(); const cleanup = (h: (e: MessageEvent) => void, t: number | undefined) => { this.bc?.removeEventListener('message', h); if (t) clearTimeout(t); }; const onReply = (e: MessageEvent) => { const msg = e.data as TrailResponseMsg; if (!msg || msg.type !== 'bogam:trail:response' || msg.id !== id) return; const adopted = Array.isArray(msg.visitedPages) ? [...msg.visitedPages] : []; const here = this.pageNameFromPath(location.pathname); if (here && adopted[adopted.length - 1] !== here) adopted.push(here); this.session.visitedPages = adopted; if (msg.context) this.session.lastPageTransitionContext = msg.context; this.save(); cleanup(onReply, t as unknown as number); }; this.bc.addEventListener('message', onReply); const t = setTimeout(() => { cleanup(onReply, undefined); this.adoptLocalUserActionIfAny(); }, 1200); this.bc.postMessage({ type: 'bogam:trail:request', id, fromUrl: location.href, } as TrailRequestMsg); } private bindEvents(): void { const s = this.listeners.signal; document.addEventListener('pointerdown', ev => this.captureLinkIntent(ev.target), { capture: true, passive: true, signal: s, }); if (!('PointerEvent' in window)) { document.addEventListener('touchstart', ev => this.captureLinkIntent(ev.target), { capture: true, passive: true, signal: s, }); } document.addEventListener('click', ev => this.captureLinkIntent(ev.target), { capture: true, signal: s, }); document.addEventListener('contextmenu', ev => this.captureLinkIntent(ev.target), { capture: true, signal: s, }); document.addEventListener( 'auxclick', ev => { if ((ev as MouseEvent).button === 1) this.captureLinkIntent(ev.target); }, { capture: true, signal: s } ); } private captureLinkIntent = (target: EventTarget | null) => { const a = (target as HTMLElement | null)?.closest?.('a.bogam-link') as HTMLAnchorElement | null; if (!a) return; const context = this.contextForLink(a); this.session.nextPageTransitionContext = context; this.save(); const href = new URL(a.href, location.href).href; this.ssAppendAction(href, context); this.lsAppendAction(href, context); }; private ssAppendAction(href: string, context: PageTransitionContext | null): void { try { const log = JSON.parse( sessionStorage.getItem(SS_ACTION_LOG_KEY) || '[]' ) as UserActionLogItem[]; log.push({ href, timestamp: Date.now(), windowId: this.session.windowId, context }); while (log.length > SS_ACTION_LOG_MAX) log.shift(); sessionStorage.setItem(SS_ACTION_LOG_KEY, JSON.stringify(log)); } catch { return; } } private lsAppendAction(href: string, context: PageTransitionContext | null): void { const now = Date.now(); const log = this.readJSON(LS_ACTION_LOG_KEY, []); log.push({ href, timestamp: now, windowId: this.session.windowId, context }); const floor = now - TTL_PAGE_TRANSITION_CONTEXT_DATA; const pruned = log.filter(i => i.timestamp >= floor); while (pruned.length > LS_ACTION_LOG_MAX) pruned.shift(); this.writeJSON(LS_ACTION_LOG_KEY, pruned); } private findRecentLSAction(href: string): UserActionLogItem | null { const log = this.readJSON(LS_ACTION_LOG_KEY, []); const now = Date.now(), floor = now - TTL_PAGE_TRANSITION_CONTEXT_DATA; for (let i = log.length - 1; i >= 0; i--) { const it = log[i]; if (it.timestamp < floor) continue; if (this.hrefsRoutishlyMatch(it.href, href)) return it; } return null; } private findRecentSSAction( href: string, maxAgeMs = TIME_TO_FALLBACK_FROM_BC_TO_SS_IN_MS ): UserActionLogItem | null { try { const log = JSON.parse( sessionStorage.getItem(SS_ACTION_LOG_KEY) || '[]' ) as UserActionLogItem[]; for (let i = log.length - 1; i >= 0; i--) { const item = log[i]; if (Date.now() - item.timestamp > maxAgeMs) continue; if (this.hrefsRoutishlyMatch(item.href, href)) return item; } return null; } catch { return null; } } private recordPageVisit(): void { if (sessionStorage.getItem('bogam-session:skip-recording-history')) { sessionStorage.removeItem('bogam-session:skip-recording-history'); return; } const page = this.pageNameFromPath(location.pathname); if (!page) return; const last = this.session.visitedPages[this.session.visitedPages.length - 1]; if (last !== page) this.session.visitedPages.push(page); } private contextForLink(link: HTMLAnchorElement): PageTransitionContext | null { const clickedTarget = (link.textContent || '').trim(); if (!clickedTarget) return null; const root = this.pickContextRoot(link); const linkRange = document.createRange(); linkRange.selectNodeContents(link); const beforeRange = document.createRange(); beforeRange.setStart(root, 0); beforeRange.setEnd(linkRange.startContainer, linkRange.startOffset); const beforeRaw = beforeRange.toString(); const textBeforeClickedTarget = beforeRaw.slice(-CONTEXT_CHAR_LIMIT).trim(); const afterRange = document.createRange(); afterRange.setStart(linkRange.endContainer, linkRange.endOffset); afterRange.setEnd(root, root.childNodes.length); const afterRaw = afterRange.toString(); const textAfterClickedTarget = afterRaw.slice(0, CONTEXT_CHAR_LIMIT).trim(); return { clickedTarget, textBeforeClickedTarget, textAfterClickedTarget, }; } private loadLastPageTransitionContext(): void { const next = this.session.nextPageTransitionContext; if (next) { this.session.lastPageTransitionContext = next; this.session.nextPageTransitionContext = null; this.save(); } } private adoptLocalUserActionIfAny(): void { if (!this.session.lastPageTransitionContext && this.session.nextPageTransitionContext) { this.loadLastPageTransitionContext(); return; } const action = this.findRecentSSAction(location.href); if (action?.context) { this.session.lastPageTransitionContext = action.context; this.save(); } } private hrefsRoutishlyMatch(a: string, b: string): boolean { try { const A = new URL(a, location.href); const B = new URL(b, location.href); if (A.origin !== B.origin) return false; const norm = (u: URL) => (u.pathname.replace(/\/+$/, '') || '/') + (u.search || ''); return norm(A) === norm(B); } catch { return a === b; } } private pickContextRoot(link: HTMLAnchorElement): Element { let el: Element = link.closest('p,li,div,section,article,main,header,footer,nav,aside,dd,dt,td,th') || document.body; for (let i = 0; i < 3 && el.parentElement; i++) { const len = (el.textContent || '').replace(/\s+/g, ' ').trim().length; if (len >= CONTEXT_CHAR_LIMIT * 2 || el === document.body) break; el = el.parentElement; } return el; } private pageNameFromPath(path: string): string | null { if (!path || path === '/') return null; const clean = path.startsWith('/') ? path.slice(1) : path; return decodeURIComponent(clean); } private getOrCreateWindowId(): string { const existing = sessionStorage.getItem(SS_WINDOW_ID_KEY); if (existing) return existing; const id = this.uid('w-'); sessionStorage.setItem(SS_WINDOW_ID_KEY, id); return id; } private bcCloseOnPageHide(): void { window.addEventListener( 'pagehide', () => { try { this.bc?.close(); } catch { return; } try { this.listeners.abort(); } catch { return; } }, { once: true } ); } private uid(prefix = ''): string { return `${prefix}${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } private uuid(): string { try { return crypto.randomUUID(); } catch { return this.uid('req-'); } } }