diff --git a/packages/aws-amplify/__tests__/Common/ServiceWorker-test.ts b/packages/aws-amplify/__tests__/Common/ServiceWorker-test.ts new file mode 100644 index 00000000000..c5a915e7503 --- /dev/null +++ b/packages/aws-amplify/__tests__/Common/ServiceWorker-test.ts @@ -0,0 +1,153 @@ +import { ServiceWorker } from "../../src/Common"; + +describe('ServiceWorker test', () => { + describe('Error conditions', () => { + test('fails when serviceworker not available', () => { + const serviceWorker = new ServiceWorker(); + + return expect(serviceWorker.register()).rejects.toThrow('Service Worker not available'); + }); + test('fails when enablePush and serviceworker is not registered', () => { + const serviceWorker = new ServiceWorker(); + const enablePush = () => { serviceWorker.enablePush('publicKey'); }; + + return expect(enablePush).toThrow('Service Worker not registered'); + }); + test('fails when registering', () => { + global.navigator.serviceWorker = { + register: () => Promise.reject('an error') + }; + + const serviceWorker = new ServiceWorker(); + + return expect(serviceWorker.register()).rejects.toThrow('an error'); + }); + }); + describe('Register with status', () => { + const statuses = [ + 'installing', + 'waiting', + 'active' + ]; + statuses.forEach(status => { + test(`can register (${status})`, () => { + const bla = { [status]: { addEventListener: () => { } } }; + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + + return expect(serviceWorker.register()).resolves.toBe(bla); + }); + }); + + statuses.forEach(status => { + test(`listeners are added (${status})`, async () => { + const bla = { + [status]: { addEventListener: jest.fn() } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + await serviceWorker.register(); + + return expect(bla[status].addEventListener).toHaveBeenCalledTimes(2); + }); + }); + + }); + describe('Send messages', () => { + test('no message is sent if not registered', () => { + const bla = { + installing: { postMessage: jest.fn(), addEventListener: jest.fn() } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + + serviceWorker.send("A message"); + + return expect(bla.installing.postMessage).toHaveBeenCalledTimes(0); + }); + test('can send string message after registration', async () => { + const bla = { + installing: { postMessage: jest.fn(), addEventListener: jest.fn() } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + await serviceWorker.register(); + + serviceWorker.send("A message"); + + return expect(bla.installing.postMessage).toBeCalledWith('A message'); + }); + test('can send object message after registration', async () => { + const bla = { + installing: { postMessage: jest.fn(), addEventListener: jest.fn() } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + await serviceWorker.register(); + + serviceWorker.send({ property: 'value' }); + + return expect(bla.installing.postMessage).toBeCalledWith(JSON.stringify({ property: 'value' })); + }); + }); + describe('Enable push', () => { + test('can enable push when user is subscribed', async () => { + const subscription = {}; + + const bla = { + installing: { addEventListener: jest.fn() }, + pushManager: { + getSubscription: jest.fn().mockReturnValue(Promise.resolve(subscription)) + } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + await serviceWorker.register(); + + return expect(serviceWorker.enablePush('publickKey')).resolves.toBe(subscription); + }); + test('can enable push when user is not subscribed', async () => { + const subscription = null; + + const bla = { + installing: { addEventListener: jest.fn() }, + pushManager: { + getSubscription: jest.fn().mockReturnValue(Promise.resolve(null)), + subscribe: jest.fn().mockReturnValue(Promise.resolve(subscription)), + } + }; + + global.navigator.serviceWorker = { + register: () => Promise.resolve(bla) + }; + + const serviceWorker = new ServiceWorker(); + await serviceWorker.register(); + + return expect(serviceWorker.enablePush('publickKey')).resolves.toBe(subscription); + }); + }); +}); diff --git a/packages/aws-amplify/src/Common/Amplify.ts b/packages/aws-amplify/src/Common/Amplify.ts index fbdb04b9056..7f4ea402314 100644 --- a/packages/aws-amplify/src/Common/Amplify.ts +++ b/packages/aws-amplify/src/Common/Amplify.ts @@ -17,6 +17,7 @@ export default class Amplify { static PubSub = null; static Logger = null; + static ServiceWorker = null; static register(comp) { logger.debug('component registed in amplify', comp); diff --git a/packages/aws-amplify/src/Common/ServiceWorker/ServiceWorker.ts b/packages/aws-amplify/src/Common/ServiceWorker/ServiceWorker.ts new file mode 100644 index 00000000000..d564afed98b --- /dev/null +++ b/packages/aws-amplify/src/Common/ServiceWorker/ServiceWorker.ts @@ -0,0 +1,186 @@ +/** + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ +import { ConsoleLogger as Logger, Amplify } from '../../Common'; + +/** + * Provides a means to registering a service worker in the browser + * and communicating with it via postMessage events. + * https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/ + * + * postMessage events are currently not supported in all browsers. See: + * https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API + * + * At the minmum this class will register the service worker and listen + * and attempt to dispatch messages on state change and record analytics + * events based on the service worker lifecycle. + */ +class ServiceWorkerClass { + + // The active service worker will be set once it is registered + private _serviceWorker: ServiceWorker; + + // The service worker registration object + private _registration: ServiceWorkerRegistration; + + // The application server public key for Push + // https://web-push-codelab.glitch.me/ + private _publicKey: string; + + // push subscription + private _subscription: PushSubscription; + + // The AWS Amplify logger + private _logger: Logger = new Logger('ServiceWorker'); + + constructor() { } + + /** + * Get the currently active service worker + */ + get serviceWorker(): ServiceWorker { + return this._serviceWorker; + } + + /** + * Register the service-worker.js file in the browser + * Make sure the service-worker.js is part of the build + * for example with Angular, modify the angular-cli.json file + * and add to "assets" array "service-worker.js" + * @param {string} - (optional) Service worker file. Defaults to "/service-worker.js" + * @param {string} - (optional) The service worker scope. Defaults to "/" + * - API Doc: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register + * @returns {Promise} + * - resolve(ServiceWorkerRegistration) + * - reject(Error) + **/ + register(filePath: string = '/service-worker.js', scope: string = '/') { + this._logger.debug(`registering ${filePath}`); + this._logger.debug(`registering service worker with scope ${scope}`); + return new Promise((resolve, reject) => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register(filePath, { + 'scope': scope + }).then((registration) => { + if (registration.installing) { + this._serviceWorker = registration.installing; + } else if (registration.waiting) { + this._serviceWorker = registration.waiting; + } else if (registration.active) { + this._serviceWorker = registration.active; + } + this._registration = registration; + this._setupListeners(); + this._logger.debug(`Service Worker Registration Success: ${registration}`); + return resolve(registration); + }).catch((error) => { + this._logger.debug(`Service Worker Registration Failed ${error}`); + return reject(error); + }); + } else { + return reject(new Error('Service Worker not available')); + } + }); + } + + /** + * Enable web push notifications. If not subscribed, a new subscription will + * be created and registered. + * Test Push Server: https://web-push-codelab.glitch.me/ + * Push Server Libraries: https://github.com/web-push-libs/ + * API Doc: https://developers.google.com/web/fundamentals/codelabs/push-notifications/ + * @param publicKey + * @returns {Promise} + * - resolve(PushSubscription) + * - reject(Error) + */ + enablePush(publicKey: string) { + if (!this._registration) throw new Error('Service Worker not registered'); + this._publicKey = publicKey; + return new Promise((resolve, reject) => { + this._registration.pushManager.getSubscription() + .then((subscription) => { + if (subscription) { + this._subscription = subscription; + this._logger.debug(`User is subscribed to push: ${JSON.stringify(subscription)}`); + resolve(subscription); + } else { + this._logger.debug(`User is NOT subscribed to push`); + this._registration.pushManager.subscribe({ + 'userVisibleOnly': true, + 'applicationServerKey': this._urlB64ToUint8Array(publicKey) + }).then((subscription) => { + this._subscription = subscription; + this._logger.debug(`User subscribed: ${JSON.stringify(subscription)}`); + resolve(subscription); + }).catch((error) => { + this._logger.error(error); + }); + } + }); + }); + } + + /** + * Convert a base64 encoded string to a Uint8 array for the push server key + * @param base64String + */ + private _urlB64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + + /** + * Send a message to the service worker. The service worker needs + * to implement `self.addEventListener('message') to handle the + * message. This ***currently*** does not work in Safari or IE. + * @param {object | string} - An arbitrary JSON object or string message to send to the service worker + * - see: https://developer.mozilla.org/en-US/docs/Web/API/Transferable + * @returns {Promise} + **/ + send(message: object | string) { + if (this._serviceWorker) { + this._serviceWorker.postMessage(typeof message === 'object' ? JSON.stringify(message) : message); + } + } + + /** + * Listen for service worker state change and message events + * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/state + **/ + _setupListeners() { + this._serviceWorker.addEventListener('statechange', event => { + const currentState = this._serviceWorker.state; + this._logger.debug(`ServiceWorker statechange: ${currentState}`); + Amplify.Analytics.record('ServiceWorker', { + 'state': currentState + }); + }); + this._serviceWorker.addEventListener('message', event => { + this._logger.debug(`ServiceWorker message event: ${event}`); + }); + } +} + +export default ServiceWorkerClass; + diff --git a/packages/aws-amplify/src/Common/ServiceWorker/index.ts b/packages/aws-amplify/src/Common/ServiceWorker/index.ts new file mode 100644 index 00000000000..0274ac84c28 --- /dev/null +++ b/packages/aws-amplify/src/Common/ServiceWorker/index.ts @@ -0,0 +1,14 @@ +/** + * Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with + * the License. A copy of the License is located at + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR + * CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions + * and limitations under the License. + */ + +export { default } from './ServiceWorker'; diff --git a/packages/aws-amplify/src/Common/index.ts b/packages/aws-amplify/src/Common/index.ts index 27a7526e59e..7791cab744a 100644 --- a/packages/aws-amplify/src/Common/index.ts +++ b/packages/aws-amplify/src/Common/index.ts @@ -26,6 +26,7 @@ export { default as Parser } from './Parser'; export { FacebookOAuth, GoogleOAuth } from './OAuthHelper'; export { default as Amplify } from './Amplify'; export * from './RNComponents'; +export { default as ServiceWorker } from './ServiceWorker'; import Platform from './Platform'; export const Constants = { diff --git a/packages/aws-amplify/src/index.ts b/packages/aws-amplify/src/index.ts index 8d0ab41ec91..dc652a60265 100644 --- a/packages/aws-amplify/src/index.ts +++ b/packages/aws-amplify/src/index.ts @@ -24,7 +24,8 @@ import { ClientDevice, Signer, I18n, - Amplify + Amplify, + ServiceWorker } from './Common'; export default Amplify; @@ -37,7 +38,8 @@ Amplify.I18n = I18n; Amplify.Cache = Cache; Amplify.PubSub = PubSub; Amplify.Logger = Logger; +Amplify.ServiceWorker = ServiceWorker; -export { Auth, Analytics, Storage, API, PubSub, I18n, Logger, Hub, Cache, JS, ClientDevice, Signer }; +export { Auth, Analytics, Storage, API, PubSub, I18n, Logger, Hub, Cache, JS, ClientDevice, Signer, ServiceWorker }; export { AuthClass, AnalyticsClass, APIClass, StorageClass, AnalyticsProvider }; export { graphqlOperation };