Maximilian Koeller ee35e223a7 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
2020-05-01 09:44:07 -07:00

365 lines
13 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {CacheDatabase} from '../src/db-cache';
import {Driver} from '../src/driver';
import {Manifest} from '../src/manifest';
import {MockCache} from '../testing/cache';
import {MockRequest} from '../testing/fetch';
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
(function() {
// Skip environments that don't support the minimum APIs needed to run the SW tests.
if (!SwTestHarness.envIsSupported()) {
return;
}
const dist = new MockFileSystemBuilder()
.addFile('/foo.txt', 'this is foo')
.addFile('/bar.txt', 'this is bar')
.addFile('/api/test', 'version 1')
.addFile('/api/a', 'version A')
.addFile('/api/b', 'version B')
.addFile('/api/c', 'version C')
.addFile('/api/d', 'version D')
.addFile('/api/e', 'version E')
.addFile('/fresh/data', 'this is fresh data')
.addFile('/refresh/data', 'this is some data')
.build();
const distUpdate = new MockFileSystemBuilder()
.addFile('/foo.txt', 'this is foo v2')
.addFile('/bar.txt', 'this is bar')
.addFile('/api/test', 'version 2')
.addFile('/fresh/data', 'this is fresher data')
.addFile('/refresh/data', 'this is refreshed data')
.build();
const manifest: Manifest = {
configVersion: 1,
timestamp: 1234567890123,
index: '/index.html',
assetGroups: [
{
name: 'assets',
installMode: 'prefetch',
updateMode: 'prefetch',
urls: [
'/foo.txt',
'/bar.txt',
],
patterns: [],
cacheQueryOptions: {ignoreVary: true},
},
],
dataGroups: [
{
name: 'testPerf',
maxSize: 3,
strategy: 'performance',
patterns: ['^/api/.*$'],
timeoutMs: 1000,
maxAge: 5000,
version: 1,
cacheQueryOptions: {ignoreVary: true, ignoreSearch: true},
},
{
name: 'testRefresh',
maxSize: 3,
strategy: 'performance',
patterns: ['^/refresh/.*$'],
timeoutMs: 1000,
refreshAheadMs: 1000,
maxAge: 5000,
version: 1,
cacheQueryOptions: {ignoreVary: true},
},
{
name: 'testFresh',
maxSize: 3,
strategy: 'freshness',
patterns: ['^/fresh/.*$'],
timeoutMs: 1000,
maxAge: 5000,
version: 1,
cacheQueryOptions: {ignoreVary: true},
},
],
navigationUrls: [],
hashTable: tmpHashTableForFs(dist),
};
const seqIncreasedManifest: Manifest = {
...manifest,
dataGroups: [
{
...manifest.dataGroups![0],
version: 2,
},
manifest.dataGroups![1],
manifest.dataGroups![2],
],
};
const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build();
const serverUpdate =
new MockServerStateBuilder().withStaticFiles(distUpdate).withManifest(manifest).build();
const serverSeqUpdate = new MockServerStateBuilder()
.withStaticFiles(distUpdate)
.withManifest(seqIncreasedManifest)
.build();
describe('data cache', () => {
let scope: SwTestHarness;
let driver: Driver;
beforeEach(async () => {
scope = new SwTestHarnessBuilder().withServerState(server).build();
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
// Initialize.
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
await driver.initialized;
server.clearRequests();
serverUpdate.clearRequests();
serverSeqUpdate.clearRequests();
});
afterEach(() => {
server.reset();
serverUpdate.reset();
serverSeqUpdate.reset();
});
describe('in performance mode', () => {
it('names the caches correctly', async () => {
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
const keys = await scope.caches.keys();
expect(keys.every(key => key.startsWith('ngsw:/:'))).toEqual(true);
});
it('caches a basic request', async () => {
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
server.assertSawRequestFor('/api/test');
scope.advance(1000);
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
server.assertNoOtherRequests();
});
it('does not cache opaque responses', async () => {
expect(await makeNoCorsRequest(scope, '/api/test')).toBe('');
server.assertSawRequestFor('/api/test');
expect(await makeNoCorsRequest(scope, '/api/test')).toBe('');
server.assertSawRequestFor('/api/test');
});
it('refreshes after awhile', async () => {
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
server.clearRequests();
scope.advance(10000);
scope.updateServerState(serverUpdate);
expect(await makeRequest(scope, '/api/test')).toEqual('version 2');
});
it('expires the least recently used entry', async () => {
expect(await makeRequest(scope, '/api/a')).toEqual('version A');
expect(await makeRequest(scope, '/api/b')).toEqual('version B');
expect(await makeRequest(scope, '/api/c')).toEqual('version C');
expect(await makeRequest(scope, '/api/d')).toEqual('version D');
expect(await makeRequest(scope, '/api/e')).toEqual('version E');
server.clearRequests();
expect(await makeRequest(scope, '/api/c')).toEqual('version C');
expect(await makeRequest(scope, '/api/d')).toEqual('version D');
expect(await makeRequest(scope, '/api/e')).toEqual('version E');
server.assertNoOtherRequests();
expect(await makeRequest(scope, '/api/a')).toEqual('version A');
expect(await makeRequest(scope, '/api/b')).toEqual('version B');
server.assertSawRequestFor('/api/a');
server.assertSawRequestFor('/api/b');
server.assertNoOtherRequests();
});
it('does not carry over cache with new version', async () => {
expect(await makeRequest(scope, '/api/test')).toEqual('version 1');
scope.updateServerState(serverSeqUpdate);
expect(await driver.checkForUpdate()).toEqual(true);
await driver.updateClient(await scope.clients.get('default'));
expect(await makeRequest(scope, '/api/test')).toEqual('version 2');
});
it('CacheQueryOptions are passed through', async () => {
await driver.initialized;
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
// the first request fetches the resource from the server
await makeRequest(scope, '/api/a');
// the second one will be loaded from the cache
await makeRequest(scope, '/api/a');
expect(matchSpy).toHaveBeenCalledWith(
new MockRequest('/api/a'), {ignoreVary: true, ignoreSearch: true});
});
it('still matches if search differs but ignoreSearch is enabled', async () => {
await driver.initialized;
const matchSpy = spyOn(MockCache.prototype, 'match').and.callThrough();
// the first request fetches the resource from the server
await makeRequest(scope, '/api/a?v=1');
// the second one will be loaded from the cache
server.clearRequests();
await makeRequest(scope, '/api/a?v=2');
server.assertNoOtherRequests();
});
});
describe('in freshness mode', () => {
it('goes to the server first', async () => {
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
server.assertSawRequestFor('/fresh/data');
server.clearRequests();
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
server.assertSawRequestFor('/fresh/data');
server.assertNoOtherRequests();
scope.updateServerState(serverUpdate);
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresher data');
serverUpdate.assertSawRequestFor('/fresh/data');
serverUpdate.assertNoOtherRequests();
});
it('caches opaque responses', async () => {
expect(await makeNoCorsRequest(scope, '/fresh/data')).toBe('');
server.assertSawRequestFor('/fresh/data');
server.online = false;
expect(await makeRequest(scope, '/fresh/data')).toBe('');
server.assertNoOtherRequests();
});
it('falls back on the cache when server times out', async () => {
expect(await makeRequest(scope, '/fresh/data')).toEqual('this is fresh data');
server.assertSawRequestFor('/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);
expect(await res).toEqual('this is fresh data');
// Unpausing allows the worker to continue with caching.
serverUpdate.unpause();
await done;
serverUpdate.pause();
const [res2, done2] = makePendingRequest(scope, '/fresh/data');
await serverUpdate.nextRequest;
scope.advance(2000);
expect(await res2).toEqual('this is fresher data');
});
it('refreshes ahead', async () => {
server.assertNoOtherRequests();
serverUpdate.assertNoOtherRequests();
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
server.assertSawRequestFor('/refresh/data');
server.clearRequests();
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
server.assertNoOtherRequests();
scope.updateServerState(serverUpdate);
scope.advance(1500);
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is some data');
serverUpdate.assertSawRequestFor('/refresh/data');
expect(await makeRequest(scope, '/refresh/data')).toEqual('this is refreshed data');
serverUpdate.assertNoOtherRequests();
});
it('caches opaque responses on refresh', async () => {
// Make the initial request and populate the cache.
expect(await makeRequest(scope, '/fresh/data')).toBe('this is fresh data');
server.assertSawRequestFor('/fresh/data');
server.clearRequests();
// Update the server state and pause the server, so the next request times out.
scope.updateServerState(serverUpdate);
serverUpdate.pause();
const [res, done] =
makePendingRequest(scope, new MockRequest('/fresh/data', {mode: 'no-cors'}));
// The network request times out after 1,000ms and the cached response is returned.
await serverUpdate.nextRequest;
scope.advance(2000);
expect(await res).toBe('this is fresh data');
// Unpause the server to allow the network request to complete and be cached.
serverUpdate.unpause();
await done;
// Pause the server to force the cached (opaque) response to be returned.
serverUpdate.pause();
const [res2] = makePendingRequest(scope, '/fresh/data');
await serverUpdate.nextRequest;
scope.advance(2000);
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;
});
});
});
})();
function makeRequest(scope: SwTestHarness, url: string, clientId?: string): Promise<string|null> {
const [resTextPromise, done] = makePendingRequest(scope, url, clientId);
return done.then(() => resTextPromise);
}
function makeNoCorsRequest(
scope: SwTestHarness, url: string, clientId?: string): Promise<string|null> {
const req = new MockRequest(url, {mode: 'no-cors'});
const [resTextPromise, done] = makePendingRequest(scope, req, clientId);
return done.then(() => resTextPromise);
}
function makePendingRequest(scope: SwTestHarness, urlOrReq: string|MockRequest, clientId?: string):
[Promise<string|null>, Promise<void>] {
const req = (typeof urlOrReq === 'string') ? new MockRequest(urlOrReq) : urlOrReq;
const [resPromise, done] = scope.handleFetch(req, clientId || 'default');
return [
resPromise.then<string|null>(res => res ? res.text() : null),
done,
];
}