mixpanel-browser 2.60.0 → 2.61.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.
@@ -1,10 +1,11 @@
1
- import { IncrementalSource, EventType } from '@rrweb/types';
2
-
3
- import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, _} from '../utils'; // eslint-disable-line camelcase
4
1
  import { window } from '../window';
2
+ import { IncrementalSource, EventType } from '@rrweb/types';
3
+ import { MAX_RECORDING_MS, MAX_VALUE_FOR_MIN_RECORDING_MS, console_with_prefix, NOOP_FUNC, _, localStorageSupported} from '../utils'; // eslint-disable-line camelcase
4
+ import { IDBStorageWrapper, RECORDING_EVENTS_STORE_NAME } from '../storage/indexed-db';
5
5
  import { addOptOutCheckMixpanelLib } from '../gdpr-utils';
6
6
  import { RequestBatcher } from '../request-batcher';
7
7
  import Config from '../config';
8
+ import { RECORD_ENQUEUE_THROTTLE_MS } from './utils';
8
9
 
9
10
  var logger = console_with_prefix('recorder');
10
11
  var CompressionStream = window['CompressionStream'];
@@ -32,29 +33,58 @@ function isUserEvent(ev) {
32
33
  return ev.type === EventType.IncrementalSnapshot && ACTIVE_SOURCES.has(ev.data.source);
33
34
  }
34
35
 
36
+ /**
37
+ * @typedef {Object} SerializedRecording
38
+ * @property {number} idleExpires
39
+ * @property {number} maxExpires
40
+ * @property {number} replayStartTime
41
+ * @property {number} seqNo
42
+ * @property {string} batchStartUrl
43
+ * @property {string} replayId
44
+ * @property {string} tabId
45
+ * @property {string} replayStartUrl
46
+ */
47
+
48
+ /**
49
+ * @typedef {Object} SessionRecordingOptions
50
+ * @property {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
51
+ * @property {String} [options.replayId] - unique uuid for a single replay
52
+ * @property {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
53
+ * @property {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
54
+ * @property {Function} [options.rrwebRecord] - rrweb's `record` function
55
+ * @property {Function} [options.onBatchSent] - callback when a batch of events is sent to the server
56
+ * @property {Storage} [options.sharedLockStorage] - optional storage for shared lock, used for test dependency injection
57
+ * optional properties for deserialization:
58
+ * @property {number} idleExpires
59
+ * @property {number} maxExpires
60
+ * @property {number} replayStartTime
61
+ * @property {number} seqNo
62
+ * @property {string} batchStartUrl
63
+ * @property {string} replayStartUrl
64
+ */
65
+
66
+
35
67
  /**
36
68
  * This class encapsulates a single session recording and its lifecycle.
37
- * @param {Object} [options.mixpanelInstance] - reference to the core MixpanelLib
38
- * @param {String} [options.replayId] - unique uuid for a single replay
39
- * @param {Function} [options.onIdleTimeout] - callback when a recording reaches idle timeout
40
- * @param {Function} [options.onMaxLengthReached] - callback when a recording reaches its maximum length
41
- * @param {Function} [options.rrwebRecord] - rrweb's `record` function
69
+ * @param {SessionRecordingOptions} options
42
70
  */
43
71
  var SessionRecording = function(options) {
44
72
  this._mixpanel = options.mixpanelInstance;
45
- this._onIdleTimeout = options.onIdleTimeout;
46
- this._onMaxLengthReached = options.onMaxLengthReached;
47
- this._rrwebRecord = options.rrwebRecord;
48
-
49
- this.replayId = options.replayId;
73
+ this._onIdleTimeout = options.onIdleTimeout || NOOP_FUNC;
74
+ this._onMaxLengthReached = options.onMaxLengthReached || NOOP_FUNC;
75
+ this._onBatchSent = options.onBatchSent || NOOP_FUNC;
76
+ this._rrwebRecord = options.rrwebRecord || null;
50
77
 
51
78
  // internal rrweb stopRecording function
52
79
  this._stopRecording = null;
80
+ this.replayId = options.replayId;
53
81
 
54
- this.seqNo = 0;
55
- this.replayStartTime = null;
56
- this.replayStartUrl = null;
57
- this.batchStartUrl = null;
82
+ this.batchStartUrl = options.batchStartUrl || null;
83
+ this.replayStartUrl = options.replayStartUrl || null;
84
+ this.idleExpires = options.idleExpires || null;
85
+ this.maxExpires = options.maxExpires || null;
86
+ this.replayStartTime = options.replayStartTime || null;
87
+ this.seqNo = options.seqNo || 0;
58
88
 
59
89
  this.idleTimeoutId = null;
60
90
  this.maxTimeoutId = null;
@@ -62,18 +92,40 @@ var SessionRecording = function(options) {
62
92
  this.recordMaxMs = MAX_RECORDING_MS;
63
93
  this.recordMinMs = 0;
64
94
 
95
+ // disable persistence if localStorage is not supported
96
+ // request-queue will automatically disable persistence if indexedDB fails to initialize
97
+ var usePersistence = localStorageSupported(options.sharedLockStorage, true);
98
+
65
99
  // each replay has its own batcher key to avoid conflicts between rrweb events of different recordings
66
100
  // this will be important when persistence is introduced
67
- var batcherKey = '__mprec_' + this.getConfig('token') + '_' + this.replayId;
68
- this.batcher = new RequestBatcher(batcherKey, {
69
- errorReporter: _.bind(this.reportError, this),
101
+ this.batcherKey = '__mprec_' + this.getConfig('name') + '_' + this.getConfig('token') + '_' + this.replayId;
102
+ this.queueStorage = new IDBStorageWrapper(RECORDING_EVENTS_STORE_NAME);
103
+ this.batcher = new RequestBatcher(this.batcherKey, {
104
+ errorReporter: this.reportError.bind(this),
70
105
  flushOnlyOnInterval: true,
71
106
  libConfig: RECORDER_BATCHER_LIB_CONFIG,
72
- sendRequestFunc: _.bind(this.flushEventsWithOptOut, this),
73
- usePersistence: false
107
+ sendRequestFunc: this.flushEventsWithOptOut.bind(this),
108
+ queueStorage: this.queueStorage,
109
+ sharedLockStorage: options.sharedLockStorage,
110
+ usePersistence: usePersistence,
111
+ stopAllBatchingFunc: this.stopRecording.bind(this),
112
+
113
+ // increased throttle and shared lock timeout because recording events are very high frequency.
114
+ // this will minimize the amount of lock contention between enqueued events.
115
+ // for session recordings there is a lock for each tab anyway, so there's no risk of deadlock between tabs.
116
+ enqueueThrottleMs: RECORD_ENQUEUE_THROTTLE_MS,
117
+ sharedLockTimeoutMS: 10 * 1000,
74
118
  });
75
119
  };
76
120
 
121
+ SessionRecording.prototype.unloadPersistedData = function () {
122
+ this.batcher.stop();
123
+ return this.batcher.flush()
124
+ .then(function () {
125
+ return this.queueStorage.removeItem(this.batcherKey);
126
+ }.bind(this));
127
+ };
128
+
77
129
  SessionRecording.prototype.getConfig = function(configVar) {
78
130
  return this._mixpanel.get_config(configVar);
79
131
  };
@@ -86,6 +138,11 @@ SessionRecording.prototype.get_config = function(configVar) {
86
138
  };
87
139
 
88
140
  SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
141
+ if (this._rrwebRecord === null) {
142
+ this.reportError('rrweb record function not provided. ');
143
+ return;
144
+ }
145
+
89
146
  if (this._stopRecording !== null) {
90
147
  logger.log('Recording already in progress, skipping startRecording.');
91
148
  return;
@@ -97,15 +154,21 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
97
154
  logger.critical('record_max_ms cannot be greater than ' + MAX_RECORDING_MS + 'ms. Capping value.');
98
155
  }
99
156
 
157
+ if (!this.maxExpires) {
158
+ this.maxExpires = new Date().getTime() + this.recordMaxMs;
159
+ }
160
+
100
161
  this.recordMinMs = this.getConfig('record_min_ms');
101
162
  if (this.recordMinMs > MAX_VALUE_FOR_MIN_RECORDING_MS) {
102
163
  this.recordMinMs = MAX_VALUE_FOR_MIN_RECORDING_MS;
103
164
  logger.critical('record_min_ms cannot be greater than ' + MAX_VALUE_FOR_MIN_RECORDING_MS + 'ms. Capping value.');
104
165
  }
105
166
 
106
- this.replayStartTime = new Date().getTime();
107
- this.batchStartUrl = _.info.currentUrl();
108
- this.replayStartUrl = _.info.currentUrl();
167
+ if (!this.replayStartTime) {
168
+ this.replayStartTime = new Date().getTime();
169
+ this.batchStartUrl = _.info.currentUrl();
170
+ this.replayStartUrl = _.info.currentUrl();
171
+ }
109
172
 
110
173
  if (shouldStopBatcher || this.recordMinMs > 0) {
111
174
  // the primary case for shouldStopBatcher is when we're starting recording after a reset
@@ -118,42 +181,49 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
118
181
  this.batcher.start();
119
182
  }
120
183
 
121
- var resetIdleTimeout = _.bind(function () {
184
+ var resetIdleTimeout = function () {
122
185
  clearTimeout(this.idleTimeoutId);
123
- this.idleTimeoutId = setTimeout(this._onIdleTimeout, this.getConfig('record_idle_timeout_ms'));
124
- }, this);
186
+ var idleTimeoutMs = this.getConfig('record_idle_timeout_ms');
187
+ this.idleTimeoutId = setTimeout(this._onIdleTimeout, idleTimeoutMs);
188
+ this.idleExpires = new Date().getTime() + idleTimeoutMs;
189
+ }.bind(this);
125
190
 
126
191
  var blockSelector = this.getConfig('record_block_selector');
127
192
  if (blockSelector === '' || blockSelector === null) {
128
193
  blockSelector = undefined;
129
194
  }
130
195
 
131
- this._stopRecording = this._rrwebRecord({
132
- 'emit': _.bind(function (ev) {
133
- this.batcher.enqueue(ev);
134
- if (isUserEvent(ev)) {
135
- if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
136
- // start flushing again after user activity
137
- this.batcher.start();
196
+ try {
197
+ this._stopRecording = this._rrwebRecord({
198
+ 'emit': function (ev) {
199
+ if (isUserEvent(ev)) {
200
+ if (this.batcher.stopped && new Date().getTime() - this.replayStartTime >= this.recordMinMs) {
201
+ // start flushing again after user activity
202
+ this.batcher.start();
203
+ }
204
+ resetIdleTimeout();
138
205
  }
139
- resetIdleTimeout();
206
+ // promise only used to await during tests
207
+ this.__enqueuePromise = this.batcher.enqueue(ev);
208
+ }.bind(this),
209
+ 'blockClass': this.getConfig('record_block_class'),
210
+ 'blockSelector': blockSelector,
211
+ 'collectFonts': this.getConfig('record_collect_fonts'),
212
+ 'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
213
+ 'type': 'image/webp',
214
+ 'quality': 0.6
215
+ },
216
+ 'maskAllInputs': true,
217
+ 'maskTextClass': this.getConfig('record_mask_text_class'),
218
+ 'maskTextSelector': this.getConfig('record_mask_text_selector'),
219
+ 'recordCanvas': this.getConfig('record_canvas'),
220
+ 'sampling': {
221
+ 'canvas': 15
140
222
  }
141
- }, this),
142
- 'blockClass': this.getConfig('record_block_class'),
143
- 'blockSelector': blockSelector,
144
- 'collectFonts': this.getConfig('record_collect_fonts'),
145
- 'dataURLOptions': { // canvas image options (https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL)
146
- 'type': 'image/webp',
147
- 'quality': 0.6
148
- },
149
- 'maskAllInputs': true,
150
- 'maskTextClass': this.getConfig('record_mask_text_class'),
151
- 'maskTextSelector': this.getConfig('record_mask_text_selector'),
152
- 'recordCanvas': this.getConfig('record_canvas'),
153
- 'sampling': {
154
- 'canvas': 15
155
- }
156
- });
223
+ });
224
+ } catch (err) {
225
+ this.reportError('Unexpected error when starting rrweb recording.', err);
226
+ }
157
227
 
158
228
  if (typeof this._stopRecording !== 'function') {
159
229
  this.reportError('rrweb failed to start, skipping this recording.');
@@ -164,10 +234,11 @@ SessionRecording.prototype.startRecording = function (shouldStopBatcher) {
164
234
 
165
235
  resetIdleTimeout();
166
236
 
167
- this.maxTimeoutId = setTimeout(_.bind(this._onMaxLengthReached, this), this.recordMaxMs);
237
+ var maxTimeoutMs = this.maxExpires - new Date().getTime();
238
+ this.maxTimeoutId = setTimeout(this._onMaxLengthReached.bind(this), maxTimeoutMs);
168
239
  };
169
240
 
170
- SessionRecording.prototype.stopRecording = function () {
241
+ SessionRecording.prototype.stopRecording = function (skipFlush) {
171
242
  if (!this.isRrwebStopped()) {
172
243
  try {
173
244
  this._stopRecording();
@@ -177,40 +248,91 @@ SessionRecording.prototype.stopRecording = function () {
177
248
  this._stopRecording = null;
178
249
  }
179
250
 
251
+ var flushPromise;
180
252
  if (this.batcher.stopped) {
181
253
  // never got user activity to flush after reset, so just clear the batcher
182
- this.batcher.clear();
183
- } else {
254
+ flushPromise = this.batcher.clear();
255
+ } else if (!skipFlush) {
184
256
  // flush any remaining events from running batcher
185
- this.batcher.flush();
186
- this.batcher.stop();
257
+ flushPromise = this.batcher.flush();
187
258
  }
259
+ this.batcher.stop();
188
260
 
189
261
  clearTimeout(this.idleTimeoutId);
190
262
  clearTimeout(this.maxTimeoutId);
263
+ return flushPromise;
191
264
  };
192
265
 
193
266
  SessionRecording.prototype.isRrwebStopped = function () {
194
267
  return this._stopRecording === null;
195
268
  };
196
269
 
270
+
197
271
  /**
198
272
  * Flushes the current batch of events to the server, but passes an opt-out callback to make sure
199
273
  * we stop recording and dump any queued events if the user has opted out.
200
274
  */
201
275
  SessionRecording.prototype.flushEventsWithOptOut = function (data, options, cb) {
202
- this._flushEvents(data, options, cb, _.bind(this._onOptOut, this));
276
+ var onOptOut = function (code) {
277
+ // addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
278
+ if (code === 0) {
279
+ this.stopRecording();
280
+ cb({error: 'Tracking has been opted out, stopping recording.'});
281
+ }
282
+ }.bind(this);
283
+
284
+ this._flushEvents(data, options, cb, onOptOut);
203
285
  };
204
286
 
205
- SessionRecording.prototype._onOptOut = function (code) {
206
- // addOptOutCheckMixpanelLib invokes this function with code=0 when the user has opted out
207
- if (code === 0) {
208
- this.stopRecording();
287
+ /**
288
+ * @returns {SerializedRecording}
289
+ */
290
+ SessionRecording.prototype.serialize = function () {
291
+ // don't break if mixpanel instance was destroyed at some point
292
+ var tabId;
293
+ try {
294
+ tabId = this._mixpanel.get_tab_id();
295
+ } catch (e) {
296
+ this.reportError('Error getting tab ID for serialization ', e);
297
+ tabId = null;
209
298
  }
299
+
300
+ return {
301
+ 'replayId': this.replayId,
302
+ 'seqNo': this.seqNo,
303
+ 'replayStartTime': this.replayStartTime,
304
+ 'batchStartUrl': this.batchStartUrl,
305
+ 'replayStartUrl': this.replayStartUrl,
306
+ 'idleExpires': this.idleExpires,
307
+ 'maxExpires': this.maxExpires,
308
+ 'tabId': tabId,
309
+ };
310
+ };
311
+
312
+
313
+ /**
314
+ * @static
315
+ * @param {SerializedRecording} serializedRecording
316
+ * @param {SessionRecordingOptions} options
317
+ * @returns {SessionRecording}
318
+ */
319
+ SessionRecording.deserialize = function (serializedRecording, options) {
320
+ var recording = new SessionRecording(_.extend({}, options, {
321
+ replayId: serializedRecording['replayId'],
322
+ batchStartUrl: serializedRecording['batchStartUrl'],
323
+ replayStartUrl: serializedRecording['replayStartUrl'],
324
+ idleExpires: serializedRecording['idleExpires'],
325
+ maxExpires: serializedRecording['maxExpires'],
326
+ replayStartTime: serializedRecording['replayStartTime'],
327
+ seqNo: serializedRecording['seqNo'],
328
+ sharedLockStorage: options.sharedLockStorage,
329
+ }));
330
+
331
+ return recording;
210
332
  };
211
333
 
212
334
  SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, reqBody, callback) {
213
- var onSuccess = _.bind(function (response, responseBody) {
335
+ var onSuccess = function (response, responseBody) {
214
336
  // Update batch specific props only if the request was successful to guarantee ordering.
215
337
  // RequestBatcher will always flush the next batch after the previous one succeeds.
216
338
  // extra check to see if the replay ID has changed so that we don't increment the seqNo on the wrong replay
@@ -218,13 +340,15 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
218
340
  this.seqNo++;
219
341
  this.batchStartUrl = _.info.currentUrl();
220
342
  }
343
+
344
+ this._onBatchSent();
221
345
  callback({
222
346
  status: 0,
223
347
  httpStatusCode: response.status,
224
348
  responseBody: responseBody,
225
349
  retryAfter: response.headers.get('Retry-After')
226
350
  });
227
- }, this);
351
+ }.bind(this);
228
352
 
229
353
  window['fetch'](this.getConfig('api_host') + '/' + this.getConfig('api_routes')['record'] + '?' + new URLSearchParams(reqParams), {
230
354
  'method': 'POST',
@@ -245,21 +369,36 @@ SessionRecording.prototype._sendRequest = function(currentReplayId, reqParams, r
245
369
  };
246
370
 
247
371
  SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (data, options, callback) {
248
- const numEvents = data.length;
372
+ var numEvents = data.length;
249
373
 
250
374
  if (numEvents > 0) {
251
375
  var replayId = this.replayId;
376
+
252
377
  // each rrweb event has a timestamp - leverage those to get time properties
253
- var batchStartTime = data[0].timestamp;
254
- if (this.seqNo === 0 || !this.replayStartTime) {
255
- // extra safety net so that we don't send a null replay start time
256
- if (this.seqNo !== 0) {
257
- this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
378
+ var batchStartTime = Infinity;
379
+ var batchEndTime = -Infinity;
380
+ var hasFullSnapshot = false;
381
+ for (var i = 0; i < numEvents; i++) {
382
+ batchStartTime = Math.min(batchStartTime, data[i].timestamp);
383
+ batchEndTime = Math.max(batchEndTime, data[i].timestamp);
384
+ if (data[i].type === EventType.FullSnapshot) {
385
+ hasFullSnapshot = true;
258
386
  }
387
+ }
259
388
 
389
+ if (this.seqNo === 0) {
390
+ if (!hasFullSnapshot) {
391
+ callback({error: 'First batch does not contain a full snapshot. Aborting recording.'});
392
+ this.stopRecording(true);
393
+ return;
394
+ }
395
+ this.replayStartTime = batchStartTime;
396
+ } else if (!this.replayStartTime) {
397
+ this.reportError('Replay start time not set but seqNo is not 0. Using current batch start time as a fallback.');
260
398
  this.replayStartTime = batchStartTime;
261
399
  }
262
- var replayLengthMs = data[numEvents - 1].timestamp - this.replayStartTime;
400
+
401
+ var replayLengthMs = batchEndTime - this.replayStartTime;
263
402
 
264
403
  var reqParams = {
265
404
  '$current_url': this.batchStartUrl,
@@ -290,10 +429,10 @@ SessionRecording.prototype._flushEvents = addOptOutCheckMixpanelLib(function (da
290
429
  var gzipStream = jsonStream.pipeThrough(new CompressionStream('gzip'));
291
430
  new Response(gzipStream)
292
431
  .blob()
293
- .then(_.bind(function(compressedBlob) {
432
+ .then(function(compressedBlob) {
294
433
  reqParams['format'] = 'gzip';
295
434
  this._sendRequest(replayId, reqParams, compressedBlob, callback);
296
- }, this));
435
+ }.bind(this));
297
436
  } else {
298
437
  reqParams['format'] = 'body';
299
438
  this._sendRequest(replayId, reqParams, eventsJson, callback);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @param {import('./session-recording').SerializedRecording} serializedRecording
3
+ * @returns {boolean}
4
+ */
5
+ var isRecordingExpired = function(serializedRecording) {
6
+ var now = Date.now();
7
+ return !serializedRecording || now > serializedRecording['maxExpires'] || now > serializedRecording['idleExpires'];
8
+ };
9
+
10
+ var RECORD_ENQUEUE_THROTTLE_MS = 250;
11
+
12
+ export { isRecordingExpired, RECORD_ENQUEUE_THROTTLE_MS};
@@ -20,7 +20,9 @@ var RequestBatcher = function(storageKey, options) {
20
20
  errorReporter: _.bind(this.reportError, this),
21
21
  queueStorage: options.queueStorage,
22
22
  sharedLockStorage: options.sharedLockStorage,
23
- usePersistence: options.usePersistence
23
+ sharedLockTimeoutMS: options.sharedLockTimeoutMS,
24
+ usePersistence: options.usePersistence,
25
+ enqueueThrottleMs: options.enqueueThrottleMs
24
26
  });
25
27
 
26
28
  this.libConfig = options.libConfig;
@@ -42,6 +44,8 @@ var RequestBatcher = function(storageKey, options) {
42
44
  // as long as the queue is not empty. This is useful for high-frequency events like Session Replay where we might end up
43
45
  // in a request loop and get ratelimited by the server.
44
46
  this.flushOnlyOnInterval = options.flushOnlyOnInterval || false;
47
+
48
+ this._flushPromise = null;
45
49
  };
46
50
 
47
51
  /**
@@ -101,7 +105,7 @@ RequestBatcher.prototype.scheduleFlush = function(flushMS) {
101
105
  if (!this.stopped) { // don't schedule anymore if batching has been stopped
102
106
  this.timeoutID = setTimeout(_.bind(function() {
103
107
  if (!this.stopped) {
104
- this.flush();
108
+ this._flushPromise = this.flush();
105
109
  }
106
110
  }, this), this.flushInterval);
107
111
  }
@@ -1,5 +1,6 @@
1
1
  import { SharedLock } from './shared-lock';
2
- import { cheap_guid, console_with_prefix, localStorageSupported, JSONParse, JSONStringify, _ } from './utils'; // eslint-disable-line camelcase
2
+ import { batchedThrottle, cheap_guid, console_with_prefix, localStorageSupported, _ } from './utils'; // eslint-disable-line camelcase
3
+ import { window } from './window';
3
4
  import { LocalStorageWrapper } from './storage/local-storage';
4
5
  import { Promise } from './promise-polyfill';
5
6
 
@@ -27,8 +28,10 @@ var RequestQueue = function (storageKey, options) {
27
28
  this.usePersistence = options.usePersistence;
28
29
  if (this.usePersistence) {
29
30
  this.queueStorage = options.queueStorage || new LocalStorageWrapper();
30
- this.lock = new SharedLock(storageKey, { storage: options.sharedLockStorage || window.localStorage });
31
- this.queueStorage.init();
31
+ this.lock = new SharedLock(storageKey, {
32
+ storage: options.sharedLockStorage || window.localStorage,
33
+ timeoutMS: options.sharedLockTimeoutMS,
34
+ });
32
35
  }
33
36
  this.reportError = options.errorReporter || _.bind(logger.error, logger);
34
37
 
@@ -36,6 +39,14 @@ var RequestQueue = function (storageKey, options) {
36
39
 
37
40
  this.memQueue = [];
38
41
  this.initialized = false;
42
+
43
+ if (options.enqueueThrottleMs) {
44
+ this.enqueuePersisted = batchedThrottle(_.bind(this._enqueuePersisted, this), options.enqueueThrottleMs);
45
+ } else {
46
+ this.enqueuePersisted = _.bind(function (queueEntry) {
47
+ return this._enqueuePersisted([queueEntry]);
48
+ }, this);
49
+ }
39
50
  };
40
51
 
41
52
  RequestQueue.prototype.ensureInit = function () {
@@ -78,36 +89,39 @@ RequestQueue.prototype.enqueue = function (item, flushInterval) {
78
89
  this.memQueue.push(queueEntry);
79
90
  return Promise.resolve(true);
80
91
  } else {
92
+ return this.enqueuePersisted(queueEntry);
93
+ }
94
+ };
81
95
 
82
- var enqueueItem = _.bind(function () {
83
- return this.ensureInit()
84
- .then(_.bind(function () {
85
- return this.readFromStorage();
86
- }, this))
87
- .then(_.bind(function (storedQueue) {
88
- storedQueue.push(queueEntry);
89
- return this.saveToStorage(storedQueue);
90
- }, this))
91
- .then(_.bind(function (succeeded) {
92
- // only add to in-memory queue when storage succeeds
93
- if (succeeded) {
94
- this.memQueue.push(queueEntry);
95
- }
96
- return succeeded;
97
- }, this))
98
- .catch(_.bind(function (err) {
99
- this.reportError('Error enqueueing item', err, item);
100
- return false;
101
- }, this));
102
- }, this);
96
+ RequestQueue.prototype._enqueuePersisted = function (queueEntries) {
97
+ var enqueueItem = _.bind(function () {
98
+ return this.ensureInit()
99
+ .then(_.bind(function () {
100
+ return this.readFromStorage();
101
+ }, this))
102
+ .then(_.bind(function (storedQueue) {
103
+ return this.saveToStorage(storedQueue.concat(queueEntries));
104
+ }, this))
105
+ .then(_.bind(function (succeeded) {
106
+ // only add to in-memory queue when storage succeeds
107
+ if (succeeded) {
108
+ this.memQueue = this.memQueue.concat(queueEntries);
109
+ }
103
110
 
104
- return this.lock
105
- .withLock(enqueueItem, this.pid)
111
+ return succeeded;
112
+ }, this))
106
113
  .catch(_.bind(function (err) {
107
- this.reportError('Error acquiring storage lock', err);
114
+ this.reportError('Error enqueueing items', err, queueEntries);
108
115
  return false;
109
116
  }, this));
110
- }
117
+ }, this);
118
+
119
+ return this.lock
120
+ .withLock(enqueueItem, this.pid)
121
+ .catch(_.bind(function (err) {
122
+ this.reportError('Error acquiring storage lock', err);
123
+ return false;
124
+ }, this));
111
125
  };
112
126
 
113
127
  /**
@@ -128,7 +142,7 @@ RequestQueue.prototype.fillBatch = function (batchSize) {
128
142
  }, this))
129
143
  .then(_.bind(function (storedQueue) {
130
144
  if (storedQueue.length) {
131
- // item IDs already in batch; don't duplicate out of storage
145
+ // item IDs already in batch; don't duplicate out of storage
132
146
  var idsInBatch = {}; // poor man's Set
133
147
  _.each(batch, function (item) {
134
148
  idsInBatch[item['id']] = true;
@@ -215,7 +229,7 @@ RequestQueue.prototype.removeItemsByID = function (ids) {
215
229
  .withLock(removeFromStorage, this.pid)
216
230
  .catch(_.bind(function (err) {
217
231
  this.reportError('Error acquiring storage lock', err);
218
- if (!localStorageSupported(this.queueStorage.storage, true)) {
232
+ if (!localStorageSupported(this.lock.storage, true)) {
219
233
  // Looks like localStorage writes have stopped working sometime after
220
234
  // initialization (probably full), and so nobody can acquire locks
221
235
  // anymore. Consider it temporarily safe to remove items without the
@@ -303,7 +317,6 @@ RequestQueue.prototype.readFromStorage = function () {
303
317
  }, this))
304
318
  .then(_.bind(function (storageEntry) {
305
319
  if (storageEntry) {
306
- storageEntry = JSONParse(storageEntry);
307
320
  if (!_.isArray(storageEntry)) {
308
321
  this.reportError('Invalid storage entry:', storageEntry);
309
322
  storageEntry = null;
@@ -321,16 +334,9 @@ RequestQueue.prototype.readFromStorage = function () {
321
334
  * Serialize the given items array to localStorage.
322
335
  */
323
336
  RequestQueue.prototype.saveToStorage = function (queue) {
324
- try {
325
- var serialized = JSONStringify(queue);
326
- } catch (err) {
327
- this.reportError('Error serializing queue', err);
328
- return Promise.resolve(false);
329
- }
330
-
331
337
  return this.ensureInit()
332
338
  .then(_.bind(function () {
333
- return this.queueStorage.setItem(this.storageKey, serialized);
339
+ return this.queueStorage.setItem(this.storageKey, queue);
334
340
  }, this))
335
341
  .then(function () {
336
342
  return true;
@@ -1,5 +1,6 @@
1
1
  import { Promise } from './promise-polyfill';
2
2
  import { console_with_prefix, localStorageSupported, _ } from './utils'; // eslint-disable-line camelcase
3
+ import { window } from './window';
3
4
 
4
5
  var logger = console_with_prefix('lock');
5
6
 
@@ -42,7 +43,6 @@ SharedLock.prototype.withLock = function(lockedCB, pid) {
42
43
  return new Promise(_.bind(function (resolve, reject) {
43
44
  var i = pid || (new Date().getTime() + '|' + Math.random());
44
45
  var startTime = new Date().getTime();
45
-
46
46
  var key = this.storageKey;
47
47
  var pollIntervalMS = this.pollIntervalMS;
48
48
  var timeoutMS = this.timeoutMS;