@faststats/web 0.2.13 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,416 @@
1
+ export type SessionContext = {
2
+ sessionId: string;
3
+ windowId: string;
4
+ sessionStart: number;
5
+ };
6
+
7
+ export type SessionRotation = {
8
+ prev: SessionContext;
9
+ next: SessionContext;
10
+ };
11
+
12
+ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
13
+ const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000;
14
+
15
+ const LS_SESSION_ID = "faststats_session_id";
16
+ const LS_SESSION_ACTIVITY = "faststats_session_activity";
17
+ const LS_SESSION_START = "faststats_session_start";
18
+
19
+ const LEGACY_SESSION_ID = "session_id";
20
+ const LEGACY_SESSION_TIMESTAMP = "session_timestamp";
21
+ const LEGACY_SESSION_START = "session_start";
22
+
23
+ let cookielessMode = false;
24
+ let defaultSiteKey = "";
25
+ let ephemeralSession: {
26
+ sessionId: string;
27
+ sessionStart: number;
28
+ activity: number;
29
+ } | null = null;
30
+
31
+ type RotationListener = (prev: SessionContext, next: SessionContext) => void;
32
+ const rotationListeners = new Set<RotationListener>();
33
+
34
+ function storageGet(storage: Storage | undefined, key: string): string | null {
35
+ try {
36
+ return storage?.getItem(key) ?? null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function storageSet(
43
+ storage: Storage | undefined,
44
+ key: string,
45
+ value: string,
46
+ ): boolean {
47
+ if (!storage) return false;
48
+ try {
49
+ storage.setItem(key, value);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function storageRemove(storage: Storage | undefined, key: string): void {
57
+ try {
58
+ storage?.removeItem(key);
59
+ } catch {}
60
+ }
61
+
62
+ function getLocalStorage(): Storage | undefined {
63
+ try {
64
+ return globalThis.localStorage;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ function getSessionStorage(): Storage | undefined {
71
+ try {
72
+ return globalThis.sessionStorage;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ function notifyRotation(prev: SessionContext, next: SessionContext): void {
79
+ for (const listener of rotationListeners) {
80
+ listener(prev, next);
81
+ }
82
+ }
83
+
84
+ export function createId(): string {
85
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
86
+ return crypto.randomUUID();
87
+ }
88
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
89
+ }
90
+
91
+ export function setCookielessMode(mode: boolean): void {
92
+ cookielessMode = mode;
93
+ if (mode) ephemeralSession = null;
94
+ }
95
+
96
+ export function isCookielessMode(): boolean {
97
+ return cookielessMode;
98
+ }
99
+
100
+ export function setDefaultSiteKey(siteKey: string): void {
101
+ defaultSiteKey = siteKey;
102
+ }
103
+
104
+ function windowIdKey(siteKey: string): string {
105
+ return `faststats_window_id_${siteKey}`;
106
+ }
107
+
108
+ function parseTimestamp(value: string | null): number | null {
109
+ if (!value) return null;
110
+ const parsed = Number.parseInt(value, 10);
111
+ return Number.isFinite(parsed) ? parsed : null;
112
+ }
113
+
114
+ function migrateLegacySession(): void {
115
+ const session = getSessionStorage();
116
+ const local = getLocalStorage();
117
+ const legacyId = storageGet(session, LEGACY_SESSION_ID);
118
+ if (!legacyId || storageGet(local, LS_SESSION_ID)) {
119
+ if (legacyId) {
120
+ storageRemove(session, LEGACY_SESSION_ID);
121
+ storageRemove(session, LEGACY_SESSION_TIMESTAMP);
122
+ storageRemove(session, LEGACY_SESSION_START);
123
+ }
124
+ return;
125
+ }
126
+
127
+ const legacyActivity = parseTimestamp(
128
+ storageGet(session, LEGACY_SESSION_TIMESTAMP),
129
+ );
130
+ const legacyStart = parseTimestamp(storageGet(session, LEGACY_SESSION_START));
131
+ const now = Date.now();
132
+
133
+ const migrated = storageSet(local, LS_SESSION_ID, legacyId);
134
+ storageSet(local, LS_SESSION_ACTIVITY, String(legacyActivity ?? now));
135
+ storageSet(
136
+ local,
137
+ LS_SESSION_START,
138
+ String(legacyStart ?? legacyActivity ?? now),
139
+ );
140
+
141
+ if (migrated) {
142
+ storageRemove(session, LEGACY_SESSION_ID);
143
+ storageRemove(session, LEGACY_SESSION_TIMESTAMP);
144
+ storageRemove(session, LEGACY_SESSION_START);
145
+ }
146
+ }
147
+
148
+ function readPersistedSession(): {
149
+ sessionId: string;
150
+ activity: number;
151
+ sessionStart: number;
152
+ } | null {
153
+ migrateLegacySession();
154
+
155
+ const local = getLocalStorage();
156
+ const sessionId = storageGet(local, LS_SESSION_ID);
157
+ const activity = parseTimestamp(storageGet(local, LS_SESSION_ACTIVITY));
158
+ const sessionStart = parseTimestamp(storageGet(local, LS_SESSION_START));
159
+
160
+ if (!sessionId || activity === null) return null;
161
+
162
+ return {
163
+ sessionId,
164
+ activity,
165
+ sessionStart: sessionStart ?? activity,
166
+ };
167
+ }
168
+
169
+ function writePersistedSession(
170
+ sessionId: string,
171
+ activity: number,
172
+ sessionStart: number,
173
+ ): void {
174
+ const local = getLocalStorage();
175
+ const wroteId = storageSet(local, LS_SESSION_ID, sessionId);
176
+ storageSet(local, LS_SESSION_ACTIVITY, activity.toString());
177
+ storageSet(local, LS_SESSION_START, sessionStart.toString());
178
+
179
+ if (!wroteId) {
180
+ ephemeralSession = { sessionId, activity, sessionStart };
181
+ }
182
+ }
183
+
184
+ function clearPersistedSession(): void {
185
+ const local = getLocalStorage();
186
+ storageRemove(local, LS_SESSION_ID);
187
+ storageRemove(local, LS_SESSION_ACTIVITY);
188
+ storageRemove(local, LS_SESSION_START);
189
+ }
190
+
191
+ function isSessionExpired(
192
+ activity: number,
193
+ sessionStart: number,
194
+ now: number,
195
+ ): boolean {
196
+ if (now - activity >= SESSION_IDLE_TIMEOUT_MS) return true;
197
+ return now - sessionStart >= SESSION_MAX_AGE_MS;
198
+ }
199
+
200
+ function ephemeralRecord(now: number): {
201
+ sessionId: string;
202
+ sessionStart: number;
203
+ activity: number;
204
+ } {
205
+ if (!ephemeralSession) {
206
+ ephemeralSession = {
207
+ sessionId: createId(),
208
+ sessionStart: now,
209
+ activity: now,
210
+ };
211
+ }
212
+ return ephemeralSession;
213
+ }
214
+
215
+ function resolveSessionRecord(
216
+ cookieless: boolean,
217
+ touch: boolean,
218
+ ): {
219
+ sessionId: string;
220
+ sessionStart: number;
221
+ rotated: boolean;
222
+ previous: { sessionId: string; sessionStart: number } | null;
223
+ } {
224
+ const now = Date.now();
225
+
226
+ if (cookieless) {
227
+ const record = ephemeralRecord(now);
228
+ if (touch) record.activity = now;
229
+ return {
230
+ sessionId: record.sessionId,
231
+ sessionStart: record.sessionStart,
232
+ rotated: false,
233
+ previous: null,
234
+ };
235
+ }
236
+
237
+ const existing = readPersistedSession() ?? ephemeralSession;
238
+ if (
239
+ existing &&
240
+ !isSessionExpired(existing.activity, existing.sessionStart, now)
241
+ ) {
242
+ if (touch) {
243
+ writePersistedSession(existing.sessionId, now, existing.sessionStart);
244
+ }
245
+ return {
246
+ sessionId: existing.sessionId,
247
+ sessionStart: existing.sessionStart,
248
+ rotated: false,
249
+ previous: null,
250
+ };
251
+ }
252
+
253
+ const previous =
254
+ existing !== null
255
+ ? {
256
+ sessionId: existing.sessionId,
257
+ sessionStart: existing.sessionStart,
258
+ }
259
+ : null;
260
+
261
+ const sessionId = createId();
262
+ writePersistedSession(sessionId, now, now);
263
+
264
+ return {
265
+ sessionId,
266
+ sessionStart: now,
267
+ rotated: previous !== null,
268
+ previous,
269
+ };
270
+ }
271
+
272
+ export function getOrCreateWindowId(
273
+ siteKey: string,
274
+ cookieless?: boolean,
275
+ ): string {
276
+ if (cookieless ?? cookielessMode) {
277
+ return resolveSessionRecord(true, false).sessionId;
278
+ }
279
+
280
+ const key = windowIdKey(siteKey);
281
+ const session = getSessionStorage();
282
+ if (!session) return resolveSessionRecord(false, false).sessionId;
283
+
284
+ const existing = storageGet(session, key);
285
+ if (existing) return existing;
286
+
287
+ const windowId = createId();
288
+ storageSet(session, key, windowId);
289
+ return windowId;
290
+ }
291
+
292
+ function clearWindowId(siteKey: string): void {
293
+ storageRemove(getSessionStorage(), windowIdKey(siteKey));
294
+ }
295
+
296
+ export function getSessionContext(
297
+ siteKey: string,
298
+ cookieless?: boolean,
299
+ ): SessionContext {
300
+ const useCookieless = cookieless ?? cookielessMode;
301
+ const record = resolveSessionRecord(useCookieless, false);
302
+ const windowId = getOrCreateWindowId(siteKey, useCookieless);
303
+
304
+ const context: SessionContext = {
305
+ sessionId: record.sessionId,
306
+ windowId,
307
+ sessionStart: record.sessionStart,
308
+ };
309
+
310
+ if (record.rotated && record.previous) {
311
+ const prev: SessionContext = {
312
+ sessionId: record.previous.sessionId,
313
+ windowId,
314
+ sessionStart: record.previous.sessionStart,
315
+ };
316
+ notifyRotation(prev, context);
317
+ }
318
+
319
+ return context;
320
+ }
321
+
322
+ export function getOrCreateSessionId(cookieless?: boolean): string {
323
+ return resolveSessionRecord(cookieless ?? cookielessMode, true).sessionId;
324
+ }
325
+
326
+ export function getSessionStart(cookieless?: boolean): number {
327
+ return resolveSessionRecord(cookieless ?? cookielessMode, false).sessionStart;
328
+ }
329
+
330
+ export function touchActivity(cookieless?: boolean): SessionRotation | null {
331
+ const useCookieless = cookieless ?? cookielessMode;
332
+ const record = resolveSessionRecord(useCookieless, true);
333
+ if (record.rotated && record.previous) {
334
+ const siteKey = defaultSiteKey || "_default";
335
+ const rotation = {
336
+ prev: {
337
+ sessionId: record.previous.sessionId,
338
+ windowId: getOrCreateWindowId(siteKey, useCookieless),
339
+ sessionStart: record.previous.sessionStart,
340
+ },
341
+ next: {
342
+ sessionId: record.sessionId,
343
+ windowId: getOrCreateWindowId(siteKey, useCookieless),
344
+ sessionStart: record.sessionStart,
345
+ },
346
+ };
347
+ notifyRotation(rotation.prev, rotation.next);
348
+ return rotation;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ export function refreshSessionTimestamp(cookieless?: boolean): void {
354
+ const useCookieless = cookieless ?? cookielessMode;
355
+ const now = Date.now();
356
+
357
+ if (useCookieless) {
358
+ if (ephemeralSession) ephemeralSession.activity = now;
359
+ return;
360
+ }
361
+
362
+ const existing = readPersistedSession() ?? ephemeralSession;
363
+ if (!existing) return;
364
+
365
+ writePersistedSession(existing.sessionId, now, existing.sessionStart);
366
+ }
367
+
368
+ export function resetSession(siteKey?: string): SessionContext {
369
+ const key = siteKey ?? defaultSiteKey ?? "_default";
370
+ const now = Date.now();
371
+ let previous: SessionContext | null = null;
372
+
373
+ if (cookielessMode) {
374
+ if (ephemeralSession) {
375
+ previous = {
376
+ sessionId: ephemeralSession.sessionId,
377
+ windowId: ephemeralSession.sessionId,
378
+ sessionStart: ephemeralSession.sessionStart,
379
+ };
380
+ }
381
+ ephemeralSession = {
382
+ sessionId: createId(),
383
+ sessionStart: now,
384
+ activity: now,
385
+ };
386
+ } else {
387
+ const existing = readPersistedSession();
388
+ if (existing) {
389
+ previous = {
390
+ sessionId: existing.sessionId,
391
+ windowId: getOrCreateWindowId(key, false),
392
+ sessionStart: existing.sessionStart,
393
+ };
394
+ }
395
+ clearPersistedSession();
396
+ ephemeralSession = null;
397
+ }
398
+
399
+ clearWindowId(key);
400
+ const next = getSessionContext(key, cookielessMode);
401
+
402
+ if (previous && previous.sessionId !== next.sessionId) {
403
+ notifyRotation(previous, next);
404
+ }
405
+
406
+ return next;
407
+ }
408
+
409
+ export function onSessionRotated(
410
+ listener: (prev: SessionContext, next: SessionContext) => void,
411
+ ): () => void {
412
+ rotationListeners.add(listener);
413
+ return () => {
414
+ rotationListeners.delete(listener);
415
+ };
416
+ }
package/src/web-vitals.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Metric, MetricWithAttribution } from "web-vitals";
2
2
  import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
3
- import { getOrCreateSessionId } from "./utils/identifiers";
3
+ import { getOrCreateSessionId } from "./utils/session-manager";
4
4
  import { normalizeSamplingPercentage } from "./utils/types";
5
5
 
6
6
  export interface WebVitalsOptions {
@@ -399,10 +399,13 @@ describe("WebAnalytics lifecycle", () => {
399
399
  });
400
400
 
401
401
  await analytics.start();
402
- const storage = globalThis.sessionStorage as unknown as MockStorage;
403
- const sessionId = storage.getItem("session_id");
402
+ const storage = globalThis.localStorage as unknown as MockStorage;
403
+ const sessionId = storage.getItem("faststats_session_id");
404
404
  expect(sessionId).toBeTruthy();
405
- storage.setItem("session_timestamp", "0");
405
+ storage.setItem(
406
+ "faststats_session_activity",
407
+ (Date.now() - 31 * 60 * 1000).toString(),
408
+ );
406
409
  harness.fetchCalls.length = 0;
407
410
 
408
411
  Object.defineProperty(globalThis.document, "visibilityState", {
@@ -420,7 +423,7 @@ describe("WebAnalytics lifecycle", () => {
420
423
  }
421
424
  await wait(0);
422
425
 
423
- expect(storage.getItem("session_id")).toBe(sessionId);
426
+ expect(storage.getItem("faststats_session_id")).toBe(sessionId);
424
427
  const pageLeaveCall = harness.fetchCalls.find((call) =>
425
428
  call.body?.includes('"event":"page_leave"'),
426
429
  );
@@ -1,10 +1,10 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import {
3
+ getAnonymousId,
3
4
  getOrCreateAnonymousId,
4
- getOrCreateSessionId,
5
- getSessionStart,
6
- resetSessionId,
5
+ resetAnonymousId,
7
6
  } from "../src/utils/identifiers";
7
+ import { setCookielessMode } from "../src/utils/session-manager";
8
8
 
9
9
  class MockStorage {
10
10
  private readonly data = new Map<string, string>();
@@ -32,17 +32,16 @@ function setGlobal(name: keyof typeof globalThis, value: unknown): void {
32
32
 
33
33
  const original = {
34
34
  localStorage: globalThis.localStorage,
35
- sessionStorage: globalThis.sessionStorage,
36
35
  };
37
36
 
38
37
  beforeEach(() => {
39
38
  setGlobal("localStorage", new MockStorage());
40
- setGlobal("sessionStorage", new MockStorage());
39
+ setCookielessMode(false);
41
40
  });
42
41
 
43
42
  afterEach(() => {
44
43
  setGlobal("localStorage", original.localStorage);
45
- setGlobal("sessionStorage", original.sessionStorage);
44
+ setCookielessMode(false);
46
45
  });
47
46
 
48
47
  describe("identifiers", () => {
@@ -53,43 +52,27 @@ describe("identifiers", () => {
53
52
  expect(second).toBe(id);
54
53
  });
55
54
 
56
- test("session id is recreated when timestamp is invalid", () => {
57
- const storage = globalThis.sessionStorage as unknown as MockStorage;
58
- storage.setItem("session_id", "old-session");
59
- storage.setItem("session_timestamp", "NaN");
60
- const id = getOrCreateSessionId();
61
- expect(id).not.toBe("old-session");
62
- });
63
-
64
- test("session start falls back when stored value is invalid", () => {
65
- const storage = globalThis.sessionStorage as unknown as MockStorage;
66
- storage.setItem("session_start", "NaN");
67
- const start = getSessionStart();
68
- expect(Number.isNaN(start)).toBe(true);
69
- });
70
-
71
- test("resetSessionId creates a new session id", () => {
72
- const first = getOrCreateSessionId();
73
- const second = resetSessionId();
55
+ test("resetAnonymousId creates a new anonymous id", () => {
56
+ const first = getOrCreateAnonymousId();
57
+ const second = resetAnonymousId();
74
58
  expect(second.length).toBeGreaterThan(0);
75
59
  expect(second).not.toBe(first);
76
60
  });
77
61
 
78
- test("storage access failures disable persisted identifiers", () => {
62
+ test("cookieless mode skips anonymous id persistence", () => {
63
+ setCookielessMode(true);
64
+ expect(getAnonymousId()).toBe("");
65
+ });
66
+
67
+ test("storage access failures disable persisted anonymous id", () => {
79
68
  Object.defineProperty(globalThis, "localStorage", {
80
69
  configurable: true,
81
70
  get() {
82
71
  throw new Error("storage blocked");
83
72
  },
84
73
  });
85
- Object.defineProperty(globalThis, "sessionStorage", {
86
- configurable: true,
87
- get() {
88
- throw new Error("storage blocked");
89
- },
90
- });
91
74
 
92
75
  expect(getOrCreateAnonymousId()).toBe("");
93
- expect(getOrCreateSessionId()).toBe("");
76
+ expect(resetAnonymousId()).toBe("");
94
77
  });
95
78
  });