@visitor-analytics-sdk/core 1.0.0 → 1.0.1

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.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @visitor-analytics-sdk/core
2
+
3
+ Privacy-preserving, framework-agnostic, zero-dependency analytics SDK for web applications. Drop it in any stack and start collecting anonymous visitor data — browser, device, performance, features, environment, and interaction.
4
+
5
+ ```bash
6
+ npm install @visitor-analytics-sdk/core
7
+ ```
8
+
9
+ ```ts
10
+ import { createAnalytics } from "@visitor-analytics-sdk/core";
11
+
12
+ const analytics = createAnalytics({
13
+ endpoint: "/api/analytics",
14
+ storage: "indexeddb",
15
+ autoStart: true,
16
+ });
17
+ ```
18
+
19
+ ## Features
20
+
21
+ - **Zero runtime dependencies** — no external packages, tiny bundle impact
22
+ - **Privacy-first** — never collects PII; all data anonymous and aggregate-only
23
+ - **Plugin-based** — modular collectors, storage adapters, and plugins
24
+ - **Full TypeScript** — strict types, no `any`, auto-complete ready
25
+ - **Tree-shakable** — ESM-first, import only what you need
26
+ - **Offline-ready** — queues records offline, auto-syncs when online
27
+ - **Framework-agnostic** — works with React, Vue, Svelte, Solid, Astro, vanilla JS
28
+
29
+ ## API
30
+
31
+ ```ts
32
+ createAnalytics({
33
+ endpoint: string;
34
+ storage: "memory" | "localstorage" | "indexeddb" | StorageAdapter;
35
+ autoStart?: boolean;
36
+ batchSize?: number; // default: 50
37
+ flushInterval?: number; // default: 30000 (30s)
38
+ maxRetries?: number; // default: 5
39
+ compressionEnabled?: boolean;
40
+ deduplicationEnabled?: boolean;
41
+ sessionTimeout?: number; // default: 1800000 (30min)
42
+ includeUserAgent?: boolean;
43
+ includeReferrer?: boolean;
44
+ customData?: Record<string, string | number | boolean>;
45
+ })
46
+ ```
47
+
48
+ ### Instance methods
49
+
50
+ ```ts
51
+ analytics.start()
52
+ analytics.stop()
53
+ analytics.flush()
54
+ analytics.sync()
55
+ analytics.retryFailed()
56
+ analytics.use(plugin)
57
+ analytics.addCollector(collector)
58
+ analytics.removeCollector(name)
59
+ analytics.query(filters)
60
+ analytics.getCollectedData()
61
+ analytics.export()
62
+ analytics.on(event, handler)
63
+ analytics.off(event, handler)
64
+ analytics.destroy()
65
+ ```
66
+
67
+ ### Events
68
+
69
+ ```ts
70
+ analytics.on("start", () => {});
71
+ analytics.on("stop", () => {});
72
+ analytics.on("flush", () => {});
73
+ analytics.on("record-collected", (record) => {});
74
+ analytics.on("batch-uploaded", (batchId) => {});
75
+ analytics.on("batch-failed", (batchId, error) => {});
76
+ ```
77
+
78
+ ## Collectors
79
+
80
+ Six built-in collectors activate automatically:
81
+
82
+ | Collector | Data |
83
+ |---|---|
84
+ | Browser | name, version, engine, language |
85
+ | Device | OS, screen, touch, form factor |
86
+ | Performance | CLS, LCP, INP, navigation timing |
87
+ | Environment | timezone, language, color scheme |
88
+ | Features | WebGL, WASM, WebGPU, WebRTC |
89
+ | Interaction | session, clicks, scroll, routes |
90
+
91
+ ## License
92
+
93
+ MIT
package/dist/index.d.ts CHANGED
@@ -1,3 +1,16 @@
1
+ type AnalyticsEvent = "start" | "stop" | "flush" | "sync" | "record-collected" | "batch-uploaded" | "batch-failed" | "error" | "collector-registered" | "collector-removed" | "plugin-installed" | "plugin-uninstalled" | "storage-saved" | "storage-loaded" | "config-changed";
2
+ type StorageType = "memory" | "localstorage" | "indexeddb";
3
+
4
+ type Handler = (...args: readonly unknown[]) => void;
5
+ declare class EventBus {
6
+ private readonly listeners;
7
+ on(event: AnalyticsEvent, handler: Handler): void;
8
+ off(event: AnalyticsEvent, handler: Handler): void;
9
+ emit(event: AnalyticsEvent, ...args: readonly unknown[]): void;
10
+ removeAllListeners(event?: AnalyticsEvent): void;
11
+ listenerCount(event: AnalyticsEvent): number;
12
+ }
13
+
1
14
  interface AnalyticsRecord {
2
15
  readonly id: string;
3
16
  readonly timestamp: number;
@@ -267,7 +280,6 @@ interface PermissionsLike {
267
280
  interface PermissionDesc {
268
281
  name: string;
269
282
  }
270
- type StorageType = "memory" | "localstorage" | "indexeddb" | "custom";
271
283
  interface StorageAdapter {
272
284
  save(record: AnalyticsRecord): Promise<void>;
273
285
  saveBatch(records: readonly AnalyticsRecord[]): Promise<void>;
@@ -304,15 +316,35 @@ interface UploadResult {
304
316
  readonly retryable: boolean;
305
317
  }
306
318
  type UploadEventHandler = (event: UploadEvent) => void;
307
- interface UploadEvent {
308
- readonly type: "batch-sent" | "batch-success" | "batch-failed" | "retry-scheduled" | "queue-full";
319
+ type UploadEvent = {
320
+ readonly type: "batch-sent";
321
+ readonly batchId: string;
322
+ readonly recordCount: number;
323
+ readonly timestamp: number;
324
+ } | {
325
+ readonly type: "batch-success";
326
+ readonly batchId: string;
327
+ readonly recordCount: number;
328
+ readonly timestamp: number;
329
+ } | {
330
+ readonly type: "batch-failed";
309
331
  readonly batchId: string;
310
332
  readonly recordCount: number;
311
333
  readonly timestamp: number;
312
334
  readonly error?: string;
313
- readonly retryCount?: number;
314
- readonly nextRetryAt?: number;
315
- }
335
+ } | {
336
+ readonly type: "retry-scheduled";
337
+ readonly batchId: string;
338
+ readonly recordCount: number;
339
+ readonly timestamp: number;
340
+ readonly retryCount: number;
341
+ readonly nextRetryAt: number;
342
+ } | {
343
+ readonly type: "queue-full";
344
+ readonly batchId: string;
345
+ readonly recordCount: number;
346
+ readonly timestamp: number;
347
+ };
316
348
  interface Plugin {
317
349
  readonly name: string;
318
350
  readonly version: string;
@@ -327,7 +359,6 @@ interface PluginContext {
327
359
  off(event: AnalyticsEvent, handler: (...args: readonly unknown[]) => void): void;
328
360
  getConfig(): AnalyticsConfig;
329
361
  }
330
- type AnalyticsEvent = "start" | "stop" | "flush" | "sync" | "record-collected" | "batch-uploaded" | "batch-failed" | "error" | "collector-registered" | "collector-removed" | "plugin-installed" | "plugin-uninstalled" | "storage-saved" | "storage-loaded" | "config-changed";
331
362
  interface AnalyticsConfig {
332
363
  readonly endpoint: string;
333
364
  readonly storage: StorageType | StorageAdapter;
@@ -366,11 +397,21 @@ interface VisitorAnalyticsInstance {
366
397
  addCollector(collector: Collector): void;
367
398
  removeCollector(name: string): void;
368
399
  getCollectedData(): Promise<readonly AnalyticsRecord[]>;
400
+ query(query: AnalyticsQuery): Promise<readonly AnalyticsRecord[]>;
369
401
  export(): Promise<string>;
370
402
  on(event: AnalyticsEvent, handler: (...args: readonly unknown[]) => void): void;
371
403
  off(event: AnalyticsEvent, handler: (...args: readonly unknown[]) => void): void;
404
+ trackRouteChange(url: string): void;
372
405
  destroy(): Promise<void>;
373
406
  }
407
+ interface AnalyticsQuery {
408
+ readonly since?: number;
409
+ readonly until?: number;
410
+ readonly pagePath?: string;
411
+ readonly sessionId?: string;
412
+ readonly limit?: number;
413
+ readonly offset?: number;
414
+ }
374
415
 
375
416
  declare class VisitorAnalytics implements VisitorAnalyticsInstance {
376
417
  private config;
@@ -395,7 +436,9 @@ declare class VisitorAnalytics implements VisitorAnalyticsInstance {
395
436
  addCollector(collector: Collector): void;
396
437
  removeCollector(name: string): void;
397
438
  getCollectedData(): Promise<readonly AnalyticsRecord[]>;
439
+ query(query: AnalyticsQuery): Promise<readonly AnalyticsRecord[]>;
398
440
  export(): Promise<string>;
441
+ trackRouteChange(url: string): void;
399
442
  on(event: AnalyticsEvent, handler: (...args: readonly unknown[]) => void): void;
400
443
  off(event: AnalyticsEvent, handler: (...args: readonly unknown[]) => void): void;
401
444
  destroy(): Promise<void>;
@@ -405,14 +448,4 @@ declare class VisitorAnalytics implements VisitorAnalyticsInstance {
405
448
  }
406
449
  declare function createAnalytics(config?: AnalyticsConfigPartial): VisitorAnalyticsInstance;
407
450
 
408
- type Handler = (...args: readonly unknown[]) => void;
409
- declare class EventBus {
410
- private readonly listeners;
411
- on(event: AnalyticsEvent, handler: Handler): void;
412
- off(event: AnalyticsEvent, handler: Handler): void;
413
- emit(event: AnalyticsEvent, ...args: readonly unknown[]): void;
414
- removeAllListeners(event?: AnalyticsEvent): void;
415
- listenerCount(event: AnalyticsEvent): number;
416
- }
417
-
418
- export { type AnalyticsConfig, type AnalyticsConfigPartial, type AnalyticsEvent, type AnalyticsRecord, type BrowserData, type ClipboardLike, type Collector, type CollectorCategory, type CollectorContext, type DeviceData, type DeviceFormFactor, type DocumentLike, type EnvironmentData, EventBus, type FeatureData, type HistoryLike, type InteractionData, type LocationLike, type MetadataData, type NavigationTiming, type NavigatorLike, type NetworkInformationLike, type PaintTiming, type PerformanceData, type PerformanceLike, type PermissionDesc, type PermissionsLike, type Plugin, type PluginContext, type ScreenLike, type ScreenOrientationLike, type StorageAdapter, type StorageManagerLike, type StorageType, type TouchSupport, type UploadEvent, type UploadEventHandler, type UploadPayload, type UploadResult, type UploaderConfig, VisitorAnalytics, type VisitorAnalyticsFactory, type VisitorAnalyticsInstance, type WindowLike, createAnalytics };
451
+ export { type AnalyticsConfig, type AnalyticsConfigPartial, type AnalyticsEvent, type AnalyticsQuery, type AnalyticsRecord, type BrowserData, type ClipboardLike, type Collector, type CollectorCategory, type CollectorContext, type DeviceData, type DeviceFormFactor, type DocumentLike, type EnvironmentData, EventBus, type FeatureData, type HistoryLike, type InteractionData, type LocationLike, type MetadataData, type NavigationTiming, type NavigatorLike, type NetworkInformationLike, type PaintTiming, type PerformanceData, type PerformanceLike, type PermissionDesc, type PermissionsLike, type Plugin, type PluginContext, type ScreenLike, type ScreenOrientationLike, type StorageAdapter, type StorageManagerLike, type StorageType, type TouchSupport, type UploadEvent, type UploadEventHandler, type UploadPayload, type UploadResult, type UploaderConfig, VisitorAnalytics, type VisitorAnalyticsFactory, type VisitorAnalyticsInstance, type WindowLike, createAnalytics };
package/dist/index.js CHANGED
@@ -1,4 +1,50 @@
1
- // src/event-bus.ts
1
+ // ../utils/src/index.ts
2
+ var SDK_VERSION = "1.0.0";
3
+ function generateId() {
4
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
5
+ return crypto.randomUUID();
6
+ }
7
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
8
+ const r = Math.random() * 16 | 0;
9
+ const v = c === "x" ? r : r & 3 | 8;
10
+ return v.toString(16);
11
+ });
12
+ }
13
+ function generateShortId() {
14
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
15
+ const bytes = new Uint8Array(8);
16
+ crypto.getRandomValues(bytes);
17
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
18
+ }
19
+ return generateId().replace(/-/g, "").slice(0, 16);
20
+ }
21
+ function safeCall(fn, fallback) {
22
+ try {
23
+ return fn();
24
+ } catch {
25
+ return fallback;
26
+ }
27
+ }
28
+ function requestIdle(callback, timeout = 5e3) {
29
+ if (typeof window !== "undefined" && "requestIdleCallback" in window) {
30
+ window.requestIdleCallback(callback, { timeout });
31
+ } else {
32
+ setTimeout(callback, 0);
33
+ }
34
+ }
35
+ function deepFreeze(obj) {
36
+ Object.freeze(obj);
37
+ for (const key of Object.keys(obj)) {
38
+ const val = obj[key];
39
+ if (val !== null && typeof val === "object" && !Object.isFrozen(val)) {
40
+ deepFreeze(val);
41
+ }
42
+ }
43
+ return obj;
44
+ }
45
+ function isBrowser() {
46
+ return typeof window !== "undefined" && typeof document !== "undefined" && typeof navigator !== "undefined";
47
+ }
2
48
  var EventBus = class {
3
49
  listeners = /* @__PURE__ */ new Map();
4
50
  on(event, handler) {
@@ -33,6 +79,23 @@ var EventBus = class {
33
79
  return this.listeners.get(event)?.size ?? 0;
34
80
  }
35
81
  };
82
+ function detectBestStorage() {
83
+ if (typeof window === "undefined") return "memory";
84
+ try {
85
+ if (typeof indexedDB !== "undefined") return "indexeddb";
86
+ } catch {
87
+ }
88
+ try {
89
+ if (typeof localStorage !== "undefined") {
90
+ const testKey = "__va_storage_test__";
91
+ localStorage.setItem(testKey, "1");
92
+ localStorage.removeItem(testKey);
93
+ return "localstorage";
94
+ }
95
+ } catch {
96
+ }
97
+ return "memory";
98
+ }
36
99
 
37
100
  // ../plugins/src/plugin-manager.ts
38
101
  var PluginManager = class {
@@ -99,57 +162,6 @@ function createPluginContext(addCollector, removeCollector, eventBus, getConfig)
99
162
  };
100
163
  }
101
164
 
102
- // ../utils/src/index.ts
103
- var SDK_VERSION = "1.0.0";
104
- function generateId() {
105
- if (typeof crypto !== "undefined" && crypto.randomUUID) {
106
- return crypto.randomUUID();
107
- }
108
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
109
- const r = Math.random() * 16 | 0;
110
- const v = c === "x" ? r : r & 3 | 8;
111
- return v.toString(16);
112
- });
113
- }
114
- function generateShortId() {
115
- if (typeof crypto !== "undefined" && crypto.getRandomValues) {
116
- const bytes = new Uint8Array(8);
117
- crypto.getRandomValues(bytes);
118
- return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
119
- }
120
- return generateId().replace(/-/g, "").slice(0, 16);
121
- }
122
- function safeCall(fn, fallback) {
123
- try {
124
- return fn();
125
- } catch {
126
- return fallback;
127
- }
128
- }
129
- function requestIdle(callback, timeout = 5e3) {
130
- if (typeof window !== "undefined" && "requestIdleCallback" in window) {
131
- window.requestIdleCallback(callback, { timeout });
132
- } else {
133
- setTimeout(callback, 0);
134
- }
135
- }
136
- function deepFreeze(obj) {
137
- Object.freeze(obj);
138
- for (const key of Object.keys(obj)) {
139
- const val = obj[key];
140
- if (val !== null && typeof val === "object" && !Object.isFrozen(val)) {
141
- deepFreeze(val);
142
- }
143
- }
144
- return obj;
145
- }
146
- function cloneRecord(record) {
147
- return JSON.parse(JSON.stringify(record));
148
- }
149
- function isBrowser() {
150
- return typeof window !== "undefined" && typeof document !== "undefined" && typeof navigator !== "undefined";
151
- }
152
-
153
165
  // ../uploader/src/uploader.ts
154
166
  var DEFAULT_CONFIG = {
155
167
  endpoint: "",
@@ -173,6 +185,7 @@ var Uploader = class {
173
185
  seenBatchIds = /* @__PURE__ */ new Set();
174
186
  isUploading = false;
175
187
  isStopped = false;
188
+ beforeUnloadHandler = null;
176
189
  constructor(storage, config) {
177
190
  this.config = { ...DEFAULT_CONFIG, ...config };
178
191
  this.storage = storage;
@@ -184,6 +197,12 @@ var Uploader = class {
184
197
  requestIdle(() => this.flush());
185
198
  }, this.config.flushInterval);
186
199
  }
200
+ if (typeof window !== "undefined") {
201
+ this.beforeUnloadHandler = () => {
202
+ this.flushViaSendBeacon();
203
+ };
204
+ window.addEventListener("beforeunload", this.beforeUnloadHandler);
205
+ }
187
206
  }
188
207
  stop() {
189
208
  this.isStopped = true;
@@ -195,6 +214,10 @@ var Uploader = class {
195
214
  clearTimeout(this.retryTimer);
196
215
  this.retryTimer = null;
197
216
  }
217
+ if (this.beforeUnloadHandler && typeof window !== "undefined") {
218
+ window.removeEventListener("beforeunload", this.beforeUnloadHandler);
219
+ this.beforeUnloadHandler = null;
220
+ }
198
221
  this.retryQueue.clear();
199
222
  }
200
223
  onEvent(handler) {
@@ -267,6 +290,28 @@ var Uploader = class {
267
290
  this.isUploading = false;
268
291
  }
269
292
  }
293
+ // C3: sendBeacon fallback for page unload
294
+ flushViaSendBeacon() {
295
+ if (this.isStopped) return;
296
+ try {
297
+ const raw = typeof localStorage !== "undefined" ? localStorage.getItem("va_records") : null;
298
+ if (!raw) return;
299
+ const records = JSON.parse(raw);
300
+ if (!Array.isArray(records) || records.length === 0) return;
301
+ const batchId = generateShortId();
302
+ const payload = {
303
+ records,
304
+ batchId,
305
+ timestamp: Date.now(),
306
+ sdkVersion: SDK_VERSION
307
+ };
308
+ const blob = new Blob([JSON.stringify(payload)], { type: "application/json" });
309
+ if (this.config.endpoint && typeof navigator !== "undefined" && "sendBeacon" in navigator) {
310
+ navigator.sendBeacon(this.config.endpoint, blob);
311
+ }
312
+ } catch {
313
+ }
314
+ }
270
315
  async sendBatch(payload) {
271
316
  if (!this.config.endpoint) {
272
317
  return { success: false, batchId: payload.batchId, error: "No endpoint configured", retryable: false };
@@ -319,8 +364,7 @@ var Uploader = class {
319
364
  batchId: payload.batchId,
320
365
  recordCount: payload.records.length,
321
366
  timestamp: Date.now(),
322
- error: "Max retries exceeded",
323
- retryCount
367
+ error: "Max retries exceeded"
324
368
  });
325
369
  return;
326
370
  }
@@ -381,6 +425,7 @@ var Uploader = class {
381
425
  async sync() {
382
426
  await this.flush();
383
427
  }
428
+ // M1: Document compression browser support
384
429
  async compress(data) {
385
430
  if (typeof CompressionStream === "undefined") {
386
431
  return data;
@@ -404,15 +449,15 @@ var Uploader = class {
404
449
  var MemoryStorage = class {
405
450
  records = [];
406
451
  async save(record) {
407
- this.records.push(cloneRecord(record));
452
+ this.records.push(record);
408
453
  }
409
454
  async saveBatch(records) {
410
455
  for (const record of records) {
411
- this.records.push(cloneRecord(record));
456
+ this.records.push(record);
412
457
  }
413
458
  }
414
459
  async load() {
415
- return this.records.map((r) => cloneRecord(r));
460
+ return this.records;
416
461
  }
417
462
  async loadBatch(limit) {
418
463
  return this.records.splice(0, limit);
@@ -441,13 +486,13 @@ var LocalStorageAdapter = class {
441
486
  }
442
487
  async save(record) {
443
488
  const records = this.readAll();
444
- records.push(cloneRecord(record));
489
+ records.push(record);
445
490
  this.writeAll(records);
446
491
  }
447
492
  async saveBatch(records) {
448
493
  const existing = this.readAll();
449
494
  for (const record of records) {
450
- existing.push(cloneRecord(record));
495
+ existing.push(record);
451
496
  }
452
497
  this.writeAll(existing);
453
498
  }
@@ -543,7 +588,7 @@ var IndexedDBAdapter = class {
543
588
  return new Promise((resolve, reject) => {
544
589
  const tx = db.transaction(this.storeName, "readwrite");
545
590
  const store = tx.objectStore(this.storeName);
546
- const request = store.put(cloneRecord(record));
591
+ const request = store.put(record);
547
592
  request.onsuccess = () => resolve();
548
593
  request.onerror = () => reject(request.error);
549
594
  });
@@ -554,7 +599,7 @@ var IndexedDBAdapter = class {
554
599
  const tx = db.transaction(this.storeName, "readwrite");
555
600
  const store = tx.objectStore(this.storeName);
556
601
  for (const record of records) {
557
- store.put(cloneRecord(record));
602
+ store.put(record);
558
603
  }
559
604
  tx.oncomplete = () => resolve();
560
605
  tx.onerror = () => reject(tx.error);
@@ -684,11 +729,13 @@ var BrowserCollector = class {
684
729
  }
685
730
  };
686
731
  }
732
+ // H1: Improved UA parser with proper detection order
687
733
  parseUA(ua) {
688
734
  let name = "unknown";
689
735
  let version = "0";
690
736
  let engine = "unknown";
691
737
  let engineVersion = "0";
738
+ const isHeadless = ua.includes("HeadlessChrome") || ua.includes("Headless");
692
739
  if (ua.includes("Firefox/") && !ua.includes("Seamonkey")) {
693
740
  name = "Firefox";
694
741
  const match = ua.match(/Firefox\/([\d.]+)/);
@@ -704,7 +751,27 @@ var BrowserCollector = class {
704
751
  const match = ua.match(/(?:OPR|Opera)\/([\d.]+)/);
705
752
  version = match?.[1] ?? "0";
706
753
  engine = "Blink";
707
- } else if (ua.includes("Chrome/") && !ua.includes("Edg/")) {
754
+ } else if (ua.includes("SamsungBrowser/")) {
755
+ name = "Samsung Internet";
756
+ const match = ua.match(/SamsungBrowser\/([\d.]+)/);
757
+ version = match?.[1] ?? "0";
758
+ engine = "Blink";
759
+ } else if (ua.includes("UCBrowser/") || ua.includes("UCWEB")) {
760
+ name = "UC Browser";
761
+ const match = ua.match(/(?:UCBrowser|UCWEB)\/([\d.]+)/);
762
+ version = match?.[1] ?? "0";
763
+ engine = "Blink";
764
+ } else if (ua.includes("Opera Mini/")) {
765
+ name = "Opera Mini";
766
+ const match = ua.match(/Opera Mini\/([\d.]+)/);
767
+ version = match?.[1] ?? "0";
768
+ engine = "Presto";
769
+ } else if (ua.includes("CriOS/")) {
770
+ name = "Chrome";
771
+ const match = ua.match(/CriOS\/([\d.]+)/);
772
+ version = match?.[1] ?? "0";
773
+ engine = "WebKit";
774
+ } else if (ua.includes("Chrome/") && !ua.includes("Edg/") && !ua.includes("OPR/")) {
708
775
  name = "Chrome";
709
776
  const match = ua.match(/Chrome\/([\d.]+)/);
710
777
  version = match?.[1] ?? "0";
@@ -720,6 +787,9 @@ var BrowserCollector = class {
720
787
  version = match?.[1] ?? "0";
721
788
  engine = "Trident";
722
789
  }
790
+ if (isHeadless && name !== "unknown") {
791
+ name += " (headless)";
792
+ }
723
793
  if (engine === "Blink") {
724
794
  const match = ua.match(/AppleWebKit\/([\d.]+)/);
725
795
  engineVersion = match?.[1] ?? "0";
@@ -1177,6 +1247,8 @@ var InteractionCollector = class {
1177
1247
  cleanupFns = [];
1178
1248
  sessionTimeout = null;
1179
1249
  sessionTimeoutMs;
1250
+ originalPushState = null;
1251
+ originalReplaceState = null;
1180
1252
  constructor(options) {
1181
1253
  this.sessionTimeoutMs = options?.sessionTimeout ?? 30 * 60 * 1e3;
1182
1254
  this.state = this.createInitialState();
@@ -1271,8 +1343,28 @@ var InteractionCollector = class {
1271
1343
  this.cleanupFns.push(() => {
1272
1344
  win.removeEventListener("popstate", onPopState);
1273
1345
  });
1346
+ const histWin = win;
1347
+ this.originalPushState = histWin.history.pushState.bind(histWin.history);
1348
+ this.originalReplaceState = histWin.history.replaceState.bind(histWin.history);
1349
+ const state = this.state;
1350
+ const origPush = this.originalPushState;
1351
+ const origReplace = this.originalReplaceState;
1352
+ histWin.history.pushState = function(data, unused, url) {
1353
+ origPush.call(this, data, unused, url);
1354
+ state.routeChanges++;
1355
+ state.lastPage = location.href;
1356
+ };
1357
+ histWin.history.replaceState = function(data, unused, url) {
1358
+ origReplace.call(this, data, unused, url);
1359
+ state.lastPage = location.href;
1360
+ };
1274
1361
  this.startSessionTimeout();
1275
1362
  }
1363
+ // H3: Public method for framework integrations to manually track route changes
1364
+ trackRouteChange(url) {
1365
+ this.state.routeChanges++;
1366
+ this.state.lastPage = url;
1367
+ }
1276
1368
  startSessionTimeout() {
1277
1369
  this.sessionTimeout = setTimeout(() => {
1278
1370
  this.state.sessionStart = Date.now();
@@ -1316,6 +1408,12 @@ var InteractionCollector = class {
1316
1408
  if (this.sessionTimeout !== null) {
1317
1409
  clearTimeout(this.sessionTimeout);
1318
1410
  }
1411
+ if (this.originalPushState && typeof history !== "undefined") {
1412
+ history.pushState = this.originalPushState;
1413
+ }
1414
+ if (this.originalReplaceState && typeof history !== "undefined") {
1415
+ history.replaceState = this.originalReplaceState;
1416
+ }
1319
1417
  for (const fn of this.cleanupFns) {
1320
1418
  fn();
1321
1419
  }
@@ -1326,7 +1424,8 @@ var InteractionCollector = class {
1326
1424
  // src/visitor-analytics.ts
1327
1425
  var DEFAULT_CONFIG2 = {
1328
1426
  endpoint: "",
1329
- storage: "memory",
1427
+ // H5: Auto-detect best storage instead of defaulting to memory
1428
+ storage: "indexeddb",
1330
1429
  autoStart: true,
1331
1430
  batchSize: 50,
1332
1431
  flushInterval: 3e4,
@@ -1367,6 +1466,9 @@ var VisitorAnalytics = class {
1367
1466
  if (config.headers) {
1368
1467
  merged.headers = { ...DEFAULT_CONFIG2.headers, ...config.headers };
1369
1468
  }
1469
+ if (!config.storage) {
1470
+ merged.storage = detectBestStorage();
1471
+ }
1370
1472
  this.config = merged;
1371
1473
  this.eventBus = new EventBus();
1372
1474
  this.sessionId = generateId();
@@ -1415,7 +1517,9 @@ var VisitorAnalytics = class {
1415
1517
  );
1416
1518
  }
1417
1519
  }
1520
+ // C1: Guard buildCollectorContext with isBrowser() - returns null on server
1418
1521
  buildCollectorContext() {
1522
+ if (!isBrowser()) return null;
1419
1523
  if (this.collectorContext) return this.collectorContext;
1420
1524
  const ctx = {
1421
1525
  document,
@@ -1431,13 +1535,16 @@ var VisitorAnalytics = class {
1431
1535
  }
1432
1536
  start() {
1433
1537
  if (this.isRunning) return;
1538
+ if (!isBrowser()) return;
1434
1539
  this.isRunning = true;
1435
1540
  this.uploader.start();
1436
1541
  this.eventBus.emit("start");
1437
1542
  for (const collector of this.collectors) {
1438
- if (collector.init && isBrowser()) {
1543
+ if (collector.init) {
1544
+ const ctx = this.buildCollectorContext();
1545
+ if (!ctx) continue;
1439
1546
  requestIdle(() => {
1440
- collector.init(this.buildCollectorContext()).catch((err) => {
1547
+ collector.init(ctx).catch((err) => {
1441
1548
  console.debug("[VisitorAnalytics] Collector init failed:", collector.name, err);
1442
1549
  });
1443
1550
  });
@@ -1457,13 +1564,13 @@ var VisitorAnalytics = class {
1457
1564
  }
1458
1565
  this.eventBus.emit("stop");
1459
1566
  }
1567
+ // C2: flush() should only upload what's already stored, not collect again
1460
1568
  async flush() {
1461
- await this.collectAndStore();
1462
1569
  await this.uploader.flush();
1463
1570
  this.eventBus.emit("flush");
1464
1571
  }
1572
+ // C2: sync() same - only upload, don't re-collect
1465
1573
  async sync() {
1466
- await this.collectAndStore();
1467
1574
  await this.uploader.sync();
1468
1575
  this.eventBus.emit("sync");
1469
1576
  }
@@ -1497,9 +1604,40 @@ var VisitorAnalytics = class {
1497
1604
  async getCollectedData() {
1498
1605
  return this.storage.load();
1499
1606
  }
1607
+ // M2: Query API for filtering records
1608
+ async query(query) {
1609
+ const allRecords = await this.storage.load();
1610
+ let result = [...allRecords];
1611
+ if (query.since !== void 0) {
1612
+ result = result.filter((r) => r.timestamp >= query.since);
1613
+ }
1614
+ if (query.until !== void 0) {
1615
+ result = result.filter((r) => r.timestamp <= query.until);
1616
+ }
1617
+ if (query.pagePath !== void 0) {
1618
+ result = result.filter((r) => r.pagePath === query.pagePath);
1619
+ }
1620
+ if (query.sessionId !== void 0) {
1621
+ result = result.filter((r) => r.sessionId === query.sessionId);
1622
+ }
1623
+ if (query.offset !== void 0) {
1624
+ result = result.slice(query.offset);
1625
+ }
1626
+ if (query.limit !== void 0) {
1627
+ result = result.slice(0, query.limit);
1628
+ }
1629
+ return result;
1630
+ }
1500
1631
  async export() {
1501
1632
  return this.storage.export();
1502
1633
  }
1634
+ // H3: Expose trackRouteChange for SPA framework integrations
1635
+ trackRouteChange(url) {
1636
+ const interactionCollector = this.collectors.find((c) => c.name === "interaction");
1637
+ if (interactionCollector) {
1638
+ interactionCollector.trackRouteChange(url);
1639
+ }
1640
+ }
1503
1641
  on(event, handler) {
1504
1642
  this.eventBus.on(event, handler);
1505
1643
  }
@@ -1521,6 +1659,7 @@ var VisitorAnalytics = class {
1521
1659
  async collectAndStore() {
1522
1660
  if (!isBrowser()) return;
1523
1661
  const context = this.buildCollectorContext();
1662
+ if (!context) return;
1524
1663
  const record = await this.collectRecord(context);
1525
1664
  if (record) {
1526
1665
  await this.storage.save(record);
@@ -1675,8 +1814,5 @@ var VisitorAnalytics = class {
1675
1814
  function createAnalytics(config) {
1676
1815
  return new VisitorAnalytics(config);
1677
1816
  }
1678
- export {
1679
- EventBus,
1680
- VisitorAnalytics,
1681
- createAnalytics
1682
- };
1817
+
1818
+ export { EventBus, VisitorAnalytics, createAnalytics };
package/package.json CHANGED
@@ -1,10 +1,33 @@
1
1
  {
2
2
  "name": "@visitor-analytics-sdk/core",
3
- "version": "1.0.0",
3
+ "description": "Privacy-preserving, framework-agnostic, zero-dependency analytics SDK for web applications. Core orchestrator with event bus, type-safe API, and plugin system.",
4
+ "license": "MIT",
5
+ "keywords": [
6
+ "visitor-analytics",
7
+ "analytics",
8
+ "privacy",
9
+ "tracking",
10
+ "sdk",
11
+ "typescript",
12
+ "web-analytics",
13
+ "zero-deps"
14
+ ],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/instax-dutta/visitor-analytics.git"
18
+ },
19
+ "homepage": "https://github.com/instax-dutta/visitor-analytics#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/instax-dutta/visitor-analytics/issues"
22
+ },
23
+ "version": "1.0.1",
4
24
  "type": "module",
5
25
  "main": "dist/index.js",
6
26
  "module": "dist/index.js",
7
27
  "types": "dist/index.d.ts",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
8
31
  "exports": {
9
32
  ".": {
10
33
  "import": "./dist/index.js",
@@ -19,7 +42,7 @@
19
42
  "tsup": "^8.0.0"
20
43
  },
21
44
  "scripts": {
22
- "build": "tsup src/index.ts --format esm --dts",
45
+ "build": "tsup src/index.ts --format esm --dts --treeshake",
23
46
  "typecheck": "tsc --noEmit"
24
47
  }
25
48
  }