feat(service-worker): introduce the @angular/service-worker package (#19274)

This service worker is a conceptual derivative of the existing @angular/service-worker maintained at github.com/angular/mobile-toolkit, but has been rewritten to support use across a much wider variety of applications.

Entrypoints include:

@angular/service-worker: a library for use within Angular client apps to communicate with the service worker.
@angular/service-worker/gen: a library for generating ngsw.json files from glob-based SW config files.
@angular/service-worker/ngsw-worker.js: the bundled service worker script itself.
@angular/service-worker/ngsw-cli.js: a CLI tool for generating ngsw.json files from glob-based SW config files.
This commit is contained in:
Alex Rickabaugh
2017-09-28 16:18:12 -07:00
committed by Victor Berchet
parent 7c1d3e0f5a
commit d442b6855f
63 changed files with 6722 additions and 8 deletions

View File

@ -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);
}

View File

@ -0,0 +1,19 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* An abstraction over a virtual file system used to enable testing and operation
* of the config generator in different environments.
*
* @experimental
*/
export interface Filesystem {
list(dir: string): Promise<string[]>;
read(file: string): Promise<string>;
write(file: string, contents: string): Promise<void>;
}

View File

@ -0,0 +1,134 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {parseDurationToMs} from './duration';
import {Filesystem} from './filesystem';
import {globToRegex} from './glob';
import {Config} from './in';
import {sha1} from './sha1';
/**
* Consumes service worker configuration files and processes them into control files.
*
* @experimental
*/
export class Generator {
constructor(readonly fs: Filesystem, private baseHref: string) {}
async process(config: Config): Promise<Object> {
const hashTable = {};
return {
configVersion: 1,
index: joinUrls(this.baseHref, config.index),
appData: config.appData,
assetGroups: await this.processAssetGroups(config, hashTable),
dataGroups: this.processDataGroups(config), hashTable,
};
}
private async processAssetGroups(config: Config, hashTable: {[file: string]: string | undefined}):
Promise<Object[]> {
const seenMap = new Set<string>();
return Promise.all((config.assetGroups || []).map(async(group) => {
const fileMatcher = globListToMatcher(group.resources.files || []);
const versionedMatcher = globListToMatcher(group.resources.versionedFiles || []);
const allFiles = (await this.fs.list('/'));
const versionedFiles = allFiles.filter(versionedMatcher).filter(file => !seenMap.has(file));
versionedFiles.forEach(file => seenMap.add(file));
const plainFiles = allFiles.filter(fileMatcher).filter(file => !seenMap.has(file));
plainFiles.forEach(file => seenMap.add(file));
// Add the hashes.
await plainFiles.reduce(async(previous, file) => {
await previous;
const hash = sha1(await this.fs.read(file));
hashTable[joinUrls(this.baseHref, file)] = hash;
}, Promise.resolve());
// Figure out the patterns.
const patterns = (group.resources.urls || [])
.map(
glob => glob.startsWith('/') || glob.indexOf('://') !== -1 ?
glob :
joinUrls(this.baseHref, glob))
.map(glob => globToRegex(glob));
return {
name: group.name,
installMode: group.installMode || 'prefetch',
updateMode: group.updateMode || group.installMode || 'prefetch',
urls: ([] as string[])
.concat(plainFiles)
.concat(versionedFiles)
.map(url => joinUrls(this.baseHref, url)),
patterns,
};
}));
}
private processDataGroups(config: Config): Object[] {
return (config.dataGroups || []).map(group => {
const patterns = group.urls
.map(
glob => glob.startsWith('/') || glob.indexOf('://') !== -1 ?
glob :
joinUrls(this.baseHref, glob))
.map(glob => globToRegex(glob));
return {
name: group.name,
patterns,
strategy: group.cacheConfig.strategy || 'performance',
maxSize: group.cacheConfig.maxSize,
maxAge: parseDurationToMs(group.cacheConfig.maxAge),
timeoutMs: group.cacheConfig.timeout && parseDurationToMs(group.cacheConfig.timeout),
version: group.version !== undefined ? group.version : 1,
};
});
}
}
function globListToMatcher(globs: string[]): (file: string) => boolean {
const patterns = globs.map(pattern => {
if (pattern.startsWith('!')) {
return {
positive: false,
regex: new RegExp('^' + globToRegex(pattern.substr(1)) + '$'),
};
} else {
return {
positive: true,
regex: new RegExp('^' + globToRegex(pattern) + '$'),
};
}
});
return (file: string) => matches(file, patterns);
}
function matches(file: string, patterns: {positive: boolean, regex: RegExp}[]): boolean {
const res = patterns.reduce((isMatch, pattern) => {
if (pattern.positive) {
return isMatch || pattern.regex.test(file);
} else {
return isMatch && !pattern.regex.test(file);
}
}, false);
return res;
}
function joinUrls(a: string, b: string): string {
if (a.endsWith('/') && b.startsWith('/')) {
return a + b.substr(1);
} else if (!a.endsWith('/') && !b.startsWith('/')) {
return a + '/' + b;
}
return a + b;
}

View File

@ -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;
}

View File

@ -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';
};
}

View File

@ -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;
}