Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Safari client spike #75

Draft
wants to merge 34 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4f85ce1
First pass at github actions
Feb 17, 2021
278ed93
start chromedriver and Xvfb
Feb 17, 2021
8a8a71e
Remove .travis.yml
Feb 17, 2021
57a8e37
Split library into web and safari clients
Jan 13, 2021
4179524
Split common methods into base client
Jan 19, 2021
6f7aaac
Move register device method to base client
Jan 22, 2021
1df7da9
add some TODOs
Jan 22, 2021
f32dce6
Add missing brace
Feb 17, 2021
280ae74
token -> deviceToken
Feb 17, 2021
ab70466
fix linter errors
Feb 18, 2021
d672b11
fix accidental renaming token->deviceToken
Feb 18, 2021
fdb1cb5
Split register device methods
Feb 18, 2021
61ee849
Add dummy request for websitePushID
Feb 18, 2021
d5b7483
dummy method for requesting websitePushId
Feb 18, 2021
2bf668b
Move shared config into base client
Feb 19, 2021
3721fec
Move isSupportedBrowser to subclass
Feb 19, 2021
9a9a4b7
Move setUserId to base class
Feb 19, 2021
a0cc09e
Add not implemented methods on abstract class
Feb 19, 2021
f29633d
Add comment about base client
Feb 19, 2021
31f8c18
Merge pull request #83 from pusher/website-push-id
Feb 24, 2021
28baf12
Merge pull request #82 from pusher/update-registration
Feb 24, 2021
e7103d1
Merge pull request #84 from pusher/consolidate-config
Feb 24, 2021
1ffd38b
Merge pull request #85 from pusher/not-implemented-methods
Feb 24, 2021
8a69890
Remove pointless getPermission function
Feb 24, 2021
8477e52
Handle state updating on init
Feb 24, 2021
8a864e6
Implement a clearAllState method
Feb 24, 2021
bea8c12
Fetch website push ID from beams
Feb 26, 2021
5cccdcc
Replace getPermission with getCurrentPermission
Mar 3, 2021
7068dd8
Merge pull request #87 from pusher/clear-all-state
Mar 3, 2021
8dfed8c
Merge pull request #88 from pusher/fetch-website-push-id
Mar 3, 2021
993766c
Use real URL instead of hardcoded localhost
Mar 4, 2021
2619770
Bug fixes after testing
Mar 22, 2021
bf99229
Throw error if permission denied
Mar 23, 2021
a93b9c8
Merge pull request #91 from pusher/safari-bug-fixes
TomKemp Mar 24, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
@@ -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
37 changes: 0 additions & 37 deletions .travis.yml

This file was deleted.

320 changes: 320 additions & 0 deletions src/base-client.js
Original file line number Diff line number Diff line change
@@ -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`
);
}
}
Loading