Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes a bug with cacheFirst handler + maxAgeSeconds + CORS request #694

Merged
merged 4 commits into from
Jul 26, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ class CacheExpirationPlugin extends CacheExpiration {
*
* @private
* @param {Object} input
* @param {string} input.cacheName Name of the cache the responses belong to.
* @param {Response} input.cachedResponse The `Response` object that's been
* read from a cache and whose freshness should be checked.
* @param {Number} [input.now] A timestamp. Defaults to the current time.
* @return {Response|null} Either the `cachedResponse`, if it's fresh, or
* `null` if the `Response` is older than `maxAgeSeconds`.
* @return {Response} Either the `cachedResponse`, if it's
* fresh, or `null` if the `Response` is older than `maxAgeSeconds`.
*/
cacheWillMatch({cachedResponse, now} = {}) {
if (this.isResponseFresh({cachedResponse, now})) {
cacheWillMatch({cacheName, cachedResponse, now} = {}) {
if (this.isResponseFresh({cacheName, cachedResponse, now})) {
return cachedResponse;
}

Expand Down
45 changes: 35 additions & 10 deletions packages/workbox-cache-expiration/src/lib/cache-expiration.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,28 +126,31 @@ class CacheExpiration {
* `Date` header and the `maxAgeSeconds` parameter passed into the
* constructor.
*
* The general approach is to default to fresh unless proven otherwise.
*
* If `maxAgeSeconds` or the `Date` header is not set then it will
* default to returning `true`, i.e. the response is still fresh and should
* be used.
*
* @param {Object} input
* @param {string} input.cacheName Name of the cache the responses belong to.
* @param {Response} input.cachedResponse The `Response` object that's been
* read from a cache and whose freshness should be checked.
* @param {Number} [input.now] A timestamp.
*
* Defaults to the current time.
* @return {boolean} Either `true` if the response is fresh, or `false` if the
* `Response` is older than `maxAgeSeconds` and should no longer be
* used.
* @return {boolean} Either `true` if the response is fresh, or
* `false` if the `Response` is older than `maxAgeSeconds` and should no
* longer be used.
*
* @example
* expirationPlugin.isResponseFresh({
* cachedResponse: responseFromCache
* });
*/
isResponseFresh({cachedResponse, now} = {}) {
isResponseFresh({cacheName, cachedResponse, now} = {}) {
// Only bother checking for freshness if we have a valid response and if
// maxAgeSeconds is set. Otherwise, skip the check and always return true.
// maxAgeSeconds is set.
if (cachedResponse && this.maxAgeSeconds) {
isInstance({cachedResponse}, Response);

Expand All @@ -158,16 +161,38 @@ class CacheExpiration {
}

const parsedDate = new Date(dateHeader);
const headerTime = parsedDate.getTime();
// If the Date header was invalid for some reason, parsedDate.getTime()
// will return NaN, and the comparison will always be false. That means
// that an invalid date will be treated as if the response is fresh.
if ((parsedDate.getTime() + (this.maxAgeSeconds * 1000)) < now) {
// Only return false if all the conditions are met.
return false;
// will return NaN. We want to treat that as a fresh response, since we
// assume fresh unless proven otherwise.
if (isNaN(headerTime)) {
return true;
}

// If we have a valid headerTime, then our response is fresh iff the
// headerTime plus maxAgeSeconds is greater than the current time.
return (headerTime + (this.maxAgeSeconds * 1000)) > now;
} else {
// TODO (jeffposnick): Change this method interface to be async, and
// check for the IDB for the specific URL in order to determine
// freshness when Date is not available.

// If there is no Date header (i.e. if this is a cross-origin response),
// then we don't know for sure whether the response is fresh or not.
// One thing we can do is trigger cache expiration, which will clean up
// any old responses based on IDB timestamps, and ensure that when a
// cache-first handler is used, stale responses will eventually be
// replaced (though not until the *next* request is made).
// See https://github.com/GoogleChrome/workbox/issues/691
this.expireEntries({cacheName, now});
// Return true, since otherwise a cross-origin cached response without
// a Date header would *never* be considered valid.
return true;
}
}

// If either cachedResponse or maxAgeSeconds wasn't set, then the response
// is "trivially" fresh, so return true.
return true;
}

Expand Down
41 changes: 41 additions & 0 deletions packages/workbox-cache-expiration/test/browser/end-to-end.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ describe(`End to End Test of Cache Expiration`, function() {

beforeEach(() => goog.swUtils.cleanState());

async function asyncDelay(seconds) {
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}

it(`should work properly when there are many simultaneous requests`, async function() {
const iframe = await goog.swUtils.controlledBySW(
'/packages/workbox-cache-expiration/test/static/cache-expiration.js');
Expand Down Expand Up @@ -37,4 +41,41 @@ describe(`End to End Test of Cache Expiration`, function() {

expect(keys).to.have.length(EXPECTED_CACHE_SIZE);
});

it(`should work properly when a cache-first strategy + maxAgeSeconds is used, and responses lack a Date header`, async function() {
// See the comment later about a source of potential flakiness.
this.retries(2);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should probably set the timeout of this test case to 5 or 6 with this.timeout(6 * 1000);

this.timeout(6 * 1000);

const iframe = await goog.swUtils.controlledBySW(
'/packages/workbox-cache-expiration/test/static/cache-first-max-age-seconds.js');

const currentUrl = new URL(location);
const nextPort = parseInt(currentUrl.port) + 1;
const corsOrigin = `${currentUrl.protocol}//${currentUrl.hostname}:${nextPort}`;
const testUrl = new URL('/__echo/cors-no-cache/test', corsOrigin);

const firstResponse = await iframe.contentWindow.fetch(testUrl);
const firstResponseBody = await firstResponse.text();

// The service worker should be using a maxAgeSeconds of 1, so skip ahead
// 1.5 seconds. We can't use sinon's fake clock here, since we need a
// consistent clock between the iframe's scope and the service worker scope.
await asyncDelay(1.5);
const secondResponse = await iframe.contentWindow.fetch(testUrl);
const secondResponseBody = await secondResponse.text();
// Since we're using a cache-first policy, the expiration happens after the
// previous entry had already been read from cache. So the first and second
// responses should be the same.
expect(firstResponseBody).to.eql(secondResponseBody);

// POTENTIALLY FLAKY: Wait another 2 seconds, after which point the cache
// expiration logic will have hopefully completed.
await asyncDelay(2);
const thirdResponse = await iframe.contentWindow.fetch(testUrl);
const thirdResponseBody = await thirdResponse.text();
// By the time the third response is read, the cache expiration will have
// completed, so this one should be different.
expect(firstResponseBody).not.to.eql(thirdResponseBody);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
importScripts('/__test/bundle/workbox-runtime-caching');
importScripts('/__test/bundle/workbox-cache-expiration');

self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()));
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));

const cacheExpirationPlugin= new workbox.cacheExpiration.CacheExpirationPlugin({
maxAgeSeconds: 1,
});
const requestWrapper = new workbox.runtimeCaching.RequestWrapper({
cacheName: 'cache-first-max-age-seconds',
plugins: [cacheExpirationPlugin],
});
const handler = new workbox.runtimeCaching.CacheFirst({
requestWrapper,
waitOnCache: true,
});

self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/__echo/cors-no-cache')) {
event.respondWith(handler.handle({event}));
}
});
6 changes: 4 additions & 2 deletions packages/workbox-runtime-caching/src/lib/request-wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ class RequestWrapper {

if (this.plugins.has('cacheWillMatch')) {
const plugin = this.plugins.get('cacheWillMatch')[0];
cachedResponse = plugin.cacheWillMatch({request, cache, cachedResponse,
matchOptions: this.matchOptions});
cachedResponse = plugin.cacheWillMatch({
request, cache, cachedResponse,
matchOptions: this.matchOptions, cacheName: this.cacheName,
});
}

return cachedResponse;
Expand Down
8 changes: 8 additions & 0 deletions utils/server-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ class ServerInstance {
res.send(`${req.params.file}-${Date.now()}`);
});

this._app.all('/__echo/cors-no-cache/:file', function(req, res) {
res.setHeader('Access-Control-Allow-Methods',
'POST, GET, PUT, DELETE, OPTIONS');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Access-Control-Allow-Origin', '*');
res.send(`${req.params.file}-${Date.now()}`);
});

this._app.all('/__echo/date-with-cors/:file', function(req, res) {
res.setHeader('Access-Control-Allow-Methods',
'POST, GET, PUT, DELETE, OPTIONS');
Expand Down