Skip to content

Commit

Permalink
feat: web push
Browse files Browse the repository at this point in the history
  • Loading branch information
owenpearson committed Jun 24, 2024
1 parent 59ce31b commit d532b27
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 15 deletions.
7 changes: 6 additions & 1 deletion src/common/lib/types/devicedetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@ export enum DevicePlatform {

type DevicePushState = 'ACTIVE' | 'FAILING' | 'FAILED';

interface WebPushEncryptionKey {
p256dh: string;
auth: string;
}

interface WebPushRecipient {
transportType: 'web';
targetUrl: string;
encryptionKey: string;
encryptionKey: WebPushEncryptionKey;
}

interface PushChannelRecipient {
Expand Down
7 changes: 7 additions & 0 deletions src/platform/web/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { IPlatformConfig } from '../../common/types/IPlatformConfig';
import * as Utils from 'common/lib/util/utils';
import { DeviceFormFactor, DevicePlatform } from 'common/lib/types/devicedetails';
import webstorage from './lib/util/webstorage';

// Workaround for salesforce lightning locker compat
const globalObject = Utils.getGlobalObject();
Expand Down Expand Up @@ -79,6 +81,11 @@ const Config: IPlatformConfig = {
return byteArray.buffer;
},
isWebworker: isWebWorkerContext(),
push: {
platform: DevicePlatform.Browser,
formFactor: DeviceFormFactor.Desktop,
storage: webstorage,
},
};

export default Config;
89 changes: 89 additions & 0 deletions src/plugins/push/getW3CDeviceDetails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ActivationStateMachine } from 'plugins/push/pushactivation';

function toBase64Url(arrayBuffer: ArrayBuffer) {
const buffer = new Uint8Array(arrayBuffer.slice(0, arrayBuffer.byteLength));
return btoa(String.fromCharCode.apply(null, Array.from(buffer)));
}

function urlBase64ToBase64(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
return base64;
}

function base64ToUint8Array(base64String: string) {
const rawData = window.atob(base64String);
const rawDataChars = [];
for (let i = 0; i < rawData.length; i++) {
rawDataChars.push(rawData[i].charCodeAt(0));
}
return Uint8Array.from(rawDataChars);
}

export async function getW3CPushDeviceDetails(machine: ActivationStateMachine) {
const GettingPushDeviceDetailsFailed = machine.GettingPushDeviceDetailsFailed;
const GotPushDeviceDetails = machine.GotPushDeviceDetails;
const { ErrorInfo, Defaults } = machine.client;

const permission = await Notification.requestPermission();

if (permission !== 'granted') {
machine.handleEvent(
new GettingPushDeviceDetailsFailed(new ErrorInfo('User denied permission to send notifications', 400, 40000)),
);
return;
}

const swUrl = machine.client.options.pushServiceWorkerUrl;
if (!swUrl) {
machine.handleEvent(
new GettingPushDeviceDetailsFailed(new ErrorInfo('Missing ClientOptions.pushServiceWorkerUrl', 400, 40000)),
);
return;
}

try {
const worker = await navigator.serviceWorker.register(swUrl);

machine._pushManager = worker.pushManager;

const headers = Defaults.defaultGetHeaders(machine.client.options, { format: 'text' });
const appServerKey = (
await machine.client.rest.Resource.get(machine.client, '/push/publicVapidKey', headers, {}, null, true)
).body as string;

if (!worker.active) {
await navigator.serviceWorker.ready;
}

const subscription = await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: base64ToUint8Array(urlBase64ToBase64(appServerKey)),
});

const endpoint = subscription.endpoint;

const [p256dh, auth] = [subscription.getKey('p256dh'), subscription.getKey('auth')];

if (!p256dh || !auth) {
throw new ErrorInfo('Public key not found', 50000, 500);
}

const device = machine.client.device;
device.push.recipient = {
transportType: 'web',
targetUrl: btoa(endpoint),
encryptionKey: {
p256dh: toBase64Url(p256dh),
auth: toBase64Url(auth),
},
};
device.persist();

machine.handleEvent(new GotPushDeviceDetails());
} catch (err) {
machine.handleEvent(
new GettingPushDeviceDetailsFailed(new ErrorInfo('Failed to register service worker', 50000, 500, err as Error)),
);
}
}
3 changes: 3 additions & 0 deletions src/plugins/push/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import PushChannel from './pushchannel';
import { getW3CPushDeviceDetails } from './getW3CDeviceDetails';
import { ActivationStateMachine, CalledActivate, CalledDeactivate, localDeviceFactory } from './pushactivation';

export {
Expand All @@ -7,6 +8,7 @@ export {
CalledActivate,
CalledDeactivate,
PushChannel,
getW3CPushDeviceDetails,
};

export default {
Expand All @@ -15,4 +17,5 @@ export default {
CalledActivate,
CalledDeactivate,
PushChannel,
getW3CPushDeviceDetails,
};
22 changes: 10 additions & 12 deletions src/plugins/push/pushactivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export class ActivationStateMachine {
updateFailedCallback?: ErrCallback;

// Used for testing
pushManager?: PushManager;
_pushManager?: PushManager;

// exported for testing
GettingPushDeviceDetailsFailed = GettingPushDeviceDetailsFailed;
Expand Down Expand Up @@ -481,18 +481,16 @@ class NotActivated extends ActivationState {

if (device.push.recipient) {
machine.pendingEvents.push(new GotPushDeviceDetails());
} else if (machine.pushConfig.getPushDeviceDetails) {
machine.pushConfig.getPushDeviceDetails?.(machine);
} else if (machine.pushConfig.platform === DevicePlatform.Browser) {
getW3CPushDeviceDetails(machine);
} else {
if (machine.pushConfig.getPushDeviceDetails) {
machine.pushConfig.getPushDeviceDetails?.(machine);
} else if (machine.pushConfig.platform === DevicePlatform.Browser) {
getW3CPushDeviceDetails(machine);
} else {
machine.handleEvent(
new GettingPushDeviceDetailsFailed(
new machine.client.ErrorInfo('No available implementation to get push device details', 50000, 500),
),
);
}
machine.handleEvent(
new GettingPushDeviceDetailsFailed(
new machine.client.ErrorInfo('No available implementation to get push device details', 50000, 500),
),
);
}

return new WaitingForPushDeviceDetails();
Expand Down
95 changes: 95 additions & 0 deletions test/browser/push.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use strict';

define(['ably', 'shared_helper', 'chai', 'push'], function (Ably, helper, chai, PushPlugin) {
const expect = chai.expect;
const whenPromiseSettles = helper.whenPromiseSettles;
const swUrl = '/push_sw.js';
let rest;

const persistKeys = {
deviceId: 'ably.push.deviceId',
deviceSecret: 'ably.push.deviceSecret',
deviceIdentityToken: 'ably.push.deviceIdentityToken',
pushRecipient: 'ably.push.pushRecipient',
activationState: 'ably.push.activationState',
};

const messageChannel = new MessageChannel();

/**
* These tests don't work in CI for various reasons (below) but should work when running locally via `npm run test:webserver`, provided
* that you have enabled notification permissions for the origin serving the test ui.
*
* chromium, firefox - don't support push notifications in incognito
* webkit - doesn't have a way to launch programatically with notification permissions granted
*/
if (Notification.permission === 'granted') {
describe('browser/push', function () {
this.timeout(60 * 1000);

before(function (done) {
helper.setupApp(function () {
rest = helper.AblyRest({
pushServiceWorkerUrl: swUrl,
plugins: { Push: PushPlugin },
});
done();
});
});

beforeEach(async function () {
Object.values(persistKeys).forEach((key) => {
Ably.Realtime.Platform.Config.push.storage.remove(key);
});

const worker = await navigator.serviceWorker.getRegistration(swUrl);

if (worker) {
await worker.unregister();
}
});

afterEach(async function () {
await rest.push.deactivate();
});

/*
* RSH2a
*/
it('push_activation_succeeds', async function () {
await rest.push.activate();
expect(rest.device.deviceIdentityToken).to.be.ok;
});

// no spec item
it('direct_publish_device_id', async function () {
await rest.push.activate();

const pushRecipient = {
deviceId: rest.device.id,
};

const pushPayload = {
notification: { title: 'Test message', body: 'Test message body' },
data: { foo: 'bar' },
};

const sw = await navigator.serviceWorker.getRegistration(swUrl);

sw.active.postMessage({ type: 'INIT_PORT' }, [messageChannel.port2]);

const receivedPushPayload = await new Promise((resolve, reject) => {
messageChannel.port1.onmessage = function (event) {
resolve(event.data.payload);
};

rest.push.admin.publish(pushRecipient, pushPayload).catch(reject);
});

expect(receivedPushPayload.data).to.deep.equal(pushPayload.data);
expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title);
expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body);
});
});
}
});
1 change: 1 addition & 0 deletions test/support/browser_file_list.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ window.__testFiles__.files = {
'test/browser/connection.test.js': true,
'test/browser/simple.test.js': true,
'test/browser/http.test.js': true,
'test/browser/push.test.js': true,
'test/rest/status.test.js': true,
'test/rest/batch.test.js': true,
};
12 changes: 12 additions & 0 deletions test/support/push_sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
let port;

self.addEventListener('push', (event) => {
const res = event.data.json();
port.postMessage({ payload: res });
});

self.addEventListener('message', (event) => {
if (event.data.type === 'INIT_PORT') {
port = event.ports[0];
}
});
3 changes: 2 additions & 1 deletion test/support/runPlaywrightTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const mochaServer = new MochaServer(/* playwrightTest: */ true);
const runTests = async (browserType) => {
mochaServer.listen();
const browser = await browserType.launch();
const page = await browser.newPage();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(`http://${host}:${port}`);

console.log(`\nrunning tests in ${browserType.name()}`);
Expand Down
8 changes: 7 additions & 1 deletion test/web_server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
var express = require('express'),
cors = require('cors');
cors = require('cors'),
path = require('path');

/**
* Runs a simple web server that runs the mocha.html tests
Expand Down Expand Up @@ -33,6 +34,11 @@ class MochaServer {
}
});

// service workers have limited scope if not served from the base path
app.get('/push_sw.js', (req, res) => {
res.sendFile(path.join(__dirname, 'support', 'push_sw.js'));
});

app.use('/node_modules', express.static(__dirname + '/../node_modules'));
app.use('/test', express.static(__dirname));
app.use('/browser', express.static(__dirname + '/../src/web'));
Expand Down

0 comments on commit d532b27

Please sign in to comment.