Skip to content

Instantly share code, notes, and snippets.

@steveworkman
Last active May 25, 2017 17:29
Show Gist options
  • Save steveworkman/90936242b21c7b85a43525fd911c1242 to your computer and use it in GitHub Desktop.
Save steveworkman/90936242b21c7b85a43525fd911c1242 to your computer and use it in GitHub Desktop.
Attempt to turn the offline-analytics example into a re-usable bit of code that is also testable. Each function now uses promises to keep state
// IndexedDB properties
var idbDatabase;
const IDB_VERSION = 1,
STOP_RETRYING_AFTER = (1000 * 60 * 60 * 24), // One day, in milliseconds.
STORE_NAME = 'urls',
IDB_NAME = 'offline-analytics';
// These URLs should always be fetched from the server, or page views don't count!
const analyticsDomains = [
{ host: 'omniture.adobe.com', pathStart: '/b/ss'}, // Omniture
{ host: 'www.google-analytics.com', pathStart: '/collect' }, // Google Analytics
{ host: 'c.go-mpulse.net', pathStart: '/boomerang' } // mPulse
];
// This code is pretty much word-for-word from https://googlechrome.github.io/samples/service-worker/offline-analytics/service-worker.js
// I've added promises to it as well, because it's not bullet-proof in the sample form
// This is basic boilerplate for interacting with IndexedDB. Adapted from
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
// However, once I started doing unit tests I discovered a race condition, and the database wasn't always open
// So, this is now done using promises.
function openDatabase(storeName=STORE_NAME) {
return new Promise((resolve, reject) => {
var indexedDBOpenRequest = indexedDB.open(IDB_NAME, IDB_VERSION);
// This top-level error handler will be invoked any time there's an IndexedDB-related error.
indexedDBOpenRequest.onerror = function(error) {
reject(error);
};
// This should only execute if there's a need to create a new database for the given IDB_VERSION.
indexedDBOpenRequest.onupgradeneeded = function() {
this.result.createObjectStore(storeName, {keyPath: 'url'});
};
// This will execute each time the database is opened.
indexedDBOpenRequest.onsuccess = function() {
idbDatabase = this.result;
resolve(idbDatabase);
};
});
}
// Runs once the service worker is brought back to life
function openDatabaseAndReplayRequests() {
openDatabase()
.then(replayAnalyticsRequests)
.catch(error => console.error(error));
}
// Helper method to get the object store that we care about.
// This cannot be written with browser-native promises and work across browsers
// because only Chrome uses micro-transactions for promise events
// All other browsers make use of the event loop, and because of that, transactions time out when
// they resolve. So, this is an ES5 callback
function getObjectStore(storeName, mode, callback) {
if (idbDatabase) {
callback(null, idbDatabase.transaction(storeName, mode).objectStore(storeName));
} else {
// database isn't open yet
openDatabase(storeName)
.then(() => {
callback(null, idbDatabase.transaction(storeName, mode).objectStore(storeName));
}).catch((error) => { callback(error);});
}
}
// Tried to replay the analytics requests
function replayAnalyticsRequests() {
var savedRequests = [];
getObjectStore(STORE_NAME, 'readonly', function(err, store) {
store.openCursor().onsuccess = function(event) {
// See https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB#Using_a_cursor
var cursor = event.target.result;
if (cursor) {
// Keep moving the cursor forward and collecting saved requests.
savedRequests.push(cursor.value);
cursor.continue();
} else {
// At this point, we have all the saved requests.
console.log(`About to replay ${savedRequests.length} saved Omniture requests`);
savedRequests.forEach(function(savedRequest) {
var queueTime = Date.now() - savedRequest.timestamp;
console.log(`Queue time: ${queueTime}`);
if (queueTime > STOP_RETRYING_AFTER) {
getObjectStore(STORE_NAME, 'readwrite', function(retryErr, rwstore) { if (retryErr) return; rwstore.delete(savedRequest.url); });
console.log(`Request has been queued for ${queueTime} milliseconds. No longer attempting to replay.`);
} else {
// The qt= URL parameter specifies the time delta in between right now, and when the
// /collect request was initially intended to be sent. See
// https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt
var requestUrl = savedRequest.url + '&qt=' + queueTime;
console.log('Replaying', requestUrl);
fetch(requestUrl).then(function(response) {
if (response.status < 400) {
// If sending the /collect request was successful, then remove it from the IndexedDB.
getObjectStore(STORE_NAME, 'readwrite', function(replayErr, rwstore) { if (replayErr) return; rwstore.delete(savedRequest.url); });
console.log(`Replayed ${savedRequest.url} successfully.`);
} else {
// This will be triggered if, e.g., Google Analytics returns a HTTP 50x response.
// The request will be replayed the next time the service worker starts up.
console.error(' Replaying failed:', response);
}
}).catch(function(error) {
// This will be triggered if the network is still down. The request will be replayed again
// the next time the service worker starts up.
console.error(' Replaying failed:', error);
});
}
});
}
};
});
}
// Open the IndexedDB and check for requests to replay each time the service worker starts up.
// Since the service worker is terminated fairly frequently, it should start up again for most
// page navigations. It also might start up if it's used in a background sync or a push
// notification context.
openDatabaseAndReplayRequests();
// Checks a URL to see if it's a request to one of our analytics providers
// If it is, then save it to the IDB
function checkForAnalyticsRequest(requestUrl) {
// Construct a URL object (https://developer.mozilla.org/en-US/docs/Web/API/URL.URL)
// to make it easier to check the various components without dealing with string parsing.
var url = new URL(requestUrl);
if (analyticsDomains.some(fetchDomain => url.hostname === fetchDomain.host && url.pathname.startsWith(fetchDomain.pathStart))) {
console.log(`Storing ${requestUrl} request in IndexedDB to be replayed later.`);
saveAnalyticsRequest(requestUrl);
}
}
// Saves a request to the IDB
function saveAnalyticsRequest(requestUrl) {
getObjectStore(STORE_NAME, 'readwrite', function(err, store) {
store.add({
url: requestUrl,
timestamp: Date.now()
});
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment