feat(service-worker): introduce the @angular/service-worker package (#19274)
This service worker is a conceptual derivative of the existing @angular/service-worker maintained at github.com/angular/mobile-toolkit, but has been rewritten to support use across a much wider variety of applications. Entrypoints include: @angular/service-worker: a library for use within Angular client apps to communicate with the service worker. @angular/service-worker/gen: a library for generating ngsw.json files from glob-based SW config files. @angular/service-worker/ngsw-worker.js: the bundled service worker script itself. @angular/service-worker/ngsw-cli.js: a CLI tool for generating ngsw.json files from glob-based SW config files.
This commit is contained in:
parent
7c1d3e0f5a
commit
d442b6855f
9
build.sh
9
build.sh
|
@ -23,7 +23,8 @@ PACKAGES=(core
|
|||
router
|
||||
compiler-cli
|
||||
language-service
|
||||
benchpress)
|
||||
benchpress
|
||||
service-worker)
|
||||
|
||||
TSC_PACKAGES=(compiler-cli
|
||||
language-service
|
||||
|
@ -235,6 +236,7 @@ compilePackage() {
|
|||
fi
|
||||
fi
|
||||
|
||||
# Build subpackages
|
||||
for DIR in ${1}/* ; do
|
||||
[ -d "${DIR}" ] || continue
|
||||
BASE_DIR=$(basename "${DIR}")
|
||||
|
@ -465,6 +467,11 @@ do
|
|||
addBanners ${BUNDLES_DIR}
|
||||
minify ${BUNDLES_DIR}
|
||||
|
||||
if [[ -e ${SRC_DIR}/build.sh ]]; then
|
||||
echo "====== Custom build for ${PACKAGE}"
|
||||
cd ${SRC_DIR} && ${SRC_DIR}/build.sh
|
||||
fi
|
||||
|
||||
) 2>&1 | grep -v "as external dependency"
|
||||
|
||||
if [[ ${PACKAGE} == "common" ]]; then
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -u -e -o pipefail
|
||||
|
||||
BIN=$(cd .. && npm bin)
|
||||
|
||||
$BIN/tsc -p worker/tsconfig.json
|
||||
$BIN/rollup -c worker/rollup-worker.config.js
|
||||
|
||||
|
||||
$BIN/tsc -p cli/tsconfig.json
|
||||
$BIN/rollup -c cli/rollup-cli.config.js
|
||||
|
||||
echo "#!/usr/bin/env node" > ../../dist/packages-dist/service-worker/ngsw-config.js
|
||||
|
||||
cat ../../dist/packages-dist/service-worker/ngsw-config-tmp.js >> ../../dist/packages-dist/service-worker/ngsw-config.js
|
||||
rm ../../dist/packages-dist/service-worker/ngsw-config-tmp.js
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @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 {Filesystem} from '@angular/service-worker/config';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
export class NodeFilesystem implements Filesystem {
|
||||
constructor(private base: string) {}
|
||||
|
||||
async list(_path: string): Promise<string[]> {
|
||||
const dir = this.canonical(_path);
|
||||
const entries = fs.readdirSync(dir).map(
|
||||
(entry: string) => ({entry, stats: fs.statSync(path.join(dir, entry))}));
|
||||
const files = entries.filter((entry: any) => !entry.stats.isDirectory())
|
||||
.map((entry: any) => path.join(_path, entry.entry));
|
||||
|
||||
return entries.filter((entry: any) => entry.stats.isDirectory())
|
||||
.map((entry: any) => path.join(_path, entry.entry))
|
||||
.reduce(
|
||||
async(list: string[], subdir: string) => (await list).concat(await this.list(subdir)),
|
||||
Promise.resolve(files));
|
||||
}
|
||||
|
||||
async read(_path: string): Promise<string> {
|
||||
const file = this.canonical(_path);
|
||||
return fs.readFileSync(file).toString();
|
||||
}
|
||||
|
||||
async write(_path: string, contents: string): Promise<void> {
|
||||
const file = this.canonical(_path);
|
||||
fs.writeFileSync(file, contents);
|
||||
}
|
||||
|
||||
private canonical(_path: string): string { return path.join(this.base, _path); }
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
const {Generator, NgswConfig} = require('@angular/service-worker/config');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
import {NodeFilesystem} from './filesystem';
|
||||
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
const distDir = path.join(cwd, process.argv[2]);
|
||||
const config = path.join(cwd, process.argv[3]);
|
||||
const baseHref = process.argv[4] || '/';
|
||||
|
||||
const configParsed = JSON.parse(fs.readFileSync(config).toString());
|
||||
|
||||
const filesystem = new NodeFilesystem(distDir);
|
||||
const gen = new Generator(filesystem, baseHref);
|
||||
|
||||
(async() => {
|
||||
const control = await gen.process(configParsed);
|
||||
await filesystem.write('/ngsw.json', JSON.stringify(control, null, 2));
|
||||
})();
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @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 resolve from 'rollup-plugin-node-resolve';
|
||||
|
||||
export default {
|
||||
entry: '../../dist/all/@angular/service-worker/cli-custom/main.js',
|
||||
dest: '../../dist/packages-dist/service-worker/ngsw-config-tmp.js',
|
||||
format: 'iife',
|
||||
plugins: [resolve()],
|
||||
external: [
|
||||
'fs',
|
||||
'path',
|
||||
'@angular/service-worker/config',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"declaration": false,
|
||||
"strict": true,
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"strictNullChecks": true,
|
||||
"outDir": "../../../dist/all/@angular/service-worker/cli-custom",
|
||||
"noImplicitAny": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@angular/service-worker/config": ["../../../dist/packages/service-worker/config"]
|
||||
},
|
||||
"inlineSourceMap": true,
|
||||
"lib": ["es2015"],
|
||||
"target": "es5",
|
||||
"typeRoots": []
|
||||
},
|
||||
"files": [
|
||||
"main.ts",
|
||||
"../../../node_modules/@types/node/index.d.ts"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
// This file is not used to build this module. It is only used during editing
|
||||
// by the TypeScript language service and during build for verification. `ngc`
|
||||
// replaces this file with production index.ts when it rewrites private symbol
|
||||
// names.
|
||||
|
||||
export * from './public_api';
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "@angular/service-worker/config",
|
||||
"typings": "./index.d.ts",
|
||||
"main": "../bundles/service-worker-config.umd.js",
|
||||
"module": "../esm5/config/index.js",
|
||||
"es2015": "../esm15/config/index.js"
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export {Filesystem} from './src/filesystem';
|
||||
export {Generator} from './src/generator';
|
||||
export {AssetGroup, Config, DataGroup, Duration, Glob} from './src/in';
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* @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 resolve from 'rollup-plugin-node-resolve';
|
||||
import sourcemaps from 'rollup-plugin-sourcemaps';
|
||||
|
||||
const globals = {};
|
||||
|
||||
export default {
|
||||
entry: '../../../dist/packages-dist/service-worker/esm5/config.js',
|
||||
dest: '../../../dist/packages-dist/service-worker/bundles/service-worker-config.umd.js',
|
||||
format: 'umd',
|
||||
exports: 'named',
|
||||
moduleName: 'ng.serviceWorker.config',
|
||||
plugins: [resolve(), sourcemaps()],
|
||||
external: Object.keys(globals),
|
||||
globals: globals
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
const PARSE_TO_PAIRS = /([0-9]+[^0-9]+)/g;
|
||||
const PAIR_SPLIT = /^([0-9]+)([dhmsu]+)$/;
|
||||
|
||||
export function parseDurationToMs(duration: string): number {
|
||||
const matches: string[] = [];
|
||||
|
||||
let array: RegExpExecArray|null;
|
||||
while ((array = PARSE_TO_PAIRS.exec(duration)) !== null) {
|
||||
matches.push(array[0]);
|
||||
}
|
||||
return matches
|
||||
.map(match => {
|
||||
const res = PAIR_SPLIT.exec(match);
|
||||
if (res === null) {
|
||||
throw new Error(`Not a valid duration: ${match}`);
|
||||
}
|
||||
let factor: number = 0;
|
||||
switch (res[2]) {
|
||||
case 'd':
|
||||
factor = 86400000;
|
||||
break;
|
||||
case 'h':
|
||||
factor = 3600000;
|
||||
break;
|
||||
case 'm':
|
||||
factor = 60000;
|
||||
break;
|
||||
case 's':
|
||||
factor = 1000;
|
||||
break;
|
||||
case 'u':
|
||||
factor = 1;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Not a valid duration unit: ${res[2]}`);
|
||||
}
|
||||
return parseInt(res[1]) * factor;
|
||||
})
|
||||
.reduce((total, value) => total + value, 0);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* An abstraction over a virtual file system used to enable testing and operation
|
||||
* of the config generator in different environments.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface Filesystem {
|
||||
list(dir: string): Promise<string[]>;
|
||||
read(file: string): Promise<string>;
|
||||
write(file: string, contents: string): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* @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 {parseDurationToMs} from './duration';
|
||||
import {Filesystem} from './filesystem';
|
||||
import {globToRegex} from './glob';
|
||||
import {Config} from './in';
|
||||
import {sha1} from './sha1';
|
||||
|
||||
/**
|
||||
* Consumes service worker configuration files and processes them into control files.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class Generator {
|
||||
constructor(readonly fs: Filesystem, private baseHref: string) {}
|
||||
|
||||
async process(config: Config): Promise<Object> {
|
||||
const hashTable = {};
|
||||
return {
|
||||
configVersion: 1,
|
||||
index: joinUrls(this.baseHref, config.index),
|
||||
appData: config.appData,
|
||||
assetGroups: await this.processAssetGroups(config, hashTable),
|
||||
dataGroups: this.processDataGroups(config), hashTable,
|
||||
};
|
||||
}
|
||||
|
||||
private async processAssetGroups(config: Config, hashTable: {[file: string]: string | undefined}):
|
||||
Promise<Object[]> {
|
||||
const seenMap = new Set<string>();
|
||||
return Promise.all((config.assetGroups || []).map(async(group) => {
|
||||
const fileMatcher = globListToMatcher(group.resources.files || []);
|
||||
const versionedMatcher = globListToMatcher(group.resources.versionedFiles || []);
|
||||
|
||||
const allFiles = (await this.fs.list('/'));
|
||||
|
||||
const versionedFiles = allFiles.filter(versionedMatcher).filter(file => !seenMap.has(file));
|
||||
versionedFiles.forEach(file => seenMap.add(file));
|
||||
|
||||
const plainFiles = allFiles.filter(fileMatcher).filter(file => !seenMap.has(file));
|
||||
plainFiles.forEach(file => seenMap.add(file));
|
||||
|
||||
// Add the hashes.
|
||||
await plainFiles.reduce(async(previous, file) => {
|
||||
await previous;
|
||||
const hash = sha1(await this.fs.read(file));
|
||||
hashTable[joinUrls(this.baseHref, file)] = hash;
|
||||
}, Promise.resolve());
|
||||
|
||||
|
||||
// Figure out the patterns.
|
||||
const patterns = (group.resources.urls || [])
|
||||
.map(
|
||||
glob => glob.startsWith('/') || glob.indexOf('://') !== -1 ?
|
||||
glob :
|
||||
joinUrls(this.baseHref, glob))
|
||||
.map(glob => globToRegex(glob));
|
||||
|
||||
return {
|
||||
name: group.name,
|
||||
installMode: group.installMode || 'prefetch',
|
||||
updateMode: group.updateMode || group.installMode || 'prefetch',
|
||||
urls: ([] as string[])
|
||||
.concat(plainFiles)
|
||||
.concat(versionedFiles)
|
||||
.map(url => joinUrls(this.baseHref, url)),
|
||||
patterns,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
private processDataGroups(config: Config): Object[] {
|
||||
return (config.dataGroups || []).map(group => {
|
||||
const patterns = group.urls
|
||||
.map(
|
||||
glob => glob.startsWith('/') || glob.indexOf('://') !== -1 ?
|
||||
glob :
|
||||
joinUrls(this.baseHref, glob))
|
||||
.map(glob => globToRegex(glob));
|
||||
return {
|
||||
name: group.name,
|
||||
patterns,
|
||||
strategy: group.cacheConfig.strategy || 'performance',
|
||||
maxSize: group.cacheConfig.maxSize,
|
||||
maxAge: parseDurationToMs(group.cacheConfig.maxAge),
|
||||
timeoutMs: group.cacheConfig.timeout && parseDurationToMs(group.cacheConfig.timeout),
|
||||
version: group.version !== undefined ? group.version : 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function globListToMatcher(globs: string[]): (file: string) => boolean {
|
||||
const patterns = globs.map(pattern => {
|
||||
if (pattern.startsWith('!')) {
|
||||
return {
|
||||
positive: false,
|
||||
regex: new RegExp('^' + globToRegex(pattern.substr(1)) + '$'),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
positive: true,
|
||||
regex: new RegExp('^' + globToRegex(pattern) + '$'),
|
||||
};
|
||||
}
|
||||
});
|
||||
return (file: string) => matches(file, patterns);
|
||||
}
|
||||
|
||||
function matches(file: string, patterns: {positive: boolean, regex: RegExp}[]): boolean {
|
||||
const res = patterns.reduce((isMatch, pattern) => {
|
||||
if (pattern.positive) {
|
||||
return isMatch || pattern.regex.test(file);
|
||||
} else {
|
||||
return isMatch && !pattern.regex.test(file);
|
||||
}
|
||||
}, false);
|
||||
return res;
|
||||
}
|
||||
|
||||
function joinUrls(a: string, b: string): string {
|
||||
if (a.endsWith('/') && b.startsWith('/')) {
|
||||
return a + b.substr(1);
|
||||
} else if (!a.endsWith('/') && !b.startsWith('/')) {
|
||||
return a + '/' + b;
|
||||
}
|
||||
return a + b;
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
const WILD_SINGLE = '[^\\/]+';
|
||||
const WILD_OPEN = '(?:.+\\/)?';
|
||||
|
||||
export function globToRegex(glob: string): string {
|
||||
const segments = glob.split('/').reverse();
|
||||
let regex: string = '';
|
||||
while (segments.length > 0) {
|
||||
const segment = segments.pop() !;
|
||||
if (segment === '**') {
|
||||
if (segments.length > 0) {
|
||||
regex += WILD_OPEN;
|
||||
} else {
|
||||
regex += '.*';
|
||||
}
|
||||
continue;
|
||||
} else {
|
||||
const processed = segment.replace(/\./g, '\\.').replace(/\*/g, WILD_SINGLE);
|
||||
regex += processed;
|
||||
if (segments.length > 0) {
|
||||
regex += '\\/';
|
||||
}
|
||||
}
|
||||
}
|
||||
return regex;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export type Glob = string;
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export type Duration = string;
|
||||
|
||||
/**
|
||||
* A top-level Angular Service Worker configuration object.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface Config {
|
||||
appData?: {};
|
||||
index: string;
|
||||
assetGroups?: AssetGroup[];
|
||||
dataGroups?: DataGroup[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a particular group of assets.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface AssetGroup {
|
||||
name: string;
|
||||
installMode?: 'prefetch'|'lazy';
|
||||
updateMode?: 'prefetch'|'lazy';
|
||||
resources: {files?: Glob[]; versionedFiles?: Glob[]; urls?: Glob[];};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a particular group of dynamic URLs.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export interface DataGroup {
|
||||
name: string;
|
||||
urls: Glob[];
|
||||
version?: number;
|
||||
cacheConfig: {
|
||||
maxSize: number; maxAge: Duration; timeout?: Duration; strategy?: 'freshness' | 'performance';
|
||||
};
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the SHA1 of the given string
|
||||
*
|
||||
* see http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf
|
||||
*
|
||||
* WARNING: this function has not been designed not tested with security in mind.
|
||||
* DO NOT USE IT IN A SECURITY SENSITIVE CONTEXT.
|
||||
*
|
||||
* Borrowed from @angular/compiler/src/i18n/digest.ts
|
||||
*/
|
||||
export function sha1(str: string): string {
|
||||
const utf8 = str;
|
||||
const words32 = stringToWords32(utf8, Endian.Big);
|
||||
const len = utf8.length * 8;
|
||||
|
||||
const w = new Array(80);
|
||||
let [a, b, c, d, e]: number[] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
|
||||
|
||||
words32[len >> 5] |= 0x80 << (24 - len % 32);
|
||||
words32[((len + 64 >> 9) << 4) + 15] = len;
|
||||
|
||||
for (let i = 0; i < words32.length; i += 16) {
|
||||
const [h0, h1, h2, h3, h4]: number[] = [a, b, c, d, e];
|
||||
|
||||
for (let j = 0; j < 80; j++) {
|
||||
if (j < 16) {
|
||||
w[j] = words32[i + j];
|
||||
} else {
|
||||
w[j] = rol32(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
|
||||
}
|
||||
|
||||
const [f, k] = fk(j, b, c, d);
|
||||
const temp = [rol32(a, 5), f, e, k, w[j]].reduce(add32);
|
||||
[e, d, c, b, a] = [d, c, rol32(b, 30), a, temp];
|
||||
}
|
||||
|
||||
[a, b, c, d, e] = [add32(a, h0), add32(b, h1), add32(c, h2), add32(d, h3), add32(e, h4)];
|
||||
}
|
||||
|
||||
return byteStringToHexString(words32ToByteString([a, b, c, d, e]));
|
||||
}
|
||||
|
||||
function add32(a: number, b: number): number {
|
||||
return add32to64(a, b)[1];
|
||||
}
|
||||
|
||||
function add32to64(a: number, b: number): [number, number] {
|
||||
const low = (a & 0xffff) + (b & 0xffff);
|
||||
const high = (a >>> 16) + (b >>> 16) + (low >>> 16);
|
||||
return [high >>> 16, (high << 16) | (low & 0xffff)];
|
||||
}
|
||||
|
||||
function add64([ah, al]: [number, number], [bh, bl]: [number, number]): [number, number] {
|
||||
const [carry, l] = add32to64(al, bl);
|
||||
const h = add32(add32(ah, bh), carry);
|
||||
return [h, l];
|
||||
}
|
||||
|
||||
function sub32(a: number, b: number): number {
|
||||
const low = (a & 0xffff) - (b & 0xffff);
|
||||
const high = (a >> 16) - (b >> 16) + (low >> 16);
|
||||
return (high << 16) | (low & 0xffff);
|
||||
}
|
||||
|
||||
// Rotate a 32b number left `count` position
|
||||
function rol32(a: number, count: number): number {
|
||||
return (a << count) | (a >>> (32 - count));
|
||||
}
|
||||
|
||||
// Rotate a 64b number left `count` position
|
||||
function rol64([hi, lo]: [number, number], count: number): [number, number] {
|
||||
const h = (hi << count) | (lo >>> (32 - count));
|
||||
const l = (lo << count) | (hi >>> (32 - count));
|
||||
return [h, l];
|
||||
}
|
||||
|
||||
enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
function fk(index: number, b: number, c: number, d: number): [number, number] {
|
||||
if (index < 20) {
|
||||
return [(b & c) | (~b & d), 0x5a827999];
|
||||
}
|
||||
|
||||
if (index < 40) {
|
||||
return [b ^ c ^ d, 0x6ed9eba1];
|
||||
}
|
||||
|
||||
if (index < 60) {
|
||||
return [(b & c) | (b & d) | (c & d), 0x8f1bbcdc];
|
||||
}
|
||||
|
||||
return [b ^ c ^ d, 0xca62c1d6];
|
||||
}
|
||||
|
||||
|
||||
function stringToWords32(str: string, endian: Endian): number[] {
|
||||
const words32 = Array((str.length + 3) >>> 2);
|
||||
|
||||
for (let i = 0; i < words32.length; i++) {
|
||||
words32[i] = wordAt(str, i * 4, endian);
|
||||
}
|
||||
|
||||
return words32;
|
||||
}
|
||||
|
||||
function byteAt(str: string, index: number): number {
|
||||
return index >= str.length ? 0 : str.charCodeAt(index) & 0xff;
|
||||
}
|
||||
|
||||
function wordAt(str: string, index: number, endian: Endian): number {
|
||||
let word = 0;
|
||||
if (endian === Endian.Big) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
word += byteAt(str, index + i) << (24 - 8 * i);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
word += byteAt(str, index + i) << 8 * i;
|
||||
}
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
function words32ToByteString(words32: number[]): string {
|
||||
return words32.reduce((str, word) => str + word32ToByteString(word), '');
|
||||
}
|
||||
|
||||
function word32ToByteString(word: number): string {
|
||||
let str = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
str += String.fromCharCode((word >>> 8 * (3 - i)) & 0xff);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function byteStringToHexString(str: string): string {
|
||||
let hex: string = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const b = byteAt(str, i);
|
||||
hex += (b >>> 4).toString(16) + (b & 0x0f).toString(16);
|
||||
}
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
|
||||
// based on http://www.danvk.org/hex2dec.html (JS can not handle more than 56b)
|
||||
function byteStringToDecString(str: string): string {
|
||||
let decimal = '';
|
||||
let toThePower = '1';
|
||||
|
||||
for (let i = str.length - 1; i >= 0; i--) {
|
||||
decimal = addBigInt(decimal, numberTimesBigInt(byteAt(str, i), toThePower));
|
||||
toThePower = numberTimesBigInt(256, toThePower);
|
||||
}
|
||||
|
||||
return decimal.split('').reverse().join('');
|
||||
}
|
||||
|
||||
// x and y decimal, lowest significant digit first
|
||||
function addBigInt(x: string, y: string): string {
|
||||
let sum = '';
|
||||
const len = Math.max(x.length, y.length);
|
||||
for (let i = 0, carry = 0; i < len || carry; i++) {
|
||||
const tmpSum = carry + +(x[i] || 0) + +(y[i] || 0);
|
||||
if (tmpSum >= 10) {
|
||||
carry = 1;
|
||||
sum += tmpSum - 10;
|
||||
} else {
|
||||
carry = 0;
|
||||
sum += tmpSum;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
|
||||
function numberTimesBigInt(num: number, b: string): string {
|
||||
let product = '';
|
||||
let bToThePower = b;
|
||||
for (; num !== 0; num = num >>> 1) {
|
||||
if (num & 1) product = addBigInt(product, bToThePower);
|
||||
bToThePower = addBigInt(bToThePower, bToThePower);
|
||||
}
|
||||
return product;
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* @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 {Generator} from '../src/generator';
|
||||
import {MockFilesystem} from '../testing/mock';
|
||||
|
||||
export function main() {
|
||||
describe('Generator', () => {
|
||||
it('generates a correct config', (done: DoneFn) => {
|
||||
const fs = new MockFilesystem({
|
||||
'/index.html': 'This is a test',
|
||||
'/foo/test.html': 'Another test',
|
||||
'/ignored/x.html': 'should be ignored',
|
||||
});
|
||||
const gen = new Generator(fs, '/test');
|
||||
const res = gen.process({
|
||||
index: '/index.html',
|
||||
appData: {
|
||||
test: true,
|
||||
},
|
||||
assetGroups: [{
|
||||
name: 'test',
|
||||
resources: {
|
||||
files: [
|
||||
'/**/*.html', '!/ignored/**',
|
||||
// '/*.html',
|
||||
],
|
||||
versionedFiles: [],
|
||||
urls: [
|
||||
'/absolute/**',
|
||||
'relative/*.txt',
|
||||
]
|
||||
}
|
||||
}],
|
||||
dataGroups: [{
|
||||
name: 'other',
|
||||
urls: [
|
||||
'/api/**',
|
||||
'relapi/**',
|
||||
],
|
||||
cacheConfig: {
|
||||
maxSize: 100,
|
||||
maxAge: '3d',
|
||||
timeout: '1m',
|
||||
}
|
||||
}]
|
||||
});
|
||||
res.then(config => {
|
||||
expect(config).toEqual({
|
||||
'configVersion': 1,
|
||||
'index': '/test/index.html',
|
||||
'appData': {
|
||||
'test': true,
|
||||
},
|
||||
'assetGroups': [{
|
||||
'name': 'test',
|
||||
'installMode': 'prefetch',
|
||||
'updateMode': 'prefetch',
|
||||
'urls': ['/test/index.html', '/test/foo/test.html'],
|
||||
'patterns': ['\\/absolute\\/.*', '\\/test\\/relative\\/[^\\/]+\\.txt']
|
||||
}],
|
||||
'dataGroups': [{
|
||||
'name': 'other',
|
||||
'patterns': ['\\/api\\/.*', '\\/test\\/relapi\\/.*'],
|
||||
'strategy': 'performance',
|
||||
'maxSize': 100,
|
||||
'maxAge': 259200000,
|
||||
'timeoutMs': 60000,
|
||||
'version': 1,
|
||||
}],
|
||||
'hashTable': {
|
||||
'/test/index.html': 'a54d88e06612d820bc3be72877c74f257b561b19',
|
||||
'/test/foo/test.html': '18f6f8eb7b1c23d2bb61bff028b83d867a9e4643'
|
||||
}
|
||||
});
|
||||
done();
|
||||
})
|
||||
.catch(err => done.fail(err));
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* @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 {Filesystem} from '../src/filesystem';
|
||||
|
||||
export class MockFilesystem implements Filesystem {
|
||||
private files = new Map<string, string>();
|
||||
|
||||
constructor(files: {[name: string]: string | undefined}) {
|
||||
Object.keys(files).forEach(path => this.files.set(path, files[path] !));
|
||||
}
|
||||
|
||||
async list(dir: string): Promise<string[]> {
|
||||
return Array.from(this.files.keys()).filter(path => path.startsWith(dir));
|
||||
}
|
||||
|
||||
async read(path: string): Promise<string> { return this.files.get(path) !; }
|
||||
|
||||
async write(path: string, contents: string): Promise<void> { this.files.set(path, contents); }
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"extends": "../tsconfig-build.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": "../",
|
||||
"paths": {
|
||||
"@angular/core": ["../../../dist/packages/core"]
|
||||
},
|
||||
"outDir": "../../../dist/packages/service-worker"
|
||||
},
|
||||
|
||||
"files": [
|
||||
"public_api.ts"
|
||||
],
|
||||
|
||||
"angularCompilerOptions": {
|
||||
"annotateForClosureCompiler": true,
|
||||
"strictMetadataEmit": false,
|
||||
"flatModuleOutFile": "config.js",
|
||||
"flatModuleId": "@angular/service-worker/config"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@angular/service-worker",
|
||||
"version": "0.0.0-PLACEHOLDER",
|
||||
"description": "Angular - service worker tooling!",
|
||||
"main": "./bundles/service-worker.umd.js",
|
||||
"module": "./esm5/service-worker.js",
|
||||
"es2015": "./esm15/service-worker.js",
|
||||
"typings": "./service-worker.d.ts",
|
||||
"author": "angular",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^1.7.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "0.0.0-PLACEHOLDER"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/angular/angular.git"
|
||||
},
|
||||
"bin": {
|
||||
"ngsw-config": "./ngsw-config.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @module
|
||||
* @description
|
||||
* Entry point for all public APIs of this package.
|
||||
*/
|
||||
export * from './src/index';
|
||||
|
||||
// This file only reexports content of the `src` folder. Keep it that way.
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* @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 resolve from 'rollup-plugin-node-resolve';
|
||||
import sourcemaps from 'rollup-plugin-sourcemaps';
|
||||
|
||||
const globals = {
|
||||
'@angular/core': 'ng.core',
|
||||
|
||||
'rxjs/BehaviorSubject': 'Rx',
|
||||
'rxjs/ConnectableObservable': 'Rx',
|
||||
'rxjs/Observable': 'Rx',
|
||||
'rxjs/Subject': 'Rx',
|
||||
|
||||
'rxjs/observable/concat': 'Rx.Observable',
|
||||
'rxjs/observable/defer': 'Rx.Observable',
|
||||
'rxjs/observable/fromEvent': 'Rx.Observable',
|
||||
'rxjs/observable/merge': 'Rx.Observable',
|
||||
'rxjs/observable/of': 'Rx.Observable',
|
||||
'rxjs/observable/throw': 'Rx.Observable',
|
||||
|
||||
'rxjs/operator/do': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/filter': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/map': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/publish': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/startWith': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/switchMap': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/take': 'Rx.Observable.prototype',
|
||||
'rxjs/operator/toPromise': 'Rx.Observable.prototype',
|
||||
};
|
||||
|
||||
export default {
|
||||
entry: '../../dist/packages-dist/service-worker/esm5/service-worker.js',
|
||||
dest: '../../dist/packages-dist/service-worker/bundles/service-worker.umd.js',
|
||||
format: 'umd',
|
||||
exports: 'named',
|
||||
moduleName: 'ng.serviceWorker',
|
||||
plugins: [resolve(), sourcemaps()],
|
||||
external: Object.keys(globals),
|
||||
globals: globals
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export {ServiceWorkerModule} from './module';
|
||||
export {SwPush} from './push';
|
||||
export {SwUpdate} from './update';
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* @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 {Injectable} from '@angular/core';
|
||||
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {ConnectableObservable} from 'rxjs/observable/ConnectableObservable';
|
||||
import {concat as obs_concat} from 'rxjs/observable/concat';
|
||||
import {defer as obs_defer} from 'rxjs/observable/defer';
|
||||
import {fromEvent as obs_fromEvent} from 'rxjs/observable/fromEvent';
|
||||
import {of as obs_of} from 'rxjs/observable/of';
|
||||
import {_throw as obs_throw} from 'rxjs/observable/throw';
|
||||
import {_do as op_do} from 'rxjs/operator/do';
|
||||
import {filter as op_filter} from 'rxjs/operator/filter';
|
||||
import {map as op_map} from 'rxjs/operator/map';
|
||||
import {publish as op_publish} from 'rxjs/operator/publish';
|
||||
import {startWith as op_startWith} from 'rxjs/operator/startWith';
|
||||
import {switchMap as op_switchMap} from 'rxjs/operator/switchMap';
|
||||
import {take as op_take} from 'rxjs/operator/take';
|
||||
import {toPromise as op_toPromise} from 'rxjs/operator/toPromise';
|
||||
|
||||
const ERR_SW_NOT_SUPPORTED = 'Service workers are not supported by this browser';
|
||||
|
||||
export interface Version {
|
||||
hash: string;
|
||||
appData?: Object;
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export interface UpdateAvailableEvent {
|
||||
type: 'UPDATE_AVAILABLE';
|
||||
current: Version;
|
||||
available: Version;
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export interface UpdateActivatedEvent {
|
||||
type: 'UPDATE_ACTIVATED';
|
||||
previous?: Version;
|
||||
current: Version;
|
||||
}
|
||||
|
||||
export type IncomingEvent = UpdateAvailableEvent | UpdateActivatedEvent;
|
||||
|
||||
interface TypedEvent {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface StatusEvent {
|
||||
type: 'STATUS';
|
||||
nonce: number;
|
||||
status: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
function errorObservable(message: string): Observable<any> {
|
||||
return obs_defer(() => obs_throw(new Error(message)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
export class NgswCommChannel {
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly worker: Observable<ServiceWorker>;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly registration: Observable<ServiceWorkerRegistration>;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
readonly events: Observable<IncomingEvent>;
|
||||
|
||||
constructor(serviceWorker: ServiceWorkerContainer|undefined) {
|
||||
if (!serviceWorker) {
|
||||
this.worker = this.events = errorObservable(ERR_SW_NOT_SUPPORTED);
|
||||
} else {
|
||||
const controllerChangeEvents =
|
||||
<Observable<any>>(obs_fromEvent(serviceWorker, 'controllerchange'));
|
||||
const controllerChanges = <Observable<ServiceWorker|null>>(
|
||||
op_map.call(controllerChangeEvents, () => serviceWorker.controller));
|
||||
|
||||
const currentController =
|
||||
<Observable<ServiceWorker|null>>(obs_defer(() => obs_of(serviceWorker.controller)));
|
||||
|
||||
const controllerWithChanges =
|
||||
<Observable<ServiceWorker|null>>(obs_concat(currentController, controllerChanges));
|
||||
this.worker = <Observable<ServiceWorker>>(
|
||||
op_filter.call(controllerWithChanges, (c: ServiceWorker) => !!c));
|
||||
|
||||
this.registration = <Observable<ServiceWorkerRegistration>>(
|
||||
op_switchMap.call(this.worker, () => serviceWorker.getRegistration()));
|
||||
|
||||
const rawEvents = <Observable<MessageEvent>>(op_switchMap.call(
|
||||
this.registration, (reg: ServiceWorkerRegistration) => obs_fromEvent(reg, 'message')));
|
||||
|
||||
const rawEventPayload =
|
||||
<Observable<Object>>(op_map.call(rawEvents, (event: MessageEvent) => event.data));
|
||||
const eventsUnconnected = <Observable<IncomingEvent>>(
|
||||
op_filter.call(rawEventPayload, (event: Object) => !!event && !!(event as any)['type']));
|
||||
const events = <ConnectableObservable<IncomingEvent>>(op_publish.call(eventsUnconnected));
|
||||
this.events = events;
|
||||
events.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
postMessage(action: string, payload: Object): Promise<void> {
|
||||
const worker = op_take.call(this.worker, 1);
|
||||
const sideEffect = op_do.call(worker, (sw: ServiceWorker) => {
|
||||
sw.postMessage({
|
||||
action, ...payload,
|
||||
});
|
||||
});
|
||||
return <Promise<void>>(op_toPromise.call(sideEffect).then(() => undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
postMessageWithStatus(type: string, payload: Object, nonce: number): Promise<void> {
|
||||
const waitForStatus = this.waitForStatus(nonce);
|
||||
const postMessage = this.postMessage(type, payload);
|
||||
return Promise.all([waitForStatus, postMessage]).then(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
generateNonce(): number { return Math.round(Math.random() * 10000000); }
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
eventsOfType<T>(type: string): Observable<T> {
|
||||
return <Observable<T>>(
|
||||
op_filter.call(this.events, (event: T & TypedEvent) => { return event.type === type; }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
nextEventOfType<T>(type: string): Observable<T> {
|
||||
return <Observable<T>>(op_take.call(this.eventsOfType(type), 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
waitForStatus(nonce: number): Promise<void> {
|
||||
const statusEventsWithNonce = <Observable<StatusEvent>>(
|
||||
op_filter.call(this.eventsOfType('STATUS'), (event: StatusEvent) => event.nonce === nonce));
|
||||
const singleStatusEvent = <Observable<StatusEvent>>(op_take.call(statusEventsWithNonce, 1));
|
||||
const mapErrorAndValue =
|
||||
<Observable<void>>(op_map.call(singleStatusEvent, (event: StatusEvent) => {
|
||||
if (event.status) {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(event.error !);
|
||||
}));
|
||||
return op_toPromise.call(mapErrorAndValue);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* @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 {APP_INITIALIZER, ApplicationRef, Inject, InjectionToken, Injector, ModuleWithProviders, NgModule} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {filter as op_filter} from 'rxjs/operator/filter';
|
||||
import {take as op_take} from 'rxjs/operator/take';
|
||||
import {toPromise as op_toPromise} from 'rxjs/operator/toPromise';
|
||||
|
||||
import {NgswCommChannel} from './low_level';
|
||||
import {SwPush} from './push';
|
||||
import {SwUpdate} from './update';
|
||||
|
||||
export const SCRIPT = new InjectionToken<string>('NGSW_REGISTER_SCRIPT');
|
||||
export const OPTS = new InjectionToken<Object>('NGSW_REGISTER_OPTIONS');
|
||||
|
||||
export function ngswAppInitializer(
|
||||
injector: Injector, script: string, options: RegistrationOptions): Function {
|
||||
const initializer = () => {
|
||||
const app = injector.get<ApplicationRef>(ApplicationRef);
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
return;
|
||||
}
|
||||
const onStable =
|
||||
op_filter.call(app.isStable, (stable: boolean) => !!stable) as Observable<boolean>;
|
||||
const isStable = op_take.call(onStable, 1) as Observable<boolean>;
|
||||
const whenStable = op_toPromise.call(isStable) as Promise<boolean>;
|
||||
return whenStable.then(() => navigator.serviceWorker.register(script, options))
|
||||
.then(() => undefined) as Promise<void>;
|
||||
};
|
||||
return initializer;
|
||||
}
|
||||
|
||||
export function ngswCommChannelFactory(): NgswCommChannel {
|
||||
return new NgswCommChannel(navigator.serviceWorker);
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
@NgModule({
|
||||
providers: [SwPush, SwUpdate],
|
||||
})
|
||||
export class ServiceWorkerModule {
|
||||
static register(script: string, opts: RegistrationOptions = {}): ModuleWithProviders {
|
||||
return {
|
||||
ngModule: ServiceWorkerModule,
|
||||
providers: [
|
||||
{provide: SCRIPT, useValue: script},
|
||||
{provide: OPTS, useValue: opts},
|
||||
{provide: NgswCommChannel, useFactory: ngswCommChannelFactory},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: ngswAppInitializer,
|
||||
deps: [Injector, SCRIPT, OPTS],
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* @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 {Observable} from 'rxjs/Observable';
|
||||
import {Subject} from 'rxjs/Subject';
|
||||
|
||||
import {merge as obs_merge} from 'rxjs/observable/merge';
|
||||
|
||||
import {map as op_map} from 'rxjs/operator/map';
|
||||
import {switchMap as op_switchMap} from 'rxjs/operator/switchMap';
|
||||
import {take as op_take} from 'rxjs/operator/take';
|
||||
import {toPromise as op_toPromise} from 'rxjs/operator/toPromise';
|
||||
|
||||
import {NgswCommChannel} from './low_level';
|
||||
|
||||
/**
|
||||
* Subscribe and listen to push notifications from the Service Worker.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
export class SwPush {
|
||||
readonly messages: Observable<object>;
|
||||
readonly subscription: Observable<PushSubscription|null>;
|
||||
|
||||
private pushManager: Observable<PushManager>;
|
||||
private subscriptionChanges: Subject<PushSubscription|null> =
|
||||
new Subject<PushSubscription|null>();
|
||||
|
||||
constructor(private sw: NgswCommChannel) {
|
||||
this.messages =
|
||||
op_map.call(this.sw.eventsOfType('PUSH'), (message: {data: object}) => message.data);
|
||||
|
||||
this.pushManager = <Observable<PushManager>>(op_map.call(
|
||||
this.sw.registration,
|
||||
(registration: ServiceWorkerRegistration) => { return registration.pushManager; }));
|
||||
|
||||
const workerDrivenSubscriptions = <Observable<PushSubscription|null>>(op_switchMap.call(
|
||||
this.pushManager, (pm: PushManager) => pm.getSubscription().then(sub => { return sub; })));
|
||||
this.subscription = obs_merge.call(workerDrivenSubscriptions, this.subscriptionChanges);
|
||||
}
|
||||
|
||||
requestSubscription(options: {serverPublicKey: string}): Promise<PushSubscription> {
|
||||
const pushOptions: PushSubscriptionOptionsInit = {userVisibleOnly: true};
|
||||
let key = atob(options.serverPublicKey.replace(/_/g, '/').replace(/-/g, '+'));
|
||||
let applicationServerKey = new Uint8Array(new ArrayBuffer(key.length));
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
applicationServerKey[i] = key.charCodeAt(i);
|
||||
}
|
||||
pushOptions.applicationServerKey = applicationServerKey;
|
||||
const subscribe = <Observable<PushSubscription>>(
|
||||
op_switchMap.call(this.pushManager, (pm: PushManager) => pm.subscribe(pushOptions)));
|
||||
const subscribeOnce = op_take.call(subscribe, 1);
|
||||
return (op_toPromise.call(subscribeOnce) as Promise<PushSubscription>).then(sub => {
|
||||
this.subscriptionChanges.next(sub);
|
||||
return sub;
|
||||
});
|
||||
}
|
||||
|
||||
unsubscribe(): Promise<void> {
|
||||
const unsubscribe = op_switchMap.call(this.subscription, (sub: PushSubscription | null) => {
|
||||
if (sub !== null) {
|
||||
return sub.unsubscribe().then(success => {
|
||||
if (success) {
|
||||
this.subscriptionChanges.next(null);
|
||||
return undefined;
|
||||
} else {
|
||||
throw new Error('Unsubscribe failed!');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error('Not subscribed to push notifications.');
|
||||
}
|
||||
});
|
||||
const unsubscribeOnce = op_take.call(unsubscribe, 1);
|
||||
return op_toPromise.call(unsubscribeOnce) as Promise<void>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* @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 {Injectable} from '@angular/core';
|
||||
import {Observable} from 'rxjs/Observable';
|
||||
import {defer as obs_defer} from 'rxjs/observable/defer';
|
||||
import {map as op_map} from 'rxjs/operator/map';
|
||||
|
||||
import {NgswCommChannel, UpdateActivatedEvent, UpdateAvailableEvent} from './low_level';
|
||||
|
||||
|
||||
/**
|
||||
* Subscribe to update notifications from the Service Worker, trigger update
|
||||
* checks, and forcibly activate updates.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
@Injectable()
|
||||
export class SwUpdate {
|
||||
readonly available: Observable<UpdateAvailableEvent>;
|
||||
readonly activated: Observable<UpdateActivatedEvent>;
|
||||
|
||||
constructor(private sw: NgswCommChannel) {
|
||||
this.available = this.sw.eventsOfType('UPDATE_AVAILABLE');
|
||||
this.activated = this.sw.eventsOfType('UPDATE_ACTIVATED');
|
||||
}
|
||||
|
||||
checkForUpdate(): Promise<void> {
|
||||
const statusNonce = this.sw.generateNonce();
|
||||
return this.sw.postMessageWithStatus('CHECK_FOR_UPDATES', {statusNonce}, statusNonce);
|
||||
}
|
||||
|
||||
activateUpdate(): Promise<void> {
|
||||
const statusNonce = this.sw.generateNonce();
|
||||
return this.sw.postMessageWithStatus('ACTIVATE_UPDATE', {statusNonce}, statusNonce);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
function wrap(fn: () => Promise<void>): (done: DoneFn) => void {
|
||||
return (done: DoneFn) => { fn().then(() => done()).catch(err => done.fail(err)); };
|
||||
}
|
||||
|
||||
export function async_beforeAll(fn: () => Promise<void>): void {
|
||||
beforeAll(wrap(fn));
|
||||
}
|
||||
|
||||
export function async_beforeEach(fn: () => Promise<void>): void {
|
||||
beforeEach(wrap(fn));
|
||||
}
|
||||
|
||||
export function async_it(desc: string, fn: () => Promise<void>): void {
|
||||
it(desc, wrap(fn));
|
||||
}
|
||||
|
||||
export function async_fit(desc: string, fn: () => Promise<void>): void {
|
||||
// tslint:disable-next-line:no-jasmine-focus
|
||||
fit(desc, wrap(fn));
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* @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 'rxjs/add/operator/toPromise';
|
||||
|
||||
import {NgswCommChannel} from '../src/low_level';
|
||||
import {SwPush} from '../src/push';
|
||||
import {SwUpdate} from '../src/update';
|
||||
import {MockServiceWorkerContainer, MockServiceWorkerRegistration} from '../testing/mock';
|
||||
|
||||
export function main() {
|
||||
describe('ServiceWorker library', () => {
|
||||
let mock: MockServiceWorkerContainer;
|
||||
let comm: NgswCommChannel;
|
||||
beforeEach(() => {
|
||||
mock = new MockServiceWorkerContainer();
|
||||
comm = new NgswCommChannel(mock as any);
|
||||
});
|
||||
describe('NgswCommsChannel', () => {
|
||||
it('can access the registration when it comes before subscription', (done: DoneFn) => {
|
||||
const mock = new MockServiceWorkerContainer();
|
||||
const comm = new NgswCommChannel(mock as any);
|
||||
const regPromise = mock.getRegistration() as any as MockServiceWorkerRegistration;
|
||||
|
||||
mock.setupSw();
|
||||
|
||||
comm.registration.subscribe(reg => { done(); });
|
||||
});
|
||||
it('can access the registration when it comes after subscription', (done: DoneFn) => {
|
||||
const mock = new MockServiceWorkerContainer();
|
||||
const comm = new NgswCommChannel(mock as any);
|
||||
const regPromise = mock.getRegistration() as any as MockServiceWorkerRegistration;
|
||||
|
||||
comm.registration.subscribe(reg => { done(); });
|
||||
|
||||
mock.setupSw();
|
||||
});
|
||||
});
|
||||
describe('NgswPush', () => {
|
||||
let push: SwPush;
|
||||
let reg: MockServiceWorkerRegistration;
|
||||
beforeEach((done: DoneFn) => {
|
||||
push = new SwPush(comm);
|
||||
mock.setupSw();
|
||||
mock.mockRegistration.then(r => reg = r).then(() => done());
|
||||
});
|
||||
it('receives push messages', (done: DoneFn) => {
|
||||
push.messages.subscribe(msg => {
|
||||
expect(msg).toEqual({
|
||||
message: 'this was a push message',
|
||||
});
|
||||
done();
|
||||
});
|
||||
reg.sendMessage({
|
||||
type: 'PUSH',
|
||||
data: {
|
||||
message: 'this was a push message',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('NgswUpdate', () => {
|
||||
let update: SwUpdate;
|
||||
let reg: MockServiceWorkerRegistration;
|
||||
beforeEach((done: DoneFn) => {
|
||||
update = new SwUpdate(comm);
|
||||
mock.setupSw();
|
||||
mock.mockRegistration.then(r => reg = r).then(() => done());
|
||||
});
|
||||
it('processes update availability notifications when sent', (done: DoneFn) => {
|
||||
update.available.subscribe(event => {
|
||||
expect(event.current).toEqual({version: 'A'});
|
||||
expect(event.available).toEqual({version: 'B'});
|
||||
expect(event.type).toEqual('UPDATE_AVAILABLE');
|
||||
done();
|
||||
});
|
||||
reg.sendMessage({
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: {
|
||||
version: 'A',
|
||||
},
|
||||
available: {
|
||||
version: 'B',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('processes update activation notifications when sent', (done: DoneFn) => {
|
||||
update.activated.subscribe(event => {
|
||||
expect(event.previous).toEqual({version: 'A'});
|
||||
expect(event.current).toEqual({version: 'B'});
|
||||
expect(event.type).toEqual('UPDATE_ACTIVATED');
|
||||
done();
|
||||
});
|
||||
reg.sendMessage({
|
||||
type: 'UPDATE_ACTIVATED',
|
||||
previous: {
|
||||
version: 'A',
|
||||
},
|
||||
current: {
|
||||
version: 'B',
|
||||
},
|
||||
});
|
||||
});
|
||||
it('activates updates when requested', (done: DoneFn) => {
|
||||
mock.messages.subscribe((msg: {action: string, statusNonce: number}) => {
|
||||
expect(msg.action).toEqual('ACTIVATE_UPDATE');
|
||||
reg.sendMessage({
|
||||
type: 'STATUS',
|
||||
nonce: msg.statusNonce,
|
||||
status: true,
|
||||
});
|
||||
});
|
||||
return update.activateUpdate().then(() => done()).catch(err => done.fail(err));
|
||||
});
|
||||
it('reports activation failure when requested', (done: DoneFn) => {
|
||||
mock.messages.subscribe((msg: {action: string, statusNonce: number}) => {
|
||||
expect(msg.action).toEqual('ACTIVATE_UPDATE');
|
||||
reg.sendMessage({
|
||||
type: 'STATUS',
|
||||
nonce: msg.statusNonce,
|
||||
status: false,
|
||||
error: 'Failed to activate',
|
||||
});
|
||||
});
|
||||
return update.activateUpdate()
|
||||
.catch(err => { expect(err.message).toEqual('Failed to activate'); })
|
||||
.then(() => done())
|
||||
.catch(err => done.fail(err));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* @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 {Observable} from 'rxjs/Observable';
|
||||
import {take} from 'rxjs/operator/take';
|
||||
import {toPromise} from 'rxjs/operator/toPromise';
|
||||
|
||||
import {NgswCommChannel, UpdateAvailableEvent} from '../src/low_level';
|
||||
import {SwPush} from '../src/push';
|
||||
import {SwUpdate} from '../src/update';
|
||||
import {MockServiceWorkerContainer, MockServiceWorkerRegistration} from '../testing/mock';
|
||||
import {CacheDatabase} from '../worker/src/db-cache';
|
||||
import {Driver} from '../worker/src/driver';
|
||||
import {Manifest} from '../worker/src/manifest';
|
||||
import {MockRequest} from '../worker/testing/fetch';
|
||||
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../worker/testing/mock';
|
||||
import {SwTestHarness, SwTestHarnessBuilder} from '../worker/testing/scope';
|
||||
|
||||
import {async_beforeEach, async_fit, async_it} from './async';
|
||||
|
||||
const dist = new MockFileSystemBuilder().addFile('/only.txt', 'this is only').build();
|
||||
|
||||
const distUpdate = new MockFileSystemBuilder().addFile('/only.txt', 'this is only v2').build();
|
||||
|
||||
function obsToSinglePromise<T>(obs: Observable<T>): Promise<T> {
|
||||
const takeOne = take.call(obs, 1);
|
||||
return toPromise.call(takeOne);
|
||||
}
|
||||
|
||||
const manifest: Manifest = {
|
||||
configVersion: 1,
|
||||
appData: {version: '1'},
|
||||
index: '/only.txt',
|
||||
assetGroups: [{
|
||||
name: 'assets',
|
||||
installMode: 'prefetch',
|
||||
updateMode: 'prefetch',
|
||||
urls: ['/only.txt'],
|
||||
patterns: [],
|
||||
}],
|
||||
hashTable: tmpHashTableForFs(dist),
|
||||
};
|
||||
|
||||
const manifestUpdate: Manifest = {
|
||||
configVersion: 1,
|
||||
appData: {version: '2'},
|
||||
index: '/only.txt',
|
||||
assetGroups: [{
|
||||
name: 'assets',
|
||||
installMode: 'prefetch',
|
||||
updateMode: 'prefetch',
|
||||
urls: ['/only.txt'],
|
||||
patterns: [],
|
||||
}],
|
||||
hashTable: tmpHashTableForFs(distUpdate),
|
||||
};
|
||||
|
||||
const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build();
|
||||
|
||||
const serverUpdate =
|
||||
new MockServerStateBuilder().withStaticFiles(distUpdate).withManifest(manifestUpdate).build();
|
||||
|
||||
export function main() {
|
||||
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
||||
if (!SwTestHarness.envIsSupported()) {
|
||||
return;
|
||||
}
|
||||
describe('ngsw + companion lib', () => {
|
||||
let mock: MockServiceWorkerContainer;
|
||||
let comm: NgswCommChannel;
|
||||
let reg: MockServiceWorkerRegistration;
|
||||
let scope: SwTestHarness;
|
||||
let driver: Driver;
|
||||
|
||||
async_beforeEach(async() => {
|
||||
// Fire up the client.
|
||||
mock = new MockServiceWorkerContainer();
|
||||
comm = new NgswCommChannel(mock as any);
|
||||
scope = new SwTestHarnessBuilder().withServerState(server).build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
|
||||
scope.clients.add('default');
|
||||
scope.clients.getMock('default') !.queue.subscribe(msg => { reg.sendMessage(msg); });
|
||||
|
||||
mock.messages.subscribe(msg => { scope.handleMessage(msg, 'default'); });
|
||||
|
||||
mock.setupSw();
|
||||
reg = await mock.mockRegistration;
|
||||
|
||||
await Promise.all(scope.handleFetch(new MockRequest('/only.txt'), 'default'));
|
||||
await driver.initialized;
|
||||
});
|
||||
|
||||
async_it('communicates back and forth via update check', async() => {
|
||||
const update = new SwUpdate(comm);
|
||||
await update.checkForUpdate();
|
||||
});
|
||||
|
||||
async_it('detects an actual update', async() => {
|
||||
const update = new SwUpdate(comm);
|
||||
scope.updateServerState(serverUpdate);
|
||||
|
||||
const gotUpdateNotice =
|
||||
(async() => { const notice = await obsToSinglePromise(update.available); })();
|
||||
|
||||
await update.checkForUpdate();
|
||||
await gotUpdateNotice;
|
||||
});
|
||||
|
||||
async_it('receives push message notifications', async() => {
|
||||
const push = new SwPush(comm);
|
||||
scope.updateServerState(serverUpdate);
|
||||
|
||||
const gotPushNotice = (async() => {
|
||||
const message = await obsToSinglePromise(push.messages);
|
||||
expect(message).toEqual({
|
||||
test: 'success',
|
||||
});
|
||||
})();
|
||||
|
||||
await scope.handlePush({
|
||||
test: 'success',
|
||||
});
|
||||
await gotPushNotice;
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* @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 {Subject} from 'rxjs/Subject';
|
||||
|
||||
export class MockServiceWorkerContainer {
|
||||
private onControllerChange: Function[] = [];
|
||||
private registration: MockServiceWorkerRegistration|null = null;
|
||||
controller: MockServiceWorker|null = null;
|
||||
|
||||
messages = new Subject();
|
||||
|
||||
addEventListener(event: 'controllerchange', handler: Function) {
|
||||
this.onControllerChange.push(handler);
|
||||
}
|
||||
|
||||
removeEventListener(event: 'controllerchange', handler: Function) {
|
||||
this.onControllerChange = this.onControllerChange.filter(h => h !== handler);
|
||||
}
|
||||
|
||||
async register(url: string): Promise<void> { return; }
|
||||
|
||||
async getRegistration(): Promise<ServiceWorkerRegistration> { return this.registration as any; }
|
||||
|
||||
setupSw(url: string = '/ngsw-worker.js'): void {
|
||||
this.registration = new MockServiceWorkerRegistration();
|
||||
this.controller = new MockServiceWorker(this, url);
|
||||
this.onControllerChange.forEach(onChange => onChange(this.controller));
|
||||
}
|
||||
|
||||
get mockRegistration(): Promise<MockServiceWorkerRegistration> {
|
||||
return Promise.resolve(this.registration !);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockServiceWorker {
|
||||
constructor(private mock: MockServiceWorkerContainer, readonly scriptURL: string) {}
|
||||
|
||||
postMessage(value: Object) { this.mock.messages.next(value); }
|
||||
}
|
||||
|
||||
export class MockServiceWorkerRegistration {
|
||||
private onMessage: Function[] = [];
|
||||
messages: Object[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
addEventListener(event: 'message', handler: Function) { this.onMessage.push(handler); }
|
||||
|
||||
removeEventListener(event: 'message', handler: Function) {
|
||||
this.onMessage = this.onMessage.filter(h => h !== handler);
|
||||
}
|
||||
|
||||
sendMessage(value: Object): void {
|
||||
this.onMessage.forEach(onMessage => onMessage({
|
||||
data: value,
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"extends": "../tsconfig-build.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@angular/core": ["../../dist/packages/core"]
|
||||
},
|
||||
"outDir": "../../dist/packages/service-worker"
|
||||
},
|
||||
|
||||
"files": [
|
||||
"public_api.ts",
|
||||
"../../node_modules/zone.js/dist/zone.js.d.ts"
|
||||
],
|
||||
|
||||
"angularCompilerOptions": {
|
||||
"annotateForClosureCompiler": true,
|
||||
"strictMetadataEmit": false,
|
||||
"flatModuleOutFile": "service-worker.js",
|
||||
"flatModuleId": "@angular/service-worker"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* @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 {Adapter} from './src/adapter';
|
||||
import {CacheDatabase} from './src/db-cache';
|
||||
import {Driver} from './src/driver';
|
||||
|
||||
const scope = self as any as ServiceWorkerGlobalScope;
|
||||
|
||||
const adapter = new Adapter();
|
||||
const driver = new Driver(scope, adapter, new CacheDatabase(scope, adapter));
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export default {
|
||||
entry: '../../dist/all/@angular/service-worker/worker-es2017/main.js',
|
||||
dest: '../../dist/packages-dist/service-worker/ngsw-worker.js',
|
||||
format: 'iife',
|
||||
};
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Adapts the service worker to its runtime environment.
|
||||
*
|
||||
* Mostly, this is used to mock out identifiers which are otherwise read
|
||||
* from the global scope.
|
||||
*/
|
||||
export class Adapter {
|
||||
/**
|
||||
* Wrapper around the `Request` constructor.
|
||||
*/
|
||||
newRequest(input: string|Request, init?: RequestInit): Request {
|
||||
return new Request(input, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around the `Response` constructor.
|
||||
*/
|
||||
newResponse(body: any, init?: ResponseInit) { return new Response(body, init); }
|
||||
|
||||
/**
|
||||
* Wrapper around the `Headers` constructor.
|
||||
*/
|
||||
newHeaders(headers: {[name: string]: string}): Headers { return new Headers(headers); }
|
||||
|
||||
/**
|
||||
* Test if a given object is an instance of `Client`.
|
||||
*/
|
||||
isClient(source: any): source is Client { return (source instanceof Client); }
|
||||
|
||||
/**
|
||||
* Read the current UNIX time in milliseconds.
|
||||
*/
|
||||
get time(): number { return Date.now(); }
|
||||
|
||||
/**
|
||||
* Extract the pathname of a URL.
|
||||
*/
|
||||
getPath(url: string): string {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a given amount of time before completing a Promise.
|
||||
*/
|
||||
timeout(ms: number): Promise<void> {
|
||||
return new Promise<void>(resolve => { setTimeout(() => resolve(), ms); });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An event context in which an operation is taking place, which allows
|
||||
* the delaying of Service Worker shutdown until certain triggers occur.
|
||||
*/
|
||||
export interface Context {
|
||||
/**
|
||||
* Delay shutdown of the Service Worker until the given promise resolves.
|
||||
*/
|
||||
waitUntil(fn: Promise<any>): void;
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export enum UpdateCacheStatus {
|
||||
NOT_CACHED,
|
||||
CACHED_BUT_UNUSED,
|
||||
CACHED,
|
||||
}
|
||||
|
||||
/**
|
||||
* A source for old versions of URL contents and other resources.
|
||||
*
|
||||
* Used to abstract away the fetching of old contents, to avoid a
|
||||
* circular dependency between the `Driver` and `AppVersion`. Without
|
||||
* this interface, `AppVersion` would need a reference to the `Driver`
|
||||
* to access information from other versions.
|
||||
*/
|
||||
export interface UpdateSource {
|
||||
/**
|
||||
* Lookup an older version of a resource for which the hash is known.
|
||||
*
|
||||
* If an old version of the resource doesn't exist, or exists but does
|
||||
* not match the hash given, this returns null.
|
||||
*/
|
||||
lookupResourceWithHash(url: string, hash: string): Promise<Response|null>;
|
||||
|
||||
/**
|
||||
* Lookup an older version of a resource for which the hash is not known.
|
||||
*
|
||||
* This will return the most recent previous version of the resource, if
|
||||
* it exists. It returns a `CacheState` object which encodes not only the
|
||||
* `Response`, but the cache metadata needed to re-cache the resource in
|
||||
* a newer `AppVersion`.
|
||||
*/
|
||||
lookupResourceWithoutHash(url: string): Promise<CacheState|null>;
|
||||
|
||||
/**
|
||||
* List the URLs of all of the resources which were previously cached.
|
||||
*
|
||||
* This allows for the discovery of resources which are not listed in the
|
||||
* manifest but which were picked up because they matched URL patterns.
|
||||
*/
|
||||
previouslyCachedResources(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Check whether a particular resource exists in the most recent cache.
|
||||
*
|
||||
* This returns a state value which indicates whether the resource was
|
||||
* cached at all and whether that cache was utilized.
|
||||
*/
|
||||
recentCacheStatus(url: string): Promise<UpdateCacheStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata cached along with a URL.
|
||||
*/
|
||||
export interface UrlMetadata {
|
||||
/**
|
||||
* The timestamp, in UNIX time in milliseconds, of when this URL was stored
|
||||
* in the cache.
|
||||
*/
|
||||
ts: number;
|
||||
|
||||
/**
|
||||
* Whether the resource was requested before for this particular cached
|
||||
* instance.
|
||||
*/
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fully cached state of a resource, including both the `Response` itself
|
||||
* and the cache metadata.
|
||||
*/
|
||||
export interface CacheState {
|
||||
response: Response;
|
||||
metadata?: UrlMetadata;
|
||||
}
|
||||
|
||||
export interface DebugState {
|
||||
state: string;
|
||||
why: string;
|
||||
latestHash: string|null;
|
||||
lastUpdateCheck: number|null;
|
||||
}
|
||||
|
||||
export interface DebugVersion {
|
||||
hash: string;
|
||||
manifest: Object;
|
||||
clients: string[];
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DebugIdleState {
|
||||
queue: string[];
|
||||
lastTrigger: number|null;
|
||||
lastRun: number|null;
|
||||
}
|
||||
|
||||
export interface Debuggable {
|
||||
debugState(): Promise<DebugState>;
|
||||
debugVersions(): Promise<DebugVersion[]>;
|
||||
debugIdleState(): Promise<DebugIdleState>;
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* @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 {Adapter, Context} from './adapter';
|
||||
import {CacheState, UpdateCacheStatus, UpdateSource} from './api';
|
||||
import {AssetGroup, LazyAssetGroup, PrefetchAssetGroup} from './assets';
|
||||
import {DataGroup} from './data';
|
||||
import {Database} from './database';
|
||||
import {IdleScheduler} from './idle';
|
||||
import {Manifest} from './manifest';
|
||||
import {isNavigationRequest} from './util';
|
||||
|
||||
|
||||
/**
|
||||
* A specific version of the application, identified by a unique manifest
|
||||
* as determined by its hash.
|
||||
*
|
||||
* Each `AppVersion` can be thought of as a published version of the app
|
||||
* that can be installed as an update to any previously installed versions.
|
||||
*/
|
||||
export class AppVersion implements UpdateSource {
|
||||
/**
|
||||
* A Map of absolute URL paths (/foo.txt) to the known hash of their
|
||||
* contents (if available).
|
||||
*/
|
||||
private hashTable = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* All of the asset groups active in this version of the app.
|
||||
*/
|
||||
private assetGroups: AssetGroup[];
|
||||
|
||||
/**
|
||||
* All of the data groups active in this version of the app.
|
||||
*/
|
||||
private dataGroups: DataGroup[];
|
||||
|
||||
/**
|
||||
* Tracks whether the manifest has encountered any inconsistencies.
|
||||
*/
|
||||
private _okay = true;
|
||||
|
||||
get okay(): boolean { return this._okay; }
|
||||
|
||||
constructor(
|
||||
private scope: ServiceWorkerGlobalScope, private adapter: Adapter, private database: Database,
|
||||
private idle: IdleScheduler, readonly manifest: Manifest, readonly manifestHash: string) {
|
||||
// The hashTable within the manifest is an Object - convert it to a Map for easier lookups.
|
||||
Object.keys(this.manifest.hashTable).forEach(url => {
|
||||
this.hashTable.set(url, this.manifest.hashTable[url]);
|
||||
});
|
||||
|
||||
// Process each `AssetGroup` declared in the manifest. Each declared group gets an `AssetGroup`
|
||||
// instance
|
||||
// created for it, of a type that depends on the configuration mode.
|
||||
this.assetGroups = (manifest.assetGroups || []).map(config => {
|
||||
// Every asset group has a cache that's prefixed by the manifest hash and the name of the
|
||||
// group.
|
||||
const prefix = `ngsw:${this.manifestHash}:assets`;
|
||||
// Check the caching mode, which determines when resources will be fetched/updated.
|
||||
switch (config.installMode) {
|
||||
case 'prefetch':
|
||||
return new PrefetchAssetGroup(
|
||||
this.scope, this.adapter, this.idle, config, this.hashTable, this.database, prefix);
|
||||
case 'lazy':
|
||||
return new LazyAssetGroup(
|
||||
this.scope, this.adapter, this.idle, config, this.hashTable, this.database, prefix);
|
||||
}
|
||||
});
|
||||
|
||||
// Process each `DataGroup` declared in the manifest.
|
||||
this.dataGroups =
|
||||
(manifest.dataGroups || [])
|
||||
.map(
|
||||
config => new DataGroup(
|
||||
this.scope, this.adapter, config, this.database, `${config.version}:data`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully initialize this version of the application. If this Promise resolves successfully, all
|
||||
* required
|
||||
* data has been safely downloaded.
|
||||
*/
|
||||
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
|
||||
try {
|
||||
// Fully initialize each asset group, in series. Starts with an empty Promise, and waits for
|
||||
// the previous
|
||||
// groups to have been initialized before initializing the next one in turn.
|
||||
await this.assetGroups.reduce<Promise<void>>(async(previous, group) => {
|
||||
// Wait for the previous groups to complete initialization. If there is a failure, this will
|
||||
// throw, and
|
||||
// each subsequent group will throw, until the whole sequence fails.
|
||||
await previous;
|
||||
|
||||
// Initialize this group.
|
||||
return group.initializeFully(updateFrom);
|
||||
}, Promise.resolve());
|
||||
} catch (err) {
|
||||
this._okay = false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async handleFetch(req: Request, context: Context): Promise<Response|null> {
|
||||
// Check the request against each `AssetGroup` in sequence. If an `AssetGroup` can't handle the
|
||||
// request,
|
||||
// it will return `null`. Thus, the first non-null response is the SW's answer to the request.
|
||||
// So reduce
|
||||
// the group list, keeping track of a possible response. If there is one, it gets passed
|
||||
// through, and if
|
||||
// not the next group is consulted to produce a candidate response.
|
||||
const asset = await this.assetGroups.reduce(async(potentialResponse, group) => {
|
||||
// Wait on the previous potential response. If it's not null, it should just be passed
|
||||
// through.
|
||||
const resp = await potentialResponse;
|
||||
if (resp !== null) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// No response has been found yet. Maybe this group will have one.
|
||||
return group.handleFetch(req, context);
|
||||
}, Promise.resolve(null));
|
||||
|
||||
// The result of the above is the asset response, if there is any, or null otherwise. Return the
|
||||
// asset
|
||||
// response if there was one. If not, check with the data caching groups.
|
||||
if (asset !== null) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
// Perform the same reduction operation as above, but this time processing
|
||||
// the data caching groups.
|
||||
const data = await this.dataGroups.reduce(async(potentialResponse, group) => {
|
||||
const resp = await potentialResponse;
|
||||
if (resp !== null) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
return group.handleFetch(req, context);
|
||||
}, Promise.resolve(null));
|
||||
|
||||
// If the data caching group returned a response, go with it.
|
||||
if (data !== null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Next, check if this is a navigation request for a route. Detect circular
|
||||
// navigations by checking if the request URL is the same as the index URL.
|
||||
if (isNavigationRequest(req, this.adapter) && req.url !== this.manifest.index) {
|
||||
// This was a navigation request. Re-enter `handleFetch` with a request for
|
||||
// the URL.
|
||||
return this.handleFetch(this.adapter.newRequest(this.manifest.index), context);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check this version for a given resource with a particular hash.
|
||||
*/
|
||||
async lookupResourceWithHash(url: string, hash: string): Promise<Response|null> {
|
||||
const req = this.adapter.newRequest(url);
|
||||
|
||||
// Verify that this version has the requested resource cached. If not,
|
||||
// there's no point in trying.
|
||||
if (!this.hashTable.has(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Next, check whether the resource has the correct hash. If not, any cached
|
||||
// response isn't usable.
|
||||
if (this.hashTable.get(url) ! !== hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: no-op context and appropriate contract. Currently this is a violation
|
||||
// of the typings and could cause issues if handleFetch() has side effects. A
|
||||
// better strategy to deal with side effects is needed.
|
||||
// TODO: this could result in network fetches if the response is lazy. Refactor
|
||||
// to avoid them.
|
||||
return this.handleFetch(req, null !);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check this version for a given resource regardless of its hash.
|
||||
*/
|
||||
lookupResourceWithoutHash(url: string): Promise<CacheState|null> {
|
||||
// Limit the search to asset groups, and only scan the cache, don't
|
||||
// load resources from the network.
|
||||
return this.assetGroups.reduce(async(potentialResponse, group) => {
|
||||
const resp = await potentialResponse;
|
||||
if (resp !== null) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
// fetchFromCacheOnly() avoids any network fetches, and returns the
|
||||
// full set of cache data, not just the Response.
|
||||
return group.fetchFromCacheOnly(url);
|
||||
}, Promise.resolve<CacheState|null>(null));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all unhashed resources from all asset groups.
|
||||
*/
|
||||
previouslyCachedResources(): Promise<string[]> {
|
||||
return this.assetGroups.reduce(async(resources, group) => {
|
||||
return (await resources).concat(await group.unhashedResources());
|
||||
}, Promise.resolve<string[]>([]));
|
||||
}
|
||||
|
||||
async recentCacheStatus(url: string): Promise<UpdateCacheStatus> {
|
||||
return this.assetGroups.reduce(async(current, group) => {
|
||||
const status = await current;
|
||||
if (status === UpdateCacheStatus.CACHED) {
|
||||
return status;
|
||||
}
|
||||
const groupStatus = await group.cacheStatus(url);
|
||||
if (groupStatus === UpdateCacheStatus.NOT_CACHED) {
|
||||
return status;
|
||||
}
|
||||
return groupStatus;
|
||||
}, Promise.resolve(UpdateCacheStatus.NOT_CACHED));
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase this application version, by cleaning up all the caches.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await Promise.all(this.assetGroups.map(group => group.cleanup()));
|
||||
await Promise.all(this.dataGroups.map(group => group.cleanup()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the opaque application data which was provided with the manifest.
|
||||
*/
|
||||
get appData(): Object|null { return this.manifest.appData || null; }
|
||||
}
|
|
@ -0,0 +1,556 @@
|
|||
/**
|
||||
* @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 {Adapter, Context} from './adapter';
|
||||
import {CacheState, UpdateCacheStatus, UpdateSource, UrlMetadata} from './api';
|
||||
import {Database, Table} from './database';
|
||||
import {IdleScheduler} from './idle';
|
||||
import {AssetGroupConfig} from './manifest';
|
||||
import {sha1} from './sha1';
|
||||
|
||||
/**
|
||||
* A group of assets that are cached in a `Cache` and managed by a given policy.
|
||||
*
|
||||
* Concrete classes derive from this base and specify the exact caching policy.
|
||||
*/
|
||||
export abstract class AssetGroup {
|
||||
/**
|
||||
* A deduplication cache, to make sure the SW never makes two network requests
|
||||
* for the same resource at once. Managed by `fetchAndCacheOnce`.
|
||||
*/
|
||||
private inFlightRequests = new Map<string, Promise<Response>>();
|
||||
|
||||
/**
|
||||
* Regular expression patterns.
|
||||
*/
|
||||
protected patterns: RegExp[] = [];
|
||||
|
||||
/**
|
||||
* A Promise which resolves to the `Cache` used to back this asset group. This
|
||||
* is openedfrom the constructor.
|
||||
*/
|
||||
protected cache: Promise<Cache>;
|
||||
|
||||
/**
|
||||
* Group name from the configuration.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Metadata associated with specific cache entries.
|
||||
*/
|
||||
protected metadata: Promise<Table>;
|
||||
|
||||
constructor(
|
||||
protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter,
|
||||
protected idle: IdleScheduler, protected config: AssetGroupConfig,
|
||||
protected hashes: Map<string, string>, protected db: Database, protected prefix: string) {
|
||||
this.name = config.name;
|
||||
// Patterns in the config are regular expressions disguised as strings. Breathe life into them.
|
||||
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
|
||||
|
||||
// This is the primary cache, which holds all of the cached requests for this group. If a
|
||||
// resource
|
||||
// isn't in this cache, it hasn't been fetched yet.
|
||||
this.cache = this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`);
|
||||
|
||||
// This is the metadata table, which holds specific information for each cached URL, such as
|
||||
// the timestamp of when it was added to the cache.
|
||||
this.metadata = this.db.open(`${this.prefix}:${this.config.name}:meta`);
|
||||
}
|
||||
|
||||
async cacheStatus(url: string): Promise<UpdateCacheStatus> {
|
||||
const cache = await this.cache;
|
||||
const meta = await this.metadata;
|
||||
const res = await cache.match(this.adapter.newRequest(url));
|
||||
if (res === undefined) {
|
||||
return UpdateCacheStatus.NOT_CACHED;
|
||||
}
|
||||
try {
|
||||
const data = await meta.read<UrlMetadata>(url);
|
||||
if (!data.used) {
|
||||
return UpdateCacheStatus.CACHED_BUT_UNUSED;
|
||||
}
|
||||
} catch (_) {
|
||||
// Error on the side of safety and assume cached.
|
||||
}
|
||||
return UpdateCacheStatus.CACHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize this asset group, updating from the given source if available.
|
||||
*/
|
||||
abstract initializeFully(updateFrom?: UpdateSource): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clean up all the cached data for this group.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
await this.scope.caches.delete(`${this.prefix}:${this.config.name}:cache`);
|
||||
await this.db.delete(`${this.prefix}:${this.config.name}:meta`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a request for a given resource and return it, or return null if it's not available.
|
||||
*/
|
||||
async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
|
||||
// Either the request matches one of the known resource URLs, one of the patterns for
|
||||
// dynamically matched URLs, or neither. Determine which is the case for this request in
|
||||
// order to decide how to handle it.
|
||||
if (this.config.urls.indexOf(req.url) !== -1 ||
|
||||
this.patterns.some(pattern => pattern.test(req.url))) {
|
||||
// This URL matches a known resource. Either it's been cached already or it's missing, in
|
||||
// which case it needs to be loaded from the network.
|
||||
|
||||
// Open the cache to check whether this resource is present.
|
||||
const cache = await this.cache;
|
||||
|
||||
// Look for a cached response. If one exists, it can be used to resolve the fetch
|
||||
// operation.
|
||||
const cachedResponse = await cache.match(req);
|
||||
if (cachedResponse !== undefined) {
|
||||
// A response has already been cached (which presumably matches the hash for this
|
||||
// resource). Check whether it's safe to serve this resource from cache.
|
||||
if (this.hashes.has(req.url)) {
|
||||
// This resource has a hash, and thus is versioned by the manifest. It's safe to return
|
||||
// the response.
|
||||
return cachedResponse;
|
||||
} else {
|
||||
// This resource has no hash, and yet exists in the cache. Check how old this request is
|
||||
// to make sure it's still usable.
|
||||
if (await this.needToRevalidate(req, cachedResponse)) {
|
||||
this.idle.schedule(
|
||||
`revalidate(${this.prefix}, ${this.config.name}): ${req.url}`,
|
||||
async() => { await this.fetchAndCacheOnce(req); });
|
||||
}
|
||||
|
||||
// In either case (revalidation or not), the cached response must be good.
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
// No already-cached response exists, so attempt a fetch/cache operation.
|
||||
const res = await this.fetchAndCacheOnce(req);
|
||||
|
||||
// If this is successful, the response needs to be cloned as it might be used to respond to
|
||||
// multiple fetch operations at the same time.
|
||||
return res.clone();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Some resources are cached without a hash, meaning that their expiration is controlled
|
||||
* by HTTP caching headers. Check whether the given request/response pair is still valid
|
||||
* per the caching headers.
|
||||
*/
|
||||
private async needToRevalidate(req: Request, res: Response): Promise<boolean> {
|
||||
// Three different strategies apply here:
|
||||
// 1) The request has a Cache-Control header, and thus expiration needs to be based on its age.
|
||||
// 2) The request has an Expires header, and expiration is based on the current timestamp.
|
||||
// 3) The request has no applicable caching headers, and must be revalidated.
|
||||
if (res.headers.has('Cache-Control')) {
|
||||
// Figure out if there is a max-age directive in the Cache-Control header.
|
||||
const cacheControl = res.headers.get('Cache-Control') !;
|
||||
const cacheDirectives =
|
||||
cacheControl
|
||||
// Directives are comma-separated within the Cache-Control header value.
|
||||
.split(',')
|
||||
// Make sure each directive doesn't have extraneous whitespace.
|
||||
.map(v => v.trim())
|
||||
// Some directives have values (like maxage and s-maxage)
|
||||
.map(v => v.split('='));
|
||||
|
||||
// Lowercase all the directive names.
|
||||
cacheDirectives.forEach(v => v[0] = v[0].toLowerCase());
|
||||
|
||||
// Find the max-age directive, if one exists.
|
||||
const cacheAge = cacheDirectives.filter(v => v[0] === 'max-age').map(v => v[1])[0];
|
||||
|
||||
if (cacheAge.length === 0) {
|
||||
// No usable TTL defined. Must assume that the response is stale.
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const maxAge = 1000 * parseInt(cacheAge);
|
||||
|
||||
// Determine the origin time of this request. If the SW has metadata on the request (which
|
||||
// it
|
||||
// should), it will have the time the request was added to the cache. If it doesn't for some
|
||||
// reason, the request may have a Date header which will serve the same purpose.
|
||||
let ts: number;
|
||||
try {
|
||||
// Check the metadata table. If a timestamp is there, use it.
|
||||
const metaTable = await this.metadata;
|
||||
ts = (await metaTable.read<UrlMetadata>(req.url)).ts;
|
||||
} catch (e) {
|
||||
// Otherwise, look for a Date header.
|
||||
const date = res.headers.get('Date');
|
||||
if (date === null) {
|
||||
// Unable to determine when this response was created. Assume that it's stale, and
|
||||
// revalidate it.
|
||||
return true;
|
||||
}
|
||||
ts = Date.parse(date);
|
||||
}
|
||||
const age = this.adapter.time - ts;
|
||||
return age < 0 || age > maxAge;
|
||||
} catch (e) {
|
||||
// Assume stale.
|
||||
return true;
|
||||
}
|
||||
} else if (res.headers.has('Expires')) {
|
||||
// Determine if the expiration time has passed.
|
||||
const expiresStr = res.headers.get('Expires') !;
|
||||
try {
|
||||
// The request needs to be revalidated if the current time is later than the expiration
|
||||
// time, if it parses correctly.
|
||||
return this.adapter.time > Date.parse(expiresStr);
|
||||
} catch (e) {
|
||||
// The expiration date failed to parse, so revalidate as a precaution.
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// No way to evaluate staleness, so assume the response is already stale.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the complete state of a cached resource, or return null if it's not found.
|
||||
*/
|
||||
async fetchFromCacheOnly(url: string): Promise<CacheState|null> {
|
||||
const cache = await this.cache;
|
||||
const metaTable = await this.metadata;
|
||||
|
||||
// Lookup the response in the cache.
|
||||
const response = await cache.match(this.adapter.newRequest(url));
|
||||
if (response === undefined) {
|
||||
// It's not found, return null.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Next, lookup the cached metadata.
|
||||
let metadata: UrlMetadata|undefined = undefined;
|
||||
try {
|
||||
metadata = await metaTable.read<UrlMetadata>(url);
|
||||
} catch (e) {
|
||||
// Do nothing, not found. This shouldn't happen, but it can be handled.
|
||||
}
|
||||
|
||||
// Return both the response and any available metadata.
|
||||
return {response, metadata};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup all resources currently stored in the cache which have no associated hash.
|
||||
*/
|
||||
async unhashedResources(): Promise<string[]> {
|
||||
const cache = await this.cache;
|
||||
// Start with the set of all cached URLs.
|
||||
return (await cache.keys())
|
||||
// Exclude the URLs which have hashes.
|
||||
.filter(url => !this.hashes.has(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the given resource from the network, and cache it if able.
|
||||
*/
|
||||
protected async fetchAndCacheOnce(req: Request, used: boolean = true): Promise<Response> {
|
||||
// The `inFlightRequests` map holds information about which caching operations are currently
|
||||
// underway for known resources. If this request appears there, another "thread" is already
|
||||
// in the process of caching it, and this work should not be duplicated.
|
||||
if (this.inFlightRequests.has(req.url)) {
|
||||
// There is a caching operation already in progress for this request. Wait for it to
|
||||
// complete, and hopefully it will have yielded a useful response.
|
||||
return this.inFlightRequests.get(req.url) !;
|
||||
}
|
||||
|
||||
|
||||
// No other caching operation is being attempted for this resource, so it will be owned here.
|
||||
// Go to the network and get the correct version.
|
||||
const fetchOp = this.fetchFromNetwork(req);
|
||||
|
||||
// Save this operation in `inFlightRequests` so any other "thread" attempting to cache it
|
||||
// will block on this chain instead of duplicating effort.
|
||||
this.inFlightRequests.set(req.url, fetchOp);
|
||||
|
||||
// Make sure this attempt is cleaned up properly on failure.
|
||||
try {
|
||||
// Wait for a response. If this fails, the request will remain in `inFlightRequests`
|
||||
// indefinitely.
|
||||
const res = await fetchOp;
|
||||
|
||||
// It's very important that only successful responses are cached. Unsuccessful responses
|
||||
// should never be cached as this can completely break applications.
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Response not Ok (fetchAndCacheOnce): request for ${req.url} returned response ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
// This response is safe to cache (as long as it's cloned). Wait until the cache operation
|
||||
// is complete.
|
||||
const cache = await this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`);
|
||||
await cache.put(req, res.clone());
|
||||
|
||||
// If the request is not hashed, update its metadata, especially the timestamp. This is needed
|
||||
// for future determination of whether this cached response is stale or not.
|
||||
if (!this.hashes.has(req.url)) {
|
||||
// Metadata is tracked for requests that are unhashed.
|
||||
const meta: UrlMetadata = {ts: this.adapter.time, used};
|
||||
const metaTable = await this.metadata;
|
||||
await metaTable.write(req.url, meta);
|
||||
}
|
||||
|
||||
return res;
|
||||
} finally {
|
||||
// Finally, it can be removed from `inFlightRequests`. This might result in a double-remove
|
||||
// if some other chain was already making this request too, but that won't hurt anything.
|
||||
this.inFlightRequests.delete(req.url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a particular asset from the network, accounting for hash validation.
|
||||
*/
|
||||
protected async fetchFromNetwork(req: Request): Promise<Response> {
|
||||
// If a hash is available for this resource, then compare the fetched version with the
|
||||
// canonical hash. Otherwise, the network version will have to be trusted.
|
||||
if (this.hashes.has(req.url)) {
|
||||
// It turns out this resource does have a hash. Look it up. Unless the fetched version
|
||||
// matches this hash, it's invalid and the whole manifest may need to be thrown out.
|
||||
const canonicalHash = this.hashes.get(req.url) !;
|
||||
|
||||
// Ideally, the resource would be requested with cache-busting to guarantee the SW gets
|
||||
// the freshest version. However, doing this would eliminate any chance of the response
|
||||
// being in the HTTP cache. Given that the browser has recently actively loaded the page,
|
||||
// it's likely that many of the responses the SW needs to cache are in the HTTP cache and
|
||||
// are fresh enough to use. In the future, this could be done by setting cacheMode to
|
||||
// *only* check the browser cache for a cached version of the resource, when cacheMode is
|
||||
// fully supported. For now, the resource is fetched directly, without cache-busting, and
|
||||
// if the hash test fails a cache-busted request is tried before concluding that the
|
||||
// resource isn't correct. This gives the benefit of acceleration via the HTTP cache
|
||||
// without the risk of stale data, at the expense of a duplicate request in the event of
|
||||
// a stale response.
|
||||
|
||||
// Fetch the resource from the network (possibly hitting the HTTP cache).
|
||||
const networkResult = await this.scope.fetch(req);
|
||||
|
||||
// Decide whether a cache-busted request is necessary. It might be for two independent
|
||||
// reasons: either the non-cache-busted request failed (hopefully transiently) or if the
|
||||
// hash of the content retrieved does not match the canonical hash from the manifest. It's
|
||||
// only valid to access the content of the first response if the request was successful.
|
||||
let makeCacheBustedRequest: boolean = networkResult.ok;
|
||||
if (makeCacheBustedRequest) {
|
||||
// The request was successful. A cache-busted request is only necessary if the hashes
|
||||
// don't match. Compare them, making sure to clone the response so it can be used later
|
||||
// if it proves to be valid.
|
||||
const fetchedHash = sha1(await networkResult.clone().text());
|
||||
makeCacheBustedRequest = (fetchedHash !== canonicalHash);
|
||||
}
|
||||
|
||||
// Make a cache busted request to the network, if necessary.
|
||||
if (makeCacheBustedRequest) {
|
||||
// Hash failure, the version that was retrieved under the default URL did not have the
|
||||
// hash expected. This could be because the HTTP cache got in the way and returned stale
|
||||
// data, or because the version on the server really doesn't match. A cache-busting
|
||||
// request will differentiate these two situations.
|
||||
// TODO: handle case where the URL has parameters already (unlikely for assets).
|
||||
const cacheBustedResult = await this.scope.fetch(this.cacheBust(req.url));
|
||||
|
||||
// If the response was unsuccessful, there's nothing more that can be done.
|
||||
if (!cacheBustedResult.ok) {
|
||||
throw new Error(
|
||||
`Response not Ok (fetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`);
|
||||
}
|
||||
|
||||
// Hash the contents.
|
||||
const cacheBustedHash = sha1(await cacheBustedResult.clone().text());
|
||||
|
||||
// If the cache-busted version doesn't match, then the manifest is not an accurate
|
||||
// representation of the server's current set of files, and the SW should give up.
|
||||
if (canonicalHash !== cacheBustedHash) {
|
||||
throw new Error(
|
||||
`Hash mismatch (${req.url}): expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`);
|
||||
}
|
||||
|
||||
// If it does match, then use the cache-busted result.
|
||||
return cacheBustedResult;
|
||||
}
|
||||
|
||||
// Excellent, the version from the network matched on the first try, with no need for
|
||||
// cache-busting. Use it.
|
||||
return networkResult;
|
||||
} else {
|
||||
// This URL doesn't exist in our hash database, so it must be requested directly.
|
||||
return this.scope.fetch(req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Possibly update a resource, if it's expired and needs to be updated. A no-op otherwise.
|
||||
*/
|
||||
protected async maybeUpdate(updateFrom: UpdateSource, req: Request, cache: Cache):
|
||||
Promise<boolean> {
|
||||
const meta = await this.metadata;
|
||||
// Check if this resource is hashed and already exists in the cache of a prior version.
|
||||
if (this.hashes.has(req.url)) {
|
||||
const hash = this.hashes.get(req.url) !;
|
||||
|
||||
// Check the caches of prior versions, using the hash to ensure the correct version of
|
||||
// the resource is loaded.
|
||||
const res = await updateFrom.lookupResourceWithHash(req.url, hash);
|
||||
|
||||
// If a previously cached version was available, copy it over to this cache.
|
||||
if (res !== null) {
|
||||
// Copy to this cache.
|
||||
await cache.put(req, res);
|
||||
await meta.write(req.url, { ts: this.adapter.time, used: false } as UrlMetadata);
|
||||
|
||||
// No need to do anything further with this resource, it's now cached properly.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// No up-to-date version of this resource could be found.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a cache-busting URL for a given URL.
|
||||
*/
|
||||
private cacheBust(url: string): string {
|
||||
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'ngsw-cache-bust=' + Math.random();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An `AssetGroup` that prefetches all of its resources during initialization.
|
||||
*/
|
||||
export class PrefetchAssetGroup extends AssetGroup {
|
||||
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
|
||||
// Open the cache which actually holds requests.
|
||||
const cache = await this.cache;
|
||||
|
||||
// Cache all known resources serially. As this reduce proceeds, each Promise waits
|
||||
// on the last before starting the fetch/cache operation for the next request. Any
|
||||
// errors cause fall-through to the final Promise which rejects.
|
||||
await this.config.urls.reduce(async(previous: Promise<void>, url: string) => {
|
||||
// Wait on all previous operations to complete.
|
||||
await previous;
|
||||
|
||||
// Construct the Request for this url.
|
||||
const req = this.adapter.newRequest(url);
|
||||
|
||||
// First, check the cache to see if there is already a copy of this resource.
|
||||
const alreadyCached = (await cache.match(req)) !== undefined;
|
||||
|
||||
// If the resource is in the cache already, it can be skipped.
|
||||
if (alreadyCached) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an update source is available.
|
||||
if (updateFrom !== undefined && await this.maybeUpdate(updateFrom, req, cache)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, go to the network and hopefully cache the response (if successful).
|
||||
await this.fetchAndCacheOnce(req, false);
|
||||
}, Promise.resolve());
|
||||
|
||||
// Handle updating of unknown (unhashed) resources. This is only possible if there's
|
||||
// a source to update from.
|
||||
if (updateFrom !== undefined) {
|
||||
const metaTable = await this.metadata;
|
||||
|
||||
// Select all of the previously cached resources. These are cached unhashed resources
|
||||
// from previous versions of the app, in any asset group.
|
||||
await(await updateFrom.previouslyCachedResources())
|
||||
// First, narrow down the set of resources to those which are handled by this group.
|
||||
// Either it's a known URL, or it matches a given pattern.
|
||||
.filter(
|
||||
url => this.config.urls.some(cacheUrl => cacheUrl === url) ||
|
||||
this.patterns.some(pattern => pattern.test(url)))
|
||||
// Finally, process each resource in turn.
|
||||
.reduce(async(previous, url) => {
|
||||
await previous;
|
||||
const req = this.adapter.newRequest(url);
|
||||
|
||||
// It's possible that the resource in question is already cached. If so,
|
||||
// continue to the next one.
|
||||
const alreadyCached = (await cache.match(req) !== undefined);
|
||||
if (alreadyCached) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the most recent old version of the resource.
|
||||
const res = await updateFrom.lookupResourceWithoutHash(url);
|
||||
if (res === null || res.metadata === undefined) {
|
||||
// Unexpected, but not harmful.
|
||||
return;
|
||||
}
|
||||
|
||||
// Write it into the cache. It may already be expired, but it can still serve
|
||||
// traffic until it's updated (stale-while-revalidate approach).
|
||||
await cache.put(req, res.response);
|
||||
await metaTable.write(url, { ...res.metadata, used: false } as UrlMetadata);
|
||||
}, Promise.resolve());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LazyAssetGroup extends AssetGroup {
|
||||
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
|
||||
// No action necessary if no update source is available - resources managed in this group
|
||||
// are all lazily loaded, so there's nothing to initialize.
|
||||
if (updateFrom === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the cache which actually holds requests.
|
||||
const cache = await this.cache;
|
||||
|
||||
// Loop through the listed resources, caching any which are available.
|
||||
await this.config.urls.reduce(async(previous: Promise<void>, url: string) => {
|
||||
// Wait on all previous operations to complete.
|
||||
await previous;
|
||||
|
||||
// Construct the Request for this url.
|
||||
const req = this.adapter.newRequest(url);
|
||||
|
||||
// First, check the cache to see if there is already a copy of this resource.
|
||||
const alreadyCached = (await cache.match(req)) !== undefined;
|
||||
|
||||
// If the resource is in the cache already, it can be skipped.
|
||||
if (alreadyCached) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await this.maybeUpdate(updateFrom, req, cache);
|
||||
if (this.config.updateMode === 'prefetch' && !updated) {
|
||||
// If the resource was not updated, either it was not cached before or
|
||||
// the previously cached version didn't match the updated hash. In that
|
||||
// case, prefetch update mode dictates that the resource will be updated,
|
||||
// except if it was not previously utilized. Check the status of the
|
||||
// cached resource to see.
|
||||
|
||||
const cacheStatus = await updateFrom.recentCacheStatus(url);
|
||||
|
||||
// If the resource is not cached, or was cached but unused, then it will be
|
||||
// loaded lazily.
|
||||
if (cacheStatus !== UpdateCacheStatus.CACHED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update from the network.
|
||||
await this.fetchAndCacheOnce(req, false);
|
||||
}
|
||||
}, Promise.resolve());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,541 @@
|
|||
/**
|
||||
* @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 {Adapter, Context} from './adapter';
|
||||
import {Database, Table} from './database';
|
||||
import {DataGroupConfig} from './manifest';
|
||||
|
||||
/**
|
||||
* A metadata record of how old a particular cached resource is.
|
||||
*/
|
||||
interface AgeRecord {
|
||||
age: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A node in the LRU chain for a given `DataGroup`.
|
||||
*
|
||||
* Serializable as previous/next are identified by their URL and are not references.
|
||||
*/
|
||||
interface LruNode {
|
||||
/**
|
||||
* The URL tracked by this node.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* The previous (more recent) node in the chain, or null if this is the head.
|
||||
*/
|
||||
previous: string|null;
|
||||
|
||||
/**
|
||||
* The next (less recent) node in the chain, or null if this is the tail.
|
||||
*/
|
||||
next: string|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable state of an entire LRU chain.
|
||||
*
|
||||
* Essentially a doubly linked list of URLs.
|
||||
*/
|
||||
interface LruState {
|
||||
/**
|
||||
* URL of the head node, or null if the chain is empty.
|
||||
*/
|
||||
head: string|null;
|
||||
|
||||
/**
|
||||
* URL of the tail node, or null if the chain is empty.
|
||||
*/
|
||||
tail: string|null;
|
||||
|
||||
/**
|
||||
* Map of URLs to data for each URL (including next/prev pointers).
|
||||
*/
|
||||
map: {[url: string]: LruNode | undefined};
|
||||
|
||||
/**
|
||||
* Count of the number of nodes in the chain.
|
||||
*/
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages an instance of `LruState` and moves URLs to the head of the
|
||||
* chain when requested.
|
||||
*/
|
||||
class LruList {
|
||||
state: LruState;
|
||||
constructor(state?: LruState) {
|
||||
if (state === undefined) {
|
||||
state = {
|
||||
head: null,
|
||||
tail: null,
|
||||
map: {},
|
||||
count: 0,
|
||||
};
|
||||
}
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
/**
|
||||
* The current count of URLs in the list.
|
||||
*/
|
||||
get size(): number { return this.state.count; }
|
||||
|
||||
/**
|
||||
* Remove the tail.
|
||||
*/
|
||||
pop(): string|null {
|
||||
// If there is no tail, return null.
|
||||
if (this.state.tail === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = this.state.tail;
|
||||
|
||||
// Special case if this is the last node.
|
||||
if (this.state.head === this.state.tail) {
|
||||
// When removing the last node, both head and tail pointers become null.
|
||||
this.state.head = null;
|
||||
this.state.tail = null;
|
||||
} else {
|
||||
// Normal node removal. All that needs to be done is to clear the next pointer
|
||||
// of the previous node and make it the new tail.
|
||||
const block = this.state.map[url] !;
|
||||
const previous = this.state.map[block.previous !] !;
|
||||
this.state.tail = previous.url;
|
||||
previous.next = block.next;
|
||||
}
|
||||
|
||||
// In any case, this URL is no longer tracked, so remove it from the count and the
|
||||
// map of tracked URLs.
|
||||
delete this.state.map[url];
|
||||
this.state.count--;
|
||||
|
||||
// This URL has been successfully evicted.
|
||||
return url;
|
||||
}
|
||||
|
||||
remove(url: string): boolean {
|
||||
const node = this.state.map[url];
|
||||
if (node === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Special case if removing the current head.
|
||||
if (this.state.head === url) {
|
||||
// The node is the current head. Special case the removal.
|
||||
if (node.next === null) {
|
||||
// This is the only node. Reset the cache to be empty.
|
||||
this.state.head = null;
|
||||
this.state.tail = null;
|
||||
this.state.map = {};
|
||||
this.state.count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// There is at least one other node. Make the next node the new head.
|
||||
const next = this.state.map[node.next !] !;
|
||||
next.previous = null;
|
||||
this.state.head = next.url;
|
||||
this.state.count--;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The node is not the head, so it has a previous. It may or may not be the tail.
|
||||
// If it is not, then it has a next. First, grab the previous node.
|
||||
const previous = this.state.map[node.previous !] !;
|
||||
|
||||
// Fix the forward pointer to skip over node and go directly to node.next.
|
||||
previous.next = node.next;
|
||||
|
||||
// node.next may or may not be set. If it is, fix the back pointer to skip over node.
|
||||
// If it's not set, then this node happened to be the tail, and the tail needs to be
|
||||
// updated to point to the previous node (removing the tail).
|
||||
if (node.next !== null) {
|
||||
// There is a next node, fix its back pointer to skip this node.
|
||||
this.state.map[node.next] !.previous = node.previous !;
|
||||
} else {
|
||||
// There is no next node - the accessed node must be the tail. Move the tail pointer.
|
||||
this.state.tail = node.previous !;
|
||||
}
|
||||
|
||||
// Count the removal.
|
||||
this.state.count--;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
accessed(url: string): void {
|
||||
// When a URL is accessed, its node needs to be moved to the head of the chain.
|
||||
// This is accomplished in two steps:
|
||||
//
|
||||
// 1) remove the node from its position within the chain.
|
||||
// 2) insert the node as the new head.
|
||||
//
|
||||
// Sometimes, a URL is accessed which has not been seen before. In this case, step 1 can
|
||||
// be skipped completely (which will grow the chain by one). Of course, if the node is
|
||||
// already the head, this whole operation can be skipped.
|
||||
if (this.state.head === url) {
|
||||
// The URL is already in the head position, accessing it is a no-op.
|
||||
return;
|
||||
}
|
||||
|
||||
// Look up the node in the map, and construct a new entry if it's
|
||||
const node = this.state.map[url] || {url, next: null, previous: null};
|
||||
|
||||
// Step 1: remove the node from its position within the chain, if it is in the chain.
|
||||
if (this.state.map[url] !== undefined) {
|
||||
this.remove(url);
|
||||
}
|
||||
|
||||
// Step 2: insert the node at the head of the chain.
|
||||
|
||||
// First, check if there's an existing head node. If there is, it has previous: null.
|
||||
// Its previous pointer should be set to the node we're inserting.
|
||||
if (this.state.head !== null) {
|
||||
this.state.map[this.state.head] !.previous = url;
|
||||
}
|
||||
|
||||
// The next pointer of the node being inserted gets set to the old head, before the head
|
||||
// pointer is updated to this node.
|
||||
node.next = this.state.head;
|
||||
|
||||
// The new head is the new node.
|
||||
this.state.head = url;
|
||||
|
||||
// If there is no tail, then this is the first node, and is both the head and the tail.
|
||||
if (this.state.tail === null) {
|
||||
this.state.tail = url;
|
||||
}
|
||||
|
||||
// Set the node in the map of nodes (if the URL has been seen before, this is a no-op)
|
||||
// and count the insertion.
|
||||
this.state.map[url] = node;
|
||||
this.state.count++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of cached resources determined by a set of URL patterns which follow a LRU policy
|
||||
* for caching.
|
||||
*/
|
||||
export class DataGroup {
|
||||
/**
|
||||
* Compiled regular expression set used to determine which resources fall under the purview
|
||||
* of this group.
|
||||
*/
|
||||
private readonly patterns: RegExp[];
|
||||
|
||||
/**
|
||||
* The `Cache` instance in which resources belonging to this group are cached.
|
||||
*/
|
||||
private readonly cache: Promise<Cache>;
|
||||
|
||||
/**
|
||||
* Tracks the LRU state of resources in this cache.
|
||||
*/
|
||||
private _lru: LruList|null = null;
|
||||
|
||||
/**
|
||||
* Database table used to store the state of the LRU cache.
|
||||
*/
|
||||
private readonly lruTable: Promise<Table>;
|
||||
|
||||
/**
|
||||
* Database table used to store metadata for resources in the cache.
|
||||
*/
|
||||
private readonly ageTable: Promise<Table>;
|
||||
|
||||
constructor(
|
||||
private scope: ServiceWorkerGlobalScope, private adapter: Adapter,
|
||||
private config: DataGroupConfig, private db: Database, private prefix: string) {
|
||||
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
|
||||
this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`);
|
||||
this.lruTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:lru`);
|
||||
this.ageTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:age`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily initialize/load the LRU chain.
|
||||
*/
|
||||
private async lru(): Promise<LruList> {
|
||||
if (this._lru === null) {
|
||||
const table = await this.lruTable;
|
||||
try {
|
||||
this._lru = new LruList(await table.read<LruState>('lru'));
|
||||
} catch (e) {
|
||||
this._lru = new LruList();
|
||||
}
|
||||
}
|
||||
return this._lru;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the LRU chain to non-volatile storage.
|
||||
*/
|
||||
async syncLru(): Promise<void> {
|
||||
if (this._lru === null) {
|
||||
return;
|
||||
}
|
||||
const table = await this.lruTable;
|
||||
return table.write('lru', this._lru !.state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a fetch event and return a `Response` if the resource is covered by this group,
|
||||
* or `null` otherwise.
|
||||
*/
|
||||
async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
|
||||
// Do nothing
|
||||
if (!this.patterns.some(pattern => pattern.test(req.url))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lazily initialize the LRU cache.
|
||||
const lru = await this.lru();
|
||||
|
||||
// The URL matches this cache. First, check whether this is a mutating request or not.
|
||||
switch (req.method) {
|
||||
case 'OPTIONS':
|
||||
// Don't try to cache this - it's non-mutating, but is part of a mutating request.
|
||||
// Most likely SWs don't even see this, but this guard is here just in case.
|
||||
return null;
|
||||
case 'GET':
|
||||
case 'HEAD':
|
||||
// Handle the request with whatever strategy was selected.
|
||||
switch (this.config.strategy) {
|
||||
case 'freshness':
|
||||
return this.handleFetchWithFreshness(req, ctx, lru);
|
||||
case 'performance':
|
||||
return this.handleFetchWithPerformance(req, ctx, lru);
|
||||
default:
|
||||
throw new Error(`Unknown strategy: ${this.config.strategy}`);
|
||||
}
|
||||
default:
|
||||
// This was a mutating request. Assume the cache for this URL is no longer valid.
|
||||
const wasCached = lru.remove(req.url);
|
||||
|
||||
// If there was a cached entry, remove it.
|
||||
if (wasCached) {
|
||||
await this.clearCacheForUrl(req.url);
|
||||
}
|
||||
|
||||
// Sync the LRU chain to non-volatile storage.
|
||||
await this.syncLru();
|
||||
|
||||
// Finally, fall back on the network.
|
||||
return this.scope.fetch(req);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFetchWithPerformance(req: Request, ctx: Context, lru: LruList):
|
||||
Promise<Response|null> {
|
||||
let res: Response|null|undefined = null;
|
||||
|
||||
// Check the cache first. If the resource exists there (and is not expired), the cached
|
||||
// version can be used.
|
||||
const fromCache = await this.loadFromCache(req, lru);
|
||||
if (fromCache !== null) {
|
||||
res = fromCache.res;
|
||||
// Check the age of the resource.
|
||||
if (this.config.refreshAheadMs !== undefined && fromCache.age >= this.config.refreshAheadMs) {
|
||||
ctx.waitUntil(this.safeCacheResponse(req, this.scope.fetch(req)));
|
||||
}
|
||||
}
|
||||
|
||||
if (res !== null) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// No match from the cache. Go to the network. Note that this is not an 'await'
|
||||
// call, networkFetch is the actual Promise. This is due to timeout handling.
|
||||
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
|
||||
res = await timeoutFetch;
|
||||
|
||||
// Since fetch() will always return a response, undefined indicates a timeout.
|
||||
if (res === undefined) {
|
||||
// The request timed out. Return a Gateway Timeout error.
|
||||
res = this.adapter.newResponse(null, {status: 504, statusText: 'Gateway Timeout'});
|
||||
|
||||
// Cache the network response eventually.
|
||||
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
|
||||
}
|
||||
|
||||
// The request completed in time, so cache it inline with the response flow.
|
||||
// Make sure to clone it so the real response can still be returned to the user.
|
||||
await this.cacheResponse(req, res.clone(), lru);
|
||||
return res;
|
||||
}
|
||||
|
||||
private async handleFetchWithFreshness(req: Request, ctx: Context, lru: LruList):
|
||||
Promise<Response|null> {
|
||||
// Start with a network fetch.
|
||||
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
|
||||
let res: Response|null|undefined;
|
||||
|
||||
|
||||
// If that fetch errors, treat it as a timed out request.
|
||||
try {
|
||||
res = await timeoutFetch;
|
||||
} catch (e) {
|
||||
res = undefined;
|
||||
}
|
||||
|
||||
// If the network fetch times out or errors, fall back on the cache.
|
||||
if (res === undefined) {
|
||||
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
|
||||
|
||||
// Ignore the age, the network response will be cached anyway due to the
|
||||
// behavior of freshness.
|
||||
const fromCache = await this.loadFromCache(req, lru);
|
||||
res = (fromCache !== null) ? fromCache.res : null;
|
||||
} else {
|
||||
await this.cacheResponse(req, res, lru, true);
|
||||
}
|
||||
|
||||
// Either the network fetch didn't time out, or the cache yielded a usable response.
|
||||
// In either case, use it.
|
||||
if (res !== null) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// No response in the cache. No choice but to fall back on the full network fetch.
|
||||
res = await networkFetch;
|
||||
await this.cacheResponse(req, res.clone(), lru, true);
|
||||
return res;
|
||||
}
|
||||
|
||||
private networkFetchWithTimeout(req: Request): [Promise<Response|undefined>, Promise<Response>] {
|
||||
const networkFetch = this.scope.fetch(req);
|
||||
|
||||
// If there is a timeout configured, race a timeout Promise with the network fetch.
|
||||
// Otherwise, just fetch from the network directly.
|
||||
if (this.config.timeoutMs !== undefined) {
|
||||
// Construct a Promise<undefined> for the timeout.
|
||||
const timeout = this.adapter.timeout(this.config.timeoutMs) as Promise<undefined>;
|
||||
// Race that with the network fetch. This will either be a Response, an error, or
|
||||
// `undefined` in the event that the request times out.
|
||||
return [Promise.race([networkFetch, timeout]), networkFetch];
|
||||
} else {
|
||||
// Do a plain fetch.
|
||||
return [networkFetch, networkFetch];
|
||||
}
|
||||
}
|
||||
|
||||
private async safeCacheResponse(req: Request, res: Promise<Response>): Promise<void> {
|
||||
try {
|
||||
await this.cacheResponse(req, await res, await this.lru());
|
||||
} catch (e) {
|
||||
// TODO: handle this error somehow?
|
||||
}
|
||||
}
|
||||
|
||||
private async loadFromCache(req: Request, lru: LruList):
|
||||
Promise<{res: Response, age: number}|null> {
|
||||
// Look for a response in the cache. If one exists, return it.
|
||||
const cache = await this.cache;
|
||||
let res = await cache.match(req);
|
||||
if (res !== undefined) {
|
||||
// A response was found in the cache, but its age is not yet known. Look it up.
|
||||
try {
|
||||
const ageTable = await this.ageTable;
|
||||
const age = this.adapter.time - (await ageTable.read<AgeRecord>(req.url)).age;
|
||||
// If the response is young enough, use it.
|
||||
if (age <= this.config.maxAge) {
|
||||
// Successful match from the cache. Use the response, after marking it as having
|
||||
// been accessed.
|
||||
lru.accessed(req.url);
|
||||
return {res, age};
|
||||
}
|
||||
|
||||
// Otherwise, or if there was an error, assume the response is expired, and evict it.
|
||||
} catch (e) {
|
||||
// Some error getting the age for the response. Assume it's expired.
|
||||
}
|
||||
|
||||
lru.remove(req.url);
|
||||
await this.clearCacheForUrl(req.url);
|
||||
|
||||
// TODO: avoid duplicate in event of network timeout, maybe.
|
||||
await this.syncLru();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation for caching the response from the server. This has to happen all
|
||||
* at once, so that the cache and LRU tracking remain in sync. If the network request
|
||||
* completes before the timeout, this logic will be run inline with the response flow.
|
||||
* If the request times out on the server, an error will be returned but the real network
|
||||
* request will still be running in the background, to be cached when it completes.
|
||||
*/
|
||||
private async cacheResponse(
|
||||
req: Request, res: Response, lru: LruList, okToCacheOpaque: boolean = false): Promise<void> {
|
||||
// Only cache successful responses.
|
||||
if (!res.ok || (okToCacheOpaque && res.type === 'opaque')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If caching this response would make the cache exceed its maximum size, evict something
|
||||
// first.
|
||||
if (lru.size >= this.config.maxSize) {
|
||||
// The cache is too big, evict something.
|
||||
const evictedUrl = lru.pop();
|
||||
if (evictedUrl !== null) {
|
||||
await this.clearCacheForUrl(evictedUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: evaluate for possible race conditions during flaky network periods.
|
||||
|
||||
// Mark this resource as having been accessed recently. This ensures it won't be evicted
|
||||
// until enough other resources are requested that it falls off the end of the LRU chain.
|
||||
lru.accessed(req.url);
|
||||
|
||||
// Store the response in the cache.
|
||||
await(await this.cache).put(req, res);
|
||||
|
||||
// Store the age of the cache.
|
||||
const ageTable = await this.ageTable;
|
||||
await ageTable.write(req.url, {age: this.adapter.time});
|
||||
|
||||
// Sync the LRU chain to non-volatile storage.
|
||||
await this.syncLru();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the saved state which this group uses to track resources.
|
||||
*/
|
||||
async cleanup(): Promise<void> {
|
||||
// Remove both the cache and the database entries which track LRU stats.
|
||||
await Promise.all([
|
||||
this.scope.caches.delete(`${this.prefix}:dynamic:${this.config.name}:cache`),
|
||||
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:age`),
|
||||
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:lru`),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the state of the cache for a particular resource.
|
||||
*
|
||||
* This doesn't remove the resource from the LRU table, that is assumed to have
|
||||
* been done already. This clears the GET and HEAD versions of the request from
|
||||
* the cache itself, as well as the metadata stored in the age table.
|
||||
*/
|
||||
private async clearCacheForUrl(url: string): Promise<void> {
|
||||
const [cache, ageTable] = await Promise.all([this.cache, this.ageTable]);
|
||||
await Promise.all([
|
||||
cache.delete(this.adapter.newRequest(url, {method: 'GET'})),
|
||||
cache.delete(this.adapter.newRequest(url, {method: 'HEAD'})),
|
||||
ageTable.delete(url),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* An abstract table, with the ability to read/write objects stored under keys.
|
||||
*/
|
||||
export interface Table {
|
||||
/**
|
||||
* Delete a key from the table.
|
||||
*/
|
||||
'delete'(key: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* List all the keys currently stored in the table.
|
||||
*/
|
||||
keys(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Read a key from a table, either as an Object or with a given type.
|
||||
*/
|
||||
read(key: string): Promise<Object>;
|
||||
read<T>(key: string): Promise<T>;
|
||||
|
||||
/**
|
||||
* Write a new value for a key to the table, overwriting any previous value.
|
||||
*/
|
||||
write(key: string, value: Object): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An abstract database, consisting of multiple named `Table`s.
|
||||
*/
|
||||
export interface Database {
|
||||
/**
|
||||
* Delete an entire `Table` from the database, by name.
|
||||
*/
|
||||
'delete'(table: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* List all `Table`s by name.
|
||||
*/
|
||||
list(): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Open a `Table`.
|
||||
*/
|
||||
open(table: string): Promise<Table>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An error returned in rejected promises if the given key is not found in the table.
|
||||
*/
|
||||
export class NotFound {
|
||||
constructor(public table: string, public key: string) {}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* @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 {Adapter} from './adapter';
|
||||
import {Database, NotFound, Table} from './database';
|
||||
|
||||
|
||||
/**
|
||||
* An implementation of a `Database` that uses the `CacheStorage` API to serialize
|
||||
* state within mock `Response` objects.
|
||||
*/
|
||||
export class CacheDatabase implements Database {
|
||||
private tables = new Map<string, Promise<CacheTable>>();
|
||||
|
||||
constructor(private scope: ServiceWorkerGlobalScope, private adapter: Adapter) {}
|
||||
|
||||
'delete'(name: string): Promise<boolean> {
|
||||
if (this.tables.has(name)) {
|
||||
this.tables.delete(name);
|
||||
}
|
||||
return this.scope.caches.delete(`ngsw:db:${name}`);
|
||||
}
|
||||
|
||||
list(): Promise<string[]> {
|
||||
return this.scope.caches.keys().then(keys => keys.filter(key => key.startsWith('ngsw:db:')));
|
||||
}
|
||||
|
||||
open(name: string): Promise<Table> {
|
||||
if (!this.tables.has(name)) {
|
||||
const table = this.scope.caches.open(`ngsw:db:${name}`)
|
||||
.then(cache => new CacheTable(name, cache, this.adapter));
|
||||
this.tables.set(name, table);
|
||||
}
|
||||
return this.tables.get(name) !;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Table` backed by a `Cache`.
|
||||
*/
|
||||
export class CacheTable implements Table {
|
||||
constructor(readonly table: string, private cache: Cache, private adapter: Adapter) {}
|
||||
|
||||
private request(key: string): Request { return this.adapter.newRequest('/' + key); }
|
||||
|
||||
'delete'(key: string): Promise<boolean> { return this.cache.delete(this.request(key)); }
|
||||
|
||||
keys(): Promise<string[]> {
|
||||
return this.cache.keys().then(keys => keys.map(key => key.substr(1)));
|
||||
}
|
||||
|
||||
read(key: string): Promise<any> {
|
||||
return this.cache.match(this.request(key)).then(res => {
|
||||
if (res === undefined) {
|
||||
return Promise.reject(new NotFound(this.table, key));
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
write(key: string, value: Object): Promise<void> {
|
||||
return this.cache.put(this.request(key), this.adapter.newResponse(JSON.stringify(value)));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* @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 {Adapter} from './adapter';
|
||||
import {Debuggable} from './api';
|
||||
|
||||
export class DebugHandler {
|
||||
constructor(readonly driver: Debuggable, readonly adapter: Adapter) {}
|
||||
|
||||
async handleFetch(req: Request): Promise<Response> {
|
||||
const [state, versions, idle] = await Promise.all([
|
||||
this.driver.debugState(),
|
||||
this.driver.debugVersions(),
|
||||
this.driver.debugIdleState(),
|
||||
]);
|
||||
|
||||
const msgState = `NGSW Debug Info:
|
||||
|
||||
Driver state: ${state.state} (${state.why})
|
||||
Latest manifest hash: ${state.latestHash || 'none'}
|
||||
Last update check: ${this.since(state.lastUpdateCheck)}`;
|
||||
|
||||
const msgVersions = versions
|
||||
.map(version => `=== Version ${version.hash} ===
|
||||
|
||||
Clients: ${version.clients.join(', ')}`)
|
||||
.join('\n\n');
|
||||
|
||||
const msgIdle = `=== Idle Task Queue ===
|
||||
Last update tick: ${this.since(idle.lastTrigger)}
|
||||
Last update run: ${this.since(idle.lastRun)}
|
||||
Task queue:
|
||||
${idle.queue.map(v => ' * ' + v).join('\n')}
|
||||
`;
|
||||
|
||||
return this.adapter.newResponse(
|
||||
`${msgState}
|
||||
|
||||
${msgVersions}
|
||||
|
||||
${msgIdle}`,
|
||||
{headers: this.adapter.newHeaders({'Content-Type': 'text/plain'})});
|
||||
}
|
||||
|
||||
since(time: number|null): string {
|
||||
if (time === null) {
|
||||
return 'never';
|
||||
}
|
||||
let age = this.adapter.time - time;
|
||||
const days = Math.floor(age / 86400000);
|
||||
age = age % 86400000;
|
||||
const hours = Math.floor(age / 3600000);
|
||||
age = age % 3600000;
|
||||
const minutes = Math.floor(age / 60000);
|
||||
age = age % 60000;
|
||||
const seconds = Math.floor(age / 1000);
|
||||
const millis = age % 1000;
|
||||
|
||||
return '' + (days > 0 ? `${days}d` : '') + (hours > 0 ? `${hours}h` : '') +
|
||||
(minutes > 0 ? `${minutes}m` : '') + (seconds > 0 ? `${seconds}s` : '') +
|
||||
(millis > 0 ? `${millis}u` : '');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,858 @@
|
|||
/**
|
||||
* @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 {Adapter, Context} from './adapter';
|
||||
import {CacheState, DebugIdleState, DebugState, DebugVersion, Debuggable, UpdateCacheStatus, UpdateSource} from './api';
|
||||
import {AppVersion} from './app-version';
|
||||
import {Database, Table} from './database';
|
||||
import {DebugHandler} from './debug';
|
||||
import {IdleScheduler} from './idle';
|
||||
import {Manifest, ManifestHash, hashManifest} from './manifest';
|
||||
import {MsgAny, isMsgActivateUpdate, isMsgCheckForUpdates} from './msg';
|
||||
import {isNavigationRequest} from './util';
|
||||
|
||||
type ClientId = string;
|
||||
|
||||
type ManifestMap = {
|
||||
[hash: string]: Manifest
|
||||
};
|
||||
type ClientAssignments = {
|
||||
[id: string]: ManifestHash
|
||||
};
|
||||
|
||||
const SYNC_THRESHOLD = 5000;
|
||||
|
||||
const SUPPORTED_CONFIG_VERSION = 1;
|
||||
|
||||
const NOTIFICATION_OPTION_NAMES = [
|
||||
'actions', 'body', 'dir', 'icon', 'lang', 'renotify', 'requireInteraction', 'tag', 'vibrate',
|
||||
'data'
|
||||
];
|
||||
|
||||
interface LatestEntry {
|
||||
latest: string;
|
||||
}
|
||||
|
||||
enum DriverReadyState {
|
||||
// The SW is operating in a normal mode, responding to all traffic.
|
||||
NORMAL,
|
||||
|
||||
// The SW does not have a clean installation of the latest version of the app, but older cached
|
||||
// versions
|
||||
// are safe to use so long as they don't try to fetch new dependencies. This is a degraded state.
|
||||
EXISTING_CLIENTS_ONLY,
|
||||
|
||||
// The SW has decided that caching is completely unreliable, and is forgoing request handling
|
||||
// until the
|
||||
// next restart.
|
||||
SAFE_MODE,
|
||||
}
|
||||
|
||||
export class Driver implements Debuggable, UpdateSource {
|
||||
/**
|
||||
* Tracks the current readiness condition under which the SW is operating. This controls whether
|
||||
* the SW
|
||||
* attempts to respond to some or all requests.
|
||||
*/
|
||||
private state: DriverReadyState = DriverReadyState.NORMAL;
|
||||
private stateMessage: string = '(nominal)';
|
||||
|
||||
/**
|
||||
* Tracks whether the SW is in an initialized state or not. Before initialization, it's not legal
|
||||
* to
|
||||
* respond to requests.
|
||||
*/
|
||||
initialized: Promise<void>|null = null;
|
||||
|
||||
/**
|
||||
* Maps client IDs to the manifest hash of the application version being used to serve them. If a
|
||||
* client ID is not present here, it has not yet been assigned a version.
|
||||
*
|
||||
* If a ManifestHash appears here, it is also present in the `versions` map below.
|
||||
*/
|
||||
private clientVersionMap = new Map<ClientId, ManifestHash>();
|
||||
|
||||
/**
|
||||
* Maps manifest hashes to instances of `AppVersion` for those manifests.
|
||||
*/
|
||||
private versions = new Map<ManifestHash, AppVersion>();
|
||||
|
||||
/**
|
||||
* The latest version fetched from the server.
|
||||
*
|
||||
* Valid after initialization has completed.
|
||||
*/
|
||||
private latestHash: ManifestHash|null = null;
|
||||
|
||||
private lastUpdateCheck: number|null = null;
|
||||
|
||||
/**
|
||||
* A scheduler which manages a queue of tasks that need to be executed when the SW is not doing
|
||||
* any other work (not processing any other requests).
|
||||
*/
|
||||
idle: IdleScheduler;
|
||||
|
||||
debugger: DebugHandler;
|
||||
|
||||
constructor(
|
||||
private scope: ServiceWorkerGlobalScope, private adapter: Adapter, private db: Database) {
|
||||
// Listen to fetch events.
|
||||
this.scope.addEventListener(
|
||||
'install', (event) => { event !.waitUntil(this.scope.skipWaiting()); });
|
||||
this.scope.addEventListener('activate', (event) => {
|
||||
event !.waitUntil(this.scope.clients.claim());
|
||||
if (this.scope.registration.active !== null) {
|
||||
this.scope.registration.active.postMessage({action: 'INITIALIZE'});
|
||||
}
|
||||
});
|
||||
this.scope.addEventListener('fetch', (event) => this.onFetch(event !));
|
||||
this.scope.addEventListener('message', (event) => this.onMessage(event !));
|
||||
this.scope.addEventListener('push', (event) => this.onPush(event !));
|
||||
|
||||
this.idle = new IdleScheduler(this.adapter, SYNC_THRESHOLD);
|
||||
this.debugger = new DebugHandler(this, this.adapter);
|
||||
}
|
||||
|
||||
private onFetch(event: FetchEvent): void {
|
||||
// The only thing that is served unconditionally is the debug page.
|
||||
if (this.adapter.getPath(event.request.url) === '/ngsw/state') {
|
||||
event.respondWith(this.debugger.handleFetch(event.request));
|
||||
return;
|
||||
}
|
||||
|
||||
// If the SW is in a broken state where it's not safe to handle requests at all, returning
|
||||
// causes the request to fall back on the network. This is preferred over
|
||||
// `respondWith(fetch(req))` because the latter still shows in DevTools that the request
|
||||
// was handled by the SW.
|
||||
// TODO: try to handle DriverReadyState.EXISTING_CLIENTS_ONLY here.
|
||||
if (this.state === DriverReadyState.SAFE_MODE) {
|
||||
// Even though the worker is in safe mode, idle tasks still need to happen so things
|
||||
// like update checks, etc. can take place.
|
||||
event.waitUntil(this.idle.trigger());
|
||||
return;
|
||||
}
|
||||
|
||||
// Past this point, the SW commits to handling the request itself. This could still fail (and
|
||||
// result in `state` being set to `SAFE_MODE`), but even in that case the SW will still deliver
|
||||
// a response.
|
||||
event.respondWith(this.handleFetch(event));
|
||||
}
|
||||
|
||||
private onMessage(event: ExtendableMessageEvent): void {
|
||||
if (this.state === DriverReadyState.SAFE_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || !data.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.action === 'INITIALIZE' && this.initialized === null) {
|
||||
this.initialized = this.initialize();
|
||||
event.waitUntil(this.initialized);
|
||||
event.waitUntil(this.idle.trigger());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.adapter.isClient(event.source)) {
|
||||
return;
|
||||
}
|
||||
event.waitUntil(this.handleMessage(data, event.source));
|
||||
}
|
||||
|
||||
private onPush(msg: PushEvent): void {
|
||||
if (!msg.data) {
|
||||
return;
|
||||
}
|
||||
msg.waitUntil(this.handlePush(msg.data));
|
||||
}
|
||||
|
||||
private async handleMessage(msg: MsgAny&{action: string}, from: Client): Promise<void> {
|
||||
if (isMsgCheckForUpdates(msg)) {
|
||||
const action = (async() => { await this.checkForUpdate(); })();
|
||||
await this.reportStatus(from, action, msg.statusNonce);
|
||||
} else if (isMsgActivateUpdate(msg)) {
|
||||
await this.reportStatus(from, this.updateClient(from), msg.statusNonce);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePush(data: any): Promise<void> {
|
||||
this.broadcast({
|
||||
type: 'PUSH',
|
||||
data,
|
||||
});
|
||||
if (!data.notification || !data.notification.title) {
|
||||
return;
|
||||
}
|
||||
const desc = data.notification as{[key: string]: string | undefined};
|
||||
let options: {[key: string]: string | undefined} = {};
|
||||
NOTIFICATION_OPTION_NAMES.filter(name => desc.hasOwnProperty(name))
|
||||
.forEach(name => options[name] = desc[name]);
|
||||
this.scope.registration.showNotification(desc['title'] !, options);
|
||||
}
|
||||
|
||||
private async reportStatus(client: Client, promise: Promise<void>, nonce: number): Promise<void> {
|
||||
const response = {type: 'STATUS', nonce, status: true};
|
||||
try {
|
||||
await promise;
|
||||
client.postMessage(response);
|
||||
} catch (e) {
|
||||
client.postMessage({
|
||||
...response,
|
||||
status: false,
|
||||
error: e.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateClient(client: Client): Promise<void> {
|
||||
// Figure out which version the client is on. If it's not on the latest, it needs to be moved.
|
||||
const existing = this.clientVersionMap.get(client.id);
|
||||
if (existing === this.latestHash) {
|
||||
// Nothing to do, this client is already on the latest version.
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch the client over.
|
||||
let previous: Object|undefined = undefined;
|
||||
|
||||
// Look up the application data associated with the existing version. If there isn't any,
|
||||
// fall back on using the hash.
|
||||
if (existing !== undefined) {
|
||||
const existingVersion = this.versions.get(existing) !;
|
||||
previous = this.mergeHashWithAppData(existingVersion.manifest, existing);
|
||||
}
|
||||
|
||||
// Set the current version used by the client, and
|
||||
this.clientVersionMap.set(client.id, this.latestHash !);
|
||||
await this.sync();
|
||||
|
||||
// Notify the client about this activation.
|
||||
const current = this.versions.get(this.latestHash !) !;
|
||||
const notice = {
|
||||
type: 'UPDATE_ACTIVATED',
|
||||
previous,
|
||||
current: this.mergeHashWithAppData(current.manifest, this.latestHash !),
|
||||
};
|
||||
|
||||
client.postMessage(notice);
|
||||
}
|
||||
|
||||
private async handleFetch(event: FetchEvent): Promise<Response> {
|
||||
// Since the SW may have just been started, it may or may not have been initialized already.
|
||||
// this.initialized will be `null` if initialization has not yet been attempted, or will be a
|
||||
// Promise which will resolve (successfully or unsuccessfully) if it has.
|
||||
if (this.initialized === null) {
|
||||
// Initialization has not yet been attempted, so attempt it. This should only ever happen once
|
||||
// per SW instantiation.
|
||||
this.initialized = this.initialize();
|
||||
}
|
||||
|
||||
// If initialization fails, the SW needs to enter a safe state, where it declines to respond to
|
||||
// network requests.
|
||||
try {
|
||||
// Wait for initialization.
|
||||
await this.initialized;
|
||||
} catch (e) {
|
||||
// Initialization failed. Enter a safe state.
|
||||
this.state = DriverReadyState.SAFE_MODE;
|
||||
this.stateMessage = `Initialization failed due to error: ${errorToString(e)}`;
|
||||
|
||||
// Even though the driver entered safe mode, background tasks still need to happen.
|
||||
event.waitUntil(this.idle.trigger());
|
||||
|
||||
// Since the SW is already committed to responding to the currently active request,
|
||||
// respond with a network fetch.
|
||||
return this.scope.fetch(event.request);
|
||||
}
|
||||
|
||||
// Decide which version of the app to use to serve this request. This is asynchronous as in
|
||||
// some cases, a record will need to be written to disk about the assignment that is made.
|
||||
const appVersion = await this.assignVersion(event);
|
||||
|
||||
// Bail out
|
||||
if (appVersion === null) {
|
||||
event.waitUntil(this.idle.trigger());
|
||||
return this.scope.fetch(event.request);
|
||||
}
|
||||
|
||||
// Handle the request. First try the AppVersion. If that doesn't work, fall back on the network.
|
||||
const res = await appVersion.handleFetch(event.request, event);
|
||||
|
||||
// The AppVersion will only return null if the manifest doesn't specify what to do about this
|
||||
// request. In that case, just fall back on the network.
|
||||
if (res === null) {
|
||||
event.waitUntil(this.idle.trigger());
|
||||
return this.scope.fetch(event.request);
|
||||
}
|
||||
|
||||
// Trigger the idle scheduling system. The Promise returned by trigger() will resolve after
|
||||
// a specific amount of time has passed. If trigger() hasn't been called again by then (e.g.
|
||||
// on a subsequent request), the idle task queue will be drained and the Promise won't resolve
|
||||
// until that operation is complete as well.
|
||||
event.waitUntil(this.idle.trigger());
|
||||
|
||||
// The AppVersion returned a usable response, so return it.
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to quickly reach a state where it's safe to serve responses.
|
||||
*/
|
||||
private async initialize(): Promise<void> {
|
||||
// On initialization, all of the serialized state is read out of the 'control' table. This
|
||||
// includes:
|
||||
// - map of hashes to manifests of currently loaded application versions
|
||||
// - map of client IDs to their pinned versions
|
||||
// - record of the most recently fetched manifest hash
|
||||
//
|
||||
// If these values don't exist in the DB, then this is the either the first time the SW has run
|
||||
// or
|
||||
// the DB state has been wiped or is inconsistent. In that case, load a fresh copy of the
|
||||
// manifest
|
||||
// and reset the state from scratch.
|
||||
|
||||
// Open up the DB table.
|
||||
const table = await this.db.open('control');
|
||||
|
||||
// Attempt to load the needed state from the DB. If this fails, the catch {} block will populate
|
||||
// these variables with freshly constructed values.
|
||||
let manifests: ManifestMap, assignments: ClientAssignments, latest: LatestEntry;
|
||||
try {
|
||||
// Read them from the DB simultaneously.
|
||||
[manifests, assignments, latest] = await Promise.all([
|
||||
table.read<ManifestMap>('manifests'),
|
||||
table.read<ClientAssignments>('assignments'),
|
||||
table.read<LatestEntry>('latest'),
|
||||
]);
|
||||
|
||||
// Successfully loaded from saved state. This implies a manifest exists, so the update check
|
||||
// needs to happen in the background.
|
||||
this.idle.schedule('init post-load (update, cleanup)', async() => {
|
||||
await this.checkForUpdate();
|
||||
await this.cleanupCaches();
|
||||
});
|
||||
} catch (_) {
|
||||
// Something went wrong. Try to start over by fetching a new manifest from the server and
|
||||
// building
|
||||
// up an empty initial state.
|
||||
const manifest = await this.fetchLatestManifest();
|
||||
const hash = hashManifest(manifest);
|
||||
manifests = {};
|
||||
manifests[hash] = manifest;
|
||||
assignments = {};
|
||||
latest = {latest: hash};
|
||||
|
||||
// Save the initial state to the DB.
|
||||
await Promise.all([
|
||||
table.write('manifests', manifests),
|
||||
table.write('assignments', assignments),
|
||||
table.write('latest', latest),
|
||||
]);
|
||||
}
|
||||
|
||||
// At this point, either the state has been loaded successfully, or fresh state with a new copy
|
||||
// of
|
||||
// the manifest has been produced. At this point, the `Driver` can have its internals hydrated
|
||||
// from
|
||||
// the state.
|
||||
|
||||
// Initialize the `versions` map by setting each hash to a new `AppVersion` instance for that
|
||||
// manifest.
|
||||
Object.keys(manifests).forEach((hash: ManifestHash) => {
|
||||
const manifest = manifests[hash];
|
||||
|
||||
// If the manifest is newly initialized, an AppVersion may have already been created for it.
|
||||
if (!this.versions.has(hash)) {
|
||||
this.versions.set(
|
||||
hash, new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash));
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the scheduling of initialization of all versions in the manifest. Ordinarily this
|
||||
// just
|
||||
// schedules the initializations to happen during the next idle period, but in development mode
|
||||
// this might actually wait for the full initialization.
|
||||
await Promise.all(Object.keys(manifests).map(async(hash: ManifestHash) => {
|
||||
try {
|
||||
// Attempt to schedule or initialize this version. If this operation is successful, then
|
||||
// initialization either succeeded or was scheduled. If it fails, then full initialization
|
||||
// was attempted and failed.
|
||||
await this.scheduleInitialization(this.versions.get(hash) !);
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
|
||||
// Map each client ID to its associated hash. Along the way, verify that the hash is still valid
|
||||
// for that clinet ID. It should not be possible for a client to still be associated with a hash
|
||||
// that was since removed from the state.
|
||||
Object.keys(assignments).forEach((clientId: ClientId) => {
|
||||
const hash = assignments[clientId];
|
||||
if (!this.versions.has(hash)) {
|
||||
throw new Error(
|
||||
`Invariant violated (initialize): no manifest known for hash ${hash} active for client ${clientId}`);
|
||||
}
|
||||
this.clientVersionMap.set(clientId, hash);
|
||||
});
|
||||
|
||||
// Set the latest version.
|
||||
this.latestHash = latest.latest;
|
||||
|
||||
// Finally, assert that the latest version is in fact loaded.
|
||||
if (!this.versions.has(latest.latest)) {
|
||||
throw new Error(
|
||||
`Invariant violated (initialize): latest hash ${latest.latest} has no known manifest`);
|
||||
}
|
||||
}
|
||||
|
||||
private lookupVersionByHash(hash: ManifestHash, debugName: string = 'lookupVersionByHash'):
|
||||
AppVersion {
|
||||
// The version should exist, but check just in case.
|
||||
if (!this.versions.has(hash)) {
|
||||
throw new Error(
|
||||
`Invariant violated (${debugName}): want AppVersion for ${hash} but not loaded`);
|
||||
}
|
||||
return this.versions.get(hash) !;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which version of the manifest to use for the event.
|
||||
*/
|
||||
private async assignVersion(event: FetchEvent): Promise<AppVersion|null> {
|
||||
// First, check whether the event has a client ID. If it does, the version may already be
|
||||
// associated.
|
||||
const clientId = event.clientId;
|
||||
if (clientId !== null) {
|
||||
// Check if there is an assigned client id.
|
||||
if (this.clientVersionMap.has(clientId)) {
|
||||
// There is an assignment for this client already.
|
||||
let hash = this.clientVersionMap.get(clientId) !;
|
||||
|
||||
// Ordinarily, this client would be served from its assigned version. But, if this
|
||||
// request is a navigation request, this client can be updated to the latest version
|
||||
// immediately.
|
||||
if (this.state === DriverReadyState.NORMAL && hash !== this.latestHash &&
|
||||
isNavigationRequest(event.request, this.adapter)) {
|
||||
// Update this client to the latest version immediately.
|
||||
if (this.latestHash === null) {
|
||||
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
|
||||
}
|
||||
|
||||
const client = await this.scope.clients.get(clientId);
|
||||
|
||||
await this.updateClient(client);
|
||||
hash = this.latestHash;
|
||||
}
|
||||
|
||||
// TODO: make sure the version is valid.
|
||||
return this.lookupVersionByHash(hash, 'assignVersion');
|
||||
} else {
|
||||
// This is the first time this client ID has been seen. Whether the SW is in a state
|
||||
// to handle new clients depends on the current readiness state, so check that first.
|
||||
if (this.state !== DriverReadyState.NORMAL) {
|
||||
// It's not safe to serve new clients in the current state. It's possible that this
|
||||
// is an existing client which has not been mapped yet (see below) but even if that
|
||||
// is the case, it's invalid to make an assignment to a known invalid version, even
|
||||
// if that assignment was previously implicit. Return undefined here to let the
|
||||
// caller know that no assignment is possible at this time.
|
||||
return null;
|
||||
}
|
||||
|
||||
// It's safe to handle this request. Two cases apply. Either:
|
||||
// 1) the browser assigned a client ID at the time of the navigation request, and
|
||||
// this is truly the first time seeing this client, or
|
||||
// 2) a navigation request came previously from the same client, but with no client
|
||||
// ID attached. Browsers do this to avoid creating a client under the origin in
|
||||
// the event the navigation request is just redirected.
|
||||
//
|
||||
// In case 1, the latest version can safely be used.
|
||||
// In case 2, the latest version can be used, with the assumption that the previous
|
||||
// navigation request was answered under the same version. This assumption relies
|
||||
// on the fact that it's unlikely an update will come in between the navigation
|
||||
// request and requests for subsequent resources on that page.
|
||||
|
||||
// First validate the current state.
|
||||
if (this.latestHash === null) {
|
||||
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
|
||||
}
|
||||
|
||||
// Pin this client ID to the current latest version, indefinitely.
|
||||
this.clientVersionMap.set(clientId, this.latestHash);
|
||||
await this.sync();
|
||||
|
||||
// Return the latest `AppVersion`.
|
||||
return this.lookupVersionByHash(this.latestHash, 'assignVersion');
|
||||
}
|
||||
} else {
|
||||
// No client ID was associated with the request. This must be a navigation request
|
||||
// for a new client. First check that the SW is accepting new clients.
|
||||
if (this.state !== DriverReadyState.NORMAL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Serve it with the latest version, and assume that the client will actually get
|
||||
// associated with that version on the next request.
|
||||
|
||||
// First validate the current state.
|
||||
if (this.latestHash === null) {
|
||||
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
|
||||
}
|
||||
|
||||
// Return the latest `AppVersion`.
|
||||
return this.lookupVersionByHash(this.latestHash, 'assignVersion');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a copy of the latest manifest from the server.
|
||||
*/
|
||||
private async fetchLatestManifest(): Promise<Manifest> {
|
||||
const res = await this.scope.fetch('/ngsw.json?ngsw-cache-bust=' + Math.random());
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
await this.deleteAllCaches();
|
||||
this.scope.registration.unregister();
|
||||
}
|
||||
throw new Error('Manifest fetch failed!');
|
||||
}
|
||||
this.lastUpdateCheck = this.adapter.time;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
private async deleteAllCaches(): Promise<void> {
|
||||
await(await this.scope.caches.keys())
|
||||
.filter(key => key.startsWith('ngsw:'))
|
||||
.reduce(async(previous, key) => {
|
||||
await Promise.all([
|
||||
previous,
|
||||
this.scope.caches.delete(key),
|
||||
]);
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the SW's attempt to reach a fully prefetched state for the given AppVersion
|
||||
* when the SW is not busy and has connectivity. This returns a Promise which must be
|
||||
* awaited, as under some conditions the AppVersion might be initialized immediately.
|
||||
*/
|
||||
private async scheduleInitialization(appVersion: AppVersion): Promise<void> {
|
||||
const initialize = async() => {
|
||||
try {
|
||||
await appVersion.initializeFully();
|
||||
} catch (err) {
|
||||
this.versionFailed(appVersion, err);
|
||||
}
|
||||
};
|
||||
// TODO: better logic for detecting localhost.
|
||||
if (this.scope.registration.scope.indexOf('://localhost') > -1) {
|
||||
return initialize();
|
||||
}
|
||||
this.idle.schedule(`initialization(${appVersion.manifestHash})`, initialize);
|
||||
}
|
||||
|
||||
private versionFailed(appVersion: AppVersion, err: Error): void {
|
||||
// This particular AppVersion is broken. First, find the manifest hash.
|
||||
const broken =
|
||||
Array.from(this.versions.entries()).find(([hash, version]) => version === appVersion);
|
||||
if (broken === undefined) {
|
||||
// This version is no longer in use anyway, so nobody cares.
|
||||
return;
|
||||
}
|
||||
const brokenHash = broken[0];
|
||||
|
||||
// TODO: notify affected apps.
|
||||
|
||||
// The action taken depends on whether the broken manifest is the active (latest) or not.
|
||||
// If so, the SW cannot accept new clients, but can continue to service old ones.
|
||||
if (this.latestHash === brokenHash) {
|
||||
// The latest manifest is broken. This means that new clients are at the mercy of the
|
||||
// network, but caches continue to be valid for previous versions. This is unfortunate
|
||||
// but unavoidable.
|
||||
this.state = DriverReadyState.EXISTING_CLIENTS_ONLY;
|
||||
this.stateMessage = `Degraded due to failed initialization: ${errorToString(err)}`;
|
||||
|
||||
// Cancel the binding for these clients.
|
||||
Array.from(this.clientVersionMap.keys())
|
||||
.forEach(clientId => this.clientVersionMap.delete(clientId));
|
||||
} else {
|
||||
// The current version is viable, but this older version isn't. The only possible remedy
|
||||
// is to stop serving the older version and go to the network. Figure out which clients
|
||||
// are affected and put them on the latest.
|
||||
const affectedClients =
|
||||
Array.from(this.clientVersionMap.keys())
|
||||
.filter(clientId => this.clientVersionMap.get(clientId) ! === brokenHash);
|
||||
// Push the affected clients onto the latest version.
|
||||
affectedClients.forEach(clientId => this.clientVersionMap.set(clientId, this.latestHash !));
|
||||
}
|
||||
}
|
||||
|
||||
private async setupUpdate(manifest: Manifest, hash: string): Promise<void> {
|
||||
const newVersion = new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash);
|
||||
|
||||
// Try to determine a version that's safe to update from.
|
||||
let updateFrom: AppVersion|undefined = undefined;
|
||||
|
||||
// It's always safe to update from a version, even a broken one, as it will still only have
|
||||
// valid resources cached. If there is no latest version, though, this update will have to
|
||||
// install as a fresh version.
|
||||
if (this.latestHash !== null) {
|
||||
updateFrom = this.versions.get(this.latestHash);
|
||||
}
|
||||
|
||||
// Firstly, check if the manifest version is correct.
|
||||
if (manifest.configVersion !== SUPPORTED_CONFIG_VERSION) {
|
||||
await this.deleteAllCaches();
|
||||
this.scope.registration.unregister();
|
||||
throw new Error(
|
||||
`Invalid config version: expected ${SUPPORTED_CONFIG_VERSION}, got ${manifest.configVersion}.`);
|
||||
}
|
||||
|
||||
// Cause the new version to become fully initialized. If this fails, then the version will
|
||||
// not be available for use.
|
||||
await newVersion.initializeFully(this);
|
||||
|
||||
// Install this as an active version of the app.
|
||||
this.versions.set(hash, newVersion);
|
||||
// Future new clients will use this hash as the latest version.
|
||||
this.latestHash = hash;
|
||||
|
||||
await this.sync();
|
||||
await this.notifyClientsAboutUpdate();
|
||||
}
|
||||
|
||||
async checkForUpdate(): Promise<boolean> {
|
||||
try {
|
||||
const manifest = await this.fetchLatestManifest();
|
||||
const hash = hashManifest(manifest);
|
||||
|
||||
// Check whether this is really an update.
|
||||
if (this.versions.has(hash)) {
|
||||
return false;
|
||||
}
|
||||
await this.setupUpdate(manifest, hash);
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the existing state to the underlying database.
|
||||
*/
|
||||
private async sync(): Promise<void> {
|
||||
// Open up the DB table.
|
||||
const table = await this.db.open('control');
|
||||
|
||||
// Construct a serializable map of hashes to manifests.
|
||||
const manifests: ManifestMap = {};
|
||||
this.versions.forEach((version, hash) => { manifests[hash] = version.manifest; });
|
||||
|
||||
// Construct a serializable map of client ids to version hashes.
|
||||
const assignments: ClientAssignments = {};
|
||||
this.clientVersionMap.forEach((hash, clientId) => { assignments[clientId] = hash; });
|
||||
|
||||
// Record the latest entry. Since this is a sync which is necessarily happening after
|
||||
// initialization, latestHash should always be valid.
|
||||
const latest: LatestEntry = {
|
||||
latest: this.latestHash !,
|
||||
};
|
||||
|
||||
// Synchronize all of these.
|
||||
await Promise.all([
|
||||
table.write('manifests', manifests),
|
||||
table.write('assignments', assignments),
|
||||
table.write('latest', latest),
|
||||
]);
|
||||
}
|
||||
|
||||
async cleanupCaches(): Promise<void> {
|
||||
// Make sure internal state has been initialized before attempting to clean up caches.
|
||||
await this.initialized;
|
||||
|
||||
// Query for all currently active clients, and list the client ids. This may skip some
|
||||
// clients in the browser back-forward cache, but not much can be done about that.
|
||||
const activeClients: ClientId[] =
|
||||
(await this.scope.clients.matchAll()).map(client => client.id);
|
||||
|
||||
// A simple list of client ids that the SW has kept track of. Subtracting activeClients
|
||||
// from this list will result in the set of client ids which are being tracked but are no
|
||||
// longer used in the browser, and thus can be cleaned up.
|
||||
const knownClients: ClientId[] = Array.from(this.clientVersionMap.keys());
|
||||
|
||||
// Remove clients in the clientVersionMap that are no longer active.
|
||||
knownClients.filter(id => activeClients.indexOf(id) === -1)
|
||||
.forEach(id => this.clientVersionMap.delete(id));
|
||||
|
||||
// Next, determine the set of versions which are still used. All others can be removed.
|
||||
const usedVersions = new Set<string>();
|
||||
this.clientVersionMap.forEach((version, _) => usedVersions.add(version));
|
||||
|
||||
// Collect all obsolete versions by filtering out used versions from the set of all versions.
|
||||
const obsoleteVersions =
|
||||
Array.from(this.versions.keys())
|
||||
.filter(version => !usedVersions.has(version) && version !== this.latestHash);
|
||||
|
||||
// Remove all the versions which are no longer used.
|
||||
await obsoleteVersions.reduce(async(previous, version) => {
|
||||
// Wait for the other cleanup operations to complete.
|
||||
await previous;
|
||||
|
||||
// Try to get past the failure of one particular version to clean up (this shouldn't happen,
|
||||
// but handle it just in case).
|
||||
try {
|
||||
// Get ahold of the AppVersion for this particular hash.
|
||||
const instance = this.versions.get(version) !;
|
||||
|
||||
// Delete it from the canonical map.
|
||||
this.versions.delete(version);
|
||||
|
||||
// Clean it up.
|
||||
await instance.cleanup();
|
||||
} catch (e) {
|
||||
// Oh well? Not much that can be done here. These caches will be removed when the SW revs
|
||||
// its format version, which happens from time to time.
|
||||
}
|
||||
}, Promise.resolve());
|
||||
|
||||
// Commit all the changes to the saved state.
|
||||
await this.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a specific version of the given resource is cached anywhere within the SW,
|
||||
* and fetch it if so.
|
||||
*/
|
||||
lookupResourceWithHash(url: string, hash: string): Promise<Response|null> {
|
||||
return Array
|
||||
// Scan through the set of all cached versions, valid or otherwise. It's safe to do such
|
||||
// lookups even for invalid versions as the cached version of a resource will have the
|
||||
// same hash regardless.
|
||||
.from(this.versions.values())
|
||||
// Reduce the set of versions to a single potential result. At any point along the
|
||||
// reduction, if a response has already been identified, then pass it through, as no
|
||||
// future operation could change the response. If no response has been found yet, keep
|
||||
// checking versions until one is or until all versions have been exhausted.
|
||||
.reduce(async(prev, version) => {
|
||||
// First, check the previous result. If a non-null result has been found already, just
|
||||
// return it.
|
||||
if (await prev !== null) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
// No result has been found yet. Try the next `AppVersion`.
|
||||
return version.lookupResourceWithHash(url, hash);
|
||||
}, Promise.resolve<Response|null>(null));
|
||||
}
|
||||
|
||||
async lookupResourceWithoutHash(url: string): Promise<CacheState|null> {
|
||||
await this.initialized;
|
||||
const version = this.versions.get(this.latestHash !) !;
|
||||
return version.lookupResourceWithoutHash(url);
|
||||
}
|
||||
|
||||
async previouslyCachedResources(): Promise<string[]> {
|
||||
await this.initialized;
|
||||
const version = this.versions.get(this.latestHash !) !;
|
||||
return version.previouslyCachedResources();
|
||||
}
|
||||
|
||||
recentCacheStatus(url: string): Promise<UpdateCacheStatus> {
|
||||
const version = this.versions.get(this.latestHash !) !;
|
||||
return version.recentCacheStatus(url);
|
||||
}
|
||||
|
||||
private mergeHashWithAppData(manifest: Manifest, hash: string): {hash: string, appData: Object} {
|
||||
return {
|
||||
hash,
|
||||
appData: manifest.appData as Object,
|
||||
};
|
||||
}
|
||||
|
||||
async notifyClientsAboutUpdate(): Promise<void> {
|
||||
await this.initialized;
|
||||
|
||||
const clients = await this.scope.clients.matchAll();
|
||||
const next = this.versions.get(this.latestHash !) !;
|
||||
|
||||
await clients.reduce(async(previous, client) => {
|
||||
await previous;
|
||||
|
||||
// Firstly, determine which version this client is on.
|
||||
const version = this.clientVersionMap.get(client.id);
|
||||
if (version === undefined) {
|
||||
// Unmapped client - assume it's the latest.
|
||||
return;
|
||||
}
|
||||
|
||||
if (version === this.latestHash) {
|
||||
// Client is already on the latest version, no need for a notification.
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.versions.get(version) !;
|
||||
|
||||
// Send a notice.
|
||||
const notice = {
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: this.mergeHashWithAppData(current.manifest, version),
|
||||
available: this.mergeHashWithAppData(next.manifest, this.latestHash !),
|
||||
};
|
||||
|
||||
client.postMessage(notice);
|
||||
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
async broadcast(msg: Object): Promise<void> {
|
||||
const clients = await this.scope.clients.matchAll();
|
||||
clients.forEach(client => { client.postMessage(msg); });
|
||||
}
|
||||
|
||||
async debugState(): Promise<DebugState> {
|
||||
return {
|
||||
state: DriverReadyState[this.state],
|
||||
why: this.stateMessage,
|
||||
latestHash: this.latestHash,
|
||||
lastUpdateCheck: this.lastUpdateCheck,
|
||||
};
|
||||
}
|
||||
|
||||
async debugVersions(): Promise<DebugVersion[]> {
|
||||
// Build list of versions.
|
||||
return Array.from(this.versions.keys()).map(hash => {
|
||||
const version = this.versions.get(hash) !;
|
||||
const clients = Array.from(this.clientVersionMap.entries())
|
||||
.filter(([clientId, version]) => version === hash)
|
||||
.map(([clientId, version]) => clientId);
|
||||
return {
|
||||
hash,
|
||||
manifest: version.manifest, clients,
|
||||
status: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async debugIdleState(): Promise<DebugIdleState> {
|
||||
return {
|
||||
queue: this.idle.taskDescriptions,
|
||||
lastTrigger: this.idle.lastTrigger,
|
||||
lastRun: this.idle.lastRun,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorToString(error: any): string {
|
||||
if (error instanceof Error) {
|
||||
return `${error.message}\n${error.stack}`;
|
||||
} else {
|
||||
return `${error}`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* @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 {Adapter} from './adapter';
|
||||
|
||||
export interface IdleTask {
|
||||
run: () => Promise<void>;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
interface ScheduledRun {
|
||||
cancel: boolean;
|
||||
}
|
||||
|
||||
export class IdleScheduler {
|
||||
private queue: IdleTask[] = [];
|
||||
private scheduled: ScheduledRun|null = null;
|
||||
empty: Promise<void> = Promise.resolve();
|
||||
private emptyResolve: Function|null = null;
|
||||
lastTrigger: number|null = null;
|
||||
lastRun: number|null = null;
|
||||
|
||||
constructor(private adapter: Adapter, private threshold: number) {}
|
||||
|
||||
async trigger(): Promise<void> {
|
||||
this.lastTrigger = this.adapter.time;
|
||||
if (this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scheduled !== null) {
|
||||
this.scheduled.cancel = true;
|
||||
}
|
||||
|
||||
this.scheduled = {
|
||||
cancel: false,
|
||||
};
|
||||
|
||||
await this.adapter.timeout(this.threshold);
|
||||
|
||||
if (this.scheduled !== null && this.scheduled.cancel) {
|
||||
this.scheduled = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduled = null;
|
||||
|
||||
await this.execute();
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
this.lastRun = this.adapter.time;
|
||||
while (this.queue.length > 0) {
|
||||
const queue = this.queue.map(task => {
|
||||
try {
|
||||
return task.run();
|
||||
} catch (e) {
|
||||
// Ignore errors, for now.
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
|
||||
this.queue = [];
|
||||
|
||||
await Promise.all(queue);
|
||||
if (this.emptyResolve !== null) {
|
||||
this.emptyResolve();
|
||||
this.emptyResolve = null;
|
||||
}
|
||||
this.empty = Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
schedule(desc: string, run: () => Promise<void>): void {
|
||||
this.queue.push({desc, run});
|
||||
if (this.emptyResolve === null) {
|
||||
this.empty = new Promise(resolve => { this.emptyResolve = resolve; });
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number { return this.queue.length; }
|
||||
|
||||
get taskDescriptions(): string[] { return this.queue.map(task => task.desc); }
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @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 {sha1} from './sha1';
|
||||
|
||||
export type ManifestHash = string;
|
||||
|
||||
export interface Manifest {
|
||||
configVersion: number;
|
||||
appData?: {[key: string]: string};
|
||||
index: string;
|
||||
assetGroups?: AssetGroupConfig[];
|
||||
dataGroups?: DataGroupConfig[];
|
||||
hashTable: {[url: string]: string};
|
||||
}
|
||||
|
||||
export interface AssetGroupConfig {
|
||||
name: string;
|
||||
installMode: 'prefetch'|'lazy';
|
||||
updateMode: 'prefetch'|'lazy';
|
||||
urls: string[];
|
||||
patterns: string[];
|
||||
}
|
||||
|
||||
export interface DataGroupConfig {
|
||||
name: string;
|
||||
version: number;
|
||||
strategy: 'freshness'|'performance';
|
||||
patterns: string[];
|
||||
maxSize: number;
|
||||
timeoutMs?: number;
|
||||
refreshAheadMs?: number;
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export function hashManifest(manifest: Manifest): ManifestHash {
|
||||
return sha1(JSON.stringify(manifest));
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export interface MsgAny { action: string; }
|
||||
|
||||
export interface MsgCheckForUpdates {
|
||||
action: 'CHECK_FOR_UPDATES';
|
||||
statusNonce: number;
|
||||
}
|
||||
|
||||
export function isMsgCheckForUpdates(msg: MsgAny): msg is MsgCheckForUpdates {
|
||||
return msg.action === 'CHECK_FOR_UPDATES';
|
||||
}
|
||||
|
||||
export interface MsgActivateUpdate {
|
||||
action: 'ACTIVATE_UPDATE';
|
||||
statusNonce: number;
|
||||
}
|
||||
|
||||
export function isMsgActivateUpdate(msg: MsgAny): msg is MsgActivateUpdate {
|
||||
return msg.action === 'ACTIVATE_UPDATE';
|
||||
}
|
||||
|
||||
export interface MsgCheckVersion {
|
||||
action: 'CHECK_VERSION';
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export function isMsgCheckVersion(msg: MsgAny): msg is MsgCheckVersion {
|
||||
return msg.action === 'CHECK_VERSION';
|
||||
}
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* Copyright (c) 2016, Tiernan Cridland
|
||||
*
|
||||
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without
|
||||
* fee is hereby
|
||||
* granted, provided that the above copyright notice and this permission notice appear in all
|
||||
* copies.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
|
||||
* SOFTWARE INCLUDING ALL
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
|
||||
* SPECIAL, DIRECT,
|
||||
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
|
||||
* PROFITS, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
|
||||
* WITH THE USE OR
|
||||
* PERFORMANCE OF THIS SOFTWARE.
|
||||
*
|
||||
* Typings for Service Worker
|
||||
* @author Tiernan Cridland
|
||||
* @email tiernanc@gmail.com
|
||||
* @license: ISC
|
||||
*/
|
||||
interface ExtendableEvent extends Event {
|
||||
waitUntil(fn: Promise<any>): void;
|
||||
}
|
||||
|
||||
// CacheStorage API
|
||||
|
||||
interface Cache {
|
||||
add(request: Request): Promise<void>;
|
||||
addAll(requestArray: Array<Request>): Promise<void>;
|
||||
'delete'(request: Request, options?: CacheStorageOptions): Promise<boolean>;
|
||||
keys(request?: Request, options?: CacheStorageOptions): Promise<Array<string>>;
|
||||
match(request: Request, options?: CacheStorageOptions): Promise<Response|undefined>;
|
||||
matchAll(request: Request, options?: CacheStorageOptions): Promise<Array<Response>>;
|
||||
put(request: Request|string, response: Response): Promise<void>;
|
||||
}
|
||||
|
||||
interface CacheStorage {
|
||||
'delete'(cacheName: string): Promise<boolean>;
|
||||
has(cacheName: string): Promise<boolean>;
|
||||
keys(): Promise<Array<string>>;
|
||||
match(request: Request, options?: CacheStorageOptions): Promise<Response|undefined>;
|
||||
open(cacheName: string): Promise<Cache>;
|
||||
}
|
||||
|
||||
interface CacheStorageOptions {
|
||||
cacheName?: string;
|
||||
ignoreMethod?: boolean;
|
||||
ignoreSearch?: boolean;
|
||||
ignoreVary?: boolean;
|
||||
}
|
||||
|
||||
// Client API
|
||||
|
||||
declare class Client {
|
||||
frameType: ClientFrameType;
|
||||
id: string;
|
||||
url: string;
|
||||
postMessage(message: any): void;
|
||||
}
|
||||
|
||||
interface Clients {
|
||||
claim(): Promise<any>;
|
||||
get(id: string): Promise<Client>;
|
||||
matchAll(options?: ClientMatchOptions): Promise<Array<Client>>;
|
||||
}
|
||||
|
||||
interface ClientMatchOptions {
|
||||
includeUncontrolled?: boolean;
|
||||
type?: ClientMatchTypes;
|
||||
}
|
||||
|
||||
interface WindowClient {
|
||||
focused: boolean;
|
||||
visibilityState: WindowClientState;
|
||||
focus(): Promise<WindowClient>;
|
||||
navigate(url: string): Promise<WindowClient>;
|
||||
}
|
||||
|
||||
type ClientFrameType = 'auxiliary' | 'top-level' | 'nested' | 'none';
|
||||
type ClientMatchTypes = 'window' | 'worker' | 'sharedworker' | 'all';
|
||||
type WindowClientState = 'hidden' | 'visible' | 'prerender' | 'unloaded';
|
||||
|
||||
// Fetch API
|
||||
|
||||
interface FetchEvent extends ExtendableEvent {
|
||||
clientId: string|null;
|
||||
request: Request;
|
||||
respondWith(response: Promise<Response>|Response): Promise<Response>;
|
||||
}
|
||||
|
||||
interface InstallEvent extends ExtendableEvent {
|
||||
activeWorker: ServiceWorker;
|
||||
}
|
||||
|
||||
interface ActivateEvent extends ExtendableEvent {}
|
||||
|
||||
// Notification API
|
||||
|
||||
interface NotificationEvent {
|
||||
action: string;
|
||||
notification: Notification;
|
||||
}
|
||||
|
||||
// Push API
|
||||
|
||||
interface PushEvent extends ExtendableEvent {
|
||||
data: PushMessageData;
|
||||
}
|
||||
|
||||
interface PushMessageData {
|
||||
arrayBuffer(): ArrayBuffer;
|
||||
blob(): Blob;
|
||||
json(): any;
|
||||
text(): string;
|
||||
}
|
||||
|
||||
// Sync API
|
||||
|
||||
interface SyncEvent extends ExtendableEvent {
|
||||
lastChance: boolean;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface ExtendableMessageEvent extends ExtendableEvent {
|
||||
data: any;
|
||||
source: Client|Object;
|
||||
}
|
||||
|
||||
// ServiceWorkerGlobalScope
|
||||
|
||||
interface ServiceWorkerGlobalScope {
|
||||
caches: CacheStorage;
|
||||
clients: Clients;
|
||||
registration: ServiceWorkerRegistration;
|
||||
|
||||
addEventListener(event: 'activate', fn: (event?: ExtendableEvent) => any): void;
|
||||
addEventListener(event: 'message', fn: (event?: ExtendableMessageEvent) => any): void;
|
||||
addEventListener(event: 'fetch', fn: (event?: FetchEvent) => any): void;
|
||||
addEventListener(event: 'install', fn: (event?: ExtendableEvent) => any): void;
|
||||
addEventListener(event: 'push', fn: (event?: PushEvent) => any): void;
|
||||
addEventListener(event: 'sync', fn: (event?: SyncEvent) => any): void;
|
||||
|
||||
fetch(request: Request|string): Promise<Response>;
|
||||
skipWaiting(): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute the SHA1 of the given string
|
||||
*
|
||||
* see http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf
|
||||
*
|
||||
* WARNING: this function has not been designed not tested with security in mind.
|
||||
* DO NOT USE IT IN A SECURITY SENSITIVE CONTEXT.
|
||||
*
|
||||
* Borrowed from @angular/compiler/src/i18n/digest.ts
|
||||
*/
|
||||
export function sha1(str: string): string {
|
||||
const utf8 = str;
|
||||
const words32 = stringToWords32(utf8, Endian.Big);
|
||||
const len = utf8.length * 8;
|
||||
|
||||
const w = new Array(80);
|
||||
let [a, b, c, d, e]: number[] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0];
|
||||
|
||||
words32[len >> 5] |= 0x80 << (24 - len % 32);
|
||||
words32[((len + 64 >> 9) << 4) + 15] = len;
|
||||
|
||||
for (let i = 0; i < words32.length; i += 16) {
|
||||
const [h0, h1, h2, h3, h4]: number[] = [a, b, c, d, e];
|
||||
|
||||
for (let j = 0; j < 80; j++) {
|
||||
if (j < 16) {
|
||||
w[j] = words32[i + j];
|
||||
} else {
|
||||
w[j] = rol32(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1);
|
||||
}
|
||||
|
||||
const [f, k] = fk(j, b, c, d);
|
||||
const temp = [rol32(a, 5), f, e, k, w[j]].reduce(add32);
|
||||
[e, d, c, b, a] = [d, c, rol32(b, 30), a, temp];
|
||||
}
|
||||
|
||||
[a, b, c, d, e] = [add32(a, h0), add32(b, h1), add32(c, h2), add32(d, h3), add32(e, h4)];
|
||||
}
|
||||
|
||||
return byteStringToHexString(words32ToByteString([a, b, c, d, e]));
|
||||
}
|
||||
|
||||
function add32(a: number, b: number): number {
|
||||
return add32to64(a, b)[1];
|
||||
}
|
||||
|
||||
function add32to64(a: number, b: number): [number, number] {
|
||||
const low = (a & 0xffff) + (b & 0xffff);
|
||||
const high = (a >>> 16) + (b >>> 16) + (low >>> 16);
|
||||
return [high >>> 16, (high << 16) | (low & 0xffff)];
|
||||
}
|
||||
|
||||
function add64([ah, al]: [number, number], [bh, bl]: [number, number]): [number, number] {
|
||||
const [carry, l] = add32to64(al, bl);
|
||||
const h = add32(add32(ah, bh), carry);
|
||||
return [h, l];
|
||||
}
|
||||
|
||||
function sub32(a: number, b: number): number {
|
||||
const low = (a & 0xffff) - (b & 0xffff);
|
||||
const high = (a >> 16) - (b >> 16) + (low >> 16);
|
||||
return (high << 16) | (low & 0xffff);
|
||||
}
|
||||
|
||||
// Rotate a 32b number left `count` position
|
||||
function rol32(a: number, count: number): number {
|
||||
return (a << count) | (a >>> (32 - count));
|
||||
}
|
||||
|
||||
// Rotate a 64b number left `count` position
|
||||
function rol64([hi, lo]: [number, number], count: number): [number, number] {
|
||||
const h = (hi << count) | (lo >>> (32 - count));
|
||||
const l = (lo << count) | (hi >>> (32 - count));
|
||||
return [h, l];
|
||||
}
|
||||
|
||||
enum Endian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
function fk(index: number, b: number, c: number, d: number): [number, number] {
|
||||
if (index < 20) {
|
||||
return [(b & c) | (~b & d), 0x5a827999];
|
||||
}
|
||||
|
||||
if (index < 40) {
|
||||
return [b ^ c ^ d, 0x6ed9eba1];
|
||||
}
|
||||
|
||||
if (index < 60) {
|
||||
return [(b & c) | (b & d) | (c & d), 0x8f1bbcdc];
|
||||
}
|
||||
|
||||
return [b ^ c ^ d, 0xca62c1d6];
|
||||
}
|
||||
|
||||
|
||||
function stringToWords32(str: string, endian: Endian): number[] {
|
||||
const words32 = Array((str.length + 3) >>> 2);
|
||||
|
||||
for (let i = 0; i < words32.length; i++) {
|
||||
words32[i] = wordAt(str, i * 4, endian);
|
||||
}
|
||||
|
||||
return words32;
|
||||
}
|
||||
|
||||
function byteAt(str: string, index: number): number {
|
||||
return index >= str.length ? 0 : str.charCodeAt(index) & 0xff;
|
||||
}
|
||||
|
||||
function wordAt(str: string, index: number, endian: Endian): number {
|
||||
let word = 0;
|
||||
if (endian === Endian.Big) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
word += byteAt(str, index + i) << (24 - 8 * i);
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
word += byteAt(str, index + i) << 8 * i;
|
||||
}
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
function words32ToByteString(words32: number[]): string {
|
||||
return words32.reduce((str, word) => str + word32ToByteString(word), '');
|
||||
}
|
||||
|
||||
function word32ToByteString(word: number): string {
|
||||
let str = '';
|
||||
for (let i = 0; i < 4; i++) {
|
||||
str += String.fromCharCode((word >>> 8 * (3 - i)) & 0xff);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function byteStringToHexString(str: string): string {
|
||||
let hex: string = '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const b = byteAt(str, i);
|
||||
hex += (b >>> 4).toString(16) + (b & 0x0f).toString(16);
|
||||
}
|
||||
return hex.toLowerCase();
|
||||
}
|
||||
|
||||
// based on http://www.danvk.org/hex2dec.html (JS can not handle more than 56b)
|
||||
function byteStringToDecString(str: string): string {
|
||||
let decimal = '';
|
||||
let toThePower = '1';
|
||||
|
||||
for (let i = str.length - 1; i >= 0; i--) {
|
||||
decimal = addBigInt(decimal, numberTimesBigInt(byteAt(str, i), toThePower));
|
||||
toThePower = numberTimesBigInt(256, toThePower);
|
||||
}
|
||||
|
||||
return decimal.split('').reverse().join('');
|
||||
}
|
||||
|
||||
// x and y decimal, lowest significant digit first
|
||||
function addBigInt(x: string, y: string): string {
|
||||
let sum = '';
|
||||
const len = Math.max(x.length, y.length);
|
||||
for (let i = 0, carry = 0; i < len || carry; i++) {
|
||||
const tmpSum = carry + +(x[i] || 0) + +(y[i] || 0);
|
||||
if (tmpSum >= 10) {
|
||||
carry = 1;
|
||||
sum += tmpSum - 10;
|
||||
} else {
|
||||
carry = 0;
|
||||
sum += tmpSum;
|
||||
}
|
||||
}
|
||||
|
||||
return sum;
|
||||
}
|
||||
|
||||
|
||||
function numberTimesBigInt(num: number, b: string): string {
|
||||
let product = '';
|
||||
let bToThePower = b;
|
||||
for (; num !== 0; num = num >>> 1) {
|
||||
if (num & 1) product = addBigInt(product, bToThePower);
|
||||
bToThePower = addBigInt(bToThePower, bToThePower);
|
||||
}
|
||||
return product;
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* @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 {Adapter} from './adapter';
|
||||
|
||||
export function isNavigationRequest(req: Request, adapter: Adapter): boolean {
|
||||
if (req.mode !== 'navigate') {
|
||||
return false;
|
||||
}
|
||||
if (req.url.indexOf('__') !== -1) {
|
||||
return false;
|
||||
}
|
||||
if (hasFileExtension(req.url, adapter)) {
|
||||
return false;
|
||||
}
|
||||
if (!acceptsTextHtml(req)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasFileExtension(url: string, adapter: Adapter): boolean {
|
||||
const path = adapter.getPath(url);
|
||||
const lastSegment = path.split('/').pop() !;
|
||||
return lastSegment.indexOf('.') !== -1;
|
||||
}
|
||||
|
||||
function acceptsTextHtml(req: Request): boolean {
|
||||
const accept = req.headers.get('Accept');
|
||||
if (accept === null) {
|
||||
return false;
|
||||
}
|
||||
const values = accept.split(',');
|
||||
return values.some(value => value.trim().toLowerCase() === 'text/html');
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
function wrap(fn: () => Promise<void>): (done: DoneFn) => void {
|
||||
return (done: DoneFn) => { fn().then(() => done()).catch(err => done.fail(err)); };
|
||||
}
|
||||
|
||||
export function async_beforeAll(fn: () => Promise<void>): void {
|
||||
beforeAll(wrap(fn));
|
||||
}
|
||||
|
||||
export function async_beforeEach(fn: () => Promise<void>): void {
|
||||
beforeEach(wrap(fn));
|
||||
}
|
||||
|
||||
export function async_it(desc: string, fn: () => Promise<void>): void {
|
||||
it(desc, wrap(fn));
|
||||
}
|
||||
|
||||
export function async_fit(desc: string, fn: () => Promise<void>): void {
|
||||
// tslint:disable-next-line:no-jasmine-focus
|
||||
fit(desc, wrap(fn));
|
||||
}
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* @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 {MockRequest} from '../testing/fetch';
|
||||
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
|
||||
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
|
||||
|
||||
import {async_beforeEach, async_fit, async_it} from './async';
|
||||
|
||||
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,
|
||||
index: '/index.html',
|
||||
assetGroups: [
|
||||
{
|
||||
name: 'assets',
|
||||
installMode: 'prefetch',
|
||||
updateMode: 'prefetch',
|
||||
urls: [
|
||||
'/foo.txt',
|
||||
'/bar.txt',
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
dataGroups: [
|
||||
{
|
||||
name: 'testPerf',
|
||||
maxSize: 3,
|
||||
strategy: 'performance',
|
||||
patterns: ['^/api/.*$'],
|
||||
timeoutMs: 1000,
|
||||
maxAge: 5000,
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
name: 'testRefresh',
|
||||
maxSize: 3,
|
||||
strategy: 'performance',
|
||||
patterns: ['^/refresh/.*$'],
|
||||
timeoutMs: 1000,
|
||||
refreshAheadMs: 1000,
|
||||
maxAge: 5000,
|
||||
version: 1,
|
||||
},
|
||||
{
|
||||
name: 'testFresh',
|
||||
maxSize: 3,
|
||||
strategy: 'freshness',
|
||||
patterns: ['^/fresh/.*$'],
|
||||
timeoutMs: 1000,
|
||||
maxAge: 5000,
|
||||
version: 1,
|
||||
},
|
||||
],
|
||||
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();
|
||||
|
||||
const scope = new SwTestHarnessBuilder().withServerState(server).build();
|
||||
|
||||
function asyncWrap(fn: () => Promise<void>): (done: DoneFn) => void {
|
||||
return (done: DoneFn) => { fn().then(() => done(), err => done.fail(err)); };
|
||||
}
|
||||
|
||||
export function main() {
|
||||
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
||||
if (!SwTestHarness.envIsSupported()) {
|
||||
return;
|
||||
}
|
||||
describe('data cache', () => {
|
||||
let scope: SwTestHarness;
|
||||
let driver: Driver;
|
||||
async_beforeEach(async() => {
|
||||
server.clearRequests();
|
||||
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.reset();
|
||||
serverUpdate.reset();
|
||||
});
|
||||
|
||||
describe('in performance mode', () => {
|
||||
async_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();
|
||||
});
|
||||
|
||||
async_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');
|
||||
});
|
||||
|
||||
async_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();
|
||||
});
|
||||
|
||||
async_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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('in freshness mode', () => {
|
||||
async_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();
|
||||
});
|
||||
|
||||
async_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');
|
||||
});
|
||||
|
||||
async_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();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function makeRequest(scope: SwTestHarness, url: string, clientId?: string):
|
||||
Promise<string|null> {
|
||||
const [resPromise, done] = scope.handleFetch(new MockRequest(url), clientId || 'default');
|
||||
await done;
|
||||
const res = await resPromise;
|
||||
if (res !== undefined) {
|
||||
return res.text();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makePendingRequest(scope: SwTestHarness, url: string, clientId?: string):
|
||||
[Promise<string|null>, Promise<void>] {
|
||||
const [resPromise, done] = scope.handleFetch(new MockRequest(url), clientId || 'default');
|
||||
return [
|
||||
(async() => {
|
||||
const res = await resPromise;
|
||||
if (res !== undefined) {
|
||||
return res.text();
|
||||
}
|
||||
return null;
|
||||
})(),
|
||||
done
|
||||
];
|
||||
}
|
|
@ -0,0 +1,601 @@
|
|||
/**
|
||||
* @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 {sha1} from '../src/sha1';
|
||||
import {MockRequest} from '../testing/fetch';
|
||||
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTableForFs} from '../testing/mock';
|
||||
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
|
||||
|
||||
import {async_beforeEach, async_fit, async_it} from './async';
|
||||
|
||||
const dist =
|
||||
new MockFileSystemBuilder()
|
||||
.addFile('/foo.txt', 'this is foo')
|
||||
.addFile('/bar.txt', 'this is bar')
|
||||
.addFile('/baz.txt', 'this is baz')
|
||||
.addFile('/qux.txt', 'this is qux')
|
||||
.addFile('/quux.txt', 'this is quux')
|
||||
.addUnhashedFile('/unhashed/a.txt', 'this is unhashed', {'Cache-Control': 'max-age=10'})
|
||||
.build();
|
||||
|
||||
|
||||
const distUpdate =
|
||||
new MockFileSystemBuilder()
|
||||
.addFile('/foo.txt', 'this is foo v2')
|
||||
.addFile('/bar.txt', 'this is bar')
|
||||
.addFile('/baz.txt', 'this is baz v2')
|
||||
.addFile('/qux.txt', 'this is qux v2')
|
||||
.addFile('/quux.txt', 'this is quux v2')
|
||||
.addUnhashedFile('/unhashed/a.txt', 'this is unhashed v2', {'Cache-Control': 'max-age=10'})
|
||||
.build();
|
||||
|
||||
const manifest: Manifest = {
|
||||
configVersion: 1,
|
||||
appData: {
|
||||
version: 'original',
|
||||
},
|
||||
index: '/foo.txt',
|
||||
assetGroups: [
|
||||
{
|
||||
name: 'assets',
|
||||
installMode: 'prefetch',
|
||||
updateMode: 'prefetch',
|
||||
urls: [
|
||||
'/foo.txt',
|
||||
'/bar.txt',
|
||||
],
|
||||
patterns: [
|
||||
'/unhashed/.*',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
installMode: 'lazy',
|
||||
updateMode: 'lazy',
|
||||
urls: [
|
||||
'/baz.txt',
|
||||
'/qux.txt',
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
{
|
||||
name: 'lazy_prefetch',
|
||||
installMode: 'lazy',
|
||||
updateMode: 'prefetch',
|
||||
urls: ['/quux.txt'],
|
||||
patterns: [],
|
||||
}
|
||||
],
|
||||
hashTable: tmpHashTableForFs(dist),
|
||||
};
|
||||
|
||||
const manifestUpdate: Manifest = {
|
||||
configVersion: 1,
|
||||
appData: {
|
||||
version: 'update',
|
||||
},
|
||||
index: '/foo.txt',
|
||||
assetGroups: [
|
||||
{
|
||||
name: 'assets',
|
||||
installMode: 'prefetch',
|
||||
updateMode: 'prefetch',
|
||||
urls: [
|
||||
'/foo.txt',
|
||||
'/bar.txt',
|
||||
],
|
||||
patterns: [
|
||||
'/unhashed/.*',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'other',
|
||||
installMode: 'lazy',
|
||||
updateMode: 'lazy',
|
||||
urls: [
|
||||
'/baz.txt',
|
||||
'/qux.txt',
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
{
|
||||
name: 'lazy_prefetch',
|
||||
installMode: 'lazy',
|
||||
updateMode: 'prefetch',
|
||||
urls: ['/quux.txt'],
|
||||
patterns: [],
|
||||
}
|
||||
],
|
||||
hashTable: tmpHashTableForFs(distUpdate),
|
||||
};
|
||||
|
||||
const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build();
|
||||
|
||||
const serverUpdate =
|
||||
new MockServerStateBuilder().withStaticFiles(distUpdate).withManifest(manifestUpdate).build();
|
||||
|
||||
const server404 = new MockServerStateBuilder().withStaticFiles(dist).build();
|
||||
|
||||
const scope = new SwTestHarnessBuilder().withServerState(server).build();
|
||||
|
||||
const manifestHash = sha1(JSON.stringify(manifest));
|
||||
const manifestUpdateHash = sha1(JSON.stringify(manifestUpdate));
|
||||
|
||||
export function main() {
|
||||
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
||||
if (!SwTestHarness.envIsSupported()) {
|
||||
return;
|
||||
}
|
||||
describe('Driver', () => {
|
||||
let scope: SwTestHarness;
|
||||
let driver: Driver;
|
||||
|
||||
beforeEach(() => {
|
||||
server.clearRequests();
|
||||
scope = new SwTestHarnessBuilder().withServerState(server).build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
});
|
||||
|
||||
async_it('initializes prefetched content correctly, after activation', async() => {
|
||||
expect(await scope.startup(true)).toEqual(true);
|
||||
await scope.resolveSelfMessages();
|
||||
await driver.initialized;
|
||||
server.assertSawRequestFor('/ngsw.json');
|
||||
server.assertSawRequestFor('/foo.txt');
|
||||
server.assertSawRequestFor('/bar.txt');
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('initializes prefetched content correctly, after a request kicks it off', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
server.assertSawRequestFor('/ngsw.json');
|
||||
server.assertSawRequestFor('/foo.txt');
|
||||
server.assertSawRequestFor('/bar.txt');
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('caches lazy content on-request', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
server.clearRequests();
|
||||
expect(await makeRequest(scope, '/baz.txt')).toEqual('this is baz');
|
||||
server.assertSawRequestFor('/baz.txt');
|
||||
server.assertNoOtherRequests();
|
||||
expect(await makeRequest(scope, '/baz.txt')).toEqual('this is baz');
|
||||
server.assertNoOtherRequests();
|
||||
expect(await makeRequest(scope, '/qux.txt')).toEqual('this is qux');
|
||||
server.assertSawRequestFor('/qux.txt');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('updates to new content when requested', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
const client = scope.clients.getMock('default') !;
|
||||
expect(client.messages).toEqual([]);
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.assertSawRequestFor('/ngsw.json');
|
||||
serverUpdate.assertSawRequestFor('/foo.txt');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
expect(client.messages).toEqual([{
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: {hash: manifestHash, appData: {version: 'original'}},
|
||||
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
}]);
|
||||
|
||||
// Default client is still on the old version of the app.
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
|
||||
// Sending a new client id should result in the updated version being returned.
|
||||
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
|
||||
|
||||
// Of course, the old version should still work.
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
|
||||
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('updates to new content when requested', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
const client = scope.clients.getMock('default') !;
|
||||
expect(client.messages).toEqual([]);
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.assertSawRequestFor('/ngsw.json');
|
||||
serverUpdate.assertSawRequestFor('/foo.txt');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
expect(client.messages).toEqual([{
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: {hash: manifestHash, appData: {version: 'original'}},
|
||||
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
}]);
|
||||
|
||||
// Default client is still on the old version of the app.
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
|
||||
// Sending a new client id should result in the updated version being returned.
|
||||
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
|
||||
|
||||
// Of course, the old version should still work.
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
|
||||
expect(await makeRequest(scope, '/bar.txt')).toEqual('this is bar');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('updates a specific client to new content on request', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
const client = scope.clients.getMock('default') !;
|
||||
expect(client.messages).toEqual([]);
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.clearRequests();
|
||||
await driver.updateClient(client as any as Client);
|
||||
|
||||
expect(client.messages).toEqual([
|
||||
{
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: {hash: manifestHash, appData: {version: 'original'}},
|
||||
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
},
|
||||
{
|
||||
type: 'UPDATE_ACTIVATED',
|
||||
previous: {hash: manifestHash, appData: {version: 'original'}},
|
||||
current: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
}
|
||||
]);
|
||||
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
|
||||
});
|
||||
|
||||
async_it('checks for updates on restart', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope = new SwTestHarnessBuilder()
|
||||
.withCacheState(scope.caches.dehydrate())
|
||||
.withServerState(serverUpdate)
|
||||
.build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
scope.advance(12000);
|
||||
await driver.idle.empty;
|
||||
serverUpdate.assertSawRequestFor('/ngsw.json');
|
||||
serverUpdate.assertSawRequestFor('/foo.txt');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('preserves multiple client assignments across restarts', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
|
||||
serverUpdate.clearRequests();
|
||||
|
||||
scope = new SwTestHarnessBuilder()
|
||||
.withServerState(serverUpdate)
|
||||
.withCacheState(scope.caches.dehydrate())
|
||||
.build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('updates when refreshed', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
const client = scope.clients.getMock('default') !;
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.clearRequests();
|
||||
|
||||
expect(await makeRequest(scope, '/baz', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/html, text/css',
|
||||
},
|
||||
mode: 'navigate',
|
||||
})).toEqual('this is foo v2');
|
||||
|
||||
expect(client.messages).toEqual([
|
||||
{
|
||||
type: 'UPDATE_AVAILABLE',
|
||||
current: {hash: manifestHash, appData: {version: 'original'}},
|
||||
available: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
},
|
||||
{
|
||||
type: 'UPDATE_ACTIVATED',
|
||||
previous: {hash: manifestHash, appData: {version: 'original'}},
|
||||
current: {hash: manifestUpdateHash, appData: {version: 'update'}},
|
||||
}
|
||||
]);
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('cleans up properly when manually requested', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.clearRequests();
|
||||
|
||||
expect(await makeRequest(scope, '/foo.txt', 'new')).toEqual('this is foo v2');
|
||||
|
||||
// Delete the default client.
|
||||
scope.clients.remove('default');
|
||||
|
||||
// After this, the old version should no longer be cached.
|
||||
await driver.cleanupCaches();
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
|
||||
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('cleans up properly on restart', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope = new SwTestHarnessBuilder()
|
||||
.withCacheState(scope.caches.dehydrate())
|
||||
.withServerState(serverUpdate)
|
||||
.build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
scope.clients.remove('default');
|
||||
|
||||
scope.advance(12000);
|
||||
await driver.idle.empty;
|
||||
serverUpdate.clearRequests();
|
||||
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo v2');
|
||||
|
||||
const oldManifestHash = sha1(JSON.stringify(manifest));
|
||||
const keys = await scope.caches.keys();
|
||||
const hasOldCaches = keys.some(name => name.startsWith(oldManifestHash + ':'));
|
||||
expect(hasOldCaches).toEqual(false);
|
||||
});
|
||||
|
||||
async_it('shows notifications for push notifications', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
scope.clients.add('default');
|
||||
await scope.handlePush({
|
||||
notification: {
|
||||
title: 'This is a test',
|
||||
body: 'Test body',
|
||||
}
|
||||
});
|
||||
expect(scope.notifications).toEqual([{
|
||||
title: 'This is a test',
|
||||
options: {body: 'Test body'},
|
||||
}]);
|
||||
expect(scope.clients.getMock('default') !.messages).toEqual([{
|
||||
type: 'PUSH',
|
||||
data: {
|
||||
notification: {
|
||||
title: 'This is a test',
|
||||
body: 'Test body',
|
||||
},
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
async_it('prefetches updates to lazy cache when set', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
expect(await makeRequest(scope, '/quux.txt')).toEqual('this is quux');
|
||||
|
||||
scope.updateServerState(serverUpdate);
|
||||
expect(await driver.checkForUpdate()).toEqual(true);
|
||||
serverUpdate.assertSawRequestFor('/quux.txt');
|
||||
serverUpdate.clearRequests();
|
||||
driver.updateClient(await scope.clients.get('default'));
|
||||
expect(await makeRequest(scope, '/quux.txt')).toEqual('this is quux v2');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('unregisters when manifest 404s', async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope.updateServerState(server404);
|
||||
expect(await driver.checkForUpdate()).toEqual(false);
|
||||
expect(scope.unregistered).toEqual(true);
|
||||
expect(await scope.caches.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('unhashed requests', () => {
|
||||
async_beforeEach(async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
server.clearRequests();
|
||||
});
|
||||
|
||||
async_it('are cached appropriately', async() => {
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.assertSawRequestFor('/unhashed/a.txt');
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('expire according to Cache-Control headers', async() => {
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.clearRequests();
|
||||
|
||||
// Update the resource on the server.
|
||||
scope.updateServerState(serverUpdate);
|
||||
|
||||
// Move ahead by 15 seconds.
|
||||
scope.advance(15000);
|
||||
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
// Another 6 seconds.
|
||||
scope.advance(6000);
|
||||
await driver.idle.empty;
|
||||
serverUpdate.assertSawRequestFor('/unhashed/a.txt');
|
||||
|
||||
// Now the new version of the resource should be served.
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed v2');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('survive serialization', async() => {
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.clearRequests();
|
||||
|
||||
const state = scope.caches.dehydrate();
|
||||
scope = new SwTestHarnessBuilder().withCacheState(state).withServerState(server).build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
server.assertNoRequestFor('/unhashed/a.txt');
|
||||
server.clearRequests();
|
||||
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.assertNoOtherRequests();
|
||||
|
||||
// Advance the clock by 6 seconds, triggering the idle tasks. If an idle task
|
||||
// was scheduled from the request above, it means that the metadata was not
|
||||
// properly saved.
|
||||
scope.advance(6000);
|
||||
await driver.idle.empty;
|
||||
server.assertNoRequestFor('/unhashed/a.txt');
|
||||
});
|
||||
|
||||
async_it('get carried over during updates', async() => {
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
server.clearRequests();
|
||||
|
||||
scope = new SwTestHarnessBuilder()
|
||||
.withCacheState(scope.caches.dehydrate())
|
||||
.withServerState(serverUpdate)
|
||||
.build();
|
||||
driver = new Driver(scope, scope, new CacheDatabase(scope, scope));
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
|
||||
scope.advance(15000);
|
||||
await driver.idle.empty;
|
||||
serverUpdate.assertNoRequestFor('/unhashed/a.txt');
|
||||
serverUpdate.clearRequests();
|
||||
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
|
||||
scope.advance(15000);
|
||||
await driver.idle.empty;
|
||||
serverUpdate.assertSawRequestFor('/unhashed/a.txt');
|
||||
|
||||
expect(await makeRequest(scope, '/unhashed/a.txt')).toEqual('this is unhashed v2');
|
||||
serverUpdate.assertNoOtherRequests();
|
||||
});
|
||||
});
|
||||
describe('routing', () => {
|
||||
async_beforeEach(async() => {
|
||||
expect(await makeRequest(scope, '/foo.txt')).toEqual('this is foo');
|
||||
await driver.initialized;
|
||||
server.clearRequests();
|
||||
});
|
||||
|
||||
async_it('redirects to index on a route-like request', async() => {
|
||||
expect(await makeRequest(scope, '/baz', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/html, text/css',
|
||||
},
|
||||
mode: 'navigate',
|
||||
})).toEqual('this is foo');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('redirects to index on a request to the origin URL request', async() => {
|
||||
expect(await makeRequest(scope, 'http://example.com', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/html, text/css',
|
||||
},
|
||||
mode: 'navigate',
|
||||
})).toEqual('this is foo');
|
||||
server.assertNoOtherRequests();
|
||||
});
|
||||
|
||||
async_it('does not redirect to index on a non-navigation request', async() => {
|
||||
expect(await makeRequest(scope, '/baz', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/html, text/css',
|
||||
},
|
||||
})).toBeNull();
|
||||
server.assertSawRequestFor('/baz');
|
||||
});
|
||||
|
||||
async_it('does not redirect to index on a request with an extension', async() => {
|
||||
expect(await makeRequest(scope, '/baz.html', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/html, text/css',
|
||||
},
|
||||
})).toBeNull();
|
||||
server.assertSawRequestFor('/baz.html');
|
||||
});
|
||||
|
||||
async_it('does not redirect to index on a request that does not expect HTML', async() => {
|
||||
expect(await makeRequest(scope, '/baz', 'default', {
|
||||
headers: {
|
||||
'Accept': 'text/plain, text/css',
|
||||
},
|
||||
mode: 'navigate',
|
||||
})).toBeNull();
|
||||
server.assertSawRequestFor('/baz');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function makeRequest(
|
||||
scope: SwTestHarness, url: string, clientId?: string, init?: Object): Promise<string|null> {
|
||||
const [resPromise, done] = scope.handleFetch(new MockRequest(url, init), clientId || 'default');
|
||||
await done;
|
||||
const res = await resPromise;
|
||||
scope.clients.add(clientId || 'default');
|
||||
if (res !== undefined && res.ok) {
|
||||
return res.text();
|
||||
}
|
||||
return null;
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @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 {PrefetchAssetGroup} from '../src/assets';
|
||||
import {CacheDatabase} from '../src/db-cache';
|
||||
import {IdleScheduler} from '../src/idle';
|
||||
import {MockFileSystemBuilder, MockServerStateBuilder, tmpHashTable, tmpManifestSingleAssetGroup} from '../testing/mock';
|
||||
import {SwTestHarness, SwTestHarnessBuilder} from '../testing/scope';
|
||||
|
||||
import {async_fit, async_it} from './async';
|
||||
|
||||
const dist = new MockFileSystemBuilder()
|
||||
.addFile('/foo.txt', 'this is foo')
|
||||
.addFile('/bar.txt', 'this is bar')
|
||||
.build();
|
||||
|
||||
const manifest = tmpManifestSingleAssetGroup(dist);
|
||||
|
||||
const server = new MockServerStateBuilder().withStaticFiles(dist).withManifest(manifest).build();
|
||||
|
||||
const scope = new SwTestHarnessBuilder().withServerState(server).build();
|
||||
|
||||
const db = new CacheDatabase(scope, scope);
|
||||
|
||||
|
||||
|
||||
export function main() {
|
||||
// Skip environments that don't support the minimum APIs needed to run the SW tests.
|
||||
if (!SwTestHarness.envIsSupported()) {
|
||||
return;
|
||||
}
|
||||
describe('prefetch assets', () => {
|
||||
let group: PrefetchAssetGroup;
|
||||
let idle: IdleScheduler;
|
||||
beforeEach(() => {
|
||||
idle = new IdleScheduler(null !, 3000);
|
||||
group = new PrefetchAssetGroup(
|
||||
scope, scope, idle, manifest.assetGroups ![0], tmpHashTable(manifest), db, 'test');
|
||||
});
|
||||
async_it('initializes without crashing', async() => { await group.initializeFully(); });
|
||||
async_it('fully caches the two files', async() => {
|
||||
await group.initializeFully();
|
||||
scope.updateServerState();
|
||||
const res1 = await group.handleFetch(scope.newRequest('/foo.txt'), scope);
|
||||
const res2 = await group.handleFetch(scope.newRequest('/bar.txt'), scope);
|
||||
expect(await res1 !.text()).toEqual('this is foo');
|
||||
expect(await res2 !.text()).toEqual('this is bar');
|
||||
});
|
||||
async_it('persists the cache across restarts', async() => {
|
||||
await group.initializeFully();
|
||||
const freshScope =
|
||||
new SwTestHarnessBuilder().withCacheState(scope.caches.dehydrate()).build();
|
||||
group = new PrefetchAssetGroup(
|
||||
freshScope, freshScope, idle, manifest.assetGroups ![0], tmpHashTable(manifest),
|
||||
new CacheDatabase(freshScope, freshScope), 'test');
|
||||
await group.initializeFully();
|
||||
const res1 = await group.handleFetch(scope.newRequest('/foo.txt'), scope);
|
||||
const res2 = await group.handleFetch(scope.newRequest('/bar.txt'), scope);
|
||||
expect(await res1 !.text()).toEqual('this is foo');
|
||||
expect(await res2 !.text()).toEqual('this is bar');
|
||||
});
|
||||
async_it('caches properly if resources are requested before initialization', async() => {
|
||||
const res1 = await group.handleFetch(scope.newRequest('/foo.txt'), scope);
|
||||
const res2 = await group.handleFetch(scope.newRequest('/bar.txt'), scope);
|
||||
expect(await res1 !.text()).toEqual('this is foo');
|
||||
expect(await res2 !.text()).toEqual('this is bar');
|
||||
scope.updateServerState();
|
||||
await group.initializeFully();
|
||||
});
|
||||
async_it('throws if the server-side content does not match the manifest hash', async() => {
|
||||
const badHashFs = dist.extend().addFile('/foo.txt', 'corrupted file').build();
|
||||
const badServer =
|
||||
new MockServerStateBuilder().withManifest(manifest).withStaticFiles(badHashFs).build();
|
||||
const badScope = new SwTestHarnessBuilder().withServerState(badServer).build();
|
||||
group = new PrefetchAssetGroup(
|
||||
badScope, badScope, idle, manifest.assetGroups ![0], tmpHashTable(manifest),
|
||||
new CacheDatabase(badScope, badScope), 'test');
|
||||
const err = await errorFrom(group.initializeFully());
|
||||
expect(err.message).toContain('Hash mismatch');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function errorFrom(promise: Promise<any>): Promise<any> {
|
||||
return promise.catch(err => err);
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* @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 {MockResponse} from './fetch';
|
||||
|
||||
export interface DehydratedResponse {
|
||||
body: string|null;
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: {[name: string]: string};
|
||||
}
|
||||
|
||||
export type DehydratedCache = {
|
||||
[url: string]: DehydratedResponse
|
||||
};
|
||||
export type DehydratedCacheStorage = {
|
||||
[name: string]: DehydratedCache
|
||||
};
|
||||
|
||||
export class MockCacheStorage implements CacheStorage {
|
||||
private caches = new Map<string, MockCache>();
|
||||
|
||||
constructor(hydrateFrom?: string) {
|
||||
if (hydrateFrom !== undefined) {
|
||||
const hydrated = JSON.parse(hydrateFrom) as DehydratedCacheStorage;
|
||||
Object.keys(hydrated).forEach(
|
||||
name => { this.caches.set(name, new MockCache(hydrated[name])); });
|
||||
}
|
||||
}
|
||||
|
||||
async has(name: string): Promise<boolean> { return this.caches.has(name); }
|
||||
|
||||
async keys(): Promise<string[]> { return Array.from(this.caches.keys()); }
|
||||
|
||||
async open(name: string): Promise<Cache> {
|
||||
if (!this.caches.has(name)) {
|
||||
this.caches.set(name, new MockCache());
|
||||
}
|
||||
return this.caches.get(name) !;
|
||||
}
|
||||
|
||||
async match(req: Request): Promise<Response|undefined> {
|
||||
return await Array.from(this.caches.values())
|
||||
.reduce<Promise<Response|undefined>>(async(answer, cache): Promise<Response|undefined> => {
|
||||
const curr = await answer;
|
||||
if (curr !== undefined) {
|
||||
return curr;
|
||||
}
|
||||
|
||||
return cache.match(req);
|
||||
}, Promise.resolve<Response|undefined>(undefined));
|
||||
}
|
||||
|
||||
async 'delete'(name: string): Promise<boolean> {
|
||||
if (this.caches.has(name)) {
|
||||
this.caches.delete(name);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
dehydrate(): string {
|
||||
const dehydrated: DehydratedCacheStorage = {};
|
||||
Array.from(this.caches.keys()).forEach(name => {
|
||||
const cache = this.caches.get(name) !;
|
||||
dehydrated[name] = cache.dehydrate();
|
||||
});
|
||||
return JSON.stringify(dehydrated);
|
||||
}
|
||||
}
|
||||
|
||||
export class MockCache implements Cache {
|
||||
private cache = new Map<string, Response>();
|
||||
|
||||
constructor(hydrated?: DehydratedCache) {
|
||||
if (hydrated !== undefined) {
|
||||
Object.keys(hydrated).forEach(url => {
|
||||
const resp = hydrated[url];
|
||||
this.cache.set(
|
||||
url, new MockResponse(
|
||||
resp.body,
|
||||
{status: resp.status, statusText: resp.statusText, headers: resp.headers}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async add(request: RequestInfo): Promise<void> { throw 'Not implemented'; }
|
||||
|
||||
async addAll(requests: RequestInfo[]): Promise<void> { throw 'Not implemented'; }
|
||||
|
||||
async 'delete'(request: RequestInfo): Promise<boolean> {
|
||||
const url = (typeof request === 'string' ? request : request.url);
|
||||
if (this.cache.has(url)) {
|
||||
this.cache.delete(url);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async keys(match?: Request|string): Promise<string[]> {
|
||||
if (match !== undefined) {
|
||||
throw 'Not implemented';
|
||||
}
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
|
||||
async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response> {
|
||||
const url = (typeof request === 'string' ? request : request.url);
|
||||
// TODO: cleanup typings. Typescript doesn't know this can resolve to undefined.
|
||||
let res = this.cache.get(url);
|
||||
if (res !== undefined) {
|
||||
res = res.clone();
|
||||
}
|
||||
return res !;
|
||||
}
|
||||
|
||||
|
||||
async matchAll(request?: Request|string, options?: CacheQueryOptions): Promise<Response[]> {
|
||||
if (request === undefined) {
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
const url = (typeof request === 'string' ? request : request.url);
|
||||
if (this.cache.has(url)) {
|
||||
return [this.cache.get(url) !];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async put(request: RequestInfo, response: Response): Promise<void> {
|
||||
const url = (typeof request === 'string' ? request : request.url);
|
||||
this.cache.set(url, response.clone());
|
||||
return;
|
||||
}
|
||||
|
||||
dehydrate(): DehydratedCache {
|
||||
const dehydrated: DehydratedCache = {};
|
||||
Array.from(this.cache.keys()).forEach(url => {
|
||||
const resp = this.cache.get(url) !as MockResponse;
|
||||
const dehydratedResp = {
|
||||
body: resp._body,
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: {},
|
||||
} as DehydratedResponse;
|
||||
|
||||
resp.headers.forEach((value, name) => { dehydratedResp.headers[name] = value; });
|
||||
|
||||
dehydrated[url] = dehydratedResp;
|
||||
});
|
||||
return dehydrated;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* @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
|
||||
*/
|
||||
|
||||
export class MockBody implements Body {
|
||||
bodyUsed: boolean = false;
|
||||
|
||||
constructor(public _body: string|null) {}
|
||||
|
||||
async arrayBuffer(): Promise<ArrayBuffer> { throw 'Not implemented'; }
|
||||
|
||||
async blob(): Promise<Blob> { throw 'Not implemented'; }
|
||||
|
||||
async json(): Promise<any> {
|
||||
this.bodyUsed = true;
|
||||
if (this._body !== null) {
|
||||
return JSON.parse(this._body);
|
||||
} else {
|
||||
throw new Error('No body');
|
||||
}
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
this.bodyUsed = true;
|
||||
if (this._body !== null) {
|
||||
return this._body;
|
||||
} else {
|
||||
throw new Error('No body');
|
||||
}
|
||||
}
|
||||
|
||||
async formData(): Promise<FormData> { throw 'Not implemented'; }
|
||||
}
|
||||
|
||||
export class MockHeaders implements Headers {
|
||||
map = new Map<string, string>();
|
||||
append(name: string, value: string): void { this.map.set(name, value); }
|
||||
|
||||
delete (name: string): void { this.map.delete(name); }
|
||||
|
||||
forEach(callback: Function): void { this.map.forEach(callback as any); }
|
||||
|
||||
get(name: string): string|null { return this.map.get(name) || null; }
|
||||
|
||||
has(name: string): boolean { return this.map.has(name); }
|
||||
|
||||
set(name: string, value: string): void { this.map.set(name, value); }
|
||||
}
|
||||
|
||||
export class MockRequest extends MockBody implements Request {
|
||||
readonly cache: RequestCache = 'default';
|
||||
readonly credentials: RequestCredentials = 'omit';
|
||||
readonly destination: RequestDestination = 'document';
|
||||
readonly headers: Headers = new MockHeaders();
|
||||
readonly integrity: string = '';
|
||||
readonly keepalive: boolean = true;
|
||||
readonly method: string = 'GET';
|
||||
readonly mode: RequestMode = 'cors';
|
||||
readonly redirect: RequestRedirect = 'error';
|
||||
readonly referrer: string = '';
|
||||
readonly referrerPolicy: ReferrerPolicy = 'no-referrer';
|
||||
readonly type: RequestType = '';
|
||||
readonly url: string;
|
||||
|
||||
constructor(input: string|Request, init: RequestInit = {}) {
|
||||
super(init !== undefined ? init.body || null : null);
|
||||
if (typeof input !== 'string') {
|
||||
throw 'Not implemented';
|
||||
}
|
||||
this.url = input;
|
||||
if (init.headers !== undefined) {
|
||||
if (init.headers instanceof MockHeaders) {
|
||||
this.headers = init.headers;
|
||||
} else {
|
||||
Object.keys(init.headers).forEach(header => {
|
||||
this.headers.set(header, init.headers[header]);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (init.mode !== undefined) {
|
||||
this.mode = init.mode;
|
||||
}
|
||||
}
|
||||
|
||||
clone(): Request {
|
||||
if (this.bodyUsed) {
|
||||
throw 'Body already consumed';
|
||||
}
|
||||
return new MockRequest(this.url, {body: this._body});
|
||||
}
|
||||
}
|
||||
|
||||
export class MockResponse extends MockBody implements Response {
|
||||
readonly headers: Headers = new MockHeaders();
|
||||
get ok(): boolean { return this.status >= 200 && this.status < 300; }
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly type: ResponseType = 'basic';
|
||||
readonly url: string = '';
|
||||
readonly body: ReadableStream|null = null;
|
||||
|
||||
constructor(body?: any, init: ResponseInit = {}) {
|
||||
super(typeof body === 'string' ? body : null);
|
||||
this.status = (init.status !== undefined) ? init.status : 200;
|
||||
this.statusText = init.statusText || 'OK';
|
||||
if (init.headers !== undefined) {
|
||||
if (init.headers instanceof MockHeaders) {
|
||||
this.headers = init.headers;
|
||||
} else {
|
||||
Object.keys(init.headers).forEach(header => {
|
||||
this.headers.set(header, init.headers[header]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clone(): Response {
|
||||
if (this.bodyUsed) {
|
||||
throw 'Body already consumed';
|
||||
}
|
||||
return new MockResponse(
|
||||
this._body, {status: this.status, statusText: this.statusText, headers: this.headers});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* @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>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
build(): MockServerState { return new MockServerState(this.resources); }
|
||||
}
|
||||
|
||||
export class MockServerState {
|
||||
private requests: Request[] = [];
|
||||
private gate: Promise<void> = Promise.resolve();
|
||||
private resolve: Function|null = null;
|
||||
private resolveNextRequest: Function;
|
||||
nextRequest: Promise<Request>;
|
||||
|
||||
constructor(private resources: Map<string, Response>) {
|
||||
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;
|
||||
const url = req.url.split('?')[0];
|
||||
this.requests.push(req);
|
||||
if (this.resources.has(url)) {
|
||||
return this.resources.get(url) !.clone();
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function tmpHashTableForFs(fs: MockFileSystem): {[url: string]: string} {
|
||||
const table: {[url: string]: string} = {};
|
||||
fs.list().forEach(path => {
|
||||
const file = fs.lookup(path) !;
|
||||
if (file.hashThisFile) {
|
||||
table[path] = file.hash;
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* @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 {Subject} from 'rxjs/Subject';
|
||||
|
||||
import {Adapter, Context} from '../src/adapter';
|
||||
import {AssetGroupConfig, Manifest} from '../src/manifest';
|
||||
import {sha1} from '../src/sha1';
|
||||
|
||||
import {MockCacheStorage} from './cache';
|
||||
import {MockHeaders, MockRequest, MockResponse} from './fetch';
|
||||
import {MockServerState, MockServerStateBuilder} from './mock';
|
||||
|
||||
const EMPTY_SERVER_STATE = new MockServerStateBuilder().build();
|
||||
|
||||
export class MockClient {
|
||||
queue = new Subject<Object>();
|
||||
|
||||
constructor(readonly id: string) {}
|
||||
|
||||
readonly messages: Object[] = [];
|
||||
|
||||
postMessage(message: Object): void {
|
||||
this.messages.push(message);
|
||||
this.queue.next(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class SwTestHarnessBuilder {
|
||||
private server = EMPTY_SERVER_STATE;
|
||||
private caches = new MockCacheStorage();
|
||||
|
||||
withCacheState(cache: string): SwTestHarnessBuilder {
|
||||
this.caches = new MockCacheStorage(cache);
|
||||
return this;
|
||||
}
|
||||
|
||||
withServerState(state: MockServerState): SwTestHarnessBuilder {
|
||||
this.server = state;
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): SwTestHarness { return new SwTestHarness(this.server, this.caches); }
|
||||
}
|
||||
|
||||
export class MockClients implements Clients {
|
||||
private clients = new Map<string, MockClient>();
|
||||
|
||||
add(clientId: string): void {
|
||||
if (this.clients.has(clientId)) {
|
||||
return;
|
||||
}
|
||||
this.clients.set(clientId, new MockClient(clientId));
|
||||
}
|
||||
|
||||
remove(clientId: string): void { this.clients.delete(clientId); }
|
||||
|
||||
async get(id: string): Promise<Client> {
|
||||
this.add(id);
|
||||
return this.clients.get(id) !as any as Client;
|
||||
}
|
||||
|
||||
getMock(id: string): MockClient|undefined { return this.clients.get(id); }
|
||||
|
||||
async matchAll(): Promise<Client[]> {
|
||||
return Array.from(this.clients.values()) as any[] as Client[];
|
||||
}
|
||||
|
||||
async claim(): Promise<any> {}
|
||||
}
|
||||
|
||||
export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context {
|
||||
readonly clients = new MockClients();
|
||||
private eventHandlers = new Map<string, Function>();
|
||||
private skippedWaiting = true;
|
||||
|
||||
private selfMessageQueue: any[] = [];
|
||||
unregistered: boolean;
|
||||
readonly notifications: {title: string, options: Object}[] = [];
|
||||
readonly registration: ServiceWorkerRegistration = {
|
||||
active: {
|
||||
postMessage: (msg: any) => { this.selfMessageQueue.push(msg); },
|
||||
},
|
||||
scope: 'http://localhost/',
|
||||
showNotification: (title: string, options: Object) => {
|
||||
this.notifications.push({title, options});
|
||||
},
|
||||
unregister: () => { this.unregistered = true; },
|
||||
} as any;
|
||||
|
||||
static envIsSupported(): boolean {
|
||||
return (typeof require === 'function' && typeof require('url')['parse'] === 'function') ||
|
||||
(typeof URL === 'function');
|
||||
}
|
||||
|
||||
time: number;
|
||||
|
||||
private timers: {
|
||||
at: number,
|
||||
duration: number,
|
||||
fn: Function,
|
||||
fired: boolean,
|
||||
}[] = [];
|
||||
|
||||
constructor(private server: MockServerState, readonly caches: MockCacheStorage) {
|
||||
this.time = Date.now();
|
||||
}
|
||||
|
||||
async resolveSelfMessages(): Promise<void> {
|
||||
while (this.selfMessageQueue.length > 0) {
|
||||
const queue = this.selfMessageQueue;
|
||||
this.selfMessageQueue = [];
|
||||
await queue.reduce(async(previous, msg) => {
|
||||
await previous;
|
||||
await this.handleMessage(msg, null);
|
||||
}, Promise.resolve());
|
||||
}
|
||||
}
|
||||
|
||||
async startup(firstTime: boolean = false): Promise<boolean|null> {
|
||||
if (!firstTime) {
|
||||
return null;
|
||||
}
|
||||
let skippedWaiting: boolean = false;
|
||||
if (this.eventHandlers.has('install')) {
|
||||
const installEvent = new MockInstallEvent();
|
||||
this.eventHandlers.get('install') !(installEvent);
|
||||
await installEvent.ready;
|
||||
skippedWaiting = this.skippedWaiting;
|
||||
}
|
||||
if (this.eventHandlers.has('activate')) {
|
||||
const activateEvent = new MockActivateEvent();
|
||||
this.eventHandlers.get('activate') !(activateEvent);
|
||||
await activateEvent.ready;
|
||||
}
|
||||
return skippedWaiting;
|
||||
}
|
||||
updateServerState(server?: MockServerState): void { this.server = server || EMPTY_SERVER_STATE; }
|
||||
|
||||
fetch(req: string|Request): Promise<Response> {
|
||||
if (typeof req === 'string') {
|
||||
return this.server.fetch(new MockRequest(req));
|
||||
} else {
|
||||
return this.server.fetch(req);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener(event: string, handler: Function): void {
|
||||
this.eventHandlers.set(event, handler);
|
||||
}
|
||||
|
||||
removeEventListener(event: string, handler?: Function): void { this.eventHandlers.delete(event); }
|
||||
|
||||
newRequest(url: string): Request { return new MockRequest(url); }
|
||||
|
||||
newResponse(body: string): Response { return new MockResponse(body); }
|
||||
|
||||
newHeaders(headers: {[name: string]: string}): Headers {
|
||||
return Object.keys(headers).reduce((mock, name) => {
|
||||
mock.set(name, headers[name]);
|
||||
return mock;
|
||||
}, new MockHeaders());
|
||||
}
|
||||
|
||||
getPath(url: string): string {
|
||||
if (typeof URL === 'function') {
|
||||
return new URL(url, 'http://localhost/').pathname;
|
||||
} else {
|
||||
return require('url').parse(url).pathname;
|
||||
}
|
||||
}
|
||||
|
||||
async skipWaiting(): Promise<void> { this.skippedWaiting = true; }
|
||||
|
||||
waitUntil(promise: Promise<void>): void {}
|
||||
|
||||
handleFetch(req: Request, clientId?: string): [Promise<Response|undefined>, Promise<void>] {
|
||||
if (!this.eventHandlers.has('fetch')) {
|
||||
throw new Error('No fetch handler registered');
|
||||
}
|
||||
const event = new MockFetchEvent(req, clientId || null);
|
||||
this.eventHandlers.get('fetch') !.call(this, event);
|
||||
|
||||
return [event.response, event.ready];
|
||||
}
|
||||
|
||||
handleMessage(data: Object, clientId: string|null): Promise<void> {
|
||||
if (!this.eventHandlers.has('message')) {
|
||||
throw new Error('No message handler registered');
|
||||
}
|
||||
let event: MockMessageEvent;
|
||||
if (clientId === null) {
|
||||
event = new MockMessageEvent(data, null);
|
||||
} else {
|
||||
this.clients.add(clientId);
|
||||
event = new MockMessageEvent(data, this.clients.getMock(clientId) as any);
|
||||
}
|
||||
this.eventHandlers.get('message') !.call(this, event);
|
||||
return event.ready;
|
||||
}
|
||||
|
||||
handlePush(data: Object): Promise<void> {
|
||||
if (!this.eventHandlers.has('push')) {
|
||||
throw new Error('No push handler registered');
|
||||
}
|
||||
const event = new MockPushEvent(data);
|
||||
this.eventHandlers.get('push') !.call(this, event);
|
||||
return event.ready;
|
||||
}
|
||||
|
||||
timeout(ms: number): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
this.timers.push({
|
||||
at: this.time + ms,
|
||||
duration: ms,
|
||||
fn: resolve,
|
||||
fired: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
advance(by: number): void {
|
||||
this.time += by;
|
||||
this.timers.filter(timer => !timer.fired)
|
||||
.filter(timer => timer.at <= this.time)
|
||||
.forEach(timer => {
|
||||
timer.fired = true;
|
||||
timer.fn();
|
||||
});
|
||||
}
|
||||
|
||||
isClient(obj: any): obj is Client { return obj instanceof MockClient; }
|
||||
}
|
||||
|
||||
interface StaticFile {
|
||||
url: string;
|
||||
contents: string;
|
||||
hash?: string;
|
||||
}
|
||||
|
||||
export class AssetGroupBuilder {
|
||||
constructor(private up: ConfigBuilder, readonly name: string) {}
|
||||
|
||||
private files: StaticFile[] = [];
|
||||
|
||||
addFile(url: string, contents: string, hashed: boolean = true): AssetGroupBuilder {
|
||||
const file: StaticFile = {url, contents, hash: undefined};
|
||||
if (hashed) {
|
||||
file.hash = sha1(contents);
|
||||
}
|
||||
this.files.push(file);
|
||||
return this;
|
||||
}
|
||||
|
||||
finish(): ConfigBuilder { return this.up; }
|
||||
|
||||
toManifestGroup(): AssetGroupConfig { return null !; }
|
||||
}
|
||||
|
||||
export class ConfigBuilder {
|
||||
assetGroups = new Map<string, AssetGroupBuilder>();
|
||||
|
||||
addAssetGroup(name: string): ConfigBuilder {
|
||||
const builder = new AssetGroupBuilder(this, name);
|
||||
this.assetGroups.set(name, builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
finish(): Manifest {
|
||||
const assetGroups = Array.from(this.assetGroups.values()).map(group => group.toManifestGroup());
|
||||
const hashTable = {};
|
||||
return {
|
||||
configVersion: 1,
|
||||
index: '/index.html', assetGroups, hashTable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class OneTimeContext implements Context {
|
||||
private queue: Promise<void>[] = [];
|
||||
|
||||
waitUntil(promise: Promise<void>): void { this.queue.push(promise); }
|
||||
|
||||
get ready(): Promise<void> {
|
||||
return (async() => {
|
||||
while (this.queue.length > 0) {
|
||||
await this.queue.shift();
|
||||
}
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
class MockExtendableEvent extends OneTimeContext {}
|
||||
|
||||
class MockFetchEvent extends MockExtendableEvent {
|
||||
response: Promise<Response|undefined> = Promise.resolve(undefined);
|
||||
|
||||
constructor(readonly request: Request, readonly clientId: string|null) { super(); }
|
||||
|
||||
respondWith(promise: Promise<Response>): Promise<Response> {
|
||||
this.response = promise;
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
|
||||
class MockMessageEvent extends MockExtendableEvent {
|
||||
constructor(readonly data: Object, readonly source: MockClient|null) { super(); }
|
||||
}
|
||||
|
||||
class MockPushEvent extends MockExtendableEvent {
|
||||
constructor(readonly data: Object) { super(); }
|
||||
}
|
||||
|
||||
class MockInstallEvent extends MockExtendableEvent {}
|
||||
|
||||
|
||||
class MockActivateEvent extends MockExtendableEvent {}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"declaration": false,
|
||||
"strict": true,
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"strictNullChecks": true,
|
||||
"outDir": "../../../dist/all/@angular/service-worker/worker-es2017",
|
||||
"noImplicitAny": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"rootDir": ".",
|
||||
"inlineSourceMap": true,
|
||||
"lib": ["es2015", "dom"],
|
||||
"target": "es2017",
|
||||
"typeRoots": []
|
||||
},
|
||||
"files": [
|
||||
"main.ts",
|
||||
"src/service-worker.d.ts"
|
||||
]
|
||||
}
|
|
@ -25,7 +25,8 @@ const entrypoints = [
|
|||
'dist/packages-dist/platform-server/testing.d.ts', 'dist/packages-dist/http/http.d.ts',
|
||||
'dist/packages-dist/http/testing.d.ts', 'dist/packages-dist/forms/forms.d.ts',
|
||||
'dist/packages-dist/router/router.d.ts', 'dist/packages-dist/animations/animations.d.ts',
|
||||
'dist/packages-dist/animations/browser.d.ts',
|
||||
'dist/packages-dist/service-worker/service-worker.d.ts',
|
||||
'dist/packages-dist/service-worker/config.d.ts', 'dist/packages-dist/animations/browser.d.ts',
|
||||
'dist/packages-dist/animations/browser/testing.d.ts',
|
||||
'dist/packages-dist/platform-browser/animations.d.ts'
|
||||
];
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/** @experimental */
|
||||
export interface AssetGroup {
|
||||
installMode?: 'prefetch' | 'lazy';
|
||||
name: string;
|
||||
resources: {
|
||||
files?: Glob[];
|
||||
versionedFiles?: Glob[];
|
||||
urls?: Glob[];
|
||||
};
|
||||
updateMode?: 'prefetch' | 'lazy';
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export interface Config {
|
||||
appData?: {};
|
||||
assetGroups?: AssetGroup[];
|
||||
dataGroups?: DataGroup[];
|
||||
index: string;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export interface DataGroup {
|
||||
cacheConfig: {
|
||||
maxSize: number;
|
||||
maxAge: Duration;
|
||||
timeout?: Duration;
|
||||
strategy?: 'freshness' | 'performance';
|
||||
};
|
||||
name: string;
|
||||
urls: Glob[];
|
||||
version?: number;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export declare type Duration = string;
|
||||
|
||||
/** @experimental */
|
||||
export interface Filesystem {
|
||||
list(dir: string): Promise<string[]>;
|
||||
read(file: string): Promise<string>;
|
||||
write(file: string, contents: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export declare class Generator {
|
||||
readonly fs: Filesystem;
|
||||
constructor(fs: Filesystem, baseHref: string);
|
||||
process(config: Config): Promise<Object>;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export declare type Glob = string;
|
|
@ -0,0 +1,24 @@
|
|||
/** @experimental */
|
||||
export declare class ServiceWorkerModule {
|
||||
static register(script: string, opts?: RegistrationOptions): ModuleWithProviders;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export declare class SwPush {
|
||||
readonly messages: Observable<object>;
|
||||
readonly subscription: Observable<PushSubscription | null>;
|
||||
constructor(sw: NgswCommChannel);
|
||||
requestSubscription(options: {
|
||||
serverPublicKey: string;
|
||||
}): Promise<PushSubscription>;
|
||||
unsubscribe(): Promise<void>;
|
||||
}
|
||||
|
||||
/** @experimental */
|
||||
export declare class SwUpdate {
|
||||
readonly activated: Observable<UpdateActivatedEvent>;
|
||||
readonly available: Observable<UpdateAvailableEvent>;
|
||||
constructor(sw: NgswCommChannel);
|
||||
activateUpdate(): Promise<void>;
|
||||
checkForUpdate(): Promise<void>;
|
||||
}
|
|
@ -29,8 +29,8 @@
|
|||
"platform-webworker",
|
||||
"platform-webworker-dynamic",
|
||||
"router",
|
||||
"service-worker",
|
||||
"upgrade",
|
||||
|
||||
"packaging",
|
||||
"changelog"
|
||||
]
|
||||
|
|
|
@ -49,19 +49,19 @@ describe('validate-commit-message.js', function() {
|
|||
expect(validateMessage('refactor(docs): something')).toBe(INVALID);
|
||||
['INVALID COMMIT MSG: "fix(Compiler): something"\n' +
|
||||
' => ERROR: "Compiler" is not an allowed scope.\n' +
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, upgrade, packaging, changelog',
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, service-worker, upgrade, packaging, changelog',
|
||||
'INVALID COMMIT MSG: "feat(bah): something"\n' +
|
||||
' => ERROR: "bah" is not an allowed scope.\n' +
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, upgrade, packaging, changelog',
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, service-worker, upgrade, packaging, changelog',
|
||||
'INVALID COMMIT MSG: "style(webworker): something"\n' +
|
||||
' => ERROR: "webworker" is not an allowed scope.\n' +
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, upgrade, packaging, changelog',
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, service-worker, upgrade, packaging, changelog',
|
||||
'INVALID COMMIT MSG: "refactor(security): something"\n' +
|
||||
' => ERROR: "security" is not an allowed scope.\n' +
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, upgrade, packaging, changelog',
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, service-worker, upgrade, packaging, changelog',
|
||||
'INVALID COMMIT MSG: "refactor(docs): something"\n' +
|
||||
' => ERROR: "docs" is not an allowed scope.\n' +
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, upgrade, packaging, changelog']
|
||||
' => SCOPES: aio, animations, bazel, benchpress, common, compiler, compiler-cli, core, forms, http, language-service, platform-browser, platform-browser-dynamic, platform-server, platform-webworker, platform-webworker-dynamic, router, service-worker, upgrade, packaging, changelog']
|
||||
.forEach((expectedErrorMessage, index) => {
|
||||
expect(expectedErrorMessage).toEqual(errors[index]);
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue