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,68 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* Adapts the service worker to its runtime environment.
*
* Mostly, this is used to mock out identifiers which are otherwise read
* from the global scope.
*/
export class Adapter {
/**
* Wrapper around the `Request` constructor.
*/
newRequest(input: string|Request, init?: RequestInit): Request {
return new Request(input, init);
}
/**
* Wrapper around the `Response` constructor.
*/
newResponse(body: any, init?: ResponseInit) { return new Response(body, init); }
/**
* Wrapper around the `Headers` constructor.
*/
newHeaders(headers: {[name: string]: string}): Headers { return new Headers(headers); }
/**
* Test if a given object is an instance of `Client`.
*/
isClient(source: any): source is Client { return (source instanceof Client); }
/**
* Read the current UNIX time in milliseconds.
*/
get time(): number { return Date.now(); }
/**
* Extract the pathname of a URL.
*/
getPath(url: string): string {
const parsed = new URL(url);
return parsed.pathname;
}
/**
* Wait for a given amount of time before completing a Promise.
*/
timeout(ms: number): Promise<void> {
return new Promise<void>(resolve => { setTimeout(() => resolve(), ms); });
}
}
/**
* An event context in which an operation is taking place, which allows
* the delaying of Service Worker shutdown until certain triggers occur.
*/
export interface Context {
/**
* Delay shutdown of the Service Worker until the given promise resolves.
*/
waitUntil(fn: Promise<any>): void;
}

View File

@ -0,0 +1,109 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
export enum UpdateCacheStatus {
NOT_CACHED,
CACHED_BUT_UNUSED,
CACHED,
}
/**
* A source for old versions of URL contents and other resources.
*
* Used to abstract away the fetching of old contents, to avoid a
* circular dependency between the `Driver` and `AppVersion`. Without
* this interface, `AppVersion` would need a reference to the `Driver`
* to access information from other versions.
*/
export interface UpdateSource {
/**
* Lookup an older version of a resource for which the hash is known.
*
* If an old version of the resource doesn't exist, or exists but does
* not match the hash given, this returns null.
*/
lookupResourceWithHash(url: string, hash: string): Promise<Response|null>;
/**
* Lookup an older version of a resource for which the hash is not known.
*
* This will return the most recent previous version of the resource, if
* it exists. It returns a `CacheState` object which encodes not only the
* `Response`, but the cache metadata needed to re-cache the resource in
* a newer `AppVersion`.
*/
lookupResourceWithoutHash(url: string): Promise<CacheState|null>;
/**
* List the URLs of all of the resources which were previously cached.
*
* This allows for the discovery of resources which are not listed in the
* manifest but which were picked up because they matched URL patterns.
*/
previouslyCachedResources(): Promise<string[]>;
/**
* Check whether a particular resource exists in the most recent cache.
*
* This returns a state value which indicates whether the resource was
* cached at all and whether that cache was utilized.
*/
recentCacheStatus(url: string): Promise<UpdateCacheStatus>;
}
/**
* Metadata cached along with a URL.
*/
export interface UrlMetadata {
/**
* The timestamp, in UNIX time in milliseconds, of when this URL was stored
* in the cache.
*/
ts: number;
/**
* Whether the resource was requested before for this particular cached
* instance.
*/
used: boolean;
}
/**
* The fully cached state of a resource, including both the `Response` itself
* and the cache metadata.
*/
export interface CacheState {
response: Response;
metadata?: UrlMetadata;
}
export interface DebugState {
state: string;
why: string;
latestHash: string|null;
lastUpdateCheck: number|null;
}
export interface DebugVersion {
hash: string;
manifest: Object;
clients: string[];
status: string;
}
export interface DebugIdleState {
queue: string[];
lastTrigger: number|null;
lastRun: number|null;
}
export interface Debuggable {
debugState(): Promise<DebugState>;
debugVersions(): Promise<DebugVersion[]>;
debugIdleState(): Promise<DebugIdleState>;
}

View File

@ -0,0 +1,241 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter, Context} from './adapter';
import {CacheState, UpdateCacheStatus, UpdateSource} from './api';
import {AssetGroup, LazyAssetGroup, PrefetchAssetGroup} from './assets';
import {DataGroup} from './data';
import {Database} from './database';
import {IdleScheduler} from './idle';
import {Manifest} from './manifest';
import {isNavigationRequest} from './util';
/**
* A specific version of the application, identified by a unique manifest
* as determined by its hash.
*
* Each `AppVersion` can be thought of as a published version of the app
* that can be installed as an update to any previously installed versions.
*/
export class AppVersion implements UpdateSource {
/**
* A Map of absolute URL paths (/foo.txt) to the known hash of their
* contents (if available).
*/
private hashTable = new Map<string, string>();
/**
* All of the asset groups active in this version of the app.
*/
private assetGroups: AssetGroup[];
/**
* All of the data groups active in this version of the app.
*/
private dataGroups: DataGroup[];
/**
* Tracks whether the manifest has encountered any inconsistencies.
*/
private _okay = true;
get okay(): boolean { return this._okay; }
constructor(
private scope: ServiceWorkerGlobalScope, private adapter: Adapter, private database: Database,
private idle: IdleScheduler, readonly manifest: Manifest, readonly manifestHash: string) {
// The hashTable within the manifest is an Object - convert it to a Map for easier lookups.
Object.keys(this.manifest.hashTable).forEach(url => {
this.hashTable.set(url, this.manifest.hashTable[url]);
});
// Process each `AssetGroup` declared in the manifest. Each declared group gets an `AssetGroup`
// instance
// created for it, of a type that depends on the configuration mode.
this.assetGroups = (manifest.assetGroups || []).map(config => {
// Every asset group has a cache that's prefixed by the manifest hash and the name of the
// group.
const prefix = `ngsw:${this.manifestHash}:assets`;
// Check the caching mode, which determines when resources will be fetched/updated.
switch (config.installMode) {
case 'prefetch':
return new PrefetchAssetGroup(
this.scope, this.adapter, this.idle, config, this.hashTable, this.database, prefix);
case 'lazy':
return new LazyAssetGroup(
this.scope, this.adapter, this.idle, config, this.hashTable, this.database, prefix);
}
});
// Process each `DataGroup` declared in the manifest.
this.dataGroups =
(manifest.dataGroups || [])
.map(
config => new DataGroup(
this.scope, this.adapter, config, this.database, `${config.version}:data`));
}
/**
* Fully initialize this version of the application. If this Promise resolves successfully, all
* required
* data has been safely downloaded.
*/
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
try {
// Fully initialize each asset group, in series. Starts with an empty Promise, and waits for
// the previous
// groups to have been initialized before initializing the next one in turn.
await this.assetGroups.reduce<Promise<void>>(async(previous, group) => {
// Wait for the previous groups to complete initialization. If there is a failure, this will
// throw, and
// each subsequent group will throw, until the whole sequence fails.
await previous;
// Initialize this group.
return group.initializeFully(updateFrom);
}, Promise.resolve());
} catch (err) {
this._okay = false;
throw err;
}
}
async handleFetch(req: Request, context: Context): Promise<Response|null> {
// Check the request against each `AssetGroup` in sequence. If an `AssetGroup` can't handle the
// request,
// it will return `null`. Thus, the first non-null response is the SW's answer to the request.
// So reduce
// the group list, keeping track of a possible response. If there is one, it gets passed
// through, and if
// not the next group is consulted to produce a candidate response.
const asset = await this.assetGroups.reduce(async(potentialResponse, group) => {
// Wait on the previous potential response. If it's not null, it should just be passed
// through.
const resp = await potentialResponse;
if (resp !== null) {
return resp;
}
// No response has been found yet. Maybe this group will have one.
return group.handleFetch(req, context);
}, Promise.resolve(null));
// The result of the above is the asset response, if there is any, or null otherwise. Return the
// asset
// response if there was one. If not, check with the data caching groups.
if (asset !== null) {
return asset;
}
// Perform the same reduction operation as above, but this time processing
// the data caching groups.
const data = await this.dataGroups.reduce(async(potentialResponse, group) => {
const resp = await potentialResponse;
if (resp !== null) {
return resp;
}
return group.handleFetch(req, context);
}, Promise.resolve(null));
// If the data caching group returned a response, go with it.
if (data !== null) {
return data;
}
// Next, check if this is a navigation request for a route. Detect circular
// navigations by checking if the request URL is the same as the index URL.
if (isNavigationRequest(req, this.adapter) && req.url !== this.manifest.index) {
// This was a navigation request. Re-enter `handleFetch` with a request for
// the URL.
return this.handleFetch(this.adapter.newRequest(this.manifest.index), context);
}
return null;
}
/**
* Check this version for a given resource with a particular hash.
*/
async lookupResourceWithHash(url: string, hash: string): Promise<Response|null> {
const req = this.adapter.newRequest(url);
// Verify that this version has the requested resource cached. If not,
// there's no point in trying.
if (!this.hashTable.has(url)) {
return null;
}
// Next, check whether the resource has the correct hash. If not, any cached
// response isn't usable.
if (this.hashTable.get(url) ! !== hash) {
return null;
}
// TODO: no-op context and appropriate contract. Currently this is a violation
// of the typings and could cause issues if handleFetch() has side effects. A
// better strategy to deal with side effects is needed.
// TODO: this could result in network fetches if the response is lazy. Refactor
// to avoid them.
return this.handleFetch(req, null !);
}
/**
* Check this version for a given resource regardless of its hash.
*/
lookupResourceWithoutHash(url: string): Promise<CacheState|null> {
// Limit the search to asset groups, and only scan the cache, don't
// load resources from the network.
return this.assetGroups.reduce(async(potentialResponse, group) => {
const resp = await potentialResponse;
if (resp !== null) {
return resp;
}
// fetchFromCacheOnly() avoids any network fetches, and returns the
// full set of cache data, not just the Response.
return group.fetchFromCacheOnly(url);
}, Promise.resolve<CacheState|null>(null));
}
/**
* List all unhashed resources from all asset groups.
*/
previouslyCachedResources(): Promise<string[]> {
return this.assetGroups.reduce(async(resources, group) => {
return (await resources).concat(await group.unhashedResources());
}, Promise.resolve<string[]>([]));
}
async recentCacheStatus(url: string): Promise<UpdateCacheStatus> {
return this.assetGroups.reduce(async(current, group) => {
const status = await current;
if (status === UpdateCacheStatus.CACHED) {
return status;
}
const groupStatus = await group.cacheStatus(url);
if (groupStatus === UpdateCacheStatus.NOT_CACHED) {
return status;
}
return groupStatus;
}, Promise.resolve(UpdateCacheStatus.NOT_CACHED));
}
/**
* Erase this application version, by cleaning up all the caches.
*/
async cleanup(): Promise<void> {
await Promise.all(this.assetGroups.map(group => group.cleanup()));
await Promise.all(this.dataGroups.map(group => group.cleanup()));
}
/**
* Get the opaque application data which was provided with the manifest.
*/
get appData(): Object|null { return this.manifest.appData || null; }
}

View File

@ -0,0 +1,556 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter, Context} from './adapter';
import {CacheState, UpdateCacheStatus, UpdateSource, UrlMetadata} from './api';
import {Database, Table} from './database';
import {IdleScheduler} from './idle';
import {AssetGroupConfig} from './manifest';
import {sha1} from './sha1';
/**
* A group of assets that are cached in a `Cache` and managed by a given policy.
*
* Concrete classes derive from this base and specify the exact caching policy.
*/
export abstract class AssetGroup {
/**
* A deduplication cache, to make sure the SW never makes two network requests
* for the same resource at once. Managed by `fetchAndCacheOnce`.
*/
private inFlightRequests = new Map<string, Promise<Response>>();
/**
* Regular expression patterns.
*/
protected patterns: RegExp[] = [];
/**
* A Promise which resolves to the `Cache` used to back this asset group. This
* is openedfrom the constructor.
*/
protected cache: Promise<Cache>;
/**
* Group name from the configuration.
*/
readonly name: string;
/**
* Metadata associated with specific cache entries.
*/
protected metadata: Promise<Table>;
constructor(
protected scope: ServiceWorkerGlobalScope, protected adapter: Adapter,
protected idle: IdleScheduler, protected config: AssetGroupConfig,
protected hashes: Map<string, string>, protected db: Database, protected prefix: string) {
this.name = config.name;
// Patterns in the config are regular expressions disguised as strings. Breathe life into them.
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
// This is the primary cache, which holds all of the cached requests for this group. If a
// resource
// isn't in this cache, it hasn't been fetched yet.
this.cache = this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`);
// This is the metadata table, which holds specific information for each cached URL, such as
// the timestamp of when it was added to the cache.
this.metadata = this.db.open(`${this.prefix}:${this.config.name}:meta`);
}
async cacheStatus(url: string): Promise<UpdateCacheStatus> {
const cache = await this.cache;
const meta = await this.metadata;
const res = await cache.match(this.adapter.newRequest(url));
if (res === undefined) {
return UpdateCacheStatus.NOT_CACHED;
}
try {
const data = await meta.read<UrlMetadata>(url);
if (!data.used) {
return UpdateCacheStatus.CACHED_BUT_UNUSED;
}
} catch (_) {
// Error on the side of safety and assume cached.
}
return UpdateCacheStatus.CACHED;
}
/**
* Initialize this asset group, updating from the given source if available.
*/
abstract initializeFully(updateFrom?: UpdateSource): Promise<void>;
/**
* Clean up all the cached data for this group.
*/
async cleanup(): Promise<void> {
await this.scope.caches.delete(`${this.prefix}:${this.config.name}:cache`);
await this.db.delete(`${this.prefix}:${this.config.name}:meta`);
}
/**
* Process a request for a given resource and return it, or return null if it's not available.
*/
async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
// Either the request matches one of the known resource URLs, one of the patterns for
// dynamically matched URLs, or neither. Determine which is the case for this request in
// order to decide how to handle it.
if (this.config.urls.indexOf(req.url) !== -1 ||
this.patterns.some(pattern => pattern.test(req.url))) {
// This URL matches a known resource. Either it's been cached already or it's missing, in
// which case it needs to be loaded from the network.
// Open the cache to check whether this resource is present.
const cache = await this.cache;
// Look for a cached response. If one exists, it can be used to resolve the fetch
// operation.
const cachedResponse = await cache.match(req);
if (cachedResponse !== undefined) {
// A response has already been cached (which presumably matches the hash for this
// resource). Check whether it's safe to serve this resource from cache.
if (this.hashes.has(req.url)) {
// This resource has a hash, and thus is versioned by the manifest. It's safe to return
// the response.
return cachedResponse;
} else {
// This resource has no hash, and yet exists in the cache. Check how old this request is
// to make sure it's still usable.
if (await this.needToRevalidate(req, cachedResponse)) {
this.idle.schedule(
`revalidate(${this.prefix}, ${this.config.name}): ${req.url}`,
async() => { await this.fetchAndCacheOnce(req); });
}
// In either case (revalidation or not), the cached response must be good.
return cachedResponse;
}
}
// No already-cached response exists, so attempt a fetch/cache operation.
const res = await this.fetchAndCacheOnce(req);
// If this is successful, the response needs to be cloned as it might be used to respond to
// multiple fetch operations at the same time.
return res.clone();
} else {
return null;
}
}
/**
* Some resources are cached without a hash, meaning that their expiration is controlled
* by HTTP caching headers. Check whether the given request/response pair is still valid
* per the caching headers.
*/
private async needToRevalidate(req: Request, res: Response): Promise<boolean> {
// Three different strategies apply here:
// 1) The request has a Cache-Control header, and thus expiration needs to be based on its age.
// 2) The request has an Expires header, and expiration is based on the current timestamp.
// 3) The request has no applicable caching headers, and must be revalidated.
if (res.headers.has('Cache-Control')) {
// Figure out if there is a max-age directive in the Cache-Control header.
const cacheControl = res.headers.get('Cache-Control') !;
const cacheDirectives =
cacheControl
// Directives are comma-separated within the Cache-Control header value.
.split(',')
// Make sure each directive doesn't have extraneous whitespace.
.map(v => v.trim())
// Some directives have values (like maxage and s-maxage)
.map(v => v.split('='));
// Lowercase all the directive names.
cacheDirectives.forEach(v => v[0] = v[0].toLowerCase());
// Find the max-age directive, if one exists.
const cacheAge = cacheDirectives.filter(v => v[0] === 'max-age').map(v => v[1])[0];
if (cacheAge.length === 0) {
// No usable TTL defined. Must assume that the response is stale.
return true;
}
try {
const maxAge = 1000 * parseInt(cacheAge);
// Determine the origin time of this request. If the SW has metadata on the request (which
// it
// should), it will have the time the request was added to the cache. If it doesn't for some
// reason, the request may have a Date header which will serve the same purpose.
let ts: number;
try {
// Check the metadata table. If a timestamp is there, use it.
const metaTable = await this.metadata;
ts = (await metaTable.read<UrlMetadata>(req.url)).ts;
} catch (e) {
// Otherwise, look for a Date header.
const date = res.headers.get('Date');
if (date === null) {
// Unable to determine when this response was created. Assume that it's stale, and
// revalidate it.
return true;
}
ts = Date.parse(date);
}
const age = this.adapter.time - ts;
return age < 0 || age > maxAge;
} catch (e) {
// Assume stale.
return true;
}
} else if (res.headers.has('Expires')) {
// Determine if the expiration time has passed.
const expiresStr = res.headers.get('Expires') !;
try {
// The request needs to be revalidated if the current time is later than the expiration
// time, if it parses correctly.
return this.adapter.time > Date.parse(expiresStr);
} catch (e) {
// The expiration date failed to parse, so revalidate as a precaution.
return true;
}
} else {
// No way to evaluate staleness, so assume the response is already stale.
return true;
}
}
/**
* Fetch the complete state of a cached resource, or return null if it's not found.
*/
async fetchFromCacheOnly(url: string): Promise<CacheState|null> {
const cache = await this.cache;
const metaTable = await this.metadata;
// Lookup the response in the cache.
const response = await cache.match(this.adapter.newRequest(url));
if (response === undefined) {
// It's not found, return null.
return null;
}
// Next, lookup the cached metadata.
let metadata: UrlMetadata|undefined = undefined;
try {
metadata = await metaTable.read<UrlMetadata>(url);
} catch (e) {
// Do nothing, not found. This shouldn't happen, but it can be handled.
}
// Return both the response and any available metadata.
return {response, metadata};
}
/**
* Lookup all resources currently stored in the cache which have no associated hash.
*/
async unhashedResources(): Promise<string[]> {
const cache = await this.cache;
// Start with the set of all cached URLs.
return (await cache.keys())
// Exclude the URLs which have hashes.
.filter(url => !this.hashes.has(url));
}
/**
* Fetch the given resource from the network, and cache it if able.
*/
protected async fetchAndCacheOnce(req: Request, used: boolean = true): Promise<Response> {
// The `inFlightRequests` map holds information about which caching operations are currently
// underway for known resources. If this request appears there, another "thread" is already
// in the process of caching it, and this work should not be duplicated.
if (this.inFlightRequests.has(req.url)) {
// There is a caching operation already in progress for this request. Wait for it to
// complete, and hopefully it will have yielded a useful response.
return this.inFlightRequests.get(req.url) !;
}
// No other caching operation is being attempted for this resource, so it will be owned here.
// Go to the network and get the correct version.
const fetchOp = this.fetchFromNetwork(req);
// Save this operation in `inFlightRequests` so any other "thread" attempting to cache it
// will block on this chain instead of duplicating effort.
this.inFlightRequests.set(req.url, fetchOp);
// Make sure this attempt is cleaned up properly on failure.
try {
// Wait for a response. If this fails, the request will remain in `inFlightRequests`
// indefinitely.
const res = await fetchOp;
// It's very important that only successful responses are cached. Unsuccessful responses
// should never be cached as this can completely break applications.
if (!res.ok) {
throw new Error(
`Response not Ok (fetchAndCacheOnce): request for ${req.url} returned response ${res.status} ${res.statusText}`);
}
// This response is safe to cache (as long as it's cloned). Wait until the cache operation
// is complete.
const cache = await this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`);
await cache.put(req, res.clone());
// If the request is not hashed, update its metadata, especially the timestamp. This is needed
// for future determination of whether this cached response is stale or not.
if (!this.hashes.has(req.url)) {
// Metadata is tracked for requests that are unhashed.
const meta: UrlMetadata = {ts: this.adapter.time, used};
const metaTable = await this.metadata;
await metaTable.write(req.url, meta);
}
return res;
} finally {
// Finally, it can be removed from `inFlightRequests`. This might result in a double-remove
// if some other chain was already making this request too, but that won't hurt anything.
this.inFlightRequests.delete(req.url);
}
}
/**
* Load a particular asset from the network, accounting for hash validation.
*/
protected async fetchFromNetwork(req: Request): Promise<Response> {
// If a hash is available for this resource, then compare the fetched version with the
// canonical hash. Otherwise, the network version will have to be trusted.
if (this.hashes.has(req.url)) {
// It turns out this resource does have a hash. Look it up. Unless the fetched version
// matches this hash, it's invalid and the whole manifest may need to be thrown out.
const canonicalHash = this.hashes.get(req.url) !;
// Ideally, the resource would be requested with cache-busting to guarantee the SW gets
// the freshest version. However, doing this would eliminate any chance of the response
// being in the HTTP cache. Given that the browser has recently actively loaded the page,
// it's likely that many of the responses the SW needs to cache are in the HTTP cache and
// are fresh enough to use. In the future, this could be done by setting cacheMode to
// *only* check the browser cache for a cached version of the resource, when cacheMode is
// fully supported. For now, the resource is fetched directly, without cache-busting, and
// if the hash test fails a cache-busted request is tried before concluding that the
// resource isn't correct. This gives the benefit of acceleration via the HTTP cache
// without the risk of stale data, at the expense of a duplicate request in the event of
// a stale response.
// Fetch the resource from the network (possibly hitting the HTTP cache).
const networkResult = await this.scope.fetch(req);
// Decide whether a cache-busted request is necessary. It might be for two independent
// reasons: either the non-cache-busted request failed (hopefully transiently) or if the
// hash of the content retrieved does not match the canonical hash from the manifest. It's
// only valid to access the content of the first response if the request was successful.
let makeCacheBustedRequest: boolean = networkResult.ok;
if (makeCacheBustedRequest) {
// The request was successful. A cache-busted request is only necessary if the hashes
// don't match. Compare them, making sure to clone the response so it can be used later
// if it proves to be valid.
const fetchedHash = sha1(await networkResult.clone().text());
makeCacheBustedRequest = (fetchedHash !== canonicalHash);
}
// Make a cache busted request to the network, if necessary.
if (makeCacheBustedRequest) {
// Hash failure, the version that was retrieved under the default URL did not have the
// hash expected. This could be because the HTTP cache got in the way and returned stale
// data, or because the version on the server really doesn't match. A cache-busting
// request will differentiate these two situations.
// TODO: handle case where the URL has parameters already (unlikely for assets).
const cacheBustedResult = await this.scope.fetch(this.cacheBust(req.url));
// If the response was unsuccessful, there's nothing more that can be done.
if (!cacheBustedResult.ok) {
throw new Error(
`Response not Ok (fetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`);
}
// Hash the contents.
const cacheBustedHash = sha1(await cacheBustedResult.clone().text());
// If the cache-busted version doesn't match, then the manifest is not an accurate
// representation of the server's current set of files, and the SW should give up.
if (canonicalHash !== cacheBustedHash) {
throw new Error(
`Hash mismatch (${req.url}): expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`);
}
// If it does match, then use the cache-busted result.
return cacheBustedResult;
}
// Excellent, the version from the network matched on the first try, with no need for
// cache-busting. Use it.
return networkResult;
} else {
// This URL doesn't exist in our hash database, so it must be requested directly.
return this.scope.fetch(req);
}
}
/**
* Possibly update a resource, if it's expired and needs to be updated. A no-op otherwise.
*/
protected async maybeUpdate(updateFrom: UpdateSource, req: Request, cache: Cache):
Promise<boolean> {
const meta = await this.metadata;
// Check if this resource is hashed and already exists in the cache of a prior version.
if (this.hashes.has(req.url)) {
const hash = this.hashes.get(req.url) !;
// Check the caches of prior versions, using the hash to ensure the correct version of
// the resource is loaded.
const res = await updateFrom.lookupResourceWithHash(req.url, hash);
// If a previously cached version was available, copy it over to this cache.
if (res !== null) {
// Copy to this cache.
await cache.put(req, res);
await meta.write(req.url, { ts: this.adapter.time, used: false } as UrlMetadata);
// No need to do anything further with this resource, it's now cached properly.
return true;
}
}
// No up-to-date version of this resource could be found.
return false;
}
/**
* Construct a cache-busting URL for a given URL.
*/
private cacheBust(url: string): string {
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'ngsw-cache-bust=' + Math.random();
}
}
/**
* An `AssetGroup` that prefetches all of its resources during initialization.
*/
export class PrefetchAssetGroup extends AssetGroup {
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
// Open the cache which actually holds requests.
const cache = await this.cache;
// Cache all known resources serially. As this reduce proceeds, each Promise waits
// on the last before starting the fetch/cache operation for the next request. Any
// errors cause fall-through to the final Promise which rejects.
await this.config.urls.reduce(async(previous: Promise<void>, url: string) => {
// Wait on all previous operations to complete.
await previous;
// Construct the Request for this url.
const req = this.adapter.newRequest(url);
// First, check the cache to see if there is already a copy of this resource.
const alreadyCached = (await cache.match(req)) !== undefined;
// If the resource is in the cache already, it can be skipped.
if (alreadyCached) {
return;
}
// If an update source is available.
if (updateFrom !== undefined && await this.maybeUpdate(updateFrom, req, cache)) {
return;
}
// Otherwise, go to the network and hopefully cache the response (if successful).
await this.fetchAndCacheOnce(req, false);
}, Promise.resolve());
// Handle updating of unknown (unhashed) resources. This is only possible if there's
// a source to update from.
if (updateFrom !== undefined) {
const metaTable = await this.metadata;
// Select all of the previously cached resources. These are cached unhashed resources
// from previous versions of the app, in any asset group.
await(await updateFrom.previouslyCachedResources())
// First, narrow down the set of resources to those which are handled by this group.
// Either it's a known URL, or it matches a given pattern.
.filter(
url => this.config.urls.some(cacheUrl => cacheUrl === url) ||
this.patterns.some(pattern => pattern.test(url)))
// Finally, process each resource in turn.
.reduce(async(previous, url) => {
await previous;
const req = this.adapter.newRequest(url);
// It's possible that the resource in question is already cached. If so,
// continue to the next one.
const alreadyCached = (await cache.match(req) !== undefined);
if (alreadyCached) {
return;
}
// Get the most recent old version of the resource.
const res = await updateFrom.lookupResourceWithoutHash(url);
if (res === null || res.metadata === undefined) {
// Unexpected, but not harmful.
return;
}
// Write it into the cache. It may already be expired, but it can still serve
// traffic until it's updated (stale-while-revalidate approach).
await cache.put(req, res.response);
await metaTable.write(url, { ...res.metadata, used: false } as UrlMetadata);
}, Promise.resolve());
}
}
}
export class LazyAssetGroup extends AssetGroup {
async initializeFully(updateFrom?: UpdateSource): Promise<void> {
// No action necessary if no update source is available - resources managed in this group
// are all lazily loaded, so there's nothing to initialize.
if (updateFrom === undefined) {
return;
}
// Open the cache which actually holds requests.
const cache = await this.cache;
// Loop through the listed resources, caching any which are available.
await this.config.urls.reduce(async(previous: Promise<void>, url: string) => {
// Wait on all previous operations to complete.
await previous;
// Construct the Request for this url.
const req = this.adapter.newRequest(url);
// First, check the cache to see if there is already a copy of this resource.
const alreadyCached = (await cache.match(req)) !== undefined;
// If the resource is in the cache already, it can be skipped.
if (alreadyCached) {
return;
}
const updated = await this.maybeUpdate(updateFrom, req, cache);
if (this.config.updateMode === 'prefetch' && !updated) {
// If the resource was not updated, either it was not cached before or
// the previously cached version didn't match the updated hash. In that
// case, prefetch update mode dictates that the resource will be updated,
// except if it was not previously utilized. Check the status of the
// cached resource to see.
const cacheStatus = await updateFrom.recentCacheStatus(url);
// If the resource is not cached, or was cached but unused, then it will be
// loaded lazily.
if (cacheStatus !== UpdateCacheStatus.CACHED) {
return;
}
// Update from the network.
await this.fetchAndCacheOnce(req, false);
}
}, Promise.resolve());
}
}

View File

@ -0,0 +1,541 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter, Context} from './adapter';
import {Database, Table} from './database';
import {DataGroupConfig} from './manifest';
/**
* A metadata record of how old a particular cached resource is.
*/
interface AgeRecord {
age: number;
}
/**
* A node in the LRU chain for a given `DataGroup`.
*
* Serializable as previous/next are identified by their URL and are not references.
*/
interface LruNode {
/**
* The URL tracked by this node.
*/
url: string;
/**
* The previous (more recent) node in the chain, or null if this is the head.
*/
previous: string|null;
/**
* The next (less recent) node in the chain, or null if this is the tail.
*/
next: string|null;
}
/**
* Serializable state of an entire LRU chain.
*
* Essentially a doubly linked list of URLs.
*/
interface LruState {
/**
* URL of the head node, or null if the chain is empty.
*/
head: string|null;
/**
* URL of the tail node, or null if the chain is empty.
*/
tail: string|null;
/**
* Map of URLs to data for each URL (including next/prev pointers).
*/
map: {[url: string]: LruNode | undefined};
/**
* Count of the number of nodes in the chain.
*/
count: number;
}
/**
* Manages an instance of `LruState` and moves URLs to the head of the
* chain when requested.
*/
class LruList {
state: LruState;
constructor(state?: LruState) {
if (state === undefined) {
state = {
head: null,
tail: null,
map: {},
count: 0,
};
}
this.state = state;
}
/**
* The current count of URLs in the list.
*/
get size(): number { return this.state.count; }
/**
* Remove the tail.
*/
pop(): string|null {
// If there is no tail, return null.
if (this.state.tail === null) {
return null;
}
const url = this.state.tail;
// Special case if this is the last node.
if (this.state.head === this.state.tail) {
// When removing the last node, both head and tail pointers become null.
this.state.head = null;
this.state.tail = null;
} else {
// Normal node removal. All that needs to be done is to clear the next pointer
// of the previous node and make it the new tail.
const block = this.state.map[url] !;
const previous = this.state.map[block.previous !] !;
this.state.tail = previous.url;
previous.next = block.next;
}
// In any case, this URL is no longer tracked, so remove it from the count and the
// map of tracked URLs.
delete this.state.map[url];
this.state.count--;
// This URL has been successfully evicted.
return url;
}
remove(url: string): boolean {
const node = this.state.map[url];
if (node === undefined) {
return false;
}
// Special case if removing the current head.
if (this.state.head === url) {
// The node is the current head. Special case the removal.
if (node.next === null) {
// This is the only node. Reset the cache to be empty.
this.state.head = null;
this.state.tail = null;
this.state.map = {};
this.state.count = 0;
return true;
}
// There is at least one other node. Make the next node the new head.
const next = this.state.map[node.next !] !;
next.previous = null;
this.state.head = next.url;
this.state.count--;
return true;
}
// The node is not the head, so it has a previous. It may or may not be the tail.
// If it is not, then it has a next. First, grab the previous node.
const previous = this.state.map[node.previous !] !;
// Fix the forward pointer to skip over node and go directly to node.next.
previous.next = node.next;
// node.next may or may not be set. If it is, fix the back pointer to skip over node.
// If it's not set, then this node happened to be the tail, and the tail needs to be
// updated to point to the previous node (removing the tail).
if (node.next !== null) {
// There is a next node, fix its back pointer to skip this node.
this.state.map[node.next] !.previous = node.previous !;
} else {
// There is no next node - the accessed node must be the tail. Move the tail pointer.
this.state.tail = node.previous !;
}
// Count the removal.
this.state.count--;
return true;
}
accessed(url: string): void {
// When a URL is accessed, its node needs to be moved to the head of the chain.
// This is accomplished in two steps:
//
// 1) remove the node from its position within the chain.
// 2) insert the node as the new head.
//
// Sometimes, a URL is accessed which has not been seen before. In this case, step 1 can
// be skipped completely (which will grow the chain by one). Of course, if the node is
// already the head, this whole operation can be skipped.
if (this.state.head === url) {
// The URL is already in the head position, accessing it is a no-op.
return;
}
// Look up the node in the map, and construct a new entry if it's
const node = this.state.map[url] || {url, next: null, previous: null};
// Step 1: remove the node from its position within the chain, if it is in the chain.
if (this.state.map[url] !== undefined) {
this.remove(url);
}
// Step 2: insert the node at the head of the chain.
// First, check if there's an existing head node. If there is, it has previous: null.
// Its previous pointer should be set to the node we're inserting.
if (this.state.head !== null) {
this.state.map[this.state.head] !.previous = url;
}
// The next pointer of the node being inserted gets set to the old head, before the head
// pointer is updated to this node.
node.next = this.state.head;
// The new head is the new node.
this.state.head = url;
// If there is no tail, then this is the first node, and is both the head and the tail.
if (this.state.tail === null) {
this.state.tail = url;
}
// Set the node in the map of nodes (if the URL has been seen before, this is a no-op)
// and count the insertion.
this.state.map[url] = node;
this.state.count++;
}
}
/**
* A group of cached resources determined by a set of URL patterns which follow a LRU policy
* for caching.
*/
export class DataGroup {
/**
* Compiled regular expression set used to determine which resources fall under the purview
* of this group.
*/
private readonly patterns: RegExp[];
/**
* The `Cache` instance in which resources belonging to this group are cached.
*/
private readonly cache: Promise<Cache>;
/**
* Tracks the LRU state of resources in this cache.
*/
private _lru: LruList|null = null;
/**
* Database table used to store the state of the LRU cache.
*/
private readonly lruTable: Promise<Table>;
/**
* Database table used to store metadata for resources in the cache.
*/
private readonly ageTable: Promise<Table>;
constructor(
private scope: ServiceWorkerGlobalScope, private adapter: Adapter,
private config: DataGroupConfig, private db: Database, private prefix: string) {
this.patterns = this.config.patterns.map(pattern => new RegExp(pattern));
this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`);
this.lruTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:lru`);
this.ageTable = this.db.open(`${this.prefix}:dynamic:${this.config.name}:age`);
}
/**
* Lazily initialize/load the LRU chain.
*/
private async lru(): Promise<LruList> {
if (this._lru === null) {
const table = await this.lruTable;
try {
this._lru = new LruList(await table.read<LruState>('lru'));
} catch (e) {
this._lru = new LruList();
}
}
return this._lru;
}
/**
* Sync the LRU chain to non-volatile storage.
*/
async syncLru(): Promise<void> {
if (this._lru === null) {
return;
}
const table = await this.lruTable;
return table.write('lru', this._lru !.state);
}
/**
* Process a fetch event and return a `Response` if the resource is covered by this group,
* or `null` otherwise.
*/
async handleFetch(req: Request, ctx: Context): Promise<Response|null> {
// Do nothing
if (!this.patterns.some(pattern => pattern.test(req.url))) {
return null;
}
// Lazily initialize the LRU cache.
const lru = await this.lru();
// The URL matches this cache. First, check whether this is a mutating request or not.
switch (req.method) {
case 'OPTIONS':
// Don't try to cache this - it's non-mutating, but is part of a mutating request.
// Most likely SWs don't even see this, but this guard is here just in case.
return null;
case 'GET':
case 'HEAD':
// Handle the request with whatever strategy was selected.
switch (this.config.strategy) {
case 'freshness':
return this.handleFetchWithFreshness(req, ctx, lru);
case 'performance':
return this.handleFetchWithPerformance(req, ctx, lru);
default:
throw new Error(`Unknown strategy: ${this.config.strategy}`);
}
default:
// This was a mutating request. Assume the cache for this URL is no longer valid.
const wasCached = lru.remove(req.url);
// If there was a cached entry, remove it.
if (wasCached) {
await this.clearCacheForUrl(req.url);
}
// Sync the LRU chain to non-volatile storage.
await this.syncLru();
// Finally, fall back on the network.
return this.scope.fetch(req);
}
}
private async handleFetchWithPerformance(req: Request, ctx: Context, lru: LruList):
Promise<Response|null> {
let res: Response|null|undefined = null;
// Check the cache first. If the resource exists there (and is not expired), the cached
// version can be used.
const fromCache = await this.loadFromCache(req, lru);
if (fromCache !== null) {
res = fromCache.res;
// Check the age of the resource.
if (this.config.refreshAheadMs !== undefined && fromCache.age >= this.config.refreshAheadMs) {
ctx.waitUntil(this.safeCacheResponse(req, this.scope.fetch(req)));
}
}
if (res !== null) {
return res;
}
// No match from the cache. Go to the network. Note that this is not an 'await'
// call, networkFetch is the actual Promise. This is due to timeout handling.
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
res = await timeoutFetch;
// Since fetch() will always return a response, undefined indicates a timeout.
if (res === undefined) {
// The request timed out. Return a Gateway Timeout error.
res = this.adapter.newResponse(null, {status: 504, statusText: 'Gateway Timeout'});
// Cache the network response eventually.
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
}
// The request completed in time, so cache it inline with the response flow.
// Make sure to clone it so the real response can still be returned to the user.
await this.cacheResponse(req, res.clone(), lru);
return res;
}
private async handleFetchWithFreshness(req: Request, ctx: Context, lru: LruList):
Promise<Response|null> {
// Start with a network fetch.
const [timeoutFetch, networkFetch] = this.networkFetchWithTimeout(req);
let res: Response|null|undefined;
// If that fetch errors, treat it as a timed out request.
try {
res = await timeoutFetch;
} catch (e) {
res = undefined;
}
// If the network fetch times out or errors, fall back on the cache.
if (res === undefined) {
ctx.waitUntil(this.safeCacheResponse(req, networkFetch));
// Ignore the age, the network response will be cached anyway due to the
// behavior of freshness.
const fromCache = await this.loadFromCache(req, lru);
res = (fromCache !== null) ? fromCache.res : null;
} else {
await this.cacheResponse(req, res, lru, true);
}
// Either the network fetch didn't time out, or the cache yielded a usable response.
// In either case, use it.
if (res !== null) {
return res;
}
// No response in the cache. No choice but to fall back on the full network fetch.
res = await networkFetch;
await this.cacheResponse(req, res.clone(), lru, true);
return res;
}
private networkFetchWithTimeout(req: Request): [Promise<Response|undefined>, Promise<Response>] {
const networkFetch = this.scope.fetch(req);
// If there is a timeout configured, race a timeout Promise with the network fetch.
// Otherwise, just fetch from the network directly.
if (this.config.timeoutMs !== undefined) {
// Construct a Promise<undefined> for the timeout.
const timeout = this.adapter.timeout(this.config.timeoutMs) as Promise<undefined>;
// Race that with the network fetch. This will either be a Response, an error, or
// `undefined` in the event that the request times out.
return [Promise.race([networkFetch, timeout]), networkFetch];
} else {
// Do a plain fetch.
return [networkFetch, networkFetch];
}
}
private async safeCacheResponse(req: Request, res: Promise<Response>): Promise<void> {
try {
await this.cacheResponse(req, await res, await this.lru());
} catch (e) {
// TODO: handle this error somehow?
}
}
private async loadFromCache(req: Request, lru: LruList):
Promise<{res: Response, age: number}|null> {
// Look for a response in the cache. If one exists, return it.
const cache = await this.cache;
let res = await cache.match(req);
if (res !== undefined) {
// A response was found in the cache, but its age is not yet known. Look it up.
try {
const ageTable = await this.ageTable;
const age = this.adapter.time - (await ageTable.read<AgeRecord>(req.url)).age;
// If the response is young enough, use it.
if (age <= this.config.maxAge) {
// Successful match from the cache. Use the response, after marking it as having
// been accessed.
lru.accessed(req.url);
return {res, age};
}
// Otherwise, or if there was an error, assume the response is expired, and evict it.
} catch (e) {
// Some error getting the age for the response. Assume it's expired.
}
lru.remove(req.url);
await this.clearCacheForUrl(req.url);
// TODO: avoid duplicate in event of network timeout, maybe.
await this.syncLru();
}
return null;
}
/**
* Operation for caching the response from the server. This has to happen all
* at once, so that the cache and LRU tracking remain in sync. If the network request
* completes before the timeout, this logic will be run inline with the response flow.
* If the request times out on the server, an error will be returned but the real network
* request will still be running in the background, to be cached when it completes.
*/
private async cacheResponse(
req: Request, res: Response, lru: LruList, okToCacheOpaque: boolean = false): Promise<void> {
// Only cache successful responses.
if (!res.ok || (okToCacheOpaque && res.type === 'opaque')) {
return;
}
// If caching this response would make the cache exceed its maximum size, evict something
// first.
if (lru.size >= this.config.maxSize) {
// The cache is too big, evict something.
const evictedUrl = lru.pop();
if (evictedUrl !== null) {
await this.clearCacheForUrl(evictedUrl);
}
}
// TODO: evaluate for possible race conditions during flaky network periods.
// Mark this resource as having been accessed recently. This ensures it won't be evicted
// until enough other resources are requested that it falls off the end of the LRU chain.
lru.accessed(req.url);
// Store the response in the cache.
await(await this.cache).put(req, res);
// Store the age of the cache.
const ageTable = await this.ageTable;
await ageTable.write(req.url, {age: this.adapter.time});
// Sync the LRU chain to non-volatile storage.
await this.syncLru();
}
/**
* Delete all of the saved state which this group uses to track resources.
*/
async cleanup(): Promise<void> {
// Remove both the cache and the database entries which track LRU stats.
await Promise.all([
this.scope.caches.delete(`${this.prefix}:dynamic:${this.config.name}:cache`),
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:age`),
this.db.delete(`${this.prefix}:dynamic:${this.config.name}:lru`),
]);
}
/**
* Clear the state of the cache for a particular resource.
*
* This doesn't remove the resource from the LRU table, that is assumed to have
* been done already. This clears the GET and HEAD versions of the request from
* the cache itself, as well as the metadata stored in the age table.
*/
private async clearCacheForUrl(url: string): Promise<void> {
const [cache, ageTable] = await Promise.all([this.cache, this.ageTable]);
await Promise.all([
cache.delete(this.adapter.newRequest(url, {method: 'GET'})),
cache.delete(this.adapter.newRequest(url, {method: 'HEAD'})),
ageTable.delete(url),
]);
}
}

View File

@ -0,0 +1,60 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
/**
* An abstract table, with the ability to read/write objects stored under keys.
*/
export interface Table {
/**
* Delete a key from the table.
*/
'delete'(key: string): Promise<boolean>;
/**
* List all the keys currently stored in the table.
*/
keys(): Promise<string[]>;
/**
* Read a key from a table, either as an Object or with a given type.
*/
read(key: string): Promise<Object>;
read<T>(key: string): Promise<T>;
/**
* Write a new value for a key to the table, overwriting any previous value.
*/
write(key: string, value: Object): Promise<void>;
}
/**
* An abstract database, consisting of multiple named `Table`s.
*/
export interface Database {
/**
* Delete an entire `Table` from the database, by name.
*/
'delete'(table: string): Promise<boolean>;
/**
* List all `Table`s by name.
*/
list(): Promise<string[]>;
/**
* Open a `Table`.
*/
open(table: string): Promise<Table>;
}
/**
* An error returned in rejected promises if the given key is not found in the table.
*/
export class NotFound {
constructor(public table: string, public key: string) {}
}

View File

@ -0,0 +1,69 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter} from './adapter';
import {Database, NotFound, Table} from './database';
/**
* An implementation of a `Database` that uses the `CacheStorage` API to serialize
* state within mock `Response` objects.
*/
export class CacheDatabase implements Database {
private tables = new Map<string, Promise<CacheTable>>();
constructor(private scope: ServiceWorkerGlobalScope, private adapter: Adapter) {}
'delete'(name: string): Promise<boolean> {
if (this.tables.has(name)) {
this.tables.delete(name);
}
return this.scope.caches.delete(`ngsw:db:${name}`);
}
list(): Promise<string[]> {
return this.scope.caches.keys().then(keys => keys.filter(key => key.startsWith('ngsw:db:')));
}
open(name: string): Promise<Table> {
if (!this.tables.has(name)) {
const table = this.scope.caches.open(`ngsw:db:${name}`)
.then(cache => new CacheTable(name, cache, this.adapter));
this.tables.set(name, table);
}
return this.tables.get(name) !;
}
}
/**
* A `Table` backed by a `Cache`.
*/
export class CacheTable implements Table {
constructor(readonly table: string, private cache: Cache, private adapter: Adapter) {}
private request(key: string): Request { return this.adapter.newRequest('/' + key); }
'delete'(key: string): Promise<boolean> { return this.cache.delete(this.request(key)); }
keys(): Promise<string[]> {
return this.cache.keys().then(keys => keys.map(key => key.substr(1)));
}
read(key: string): Promise<any> {
return this.cache.match(this.request(key)).then(res => {
if (res === undefined) {
return Promise.reject(new NotFound(this.table, key));
}
return res.json();
});
}
write(key: string, value: Object): Promise<void> {
return this.cache.put(this.request(key), this.adapter.newResponse(JSON.stringify(value)));
}
}

View File

@ -0,0 +1,68 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter} from './adapter';
import {Debuggable} from './api';
export class DebugHandler {
constructor(readonly driver: Debuggable, readonly adapter: Adapter) {}
async handleFetch(req: Request): Promise<Response> {
const [state, versions, idle] = await Promise.all([
this.driver.debugState(),
this.driver.debugVersions(),
this.driver.debugIdleState(),
]);
const msgState = `NGSW Debug Info:
Driver state: ${state.state} (${state.why})
Latest manifest hash: ${state.latestHash || 'none'}
Last update check: ${this.since(state.lastUpdateCheck)}`;
const msgVersions = versions
.map(version => `=== Version ${version.hash} ===
Clients: ${version.clients.join(', ')}`)
.join('\n\n');
const msgIdle = `=== Idle Task Queue ===
Last update tick: ${this.since(idle.lastTrigger)}
Last update run: ${this.since(idle.lastRun)}
Task queue:
${idle.queue.map(v => ' * ' + v).join('\n')}
`;
return this.adapter.newResponse(
`${msgState}
${msgVersions}
${msgIdle}`,
{headers: this.adapter.newHeaders({'Content-Type': 'text/plain'})});
}
since(time: number|null): string {
if (time === null) {
return 'never';
}
let age = this.adapter.time - time;
const days = Math.floor(age / 86400000);
age = age % 86400000;
const hours = Math.floor(age / 3600000);
age = age % 3600000;
const minutes = Math.floor(age / 60000);
age = age % 60000;
const seconds = Math.floor(age / 1000);
const millis = age % 1000;
return '' + (days > 0 ? `${days}d` : '') + (hours > 0 ? `${hours}h` : '') +
(minutes > 0 ? `${minutes}m` : '') + (seconds > 0 ? `${seconds}s` : '') +
(millis > 0 ? `${millis}u` : '');
}
}

View File

@ -0,0 +1,858 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter, Context} from './adapter';
import {CacheState, DebugIdleState, DebugState, DebugVersion, Debuggable, UpdateCacheStatus, UpdateSource} from './api';
import {AppVersion} from './app-version';
import {Database, Table} from './database';
import {DebugHandler} from './debug';
import {IdleScheduler} from './idle';
import {Manifest, ManifestHash, hashManifest} from './manifest';
import {MsgAny, isMsgActivateUpdate, isMsgCheckForUpdates} from './msg';
import {isNavigationRequest} from './util';
type ClientId = string;
type ManifestMap = {
[hash: string]: Manifest
};
type ClientAssignments = {
[id: string]: ManifestHash
};
const SYNC_THRESHOLD = 5000;
const SUPPORTED_CONFIG_VERSION = 1;
const NOTIFICATION_OPTION_NAMES = [
'actions', 'body', 'dir', 'icon', 'lang', 'renotify', 'requireInteraction', 'tag', 'vibrate',
'data'
];
interface LatestEntry {
latest: string;
}
enum DriverReadyState {
// The SW is operating in a normal mode, responding to all traffic.
NORMAL,
// The SW does not have a clean installation of the latest version of the app, but older cached
// versions
// are safe to use so long as they don't try to fetch new dependencies. This is a degraded state.
EXISTING_CLIENTS_ONLY,
// The SW has decided that caching is completely unreliable, and is forgoing request handling
// until the
// next restart.
SAFE_MODE,
}
export class Driver implements Debuggable, UpdateSource {
/**
* Tracks the current readiness condition under which the SW is operating. This controls whether
* the SW
* attempts to respond to some or all requests.
*/
private state: DriverReadyState = DriverReadyState.NORMAL;
private stateMessage: string = '(nominal)';
/**
* Tracks whether the SW is in an initialized state or not. Before initialization, it's not legal
* to
* respond to requests.
*/
initialized: Promise<void>|null = null;
/**
* Maps client IDs to the manifest hash of the application version being used to serve them. If a
* client ID is not present here, it has not yet been assigned a version.
*
* If a ManifestHash appears here, it is also present in the `versions` map below.
*/
private clientVersionMap = new Map<ClientId, ManifestHash>();
/**
* Maps manifest hashes to instances of `AppVersion` for those manifests.
*/
private versions = new Map<ManifestHash, AppVersion>();
/**
* The latest version fetched from the server.
*
* Valid after initialization has completed.
*/
private latestHash: ManifestHash|null = null;
private lastUpdateCheck: number|null = null;
/**
* A scheduler which manages a queue of tasks that need to be executed when the SW is not doing
* any other work (not processing any other requests).
*/
idle: IdleScheduler;
debugger: DebugHandler;
constructor(
private scope: ServiceWorkerGlobalScope, private adapter: Adapter, private db: Database) {
// Listen to fetch events.
this.scope.addEventListener(
'install', (event) => { event !.waitUntil(this.scope.skipWaiting()); });
this.scope.addEventListener('activate', (event) => {
event !.waitUntil(this.scope.clients.claim());
if (this.scope.registration.active !== null) {
this.scope.registration.active.postMessage({action: 'INITIALIZE'});
}
});
this.scope.addEventListener('fetch', (event) => this.onFetch(event !));
this.scope.addEventListener('message', (event) => this.onMessage(event !));
this.scope.addEventListener('push', (event) => this.onPush(event !));
this.idle = new IdleScheduler(this.adapter, SYNC_THRESHOLD);
this.debugger = new DebugHandler(this, this.adapter);
}
private onFetch(event: FetchEvent): void {
// The only thing that is served unconditionally is the debug page.
if (this.adapter.getPath(event.request.url) === '/ngsw/state') {
event.respondWith(this.debugger.handleFetch(event.request));
return;
}
// If the SW is in a broken state where it's not safe to handle requests at all, returning
// causes the request to fall back on the network. This is preferred over
// `respondWith(fetch(req))` because the latter still shows in DevTools that the request
// was handled by the SW.
// TODO: try to handle DriverReadyState.EXISTING_CLIENTS_ONLY here.
if (this.state === DriverReadyState.SAFE_MODE) {
// Even though the worker is in safe mode, idle tasks still need to happen so things
// like update checks, etc. can take place.
event.waitUntil(this.idle.trigger());
return;
}
// Past this point, the SW commits to handling the request itself. This could still fail (and
// result in `state` being set to `SAFE_MODE`), but even in that case the SW will still deliver
// a response.
event.respondWith(this.handleFetch(event));
}
private onMessage(event: ExtendableMessageEvent): void {
if (this.state === DriverReadyState.SAFE_MODE) {
return;
}
const data = event.data;
if (!data || !data.action) {
return;
}
if (data.action === 'INITIALIZE' && this.initialized === null) {
this.initialized = this.initialize();
event.waitUntil(this.initialized);
event.waitUntil(this.idle.trigger());
return;
}
if (!this.adapter.isClient(event.source)) {
return;
}
event.waitUntil(this.handleMessage(data, event.source));
}
private onPush(msg: PushEvent): void {
if (!msg.data) {
return;
}
msg.waitUntil(this.handlePush(msg.data));
}
private async handleMessage(msg: MsgAny&{action: string}, from: Client): Promise<void> {
if (isMsgCheckForUpdates(msg)) {
const action = (async() => { await this.checkForUpdate(); })();
await this.reportStatus(from, action, msg.statusNonce);
} else if (isMsgActivateUpdate(msg)) {
await this.reportStatus(from, this.updateClient(from), msg.statusNonce);
}
}
private async handlePush(data: any): Promise<void> {
this.broadcast({
type: 'PUSH',
data,
});
if (!data.notification || !data.notification.title) {
return;
}
const desc = data.notification as{[key: string]: string | undefined};
let options: {[key: string]: string | undefined} = {};
NOTIFICATION_OPTION_NAMES.filter(name => desc.hasOwnProperty(name))
.forEach(name => options[name] = desc[name]);
this.scope.registration.showNotification(desc['title'] !, options);
}
private async reportStatus(client: Client, promise: Promise<void>, nonce: number): Promise<void> {
const response = {type: 'STATUS', nonce, status: true};
try {
await promise;
client.postMessage(response);
} catch (e) {
client.postMessage({
...response,
status: false,
error: e.toString(),
});
}
}
async updateClient(client: Client): Promise<void> {
// Figure out which version the client is on. If it's not on the latest, it needs to be moved.
const existing = this.clientVersionMap.get(client.id);
if (existing === this.latestHash) {
// Nothing to do, this client is already on the latest version.
return;
}
// Switch the client over.
let previous: Object|undefined = undefined;
// Look up the application data associated with the existing version. If there isn't any,
// fall back on using the hash.
if (existing !== undefined) {
const existingVersion = this.versions.get(existing) !;
previous = this.mergeHashWithAppData(existingVersion.manifest, existing);
}
// Set the current version used by the client, and
this.clientVersionMap.set(client.id, this.latestHash !);
await this.sync();
// Notify the client about this activation.
const current = this.versions.get(this.latestHash !) !;
const notice = {
type: 'UPDATE_ACTIVATED',
previous,
current: this.mergeHashWithAppData(current.manifest, this.latestHash !),
};
client.postMessage(notice);
}
private async handleFetch(event: FetchEvent): Promise<Response> {
// Since the SW may have just been started, it may or may not have been initialized already.
// this.initialized will be `null` if initialization has not yet been attempted, or will be a
// Promise which will resolve (successfully or unsuccessfully) if it has.
if (this.initialized === null) {
// Initialization has not yet been attempted, so attempt it. This should only ever happen once
// per SW instantiation.
this.initialized = this.initialize();
}
// If initialization fails, the SW needs to enter a safe state, where it declines to respond to
// network requests.
try {
// Wait for initialization.
await this.initialized;
} catch (e) {
// Initialization failed. Enter a safe state.
this.state = DriverReadyState.SAFE_MODE;
this.stateMessage = `Initialization failed due to error: ${errorToString(e)}`;
// Even though the driver entered safe mode, background tasks still need to happen.
event.waitUntil(this.idle.trigger());
// Since the SW is already committed to responding to the currently active request,
// respond with a network fetch.
return this.scope.fetch(event.request);
}
// Decide which version of the app to use to serve this request. This is asynchronous as in
// some cases, a record will need to be written to disk about the assignment that is made.
const appVersion = await this.assignVersion(event);
// Bail out
if (appVersion === null) {
event.waitUntil(this.idle.trigger());
return this.scope.fetch(event.request);
}
// Handle the request. First try the AppVersion. If that doesn't work, fall back on the network.
const res = await appVersion.handleFetch(event.request, event);
// The AppVersion will only return null if the manifest doesn't specify what to do about this
// request. In that case, just fall back on the network.
if (res === null) {
event.waitUntil(this.idle.trigger());
return this.scope.fetch(event.request);
}
// Trigger the idle scheduling system. The Promise returned by trigger() will resolve after
// a specific amount of time has passed. If trigger() hasn't been called again by then (e.g.
// on a subsequent request), the idle task queue will be drained and the Promise won't resolve
// until that operation is complete as well.
event.waitUntil(this.idle.trigger());
// The AppVersion returned a usable response, so return it.
return res;
}
/**
* Attempt to quickly reach a state where it's safe to serve responses.
*/
private async initialize(): Promise<void> {
// On initialization, all of the serialized state is read out of the 'control' table. This
// includes:
// - map of hashes to manifests of currently loaded application versions
// - map of client IDs to their pinned versions
// - record of the most recently fetched manifest hash
//
// If these values don't exist in the DB, then this is the either the first time the SW has run
// or
// the DB state has been wiped or is inconsistent. In that case, load a fresh copy of the
// manifest
// and reset the state from scratch.
// Open up the DB table.
const table = await this.db.open('control');
// Attempt to load the needed state from the DB. If this fails, the catch {} block will populate
// these variables with freshly constructed values.
let manifests: ManifestMap, assignments: ClientAssignments, latest: LatestEntry;
try {
// Read them from the DB simultaneously.
[manifests, assignments, latest] = await Promise.all([
table.read<ManifestMap>('manifests'),
table.read<ClientAssignments>('assignments'),
table.read<LatestEntry>('latest'),
]);
// Successfully loaded from saved state. This implies a manifest exists, so the update check
// needs to happen in the background.
this.idle.schedule('init post-load (update, cleanup)', async() => {
await this.checkForUpdate();
await this.cleanupCaches();
});
} catch (_) {
// Something went wrong. Try to start over by fetching a new manifest from the server and
// building
// up an empty initial state.
const manifest = await this.fetchLatestManifest();
const hash = hashManifest(manifest);
manifests = {};
manifests[hash] = manifest;
assignments = {};
latest = {latest: hash};
// Save the initial state to the DB.
await Promise.all([
table.write('manifests', manifests),
table.write('assignments', assignments),
table.write('latest', latest),
]);
}
// At this point, either the state has been loaded successfully, or fresh state with a new copy
// of
// the manifest has been produced. At this point, the `Driver` can have its internals hydrated
// from
// the state.
// Initialize the `versions` map by setting each hash to a new `AppVersion` instance for that
// manifest.
Object.keys(manifests).forEach((hash: ManifestHash) => {
const manifest = manifests[hash];
// If the manifest is newly initialized, an AppVersion may have already been created for it.
if (!this.versions.has(hash)) {
this.versions.set(
hash, new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash));
}
});
// Wait for the scheduling of initialization of all versions in the manifest. Ordinarily this
// just
// schedules the initializations to happen during the next idle period, but in development mode
// this might actually wait for the full initialization.
await Promise.all(Object.keys(manifests).map(async(hash: ManifestHash) => {
try {
// Attempt to schedule or initialize this version. If this operation is successful, then
// initialization either succeeded or was scheduled. If it fails, then full initialization
// was attempted and failed.
await this.scheduleInitialization(this.versions.get(hash) !);
} catch (err) {
return false;
}
}));
// Map each client ID to its associated hash. Along the way, verify that the hash is still valid
// for that clinet ID. It should not be possible for a client to still be associated with a hash
// that was since removed from the state.
Object.keys(assignments).forEach((clientId: ClientId) => {
const hash = assignments[clientId];
if (!this.versions.has(hash)) {
throw new Error(
`Invariant violated (initialize): no manifest known for hash ${hash} active for client ${clientId}`);
}
this.clientVersionMap.set(clientId, hash);
});
// Set the latest version.
this.latestHash = latest.latest;
// Finally, assert that the latest version is in fact loaded.
if (!this.versions.has(latest.latest)) {
throw new Error(
`Invariant violated (initialize): latest hash ${latest.latest} has no known manifest`);
}
}
private lookupVersionByHash(hash: ManifestHash, debugName: string = 'lookupVersionByHash'):
AppVersion {
// The version should exist, but check just in case.
if (!this.versions.has(hash)) {
throw new Error(
`Invariant violated (${debugName}): want AppVersion for ${hash} but not loaded`);
}
return this.versions.get(hash) !;
}
/**
* Decide which version of the manifest to use for the event.
*/
private async assignVersion(event: FetchEvent): Promise<AppVersion|null> {
// First, check whether the event has a client ID. If it does, the version may already be
// associated.
const clientId = event.clientId;
if (clientId !== null) {
// Check if there is an assigned client id.
if (this.clientVersionMap.has(clientId)) {
// There is an assignment for this client already.
let hash = this.clientVersionMap.get(clientId) !;
// Ordinarily, this client would be served from its assigned version. But, if this
// request is a navigation request, this client can be updated to the latest version
// immediately.
if (this.state === DriverReadyState.NORMAL && hash !== this.latestHash &&
isNavigationRequest(event.request, this.adapter)) {
// Update this client to the latest version immediately.
if (this.latestHash === null) {
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
}
const client = await this.scope.clients.get(clientId);
await this.updateClient(client);
hash = this.latestHash;
}
// TODO: make sure the version is valid.
return this.lookupVersionByHash(hash, 'assignVersion');
} else {
// This is the first time this client ID has been seen. Whether the SW is in a state
// to handle new clients depends on the current readiness state, so check that first.
if (this.state !== DriverReadyState.NORMAL) {
// It's not safe to serve new clients in the current state. It's possible that this
// is an existing client which has not been mapped yet (see below) but even if that
// is the case, it's invalid to make an assignment to a known invalid version, even
// if that assignment was previously implicit. Return undefined here to let the
// caller know that no assignment is possible at this time.
return null;
}
// It's safe to handle this request. Two cases apply. Either:
// 1) the browser assigned a client ID at the time of the navigation request, and
// this is truly the first time seeing this client, or
// 2) a navigation request came previously from the same client, but with no client
// ID attached. Browsers do this to avoid creating a client under the origin in
// the event the navigation request is just redirected.
//
// In case 1, the latest version can safely be used.
// In case 2, the latest version can be used, with the assumption that the previous
// navigation request was answered under the same version. This assumption relies
// on the fact that it's unlikely an update will come in between the navigation
// request and requests for subsequent resources on that page.
// First validate the current state.
if (this.latestHash === null) {
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
}
// Pin this client ID to the current latest version, indefinitely.
this.clientVersionMap.set(clientId, this.latestHash);
await this.sync();
// Return the latest `AppVersion`.
return this.lookupVersionByHash(this.latestHash, 'assignVersion');
}
} else {
// No client ID was associated with the request. This must be a navigation request
// for a new client. First check that the SW is accepting new clients.
if (this.state !== DriverReadyState.NORMAL) {
return null;
}
// Serve it with the latest version, and assume that the client will actually get
// associated with that version on the next request.
// First validate the current state.
if (this.latestHash === null) {
throw new Error(`Invariant violated (assignVersion): latestHash was null`);
}
// Return the latest `AppVersion`.
return this.lookupVersionByHash(this.latestHash, 'assignVersion');
}
}
/**
* Retrieve a copy of the latest manifest from the server.
*/
private async fetchLatestManifest(): Promise<Manifest> {
const res = await this.scope.fetch('/ngsw.json?ngsw-cache-bust=' + Math.random());
if (!res.ok) {
if (res.status === 404) {
await this.deleteAllCaches();
this.scope.registration.unregister();
}
throw new Error('Manifest fetch failed!');
}
this.lastUpdateCheck = this.adapter.time;
return res.json();
}
private async deleteAllCaches(): Promise<void> {
await(await this.scope.caches.keys())
.filter(key => key.startsWith('ngsw:'))
.reduce(async(previous, key) => {
await Promise.all([
previous,
this.scope.caches.delete(key),
]);
}, Promise.resolve());
}
/**
* Schedule the SW's attempt to reach a fully prefetched state for the given AppVersion
* when the SW is not busy and has connectivity. This returns a Promise which must be
* awaited, as under some conditions the AppVersion might be initialized immediately.
*/
private async scheduleInitialization(appVersion: AppVersion): Promise<void> {
const initialize = async() => {
try {
await appVersion.initializeFully();
} catch (err) {
this.versionFailed(appVersion, err);
}
};
// TODO: better logic for detecting localhost.
if (this.scope.registration.scope.indexOf('://localhost') > -1) {
return initialize();
}
this.idle.schedule(`initialization(${appVersion.manifestHash})`, initialize);
}
private versionFailed(appVersion: AppVersion, err: Error): void {
// This particular AppVersion is broken. First, find the manifest hash.
const broken =
Array.from(this.versions.entries()).find(([hash, version]) => version === appVersion);
if (broken === undefined) {
// This version is no longer in use anyway, so nobody cares.
return;
}
const brokenHash = broken[0];
// TODO: notify affected apps.
// The action taken depends on whether the broken manifest is the active (latest) or not.
// If so, the SW cannot accept new clients, but can continue to service old ones.
if (this.latestHash === brokenHash) {
// The latest manifest is broken. This means that new clients are at the mercy of the
// network, but caches continue to be valid for previous versions. This is unfortunate
// but unavoidable.
this.state = DriverReadyState.EXISTING_CLIENTS_ONLY;
this.stateMessage = `Degraded due to failed initialization: ${errorToString(err)}`;
// Cancel the binding for these clients.
Array.from(this.clientVersionMap.keys())
.forEach(clientId => this.clientVersionMap.delete(clientId));
} else {
// The current version is viable, but this older version isn't. The only possible remedy
// is to stop serving the older version and go to the network. Figure out which clients
// are affected and put them on the latest.
const affectedClients =
Array.from(this.clientVersionMap.keys())
.filter(clientId => this.clientVersionMap.get(clientId) ! === brokenHash);
// Push the affected clients onto the latest version.
affectedClients.forEach(clientId => this.clientVersionMap.set(clientId, this.latestHash !));
}
}
private async setupUpdate(manifest: Manifest, hash: string): Promise<void> {
const newVersion = new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash);
// Try to determine a version that's safe to update from.
let updateFrom: AppVersion|undefined = undefined;
// It's always safe to update from a version, even a broken one, as it will still only have
// valid resources cached. If there is no latest version, though, this update will have to
// install as a fresh version.
if (this.latestHash !== null) {
updateFrom = this.versions.get(this.latestHash);
}
// Firstly, check if the manifest version is correct.
if (manifest.configVersion !== SUPPORTED_CONFIG_VERSION) {
await this.deleteAllCaches();
this.scope.registration.unregister();
throw new Error(
`Invalid config version: expected ${SUPPORTED_CONFIG_VERSION}, got ${manifest.configVersion}.`);
}
// Cause the new version to become fully initialized. If this fails, then the version will
// not be available for use.
await newVersion.initializeFully(this);
// Install this as an active version of the app.
this.versions.set(hash, newVersion);
// Future new clients will use this hash as the latest version.
this.latestHash = hash;
await this.sync();
await this.notifyClientsAboutUpdate();
}
async checkForUpdate(): Promise<boolean> {
try {
const manifest = await this.fetchLatestManifest();
const hash = hashManifest(manifest);
// Check whether this is really an update.
if (this.versions.has(hash)) {
return false;
}
await this.setupUpdate(manifest, hash);
return true;
} catch (_) {
return false;
}
}
/**
* Synchronize the existing state to the underlying database.
*/
private async sync(): Promise<void> {
// Open up the DB table.
const table = await this.db.open('control');
// Construct a serializable map of hashes to manifests.
const manifests: ManifestMap = {};
this.versions.forEach((version, hash) => { manifests[hash] = version.manifest; });
// Construct a serializable map of client ids to version hashes.
const assignments: ClientAssignments = {};
this.clientVersionMap.forEach((hash, clientId) => { assignments[clientId] = hash; });
// Record the latest entry. Since this is a sync which is necessarily happening after
// initialization, latestHash should always be valid.
const latest: LatestEntry = {
latest: this.latestHash !,
};
// Synchronize all of these.
await Promise.all([
table.write('manifests', manifests),
table.write('assignments', assignments),
table.write('latest', latest),
]);
}
async cleanupCaches(): Promise<void> {
// Make sure internal state has been initialized before attempting to clean up caches.
await this.initialized;
// Query for all currently active clients, and list the client ids. This may skip some
// clients in the browser back-forward cache, but not much can be done about that.
const activeClients: ClientId[] =
(await this.scope.clients.matchAll()).map(client => client.id);
// A simple list of client ids that the SW has kept track of. Subtracting activeClients
// from this list will result in the set of client ids which are being tracked but are no
// longer used in the browser, and thus can be cleaned up.
const knownClients: ClientId[] = Array.from(this.clientVersionMap.keys());
// Remove clients in the clientVersionMap that are no longer active.
knownClients.filter(id => activeClients.indexOf(id) === -1)
.forEach(id => this.clientVersionMap.delete(id));
// Next, determine the set of versions which are still used. All others can be removed.
const usedVersions = new Set<string>();
this.clientVersionMap.forEach((version, _) => usedVersions.add(version));
// Collect all obsolete versions by filtering out used versions from the set of all versions.
const obsoleteVersions =
Array.from(this.versions.keys())
.filter(version => !usedVersions.has(version) && version !== this.latestHash);
// Remove all the versions which are no longer used.
await obsoleteVersions.reduce(async(previous, version) => {
// Wait for the other cleanup operations to complete.
await previous;
// Try to get past the failure of one particular version to clean up (this shouldn't happen,
// but handle it just in case).
try {
// Get ahold of the AppVersion for this particular hash.
const instance = this.versions.get(version) !;
// Delete it from the canonical map.
this.versions.delete(version);
// Clean it up.
await instance.cleanup();
} catch (e) {
// Oh well? Not much that can be done here. These caches will be removed when the SW revs
// its format version, which happens from time to time.
}
}, Promise.resolve());
// Commit all the changes to the saved state.
await this.sync();
}
/**
* Determine if a specific version of the given resource is cached anywhere within the SW,
* and fetch it if so.
*/
lookupResourceWithHash(url: string, hash: string): Promise<Response|null> {
return Array
// Scan through the set of all cached versions, valid or otherwise. It's safe to do such
// lookups even for invalid versions as the cached version of a resource will have the
// same hash regardless.
.from(this.versions.values())
// Reduce the set of versions to a single potential result. At any point along the
// reduction, if a response has already been identified, then pass it through, as no
// future operation could change the response. If no response has been found yet, keep
// checking versions until one is or until all versions have been exhausted.
.reduce(async(prev, version) => {
// First, check the previous result. If a non-null result has been found already, just
// return it.
if (await prev !== null) {
return prev;
}
// No result has been found yet. Try the next `AppVersion`.
return version.lookupResourceWithHash(url, hash);
}, Promise.resolve<Response|null>(null));
}
async lookupResourceWithoutHash(url: string): Promise<CacheState|null> {
await this.initialized;
const version = this.versions.get(this.latestHash !) !;
return version.lookupResourceWithoutHash(url);
}
async previouslyCachedResources(): Promise<string[]> {
await this.initialized;
const version = this.versions.get(this.latestHash !) !;
return version.previouslyCachedResources();
}
recentCacheStatus(url: string): Promise<UpdateCacheStatus> {
const version = this.versions.get(this.latestHash !) !;
return version.recentCacheStatus(url);
}
private mergeHashWithAppData(manifest: Manifest, hash: string): {hash: string, appData: Object} {
return {
hash,
appData: manifest.appData as Object,
};
}
async notifyClientsAboutUpdate(): Promise<void> {
await this.initialized;
const clients = await this.scope.clients.matchAll();
const next = this.versions.get(this.latestHash !) !;
await clients.reduce(async(previous, client) => {
await previous;
// Firstly, determine which version this client is on.
const version = this.clientVersionMap.get(client.id);
if (version === undefined) {
// Unmapped client - assume it's the latest.
return;
}
if (version === this.latestHash) {
// Client is already on the latest version, no need for a notification.
return;
}
const current = this.versions.get(version) !;
// Send a notice.
const notice = {
type: 'UPDATE_AVAILABLE',
current: this.mergeHashWithAppData(current.manifest, version),
available: this.mergeHashWithAppData(next.manifest, this.latestHash !),
};
client.postMessage(notice);
}, Promise.resolve());
}
async broadcast(msg: Object): Promise<void> {
const clients = await this.scope.clients.matchAll();
clients.forEach(client => { client.postMessage(msg); });
}
async debugState(): Promise<DebugState> {
return {
state: DriverReadyState[this.state],
why: this.stateMessage,
latestHash: this.latestHash,
lastUpdateCheck: this.lastUpdateCheck,
};
}
async debugVersions(): Promise<DebugVersion[]> {
// Build list of versions.
return Array.from(this.versions.keys()).map(hash => {
const version = this.versions.get(hash) !;
const clients = Array.from(this.clientVersionMap.entries())
.filter(([clientId, version]) => version === hash)
.map(([clientId, version]) => clientId);
return {
hash,
manifest: version.manifest, clients,
status: '',
};
});
}
async debugIdleState(): Promise<DebugIdleState> {
return {
queue: this.idle.taskDescriptions,
lastTrigger: this.idle.lastTrigger,
lastRun: this.idle.lastRun,
};
}
}
function errorToString(error: any): string {
if (error instanceof Error) {
return `${error.message}\n${error.stack}`;
} else {
return `${error}`;
}
}

View File

@ -0,0 +1,89 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {Adapter} from './adapter';
export interface IdleTask {
run: () => Promise<void>;
desc: string;
}
interface ScheduledRun {
cancel: boolean;
}
export class IdleScheduler {
private queue: IdleTask[] = [];
private scheduled: ScheduledRun|null = null;
empty: Promise<void> = Promise.resolve();
private emptyResolve: Function|null = null;
lastTrigger: number|null = null;
lastRun: number|null = null;
constructor(private adapter: Adapter, private threshold: number) {}
async trigger(): Promise<void> {
this.lastTrigger = this.adapter.time;
if (this.queue.length === 0) {
return;
}
if (this.scheduled !== null) {
this.scheduled.cancel = true;
}
this.scheduled = {
cancel: false,
};
await this.adapter.timeout(this.threshold);
if (this.scheduled !== null && this.scheduled.cancel) {
this.scheduled = null;
return;
}
this.scheduled = null;
await this.execute();
}
async execute(): Promise<void> {
this.lastRun = this.adapter.time;
while (this.queue.length > 0) {
const queue = this.queue.map(task => {
try {
return task.run();
} catch (e) {
// Ignore errors, for now.
return Promise.resolve();
}
});
this.queue = [];
await Promise.all(queue);
if (this.emptyResolve !== null) {
this.emptyResolve();
this.emptyResolve = null;
}
this.empty = Promise.resolve();
}
}
schedule(desc: string, run: () => Promise<void>): void {
this.queue.push({desc, run});
if (this.emptyResolve === null) {
this.empty = new Promise(resolve => { this.emptyResolve = resolve; });
}
}
get size(): number { return this.queue.length; }
get taskDescriptions(): string[] { return this.queue.map(task => task.desc); }
}

View File

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

View File

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

View File

@ -0,0 +1,148 @@
/**
* Copyright (c) 2016, Tiernan Cridland
*
* Permission to use, copy, modify, and/or distribute this software for any purpose with or without
* fee is hereby
* granted, provided that the above copyright notice and this permission notice appear in all
* copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
* SOFTWARE INCLUDING ALL
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
* SPECIAL, DIRECT,
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR
* PROFITS, WHETHER
* IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION
* WITH THE USE OR
* PERFORMANCE OF THIS SOFTWARE.
*
* Typings for Service Worker
* @author Tiernan Cridland
* @email tiernanc@gmail.com
* @license: ISC
*/
interface ExtendableEvent extends Event {
waitUntil(fn: Promise<any>): void;
}
// CacheStorage API
interface Cache {
add(request: Request): Promise<void>;
addAll(requestArray: Array<Request>): Promise<void>;
'delete'(request: Request, options?: CacheStorageOptions): Promise<boolean>;
keys(request?: Request, options?: CacheStorageOptions): Promise<Array<string>>;
match(request: Request, options?: CacheStorageOptions): Promise<Response|undefined>;
matchAll(request: Request, options?: CacheStorageOptions): Promise<Array<Response>>;
put(request: Request|string, response: Response): Promise<void>;
}
interface CacheStorage {
'delete'(cacheName: string): Promise<boolean>;
has(cacheName: string): Promise<boolean>;
keys(): Promise<Array<string>>;
match(request: Request, options?: CacheStorageOptions): Promise<Response|undefined>;
open(cacheName: string): Promise<Cache>;
}
interface CacheStorageOptions {
cacheName?: string;
ignoreMethod?: boolean;
ignoreSearch?: boolean;
ignoreVary?: boolean;
}
// Client API
declare class Client {
frameType: ClientFrameType;
id: string;
url: string;
postMessage(message: any): void;
}
interface Clients {
claim(): Promise<any>;
get(id: string): Promise<Client>;
matchAll(options?: ClientMatchOptions): Promise<Array<Client>>;
}
interface ClientMatchOptions {
includeUncontrolled?: boolean;
type?: ClientMatchTypes;
}
interface WindowClient {
focused: boolean;
visibilityState: WindowClientState;
focus(): Promise<WindowClient>;
navigate(url: string): Promise<WindowClient>;
}
type ClientFrameType = 'auxiliary' | 'top-level' | 'nested' | 'none';
type ClientMatchTypes = 'window' | 'worker' | 'sharedworker' | 'all';
type WindowClientState = 'hidden' | 'visible' | 'prerender' | 'unloaded';
// Fetch API
interface FetchEvent extends ExtendableEvent {
clientId: string|null;
request: Request;
respondWith(response: Promise<Response>|Response): Promise<Response>;
}
interface InstallEvent extends ExtendableEvent {
activeWorker: ServiceWorker;
}
interface ActivateEvent extends ExtendableEvent {}
// Notification API
interface NotificationEvent {
action: string;
notification: Notification;
}
// Push API
interface PushEvent extends ExtendableEvent {
data: PushMessageData;
}
interface PushMessageData {
arrayBuffer(): ArrayBuffer;
blob(): Blob;
json(): any;
text(): string;
}
// Sync API
interface SyncEvent extends ExtendableEvent {
lastChance: boolean;
tag: string;
}
interface ExtendableMessageEvent extends ExtendableEvent {
data: any;
source: Client|Object;
}
// ServiceWorkerGlobalScope
interface ServiceWorkerGlobalScope {
caches: CacheStorage;
clients: Clients;
registration: ServiceWorkerRegistration;
addEventListener(event: 'activate', fn: (event?: ExtendableEvent) => any): void;
addEventListener(event: 'message', fn: (event?: ExtendableMessageEvent) => any): void;
addEventListener(event: 'fetch', fn: (event?: FetchEvent) => any): void;
addEventListener(event: 'install', fn: (event?: ExtendableEvent) => any): void;
addEventListener(event: 'push', fn: (event?: PushEvent) => any): void;
addEventListener(event: 'sync', fn: (event?: SyncEvent) => any): void;
fetch(request: Request|string): Promise<Response>;
skipWaiting(): Promise<void>;
}

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

View File

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