Files
project-inter-server/src/notifications/senders/AppleNotificationSender.ts

178 lines
4.8 KiB
TypeScript

import jwt from "jsonwebtoken";
import http2 from "http2";
import { ClientHttp2Session } from "node:http2";
interface APNsUrl {
fullUrl: string;
path: string;
host: string;
}
export interface NotificationAlertArguments {
title: string;
body: string;
customKeys?: any,
}
export class AppleNotificationSender {
private apnsToken: string | undefined = undefined;
private _lastRefreshedTimeMs: number | undefined = undefined;
private client: ClientHttp2Session | undefined = undefined;
constructor(private shouldActuallySendNotifications = true) {
this.sendNotificationImmediately = this.sendNotificationImmediately.bind(this);
this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this);
this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this);
}
get lastRefreshedTimeMs(): number | undefined {
return this._lastRefreshedTimeMs;
}
private lastReloadedTimeForAPNsIsTooRecent() {
const thirtyMinutesMs = 1800000;
return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs < thirtyMinutesMs;
}
public reloadAPNsTokenIfTimePassed() {
if (this.lastReloadedTimeForAPNsIsTooRecent()) {
return;
}
const keyId = process.env.APNS_KEY_ID;
const teamId = process.env.APNS_TEAM_ID;
const privateKeyBase64 = process.env.APNS_PRIVATE_KEY;
if (!privateKeyBase64) return;
const privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8');
const tokenHeader = {
alg: "ES256",
"kid": keyId,
};
const nowMs = Date.now();
const claimsPayload = {
"iss": teamId,
"iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch
};
this.apnsToken = jwt.sign(claimsPayload, privateKey, {
algorithm: "ES256",
header: tokenHeader
});
this._lastRefreshedTimeMs = nowMs;
}
/**
* Send a notification immediately.
* @param deviceId
* @param notificationAlertArguments
*
* @return Boolean promise indicating whether the
* notification was sent successfully.
*/
public async sendNotificationImmediately(deviceId: string, notificationAlertArguments: NotificationAlertArguments) {
if (!this.shouldActuallySendNotifications) {
// pretend that the notification sent
return true;
}
this.reloadAPNsTokenIfTimePassed();
const bundleId = process.env.APNS_BUNDLE_ID;
if (typeof bundleId !== "string") {
throw new Error("APNS_BUNDLE_ID environment variable is not set correctly");
}
this.openConnectionIfNoneExists();
const { path } = AppleNotificationSender.getAPNsFullUrlToUse(deviceId);
const headers = {
':method': 'POST',
':path': path,
'authorization': `bearer ${this.apnsToken}`,
"apns-push-type": "alert",
"apns-expiration": "0",
"apns-priority": "10",
"apns-topic": bundleId,
};
try {
if (!this.client) { return false }
const client = this.client;
const req = client.request(headers);
req.setEncoding('utf8');
await new Promise<void>((resolve, reject) => {
req.on('response', (headers, _flags) => {
if (headers[":status"] !== 200) {
reject(`APNs request failed with status ${headers[":status"]}`);
}
resolve();
});
const customKeys = {
...notificationAlertArguments.customKeys,
}
delete notificationAlertArguments.customKeys;
// See https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification
// for notification payload examples
const payload = JSON.stringify({
aps: {
alert: notificationAlertArguments,
sound: "default"
},
...customKeys,
});
req.write(payload);
req.end();
});
return true;
} catch(e) {
console.error(e);
return false;
}
}
private openConnectionIfNoneExists() {
const host = AppleNotificationSender.getAPNsHostToUse();
if (!this.client) {
this.client = http2.connect(host);
}
}
private closeConnectionIfExists() {
this.client?.close();
this.client = undefined;
}
public static getAPNsFullUrlToUse(deviceId: string): APNsUrl {
let hostToUse = this.getAPNsHostToUse();
const path = "/3/device/" + deviceId;
const fullUrl = hostToUse + path;
return {
fullUrl,
host: hostToUse,
path,
};
}
public static getAPNsHostToUse() {
// Construct the fetch request
const devBaseUrl = "https://api.development.push.apple.com"
const prodBaseUrl = "https://api.push.apple.com"
let hostToUse = devBaseUrl;
if (process.env.APNS_IS_PRODUCTION === "1") {
hostToUse = prodBaseUrl;
}
return hostToUse;
}
}