Alex Rickabaugh b9a91a5e74 fix(service-worker): don't crash if SW not supported
Currently a bug exists where attempting to inject SwPush crashes the
application if Service Workers are unsupported. This happens because
SwPush doesn't properly detect that navigator.serviceWorker isn't
set.

This change ensures that all passive observation of SwPush and
SwUpdate doesn't cause crashes, and that calling methods to perform
actions on them results in rejected Promises. It's up to applications
to detect when those services are not available, and refrain from
attempting to use them.

To that end, this change also adds an `isSupported` getter to both
services, so users don't have to rely on feature detection directly
with browser APIs. Currently this simply detects whether the SW API
is present, but in the future it will be expanded to detect whether
a particular browser supports specific APIs (such as push
notifications, for example).
2017-12-01 14:18:16 -08:00

181 lines
5.5 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 {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
import {Observable} from 'rxjs/Observable';
import {ConnectableObservable} from 'rxjs/observable/ConnectableObservable';
import {concat as obs_concat} from 'rxjs/observable/concat';
import {defer as obs_defer} from 'rxjs/observable/defer';
import {fromEvent as obs_fromEvent} from 'rxjs/observable/fromEvent';
import {of as obs_of} from 'rxjs/observable/of';
import {_throw as obs_throw} from 'rxjs/observable/throw';
import {_do as op_do} from 'rxjs/operator/do';
import {filter as op_filter} from 'rxjs/operator/filter';
import {map as op_map} from 'rxjs/operator/map';
import {publish as op_publish} from 'rxjs/operator/publish';
import {startWith as op_startWith} from 'rxjs/operator/startWith';
import {switchMap as op_switchMap} from 'rxjs/operator/switchMap';
import {take as op_take} from 'rxjs/operator/take';
import {toPromise as op_toPromise} from 'rxjs/operator/toPromise';
export const ERR_SW_NOT_SUPPORTED = 'Service workers are disabled or not supported by this browser';
export interface Version {
hash: string;
appData?: Object;
}
/**
* @experimental
*/
export interface UpdateAvailableEvent {
type: 'UPDATE_AVAILABLE';
current: Version;
available: Version;
}
/**
* @experimental
*/
export interface UpdateActivatedEvent {
type: 'UPDATE_ACTIVATED';
previous?: Version;
current: Version;
}
export type IncomingEvent = UpdateAvailableEvent | UpdateActivatedEvent;
interface TypedEvent {
type: string;
}
interface StatusEvent {
type: 'STATUS';
nonce: number;
status: boolean;
error?: string;
}
function errorObservable(message: string): Observable<any> {
return obs_defer(() => obs_throw(new Error(message)));
}
/**
* @experimental
*/
export class NgswCommChannel {
/**
* @internal
*/
readonly worker: Observable<ServiceWorker>;
/**
* @internal
*/
readonly registration: Observable<ServiceWorkerRegistration>;
/**
* @internal
*/
readonly events: Observable<IncomingEvent>;
constructor(private serviceWorker: ServiceWorkerContainer|undefined) {
if (!serviceWorker) {
this.worker = this.events = this.registration = errorObservable(ERR_SW_NOT_SUPPORTED);
} else {
const controllerChangeEvents =
<Observable<any>>(obs_fromEvent(serviceWorker, 'controllerchange'));
const controllerChanges = <Observable<ServiceWorker|null>>(
op_map.call(controllerChangeEvents, () => serviceWorker.controller));
const currentController =
<Observable<ServiceWorker|null>>(obs_defer(() => obs_of(serviceWorker.controller)));
const controllerWithChanges =
<Observable<ServiceWorker|null>>(obs_concat(currentController, controllerChanges));
this.worker = <Observable<ServiceWorker>>(
op_filter.call(controllerWithChanges, (c: ServiceWorker) => !!c));
this.registration = <Observable<ServiceWorkerRegistration>>(
op_switchMap.call(this.worker, () => serviceWorker.getRegistration()));
const rawEvents = obs_fromEvent(serviceWorker, 'message');
const rawEventPayload =
<Observable<Object>>(op_map.call(rawEvents, (event: MessageEvent) => event.data));
const eventsUnconnected = <Observable<IncomingEvent>>(
op_filter.call(rawEventPayload, (event: Object) => !!event && !!(event as any)['type']));
const events = <ConnectableObservable<IncomingEvent>>(op_publish.call(eventsUnconnected));
this.events = events;
events.connect();
}
}
/**
* @internal
*/
postMessage(action: string, payload: Object): Promise<void> {
const worker = op_take.call(this.worker, 1);
const sideEffect = op_do.call(worker, (sw: ServiceWorker) => {
sw.postMessage({
action, ...payload,
});
});
return <Promise<void>>(op_toPromise.call(sideEffect).then(() => undefined));
}
/**
* @internal
*/
postMessageWithStatus(type: string, payload: Object, nonce: number): Promise<void> {
const waitForStatus = this.waitForStatus(nonce);
const postMessage = this.postMessage(type, payload);
return Promise.all([waitForStatus, postMessage]).then(() => undefined);
}
/**
* @internal
*/
generateNonce(): number { return Math.round(Math.random() * 10000000); }
/**
* @internal
*/
eventsOfType<T>(type: string): Observable<T> {
return <Observable<T>>(
op_filter.call(this.events, (event: T & TypedEvent) => { return event.type === type; }));
}
/**
* @internal
*/
nextEventOfType<T>(type: string): Observable<T> {
return <Observable<T>>(op_take.call(this.eventsOfType(type), 1));
}
/**
* @internal
*/
waitForStatus(nonce: number): Promise<void> {
const statusEventsWithNonce = <Observable<StatusEvent>>(
op_filter.call(this.eventsOfType('STATUS'), (event: StatusEvent) => event.nonce === nonce));
const singleStatusEvent = <Observable<StatusEvent>>(op_take.call(statusEventsWithNonce, 1));
const mapErrorAndValue =
<Observable<void>>(op_map.call(singleStatusEvent, (event: StatusEvent) => {
if (event.status) {
return undefined;
}
throw new Error(event.error !);
}));
return op_toPromise.call(mapErrorAndValue);
}
get isEnabled(): boolean { return !!this.serviceWorker; }
}