angular-docs-cn/packages/service-worker/worker/src/driver.ts

1009 lines
39 KiB
TypeScript

/**
* @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 {CacheState, DebugIdleState, DebugState, DebugVersion, Debuggable, UpdateCacheStatus, UpdateSource} from './api';
import {AppVersion} from './app-version';
import {Database} from './database';
import {DebugHandler} from './debug';
import {errorToString} from './error';
import {IdleScheduler} from './idle';
import {Manifest, ManifestHash, hashManifest} from './manifest';
import {MsgAny, isMsgActivateUpdate, isMsgCheckForUpdates} from './msg';
type ClientId = string;
type ManifestMap = {
[hash: string]: Manifest
};
type ClientAssignments = {
[id: string]: ManifestHash
};
const IDLE_THRESHOLD = 5000;
const SUPPORTED_CONFIG_VERSION = 1;
const NOTIFICATION_OPTION_NAMES = [
'actions', 'badge', 'body', 'dir', 'icon', 'lang', 'renotify', 'requireInteraction', 'tag',
'vibrate', 'data'
];
interface LatestEntry {
latest: string;
}
export 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.
*/
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;
/**
* Whether there is a check for updates currently scheduled due to navigation.
*/
private scheduledNavUpdateCheck: boolean = false;
/**
* Keep track of whether we have logged an invalid `only-if-cached` request.
* (See `.onFetch()` for details.)
*/
private loggedInvalidOnlyIfCachedRequest: boolean = false;
/**
* 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) {
// Set up all the event handlers that the SW needs.
// The install event is triggered when the service worker is first installed.
this.scope.addEventListener('install', (event) => {
// SW code updates are separate from application updates, so code updates are
// almost as straightforward as restarting the SW. Because of this, it's always
// safe to skip waiting until application tabs are closed, and activate the new
// SW version immediately.
event !.waitUntil(this.scope.skipWaiting());
});
// The activate event is triggered when this version of the service worker is
// first activated.
this.scope.addEventListener('activate', (event) => {
// As above, it's safe to take over from existing clients immediately, since
// the new SW version will continue to serve the old application.
event !.waitUntil(this.scope.clients.claim());
// Rather than wait for the first fetch event, which may not arrive until
// the next time the application is loaded, the SW takes advantage of the
// activation event to schedule initialization. However, if this were run
// in the context of the 'activate' event, waitUntil() here would cause fetch
// events to block until initialization completed. Thus, the SW does a
// postMessage() to itself, to schedule a new event loop iteration with an
// entirely separate event context. The SW will be kept alive by waitUntil()
// within that separate context while initialization proceeds, while at the
// same time the activation event is allowed to resolve and traffic starts
// being served.
if (this.scope.registration.active !== null) {
this.scope.registration.active.postMessage({action: 'INITIALIZE'});
}
});
// Handle the fetch, message, and push events.
this.scope.addEventListener('fetch', (event) => this.onFetch(event !));
this.scope.addEventListener('message', (event) => this.onMessage(event !));
this.scope.addEventListener('push', (event) => this.onPush(event !));
// The debugger generates debug pages in response to debugging requests.
this.debugger = new DebugHandler(this, this.adapter);
// The IdleScheduler will execute idle tasks after a given delay.
this.idle = new IdleScheduler(this.adapter, IDLE_THRESHOLD, this.debugger);
}
/**
* The handler for fetch events.
*
* This is the transition point between the synchronous event handler and the
* asynchronous execution that eventually resolves for respondWith() and waitUntil().
*/
private onFetch(event: FetchEvent): void {
const req = event.request;
// The only thing that is served unconditionally is the debug page.
if (this.adapter.parseUrl(req.url, this.scope.registration.scope).path === '/ngsw/state') {
// Allow the debugger to handle the request, but don't affect SW state in any
// other way.
event.respondWith(this.debugger.handleFetch(req));
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;
}
// When opening DevTools in Chrome, a request is made for the current URL (and possibly related
// resources, e.g. scripts) with `cache: 'only-if-cached'` and `mode: 'no-cors'`. These request
// will eventually fail, because `only-if-cached` is only allowed to be used with
// `mode: 'same-origin'`.
// This is likely a bug in Chrome DevTools. Avoid handling such requests.
// (See also https://github.com/angular/angular/issues/22362.)
// TODO(gkalpak): Remove once no longer necessary (i.e. fixed in Chrome DevTools).
if ((req.cache as string) === 'only-if-cached' && req.mode !== 'same-origin') {
// Log the incident only the first time it happens, to avoid spamming the logs.
if (!this.loggedInvalidOnlyIfCachedRequest) {
this.loggedInvalidOnlyIfCachedRequest = true;
this.debugger.log(
`Ignoring invalid request: 'only-if-cached' can be set only with 'same-origin' mode`,
`Driver.fetch(${req.url}, cache: ${req.cache}, mode: ${req.mode})`);
}
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));
}
/**
* The handler for message events.
*/
private onMessage(event: ExtendableMessageEvent): void {
// Ignore message events when the SW is in safe mode, for now.
if (this.state === DriverReadyState.SAFE_MODE) {
return;
}
// If the message doesn't have the expected signature, ignore it.
const data = event.data;
if (!data || !data.action) {
return;
}
// Initialization is the only event which is sent directly from the SW to itself,
// and thus `event.source` is not a Client. Handle it here, before the check
// for Client sources.
if (data.action === 'INITIALIZE') {
// Only initialize if not already initialized (or initializing).
if (this.initialized === null) {
// Initialize the SW.
this.initialized = this.initialize();
// Wait until initialization is properly scheduled, then trigger idle
// events to allow it to complete (assuming the SW is idle).
event.waitUntil((async() => {
await this.initialized;
await this.idle.trigger();
})());
}
return;
}
// Only messages from true clients are accepted past this point (this is essentially
// a typecast).
if (!this.adapter.isClient(event.source)) {
return;
}
// Handle the message and keep the SW alive until it's handled.
event.waitUntil(this.handleMessage(data, event.source));
}
private onPush(msg: PushEvent): void {
// Push notifications without data have no effect.
if (!msg.data) {
return;
}
// Handle the push and keep the SW alive until it's handled.
msg.waitUntil(this.handlePush(msg.data.json()));
}
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> {
await 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]);
await 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 sync the mapping to disk.
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.safeFetch(event.request);
}
// On navigation requests, check for new updates.
if (event.request.mode === 'navigate' && !this.scheduledNavUpdateCheck) {
this.scheduledNavUpdateCheck = true;
this.idle.schedule('check-updates-on-navigation', async() => {
this.scheduledNavUpdateCheck = false;
await this.checkForUpdate();
});
}
// 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.safeFetch(event.request);
}
let res: Response|null = null;
try {
// Handle the request. First try the AppVersion. If that doesn't work, fall back on the
// network.
res = await appVersion.handleFetch(event.request, event);
} catch (err) {
if (err.isCritical) {
// Something went wrong with the activation of this version.
await this.versionFailed(appVersion, err, this.latestHash === appVersion.manifestHash);
event.waitUntil(this.idle.trigger());
return this.safeFetch(event.request);
}
throw err;
}
// 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.safeFetch(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();
try {
await this.cleanupCaches();
} catch (err) {
// Nothing to do - cleanup failed. Just log it.
this.debugger.log(err, 'cleanupCaches @ init post-load');
}
});
} 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));
}
});
// Map each client ID to its associated hash. Along the way, verify that the hash
// is still valid for that client 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)) {
this.clientVersionMap.set(clientId, hash);
} else {
this.clientVersionMap.set(clientId, latest.latest);
this.debugger.log(
`Unknown version ${hash} mapped for client ${clientId}, using latest instead`,
`initialize: map assignments`);
}
});
// 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`);
}
// Finally, 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.
// If any of these initializations fail, versionFailed() will be called either
// synchronously or asynchronously to handle the failure and re-map clients.
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) !, this.latestHash === hash);
} catch (err) {
this.debugger.log(err, `initialize: schedule init of ${hash}`);
return false;
}
}));
}
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 (non empty) client ID. If it does, the version may
// already be associated.
const clientId = event.clientId;
if (clientId) {
// Check if there is an assigned client id.
if (this.clientVersionMap.has(clientId)) {
// There is an assignment for this client already.
const hash = this.clientVersionMap.get(clientId) !;
let appVersion = this.lookupVersionByHash(hash, 'assignVersion');
// 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 &&
appVersion.isNavigationRequest(event.request)) {
// 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);
appVersion = this.lookupVersionByHash(this.latestHash, 'assignVersion');
}
// TODO: make sure the version is valid.
return appVersion;
} 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.
* Return `null` if `ignoreOfflineError` is true (default: false) and the server or client are
* offline (detected as response status 504).
*/
private async fetchLatestManifest(ignoreOfflineError?: false): Promise<Manifest>;
private async fetchLatestManifest(ignoreOfflineError: true): Promise<Manifest|null>;
private async fetchLatestManifest(ignoreOfflineError = false): Promise<Manifest|null> {
const res =
await this.safeFetch(this.adapter.newRequest('ngsw.json?ngsw-cache-bust=' + Math.random()));
if (!res.ok) {
if (res.status === 404) {
await this.deleteAllCaches();
await this.scope.registration.unregister();
} else if (res.status === 504 && ignoreOfflineError) {
return null;
}
throw new Error(`Manifest fetch failed! (status: ${res.status})`);
}
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, latest: boolean): Promise<void> {
const initialize = async() => {
try {
await appVersion.initializeFully();
} catch (err) {
this.debugger.log(err, `initializeFully for ${appVersion.manifestHash}`);
await this.versionFailed(appVersion, err, latest);
}
};
// TODO: better logic for detecting localhost.
if (this.scope.registration.scope.indexOf('://localhost') > -1) {
return initialize();
}
this.idle.schedule(`initialization(${appVersion.manifestHash})`, initialize);
}
private async versionFailed(appVersion: AppVersion, err: Error, latest: boolean): Promise<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 || latest) {
// 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: ${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 !));
}
try {
await this.sync();
} catch (err2) {
// We are already in a bad state. No need to make things worse.
// Just log the error and move on.
this.debugger.log(err2, `Driver.versionFailed(${err.message || err})`);
}
}
private async setupUpdate(manifest: Manifest, hash: string): Promise<void> {
const newVersion = new AppVersion(this.scope, this.adapter, this.db, this.idle, manifest, hash);
// Firstly, check if the manifest version is correct.
if (manifest.configVersion !== SUPPORTED_CONFIG_VERSION) {
await this.deleteAllCaches();
await 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> {
let hash: string = '(unknown)';
try {
const manifest = await this.fetchLatestManifest(true);
if (manifest === null) {
// Client or server offline. Unable to check for updates at this time.
// Continue to service clients (existing and new).
this.debugger.log('Check for update aborted. (Client or server offline.)');
return false;
}
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 (err) {
this.debugger.log(err, `Error occurred while updating to manifest ${hash}`);
this.state = DriverReadyState.EXISTING_CLIENTS_ONLY;
this.stateMessage = `Degraded due to failed initialization: ${errorToString(err)}`;
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> {
// 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 (err) {
// 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.
this.debugger.log(err, `cleanupCaches - cleanup ${version}`);
}
}, 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,
};
}
async safeFetch(req: Request): Promise<Response> {
try {
return await this.scope.fetch(req);
} catch (err) {
this.debugger.log(err, `Driver.fetch(${req.url})`);
return this.adapter.newResponse(null, {
status: 504,
statusText: 'Gateway Timeout',
});
}
}
}