2017-09-28 19:18:12 -04:00
|
|
|
/**
|
|
|
|
* @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>();
|
2017-10-02 18:59:57 -04:00
|
|
|
private errors = new Set<string>();
|
2017-09-28 19:18:12 -04:00
|
|
|
|
|
|
|
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 {
|
2017-11-30 12:51:09 -05:00
|
|
|
this.resources.set('ngsw.json', new MockResponse(JSON.stringify(manifest)));
|
2017-09-28 19:18:12 -04:00
|
|
|
return this;
|
|
|
|
}
|
|
|
|
|
2017-10-02 18:59:57 -04:00
|
|
|
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 { return new MockServerState(this.resources, this.errors); }
|
2017-09-28 19:18:12 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
export class MockServerState {
|
|
|
|
private requests: Request[] = [];
|
|
|
|
private gate: Promise<void> = Promise.resolve();
|
|
|
|
private resolve: Function|null = null;
|
|
|
|
private resolveNextRequest: Function;
|
|
|
|
nextRequest: Promise<Request>;
|
|
|
|
|
2017-10-02 18:59:57 -04:00
|
|
|
constructor(private resources: Map<string, Response>, private errors: Set<string>) {
|
2017-09-28 19:18:12 -04:00
|
|
|
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;
|
2017-10-02 18:59:57 -04:00
|
|
|
|
|
|
|
if (req.credentials === 'include') {
|
|
|
|
return new MockResponse(null, {status: 0, statusText: '', type: 'opaque'});
|
|
|
|
}
|
2017-09-28 19:18:12 -04:00
|
|
|
const url = req.url.split('?')[0];
|
|
|
|
this.requests.push(req);
|
|
|
|
if (this.resources.has(url)) {
|
|
|
|
return this.resources.get(url) !.clone();
|
|
|
|
}
|
2017-10-02 18:59:57 -04:00
|
|
|
if (this.errors.has(url)) {
|
|
|
|
throw new Error('Intentional failure!');
|
|
|
|
}
|
2017-09-28 19:18:12 -04:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
index: '/index.html',
|
|
|
|
assetGroups: [
|
|
|
|
{
|
|
|
|
name: 'group',
|
|
|
|
installMode: 'prefetch',
|
|
|
|
updateMode: 'prefetch',
|
|
|
|
urls: files,
|
|
|
|
patterns: [],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
hashTable,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
fix(service-worker): properly handle invalid hashes in all scenarios (#21288)
When the SW fetches URLs listed in a manifest with hashes, it checks
the content hash against the manifest to make sure it has the correct
version of the URL. In the event of a mismatch, the SW is supposed to
consider the manifest invalid, and avoid using it. There are 3 cases
to consider by which this can happen.
Case 1: during the initial SW installation, a manifest is activated
without waiting for every URL to be fully loaded. In the background,
every prefetch URL listed by the manifest is requested and cached.
One such prefetch request could fail the hash test, and cause the
manifest to be treated as invalid. In such a case, the SW should
enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is
invalid.
This case works today.
Case 2: during the initial SW installation, as in Case 1, a manifest
is activated without waiting for each URL to fully load. However,
it's possible that the application could request a URL with a bad
hash before background initialization tries to load that URL. This
happens if, for example, the application has a broken index.html.
In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY,
and serve the request from the network instead.
What happens today is that the internal error escapes the SW and
is returned as a rejected Promise to respondWith(), causing a
browser-level error that the site cannot be loaded, breaking the
site.
This change allows the SW to detect the error and enter the correct
state, falling back on the network if needed.
Case 3: during checkForUpdate(), the SW will try to fully cache the
new update before making it the latest version. Failure here is
complicated - if the page fails to load due to transient network
conditions (timeouts, 500s, etc), then it makes sense to continue
serving the existing cached version, and attempt to activate the
update on the next cycle.
If the page fails due to non-transient conditions though (400 error,
hash mismatch, etc), then the SW should consider the updated
manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY.
Currently, all errors are treated as transient.
This change causes the SW to treat all errors during updates as
non-transient, which can cause the SW to unnecessarily enter a
safe mode. A future change can allow the SW to remain in normal mode
if the error is provably transient.
PR Close #21288
2018-01-02 15:42:39 -05:00
|
|
|
export function tmpHashTableForFs(
|
|
|
|
fs: MockFileSystem, breakHashes: {[url: string]: boolean} = {}): {[url: string]: string} {
|
2017-09-28 19:18:12 -04:00
|
|
|
const table: {[url: string]: string} = {};
|
|
|
|
fs.list().forEach(path => {
|
|
|
|
const file = fs.lookup(path) !;
|
|
|
|
if (file.hashThisFile) {
|
|
|
|
table[path] = file.hash;
|
fix(service-worker): properly handle invalid hashes in all scenarios (#21288)
When the SW fetches URLs listed in a manifest with hashes, it checks
the content hash against the manifest to make sure it has the correct
version of the URL. In the event of a mismatch, the SW is supposed to
consider the manifest invalid, and avoid using it. There are 3 cases
to consider by which this can happen.
Case 1: during the initial SW installation, a manifest is activated
without waiting for every URL to be fully loaded. In the background,
every prefetch URL listed by the manifest is requested and cached.
One such prefetch request could fail the hash test, and cause the
manifest to be treated as invalid. In such a case, the SW should
enter a state of EXISTING_CLIENTS_ONLY, as the latest manifest is
invalid.
This case works today.
Case 2: during the initial SW installation, as in Case 1, a manifest
is activated without waiting for each URL to fully load. However,
it's possible that the application could request a URL with a bad
hash before background initialization tries to load that URL. This
happens if, for example, the application has a broken index.html.
In this case, the SW should enter a state of EXISTING_CLIENTS_ONLY,
and serve the request from the network instead.
What happens today is that the internal error escapes the SW and
is returned as a rejected Promise to respondWith(), causing a
browser-level error that the site cannot be loaded, breaking the
site.
This change allows the SW to detect the error and enter the correct
state, falling back on the network if needed.
Case 3: during checkForUpdate(), the SW will try to fully cache the
new update before making it the latest version. Failure here is
complicated - if the page fails to load due to transient network
conditions (timeouts, 500s, etc), then it makes sense to continue
serving the existing cached version, and attempt to activate the
update on the next cycle.
If the page fails due to non-transient conditions though (400 error,
hash mismatch, etc), then the SW should consider the updated
manifest invalid, and enter a state of EXISTING_CLIENTS_ONLY.
Currently, all errors are treated as transient.
This change causes the SW to treat all errors during updates as
non-transient, which can cause the SW to unnecessarily enter a
safe mode. A future change can allow the SW to remain in normal mode
if the error is provably transient.
PR Close #21288
2018-01-02 15:42:39 -05:00
|
|
|
if (breakHashes[path]) {
|
|
|
|
table[path] = table[path].split('').reverse().join('');
|
|
|
|
}
|
2017-09-28 19:18:12 -04:00
|
|
|
}
|
|
|
|
});
|
|
|
|
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;
|
2017-12-22 12:36:47 -05:00
|
|
|
}
|