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: Date | undefined = undefined; constructor( private shouldActuallySendNotifications = true, private client: ClientHttp2Session | undefined = undefined, ) { this.sendNotificationImmediately = this.sendNotificationImmediately.bind(this); this.lastReloadedTimeForAPNsIsTooRecent = this.lastReloadedTimeForAPNsIsTooRecent.bind(this); this.reloadAPNsTokenIfTimePassed = this.reloadAPNsTokenIfTimePassed.bind(this); this.openConnectionIfNoneExists = this.openConnectionIfNoneExists.bind(this); this.closeConnectionIfExists = this.closeConnectionIfExists.bind(this); this.registerClosureEventsForClient = this.registerClosureEventsForClient.bind(this); if (this.client !== undefined) { this.registerClosureEventsForClient(); } } get lastRefreshedTimeMs(): Date | undefined { return this._lastRefreshedTimeMs; } private lastReloadedTimeForAPNsIsTooRecent() { const thirtyMinutesMs = 1800000; return this._lastRefreshedTimeMs && Date.now() - this._lastRefreshedTimeMs.getTime() < 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 = new Date(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 req = this.client.request(headers); req.setEncoding('utf8'); await new Promise((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); this.registerClosureEventsForClient(); } } private registerClosureEventsForClient() { this.client?.on('close', this.closeConnectionIfExists); this.client?.on('error', this.closeConnectionIfExists); this.client?.on('goaway', this.closeConnectionIfExists); this.client?.on('timeout', this.closeConnectionIfExists); } 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; } }