Skip to content

Commit

Permalink
feat: web push
Browse files Browse the repository at this point in the history
  • Loading branch information
owenpearson committed May 22, 2024
1 parent 7156282 commit e1ff454
Show file tree
Hide file tree
Showing 7 changed files with 202 additions and 1 deletion.
9 changes: 9 additions & 0 deletions src/platform/web/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
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';
import { getW3CPushDeviceDetails } from './lib/transport/push';

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

export default Config;
87 changes: 87 additions & 0 deletions src/platform/web/lib/transport/push.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ActivationStateMachine, LocalDevice } from 'plugins/push/pushactivation';
import Resource from 'common/lib/client/resource';
import ErrorInfo from 'common/lib/types/errorinfo';
import Defaults from 'common/lib/util/defaults';

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

function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
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 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.rest.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.rest.options, { format: 'text' });
const appServerKey = (await Resource.get(machine.rest, '/push/publicVapidKey', headers, {}, null, true))
.body as string;

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

const subscription = await worker.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(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 key = [p256dh, auth].map(toBase64Url).join(':');

const device = machine.rest.device as LocalDevice;
device.push.recipient = {
transportType: 'web',
targetUrl: btoa(endpoint),
encryptionKey: key,
};
device.persist();

machine.handleEvent(new GotPushDeviceDetails());
} catch (err) {
machine.handleEvent(
new GettingPushDeviceDetailsFailed(
new ErrorInfo('failed to register service worker', 50000, 500, err as Error | ErrorInfo),
),
);
}
}
83 changes: 83 additions & 0 deletions test/browser/push.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
'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();

describe('browser/push', function () {
this.timeout(60 * 1000);

before(function (done) {
helper.setupApp(function () {
done();
});

rest = helper.AblyRest({
pushServiceWorkerUrl: swUrl,
plugins: { push: PushPlugin },
});
});

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();
});

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

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);
});
});
});
2 changes: 2 additions & 0 deletions test/support/browser_file_list.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ window.__testFiles__.files = {
'test/realtime/init.test.js': true,
'test/realtime/message.test.js': true,
'test/realtime/presence.test.js': true,
'test/realtime/push.test.js': true,
'test/realtime/reauth.test.js': true,
'test/realtime/resume.test.js': true,
'test/realtime/sync.test.js': true,
Expand All @@ -61,6 +62,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];
}
});
4 changes: 3 additions & 1 deletion test/support/runPlaywrightTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ 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();
await context.grantPermissions(['notifications']);
const page = await context.newPage();
await page.goto(`http://${host}:${port}`);

console.log(`\nrunning tests in ${browserType.name()}`);
Expand Down
6 changes: 6 additions & 0 deletions 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');
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 e1ff454

Please sign in to comment.