@v-tilt/browser 1.0.11 → 1.1.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.
Files changed (45) hide show
  1. package/dist/array.js +1 -1
  2. package/dist/array.js.map +1 -1
  3. package/dist/array.no-external.js +1 -1
  4. package/dist/array.no-external.js.map +1 -1
  5. package/dist/constants.d.ts +172 -10
  6. package/dist/main.js +1 -1
  7. package/dist/main.js.map +1 -1
  8. package/dist/module.d.ts +230 -46
  9. package/dist/module.js +1 -1
  10. package/dist/module.js.map +1 -1
  11. package/dist/module.no-external.d.ts +230 -46
  12. package/dist/module.no-external.js +1 -1
  13. package/dist/module.no-external.js.map +1 -1
  14. package/dist/rate-limiter.d.ts +52 -0
  15. package/dist/request-queue.d.ts +78 -0
  16. package/dist/request.d.ts +54 -0
  17. package/dist/retry-queue.d.ts +64 -0
  18. package/dist/session.d.ts +2 -2
  19. package/dist/types.d.ts +154 -37
  20. package/dist/user-manager.d.ts +2 -2
  21. package/dist/vtilt.d.ts +51 -12
  22. package/lib/config.js +6 -13
  23. package/lib/constants.d.ts +172 -10
  24. package/lib/constants.js +644 -439
  25. package/lib/rate-limiter.d.ts +52 -0
  26. package/lib/rate-limiter.js +80 -0
  27. package/lib/request-queue.d.ts +78 -0
  28. package/lib/request-queue.js +156 -0
  29. package/lib/request.d.ts +54 -0
  30. package/lib/request.js +265 -0
  31. package/lib/retry-queue.d.ts +64 -0
  32. package/lib/retry-queue.js +182 -0
  33. package/lib/session.d.ts +2 -2
  34. package/lib/session.js +3 -3
  35. package/lib/types.d.ts +154 -37
  36. package/lib/types.js +6 -0
  37. package/lib/user-manager.d.ts +2 -2
  38. package/lib/user-manager.js +38 -11
  39. package/lib/utils/event-utils.js +88 -82
  40. package/lib/utils/index.js +2 -2
  41. package/lib/utils/request-utils.js +21 -19
  42. package/lib/vtilt.d.ts +51 -12
  43. package/lib/vtilt.js +199 -40
  44. package/lib/web-vitals.js +1 -1
  45. package/package.json +2 -1
package/lib/vtilt.js CHANGED
@@ -8,18 +8,35 @@ const session_1 = require("./session");
8
8
  const user_manager_1 = require("./user-manager");
9
9
  const web_vitals_1 = require("./web-vitals");
10
10
  const history_autocapture_1 = require("./extensions/history-autocapture");
11
+ const request_1 = require("./request");
12
+ const request_queue_1 = require("./request-queue");
13
+ const retry_queue_1 = require("./retry-queue");
14
+ const rate_limiter_1 = require("./rate-limiter");
11
15
  const utils_1 = require("./utils");
12
16
  const event_utils_1 = require("./utils/event-utils");
13
17
  const globals_1 = require("./utils/globals");
18
+ /*
19
+ STYLE GUIDE:
20
+
21
+ Naming conventions:
22
+ - snake_case: Config options and public API methods (e.g., capture_pageview, get_distinct_id)
23
+ - camelCase: Internal variables and class properties (e.g., sessionManager, requestQueue)
24
+ - _snake_case: Private methods that can be mangled/minified (e.g., _init, _dom_loaded)
25
+ - __snake_case: Internal but not minified, signals internal use (e.g., __loaded, __request_queue)
26
+ - UPPER_CASE: Constants and globals (e.g., PRIMARY_INSTANCE_NAME, ENQUEUE_REQUESTS)
27
+
28
+ Use TypeScript accessibility modifiers (private/protected) for non-public members.
29
+ */
14
30
  // Helper to check if value is an array
15
31
  const isArray = Array.isArray;
16
32
  class VTilt {
17
33
  constructor(config = {}) {
18
34
  this.__loaded = false; // Matches snippet's window.vt.__loaded check
19
- this._initialPageviewCaptured = false;
20
- this._visibilityStateListener = null;
21
- this.__request_queue = []; // Public for DOM loaded handler
22
- this._hasWarnedAboutConfig = false; // Track if we've already warned about missing config
35
+ this._initial_pageview_captured = false;
36
+ this._visibility_state_listener = null;
37
+ this.__request_queue = []; // Legacy queue for DOM loaded handler (pre-init)
38
+ this._has_warned_about_config = false; // Track if we've already warned about missing config
39
+ this._set_once_properties_sent = false; // Track if $set_once with initial props has been sent (only send once per page load)
23
40
  this.configManager = new config_1.ConfigManager(config);
24
41
  const fullConfig = this.configManager.getConfig();
25
42
  // Auto-detect domain from location if not provided
@@ -30,6 +47,27 @@ class VTilt {
30
47
  this.sessionManager = new session_1.SessionManager(fullConfig.storage || "cookie", domain);
31
48
  this.userManager = new user_manager_1.UserManager(fullConfig.persistence || "localStorage", domain);
32
49
  this.webVitalsManager = new web_vitals_1.WebVitalsManager(fullConfig, this);
50
+ // Initialize rate limiter to prevent flooding
51
+ // Default: 10 events/second with burst of 100
52
+ this.rateLimiter = new rate_limiter_1.RateLimiter({
53
+ eventsPerSecond: 10,
54
+ eventsBurstLimit: 100,
55
+ captureWarning: (message) => {
56
+ // Send rate limit warning event (bypasses rate limiting)
57
+ this._capture_internal(rate_limiter_1.RATE_LIMIT_WARNING_EVENT, {
58
+ $$client_ingestion_warning_message: message,
59
+ });
60
+ },
61
+ });
62
+ // Initialize retry queue for failed requests
63
+ // Retries with exponential backoff: 3s, 6s, 12s... up to 30 minutes
64
+ this.retryQueue = new retry_queue_1.RetryQueue({
65
+ sendRequest: (req) => this._send_http_request(req),
66
+ sendBeacon: (req) => this._send_beacon_request(req),
67
+ });
68
+ // Initialize request queue for event batching
69
+ // Events are batched and sent every 3 seconds (configurable)
70
+ this.requestQueue = new request_queue_1.RequestQueue((req) => this._send_batched_request(req), { flush_interval_ms: 3000 });
33
71
  }
34
72
  /**
35
73
  * Initializes a new instance of the VTilt tracking object.
@@ -99,10 +137,46 @@ class VTilt {
99
137
  // Initialize history autocapture
100
138
  this.historyAutocapture = new history_autocapture_1.HistoryAutocapture(this);
101
139
  this.historyAutocapture.startIfEnabled();
140
+ // Set up page unload handler to flush queued events
141
+ this._setup_unload_handler();
142
+ // Enable the request queue for batched sending (PostHog pattern)
143
+ this._start_queue_if_opted_in();
102
144
  // Capture initial pageview (with visibility check)
103
- this._captureInitialPageview();
145
+ // Only if capture_pageview is enabled (default: true)
146
+ if (fullConfig.capture_pageview !== false) {
147
+ this._capture_initial_pageview();
148
+ }
104
149
  return this;
105
150
  }
151
+ /**
152
+ * Start the request queue if user hasn't opted out
153
+ * Following PostHog's pattern - called from both _init() and _dom_loaded()
154
+ * Safe to call multiple times as enable() is idempotent
155
+ */
156
+ _start_queue_if_opted_in() {
157
+ // TODO: Add opt-out check when consent management is implemented
158
+ // if (!this.is_capturing()) return;
159
+ // Enable batched sending
160
+ this.requestQueue.enable();
161
+ }
162
+ /**
163
+ * Set up handler to flush event queue on page unload
164
+ * Uses both beforeunload and pagehide for maximum compatibility
165
+ */
166
+ _setup_unload_handler() {
167
+ if (!globals_1.window) {
168
+ return;
169
+ }
170
+ const unloadHandler = () => {
171
+ // Flush all queued events using sendBeacon for reliable delivery
172
+ this.requestQueue.unload();
173
+ // Also flush any pending retries
174
+ this.retryQueue.unload();
175
+ };
176
+ // beforeunload is more reliable but pagehide works better on mobile
177
+ (0, utils_1.addEventListener)(globals_1.window, "beforeunload", unloadHandler);
178
+ (0, utils_1.addEventListener)(globals_1.window, "pagehide", unloadHandler);
179
+ }
106
180
  /**
107
181
  * Returns a string representation of the instance name
108
182
  * Used for debugging and logging
@@ -137,16 +211,16 @@ class VTilt {
137
211
  * Returns true if projectId and token are present, false otherwise
138
212
  * Logs a warning only once per instance if not configured
139
213
  */
140
- _isConfigured() {
214
+ _is_configured() {
141
215
  const config = this.configManager.getConfig();
142
216
  if (config.projectId && config.token) {
143
217
  return true;
144
218
  }
145
219
  // Only warn once to avoid console spam
146
- if (!this._hasWarnedAboutConfig) {
220
+ if (!this._has_warned_about_config) {
147
221
  console.warn("VTilt: projectId and token are required for tracking. " +
148
222
  "Events will be skipped until init() or updateConfig() is called with these fields.");
149
- this._hasWarnedAboutConfig = true;
223
+ this._has_warned_about_config = true;
150
224
  }
151
225
  return false;
152
226
  }
@@ -155,8 +229,8 @@ class VTilt {
155
229
  */
156
230
  buildUrl() {
157
231
  const config = this.configManager.getConfig();
158
- const { proxyUrl, proxy, host, token } = config;
159
- // Use proxy endpoint to handle Tinybird authentication
232
+ const { proxyUrl, proxy, api_host, token } = config;
233
+ // Use proxy endpoint to handle backend authentication
160
234
  if (proxyUrl) {
161
235
  // Use the full proxy URL as provided
162
236
  return proxyUrl;
@@ -165,8 +239,8 @@ class VTilt {
165
239
  // Construct the proxy URL from the proxy domain with token
166
240
  return `${proxy}/api/tracking?token=${token}`;
167
241
  }
168
- else if (host) {
169
- const cleanHost = host.replace(/\/+$/gm, "");
242
+ else if (api_host) {
243
+ const cleanHost = api_host.replace(/\/+$/gm, "");
170
244
  return `${cleanHost}/api/tracking?token=${token}`;
171
245
  }
172
246
  else {
@@ -177,11 +251,12 @@ class VTilt {
177
251
  /**
178
252
  * Send HTTP request
179
253
  * This is the central entry point for all tracking requests
254
+ * Events are batched and sent every 3 seconds for better performance
180
255
  */
181
256
  sendRequest(url, event, shouldEnqueue) {
182
257
  // Validate configuration (only warns once per instance)
183
258
  // This is the single place where validation happens for all sending methods
184
- if (!this._isConfigured()) {
259
+ if (!this._is_configured()) {
185
260
  return;
186
261
  }
187
262
  // Check if we should queue requests (for older browsers before DOM is loaded)
@@ -193,10 +268,69 @@ class VTilt {
193
268
  return;
194
269
  }
195
270
  }
196
- const request = new XMLHttpRequest();
197
- request.open("POST", url, true);
198
- request.setRequestHeader("Content-Type", "application/json");
199
- request.send(JSON.stringify(event));
271
+ // Enqueue event for batched sending
272
+ this.requestQueue.enqueue({ url, event });
273
+ }
274
+ /**
275
+ * Send a batched request with multiple events
276
+ * Called by RequestQueue when flushing
277
+ * Uses RetryQueue for automatic retry on failure
278
+ */
279
+ _send_batched_request(req) {
280
+ const { transport } = req;
281
+ // Use sendBeacon for reliable delivery on page unload
282
+ if (transport === "sendBeacon") {
283
+ this._send_beacon_request(req);
284
+ return;
285
+ }
286
+ // Use retry queue for normal requests (handles failures automatically)
287
+ this.retryQueue.retriableRequest(req);
288
+ }
289
+ /**
290
+ * Send HTTP request and return status code
291
+ * Uses GZip compression for payloads > 1KB
292
+ * Used by RetryQueue for retryable requests
293
+ */
294
+ _send_http_request(req) {
295
+ return new Promise((resolve) => {
296
+ const { url, events } = req;
297
+ // Prepare payload (single event or batch)
298
+ const payload = events.length === 1 ? events[0] : { events };
299
+ // Determine compression
300
+ const compression = this.configManager.getConfig().disable_compression
301
+ ? request_1.Compression.None
302
+ : request_1.Compression.GZipJS;
303
+ (0, request_1.request)({
304
+ url,
305
+ data: payload,
306
+ method: "POST",
307
+ transport: "XHR",
308
+ compression,
309
+ callback: (response) => {
310
+ resolve({ statusCode: response.statusCode });
311
+ },
312
+ });
313
+ });
314
+ }
315
+ /**
316
+ * Send request using sendBeacon for reliable delivery on page unload
317
+ * Uses GZip compression for payloads > 1KB
318
+ */
319
+ _send_beacon_request(req) {
320
+ const { url, events } = req;
321
+ // Prepare payload (single event or batch)
322
+ const payload = events.length === 1 ? events[0] : { events };
323
+ // Determine compression
324
+ const compression = this.configManager.getConfig().disable_compression
325
+ ? request_1.Compression.None
326
+ : request_1.Compression.GZipJS;
327
+ (0, request_1.request)({
328
+ url,
329
+ data: payload,
330
+ method: "POST",
331
+ transport: "sendBeacon",
332
+ compression,
333
+ });
200
334
  }
201
335
  /**
202
336
  * Send a queued request (called after DOM is loaded)
@@ -213,8 +347,9 @@ class VTilt {
213
347
  *
214
348
  * @param name - Event name
215
349
  * @param payload - Event payload
350
+ * @param options - Optional capture options
216
351
  */
217
- capture(name, payload) {
352
+ capture(name, payload, options) {
218
353
  this.sessionManager.setSessionId();
219
354
  // Only send events in browser environment (not SSR)
220
355
  if (!globals_1.navigator || !globals_1.navigator.userAgent) {
@@ -223,6 +358,12 @@ class VTilt {
223
358
  if (!(0, utils_1.isValidUserAgent)(globals_1.navigator.userAgent)) {
224
359
  return;
225
360
  }
361
+ // Check rate limiting (unless explicitly skipped)
362
+ if (!(options === null || options === void 0 ? void 0 : options.skip_client_rate_limiting) &&
363
+ !this.rateLimiter.shouldAllowEvent()) {
364
+ // Event is rate limited - drop it silently
365
+ return;
366
+ }
226
367
  const url = this.buildUrl();
227
368
  // Add properties to all events
228
369
  // This includes: $current_url, $host, $pathname, $referrer, $referring_domain, $browser, $os, $device, $timezone, etc.
@@ -241,14 +382,16 @@ class VTilt {
241
382
  // This allows linking events with different distinct_ids that share the same anonymous_id
242
383
  // This is especially important for handling race conditions when $identify events arrive
243
384
  const anonymousId = this.userManager.getAnonymousId();
244
- // Build $set and $set_once from initial props
245
- // Initial props are added to $set_once (only first time, preserves first values)
246
- // Regular event properties that match EVENT_TO_PERSON_PROPERTIES will be added to $set by server
385
+ // Build $set_once from initial props (only on first event per page load)
386
+ // This optimization follows PostHog's pattern: initial props only need to be sent once
387
+ // since $set_once preserves first values. Sending them on every event would be redundant
388
+ // and cause unnecessary server processing for identity creation/updates.
247
389
  const setOnce = {};
248
- if (Object.keys(initialProps).length > 0) {
390
+ // Only include initial props if we haven't sent them yet this page load
391
+ if (!this._set_once_properties_sent && Object.keys(initialProps).length > 0) {
249
392
  Object.assign(setOnce, initialProps);
250
393
  }
251
- // Merge with user-provided $set_once if present
394
+ // Always merge with user-provided $set_once if present
252
395
  if (payload.$set_once) {
253
396
  Object.assign(setOnce, payload.$set_once);
254
397
  }
@@ -260,12 +403,16 @@ class VTilt {
260
403
  // Always include $anon_distinct_id for identity linking (even for identified users)
261
404
  // This allows the server to merge identities proactively when events arrive out of order
262
405
  ...(anonymousId ? { $anon_distinct_id: anonymousId } : {}),
263
- // Add $set_once with initial props
406
+ // Add $set_once with initial props (only if there are properties to set)
264
407
  ...(Object.keys(setOnce).length > 0 ? { $set_once: setOnce } : {}),
265
408
  // Merge user-provided $set if present
266
409
  ...(payload.$set ? { $set: payload.$set } : {}),
267
410
  ...payload, // User-provided payload (can override base and person properties)
268
411
  };
412
+ // Mark that $set_once with initial props has been sent (only do this once per page load)
413
+ if (!this._set_once_properties_sent && Object.keys(initialProps).length > 0) {
414
+ this._set_once_properties_sent = true;
415
+ }
269
416
  // Add title only to $pageview events
270
417
  if (name === "$pageview" && globals_1.document) {
271
418
  enrichedPayload.title = globals_1.document.title;
@@ -313,6 +460,13 @@ class VTilt {
313
460
  };
314
461
  this.sendRequest(url, trackingEvent, true);
315
462
  }
463
+ /**
464
+ * Internal capture method that bypasses rate limiting
465
+ * Used for system events like rate limit warnings
466
+ */
467
+ _capture_internal(name, payload) {
468
+ this.capture(name, payload, { skip_client_rate_limiting: true });
469
+ }
316
470
  /**
317
471
  * Track a custom event (alias for capture)
318
472
  */
@@ -380,7 +534,7 @@ class VTilt {
380
534
  // New distinct_id and properties go in payload
381
535
  this.capture("$identify", {
382
536
  distinct_id: newDistinctId, // New distinct_id in payload
383
- $anon_distinct_id: previousDistinctId || anonymousId, // Previous ID in payload
537
+ $anon_distinct_id: anonymousId, // Previous ID in payload
384
538
  $set: userPropertiesToSet || {},
385
539
  $set_once: userPropertiesToSetOnce || {},
386
540
  });
@@ -515,8 +669,9 @@ class VTilt {
515
669
  }
516
670
  /**
517
671
  * Capture initial pageview with visibility check
672
+ * Note: The capture_pageview config check happens at the call site (in _init)
518
673
  */
519
- _captureInitialPageview() {
674
+ _capture_initial_pageview() {
520
675
  if (!globals_1.document) {
521
676
  return;
522
677
  }
@@ -525,17 +680,17 @@ class VTilt {
525
680
  // This is useful to avoid `prerender` calls from Chrome/Wordpress/SPAs
526
681
  // that are not visible to the user
527
682
  if (globals_1.document.visibilityState !== "visible") {
528
- if (!this._visibilityStateListener) {
529
- this._visibilityStateListener = () => {
530
- this._captureInitialPageview();
683
+ if (!this._visibility_state_listener) {
684
+ this._visibility_state_listener = () => {
685
+ this._capture_initial_pageview();
531
686
  };
532
- (0, utils_1.addEventListener)(globals_1.document, "visibilitychange", this._visibilityStateListener);
687
+ (0, utils_1.addEventListener)(globals_1.document, "visibilitychange", this._visibility_state_listener);
533
688
  }
534
689
  return;
535
690
  }
536
691
  // Extra check here to guarantee we only ever trigger a single initial pageview event
537
- if (!this._initialPageviewCaptured) {
538
- this._initialPageviewCaptured = true;
692
+ if (!this._initial_pageview_captured) {
693
+ this._initial_pageview_captured = true;
539
694
  // Wait a bit for SPA routers
540
695
  setTimeout(() => {
541
696
  // Double-check we're still in browser environment (defensive check)
@@ -550,9 +705,9 @@ class VTilt {
550
705
  this.capture("$pageview", payload);
551
706
  }, 300);
552
707
  // After we've captured the initial pageview, we can remove the listener
553
- if (this._visibilityStateListener) {
554
- globals_1.document.removeEventListener("visibilitychange", this._visibilityStateListener);
555
- this._visibilityStateListener = null;
708
+ if (this._visibility_state_listener) {
709
+ globals_1.document.removeEventListener("visibilitychange", this._visibility_state_listener);
710
+ this._visibility_state_listener = null;
556
711
  }
557
712
  }
558
713
  }
@@ -614,15 +769,18 @@ class VTilt {
614
769
  });
615
770
  }
616
771
  /**
617
- * Called when DOM is loaded - processes queued requests
772
+ * Called when DOM is loaded - processes queued requests and enables batching
773
+ * Following PostHog's pattern in _dom_loaded()
618
774
  */
619
775
  _dom_loaded() {
620
- // Process all queued requests
776
+ // Process all pre-init queued requests
621
777
  this.__request_queue.forEach((item) => {
622
778
  this._send_retriable_request(item);
623
779
  });
624
- // Clear the queue
780
+ // Clear the legacy queue
625
781
  this.__request_queue = [];
782
+ // Enable the request queue for batched sending (PostHog pattern)
783
+ this._start_queue_if_opted_in();
626
784
  }
627
785
  }
628
786
  exports.VTilt = VTilt;
@@ -637,7 +795,8 @@ let ENQUEUE_REQUESTS = !SUPPORTS_REQUEST &&
637
795
  (globals_1.userAgent === null || globals_1.userAgent === void 0 ? void 0 : globals_1.userAgent.indexOf("MSIE")) === -1 &&
638
796
  (globals_1.userAgent === null || globals_1.userAgent === void 0 ? void 0 : globals_1.userAgent.indexOf("Mozilla")) === -1;
639
797
  // Expose ENQUEUE_REQUESTS to window for request queuing
640
- if (globals_1.window) {
798
+ // Use typeof check to safely access global window object
799
+ if (typeof globals_1.window !== "undefined" && globals_1.window !== null) {
641
800
  globals_1.window.__VTILT_ENQUEUE_REQUESTS = ENQUEUE_REQUESTS;
642
801
  }
643
802
  /**
@@ -653,7 +812,7 @@ const add_dom_loaded_handler = function () {
653
812
  dom_loaded_handler.done = true;
654
813
  // Disable request queuing now that DOM is loaded
655
814
  ENQUEUE_REQUESTS = false;
656
- if (typeof globals_1.window !== "undefined") {
815
+ if (typeof globals_1.window !== "undefined" && globals_1.window !== null) {
657
816
  globals_1.window.__VTILT_ENQUEUE_REQUESTS = false;
658
817
  }
659
818
  // Process queued requests for all instances
package/lib/web-vitals.js CHANGED
@@ -7,7 +7,7 @@ class WebVitalsManager {
7
7
  constructor(config, instance) {
8
8
  this.instance = instance;
9
9
  // Load web-vitals if enabled and in browser environment (not SSR)
10
- if (config.webVitals && globals_1.window) {
10
+ if (config.capture_performance && globals_1.window) {
11
11
  try {
12
12
  // Dynamically require web-vitals, ignore type error since it's a runtime import
13
13
  // eslint-disable-next-line
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@v-tilt/browser",
3
- "version": "1.0.11",
3
+ "version": "1.1.1",
4
4
  "description": "vTilt browser tracking library",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/module.js",
@@ -48,6 +48,7 @@
48
48
  "typescript": "^5.3.3"
49
49
  },
50
50
  "dependencies": {
51
+ "fflate": "^0.8.2",
51
52
  "web-vitals": "^3.5.0"
52
53
  },
53
54
  "peerDependencies": {