@v-tilt/browser 1.0.11 → 1.1.0

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,54 @@
1
+ /**
2
+ * Request utilities for vTilt tracking
3
+ *
4
+ * Handles HTTP requests with:
5
+ * - GZip compression (via fflate)
6
+ * - Multiple transport methods (fetch, XHR, sendBeacon)
7
+ * - Automatic fallback between transports
8
+ *
9
+ * Based on PostHog's request.ts pattern
10
+ */
11
+ /**
12
+ * Compression methods supported by the SDK
13
+ */
14
+ export declare enum Compression {
15
+ GZipJS = "gzip-js",
16
+ None = "none"
17
+ }
18
+ /**
19
+ * Response from a request
20
+ */
21
+ export interface RequestResponse {
22
+ statusCode: number;
23
+ text?: string;
24
+ json?: any;
25
+ }
26
+ /**
27
+ * Options for making a request
28
+ */
29
+ export interface RequestOptions {
30
+ url: string;
31
+ data?: any;
32
+ method?: "POST" | "GET";
33
+ headers?: Record<string, string>;
34
+ transport?: "XHR" | "fetch" | "sendBeacon";
35
+ compression?: Compression;
36
+ timeout?: number;
37
+ callback?: (response: RequestResponse) => void;
38
+ }
39
+ /**
40
+ * JSON stringify with BigInt support
41
+ */
42
+ export declare const jsonStringify: (data: any) => string;
43
+ /**
44
+ * Main request function - handles transport selection and dispatching
45
+ */
46
+ export declare const request: (options: RequestOptions) => void;
47
+ /**
48
+ * Promise-based request wrapper
49
+ */
50
+ export declare const requestAsync: (options: Omit<RequestOptions, "callback">) => Promise<RequestResponse>;
51
+ /**
52
+ * Check if compression is supported and beneficial
53
+ */
54
+ export declare const shouldCompress: (data: any) => boolean;
package/lib/request.js ADDED
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ /**
3
+ * Request utilities for vTilt tracking
4
+ *
5
+ * Handles HTTP requests with:
6
+ * - GZip compression (via fflate)
7
+ * - Multiple transport methods (fetch, XHR, sendBeacon)
8
+ * - Automatic fallback between transports
9
+ *
10
+ * Based on PostHog's request.ts pattern
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.shouldCompress = exports.requestAsync = exports.request = exports.jsonStringify = exports.Compression = void 0;
14
+ const fflate_1 = require("fflate");
15
+ // Content types
16
+ const CONTENT_TYPE_PLAIN = "text/plain";
17
+ const CONTENT_TYPE_JSON = "application/json";
18
+ // Compression threshold - skip compression for small payloads
19
+ const MIN_COMPRESSION_SIZE = 1024; // 1KB
20
+ /**
21
+ * Compression methods supported by the SDK
22
+ */
23
+ var Compression;
24
+ (function (Compression) {
25
+ Compression["GZipJS"] = "gzip-js";
26
+ Compression["None"] = "none";
27
+ })(Compression || (exports.Compression = Compression = {}));
28
+ /**
29
+ * JSON stringify with BigInt support
30
+ */
31
+ const jsonStringify = (data) => {
32
+ return JSON.stringify(data, (_, value) => typeof value === "bigint" ? value.toString() : value);
33
+ };
34
+ exports.jsonStringify = jsonStringify;
35
+ /**
36
+ * Encode request data with optional compression
37
+ */
38
+ const encodePostData = (options) => {
39
+ const { data, compression } = options;
40
+ if (!data) {
41
+ return;
42
+ }
43
+ const jsonBody = (0, exports.jsonStringify)(data);
44
+ const estimatedSize = new Blob([jsonBody]).size;
45
+ // Use GZip compression if enabled and payload is large enough
46
+ if (compression === Compression.GZipJS &&
47
+ estimatedSize >= MIN_COMPRESSION_SIZE) {
48
+ try {
49
+ const gzipData = (0, fflate_1.gzipSync)((0, fflate_1.strToU8)(jsonBody), { mtime: 0 });
50
+ const blob = new Blob([gzipData], { type: CONTENT_TYPE_PLAIN });
51
+ // Only use compression if it actually reduces size
52
+ if (blob.size < estimatedSize * 0.95) {
53
+ return {
54
+ contentType: CONTENT_TYPE_PLAIN,
55
+ body: blob,
56
+ estimatedSize: blob.size,
57
+ };
58
+ }
59
+ }
60
+ catch (_a) {
61
+ // Fallback to uncompressed on error
62
+ }
63
+ }
64
+ // Return uncompressed JSON
65
+ return {
66
+ contentType: CONTENT_TYPE_JSON,
67
+ body: jsonBody,
68
+ estimatedSize,
69
+ };
70
+ };
71
+ /**
72
+ * Send request using XMLHttpRequest
73
+ */
74
+ const xhrRequest = (options) => {
75
+ const req = new XMLHttpRequest();
76
+ req.open(options.method || "POST", options.url, true);
77
+ const encoded = encodePostData(options);
78
+ if (!encoded) {
79
+ return;
80
+ }
81
+ const { contentType, body } = encoded;
82
+ // Set headers
83
+ if (options.headers) {
84
+ Object.entries(options.headers).forEach(([key, value]) => {
85
+ req.setRequestHeader(key, value);
86
+ });
87
+ }
88
+ req.setRequestHeader("Content-Type", contentType);
89
+ // Add compression indicator for gzip
90
+ if (contentType === CONTENT_TYPE_PLAIN &&
91
+ options.compression === Compression.GZipJS) {
92
+ req.setRequestHeader("Content-Encoding", "gzip");
93
+ }
94
+ if (options.timeout) {
95
+ req.timeout = options.timeout;
96
+ }
97
+ req.onreadystatechange = () => {
98
+ var _a;
99
+ if (req.readyState === 4) {
100
+ const response = {
101
+ statusCode: req.status,
102
+ text: req.responseText,
103
+ };
104
+ if (req.status === 200) {
105
+ try {
106
+ response.json = JSON.parse(req.responseText);
107
+ }
108
+ catch (_b) {
109
+ // Ignore parse errors
110
+ }
111
+ }
112
+ (_a = options.callback) === null || _a === void 0 ? void 0 : _a.call(options, response);
113
+ }
114
+ };
115
+ req.onerror = () => {
116
+ var _a;
117
+ (_a = options.callback) === null || _a === void 0 ? void 0 : _a.call(options, { statusCode: 0 });
118
+ };
119
+ req.send(body);
120
+ };
121
+ /**
122
+ * Send request using fetch API
123
+ */
124
+ const fetchRequest = (options) => {
125
+ const encoded = encodePostData(options);
126
+ if (!encoded) {
127
+ return;
128
+ }
129
+ const { contentType, body, estimatedSize } = encoded;
130
+ const headers = {
131
+ "Content-Type": contentType,
132
+ ...(options.headers || {}),
133
+ };
134
+ // Add compression indicator for gzip
135
+ if (contentType === CONTENT_TYPE_PLAIN &&
136
+ options.compression === Compression.GZipJS) {
137
+ headers["Content-Encoding"] = "gzip";
138
+ }
139
+ const controller = new AbortController();
140
+ const timeoutId = options.timeout
141
+ ? setTimeout(() => controller.abort(), options.timeout)
142
+ : null;
143
+ fetch(options.url, {
144
+ method: options.method || "POST",
145
+ headers,
146
+ body,
147
+ // Use keepalive for smaller payloads to ensure delivery on page close
148
+ keepalive: estimatedSize < 64 * 1024 * 0.8,
149
+ signal: controller.signal,
150
+ })
151
+ .then(async (response) => {
152
+ var _a;
153
+ const text = await response.text();
154
+ const res = {
155
+ statusCode: response.status,
156
+ text,
157
+ };
158
+ if (response.status === 200) {
159
+ try {
160
+ res.json = JSON.parse(text);
161
+ }
162
+ catch (_b) {
163
+ // Ignore parse errors
164
+ }
165
+ }
166
+ (_a = options.callback) === null || _a === void 0 ? void 0 : _a.call(options, res);
167
+ })
168
+ .catch(() => {
169
+ var _a;
170
+ (_a = options.callback) === null || _a === void 0 ? void 0 : _a.call(options, { statusCode: 0 });
171
+ })
172
+ .finally(() => {
173
+ if (timeoutId) {
174
+ clearTimeout(timeoutId);
175
+ }
176
+ });
177
+ };
178
+ /**
179
+ * Send request using sendBeacon for reliable delivery on page unload
180
+ */
181
+ const sendBeaconRequest = (options) => {
182
+ const encoded = encodePostData(options);
183
+ if (!encoded) {
184
+ return;
185
+ }
186
+ const { contentType, body } = encoded;
187
+ try {
188
+ // sendBeacon requires a Blob
189
+ const beaconBody = typeof body === "string" ? new Blob([body], { type: contentType }) : body;
190
+ // Add compression query param for server to detect
191
+ const url = options.compression === Compression.GZipJS &&
192
+ contentType === CONTENT_TYPE_PLAIN
193
+ ? `${options.url}${options.url.includes("?") ? "&" : "?"}compression=gzip-js`
194
+ : options.url;
195
+ navigator.sendBeacon(url, beaconBody);
196
+ }
197
+ catch (_a) {
198
+ // sendBeacon is best-effort, ignore errors
199
+ }
200
+ };
201
+ /**
202
+ * Available transport methods in order of preference
203
+ */
204
+ const TRANSPORTS = [
205
+ {
206
+ name: "fetch",
207
+ available: typeof fetch !== "undefined",
208
+ method: fetchRequest,
209
+ },
210
+ {
211
+ name: "XHR",
212
+ available: typeof XMLHttpRequest !== "undefined",
213
+ method: xhrRequest,
214
+ },
215
+ {
216
+ name: "sendBeacon",
217
+ available: typeof navigator !== "undefined" && !!navigator.sendBeacon,
218
+ method: sendBeaconRequest,
219
+ },
220
+ ];
221
+ /**
222
+ * Main request function - handles transport selection and dispatching
223
+ */
224
+ const request = (options) => {
225
+ var _a;
226
+ const transport = options.transport || "fetch";
227
+ // Find the requested transport or fall back to first available
228
+ const transportConfig = TRANSPORTS.find((t) => t.name === transport && t.available) ||
229
+ TRANSPORTS.find((t) => t.available);
230
+ if (!transportConfig) {
231
+ console.error("vTilt: No available transport method");
232
+ (_a = options.callback) === null || _a === void 0 ? void 0 : _a.call(options, { statusCode: 0 });
233
+ return;
234
+ }
235
+ transportConfig.method(options);
236
+ };
237
+ exports.request = request;
238
+ /**
239
+ * Promise-based request wrapper
240
+ */
241
+ const requestAsync = (options) => {
242
+ return new Promise((resolve) => {
243
+ (0, exports.request)({
244
+ ...options,
245
+ callback: resolve,
246
+ });
247
+ });
248
+ };
249
+ exports.requestAsync = requestAsync;
250
+ /**
251
+ * Check if compression is supported and beneficial
252
+ */
253
+ const shouldCompress = (data) => {
254
+ if (typeof data === "undefined") {
255
+ return false;
256
+ }
257
+ try {
258
+ const size = new Blob([(0, exports.jsonStringify)(data)]).size;
259
+ return size >= MIN_COMPRESSION_SIZE;
260
+ }
261
+ catch (_a) {
262
+ return false;
263
+ }
264
+ };
265
+ exports.shouldCompress = shouldCompress;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Retry Queue - Exponential Backoff (PostHog-style)
3
+ *
4
+ * Retries failed requests with jittered exponential backoff.
5
+ * Detects online/offline status and pauses retries when offline.
6
+ *
7
+ * Features:
8
+ * - Exponential backoff: 3s, 6s, 12s, 24s... up to 30 minutes
9
+ * - Jitter: +/- 50% to prevent thundering herd
10
+ * - Online/offline detection
11
+ * - Max 10 retries before giving up
12
+ * - Uses sendBeacon on page unload for final attempt
13
+ */
14
+ import type { BatchedRequest } from "./request-queue";
15
+ /**
16
+ * Generates a jittered exponential backoff delay in milliseconds
17
+ *
18
+ * Base value is 3 seconds, doubled with each retry up to 30 minutes max.
19
+ * Each value has +/- 50% jitter.
20
+ *
21
+ * @param retriesPerformedSoFar - Number of retries already attempted
22
+ * @returns Delay in milliseconds
23
+ */
24
+ export declare function pickNextRetryDelay(retriesPerformedSoFar: number): number;
25
+ export interface RetryQueueConfig {
26
+ sendRequest: (req: BatchedRequest) => Promise<{
27
+ statusCode: number;
28
+ }>;
29
+ sendBeacon: (req: BatchedRequest) => void;
30
+ }
31
+ export declare class RetryQueue {
32
+ private _isPolling;
33
+ private _poller?;
34
+ private _pollIntervalMs;
35
+ private _queue;
36
+ private _areWeOnline;
37
+ private _sendRequest;
38
+ private _sendBeacon;
39
+ constructor(config: RetryQueueConfig);
40
+ /**
41
+ * Get current queue length
42
+ */
43
+ get length(): number;
44
+ /**
45
+ * Enqueue a failed request for retry
46
+ */
47
+ enqueue(request: BatchedRequest, retriesPerformedSoFar?: number): void;
48
+ /**
49
+ * Attempt to send a request with retry on failure
50
+ */
51
+ retriableRequest(request: BatchedRequest): Promise<void>;
52
+ /**
53
+ * Start polling for retries
54
+ */
55
+ private _poll;
56
+ /**
57
+ * Flush ready items from the queue
58
+ */
59
+ private _flush;
60
+ /**
61
+ * Flush all queued requests using sendBeacon on page unload
62
+ */
63
+ unload(): void;
64
+ }
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ /**
3
+ * Retry Queue - Exponential Backoff (PostHog-style)
4
+ *
5
+ * Retries failed requests with jittered exponential backoff.
6
+ * Detects online/offline status and pauses retries when offline.
7
+ *
8
+ * Features:
9
+ * - Exponential backoff: 3s, 6s, 12s, 24s... up to 30 minutes
10
+ * - Jitter: +/- 50% to prevent thundering herd
11
+ * - Online/offline detection
12
+ * - Max 10 retries before giving up
13
+ * - Uses sendBeacon on page unload for final attempt
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.RetryQueue = void 0;
17
+ exports.pickNextRetryDelay = pickNextRetryDelay;
18
+ const utils_1 = require("./utils");
19
+ const globals_1 = require("./utils/globals");
20
+ const THIRTY_MINUTES = 30 * 60 * 1000;
21
+ const MAX_RETRIES = 10;
22
+ /**
23
+ * Generates a jittered exponential backoff delay in milliseconds
24
+ *
25
+ * Base value is 3 seconds, doubled with each retry up to 30 minutes max.
26
+ * Each value has +/- 50% jitter.
27
+ *
28
+ * @param retriesPerformedSoFar - Number of retries already attempted
29
+ * @returns Delay in milliseconds
30
+ */
31
+ function pickNextRetryDelay(retriesPerformedSoFar) {
32
+ const rawBackoffTime = 3000 * 2 ** retriesPerformedSoFar;
33
+ const minBackoff = rawBackoffTime / 2;
34
+ const cappedBackoffTime = Math.min(THIRTY_MINUTES, rawBackoffTime);
35
+ const jitterFraction = Math.random() - 0.5; // Random between -0.5 and 0.5
36
+ const jitter = jitterFraction * (cappedBackoffTime - minBackoff);
37
+ return Math.ceil(cappedBackoffTime + jitter);
38
+ }
39
+ class RetryQueue {
40
+ constructor(config) {
41
+ this._isPolling = false;
42
+ this._pollIntervalMs = 3000;
43
+ this._queue = [];
44
+ this._areWeOnline = true;
45
+ this._sendRequest = config.sendRequest;
46
+ this._sendBeacon = config.sendBeacon;
47
+ // Set up online/offline detection
48
+ if (globals_1.window && typeof globals_1.navigator !== "undefined" && "onLine" in globals_1.navigator) {
49
+ this._areWeOnline = globals_1.navigator.onLine;
50
+ (0, utils_1.addEventListener)(globals_1.window, "online", () => {
51
+ this._areWeOnline = true;
52
+ this._flush();
53
+ });
54
+ (0, utils_1.addEventListener)(globals_1.window, "offline", () => {
55
+ this._areWeOnline = false;
56
+ });
57
+ }
58
+ }
59
+ /**
60
+ * Get current queue length
61
+ */
62
+ get length() {
63
+ return this._queue.length;
64
+ }
65
+ /**
66
+ * Enqueue a failed request for retry
67
+ */
68
+ enqueue(request, retriesPerformedSoFar = 0) {
69
+ // Don't retry if we've exceeded max retries
70
+ if (retriesPerformedSoFar >= MAX_RETRIES) {
71
+ console.warn(`VTilt: Request failed after ${MAX_RETRIES} retries, giving up`);
72
+ return;
73
+ }
74
+ const msToNextRetry = pickNextRetryDelay(retriesPerformedSoFar);
75
+ const retryAt = Date.now() + msToNextRetry;
76
+ this._queue.push({
77
+ retryAt,
78
+ request,
79
+ retriesPerformedSoFar: retriesPerformedSoFar + 1,
80
+ });
81
+ let logMessage = `VTilt: Enqueued failed request for retry in ${Math.round(msToNextRetry / 1000)}s`;
82
+ if (!this._areWeOnline) {
83
+ logMessage += " (Browser is offline)";
84
+ }
85
+ console.warn(logMessage);
86
+ // Start polling if not already
87
+ if (!this._isPolling) {
88
+ this._isPolling = true;
89
+ this._poll();
90
+ }
91
+ }
92
+ /**
93
+ * Attempt to send a request with retry on failure
94
+ */
95
+ async retriableRequest(request) {
96
+ try {
97
+ const response = await this._sendRequest(request);
98
+ // Retry on server errors (5xx) or network errors (0)
99
+ // Don't retry on client errors (4xx)
100
+ if (response.statusCode !== 200 &&
101
+ (response.statusCode < 400 || response.statusCode >= 500)) {
102
+ this.enqueue(request, 0);
103
+ }
104
+ }
105
+ catch (_a) {
106
+ // Network error - enqueue for retry
107
+ this.enqueue(request, 0);
108
+ }
109
+ }
110
+ /**
111
+ * Start polling for retries
112
+ */
113
+ _poll() {
114
+ if (this._poller) {
115
+ clearTimeout(this._poller);
116
+ }
117
+ this._poller = setTimeout(() => {
118
+ if (this._areWeOnline && this._queue.length > 0) {
119
+ this._flush();
120
+ }
121
+ // Continue polling if there are items in queue
122
+ if (this._queue.length > 0) {
123
+ this._poll();
124
+ }
125
+ else {
126
+ this._isPolling = false;
127
+ }
128
+ }, this._pollIntervalMs);
129
+ }
130
+ /**
131
+ * Flush ready items from the queue
132
+ */
133
+ _flush() {
134
+ const now = Date.now();
135
+ const notReady = [];
136
+ const ready = [];
137
+ this._queue.forEach((item) => {
138
+ if (item.retryAt < now) {
139
+ ready.push(item);
140
+ }
141
+ else {
142
+ notReady.push(item);
143
+ }
144
+ });
145
+ this._queue = notReady;
146
+ // Retry ready items
147
+ ready.forEach(async ({ request, retriesPerformedSoFar }) => {
148
+ try {
149
+ const response = await this._sendRequest(request);
150
+ // If still failing, re-enqueue
151
+ if (response.statusCode !== 200 &&
152
+ (response.statusCode < 400 || response.statusCode >= 500)) {
153
+ this.enqueue(request, retriesPerformedSoFar);
154
+ }
155
+ }
156
+ catch (_a) {
157
+ // Network error - re-enqueue
158
+ this.enqueue(request, retriesPerformedSoFar);
159
+ }
160
+ });
161
+ }
162
+ /**
163
+ * Flush all queued requests using sendBeacon on page unload
164
+ */
165
+ unload() {
166
+ if (this._poller) {
167
+ clearTimeout(this._poller);
168
+ this._poller = undefined;
169
+ }
170
+ // Attempt final send of all queued requests
171
+ this._queue.forEach(({ request }) => {
172
+ try {
173
+ this._sendBeacon(request);
174
+ }
175
+ catch (e) {
176
+ console.error("VTilt: Failed to send beacon on unload", e);
177
+ }
178
+ });
179
+ this._queue = [];
180
+ }
181
+ }
182
+ exports.RetryQueue = RetryQueue;
package/lib/types.d.ts CHANGED
@@ -13,6 +13,7 @@ export interface VTiltConfig {
13
13
  globalAttributes?: Record<string, string>;
14
14
  persistence?: "localStorage" | "cookie";
15
15
  crossSubdomainCookie?: boolean;
16
+ disable_compression?: boolean;
16
17
  }
17
18
  export interface SessionData {
18
19
  value: string;