feat(service-worker): use `ignoreVary: true` when retrieving responses from cache (#34663)

The Angular ServiceWorker always uses a copy of the request without
headers for caching assets (in order to avoid issues with opaque
responses). Therefore, it was previously not possible to retrieve
resources from the cache if the response contained [Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary) headers.

In addition to that, `Vary` headers do not work in all browsers (or work
differently) and may not work as intended with ServiceWorker caches. See
[this article](https://www.smashingmagazine.com/2017/11/understanding-vary-header) and the linked resources for more info.

This commit avoids the aforementioned issues by making sure the Angular
ServiceWorker always sets the `ignoreVary` option passed to
[Cache#match()](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) to `true`. This allows the ServiceWorker to correctly
retrieve cached responses with `Vary` headers, which was previously not
possible.

Fixes #36638

BREAKING CHANGE:

Previously, [Vary](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary)
headers would be taken into account when retrieving resources from the
cache, completely preventing the retrieval of cached assets (due to
ServiceWorker implementation details) and leading to unpredictable
behavior due to inconsistent/buggy implementations in different
browsers.

Now, `Vary` headers are ignored when retrieving resources from the
ServiceWorker caches, which can result in resources being retrieved even
when their headers are different. If your application needs to
differentiate its responses based on request headers, please make sure
the Angular ServiceWorker is [configured](https://angular.io/guide/service-worker-config)
to avoid caching the affected resources.

PR Close #34663
This commit is contained in:
Maximilian Koeller 2020-04-29 16:59:15 +02:00 committed by Alex Rickabaugh
parent dc9f4b994e
commit ee35e223a7
7 changed files with 59 additions and 9 deletions

View File

@ -153,6 +153,9 @@ function withOrderedKeys<T extends {[key: string]: any}>(unorderedObj: T): T {
} }
function buildCacheQueryOptions(inOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>): function buildCacheQueryOptions(inOptions?: Pick<CacheQueryOptions, 'ignoreSearch'>):
CacheQueryOptions|undefined { CacheQueryOptions {
return inOptions; return {
ignoreVary: true,
...inOptions,
};
} }

View File

@ -92,7 +92,7 @@ describe('Generator', () => {
'\\/some\\/url\\?with\\+escaped\\+chars', '\\/some\\/url\\?with\\+escaped\\+chars',
'\\/test\\/relative\\/[^/]*\\.txt', '\\/test\\/relative\\/[^/]*\\.txt',
], ],
cacheQueryOptions: undefined, cacheQueryOptions: {ignoreVary: true}
}], }],
dataGroups: [{ dataGroups: [{
name: 'other', name: 'other',
@ -106,7 +106,7 @@ describe('Generator', () => {
maxAge: 259200000, maxAge: 259200000,
timeoutMs: 60000, timeoutMs: 60000,
version: 1, version: 1,
cacheQueryOptions: undefined, cacheQueryOptions: {ignoreVary: true}
}], }],
navigationUrls: [ navigationUrls: [
{positive: true, regex: '^\\/included\\/absolute\\/.*$'}, {positive: true, regex: '^\\/included\\/absolute\\/.*$'},
@ -229,7 +229,7 @@ describe('Generator', () => {
'/main.js', '/main.js',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreSearch: true} cacheQueryOptions: {ignoreSearch: true, ignoreVary: true}
}], }],
dataGroups: [{ dataGroups: [{
name: 'other', name: 'other',
@ -241,7 +241,7 @@ describe('Generator', () => {
maxAge: 259200000, maxAge: 259200000,
timeoutMs: 60000, timeoutMs: 60000,
version: 1, version: 1,
cacheQueryOptions: {ignoreSearch: false} cacheQueryOptions: {ignoreSearch: false, ignoreVary: true}
}], }],
navigationUrls: [ navigationUrls: [
{positive: true, regex: '^\\/.*$'}, {positive: true, regex: '^\\/.*$'},

View File

@ -44,6 +44,7 @@ const manifest: Manifest = {
updateMode: 'prefetch', updateMode: 'prefetch',
urls: ['/only.txt'], urls: ['/only.txt'],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}], }],
navigationUrls: [], navigationUrls: [],
hashTable: tmpHashTableForFs(dist), hashTable: tmpHashTableForFs(dist),
@ -60,6 +61,7 @@ const manifestUpdate: Manifest = {
updateMode: 'prefetch', updateMode: 'prefetch',
urls: ['/only.txt'], urls: ['/only.txt'],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}], }],
navigationUrls: [], navigationUrls: [],
hashTable: tmpHashTableForFs(distUpdate), hashTable: tmpHashTableForFs(distUpdate),

View File

@ -56,6 +56,7 @@ const manifest: Manifest = {
'/bar.txt', '/bar.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}, },
], ],
dataGroups: [ dataGroups: [
@ -67,7 +68,7 @@ const manifest: Manifest = {
timeoutMs: 1000, timeoutMs: 1000,
maxAge: 5000, maxAge: 5000,
version: 1, version: 1,
cacheQueryOptions: {ignoreSearch: true}, cacheQueryOptions: {ignoreVary: true, ignoreSearch: true},
}, },
{ {
name: 'testRefresh', name: 'testRefresh',
@ -78,6 +79,7 @@ const manifest: Manifest = {
refreshAheadMs: 1000, refreshAheadMs: 1000,
maxAge: 5000, maxAge: 5000,
version: 1, version: 1,
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'testFresh', name: 'testFresh',
@ -87,6 +89,7 @@ const manifest: Manifest = {
timeoutMs: 1000, timeoutMs: 1000,
maxAge: 5000, maxAge: 5000,
version: 1, version: 1,
cacheQueryOptions: {ignoreVary: true},
}, },
], ],
navigationUrls: [], navigationUrls: [],
@ -201,7 +204,8 @@ describe('data cache', () => {
await makeRequest(scope, '/api/a'); await makeRequest(scope, '/api/a');
// the second one will be loaded from the cache // the second one will be loaded from the cache
await makeRequest(scope, '/api/a'); await makeRequest(scope, '/api/a');
expect(matchSpy).toHaveBeenCalledWith(new MockRequest('/api/a'), {ignoreSearch: true}); expect(matchSpy).toHaveBeenCalledWith(
new MockRequest('/api/a'), {ignoreVary: true, ignoreSearch: true});
}); });
it('still matches if search differs but ignoreSearch is enabled', async () => { it('still matches if search differs but ignoreSearch is enabled', async () => {
@ -312,6 +316,27 @@ describe('data cache', () => {
expect(await res2).toBe(''); expect(await res2).toBe('');
}); });
it('CacheQueryOptions are passed through when falling back to cache', async () => {
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
await makeRequest(scope, '/fresh/data');
server.clearRequests();
scope.updateServerState(serverUpdate);
serverUpdate.pause();
const [res, done] = makePendingRequest(scope, '/fresh/data');
await serverUpdate.nextRequest;
// Since the network request doesn't return within the timeout of 1,000ms,
// this should return cached data.
scope.advance(2000);
await res;
expect(matchSpy).toHaveBeenCalledWith(new MockRequest('/fresh/data'), {ignoreVary: true});
// Unpausing allows the worker to continue with caching.
serverUpdate.unpause();
await done;
});
}); });
}); });
})(); })();

View File

@ -70,6 +70,7 @@ const brokenManifest: Manifest = {
'/foo.txt', '/foo.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}], }],
dataGroups: [], dataGroups: [],
navigationUrls: processNavigationUrls(''), navigationUrls: processNavigationUrls(''),
@ -89,6 +90,7 @@ const brokenLazyManifest: Manifest = {
'/foo.txt', '/foo.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'lazy-assets', name: 'lazy-assets',
@ -98,6 +100,7 @@ const brokenLazyManifest: Manifest = {
'/bar.txt', '/bar.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}, },
], ],
dataGroups: [], dataGroups: [],
@ -143,6 +146,7 @@ const manifest: Manifest = {
patterns: [ patterns: [
'/unhashed/.*', '/unhashed/.*',
], ],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'other', name: 'other',
@ -153,6 +157,7 @@ const manifest: Manifest = {
'/qux.txt', '/qux.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'lazy_prefetch', name: 'lazy_prefetch',
@ -165,6 +170,7 @@ const manifest: Manifest = {
'/lazy/unchanged2.txt', '/lazy/unchanged2.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
} }
], ],
dataGroups: [ dataGroups: [
@ -177,6 +183,7 @@ const manifest: Manifest = {
patterns: [ patterns: [
'/api/.*', '/api/.*',
], ],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'api-static', name: 'api-static',
@ -187,6 +194,7 @@ const manifest: Manifest = {
patterns: [ patterns: [
'/api-static/.*', '/api-static/.*',
], ],
cacheQueryOptions: {ignoreVary: true},
}, },
], ],
navigationUrls: processNavigationUrls(''), navigationUrls: processNavigationUrls(''),
@ -213,6 +221,7 @@ const manifestUpdate: Manifest = {
patterns: [ patterns: [
'/unhashed/.*', '/unhashed/.*',
], ],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'other', name: 'other',
@ -223,6 +232,7 @@ const manifestUpdate: Manifest = {
'/qux.txt', '/qux.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
}, },
{ {
name: 'lazy_prefetch', name: 'lazy_prefetch',
@ -235,6 +245,7 @@ const manifestUpdate: Manifest = {
'/lazy/unchanged2.txt', '/lazy/unchanged2.txt',
], ],
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true},
} }
], ],
navigationUrls: processNavigationUrls( navigationUrls: processNavigationUrls(

View File

@ -9,6 +9,8 @@
import {PrefetchAssetGroup} from '../src/assets'; import {PrefetchAssetGroup} from '../src/assets';
import {CacheDatabase} from '../src/db-cache'; import {CacheDatabase} from '../src/db-cache';
import {IdleScheduler} from '../src/idle'; import {IdleScheduler} from '../src/idle';
import {MockCache} from '../testing/cache';
import {MockRequest} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTable, tmpManifestSingleAssetGroup} from '../testing/mock'; import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTable, tmpManifestSingleAssetGroup} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope'; import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
@ -19,7 +21,7 @@ if (!SwTestHarness.envIsSupported()) {
} }
const dist = new MockFileSystemBuilder() const dist = new MockFileSystemBuilder()
.addFile('/foo.txt', 'this is foo') .addFile('/foo.txt', 'this is foo', {Vary: 'Accept'})
.addFile('/bar.txt', 'this is bar') .addFile('/bar.txt', 'this is bar')
.build(); .build();
@ -84,6 +86,12 @@ describe('prefetch assets', () => {
const err = await errorFrom(group.initializeFully()); const err = await errorFrom(group.initializeFully());
expect(err.message).toContain('Hash mismatch'); expect(err.message).toContain('Hash mismatch');
}); });
it('CacheQueryOptions are passed through', async () => {
await group.initializeFully();
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
await group.handleFetch(scope.newRequest('/foo.txt'), scope);
expect(matchSpy).toHaveBeenCalledWith(new MockRequest('/foo.txt'), {ignoreVary: true});
});
}); });
})(); })();

View File

@ -225,6 +225,7 @@ export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest {
updateMode: 'prefetch', updateMode: 'prefetch',
urls: files, urls: files,
patterns: [], patterns: [],
cacheQueryOptions: {ignoreVary: true}
}, },
], ],
navigationUrls: [], navigationUrls: [],