
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
259 lines
6.7 KiB
TypeScript
259 lines
6.7 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 {AssetGroupConfig, Manifest} from '../src/manifest';
|
|
import {sha1} from '../src/sha1';
|
|
|
|
import {MockResponse} from './fetch';
|
|
|
|
export type HeaderMap = {
|
|
[key: string]: string
|
|
};
|
|
|
|
export class MockFile {
|
|
constructor(
|
|
readonly path: string, readonly contents: string, readonly headers = {},
|
|
readonly hashThisFile: boolean) {}
|
|
|
|
get hash(): string {
|
|
return sha1(this.contents);
|
|
}
|
|
}
|
|
|
|
export class MockFileSystemBuilder {
|
|
private resources = new Map<string, MockFile>();
|
|
|
|
addFile(path: string, contents: string, headers?: HeaderMap): MockFileSystemBuilder {
|
|
this.resources.set(path, new MockFile(path, contents, headers, true));
|
|
return this;
|
|
}
|
|
|
|
addUnhashedFile(path: string, contents: string, headers?: HeaderMap): MockFileSystemBuilder {
|
|
this.resources.set(path, new MockFile(path, contents, headers, false));
|
|
return this;
|
|
}
|
|
|
|
build(): MockFileSystem {
|
|
return new MockFileSystem(this.resources);
|
|
}
|
|
}
|
|
|
|
export class MockFileSystem {
|
|
constructor(private resources: Map<string, MockFile>) {}
|
|
|
|
lookup(path: string): MockFile|undefined {
|
|
return this.resources.get(path);
|
|
}
|
|
|
|
extend(): MockFileSystemBuilder {
|
|
const builder = new MockFileSystemBuilder();
|
|
Array.from(this.resources.keys()).forEach(path => {
|
|
const res = this.resources.get(path)!;
|
|
if (res.hashThisFile) {
|
|
builder.addFile(path, res.contents, res.headers);
|
|
} else {
|
|
builder.addUnhashedFile(path, res.contents, res.headers);
|
|
}
|
|
});
|
|
return builder;
|
|
}
|
|
|
|
list(): string[] {
|
|
return Array.from(this.resources.keys());
|
|
}
|
|
}
|
|
|
|
export class MockServerStateBuilder {
|
|
private resources = new Map<string, Response>();
|
|
private errors = new Set<string>();
|
|
|
|
withStaticFiles(fs: MockFileSystem): MockServerStateBuilder {
|
|
fs.list().forEach(path => {
|
|
const file = fs.lookup(path)!;
|
|
this.resources.set(path, new MockResponse(file.contents, {headers: file.headers}));
|
|
});
|
|
return this;
|
|
}
|
|
|
|
withManifest(manifest: Manifest): MockServerStateBuilder {
|
|
this.resources.set('ngsw.json', new MockResponse(JSON.stringify(manifest)));
|
|
return this;
|
|
}
|
|
|
|
withRedirect(from: string, to: string, toContents: string): MockServerStateBuilder {
|
|
this.resources.set(from, new MockResponse(toContents, {redirected: true, url: to}));
|
|
this.resources.set(to, new MockResponse(toContents));
|
|
return this;
|
|
}
|
|
|
|
withError(url: string): MockServerStateBuilder {
|
|
this.errors.add(url);
|
|
return this;
|
|
}
|
|
|
|
build(): MockServerState {
|
|
// Take a "snapshot" of the current `resources` and `errors`.
|
|
const resources = new Map(this.resources.entries());
|
|
const errors = new Set(this.errors.values());
|
|
|
|
return new MockServerState(resources, errors);
|
|
}
|
|
}
|
|
|
|
export class MockServerState {
|
|
private requests: Request[] = [];
|
|
private gate: Promise<void> = Promise.resolve();
|
|
private resolve: Function|null = null;
|
|
// TODO(issue/24571): remove '!'.
|
|
private resolveNextRequest!: Function;
|
|
online = true;
|
|
nextRequest: Promise<Request>;
|
|
|
|
constructor(private resources: Map<string, Response>, private errors: Set<string>) {
|
|
this.nextRequest = new Promise(resolve => {
|
|
this.resolveNextRequest = resolve;
|
|
});
|
|
}
|
|
|
|
async fetch(req: Request): Promise<Response> {
|
|
this.resolveNextRequest(req);
|
|
this.nextRequest = new Promise(resolve => {
|
|
this.resolveNextRequest = resolve;
|
|
});
|
|
|
|
await this.gate;
|
|
|
|
if (!this.online) {
|
|
throw new Error('Offline.');
|
|
}
|
|
|
|
this.requests.push(req);
|
|
|
|
if ((req.credentials === 'include') || (req.mode === 'no-cors')) {
|
|
return new MockResponse(null, {status: 0, statusText: '', type: 'opaque'});
|
|
}
|
|
const url = req.url.split('?')[0];
|
|
if (this.resources.has(url)) {
|
|
return this.resources.get(url)!.clone();
|
|
}
|
|
if (this.errors.has(url)) {
|
|
throw new Error('Intentional failure!');
|
|
}
|
|
return new MockResponse(null, {status: 404, statusText: 'Not Found'});
|
|
}
|
|
|
|
pause(): void {
|
|
this.gate = new Promise(resolve => {
|
|
this.resolve = resolve;
|
|
});
|
|
}
|
|
|
|
unpause(): void {
|
|
if (this.resolve === null) {
|
|
return;
|
|
}
|
|
this.resolve();
|
|
this.resolve = null;
|
|
}
|
|
|
|
assertSawRequestFor(url: string): void {
|
|
if (!this.sawRequestFor(url)) {
|
|
throw new Error(`Expected request for ${url}, got none.`);
|
|
}
|
|
}
|
|
|
|
assertNoRequestFor(url: string): void {
|
|
if (this.sawRequestFor(url)) {
|
|
throw new Error(`Expected no request for ${url} but saw one.`);
|
|
}
|
|
}
|
|
|
|
sawRequestFor(url: string): boolean {
|
|
const matching = this.requests.filter(req => req.url.split('?')[0] === url);
|
|
if (matching.length > 0) {
|
|
this.requests = this.requests.filter(req => req !== matching[0]);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
assertNoOtherRequests(): void {
|
|
if (!this.noOtherRequests()) {
|
|
throw new Error(`Expected no other requests, got requests for ${
|
|
this.requests.map(req => req.url.split('?')[0]).join(', ')}`);
|
|
}
|
|
}
|
|
|
|
noOtherRequests(): boolean {
|
|
return this.requests.length === 0;
|
|
}
|
|
|
|
clearRequests(): void {
|
|
this.requests = [];
|
|
}
|
|
|
|
reset(): void {
|
|
this.clearRequests();
|
|
this.nextRequest = new Promise(resolve => {
|
|
this.resolveNextRequest = resolve;
|
|
});
|
|
this.gate = Promise.resolve();
|
|
this.resolve = null;
|
|
this.online = true;
|
|
}
|
|
}
|
|
|
|
export function tmpManifestSingleAssetGroup(fs: MockFileSystem): Manifest {
|
|
const files = fs.list();
|
|
const hashTable: {[url: string]: string} = {};
|
|
files.forEach(path => {
|
|
hashTable[path] = fs.lookup(path)!.hash;
|
|
});
|
|
return {
|
|
configVersion: 1,
|
|
timestamp: 1234567890123,
|
|
index: '/index.html',
|
|
assetGroups: [
|
|
{
|
|
name: 'group',
|
|
installMode: 'prefetch',
|
|
updateMode: 'prefetch',
|
|
urls: files,
|
|
patterns: [],
|
|
cacheQueryOptions: {ignoreVary: true}
|
|
},
|
|
],
|
|
navigationUrls: [],
|
|
hashTable,
|
|
};
|
|
}
|
|
|
|
export function tmpHashTableForFs(
|
|
fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {}): {[url: string]: string} {
|
|
const table: {[url: string]: string} = {};
|
|
fs.list().forEach(path => {
|
|
const file = fs.lookup(path)!;
|
|
if (file.hashThisFile) {
|
|
table[path] = file.hash;
|
|
if (breakHashes[path]) {
|
|
table[path] = table[path].split('').reverse().join('');
|
|
}
|
|
}
|
|
});
|
|
return table;
|
|
}
|
|
|
|
export function tmpHashTable(manifest: Manifest): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
Object.keys(manifest.hashTable).forEach(url => {
|
|
const hash = manifest.hashTable[url];
|
|
map.set(url, hash);
|
|
});
|
|
return map;
|
|
}
|