diff --git a/.gitignore b/.gitignore index 8471424..496b53a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ yarn-error.log* # Testing /coverage/ + +# Keys +private/ diff --git a/package-lock.json b/package-lock.json index 4d485f2..d9b0220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@apollo/server": "^4.11.2", + "dotenv": "^16.4.7", "graphql": "^16.10.0", "jsonwebtoken": "^9.0.2" }, @@ -4856,7 +4857,6 @@ "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "dev": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index d698ded..bd50fbd 100644 --- a/package.json +++ b/package.json @@ -16,15 +16,16 @@ "@graphql-codegen/typescript": "4.1.2", "@graphql-codegen/typescript-resolvers": "4.4.1", "@jest/globals": "^29.7.0", + "@types/jsonwebtoken": "^9.0.8", "@types/node": "^22.10.2", "jest": "^29.7.0", "ts-jest": "^29.2.5", - "typescript": "^5.7.2", - "@types/jsonwebtoken": "^9.0.8" + "typescript": "^5.7.2" }, "private": true, "dependencies": { "@apollo/server": "^4.11.2", + "dotenv": "^16.4.7", "graphql": "^16.10.0", "jsonwebtoken": "^9.0.2" } diff --git a/src/index.ts b/src/index.ts index 9e93dcc..39fe3f6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,9 @@ import { ServerContext } from "./ServerContext"; import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository"; import { TimedApiBasedRepositoryLoader } from "./loaders/TimedApiBasedRepositoryLoader"; import { NotificationService } from "./services/NotificationService"; +import { configDotenv } from "dotenv"; + +configDotenv(); const typeDefs = readFileSync("./schema.graphqls", "utf8"); @@ -23,6 +26,7 @@ async function main() { await repositoryDataUpdater.start(); const notificationService = new NotificationService(repository); + notificationService.reloadAPNsTokenIfTimePassed(); const { url } = await startStandaloneServer(server, { listen: { diff --git a/src/services/NotificationService.ts b/src/services/NotificationService.ts index 66ec81d..6fee796 100644 --- a/src/services/NotificationService.ts +++ b/src/services/NotificationService.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import fs from "fs"; import { TupleKey } from "../types/TupleKey"; import { IEta } from "../entities/entities"; +import http2 from "http2"; export interface ScheduledNotificationData { deviceId: string; @@ -10,6 +11,12 @@ export interface ScheduledNotificationData { stopId: string; } +interface APNsUrl { + fullUrl: string; + path: string; + host: string; +} + export class NotificationService { public readonly secondsThresholdForNotificationToFire = 300; @@ -36,7 +43,7 @@ export class NotificationService { * stop ID, which can be generated using `TupleKey`. * @private */ - private deviceIdsToDeliverTo: { [key: string]: string[] } = {} + private deviceIdsToDeliverTo: { [key: string]: Set } = {} public reloadAPNsTokenIfTimePassed() { if (this.lastReloadedTimeForAPNsIsTooRecent()) { @@ -54,17 +61,17 @@ export class NotificationService { "kid": keyId, }; - const now = Date.now(); + const nowMs = Date.now(); const claimsPayload = { "iss": teamId, - "iat": now, + "iat": Math.ceil(nowMs / 1000), // APNs requires number of seconds since Epoch }; this.apnsToken = jwt.sign(claimsPayload, privateKey, { algorithm: "ES256", header: tokenHeader }); - this._lastRefreshedTimeMs = now; + this._lastRefreshedTimeMs = nowMs; } private lastReloadedTimeForAPNsIsTooRecent() { @@ -75,7 +82,6 @@ export class NotificationService { private async sendEtaNotificationImmediately(notificationData: ScheduledNotificationData): Promise { const { deviceId, shuttleId, stopId } = notificationData; this.reloadAPNsTokenIfTimePassed(); - const url = NotificationService.getAPNsFullUrlToUse(deviceId); const shuttle = await this.repository.getShuttleById(shuttleId); const stop = await this.repository.getStopById(stopId); @@ -102,45 +108,66 @@ export class NotificationService { throw new Error("APNS_BUNDLE_ID environment variable is not set correctly"); } + const { path, host } = NotificationService.getAPNsFullUrlToUse(deviceId); + const headers = { - authorization: `bearer ${this.apnsToken}`, + ':method': 'POST', + ':path': path, + 'authorization': `bearer ${this.apnsToken}`, "apns-push-type": "alert", "apns-expiration": "0", "apns-priority": "10", "apns-topic": bundleId, }; - const response = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ - aps: { - alert: { - title: "Shuttle is arriving", - body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` - } - } - }), - }); - const json = await response.json(); + try { + const client = http2.connect(host); + const req = client.request(headers); + req.setEncoding('utf8'); - if (response.status !== 200) { - console.error(`Notification failed for device ${deviceId}:`, json.reason); + await new Promise((resolve, reject) => { + req.on('response', (headers, flags) => { + if (headers[":status"] !== 200) { + reject(`APNs request failed with status ${headers[":status"]}`); + } + resolve(); + }); + + req.write(JSON.stringify({ + aps: { + alert: { + title: "Shuttle is arriving", + body: `Shuttle is approaching ${stop.name} in ${Math.ceil(eta.secondsRemaining / 60)} minutes.` + } + } + })); + req.end(); + }); + return true; + } catch(e) { + console.error(e); return false; } - return true; } - public static getAPNsFullUrlToUse(deviceId: string) { + public static getAPNsFullUrlToUse(deviceId: string): APNsUrl { // Construct the fetch request - const devBaseUrl = "https://api.sandbox.push.apple.com" + const devBaseUrl = "https://api.development.push.apple.com" const prodBaseUrl = "https://api.push.apple.com" - const path = "/3/device/" + deviceId; - let urlToUse = prodBaseUrl + path; + let hostToUse = prodBaseUrl; if (process.env.NODE_ENV !== "production") { - urlToUse = devBaseUrl + path; + hostToUse = devBaseUrl; } - return urlToUse; + + const path = "/3/device/" + deviceId; + const fullUrl = hostToUse + path; + + const constructedObject = { + fullUrl, + host: hostToUse, + path, + } + return constructedObject; } private async etaSubscriberCallback(eta: IEta) { @@ -149,15 +176,17 @@ export class NotificationService { return; } - const indicesToRemove = new Set(); - await Promise.all(this.deviceIdsToDeliverTo[tuple.toString()].map(async (deviceId, index) => { + const deviceIdsToRemove = new Set(); + for (let deviceId of this.deviceIdsToDeliverTo[tuple.toString()].values()) { const deliveredSuccessfully = await this.sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId, eta); if (deliveredSuccessfully) { - indicesToRemove.add(index); + deviceIdsToRemove.add(deviceId); } - })); + } - this.deviceIdsToDeliverTo[tuple.toString()] = this.deviceIdsToDeliverTo[tuple.toString()].filter((_, index) => !indicesToRemove.has(index)); + deviceIdsToRemove.forEach((deviceId) => { + this.deviceIdsToDeliverTo[tuple.toString()].delete(deviceId); + }); } private async sendEtaNotificationImmediatelyIfSecondsRemainingBelowThreshold(deviceId: string, eta: IEta) { @@ -181,10 +210,9 @@ export class NotificationService { public async scheduleNotification({ deviceId, shuttleId, stopId }: ScheduledNotificationData) { const tuple = new TupleKey(shuttleId, stopId); if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { - this.deviceIdsToDeliverTo[tuple.toString()] = [deviceId]; - } else { - this.deviceIdsToDeliverTo[tuple.toString()].push(deviceId); + this.deviceIdsToDeliverTo[tuple.toString()] = new Set(); } + this.deviceIdsToDeliverTo[tuple.toString()].add(deviceId); this.repository.unsubscribeFromEtaUpdates(this.etaSubscriberCallback); this.repository.subscribeToEtaUpdates(this.etaSubscriberCallback); @@ -200,15 +228,12 @@ export class NotificationService { const tupleKey = new TupleKey(shuttleId, stopId); if ( this.deviceIdsToDeliverTo[tupleKey.toString()] === undefined - || !this.deviceIdsToDeliverTo[tupleKey.toString()].includes(deviceId) + || !this.deviceIdsToDeliverTo[tupleKey.toString()].has(deviceId) ) { return; } - const index = this.deviceIdsToDeliverTo[tupleKey.toString()].findIndex(id => id === deviceId); - if (index !== -1) { - this.deviceIdsToDeliverTo[tupleKey.toString()].splice(index, 1); - } + this.deviceIdsToDeliverTo[tupleKey.toString()].delete(deviceId); } /** @@ -222,6 +247,6 @@ export class NotificationService { if (this.deviceIdsToDeliverTo[tuple.toString()] === undefined) { return false; } - return this.deviceIdsToDeliverTo[tuple.toString()].includes(deviceId); + return this.deviceIdsToDeliverTo[tuple.toString()].has(deviceId); } } diff --git a/test/services/NotificationServiceTests.test.ts b/test/services/NotificationServiceTests.test.ts index cf5308a..9ef66c6 100644 --- a/test/services/NotificationServiceTests.test.ts +++ b/test/services/NotificationServiceTests.test.ts @@ -2,11 +2,13 @@ import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { NotificationService } from "../../src/services/NotificationService"; import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository"; import fs from "fs"; +import http2 from "http2"; import { IEta, IShuttle, IStop } from "../../src/entities/entities"; -import { resetGlobalFetchMockJson, updateGlobalFetchMockJson } from "../testHelpers/fetchMockHelpers"; import { addMockShuttleToRepository, addMockStopToRepository } from "../testHelpers/repositorySetupHelpers"; +import EventEmitter = require("node:events"); jest.mock("fs"); +jest.mock("http2"); const sampleKey = `-----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsrmSAZIagOfCP8sB @@ -40,6 +42,26 @@ async function waitForMilliseconds(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function mockHttp2Connect(status: number) { + class MockClient extends EventEmitter { + request = jest.fn((headers: any) => { + const mockRequest: any = new EventEmitter(); + mockRequest.setEncoding = jest.fn(); + mockRequest.write = jest.fn(); + mockRequest.end = jest.fn(() => { + setTimeout(() => { + mockRequest.emit('response', { ':status': status }); + }, 10); + }); + return mockRequest; + }); + + close() {}; + } + + (http2.connect as jest.Mock) = jest.fn(() => new MockClient()); +} + describe("NotificationService", () => { let repository: UnoptimizedInMemoryRepository let notificationService: NotificationService; @@ -58,9 +80,11 @@ describe("NotificationService", () => { }; (fs.readFileSync as jest.Mock).mockReturnValue(sampleKey); + }); - resetGlobalFetchMockJson(); - }) + beforeEach(() => { + mockHttp2Connect(200); + }); describe("reloadAPNsTokenIfTimePassed", () => { it("reloads the token if token hasn't been generated yet", async () => { @@ -119,9 +143,6 @@ describe("NotificationService", () => { const { eta, notificationData1, notificationData2 } = generateNotificationDataAndEta(shuttle, stop); - // Simulate 200 + empty object for successful push notification - updateGlobalFetchMockJson({}); - // Act await notificationService.scheduleNotification(notificationData1); await notificationService.scheduleNotification(notificationData2); @@ -132,7 +153,6 @@ describe("NotificationService", () => { // wait for the change to occur first await waitForCondition(() => !notificationService.isNotificationScheduled(notificationData1)); - expect(fetch as jest.Mock).toHaveBeenCalledTimes(2); const isFirstNotificationScheduled = notificationService.isNotificationScheduled(notificationData1); const isSecondNotificationScheduled = notificationService.isNotificationScheduled(notificationData2); // No longer scheduled after being sent @@ -147,8 +167,6 @@ describe("NotificationService", () => { const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); eta.secondsRemaining = notificationService.secondsThresholdForNotificationToFire + 100; - updateGlobalFetchMockJson({}); - // Act await notificationService.scheduleNotification(notificationData1); await repository.addOrUpdateEta(eta); @@ -164,8 +182,7 @@ describe("NotificationService", () => { const shuttle = await addMockShuttleToRepository(repository, "1"); const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop) - - updateGlobalFetchMockJson({}, 400); + mockHttp2Connect(403); // Act await notificationService.scheduleNotification(notificationData1); @@ -185,21 +202,22 @@ describe("NotificationService", () => { process.env.NODE_ENV = 'production'; const deviceId = 'testDeviceId'; const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + + const { fullUrl, host, path } = result; + expect(fullUrl).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + expect(host).toBe("https://api.push.apple.com"); + expect(path).toBe(`/3/device/${deviceId}`); }); it('should return the sandbox URL when NODE_ENV is not set to "production"', () => { process.env.NODE_ENV = 'development'; const deviceId = 'testDeviceId'; const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.sandbox.push.apple.com/3/device/${deviceId}`); - }); - it('should append the correct device ID to the URL', () => { - process.env.NODE_ENV = 'production'; - const deviceId = 'device123'; - const result = NotificationService.getAPNsFullUrlToUse(deviceId); - expect(result).toBe(`https://api.push.apple.com/3/device/${deviceId}`); + const { fullUrl, host, path } = result; + expect(fullUrl).toBe(`https://api.development.push.apple.com/3/device/${deviceId}`); + expect(host).toBe("https://api.development.push.apple.com"); + expect(path).toBe(`/3/device/${deviceId}`); }); }); @@ -210,8 +228,6 @@ describe("NotificationService", () => { const stop = await addMockStopToRepository(repository, "1"); const { eta, notificationData1 } = generateNotificationDataAndEta(shuttle, stop); - updateGlobalFetchMockJson({}); - await notificationService.scheduleNotification(notificationData1); // Act @@ -220,7 +236,7 @@ describe("NotificationService", () => { // Assert await waitForMilliseconds(500); - expect(fetch as jest.Mock).toHaveBeenCalledTimes(0); + expect(http2.connect as jest.Mock).toHaveBeenCalledTimes(0); }); }); });