diff --git a/build.sh b/build.sh index 78843b2bae..654c861b96 100755 --- a/build.sh +++ b/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 diff --git a/packages/service-worker/build.sh b/packages/service-worker/build.sh new file mode 100755 index 0000000000..1ad458d288 --- /dev/null +++ b/packages/service-worker/build.sh @@ -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 \ No newline at end of file diff --git a/packages/service-worker/cli/filesystem.ts b/packages/service-worker/cli/filesystem.ts new file mode 100644 index 0000000000..eebca21884 --- /dev/null +++ b/packages/service-worker/cli/filesystem.ts @@ -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 { + 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 { + const file = this.canonical(_path); + return fs.readFileSync(file).toString(); + } + + async write(_path: string, contents: string): Promise { + const file = this.canonical(_path); + fs.writeFileSync(file, contents); + } + + private canonical(_path: string): string { return path.join(this.base, _path); } +} \ No newline at end of file diff --git a/packages/service-worker/cli/main.ts b/packages/service-worker/cli/main.ts new file mode 100644 index 0000000000..3f9df0251a --- /dev/null +++ b/packages/service-worker/cli/main.ts @@ -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)); +})(); \ No newline at end of file diff --git a/packages/service-worker/cli/rollup-cli.config.js b/packages/service-worker/cli/rollup-cli.config.js new file mode 100644 index 0000000000..66b8fae28f --- /dev/null +++ b/packages/service-worker/cli/rollup-cli.config.js @@ -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', + ], +}; diff --git a/packages/service-worker/cli/tsconfig.json b/packages/service-worker/cli/tsconfig.json new file mode 100644 index 0000000000..09c1527352 --- /dev/null +++ b/packages/service-worker/cli/tsconfig.json @@ -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" + ] +} diff --git a/packages/service-worker/config/index.ts b/packages/service-worker/config/index.ts new file mode 100644 index 0000000000..ca39d26dcd --- /dev/null +++ b/packages/service-worker/config/index.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'; \ No newline at end of file diff --git a/packages/service-worker/config/package.json b/packages/service-worker/config/package.json new file mode 100644 index 0000000000..7ffdac8fe3 --- /dev/null +++ b/packages/service-worker/config/package.json @@ -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" +} diff --git a/packages/service-worker/config/public_api.ts b/packages/service-worker/config/public_api.ts new file mode 100644 index 0000000000..f2f0a53395 --- /dev/null +++ b/packages/service-worker/config/public_api.ts @@ -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'; diff --git a/packages/service-worker/config/rollup.config.js b/packages/service-worker/config/rollup.config.js new file mode 100644 index 0000000000..877791e907 --- /dev/null +++ b/packages/service-worker/config/rollup.config.js @@ -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 +}; diff --git a/packages/service-worker/config/src/duration.ts b/packages/service-worker/config/src/duration.ts new file mode 100644 index 0000000000..a00ddaabc2 --- /dev/null +++ b/packages/service-worker/config/src/duration.ts @@ -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); +} diff --git a/packages/service-worker/config/src/filesystem.ts b/packages/service-worker/config/src/filesystem.ts new file mode 100644 index 0000000000..87ee90d39c --- /dev/null +++ b/packages/service-worker/config/src/filesystem.ts @@ -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; + read(file: string): Promise; + write(file: string, contents: string): Promise; +} \ No newline at end of file diff --git a/packages/service-worker/config/src/generator.ts b/packages/service-worker/config/src/generator.ts new file mode 100644 index 0000000000..6ce2be10d9 --- /dev/null +++ b/packages/service-worker/config/src/generator.ts @@ -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 { + 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 { + const seenMap = new Set(); + 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; +} \ No newline at end of file diff --git a/packages/service-worker/config/src/glob.ts b/packages/service-worker/config/src/glob.ts new file mode 100644 index 0000000000..512e6af93f --- /dev/null +++ b/packages/service-worker/config/src/glob.ts @@ -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; +} diff --git a/packages/service-worker/config/src/in.ts b/packages/service-worker/config/src/in.ts new file mode 100644 index 0000000000..4dbe931050 --- /dev/null +++ b/packages/service-worker/config/src/in.ts @@ -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'; + }; +} \ No newline at end of file diff --git a/packages/service-worker/config/src/sha1.ts b/packages/service-worker/config/src/sha1.ts new file mode 100644 index 0000000000..48a2f90cf6 --- /dev/null +++ b/packages/service-worker/config/src/sha1.ts @@ -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; +} diff --git a/packages/service-worker/config/test/generator_spec.ts b/packages/service-worker/config/test/generator_spec.ts new file mode 100644 index 0000000000..7c590ed9be --- /dev/null +++ b/packages/service-worker/config/test/generator_spec.ts @@ -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)); + }); + }); +} diff --git a/packages/service-worker/config/testing/mock.ts b/packages/service-worker/config/testing/mock.ts new file mode 100644 index 0000000000..1718ac05df --- /dev/null +++ b/packages/service-worker/config/testing/mock.ts @@ -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(); + + constructor(files: {[name: string]: string | undefined}) { + Object.keys(files).forEach(path => this.files.set(path, files[path] !)); + } + + async list(dir: string): Promise { + return Array.from(this.files.keys()).filter(path => path.startsWith(dir)); + } + + async read(path: string): Promise { return this.files.get(path) !; } + + async write(path: string, contents: string): Promise { this.files.set(path, contents); } +} \ No newline at end of file diff --git a/packages/service-worker/config/tsconfig-build.json b/packages/service-worker/config/tsconfig-build.json new file mode 100644 index 0000000000..e5486f097b --- /dev/null +++ b/packages/service-worker/config/tsconfig-build.json @@ -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" + } +} diff --git a/packages/service-worker/package.json b/packages/service-worker/package.json new file mode 100644 index 0000000000..e48a8f03f2 --- /dev/null +++ b/packages/service-worker/package.json @@ -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" + } +} diff --git a/packages/service-worker/public_api.ts b/packages/service-worker/public_api.ts new file mode 100644 index 0000000000..778427176d --- /dev/null +++ b/packages/service-worker/public_api.ts @@ -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. diff --git a/packages/service-worker/rollup.config.js b/packages/service-worker/rollup.config.js new file mode 100644 index 0000000000..78f04f207a --- /dev/null +++ b/packages/service-worker/rollup.config.js @@ -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 +}; diff --git a/packages/service-worker/src/index.ts b/packages/service-worker/src/index.ts new file mode 100644 index 0000000000..bd89ce2ff2 --- /dev/null +++ b/packages/service-worker/src/index.ts @@ -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'; \ No newline at end of file diff --git a/packages/service-worker/src/low_level.ts b/packages/service-worker/src/low_level.ts new file mode 100644 index 0000000000..48a6c45316 --- /dev/null +++ b/packages/service-worker/src/low_level.ts @@ -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 { + return obs_defer(() => obs_throw(new Error(message))); +} + +/** + * @experimental +*/ +export class NgswCommChannel { + /** + * @internal + */ + readonly worker: Observable; + + /** + * @internal + */ + readonly registration: Observable; + + /** + * @internal + */ + readonly events: Observable; + + constructor(serviceWorker: ServiceWorkerContainer|undefined) { + if (!serviceWorker) { + this.worker = this.events = errorObservable(ERR_SW_NOT_SUPPORTED); + } else { + const controllerChangeEvents = + >(obs_fromEvent(serviceWorker, 'controllerchange')); + const controllerChanges = >( + op_map.call(controllerChangeEvents, () => serviceWorker.controller)); + + const currentController = + >(obs_defer(() => obs_of(serviceWorker.controller))); + + const controllerWithChanges = + >(obs_concat(currentController, controllerChanges)); + this.worker = >( + op_filter.call(controllerWithChanges, (c: ServiceWorker) => !!c)); + + this.registration = >( + op_switchMap.call(this.worker, () => serviceWorker.getRegistration())); + + const rawEvents = >(op_switchMap.call( + this.registration, (reg: ServiceWorkerRegistration) => obs_fromEvent(reg, 'message'))); + + const rawEventPayload = + >(op_map.call(rawEvents, (event: MessageEvent) => event.data)); + const eventsUnconnected = >( + op_filter.call(rawEventPayload, (event: Object) => !!event && !!(event as any)['type'])); + const events = >(op_publish.call(eventsUnconnected)); + this.events = events; + events.connect(); + } + } + + /** + * @internal + */ + postMessage(action: string, payload: Object): Promise { + const worker = op_take.call(this.worker, 1); + const sideEffect = op_do.call(worker, (sw: ServiceWorker) => { + sw.postMessage({ + action, ...payload, + }); + }); + return >(op_toPromise.call(sideEffect).then(() => undefined)); + } + + /** + * @internal + */ + postMessageWithStatus(type: string, payload: Object, nonce: number): Promise { + 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(type: string): Observable { + return >( + op_filter.call(this.events, (event: T & TypedEvent) => { return event.type === type; })); + } + + /** + * @internal + */ + nextEventOfType(type: string): Observable { + return >(op_take.call(this.eventsOfType(type), 1)); + } + + /** + * @internal + */ + waitForStatus(nonce: number): Promise { + const statusEventsWithNonce = >( + op_filter.call(this.eventsOfType('STATUS'), (event: StatusEvent) => event.nonce === nonce)); + const singleStatusEvent = >(op_take.call(statusEventsWithNonce, 1)); + const mapErrorAndValue = + >(op_map.call(singleStatusEvent, (event: StatusEvent) => { + if (event.status) { + return undefined; + } + throw new Error(event.error !); + })); + return op_toPromise.call(mapErrorAndValue); + } +} \ No newline at end of file diff --git a/packages/service-worker/src/module.ts b/packages/service-worker/src/module.ts new file mode 100644 index 0000000000..b9ca82328e --- /dev/null +++ b/packages/service-worker/src/module.ts @@ -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('NGSW_REGISTER_SCRIPT'); +export const OPTS = new InjectionToken('NGSW_REGISTER_OPTIONS'); + +export function ngswAppInitializer( + injector: Injector, script: string, options: RegistrationOptions): Function { + const initializer = () => { + const app = injector.get(ApplicationRef); + if (!('serviceWorker' in navigator)) { + return; + } + const onStable = + op_filter.call(app.isStable, (stable: boolean) => !!stable) as Observable; + const isStable = op_take.call(onStable, 1) as Observable; + const whenStable = op_toPromise.call(isStable) as Promise; + return whenStable.then(() => navigator.serviceWorker.register(script, options)) + .then(() => undefined) as Promise; + }; + 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, + }, + ], + }; + } +} \ No newline at end of file diff --git a/packages/service-worker/src/push.ts b/packages/service-worker/src/push.ts new file mode 100644 index 0000000000..25309d9379 --- /dev/null +++ b/packages/service-worker/src/push.ts @@ -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; + readonly subscription: Observable; + + private pushManager: Observable; + private subscriptionChanges: Subject = + new Subject(); + + constructor(private sw: NgswCommChannel) { + this.messages = + op_map.call(this.sw.eventsOfType('PUSH'), (message: {data: object}) => message.data); + + this.pushManager = >(op_map.call( + this.sw.registration, + (registration: ServiceWorkerRegistration) => { return registration.pushManager; })); + + const workerDrivenSubscriptions = >(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 { + 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 = >( + op_switchMap.call(this.pushManager, (pm: PushManager) => pm.subscribe(pushOptions))); + const subscribeOnce = op_take.call(subscribe, 1); + return (op_toPromise.call(subscribeOnce) as Promise).then(sub => { + this.subscriptionChanges.next(sub); + return sub; + }); + } + + unsubscribe(): Promise { + 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; + } +} \ No newline at end of file diff --git a/packages/service-worker/src/update.ts b/packages/service-worker/src/update.ts new file mode 100644 index 0000000000..349f526fe8 --- /dev/null +++ b/packages/service-worker/src/update.ts @@ -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; + readonly activated: Observable; + + constructor(private sw: NgswCommChannel) { + this.available = this.sw.eventsOfType('UPDATE_AVAILABLE'); + this.activated = this.sw.eventsOfType('UPDATE_ACTIVATED'); + } + + checkForUpdate(): Promise { + const statusNonce = this.sw.generateNonce(); + return this.sw.postMessageWithStatus('CHECK_FOR_UPDATES', {statusNonce}, statusNonce); + } + + activateUpdate(): Promise { + const statusNonce = this.sw.generateNonce(); + return this.sw.postMessageWithStatus('ACTIVATE_UPDATE', {statusNonce}, statusNonce); + } +} diff --git a/packages/service-worker/test/async.ts b/packages/service-worker/test/async.ts new file mode 100644 index 0000000000..46de959258 --- /dev/null +++ b/packages/service-worker/test/async.ts @@ -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): (done: DoneFn) => void { + return (done: DoneFn) => { fn().then(() => done()).catch(err => done.fail(err)); }; +} + +export function async_beforeAll(fn: () => Promise): void { + beforeAll(wrap(fn)); +} + +export function async_beforeEach(fn: () => Promise): void { + beforeEach(wrap(fn)); +} + +export function async_it(desc: string, fn: () => Promise): void { + it(desc, wrap(fn)); +} + +export function async_fit(desc: string, fn: () => Promise): void { + // tslint:disable-next-line:no-jasmine-focus + fit(desc, wrap(fn)); +} diff --git a/packages/service-worker/test/comm_spec.ts b/packages/service-worker/test/comm_spec.ts new file mode 100644 index 0000000000..e5abea51d6 --- /dev/null +++ b/packages/service-worker/test/comm_spec.ts @@ -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)); + }); + }); + }); +} diff --git a/packages/service-worker/test/integration_spec.ts b/packages/service-worker/test/integration_spec.ts new file mode 100644 index 0000000000..1b3686a32b --- /dev/null +++ b/packages/service-worker/test/integration_spec.ts @@ -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(obs: Observable): Promise { + 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; + }); + }); +} diff --git a/packages/service-worker/testing/mock.ts b/packages/service-worker/testing/mock.ts new file mode 100644 index 0000000000..42dccc2da4 --- /dev/null +++ b/packages/service-worker/testing/mock.ts @@ -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 { return; } + + async getRegistration(): Promise { 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 { + 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, + })); + } +} \ No newline at end of file diff --git a/packages/service-worker/tsconfig-build.json b/packages/service-worker/tsconfig-build.json new file mode 100644 index 0000000000..ef3561e578 --- /dev/null +++ b/packages/service-worker/tsconfig-build.json @@ -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" + } +} diff --git a/packages/service-worker/worker/main.ts b/packages/service-worker/worker/main.ts new file mode 100644 index 0000000000..11f86ccb5e --- /dev/null +++ b/packages/service-worker/worker/main.ts @@ -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)); diff --git a/packages/service-worker/worker/rollup-worker.config.js b/packages/service-worker/worker/rollup-worker.config.js new file mode 100644 index 0000000000..bf8be460f9 --- /dev/null +++ b/packages/service-worker/worker/rollup-worker.config.js @@ -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', +}; diff --git a/packages/service-worker/worker/src/adapter.ts b/packages/service-worker/worker/src/adapter.ts new file mode 100644 index 0000000000..f873fbe744 --- /dev/null +++ b/packages/service-worker/worker/src/adapter.ts @@ -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 { + return new Promise(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): void; +} diff --git a/packages/service-worker/worker/src/api.ts b/packages/service-worker/worker/src/api.ts new file mode 100644 index 0000000000..ff8ba7b7be --- /dev/null +++ b/packages/service-worker/worker/src/api.ts @@ -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; + + /** + * 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; + + /** + * 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; + + /** + * 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; +} + +/** + * 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; + debugVersions(): Promise; + debugIdleState(): Promise; +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/app-version.ts b/packages/service-worker/worker/src/app-version.ts new file mode 100644 index 0000000000..d587ff9dcf --- /dev/null +++ b/packages/service-worker/worker/src/app-version.ts @@ -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(); + + /** + * 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 { + 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>(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 { + // 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 { + 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 { + // 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(null)); + } + + /** + * List all unhashed resources from all asset groups. + */ + previouslyCachedResources(): Promise { + return this.assetGroups.reduce(async(resources, group) => { + return (await resources).concat(await group.unhashedResources()); + }, Promise.resolve([])); + } + + async recentCacheStatus(url: string): Promise { + 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 { + 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; } +} diff --git a/packages/service-worker/worker/src/assets.ts b/packages/service-worker/worker/src/assets.ts new file mode 100644 index 0000000000..4955cf2c02 --- /dev/null +++ b/packages/service-worker/worker/src/assets.ts @@ -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>(); + + /** + * 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; + + /** + * Group name from the configuration. + */ + readonly name: string; + + /** + * Metadata associated with specific cache entries. + */ + protected metadata: Promise; + + constructor( + protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter, + protected idle: IdleScheduler, protected config: AssetGroupConfig, + protected hashes: Map, 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 { + 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(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; + + /** + * Clean up all the cached data for this group. + */ + async cleanup(): Promise { + 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 { + // 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 { + // 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(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 { + 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(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 { + 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 { + // 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 { + // 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 { + 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 { + // 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, 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 { + // 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, 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()); + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/data.ts b/packages/service-worker/worker/src/data.ts new file mode 100644 index 0000000000..af6834892d --- /dev/null +++ b/packages/service-worker/worker/src/data.ts @@ -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; + + /** + * 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
; + + /** + * Database table used to store metadata for resources in the cache. + */ + private readonly ageTable: Promise
; + + 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 { + if (this._lru === null) { + const table = await this.lruTable; + try { + this._lru = new LruList(await table.read('lru')); + } catch (e) { + this._lru = new LruList(); + } + } + return this._lru; + } + + /** + * Sync the LRU chain to non-volatile storage. + */ + async syncLru(): Promise { + 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 { + // 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 { + 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 { + // 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, Promise] { + 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 for the timeout. + const timeout = this.adapter.timeout(this.config.timeoutMs) as Promise; + // 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): Promise { + 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(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 { + // 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 { + // 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 { + 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), + ]); + } +} diff --git a/packages/service-worker/worker/src/database.ts b/packages/service-worker/worker/src/database.ts new file mode 100644 index 0000000000..f45f8fc1a4 --- /dev/null +++ b/packages/service-worker/worker/src/database.ts @@ -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; + + /** + * List all the keys currently stored in the table. + */ + keys(): Promise; + + /** + * Read a key from a table, either as an Object or with a given type. + */ + read(key: string): Promise; + read(key: string): Promise; + + /** + * Write a new value for a key to the table, overwriting any previous value. + */ + write(key: string, value: Object): Promise; +} + +/** + * 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; + + /** + * List all `Table`s by name. + */ + list(): Promise; + + /** + * Open a `Table`. + */ + open(table: string): Promise
; +} + +/** + * 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) {} +} diff --git a/packages/service-worker/worker/src/db-cache.ts b/packages/service-worker/worker/src/db-cache.ts new file mode 100644 index 0000000000..2da7c79fa7 --- /dev/null +++ b/packages/service-worker/worker/src/db-cache.ts @@ -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>(); + + constructor(private scope: ServiceWorkerGlobalScope, private adapter: Adapter) {} + + 'delete'(name: string): Promise { + if (this.tables.has(name)) { + this.tables.delete(name); + } + return this.scope.caches.delete(`ngsw:db:${name}`); + } + + list(): Promise { + return this.scope.caches.keys().then(keys => keys.filter(key => key.startsWith('ngsw:db:'))); + } + + open(name: string): Promise
{ + 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 { return this.cache.delete(this.request(key)); } + + keys(): Promise { + return this.cache.keys().then(keys => keys.map(key => key.substr(1))); + } + + read(key: string): Promise { + 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 { + return this.cache.put(this.request(key), this.adapter.newResponse(JSON.stringify(value))); + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/debug.ts b/packages/service-worker/worker/src/debug.ts new file mode 100644 index 0000000000..f4bcfe712c --- /dev/null +++ b/packages/service-worker/worker/src/debug.ts @@ -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 { + 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` : ''); + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/driver.ts b/packages/service-worker/worker/src/driver.ts new file mode 100644 index 0000000000..7b910dc19f --- /dev/null +++ b/packages/service-worker/worker/src/driver.ts @@ -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|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(); + + /** + * Maps manifest hashes to instances of `AppVersion` for those manifests. + */ + private versions = new Map(); + + /** + * 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 { + 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 { + 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, nonce: number): Promise { + 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 { + // 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 { + // 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 { + // 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('manifests'), + table.read('assignments'), + table.read('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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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(); + 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 { + 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(null)); + } + + async lookupResourceWithoutHash(url: string): Promise { + await this.initialized; + const version = this.versions.get(this.latestHash !) !; + return version.lookupResourceWithoutHash(url); + } + + async previouslyCachedResources(): Promise { + await this.initialized; + const version = this.versions.get(this.latestHash !) !; + return version.previouslyCachedResources(); + } + + recentCacheStatus(url: string): Promise { + 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 { + 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 { + const clients = await this.scope.clients.matchAll(); + clients.forEach(client => { client.postMessage(msg); }); + } + + async debugState(): Promise { + return { + state: DriverReadyState[this.state], + why: this.stateMessage, + latestHash: this.latestHash, + lastUpdateCheck: this.lastUpdateCheck, + }; + } + + async debugVersions(): Promise { + // 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 { + 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}`; + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/idle.ts b/packages/service-worker/worker/src/idle.ts new file mode 100644 index 0000000000..ec599e5463 --- /dev/null +++ b/packages/service-worker/worker/src/idle.ts @@ -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; + desc: string; +} + +interface ScheduledRun { + cancel: boolean; +} + +export class IdleScheduler { + private queue: IdleTask[] = []; + private scheduled: ScheduledRun|null = null; + empty: Promise = 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 { + 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 { + 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 { + 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); } +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/manifest.ts b/packages/service-worker/worker/src/manifest.ts new file mode 100644 index 0000000000..f65a893073 --- /dev/null +++ b/packages/service-worker/worker/src/manifest.ts @@ -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)); +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/msg.ts b/packages/service-worker/worker/src/msg.ts new file mode 100644 index 0000000000..df539386ad --- /dev/null +++ b/packages/service-worker/worker/src/msg.ts @@ -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'; +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/service-worker.d.ts b/packages/service-worker/worker/src/service-worker.d.ts new file mode 100644 index 0000000000..06a9969af8 --- /dev/null +++ b/packages/service-worker/worker/src/service-worker.d.ts @@ -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): void; +} + +// CacheStorage API + +interface Cache { + add(request: Request): Promise; + addAll(requestArray: Array): Promise; + 'delete'(request: Request, options?: CacheStorageOptions): Promise; + keys(request?: Request, options?: CacheStorageOptions): Promise>; + match(request: Request, options?: CacheStorageOptions): Promise; + matchAll(request: Request, options?: CacheStorageOptions): Promise>; + put(request: Request|string, response: Response): Promise; +} + +interface CacheStorage { + 'delete'(cacheName: string): Promise; + has(cacheName: string): Promise; + keys(): Promise>; + match(request: Request, options?: CacheStorageOptions): Promise; + open(cacheName: string): Promise; +} + +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; + get(id: string): Promise; + matchAll(options?: ClientMatchOptions): Promise>; +} + +interface ClientMatchOptions { + includeUncontrolled?: boolean; + type?: ClientMatchTypes; +} + +interface WindowClient { + focused: boolean; + visibilityState: WindowClientState; + focus(): Promise; + navigate(url: string): Promise; +} + +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): Promise; +} + +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; + skipWaiting(): Promise; +} \ No newline at end of file diff --git a/packages/service-worker/worker/src/sha1.ts b/packages/service-worker/worker/src/sha1.ts new file mode 100644 index 0000000000..48a2f90cf6 --- /dev/null +++ b/packages/service-worker/worker/src/sha1.ts @@ -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; +} diff --git a/packages/service-worker/worker/src/util.ts b/packages/service-worker/worker/src/util.ts new file mode 100644 index 0000000000..3fa5f0fa68 --- /dev/null +++ b/packages/service-worker/worker/src/util.ts @@ -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'); +} diff --git a/packages/service-worker/worker/test/async.ts b/packages/service-worker/worker/test/async.ts new file mode 100644 index 0000000000..46de959258 --- /dev/null +++ b/packages/service-worker/worker/test/async.ts @@ -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): (done: DoneFn) => void { + return (done: DoneFn) => { fn().then(() => done()).catch(err => done.fail(err)); }; +} + +export function async_beforeAll(fn: () => Promise): void { + beforeAll(wrap(fn)); +} + +export function async_beforeEach(fn: () => Promise): void { + beforeEach(wrap(fn)); +} + +export function async_it(desc: string, fn: () => Promise): void { + it(desc, wrap(fn)); +} + +export function async_fit(desc: string, fn: () => Promise): void { + // tslint:disable-next-line:no-jasmine-focus + fit(desc, wrap(fn)); +} diff --git a/packages/service-worker/worker/test/data_spec.ts b/packages/service-worker/worker/test/data_spec.ts new file mode 100644 index 0000000000..469340180d --- /dev/null +++ b/packages/service-worker/worker/test/data_spec.ts @@ -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): (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 { + 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, Promise] { + 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 + ]; + } \ No newline at end of file diff --git a/packages/service-worker/worker/test/happy_spec.ts b/packages/service-worker/worker/test/happy_spec.ts new file mode 100644 index 0000000000..4227cc2db4 --- /dev/null +++ b/packages/service-worker/worker/test/happy_spec.ts @@ -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 { + 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; +} \ No newline at end of file diff --git a/packages/service-worker/worker/test/prefetch_spec.ts b/packages/service-worker/worker/test/prefetch_spec.ts new file mode 100644 index 0000000000..668c54b37a --- /dev/null +++ b/packages/service-worker/worker/test/prefetch_spec.ts @@ -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): Promise { + return promise.catch(err => err); +} diff --git a/packages/service-worker/worker/testing/cache.ts b/packages/service-worker/worker/testing/cache.ts new file mode 100644 index 0000000000..88825fe87a --- /dev/null +++ b/packages/service-worker/worker/testing/cache.ts @@ -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(); + + 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 { return this.caches.has(name); } + + async keys(): Promise { return Array.from(this.caches.keys()); } + + async open(name: string): Promise { + if (!this.caches.has(name)) { + this.caches.set(name, new MockCache()); + } + return this.caches.get(name) !; + } + + async match(req: Request): Promise { + return await Array.from(this.caches.values()) + .reduce>(async(answer, cache): Promise => { + const curr = await answer; + if (curr !== undefined) { + return curr; + } + + return cache.match(req); + }, Promise.resolve(undefined)); + } + + async 'delete'(name: string): Promise { + 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(); + + 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 { throw 'Not implemented'; } + + async addAll(requests: RequestInfo[]): Promise { throw 'Not implemented'; } + + async 'delete'(request: RequestInfo): Promise { + 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 { + if (match !== undefined) { + throw 'Not implemented'; + } + return Array.from(this.cache.keys()); + } + + async match(request: RequestInfo, options?: CacheQueryOptions): Promise { + 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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/testing/fetch.ts b/packages/service-worker/worker/testing/fetch.ts new file mode 100644 index 0000000000..b8ece304f1 --- /dev/null +++ b/packages/service-worker/worker/testing/fetch.ts @@ -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 { throw 'Not implemented'; } + + async blob(): Promise { throw 'Not implemented'; } + + async json(): Promise { + this.bodyUsed = true; + if (this._body !== null) { + return JSON.parse(this._body); + } else { + throw new Error('No body'); + } + } + + async text(): Promise { + this.bodyUsed = true; + if (this._body !== null) { + return this._body; + } else { + throw new Error('No body'); + } + } + + async formData(): Promise { throw 'Not implemented'; } +} + +export class MockHeaders implements Headers { + map = new Map(); + 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}); + } +} \ No newline at end of file diff --git a/packages/service-worker/worker/testing/mock.ts b/packages/service-worker/worker/testing/mock.ts new file mode 100644 index 0000000000..35bb4dff56 --- /dev/null +++ b/packages/service-worker/worker/testing/mock.ts @@ -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(); + + 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) {} + + 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(); + + 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 = Promise.resolve(); + private resolve: Function|null = null; + private resolveNextRequest: Function; + nextRequest: Promise; + + constructor(private resources: Map) { + this.nextRequest = new Promise(resolve => { this.resolveNextRequest = resolve; }); + } + + async fetch(req: Request): Promise { + 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 { + const map = new Map(); + Object.keys(manifest.hashTable).forEach(url => { + const hash = manifest.hashTable[url]; + map.set(url, hash); + }); + return map; +} \ No newline at end of file diff --git a/packages/service-worker/worker/testing/scope.ts b/packages/service-worker/worker/testing/scope.ts new file mode 100644 index 0000000000..8db0a89328 --- /dev/null +++ b/packages/service-worker/worker/testing/scope.ts @@ -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(); + + 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(); + + 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 { + 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 { + return Array.from(this.clients.values()) as any[] as Client[]; + } + + async claim(): Promise {} +} + +export class SwTestHarness implements ServiceWorkerGlobalScope, Adapter, Context { + readonly clients = new MockClients(); + private eventHandlers = new Map(); + 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 { + 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 { + 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 { + 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 { this.skippedWaiting = true; } + + waitUntil(promise: Promise): void {} + + handleFetch(req: Request, clientId?: string): [Promise, Promise] { + 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 { + 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 { + 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 { + 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(); + + 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[] = []; + + waitUntil(promise: Promise): void { this.queue.push(promise); } + + get ready(): Promise { + return (async() => { + while (this.queue.length > 0) { + await this.queue.shift(); + } + })(); + } +} + +class MockExtendableEvent extends OneTimeContext {} + +class MockFetchEvent extends MockExtendableEvent { + response: Promise = Promise.resolve(undefined); + + constructor(readonly request: Request, readonly clientId: string|null) { super(); } + + respondWith(promise: Promise): Promise { + 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 {} diff --git a/packages/service-worker/worker/tsconfig.json b/packages/service-worker/worker/tsconfig.json new file mode 100644 index 0000000000..148211f5b8 --- /dev/null +++ b/packages/service-worker/worker/tsconfig.json @@ -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" + ] +} diff --git a/tools/gulp-tasks/public-api.js b/tools/gulp-tasks/public-api.js index 6a7e394071..59d7e14d31 100644 --- a/tools/gulp-tasks/public-api.js +++ b/tools/gulp-tasks/public-api.js @@ -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' ]; diff --git a/tools/public_api_guard/service-worker/config.d.ts b/tools/public_api_guard/service-worker/config.d.ts new file mode 100644 index 0000000000..6462ee2f23 --- /dev/null +++ b/tools/public_api_guard/service-worker/config.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; + read(file: string): Promise; + write(file: string, contents: string): Promise; +} + +/** @experimental */ +export declare class Generator { + readonly fs: Filesystem; + constructor(fs: Filesystem, baseHref: string); + process(config: Config): Promise; +} + +/** @experimental */ +export declare type Glob = string; diff --git a/tools/public_api_guard/service-worker/service-worker.d.ts b/tools/public_api_guard/service-worker/service-worker.d.ts new file mode 100644 index 0000000000..d3efcb131b --- /dev/null +++ b/tools/public_api_guard/service-worker/service-worker.d.ts @@ -0,0 +1,24 @@ +/** @experimental */ +export declare class ServiceWorkerModule { + static register(script: string, opts?: RegistrationOptions): ModuleWithProviders; +} + +/** @experimental */ +export declare class SwPush { + readonly messages: Observable; + readonly subscription: Observable; + constructor(sw: NgswCommChannel); + requestSubscription(options: { + serverPublicKey: string; + }): Promise; + unsubscribe(): Promise; +} + +/** @experimental */ +export declare class SwUpdate { + readonly activated: Observable; + readonly available: Observable; + constructor(sw: NgswCommChannel); + activateUpdate(): Promise; + checkForUpdate(): Promise; +} diff --git a/tools/validate-commit-message/commit-message.json b/tools/validate-commit-message/commit-message.json index 69002d15d1..6e0b33b623 100644 --- a/tools/validate-commit-message/commit-message.json +++ b/tools/validate-commit-message/commit-message.json @@ -29,8 +29,8 @@ "platform-webworker", "platform-webworker-dynamic", "router", + "service-worker", "upgrade", - "packaging", "changelog" ] diff --git a/tools/validate-commit-message/validate-commit-message.spec.js b/tools/validate-commit-message/validate-commit-message.spec.js index 95458b7c32..ee948e48c9 100644 --- a/tools/validate-commit-message/validate-commit-message.spec.js +++ b/tools/validate-commit-message/validate-commit-message.spec.js @@ -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]); });