diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml new file mode 100644 index 00000000..85fad611 --- /dev/null +++ b/.github/workflows/run-tests.yaml @@ -0,0 +1,29 @@ +name: run-tests +on: + push: + branches: + - '*' + pull_request: + branches: + - master +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: '14' + - uses: nanasess/setup-chromedriver@master + - name: "Install deps" + run: npm install + - name: "Lint" + run: npm run lint + - name: "Run unit tests" + run: npm run test:unit + - name: "Run end-to-end tests" + run: | + export DISPLAY=:99 + chromedriver --url-base=/wd/hub & + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + npm run build:cdn && npm run test:e2e diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 73017934..00000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -language: node_js -node_js: - - "10" -sudo: required -env: DISPLAY=':99.0' -dist: bionic - -jobs: - include: - - name: "Linting" - script: npm run lint - - - name: "Typescript type checks" - script: npm run test:ts - - - name: "Unit tests" - script: npm run test:unit - - - name: "End to end tests" - script: - - npm run build:cdn - - npm run test:e2e - before_script: - - CHROME_VERSION=`google-chrome --version | cut -d ' ' -f 3 | rev | cut -d '.' -f2- | rev` - - echo $CHROME_VERSION - - LATEST_CHROMEDRIVER_VERSION=`curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_VERSION"` - - echo $LATEST_CHROMEDRIVER_VERSION - - curl "https://chromedriver.storage.googleapis.com/${LATEST_CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" -O - - unzip chromedriver_linux64.zip - - sudo mv chromedriver /usr/local/bin - - sudo service xvfb start - addons: - chrome: stable - apt: - packages: - - curl - - unzip diff --git a/src/base-client.js b/src/base-client.js new file mode 100644 index 00000000..bd77bee8 --- /dev/null +++ b/src/base-client.js @@ -0,0 +1,320 @@ +import doRequest from './do-request'; +import { version as sdkVersion } from '../package.json'; +import DeviceStateStore from './device-state-store'; + +const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); +const MAX_INTEREST_LENGTH = 164; +const MAX_INTERESTS_NUM = 5000; + +export const RegistrationState = Object.freeze({ + PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', + PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: + 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', + PERMISSION_GRANTED_REGISTERED_WITH_BEAMS: + 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', + PERMISSION_DENIED: 'PERMISSION_DENIED', +}); + +/* BaseClient is an abstract client containing functionality shared between + * safari and web push clients. Platform specific classes should extend this + * class. This method expects sub classes to implement the following public + * methods: + * async start() + * async getRegistrationState() { + * async stop() { + * async clearAllState() { + * + * It also assumes that the following private methods are implemented: + * async _init() + * async _detectSubscriptionChange() + */ +export default class BaseClient { + constructor(config, platform) { + if (this.constructor === BaseClient) { + throw new Error( + 'BaseClient is abstract and should not be directly constructed.' + ); + } + + if (!config) { + throw new Error('Config object required'); + } + + const { instanceId, endpointOverride = null } = config; + if (instanceId === undefined) { + throw new Error('Instance ID is required'); + } + if (typeof instanceId !== 'string') { + throw new Error('Instance ID must be a string'); + } + if (instanceId.length === 0) { + throw new Error('Instance ID cannot be empty'); + } + + if (!('indexedDB' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (IndexedDB not supported)' + ); + } + this.instanceId = instanceId; + this._deviceId = null; + this._token = null; + this._userId = null; + this._deviceStateStore = new DeviceStateStore(instanceId); + this._endpoint = endpointOverride; // Internal only + this._platform = platform; + } + + async getDeviceId() { + await this._resolveSDKState(); + return this._ready.then(() => this._deviceId); + } + async getToken() { + await this._resolveSDKState(); + return this._ready.then(() => this._token); + } + + async getUserId() { + await this._resolveSDKState(); + return this._ready.then(() => this._userId); + } + + get _baseURL() { + if (this._endpoint !== null) { + return this._endpoint; + } + return `https://${this.instanceId}.pushnotifications.pusher.com`; + } + + _throwIfNotStarted(message) { + if (!this._deviceId) { + throw new Error( + `${message}. SDK not registered with Beams. Did you call .start?` + ); + } + } + + async _resolveSDKState() { + await this._ready; + await this._detectSubscriptionChange(); + } + + async addDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not add Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${ + this._deviceId + }/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'POST', + path, + }; + await doRequest(options); + } + + async removeDeviceInterest(interest) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not remove Device Interest'); + + validateInterestName(interest); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${ + this._deviceId + }/interests/${encodeURIComponent(interest)}`; + const options = { + method: 'DELETE', + path, + }; + await doRequest(options); + } + + async getDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not get Device Interests'); + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/interests`; + const options = { + method: 'GET', + path, + }; + return (await doRequest(options))['interests'] || []; + } + + async setDeviceInterests(interests) { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not set Device Interests'); + + if (interests === undefined || interests === null) { + throw new Error('interests argument is required'); + } + if (!Array.isArray(interests)) { + throw new Error('interests argument must be an array'); + } + if (interests.length > MAX_INTERESTS_NUM) { + throw new Error( + `Number of interests (${ + interests.length + }) exceeds maximum of ${MAX_INTERESTS_NUM}` + ); + } + for (let interest of interests) { + validateInterestName(interest); + } + + const uniqueInterests = Array.from(new Set(interests)); + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/interests`; + const options = { + method: 'PUT', + path, + body: { + interests: uniqueInterests, + }, + }; + await doRequest(options); + } + + async clearDeviceInterests() { + await this._resolveSDKState(); + this._throwIfNotStarted('Could not clear Device Interests'); + + await this.setDeviceInterests([]); + } + + async _deleteDevice() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${encodeURIComponent(this._deviceId)}`; + + const options = { method: 'DELETE', path }; + await doRequest(options); + } + + // TODO is this ever used? + /** + * Submit SDK version and browser details (via the user agent) to Pusher Beams. + */ + async _updateDeviceMetadata() { + const userAgent = window.navigator.userAgent; + const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); + const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); + + if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { + // Nothing to do + return; + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/metadata`; + + const metadata = { + sdkVersion, + }; + + const options = { method: 'PUT', path, body: metadata }; + await doRequest(options); + + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent(userAgent); + } + + async _registerDevice(device) { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}`; + const options = { method: 'POST', path, body: device }; + const response = await doRequest(options); + return response.id; + } + + async setUserId(userId, tokenProvider) { + await this._resolveSDKState(); + + if (!this._isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + const error = new Error('.start must be called before .setUserId'); + return Promise.reject(error); + } + if (typeof userId !== 'string') { + throw new Error(`User ID must be a string (was ${userId})`); + } + if (userId === '') { + throw new Error('User ID cannot be the empty string'); + } + if (this._userId !== null && this._userId !== userId) { + throw new Error('Changing the `userId` is not allowed.'); + } + + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/devices/${this._platform}/${this._deviceId}/user`; + + const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); + const options = { + method: 'PUT', + path, + headers: { + Authorization: `Bearer ${beamsAuthToken}`, + }, + }; + await doRequest(options); + + this._userId = userId; + return this._deviceStateStore.setUserId(userId); + } + + async start() { + throwNotImplementedError('start'); + } + async getRegistrationState() { + throwNotImplementedError('getRegistrationState'); + } + async stop() { + throwNotImplementedError('stop'); + } + async clearAllState() { + throwNotImplementedError('clearAllState'); + } +} + +function throwNotImplementedError(method) { + throw new Error( + `${method} not implemented on abstract BaseClient.` + + 'Instantiate either WebPushClient or SafariClient' + ); +} + +function validateInterestName(interest) { + if (interest === undefined || interest === null) { + throw new Error('Interest name is required'); + } + if (typeof interest !== 'string') { + throw new Error(`Interest ${interest} is not a string`); + } + if (!INTERESTS_REGEX.test(interest)) { + throw new Error( + `interest "${interest}" contains a forbidden character. ` + + 'Allowed characters are: ASCII upper/lower-case letters, ' + + 'numbers or one of _-=@,.;' + ); + } + if (interest.length > MAX_INTEREST_LENGTH) { + throw new Error( + `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` + ); + } +} diff --git a/src/push-notifications.js b/src/push-notifications.js index 4dc45585..06eb1e0d 100644 --- a/src/push-notifications.js +++ b/src/push-notifications.js @@ -1,540 +1,13 @@ -import doRequest from './do-request'; +import { WebPushClient } from './web-push-client'; +import { SafariClient } from './safari-client'; import TokenProvider from './token-provider'; -import DeviceStateStore from './device-state-store'; -import { version as sdkVersion } from '../package.json'; +import { RegistrationState } from './base-client'; -const INTERESTS_REGEX = new RegExp('^(_|\\-|=|@|,|\\.|;|[A-Z]|[a-z]|[0-9])*$'); -const MAX_INTEREST_LENGTH = 164; -const MAX_INTERESTS_NUM = 5000; - -const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; - -export const RegistrationState = Object.freeze({ - PERMISSION_PROMPT_REQUIRED: 'PERMISSION_PROMPT_REQUIRED', - PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS: - 'PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS', - PERMISSION_GRANTED_REGISTERED_WITH_BEAMS: - 'PERMISSION_GRANTED_REGISTERED_WITH_BEAMS', - PERMISSION_DENIED: 'PERMISSION_DENIED', -}); - -export class Client { - constructor(config) { - if (!config) { - throw new Error('Config object required'); - } - const { - instanceId, - endpointOverride = null, - serviceWorkerRegistration = null, - } = config; - - if (instanceId === undefined) { - throw new Error('Instance ID is required'); - } - if (typeof instanceId !== 'string') { - throw new Error('Instance ID must be a string'); - } - if (instanceId.length === 0) { - throw new Error('Instance ID cannot be empty'); - } - - if (!('indexedDB' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (IndexedDB not supported)' - ); - } - - if (!window.isSecureContext) { - throw new Error( - 'Pusher Beams relies on Service Workers, which only work in secure contexts. Check that your page is being served from localhost/over HTTPS' - ); - } - - if (!('serviceWorker' in navigator)) { - throw new Error( - 'Pusher Beams does not support this browser version (Service Workers not supported)' - ); - } - - if (!('PushManager' in window)) { - throw new Error( - 'Pusher Beams does not support this browser version (Web Push not supported)' - ); - } - - if (serviceWorkerRegistration) { - const serviceWorkerScope = serviceWorkerRegistration.scope; - const currentURL = window.location.href; - const scopeMatchesCurrentPage = currentURL.startsWith(serviceWorkerScope); - if (!scopeMatchesCurrentPage) { - throw new Error( - `Could not initialize Pusher web push: current page not in serviceWorkerRegistration scope (${serviceWorkerScope})` - ); - } - } - - this.instanceId = instanceId; - this._deviceId = null; - this._token = null; - this._userId = null; - this._serviceWorkerRegistration = serviceWorkerRegistration; - this._deviceStateStore = new DeviceStateStore(instanceId); - this._endpoint = endpointOverride; // Internal only - - this._ready = this._init(); - } - - async _init() { - if (this._deviceId !== null) { - return; - } - - await this._deviceStateStore.connect(); - - if (this._serviceWorkerRegistration) { - // If we have been given a service worker, wait for it to be ready - await window.navigator.serviceWorker.ready; - } else { - // Otherwise register our own one - this._serviceWorkerRegistration = await getServiceWorkerRegistration(); - } - - await this._detectSubscriptionChange(); - - this._deviceId = await this._deviceStateStore.getDeviceId(); - this._token = await this._deviceStateStore.getToken(); - this._userId = await this._deviceStateStore.getUserId(); - } - - // Ensure SDK is loaded and is consistent - async _resolveSDKState() { - await this._ready; - await this._detectSubscriptionChange(); - } - - async _detectSubscriptionChange() { - const storedToken = await this._deviceStateStore.getToken(); - const actualToken = await getWebPushToken(this._serviceWorkerRegistration); - - const pushTokenHasChanged = storedToken !== actualToken; - - if (pushTokenHasChanged) { - // The web push subscription has changed out from underneath us. - // This can happen when the user disables the web push permission - // (potentially also renabling it, thereby changing the token) - // - // This means the SDK has effectively been stopped, so we should update - // the SDK state to reflect that. - await this._deviceStateStore.clear(); - this._deviceId = null; - this._token = null; - this._userId = null; - } - } - - async getDeviceId() { - await this._resolveSDKState(); - return this._ready.then(() => this._deviceId); - } - - async getToken() { - await this._resolveSDKState(); - return this._ready.then(() => this._token); - } - - async getUserId() { - await this._resolveSDKState(); - return this._ready.then(() => this._userId); - } - - get _baseURL() { - if (this._endpoint !== null) { - return this._endpoint; - } - return `https://${this.instanceId}.pushnotifications.pusher.com`; - } - - _throwIfNotStarted(message) { - if (!this._deviceId) { - throw new Error( - `${message}. SDK not registered with Beams. Did you call .start?` - ); - } - } - - async start() { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return this; - } - - if (this._deviceId !== null) { - return this; - } - - const { vapidPublicKey: publicKey } = await this._getPublicKey(); - - // register with pushManager, get endpoint etc - const token = await this._getPushToken(publicKey); - - // get device id from errol - const deviceId = await this._registerDevice(token); - - await this._deviceStateStore.setToken(token); - await this._deviceStateStore.setDeviceId(deviceId); - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent( - window.navigator.userAgent - ); - - this._token = token; - this._deviceId = deviceId; - return this; - } - - async getRegistrationState() { - await this._resolveSDKState(); - - if (Notification.permission === 'denied') { - return RegistrationState.PERMISSION_DENIED; - } - - if (Notification.permission === 'granted' && this._deviceId !== null) { - return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; - } - - if (Notification.permission === 'granted' && this._deviceId === null) { - return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; - } - - return RegistrationState.PERMISSION_PROMPT_REQUIRED; - } - - async addDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not add Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'POST', - path, - }; - await doRequest(options); - } - - async removeDeviceInterest(interest) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not remove Device Interest'); - - validateInterestName(interest); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests/${encodeURIComponent(interest)}`; - const options = { - method: 'DELETE', - path, - }; - await doRequest(options); - } - - async getDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not get Device Interests'); - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'GET', - path, - }; - return (await doRequest(options))['interests'] || []; - } - - async setDeviceInterests(interests) { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not set Device Interests'); - - if (interests === undefined || interests === null) { - throw new Error('interests argument is required'); - } - if (!Array.isArray(interests)) { - throw new Error('interests argument must be an array'); - } - if (interests.length > MAX_INTERESTS_NUM) { - throw new Error( - `Number of interests (${ - interests.length - }) exceeds maximum of ${MAX_INTERESTS_NUM}` - ); - } - for (let interest of interests) { - validateInterestName(interest); - } - - const uniqueInterests = Array.from(new Set(interests)); - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/interests`; - const options = { - method: 'PUT', - path, - body: { - interests: uniqueInterests, - }, - }; - await doRequest(options); - } - - async clearDeviceInterests() { - await this._resolveSDKState(); - this._throwIfNotStarted('Could not clear Device Interests'); - - await this.setDeviceInterests([]); - } - - async setUserId(userId, tokenProvider) { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - const error = new Error('.start must be called before .setUserId'); - return Promise.reject(error); - } - if (typeof userId !== 'string') { - throw new Error(`User ID must be a string (was ${userId})`); - } - if (userId === '') { - throw new Error('User ID cannot be the empty string'); - } - if (this._userId !== null && this._userId !== userId) { - throw new Error('Changing the `userId` is not allowed.'); - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/user`; - - const { token: beamsAuthToken } = await tokenProvider.fetchToken(userId); - const options = { - method: 'PUT', - path, - headers: { - Authorization: `Bearer ${beamsAuthToken}`, - }, - }; - await doRequest(options); - - this._userId = userId; - return this._deviceStateStore.setUserId(userId); - } - - async stop() { - await this._resolveSDKState(); - - if (!isSupportedBrowser()) { - return; - } - - if (this._deviceId === null) { - return; - } - - await this._deleteDevice(); - await this._deviceStateStore.clear(); - this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. - - this._deviceId = null; - this._token = null; - this._userId = null; - } - - async clearAllState() { - if (!isSupportedBrowser()) { - return; - } - - await this.stop(); - await this.start(); - } - - async _getPublicKey() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/web-vapid-public-key`; - - const options = { method: 'GET', path }; - return doRequest(options); - } - - async _getPushToken(publicKey) { - try { - // The browser might already have a push subscription to different key. - // Lets clear it out first. - await this._clearPushToken(); - const sub = await this._serviceWorkerRegistration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUInt8Array(publicKey), - }); - return btoa(JSON.stringify(sub)); - } catch (e) { - return Promise.reject(e); - } - } - - async _clearPushToken() { - return navigator.serviceWorker.ready - .then(reg => reg.pushManager.getSubscription()) - .then(sub => { - if (sub) sub.unsubscribe(); - }); - } - - async _registerDevice(token) { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web`; - - const device = { - token, - metadata: { - sdkVersion, - }, - }; - - const options = { method: 'POST', path, body: device }; - const response = await doRequest(options); - return response.id; - } - - async _deleteDevice() { - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${encodeURIComponent(this._deviceId)}`; - - const options = { method: 'DELETE', path }; - await doRequest(options); - } - - /** - * Submit SDK version and browser details (via the user agent) to Pusher Beams. - */ - async _updateDeviceMetadata() { - const userAgent = window.navigator.userAgent; - const storedUserAgent = await this._deviceStateStore.getLastSeenUserAgent(); - const storedSdkVersion = await this._deviceStateStore.getLastSeenSdkVersion(); - - if (userAgent === storedUserAgent && sdkVersion === storedSdkVersion) { - // Nothing to do - return; - } - - const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( - this.instanceId - )}/devices/web/${this._deviceId}/metadata`; - - const metadata = { - sdkVersion, - }; - - const options = { method: 'PUT', path, body: metadata }; - await doRequest(options); - - await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); - await this._deviceStateStore.setLastSeenUserAgent(userAgent); - } -} - -const validateInterestName = interest => { - if (interest === undefined || interest === null) { - throw new Error('Interest name is required'); - } - if (typeof interest !== 'string') { - throw new Error(`Interest ${interest} is not a string`); - } - if (!INTERESTS_REGEX.test(interest)) { - throw new Error( - `interest "${interest}" contains a forbidden character. ` + - 'Allowed characters are: ASCII upper/lower-case letters, ' + - 'numbers or one of _-=@,.;' - ); - } - if (interest.length > MAX_INTEREST_LENGTH) { - throw new Error( - `Interest is longer than the maximum of ${MAX_INTEREST_LENGTH} chars` - ); - } -}; - -async function getServiceWorkerRegistration() { - // Check that service worker file exists - const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); - if (swStatusCode !== 200) { - throw new Error( - 'Cannot start SDK, service worker missing: No file found at /service-worker.js' - ); - } - - window.navigator.serviceWorker.register(SERVICE_WORKER_URL, { - // explicitly opting out of `importScripts` caching just in case our - // customers decides to host and serve the imported scripts and - // accidentally set `Cache-Control` to something other than `max-age=0` - updateViaCache: 'none', - }); - return window.navigator.serviceWorker.ready; -} - -function getWebPushToken(swReg) { - return swReg.pushManager - .getSubscription() - .then(sub => (!sub ? null : encodeSubscription(sub))); -} - -function encodeSubscription(sub) { - return btoa(JSON.stringify(sub)); -} - -function urlBase64ToUInt8Array(base64String) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); - const rawData = window.atob(base64); - return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); -} - -/** - * Modified from https://stackoverflow.com/questions/4565112 - */ -function isSupportedBrowser() { - const winNav = window.navigator; - const vendorName = winNav.vendor; - - const isChromium = - window.chrome !== null && typeof window.chrome !== 'undefined'; - const isOpera = winNav.userAgent.indexOf('OPR') > -1; - const isEdge = winNav.userAgent.indexOf('Edg') > -1; - const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; - - const isChrome = - isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; - - const isSupported = isChrome || isOpera || isFirefox || isEdge; - - if (!isSupported) { - console.warn( - 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' - ); +function Client(config) { + if ('safari' in window) { + return new SafariClient(config); } - return isSupported; + return new WebPushClient(config); } -export { TokenProvider }; +export { Client, RegistrationState, TokenProvider }; diff --git a/src/safari-client.js b/src/safari-client.js new file mode 100644 index 00000000..a7bc54cb --- /dev/null +++ b/src/safari-client.js @@ -0,0 +1,179 @@ +import doRequest from './do-request'; +import BaseClient from './base-client'; +import { version as sdkVersion } from '../package.json'; +import { RegistrationState } from './base-client'; + +const platform = 'safari'; + +export class SafariClient extends BaseClient { + constructor(config) { + super(config, platform); + if (!this._isSupportedBrowser()) { + throw new Error( + 'Pusher Beams does not support this Safari version (Safari Push Notifications not supported)' + ); + } + + this._ready = this._init(); + } + + async _init() { + let { websitePushId } = await this._fetchWebsitePushId(); + this._websitePushId = websitePushId; + this._serviceUrl = `${ + this._baseURL + }/safari_api/v1/instances/${encodeURIComponent(this.instanceId)}`; + + if (this._deviceId !== null) { + return; + } + + await this._deviceStateStore.connect(); + await this._detectSubscriptionChange(); + + this._deviceId = await this._deviceStateStore.getDeviceId( + this._websitePushId + ); + this._token = await this._deviceStateStore.getToken(); + this._userId = await this._deviceStateStore.getUserId(); + } + + async _detectSubscriptionChange() { + const storedToken = await this._deviceStateStore.getToken(); + const { deviceToken: actualToken } = getCurrentPermission( + this._websitePushId + ); + + const tokenHasChanged = storedToken !== actualToken; + if (tokenHasChanged) { + // The device token has changed. This could be because the user has + // rescinded permission, or because the user has restored from a backup. + // Either way we should clear out the old state + await this._deviceStateStore.clear(); + this._deviceId = null; + this._token = null; + this._userId = null; + } + } + + _requestPermission() { + // Check to see whether we've already asked for permission, if we have we + // can't ask again + let { deviceToken, permission } = getCurrentPermission(this._websitePushId); + if (permission !== 'default') { + return Promise.resolve({ deviceToken, permission }); + } + return new Promise((resolve, reject) => { + try { + window.safari.pushNotification.requestPermission( + this._serviceUrl, + this._websitePushId, + {}, + resolve + ); + } catch (e) { + reject(e); + } + }); + } + + async start() { + await this.ready; + + if (this._deviceId !== null) { + return this; + } + + let { deviceToken, permission } = await this._requestPermission(); + if (permission == 'granted') { + const deviceId = await this._registerDevice( + deviceToken, + this._websitePushId + ); + await this._deviceStateStore.setToken(deviceToken); + await this._deviceStateStore.setDeviceId(deviceId); + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent( + window.navigator.userAgent + ); + this._token = deviceToken; + this._deviceId = deviceId; + } else if (permission === 'denied') { + throw new Error('Registration failed - permission denied'); + } + return this; + } + + async getRegistrationState() { + await this._resolveSDKState(); + + const { permission } = getCurrentPermission(this._websitePushId); + + if (permission === 'denied') { + return RegistrationState.PERMISSION_DENIED; + } + + if (permission === 'granted' && this._deviceId !== null) { + return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; + } + + if (permission === 'granted' && this._deviceId === null) { + return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; + } + + return RegistrationState.PERMISSION_PROMPT_REQUIRED; + } + + async clearAllState() { + if (!this._isSupportedBrowser()) { + return; + } + + await this._deleteDevice(); + await this._deviceStateStore.clear(); + + this._deviceId = null; + this._token = null; + this._userId = null; + } + + async stop() { + await this._resolveSDKState(); + + if (!this._isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + return; + } + await this.clearAllState(); + } + + async _registerDevice(token, websitePushId) { + return await super._registerDevice({ + token, + websitePushId, + metadata: { + sdkVersion, + }, + }); + } + + _fetchWebsitePushId() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/safari-website-push-id`; + + const options = { method: 'GET', path }; + return doRequest(options); + } + + _isSupportedBrowser() { + return 'safari' in window && 'pushNotification' in window.safari; + } +} + +function getCurrentPermission(websitePushId) { + return window.safari.pushNotification.permission(websitePushId); +} diff --git a/src/web-push-client.js b/src/web-push-client.js new file mode 100644 index 00000000..94a81f78 --- /dev/null +++ b/src/web-push-client.js @@ -0,0 +1,268 @@ +import doRequest from './do-request'; +import BaseClient from './base-client'; +import { version as sdkVersion } from '../package.json'; +import { RegistrationState } from './base-client'; + +const SERVICE_WORKER_URL = `/service-worker.js?pusherBeamsWebSDKVersion=${sdkVersion}`; +const platform = 'web'; + +export class WebPushClient extends BaseClient { + constructor(config) { + super(config, platform); + + if (!window.isSecureContext) { + throw new Error( + 'Pusher Beams relies on Service Workers, which only work in secure contexts. Check that your page is being served from localhost/over HTTPS' + ); + } + + if (!('serviceWorker' in navigator)) { + throw new Error( + 'Pusher Beams does not support this browser version (Service Workers not supported)' + ); + } + + if (!('PushManager' in window)) { + throw new Error( + 'Pusher Beams does not support this browser version (Web Push not supported)' + ); + } + + const { serviceWorkerRegistration = null } = config; + + if (serviceWorkerRegistration) { + const serviceWorkerScope = serviceWorkerRegistration.scope; + const currentURL = window.location.href; + const scopeMatchesCurrentPage = currentURL.startsWith(serviceWorkerScope); + if (!scopeMatchesCurrentPage) { + throw new Error( + `Could not initialize Pusher web push: current page not in serviceWorkerRegistration scope (${serviceWorkerScope})` + ); + } + } + this._serviceWorkerRegistration = serviceWorkerRegistration; + this._ready = this._init(); + } + + async _init() { + if (this._deviceId !== null) { + return; + } + + await this._deviceStateStore.connect(); + + if (this._serviceWorkerRegistration) { + // If we have been given a service worker, wait for it to be ready + await window.navigator.serviceWorker.ready; + } else { + // Otherwise register our own one + this._serviceWorkerRegistration = await getServiceWorkerRegistration(); + } + + await this._detectSubscriptionChange(); + + this._deviceId = await this._deviceStateStore.getDeviceId(); + this._token = await this._deviceStateStore.getToken(); + this._userId = await this._deviceStateStore.getUserId(); + } + + async _detectSubscriptionChange() { + const storedToken = await this._deviceStateStore.getToken(); + const actualToken = await getWebPushToken(this._serviceWorkerRegistration); + + const pushTokenHasChanged = storedToken !== actualToken; + + if (pushTokenHasChanged) { + // The web push subscription has changed out from underneath us. + // This can happen when the user disables the web push permission + // (potentially also renabling it, thereby changing the token) + // + // This means the SDK has effectively been stopped, so we should update + // the SDK state to reflect that. + await this._deviceStateStore.clear(); + this._deviceId = null; + this._token = null; + this._userId = null; + } + } + + async start() { + await this._resolveSDKState(); + + if (!this._isSupportedBrowser()) { + return this; + } + + if (this._deviceId !== null) { + return this; + } + + const { vapidPublicKey: publicKey } = await this._getPublicKey(); + + // register with pushManager, get endpoint etc + const token = await this._getPushToken(publicKey); + + // get device id from errol + const deviceId = await this._registerDevice(token); + + await this._deviceStateStore.setToken(token); + await this._deviceStateStore.setDeviceId(deviceId); + await this._deviceStateStore.setLastSeenSdkVersion(sdkVersion); + await this._deviceStateStore.setLastSeenUserAgent( + window.navigator.userAgent + ); + + this._token = token; + this._deviceId = deviceId; + return this; + } + + async getRegistrationState() { + await this._resolveSDKState(); + + if (Notification.permission === 'denied') { + return RegistrationState.PERMISSION_DENIED; + } + + if (Notification.permission === 'granted' && this._deviceId !== null) { + return RegistrationState.PERMISSION_GRANTED_REGISTERED_WITH_BEAMS; + } + + if (Notification.permission === 'granted' && this._deviceId === null) { + return RegistrationState.PERMISSION_GRANTED_NOT_REGISTERED_WITH_BEAMS; + } + + return RegistrationState.PERMISSION_PROMPT_REQUIRED; + } + + async stop() { + await this._resolveSDKState(); + + if (!this._isSupportedBrowser()) { + return; + } + + if (this._deviceId === null) { + return; + } + + await this._deleteDevice(); + await this._deviceStateStore.clear(); + this._clearPushToken().catch(() => {}); // Not awaiting this, best effort. + + this._deviceId = null; + this._token = null; + this._userId = null; + } + + async clearAllState() { + if (!this._isSupportedBrowser()) { + return; + } + + await this.stop(); + await this.start(); + } + + async _getPublicKey() { + const path = `${this._baseURL}/device_api/v1/instances/${encodeURIComponent( + this.instanceId + )}/web-vapid-public-key`; + + const options = { method: 'GET', path }; + return doRequest(options); + } + + async _getPushToken(publicKey) { + try { + // The browser might already have a push subscription to different key. + // Lets clear it out first. + await this._clearPushToken(); + const sub = await this._serviceWorkerRegistration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUInt8Array(publicKey), + }); + return btoa(JSON.stringify(sub)); + } catch (e) { + return Promise.reject(e); + } + } + + async _clearPushToken() { + return navigator.serviceWorker.ready + .then(reg => reg.pushManager.getSubscription()) + .then(sub => { + if (sub) sub.unsubscribe(); + }); + } + + async _registerDevice(token) { + return await super._registerDevice({ + token, + metadata: { + sdkVersion, + }, + }); + } + + /** + * Modified from https://stackoverflow.com/questions/4565112 + */ + _isSupportedBrowser() { + const winNav = window.navigator; + const vendorName = winNav.vendor; + + const isChromium = + window.chrome !== null && typeof window.chrome !== 'undefined'; + const isOpera = winNav.userAgent.indexOf('OPR') > -1; + const isEdge = winNav.userAgent.indexOf('Edg') > -1; + const isFirefox = winNav.userAgent.indexOf('Firefox') > -1; + + const isChrome = + isChromium && vendorName === 'Google Inc.' && !isEdge && !isOpera; + + const isSupported = isChrome || isOpera || isFirefox || isEdge; + + if (!isSupported) { + console.warn( + 'Pusher Web Push Notifications supports Chrome, Firefox, Edge and Opera.' + ); + } + return isSupported; + } +} + +async function getServiceWorkerRegistration() { + // Check that service worker file exists + const { status: swStatusCode } = await fetch(SERVICE_WORKER_URL); + if (swStatusCode !== 200) { + throw new Error( + 'Cannot start SDK, service worker missing: No file found at /service-worker.js' + ); + } + + window.navigator.serviceWorker.register(SERVICE_WORKER_URL, { + // explicitly opting out of `importScripts` caching just in case our + // customers decides to host and serve the imported scripts and + // accidentally set `Cache-Control` to something other than `max-age=0` + updateViaCache: 'none', + }); + return window.navigator.serviceWorker.ready; +} + +function getWebPushToken(swReg) { + return swReg.pushManager + .getSubscription() + .then(sub => (!sub ? null : encodeSubscription(sub))); +} + +function encodeSubscription(sub) { + return btoa(JSON.stringify(sub)); +} + +function urlBase64ToUInt8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map(char => char.charCodeAt(0))); +}