diff --git a/README.md b/README.md index d2297eb..e87a746 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The official Push Notification adapter for Parse Server. See [Parse Server Push - [Installation](#installation) - [Configure Parse Server](#configure-parse-server) - [Apple Push Options](#apple-push-options) + - [Native APNS (Experimental)](#native-apns-experimental) - [Android Push Options](#android-push-options) - [Firebase Cloud Messaging (FCM)](#firebase-cloud-messaging-fcm) - [Google Cloud Service Account Key](#google-cloud-service-account-key) @@ -100,6 +101,25 @@ osx: { } ``` +#### Native APNS (Experimental) + +As an alternative to the default APNS implementation which uses the `@parse/node-apn` library, you can enable a native APNS implementation that uses only built-in Node.js modules (`node:http2`, `node:crypto`) with zero additional dependencies. This is an experimental feature that only supports token-based authentication (`.p8` key). Certificate-based authentication (`.p12`/`.pem`) is not supported; use the default APNS implementation for that. + +To enable the native APNS implementation, set `useNativeAPNs: true`: + +```js +ios: { + useNativeAPNs: true, + token: { + key: __dirname + '/apns.p8', + keyId: '', + teamId: '' + }, + topic: '', + production: true +} +``` + ### Android Push Options Delivering push notifications to Android devices can be done via Firebase Cloud Messaging (FCM): diff --git a/spec/APNS.spec.js b/spec/APNS.spec.js index 083eeeb..6e68e35 100644 --- a/spec/APNS.spec.js +++ b/spec/APNS.spec.js @@ -744,7 +744,7 @@ describe('APNS', () => { expect(result.transmitted).toBe(false); expect(result.device.deviceToken).toBe('abcd'); expect(result.device.deviceType).toBe('ios'); - expect(result.response.error).toBe('Unkown status'); + expect(result.response.error).toBe('Unknown status'); done(); }) }); diff --git a/spec/APNSConnection.spec.js b/spec/APNSConnection.spec.js new file mode 100644 index 0000000..af4c4ed --- /dev/null +++ b/spec/APNSConnection.spec.js @@ -0,0 +1,550 @@ +import http2 from 'node:http2'; +import log from 'npmlog'; +import APNSConnection from '../src/APNSConnection.js'; + +describe('APNSConnection', () => { + + describe('constructor', () => { + it('defaults to sandbox endpoint', () => { + const conn = new APNSConnection(); + expect(conn._endpoint).toBe('https://api.sandbox.push.apple.com'); + }); + + it('uses production endpoint when production is true', () => { + const conn = new APNSConnection({ production: true }); + expect(conn._endpoint).toBe('https://api.push.apple.com'); + }); + + it('uses default retry limit of 3', () => { + const conn = new APNSConnection(); + expect(conn._retryLimit).toBe(3); + }); + + it('accepts custom retry limit', () => { + const conn = new APNSConnection({ connectionRetryLimit: 5 }); + expect(conn._retryLimit).toBe(5); + }); + + it('uses default request timeout of 5000ms', () => { + const conn = new APNSConnection(); + expect(conn._requestTimeout).toBe(5000); + }); + + it('accepts custom request timeout', () => { + const conn = new APNSConnection({ requestTimeout: 10000 }); + expect(conn._requestTimeout).toBe(10000); + }); + + it('starts with no active session', () => { + const conn = new APNSConnection(); + expect(conn._session).toBeNull(); + }); + }); + + describe('send', () => { + let conn; + let mockSession; + let mockRequest; + + beforeEach(() => { + conn = new APNSConnection({ production: true }); + + // Create mock request stream + mockRequest = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + + // Create mock session + mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + }); + + afterEach(() => { + conn.destroy(); + }); + + it('sends POST request with correct path and headers', async () => { + const sendPromise = conn.send( + 'abc123', + { 'apns-topic': 'com.example', 'apns-push-type': 'alert' }, + { aps: { alert: 'hello' } }, + 'bearer-token' + ); + + // Simulate success response + mockRequest._handlers.response({ ':status': 200 }); + mockRequest._handlers.end(); + + const result = await sendPromise; + + expect(mockSession.request).toHaveBeenCalledWith({ + ':method': 'POST', + ':path': '/3/device/abc123', + 'authorization': 'bearer bearer-token', + 'apns-topic': 'com.example', + 'apns-push-type': 'alert', + }); + expect(mockRequest.write).toHaveBeenCalledWith('{"aps":{"alert":"hello"}}'); + expect(result.status).toBe(200); + }); + + it('parses error response body', async () => { + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + mockRequest._handlers.response({ ':status': 400 }); + mockRequest._handlers.data('{"reason":"BadDeviceToken"}'); + mockRequest._handlers.end(); + + const result = await sendPromise; + + expect(result.status).toBe(400); + expect(result.body.reason).toBe('BadDeviceToken'); + }); + + it('retries on 500 status code', async () => { + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + // Set up second mock request BEFORE triggering the end event, + // because the retry fires synchronously inside the end handler + const mockRequest2 = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + mockSession.request.and.returnValue(mockRequest2); + + // First attempt: 500 triggers immediate retry + mockRequest._handlers.response({ ':status': 500 }); + mockRequest._handlers.data('{"reason":"InternalServerError"}'); + mockRequest._handlers.end(); + + // Second attempt responds with success + mockRequest2._handlers.response({ ':status': 200 }); + mockRequest2._handlers.end(); + + const result = await sendPromise; + expect(result.status).toBe(200); + expect(mockSession.request).toHaveBeenCalledTimes(2); + }); + + it('resolves with timeout on request timeout', async () => { + conn = new APNSConnection({ production: true, requestTimeout: 50 }); + spyOn(conn, '_getSession').and.returnValue(mockSession); + + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + // Don't respond — let timeout fire + const result = await sendPromise; + expect(result.status).toBe(0); + expect(result.body.reason).toBe('RequestTimeout'); + expect(mockRequest.close).toHaveBeenCalledWith(http2.constants.NGHTTP2_CANCEL); + }); + + it('creates session lazily on first send', async () => { + expect(conn._session).toBeNull(); + + const sendPromise = conn.send('abc123', {}, {}, 'token'); + mockRequest._handlers.response({ ':status': 200 }); + mockRequest._handlers.end(); + + await sendPromise; + expect(http2.connect).toHaveBeenCalledWith('https://api.push.apple.com'); + }); + + it('reuses existing session', async () => { + // First send + const sendPromise1 = conn.send('abc123', {}, {}, 'token'); + mockRequest._handlers.response({ ':status': 200 }); + mockRequest._handlers.end(); + await sendPromise1; + + // Second send + const mockRequest2 = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + mockSession.request.and.returnValue(mockRequest2); + + const sendPromise2 = conn.send('abc456', {}, {}, 'token'); + mockRequest2._handlers.response({ ':status': 200 }); + mockRequest2._handlers.end(); + await sendPromise2; + + // connect should only be called once + expect(http2.connect).toHaveBeenCalledTimes(1); + }); + }); + + describe('connection lifecycle', () => { + it('reconnects after GOAWAY on next request', async () => { + const conn = new APNSConnection({ production: true }); + const mockRequest = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + + // First request establishes session + const sendPromise = conn.send('abc123', {}, {}, 'token'); + mockRequest._handlers.response({ ':status': 200 }); + mockRequest._handlers.end(); + await sendPromise; + + expect(http2.connect).toHaveBeenCalledTimes(1); + + // Trigger GOAWAY + mockSession._handlers.goaway(); + expect(conn._draining).toBe(true); + + // Next request should create new session + const mockSession2 = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + http2.connect.and.returnValue(mockSession2); + + const sendPromise2 = conn.send('abc456', {}, {}, 'token'); + mockRequest._handlers.response({ ':status': 200 }); + mockRequest._handlers.end(); + await sendPromise2; + + expect(http2.connect).toHaveBeenCalledTimes(2); + conn.destroy(); + }); + + it('handles session error event', () => { + const conn = new APNSConnection(); + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request'), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + spyOn(log, 'error').and.callFake(() => {}); + + conn._getSession(); + expect(conn._session).not.toBeNull(); + + // Trigger session error + mockSession._handlers.error(new Error('connection reset')); + expect(mockSession.destroy).toHaveBeenCalled(); + expect(conn._session).toBeNull(); + conn.destroy(); + }); + + it('handles session close event', () => { + const conn = new APNSConnection(); + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request'), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + + conn._getSession(); + expect(conn._session).not.toBeNull(); + + // Trigger session close + mockSession._handlers.close(); + expect(conn._session).toBeNull(); + conn.destroy(); + }); + + it('resets connection attempts on successful connect', () => { + const conn = new APNSConnection(); + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request'), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + + conn._connectionAttempts = 2; + conn._getSession(); + + // Trigger connect event + mockSession._handlers.connect(); + expect(conn._connectionAttempts).toBe(0); + conn.destroy(); + }); + + it('throws when connection retry limit is exceeded', () => { + const conn = new APNSConnection({ connectionRetryLimit: 2 }); + conn._connectionAttempts = 2; + + expect(() => conn._getSession()).toThrowError('Failed to connect to APNs after 2 attempts'); + }); + + it('retries on request error and reconnects', async () => { + const conn = new APNSConnection({ production: true }); + const mockRequest = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + spyOn(log, 'error').and.callFake(() => {}); + + // Set up second request mock for retry + const mockRequest2 = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + // After first request is set up, prepare for retry + const mockSession2 = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest2), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + http2.connect.and.returnValue(mockSession2); + + // Trigger error on first request + mockRequest._handlers.error(new Error('stream reset')); + + // Respond to retry request + mockRequest2._handlers.response({ ':status': 200 }); + mockRequest2._handlers.end(); + + const result = await sendPromise; + expect(result.status).toBe(200); + expect(http2.connect).toHaveBeenCalledTimes(2); + conn.destroy(); + }); + + it('resolves with error when request error exhausts retries', async () => { + const conn = new APNSConnection({ production: true, connectionRetryLimit: 3 }); + + function makeMockRequest() { + return { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + } + + const mockRequest1 = makeMockRequest(); + const mockRequest2 = makeMockRequest(); + const mockRequest3 = makeMockRequest(); + const mockRequest4 = makeMockRequest(); + + function makeMockSession(requestMock) { + return { + destroyed: false, + _handlers: {}, + on(event, handler) { + this._handlers[event] = handler; + // Auto-trigger connect to reset _connectionAttempts (like real sessions) + if (event === 'connect') { setTimeout(() => handler(), 0); } + return this; + }, + request: jasmine.createSpy('request').and.returnValue(requestMock), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + } + + // Each reconnect gets a fresh session with the next request mock + const sessions = [ + makeMockSession(mockRequest1), + makeMockSession(mockRequest2), + makeMockSession(mockRequest3), + makeMockSession(mockRequest4), + ]; + let sessionIdx = 0; + spyOn(http2, 'connect').and.callFake(() => sessions[sessionIdx++]); + spyOn(log, 'error').and.callFake(() => {}); + + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + // Allow connect events to fire + await new Promise(r => setTimeout(r, 5)); + + // Each error triggers _destroySession + _getSession + new request + mockRequest1._handlers.error(new Error('error 1')); + await new Promise(r => setTimeout(r, 5)); + mockRequest2._handlers.error(new Error('error 2')); + await new Promise(r => setTimeout(r, 5)); + mockRequest3._handlers.error(new Error('error 3')); + await new Promise(r => setTimeout(r, 5)); + // 4th attempt: retryCount (3) >= retryLimit (3), resolves with error + mockRequest4._handlers.error(new Error('final error')); + + const result = await sendPromise; + expect(result.status).toBe(0); + expect(result.body.reason).toBe('final error'); + conn.destroy(); + }); + + it('retries with delay when Retry-After is present', async () => { + const conn = new APNSConnection({ production: true }); + const mockRequest = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request').and.returnValue(mockRequest), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + + const sendPromise = conn.send('abc123', {}, {}, 'token'); + + // Set up second request for retry + const mockRequest2 = { + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + write: jasmine.createSpy('write'), + end: jasmine.createSpy('end'), + close: jasmine.createSpy('close'), + }; + mockSession.request.and.returnValue(mockRequest2); + + // 429 with Retry-After: 1 second + mockRequest._handlers.response({ ':status': 429 }); + mockRequest._handlers.data('{"reason":"TooManyRequests","retry-after":"1"}'); + mockRequest._handlers.end(); + + // Wait for retry delay (1 second) + buffer + await new Promise(r => setTimeout(r, 1100)); + + mockRequest2._handlers.response({ ':status': 200 }); + mockRequest2._handlers.end(); + + const result = await sendPromise; + expect(result.status).toBe(200); + expect(mockSession.request).toHaveBeenCalledTimes(2); + conn.destroy(); + }); + + it('handles ping failure', () => { + const conn = new APNSConnection(); + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request'), + ping: jasmine.createSpy('ping'), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + spyOn(log, 'warn').and.callFake(() => {}); + + conn._getSession(); + + // Manually invoke the ping interval callback by calling ping with error + mockSession.ping.and.callFake((cb) => cb(new Error('ping timeout'))); + + // Trigger the interval manually + jasmine.clock().install(); + conn._startPing(); + jasmine.clock().tick(60001); + + expect(log.warn).toHaveBeenCalled(); + expect(mockSession.destroy).toHaveBeenCalled(); + + jasmine.clock().uninstall(); + conn.destroy(); + }); + + it('destroy tears down session', () => { + const conn = new APNSConnection(); + const mockSession = { + destroyed: false, + _handlers: {}, + on(event, handler) { this._handlers[event] = handler; return this; }, + request: jasmine.createSpy('request'), + ping: jasmine.createSpy('ping').and.callFake((cb) => cb(null)), + destroy: jasmine.createSpy('destroy').and.callFake(function() { this.destroyed = true; }), + }; + + spyOn(http2, 'connect').and.returnValue(mockSession); + + // Establish session + conn._getSession(); + expect(conn._session).not.toBeNull(); + + conn.destroy(); + expect(mockSession.destroy).toHaveBeenCalled(); + expect(conn._session).toBeNull(); + }); + }); +}); diff --git a/spec/APNSNative.spec.js b/spec/APNSNative.spec.js new file mode 100644 index 0000000..3eed26b --- /dev/null +++ b/spec/APNSNative.spec.js @@ -0,0 +1,583 @@ +import log from 'npmlog'; +import Parse from 'parse/node'; +import { generateKeyPairSync } from 'node:crypto'; +import APNSNative from '../src/APNSNative.js'; + +describe('APNSNative', () => { + let testKeyPEM; + + beforeAll(() => { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + testKeyPEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + }); + + function makeTokenConfig(overrides = {}) { + return { + token: { key: testKeyPEM, keyId: 'KEYID12345', teamId: 'TEAMID1234' }, + production: true, + topic: 'com.example.app', + ...overrides, + }; + } + + describe('constructor', () => { + it('can initialize with token config', () => { + const apns = new APNSNative(makeTokenConfig()); + expect(apns.providers.length).toBe(1); + expect(apns.providers[0].topic).toBe('com.example.app'); + expect(apns.providers[0].index).toBe(0); + expect(apns.providers[0].priority).toBe(0); // production + }); + + it('fails to initialize with bad data', () => { + try { + new APNSNative('args'); + fail('should not be reached'); + } catch (e) { + expect(e.code).toBe(Parse.Error.PUSH_MISCONFIGURED); + } + }); + + it('fails to initialize without token config', () => { + expect(() => { + new APNSNative({ topic: 'com.example', production: true }); + }).toThrow(); + }); + + it('fails to initialize without topic', () => { + expect(() => { + new APNSNative({ + token: { key: testKeyPEM, keyId: 'KEY1', teamId: 'TEAM1' }, + production: true, + }); + }).toThrow(); + }); + + it('can initialize with multiple configs', () => { + const apns = new APNSNative([ + makeTokenConfig({ topic: 'com.example.app', production: false }), + makeTokenConfig({ topic: 'com.example.app.dev', production: true }), + ]); + expect(apns.providers.length).toBe(2); + // Production (priority 0) should be first + expect(apns.providers[0].topic).toBe('com.example.app.dev'); + expect(apns.providers[0].index).toBe(0); + expect(apns.providers[1].topic).toBe('com.example.app'); + expect(apns.providers[1].index).toBe(1); + }); + + it('supports deprecated bundleId', () => { + spyOn(log, 'warn').and.callFake(() => {}); + const apns = new APNSNative(makeTokenConfig({ bundleId: 'com.example.old', topic: undefined })); + expect(apns.providers[0].topic).toBe('com.example.old'); + expect(log.warn).toHaveBeenCalled(); + }); + }); + + describe('notification generation', () => { + it('sets priority to 10 if not set explicitly', () => { + const data = { + 'alert': 'alert', + 'title': 'title', + 'badge': 100, + 'sound': 'test', + 'content-available': 1, + 'mutable-content': 1, + 'category': 'INVITE_CATEGORY', + 'threadId': 'a-thread-id', + 'key': 'value', + 'keyAgain': 'valueAgain' + }; + const notification = APNSNative._generateNotification(data, {}); + expect(notification.priority).toEqual(10); + }); + + it('can generate APNS notification', () => { + const data = { + 'alert': 'alert', + 'title': 'title', + 'badge': 100, + 'sound': 'test', + 'content-available': 1, + 'mutable-content': 1, + 'targetContentIdentifier': 'window1', + 'interruptionLevel': 'passive', + 'category': 'INVITE_CATEGORY', + 'threadId': 'a-thread-id', + 'key': 'value', + 'keyAgain': 'valueAgain' + }; + const expirationTime = 1454571491354; + const collapseId = 'collapseIdentifier'; + const pushType = 'alert'; + const priority = 5; + const notification = APNSNative._generateNotification(data, { expirationTime, collapseId, pushType, priority }); + + expect(notification.aps.alert).toEqual({ body: 'alert', title: 'title' }); + expect(notification.aps.badge).toEqual(data.badge); + expect(notification.aps.sound).toEqual(data.sound); + expect(notification.aps['content-available']).toEqual(1); + expect(notification.aps['mutable-content']).toEqual(1); + expect(notification.aps['target-content-id']).toEqual('window1'); + expect(notification.aps['interruption-level']).toEqual('passive'); + expect(notification.aps.category).toEqual(data.category); + expect(notification.aps['thread-id']).toEqual(data.threadId); + expect(notification.payload.key).toEqual('value'); + expect(notification.payload.keyAgain).toEqual('valueAgain'); + expect(notification.expiry).toEqual(Math.round(expirationTime / 1000)); + expect(notification.collapseId).toEqual(collapseId); + expect(notification.pushType).toEqual(pushType); + expect(notification.priority).toEqual(priority); + }); + + it('can generate APNS notification with nested alert dictionary', () => { + const data = { + 'alert': { body: 'alert', title: 'title' }, + 'badge': 100, + 'sound': 'test', + 'content-available': 1, + 'mutable-content': 1, + 'targetContentIdentifier': 'window1', + 'interruptionLevel': 'passive', + 'category': 'INVITE_CATEGORY', + 'threadId': 'a-thread-id', + 'key': 'value', + 'keyAgain': 'valueAgain' + }; + const expirationTime = 1454571491354; + const collapseId = 'collapseIdentifier'; + const pushType = 'alert'; + const priority = 5; + const notification = APNSNative._generateNotification(data, { expirationTime, collapseId, pushType, priority }); + + expect(notification.aps.alert).toEqual({ body: 'alert', title: 'title' }); + expect(notification.aps.badge).toEqual(data.badge); + expect(notification.aps.sound).toEqual(data.sound); + expect(notification.aps['content-available']).toEqual(1); + expect(notification.aps['mutable-content']).toEqual(1); + expect(notification.aps['target-content-id']).toEqual('window1'); + expect(notification.aps['interruption-level']).toEqual('passive'); + expect(notification.aps.category).toEqual(data.category); + expect(notification.aps['thread-id']).toEqual(data.threadId); + expect(notification.expiry).toEqual(Math.round(expirationTime / 1000)); + expect(notification.collapseId).toEqual(collapseId); + expect(notification.pushType).toEqual(pushType); + expect(notification.priority).toEqual(priority); + }); + + it('sets push type to alert if not defined explicitly', () => { + const data = { 'alert': 'alert' }; + const notification = APNSNative._generateNotification(data, { expirationTime: 1454571491354, collapseId: 'id' }); + expect(notification.pushType).toEqual('alert'); + }); + + it('can generate APNS notification from raw data', () => { + const data = { + 'aps': { + 'alert': { + 'loc-key': 'GAME_PLAY_REQUEST_FORMAT', + 'loc-args': ['Jenna', 'Frank'] + }, + 'badge': 100, + 'sound': 'test', + 'thread-id': 'a-thread-id' + }, + 'key': 'value', + 'keyAgain': 'valueAgain' + }; + const expirationTime = 1454571491354; + const collapseId = 'collapseIdentifier'; + const pushType = 'background'; + const priority = 5; + const notification = APNSNative._generateNotification(data, { expirationTime, collapseId, pushType, priority }); + + expect(notification.expiry).toEqual(Math.round(expirationTime / 1000)); + expect(notification.collapseId).toEqual(collapseId); + expect(notification.pushType).toEqual(pushType); + expect(notification.priority).toEqual(priority); + + const jsonPayload = notification.payload; + expect(jsonPayload.aps.alert).toEqual({ 'loc-key': 'GAME_PLAY_REQUEST_FORMAT', 'loc-args': ['Jenna', 'Frank'] }); + expect(jsonPayload.aps.badge).toEqual(100); + expect(jsonPayload.aps.sound).toEqual('test'); + expect(jsonPayload.aps['thread-id']).toEqual('a-thread-id'); + expect(jsonPayload.key).toEqual('value'); + expect(jsonPayload.keyAgain).toEqual('valueAgain'); + }); + + it('generating notification prioritizes header information from notification data', () => { + const data = { + 'id': 'hello', + 'requestId': 'world', + 'channelId': 'foo', + 'topic': 'bundle', + 'expiry': 20, + 'collapseId': 'collapse', + 'pushType': 'alert', + 'priority': 7, + }; + const notification = APNSNative._generateNotification(data, { + id: 'foo', requestId: 'hello', channelId: 'world', + topic: 'bundleId', expirationTime: 1454571491354, + collapseId: 'collapseIdentifier', pushType: 'background', priority: 5 + }); + expect(notification.id).toEqual(data.id); + expect(notification.requestId).toEqual(data.requestId); + expect(notification.channelId).toEqual(data.channelId); + expect(notification.topic).toEqual(data.topic); + expect(notification.expiry).toEqual(data.expiry); + expect(notification.collapseId).toEqual(data.collapseId); + expect(notification.pushType).toEqual(data.pushType); + expect(notification.priority).toEqual(data.priority); + }); + + it('generating notification does not override default notification info when header info is missing', () => { + const data = {}; + const notification = APNSNative._generateNotification(data, { topic: 'bundleId', collapseId: 'collapseIdentifier', pushType: 'background' }); + expect(notification.topic).toEqual('bundleId'); + expect(notification.expiry).toEqual(-1); + expect(notification.collapseId).toEqual('collapseIdentifier'); + expect(notification.pushType).toEqual('background'); + expect(notification.priority).toEqual(10); + }); + }); + + describe('topic determination', () => { + it('generating notification updates topic properly', () => { + const notification = APNSNative._generateNotification({}, { topic: 'bundleId', pushType: 'liveactivity' }); + expect(notification.topic).toEqual('bundleId.push-type.liveactivity'); + expect(notification.pushType).toEqual('liveactivity'); + }); + + it('defaults to original topic', () => { + expect(APNSNative._determineTopic('bundleId', 'alert')).toEqual('bundleId'); + }); + + it('updates topic based on location pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'location')).toEqual('bundleId.location-query'); + }); + + it('updates topic based on voip pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'voip')).toEqual('bundleId.voip'); + }); + + it('updates topic based on complication pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'complication')).toEqual('bundleId.complication'); + }); + + it('updates topic based on fileprovider pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'fileprovider')).toEqual('bundleId.pushkit.fileprovider'); + }); + + it('updates topic based on liveactivity pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'liveactivity')).toEqual('bundleId.push-type.liveactivity'); + }); + + it('updates topic based on pushtotalk pushType', () => { + expect(APNSNative._determineTopic('bundleId', 'pushtotalk')).toEqual('bundleId.voip-ptt'); + }); + }); + + describe('provider selection', () => { + it('can choose providers for device with valid appIdentifier', () => { + const providers = [ + { topic: 'topic' }, + { topic: 'topicAgain' } + ]; + const qualifiedProviders = APNSNative.prototype._chooseProviders.call({ providers }, 'topic'); + expect(qualifiedProviders).toEqual([{ topic: 'topic' }]); + }); + + it('can choose providers for device with invalid appIdentifier', () => { + const providers = [ + { topic: 'bundleId' }, + { topic: 'bundleIdAgain' } + ]; + const qualifiedProviders = APNSNative.prototype._chooseProviders.call({ providers }, 'invalid'); + expect(qualifiedProviders).toEqual([]); + }); + }); + + describe('send', () => { + it('does log on invalid APNS notification', () => { + const spy = spyOn(log, 'warn'); + const apns = new APNSNative(makeTokenConfig()); + apns.send(); + expect(spy).toHaveBeenCalled(); + }); + + it('can send APNS notification', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + spyOn(provider, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices.map(d => ({ device: d })), + failed: [] + }); + }); + + const data = { + 'collapse_id': 'collapseIdentifier', + 'push_type': 'alert', + 'expiration_time': 1454571491354, + 'priority': 6, + 'data': { 'alert': 'alert' } + }; + const mockedDevices = [ + { deviceToken: '112233', appIdentifier: 'com.example.app' }, + { deviceToken: '112234', appIdentifier: 'com.example.app' }, + { deviceToken: '112235', appIdentifier: 'com.example.app' }, + { deviceToken: '112236', appIdentifier: 'com.example.app' } + ]; + + await apns.send(data, mockedDevices); + + expect(provider.send).toHaveBeenCalled(); + const calledArgs = provider.send.calls.first().args; + const notification = calledArgs[0]; + expect(notification.aps.alert).toEqual({ body: 'alert' }); + expect(notification.expiry).toEqual(Math.round(data['expiration_time'] / 1000)); + expect(notification.collapseId).toEqual('collapseIdentifier'); + expect(notification.pushType).toEqual('alert'); + expect(notification.priority).toEqual(6); + const apnDevices = calledArgs[1]; + expect(apnDevices.length).toEqual(4); + }); + + it('can send APNS notification headers in data', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + spyOn(provider, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices.map(d => ({ device: d })), + failed: [] + }); + }); + + const data = { + 'expiration_time': 1454571491354, + 'data': { + 'alert': 'alert', + 'collapse_id': 'collapseIdentifier', + 'push_type': 'alert', + 'priority': 6, + } + }; + const mockedDevices = [ + { deviceToken: '112233', appIdentifier: 'com.example.app' }, + { deviceToken: '112234', appIdentifier: 'com.example.app' }, + { deviceToken: '112235', appIdentifier: 'com.example.app' }, + { deviceToken: '112236', appIdentifier: 'com.example.app' } + ]; + + await apns.send(data, mockedDevices); + + expect(provider.send).toHaveBeenCalled(); + const calledArgs = provider.send.calls.first().args; + const notification = calledArgs[0]; + expect(notification.aps.alert).toEqual({ body: 'alert' }); + expect(notification.expiry).toEqual(Math.round(data['expiration_time'] / 1000)); + const apnDevices = calledArgs[1]; + expect(apnDevices.length).toEqual(4); + }); + + it('can send to multiple bundles', async () => { + const apns = new APNSNative([ + makeTokenConfig({ topic: 'topic', production: true }), + makeTokenConfig({ topic: 'topic.dev', production: false }), + ]); + + const provider = apns.providers[0]; + spyOn(provider, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ sent: devices.map(d => ({ device: d })), failed: [] }); + }); + const providerDev = apns.providers[1]; + spyOn(providerDev, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ sent: devices.map(d => ({ device: d })), failed: [] }); + }); + + const data = { + 'collapse_id': 'collapseIdentifier', + 'push_type': 'alert', + 'expiration_time': 1454571491354, + 'data': { 'alert': 'alert' } + }; + const mockedDevices = [ + { deviceToken: '112233', appIdentifier: 'topic' }, + { deviceToken: '112234', appIdentifier: 'topic' }, + { deviceToken: '112235', appIdentifier: 'topic' }, + { deviceToken: '112235', appIdentifier: 'topic.dev' }, + { deviceToken: '112236', appIdentifier: 'topic.dev' }, + ]; + + await apns.send(data, mockedDevices); + + expect(provider.send).toHaveBeenCalled(); + let calledArgs = provider.send.calls.first().args; + expect(calledArgs[1].length).toBe(3); + + expect(providerDev.send).toHaveBeenCalled(); + calledArgs = providerDev.send.calls.first().args; + expect(calledArgs[1].length).toBe(2); + }); + + it('reports error when no provider available', async () => { + spyOn(log, 'warn').and.callFake(() => {}); + const apns = new APNSNative(makeTokenConfig({ bundleId: 'bundleId', topic: undefined })); + const data = { 'data': { 'alert': 'alert' } }; + const devices = [{ deviceToken: '112233', appIdentifier: 'invalidBundleId' }]; + const results = await apns.send(data, devices); + expect(results.length).toBe(1); + expect(results[0].transmitted).toBe(false); + expect(results[0].response.error).toBe('No Provider found'); + }); + + it('retries with next provider when first provider fails', async () => { + spyOn(log, 'error').and.callFake(() => {}); + const apns = new APNSNative([ + makeTokenConfig({ topic: 'topic', production: true }), + makeTokenConfig({ topic: 'topic', production: false }), + ]); + + const provider1 = apns.providers[0]; + spyOn(provider1, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: [], + failed: devices.map(d => ({ device: d, status: 500, response: { reason: 'InternalError' } })) + }); + }); + const provider2 = apns.providers[1]; + spyOn(provider2, 'send').and.callFake((notification, devices) => { + return Promise.resolve({ + sent: devices.map(d => ({ device: d })), + failed: [] + }); + }); + + const data = { 'data': { 'alert': 'alert' } }; + const devices = [ + { deviceToken: '112233', appIdentifier: 'topic' }, + ]; + const results = await apns.send(data, devices); + + expect(provider1.send).toHaveBeenCalled(); + expect(provider2.send).toHaveBeenCalled(); + expect(results.length).toBe(1); + expect(results[0].transmitted).toBe(true); + }); + }); + + describe('provider internal send', () => { + it('calls connection.send and returns sent/failed', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + + spyOn(provider.connection, 'send').and.callFake(async (deviceToken) => { + if (deviceToken === 'good') { + return { status: 200, body: {} }; + } + return { status: 400, body: { reason: 'BadDeviceToken' } }; + }); + + const notification = APNSNative._generateNotification({ alert: 'test' }, { topic: 'com.example.app' }); + const result = await provider.send(notification, ['good', 'bad']); + + expect(result.sent.length).toBe(1); + expect(result.sent[0].device).toBe('good'); + expect(result.failed.length).toBe(1); + expect(result.failed[0].device).toBe('bad'); + expect(result.failed[0].response.reason).toBe('BadDeviceToken'); + }); + + it('handles ExpiredProviderToken with token refresh', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + + let callCount = 0; + spyOn(provider.connection, 'send').and.callFake(async () => { + callCount++; + if (callCount === 1) { + return { status: 403, body: { reason: 'ExpiredProviderToken' } }; + } + return { status: 200, body: {} }; + }); + spyOn(provider.token, 'refresh').and.callThrough(); + + const notification = APNSNative._generateNotification({ alert: 'test' }, { topic: 'com.example.app' }); + const result = await provider.send(notification, ['device1']); + + expect(provider.token.refresh).toHaveBeenCalled(); + expect(result.sent.length).toBe(1); + expect(result.sent[0].device).toBe('device1'); + }); + + it('reports failure when ExpiredProviderToken retry also fails', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + + spyOn(provider.connection, 'send').and.callFake(async () => { + return { status: 403, body: { reason: 'ExpiredProviderToken' } }; + }); + + const notification = APNSNative._generateNotification({ alert: 'test' }, { topic: 'com.example.app' }); + const result = await provider.send(notification, ['device1']); + + expect(result.sent.length).toBe(0); + expect(result.failed.length).toBe(1); + expect(result.failed[0].status).toBe(403); + }); + + it('handles connection errors', async () => { + const apns = new APNSNative(makeTokenConfig()); + const provider = apns.providers[0]; + + spyOn(provider.connection, 'send').and.callFake(async () => { + throw new Error('Connection refused'); + }); + + const notification = APNSNative._generateNotification({ alert: 'test' }, { topic: 'com.example.app' }); + const result = await provider.send(notification, ['device1']); + + expect(result.sent.length).toBe(0); + expect(result.failed.length).toBe(1); + expect(result.failed[0].error).toBe('Connection refused'); + }); + }); + + describe('error handling', () => { + it('properly parses errors', async () => { + spyOn(log, 'error').and.callFake(() => {}); + const result = await APNSNative._handlePushFailure({ + device: 'abcd', + status: -1, + response: { reason: 'Something wrong happend' } + }); + expect(result.transmitted).toBe(false); + expect(result.device.deviceToken).toBe('abcd'); + expect(result.device.deviceType).toBe('ios'); + expect(result.response.error).toBe('Something wrong happend'); + }); + + it('properly parses status 0 transport failures', async () => { + spyOn(log, 'error').and.callFake(() => {}); + const result = await APNSNative._handlePushFailure({ + device: 'abcd', + status: 0, + response: { reason: 'RequestTimeout' } + }); + expect(result.transmitted).toBe(false); + expect(result.device.deviceToken).toBe('abcd'); + expect(result.device.deviceType).toBe('ios'); + expect(result.response.error).toBe('RequestTimeout'); + }); + + it('properly parses errors again', async () => { + spyOn(log, 'error').and.callFake(() => {}); + const result = await APNSNative._handlePushFailure({ device: 'abcd' }); + expect(result.transmitted).toBe(false); + expect(result.device.deviceToken).toBe('abcd'); + expect(result.device.deviceType).toBe('ios'); + expect(result.response.error).toBe('Unknown status'); + }); + }); +}); diff --git a/spec/APNSToken.spec.js b/spec/APNSToken.spec.js new file mode 100644 index 0000000..a81bd2d --- /dev/null +++ b/spec/APNSToken.spec.js @@ -0,0 +1,106 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import APNSToken from '../src/APNSToken.js'; + +describe('APNSToken', () => { + let testKeyPEM; + let tmpDir; + + beforeAll(() => { + // Generate a real ES256 key pair for testing + const { privateKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + }); + testKeyPEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + tmpDir = mkdtempSync(join(tmpdir(), 'apns-token-test-')); + }); + + afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rejects missing key', () => { + expect(() => new APNSToken({ keyId: 'KEY1', teamId: 'TEAM1' })) + .toThrowError('APNSToken requires a key (.p8 private key)'); + }); + + it('rejects missing keyId', () => { + expect(() => new APNSToken({ key: testKeyPEM, teamId: 'TEAM1' })) + .toThrowError('APNSToken requires a keyId'); + }); + + it('rejects missing teamId', () => { + expect(() => new APNSToken({ key: testKeyPEM, keyId: 'KEY1' })) + .toThrowError('APNSToken requires a teamId'); + }); + + it('generates a valid JWT with correct structure', () => { + const token = new APNSToken({ key: testKeyPEM, keyId: 'KEYID12345', teamId: 'TEAMID1234' }); + const jwt = token.current; + + const parts = jwt.split('.'); + expect(parts.length).toBe(3); + + const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString()); + expect(header.alg).toBe('ES256'); + expect(header.kid).toBe('KEYID12345'); + expect(header.typ).toBeUndefined(); + + const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + expect(claims.iss).toBe('TEAMID1234'); + expect(typeof claims.iat).toBe('number'); + expect(claims.iat).toBeCloseTo(Math.floor(Date.now() / 1000), 0); + }); + + it('caches token across calls', () => { + const token = new APNSToken({ key: testKeyPEM, keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt1 = token.current; + const jwt2 = token.current; + expect(jwt1).toBe(jwt2); + }); + + it('refreshes after expiry threshold', () => { + const token = new APNSToken({ key: testKeyPEM, keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt1 = token.current; + + // Simulate token being 51 minutes old + token._tokenIssuedAt = Date.now() - (51 * 60 * 1000); + + const jwt2 = token.current; + expect(jwt2).not.toBe(jwt1); + }); + + it('does not refresh before expiry threshold', () => { + const token = new APNSToken({ key: testKeyPEM, keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt1 = token.current; + + // Simulate token being 49 minutes old (under 50 min threshold) + token._tokenIssuedAt = Date.now() - (49 * 60 * 1000); + + const jwt2 = token.current; + expect(jwt2).toBe(jwt1); + }); + + it('refresh() forces token regeneration', () => { + const token = new APNSToken({ key: testKeyPEM, keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt1 = token.current; + const jwt2 = token.refresh(); + expect(jwt2).not.toBe(jwt1); + }); + + it('accepts key as Buffer', () => { + const token = new APNSToken({ key: Buffer.from(testKeyPEM), keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt = token.current; + expect(jwt.split('.').length).toBe(3); + }); + + it('accepts key as file path', () => { + const keyPath = join(tmpDir, 'AuthKey_TEST.p8'); + writeFileSync(keyPath, testKeyPEM); + const token = new APNSToken({ key: keyPath, keyId: 'KEY1', teamId: 'TEAM1' }); + const jwt = token.current; + expect(jwt.split('.').length).toBe(3); + }); +}); diff --git a/spec/ParsePushAdapter.spec.js b/spec/ParsePushAdapter.spec.js index 0f635e6..693fc6c 100644 --- a/spec/ParsePushAdapter.spec.js +++ b/spec/ParsePushAdapter.spec.js @@ -1,11 +1,12 @@ import { join } from 'path'; import log from 'npmlog'; import apn from '@parse/node-apn'; -import ParsePushAdapterPackage, { ParsePushAdapter as _ParsePushAdapter, FCM as _FCM, APNS as _APNS, WEB as _WEB, EXPO as _EXPO, utils } from '../src/index.js'; +import ParsePushAdapterPackage, { ParsePushAdapter as _ParsePushAdapter, FCM as _FCM, APNS as _APNS, APNSNative as _APNSNative, WEB as _WEB, EXPO as _EXPO, utils } from '../src/index.js'; const ParsePushAdapter = _ParsePushAdapter; import { randomString } from '../src/PushAdapterUtils.js'; import MockAPNProvider from './MockAPNProvider.js'; import APNS from '../src/APNS.js'; +import APNSNative from '../src/APNSNative.js'; import WEB from '../src/WEB.js'; import FCM from '../src/FCM.js'; import EXPO from '../src/EXPO.js'; @@ -20,6 +21,7 @@ describe('ParsePushAdapter', () => { expect(typeof ParsePushAdapterPackage).toBe('function'); expect(typeof _ParsePushAdapter).toBe('function'); expect(typeof _APNS).toBe('function'); + expect(typeof _APNSNative).toBe('function'); expect(typeof _FCM).toBe('function'); expect(typeof _WEB).toBe('function'); expect(typeof _EXPO).toBe('function'); @@ -121,6 +123,34 @@ describe('ParsePushAdapter', () => { done(); }); + it("can be initialized with APNSNative when useNativeAPNs is true", async () => { + const { generateKeyPairSync } = await import('node:crypto'); + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const testKeyPEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + + const pushConfig = { + android: { + firebaseServiceAccount: join(__dirname, '..', 'spec', 'support', 'fakeServiceAccount.json') + }, + ios: { + useNativeAPNs: true, + token: { key: testKeyPEM, keyId: 'KEYID12345', teamId: 'TEAMID1234' }, + production: false, + topic: 'com.example.app', + }, + }; + + const parsePushAdapter = new ParsePushAdapter(pushConfig); + const iosSender = parsePushAdapter.senderMap['ios']; + expect(iosSender instanceof APNS).toBe(false); + expect(iosSender instanceof APNSNative).toBe(true); + expect(Array.isArray(iosSender.providers)).toBe(true); + expect(iosSender.providers.length).toBe(1); + expect(iosSender.providers[0].topic).toBe('com.example.app'); + const androidSender = parsePushAdapter.senderMap['android']; + expect(androidSender instanceof FCM).toBe(true); + }); + it("can be initialized with FCM for apple and FCM for android", (done) => { const pushConfig = { android: { diff --git a/src/APNS.js b/src/APNS.js index d54e1e9..f35c4ca 100644 --- a/src/APNS.js +++ b/src/APNS.js @@ -318,8 +318,8 @@ export class APNS { log.error(LOG_PREFIX, 'APNS error transmitting to device %s with status %s and reason %s', failure.device, failure.status, failure.response.reason); return APNS._createErrorPromise(failure.device, failure.response.reason); } else { - log.error(LOG_PREFIX, 'APNS error transmitting to device with unkown error'); - return APNS._createErrorPromise(failure.device, 'Unkown status'); + log.error(LOG_PREFIX, 'APNS error transmitting to device with unknown error'); + return APNS._createErrorPromise(failure.device, 'Unknown status'); } } diff --git a/src/APNSConnection.js b/src/APNSConnection.js new file mode 100644 index 0000000..a8a8470 --- /dev/null +++ b/src/APNSConnection.js @@ -0,0 +1,226 @@ +'use strict'; +import http2 from 'node:http2'; +import log from 'npmlog'; + +const LOG_PREFIX = 'parse-server-push-adapter APNSConnection'; + +const ENDPOINTS = { + production: 'https://api.push.apple.com', + sandbox: 'https://api.sandbox.push.apple.com', +}; + +const PING_INTERVAL_MS = 60 * 1000; +const DEFAULT_REQUEST_TIMEOUT_MS = 5000; +const DEFAULT_RETRY_LIMIT = 3; +const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]); + +export default class APNSConnection { + + constructor({ production = false, connectionRetryLimit, requestTimeout } = {}) { + this._endpoint = production ? ENDPOINTS.production : ENDPOINTS.sandbox; + this._retryLimit = connectionRetryLimit ?? DEFAULT_RETRY_LIMIT; + this._requestTimeout = requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS; + this._session = null; + this._pingInterval = null; + this._draining = false; + this._connectionAttempts = 0; + } + + async send(deviceToken, headers, payload, authToken) { + const session = this._getSession(); + const body = JSON.stringify(payload); + + const requestHeaders = { + ':method': 'POST', + ':path': `/3/device/${deviceToken}`, + 'authorization': `bearer ${authToken}`, + ...headers, + }; + + return this._request(session, requestHeaders, body); + } + + _request(session, headers, body, retryCount = 0) { + return new Promise((resolve, reject) => { + let req; + try { + req = session.request(headers); + } catch (err) { + // Session may have been destroyed between _getSession and request + if (this._session === session) { + this._destroySession(); + } + if (retryCount < this._retryLimit) { + const newSession = this._getSession(); + return resolve(this._request(newSession, headers, body, retryCount + 1)); + } + return reject(err); + } + + let status; + let responseData = ''; + let settled = false; + + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + req.close(http2.constants.NGHTTP2_CANCEL); + resolve({ status: 0, body: { reason: 'RequestTimeout' } }); + } + }, this._requestTimeout); + + req.on('response', (responseHeaders) => { + status = responseHeaders[':status']; + }); + + req.on('data', (chunk) => { + responseData += chunk; + }); + + req.on('end', () => { + if (settled) return; + settled = true; + clearTimeout(timeout); + + let parsedBody = {}; + if (responseData) { + try { + parsedBody = JSON.parse(responseData); + } catch { + parsedBody = { reason: responseData }; + } + } + + // Retry on retryable status codes + if (RETRYABLE_STATUS_CODES.has(status) && retryCount < this._retryLimit) { + const delay = this._getRetryDelay(parsedBody); + if (delay > 0) { + setTimeout(() => { + resolve(this._request(session, headers, body, retryCount + 1)); + }, delay); + } else { + resolve(this._request(session, headers, body, retryCount + 1)); + } + return; + } + + resolve({ status, body: parsedBody }); + }); + + req.on('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + log.error(LOG_PREFIX, 'Request error: %s', err.message); + + if (retryCount < this._retryLimit) { + if (this._session === session) { + this._destroySession(); + } + const newSession = this._getSession(); + resolve(this._request(newSession, headers, body, retryCount + 1)); + } else { + resolve({ status: 0, body: { reason: err.message } }); + } + }); + + req.write(body); + req.end(); + }); + } + + _getRetryDelay(responseBody) { + // Respect Retry-After if present (in seconds) + if (responseBody && responseBody['retry-after']) { + return parseInt(responseBody['retry-after'], 10) * 1000; + } + return 0; + } + + _getSession() { + if (this._session && !this._session.destroyed && !this._draining) { + return this._session; + } + + this._destroySession(); + + if (this._connectionAttempts >= this._retryLimit) { + throw new Error(`Failed to connect to APNs after ${this._retryLimit} attempts`); + } + + this._connectionAttempts++; + this._draining = false; + + const session = http2.connect(this._endpoint); + this._session = session; + + session.on('goaway', () => { + log.warn(LOG_PREFIX, 'Received GOAWAY from APNs, will reconnect on next request'); + if (this._session === session) { + this._draining = true; + } + }); + + session.on('error', (err) => { + log.error(LOG_PREFIX, 'Session error: %s', err.message); + if (this._session === session) { + this._destroySession(); + } + }); + + session.on('close', () => { + if (this._session === session) { + this._session = null; + this._stopPing(); + } + }); + + // Reset connection attempts on successful connect + session.on('connect', () => { + this._connectionAttempts = 0; + }); + + this._startPing(); + return session; + } + + _startPing() { + this._stopPing(); + this._pingInterval = setInterval(() => { + const currentSession = this._session; + if (currentSession && !currentSession.destroyed) { + currentSession.ping((err) => { + if (err) { + log.warn(LOG_PREFIX, 'Ping failed: %s', err.message); + if (this._session === currentSession) { + this._destroySession(); + } + } + }); + } + }, PING_INTERVAL_MS); + // Allow the process to exit even if ping interval is active + if (this._pingInterval.unref) { + this._pingInterval.unref(); + } + } + + _stopPing() { + if (this._pingInterval) { + clearInterval(this._pingInterval); + this._pingInterval = null; + } + } + + _destroySession() { + this._stopPing(); + if (this._session && !this._session.destroyed) { + this._session.destroy(); + } + this._session = null; + } + + destroy() { + this._destroySession(); + } +} diff --git a/src/APNSNative.js b/src/APNSNative.js new file mode 100644 index 0000000..720f047 --- /dev/null +++ b/src/APNSNative.js @@ -0,0 +1,375 @@ +'use strict'; +import Parse from 'parse/node'; +import log from 'npmlog'; +import APNSToken from './APNSToken.js'; +import APNSConnection from './APNSConnection.js'; + +const LOG_PREFIX = 'parse-server-push-adapter APNSNative'; + +export class APNSNative { + + /** + * Create native APNs providers using HTTP/2 and JWT token auth. + * @constructor + * @param {Object|Array} args Config or array of configs + * @param {Object} args.token Token auth config (required) + * @param {Buffer|String} args.token.key The .p8 private key (file path, string, or Buffer) + * @param {String} args.token.keyId The Key ID from Apple + * @param {String} args.token.teamId The Team ID + * @param {Boolean} args.production Use production endpoint (default: false) + * @param {String} args.topic App bundle ID (required) + * @param {String} args.bundleId Deprecated alias for topic + * @param {Number} args.connectionRetryLimit Max connection retries (default: 3) + * @param {Number} args.requestTimeout Per-request timeout in ms (default: 5000) + */ + constructor(args) { + this.providers = []; + + let argsList = []; + if (Array.isArray(args)) { + argsList = argsList.concat(args); + } else if (typeof args === 'object') { + argsList.push(args); + } else { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'APNSNative Configuration is invalid'); + } + + for (const providerArgs of argsList) { + // Backward compatibility: bundleId -> topic + if (providerArgs.bundleId && !providerArgs.topic) { + log.warn(LOG_PREFIX, 'bundleId is deprecated, use topic instead'); + providerArgs.topic = providerArgs.bundleId; + } + + const provider = APNSNative._createProvider(providerArgs); + this.providers.push(provider); + } + + // Sort by priority ascending (production = 0, sandbox = 1) + this.providers.sort((a, b) => a.priority - b.priority); + + // Set index on each provider + for (let i = 0; i < this.providers.length; i++) { + this.providers[i].index = i; + } + } + + static _createProvider(providerArgs) { + if (!providerArgs.topic) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'APNSNative requires a topic (bundle ID)'); + } + if (!providerArgs.token || !providerArgs.token.key || !providerArgs.token.keyId || !providerArgs.token.teamId) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, 'APNSNative requires token auth config (token.key, token.keyId, token.teamId)'); + } + + const token = new APNSToken(providerArgs.token); + const connection = new APNSConnection({ + production: providerArgs.production, + connectionRetryLimit: providerArgs.connectionRetryLimit, + requestTimeout: providerArgs.requestTimeout, + }); + + return { + topic: providerArgs.topic, + priority: providerArgs.production ? 0 : 1, + index: 0, + token, + connection, + send: async (notification, devices) => { + const sent = []; + const failed = []; + + const promises = devices.map(async (deviceToken) => { + try { + const result = await connection.send( + deviceToken, + notification.headers, + notification.payload, + token.current + ); + + if (result.status === 200) { + sent.push({ device: deviceToken }); + } else if (result.status === 403 && result.body.reason === 'ExpiredProviderToken') { + // Refresh token and retry once + token.refresh(); + const retryResult = await connection.send( + deviceToken, + notification.headers, + notification.payload, + token.current + ); + if (retryResult.status === 200) { + sent.push({ device: deviceToken }); + } else { + failed.push({ device: deviceToken, status: retryResult.status, response: retryResult.body }); + } + } else { + failed.push({ device: deviceToken, status: result.status, response: result.body }); + } + } catch (err) { + failed.push({ device: deviceToken, error: err.message }); + } + }); + + await Promise.all(promises); + return { sent, failed }; + }, + }; + } + + send(data, allDevices) { + const coreData = data && data.data; + if (!coreData || !allDevices || !Array.isArray(allDevices)) { + log.warn(LOG_PREFIX, 'invalid push payload'); + return; + } + const expirationTime = data['expiration_time'] ?? coreData['expiration_time']; + const collapseId = data['collapse_id'] ?? coreData['collapse_id']; + const pushType = data['push_type'] ?? coreData['push_type']; + const priority = data['priority'] ?? coreData['priority']; + let allPromises = []; + + const devicesPerAppIdentifier = Object.create(null); + + allDevices.forEach(device => { + const appIdentifier = device.appIdentifier; + devicesPerAppIdentifier[appIdentifier] = devicesPerAppIdentifier[appIdentifier] || []; + devicesPerAppIdentifier[appIdentifier].push(device); + }); + + for (const key in devicesPerAppIdentifier) { + const devices = devicesPerAppIdentifier[key]; + const appIdentifier = devices[0].appIdentifier; + const providers = this._chooseProviders(appIdentifier); + + if (!providers || providers.length === 0) { + const errorPromises = devices.map(device => APNSNative._createErrorPromise(device.deviceToken, 'No Provider found')); + allPromises = allPromises.concat(errorPromises); + continue; + } + + const headers = { expirationTime, topic: appIdentifier, collapseId, pushType, priority }; + const notification = APNSNative._generateNotification(coreData, headers); + const deviceIds = devices.map(device => device.deviceToken); + const promise = this.sendThroughProvider(notification, deviceIds, providers); + allPromises.push(promise.then(this._handlePromise.bind(this))); + } + + return Promise.all(allPromises).then((results) => { + return [].concat.apply([], results); + }); + } + + sendThroughProvider(notification, devices, providers) { + return providers[0] + .send(notification, devices) + .then((response) => { + if (response.failed + && response.failed.length > 0 + && providers && providers.length > 1) { + const retryDevices = response.failed.map((failure) => failure.device); + response.failed = []; + return this.sendThroughProvider(notification, + retryDevices, + providers.slice(1, providers.length)).then((retryResponse) => { + response.failed = response.failed.concat(retryResponse.failed); + response.sent = response.sent.concat(retryResponse.sent); + return response; + }); + } else { + return response; + } + }); + } + + static _generateNotification(coreData, headerOpts) { + const aps = {}; + const payload = {}; + + for (const key in coreData) { + switch (key) { + case 'aps': + Object.assign(aps, coreData.aps); + break; + case 'alert': + if (typeof coreData.alert === 'object') { + aps.alert = coreData.alert; + } else { + aps.alert = aps.alert || {}; + aps.alert.body = coreData.alert; + } + break; + case 'title': + aps.alert = aps.alert || {}; + aps.alert.title = coreData.title; + break; + case 'badge': + aps.badge = coreData.badge; + break; + case 'sound': + aps.sound = coreData.sound; + break; + case 'content-available': + aps['content-available'] = coreData['content-available'] === 1 ? 1 : undefined; + break; + case 'mutable-content': + aps['mutable-content'] = coreData['mutable-content'] === 1 ? 1 : undefined; + break; + case 'targetContentIdentifier': + aps['target-content-id'] = coreData.targetContentIdentifier; + break; + case 'interruptionLevel': + aps['interruption-level'] = coreData.interruptionLevel; + break; + case 'category': + aps.category = coreData.category; + break; + case 'threadId': + aps['thread-id'] = coreData.threadId; + break; + case 'id': + case 'collapseId': + case 'channelId': + case 'requestId': + case 'pushType': + case 'topic': + case 'expiry': + case 'priority': + case 'expiration_time': + case 'collapse_id': + case 'push_type': + // Header fields — handled below + break; + default: + payload[key] = coreData[key]; + break; + } + } + + // Build notification payload + const notificationPayload = { aps }; + Object.assign(notificationPayload, payload); + + // Build headers + const pushType = coreData.pushType ?? headerOpts.pushType ?? 'alert'; + const topic = coreData.topic ?? APNSNative._determineTopic(headerOpts.topic, pushType); + const defaultPriority = 10; + + let expiry = -1; + if (headerOpts.expirationTime != null) { + expiry = Math.round(headerOpts.expirationTime / 1000); + } + expiry = coreData.expiry ?? expiry; + + const notificationPriority = coreData.priority ?? headerOpts.priority ?? defaultPriority; + + const collapseId = coreData.collapseId ?? headerOpts.collapseId; + const id = coreData.id ?? headerOpts.id; + + const headers = { + 'apns-topic': topic, + 'apns-push-type': pushType, + 'apns-priority': String(notificationPriority), + }; + if (expiry >= 0) { + headers['apns-expiration'] = String(expiry); + } + if (collapseId != null) { + headers['apns-collapse-id'] = collapseId; + } + if (id) { + headers['apns-id'] = id; + } + + return { + headers, + payload: notificationPayload, + // Expose for test assertions (mirrors apn.Notification properties) + get pushType() { return pushType; }, + get topic() { return topic; }, + get expiry() { return expiry; }, + get collapseId() { return collapseId; }, + get priority() { return notificationPriority; }, + get id() { return id; }, + get requestId() { return coreData.requestId ?? headerOpts.requestId; }, + get channelId() { return coreData.channelId ?? headerOpts.channelId; }, + get aps() { return aps; }, + }; + } + + static _determineTopic(topic, pushType) { + switch (pushType) { + case 'location': + return topic + '.location-query'; + case 'voip': + return topic + '.voip'; + case 'complication': + return topic + '.complication'; + case 'fileprovider': + return topic + '.pushkit.fileprovider'; + case 'liveactivity': + return topic + '.push-type.liveactivity'; + case 'pushtotalk': + return topic + '.voip-ptt'; + default: + return topic; + } + } + + _chooseProviders(appIdentifier) { + const qualifiedProviders = this.providers.filter((provider) => appIdentifier === provider.topic); + if (qualifiedProviders.length > 0) { + return qualifiedProviders; + } + return this.providers.filter((provider) => !provider.topic || provider.topic === ''); + } + + _handlePromise(response) { + const promises = []; + response.sent.forEach((token) => { + log.verbose(LOG_PREFIX, 'APNS transmitted to %s', token.device); + promises.push(APNSNative._createSuccesfullPromise(token.device)); + }); + response.failed.forEach((failure) => { + promises.push(APNSNative._handlePushFailure(failure)); + }); + return Promise.all(promises); + } + + static _handlePushFailure(failure) { + if (failure.error) { + log.error(LOG_PREFIX, 'APNS error transmitting to device %s with error %s', failure.device, failure.error); + return APNSNative._createErrorPromise(failure.device, failure.error); + } else if (failure.status !== undefined && failure.response?.reason) { + log.error(LOG_PREFIX, 'APNS error transmitting to device %s with status %s and reason %s', failure.device, failure.status, failure.response.reason); + return APNSNative._createErrorPromise(failure.device, failure.response.reason); + } else { + log.error(LOG_PREFIX, 'APNS error transmitting to device with unknown error'); + return APNSNative._createErrorPromise(failure.device, 'Unknown status'); + } + } + + static _createErrorPromise(token, errorMessage) { + return Promise.resolve({ + transmitted: false, + device: { + deviceToken: token, + deviceType: 'ios' + }, + response: { error: errorMessage } + }); + } + + static _createSuccesfullPromise(token) { + return Promise.resolve({ + transmitted: true, + device: { + deviceToken: token, + deviceType: 'ios' + } + }); + } +} + +export default APNSNative; diff --git a/src/APNSToken.js b/src/APNSToken.js new file mode 100644 index 0000000..667b2ac --- /dev/null +++ b/src/APNSToken.js @@ -0,0 +1,64 @@ +'use strict'; +import { readFileSync } from 'node:fs'; +import { createPrivateKey, sign } from 'node:crypto'; + +// Token refresh threshold: 50 minutes (Apple requires < 60 min, recommends > 20 min between refreshes) +const TOKEN_MAX_AGE_MS = 50 * 60 * 1000; + +function base64url(input) { + const str = typeof input === 'string' ? input : input.toString('base64'); + return str.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} + +export default class APNSToken { + + constructor({ key, keyId, teamId }) { + if (!key) { + throw new Error('APNSToken requires a key (.p8 private key)'); + } + if (!keyId) { + throw new Error('APNSToken requires a keyId'); + } + if (!teamId) { + throw new Error('APNSToken requires a teamId'); + } + + // Load key: if it doesn't look like PEM content, treat as file path + let keyData = key; + if (Buffer.isBuffer(key)) { + keyData = key.toString('utf8'); + } + if (typeof keyData === 'string' && !keyData.includes('-----BEGIN')) { + keyData = readFileSync(keyData, 'utf8'); + } + + this._privateKey = createPrivateKey(keyData); + this._keyId = keyId; + this._teamId = teamId; + this._token = null; + this._tokenIssuedAt = 0; + } + + get current() { + const now = Date.now(); + if (this._token && (now - this._tokenIssuedAt) < TOKEN_MAX_AGE_MS) { + return this._token; + } + return this._generate(); + } + + refresh() { + return this._generate(); + } + + _generate() { + const iat = Math.floor(Date.now() / 1000); + const header = base64url(Buffer.from(JSON.stringify({ alg: 'ES256', kid: this._keyId }))); + const claims = base64url(Buffer.from(JSON.stringify({ iss: this._teamId, iat }))); + const signingInput = `${header}.${claims}`; + const signature = sign('sha256', Buffer.from(signingInput), { key: this._privateKey, dsaEncoding: 'ieee-p1363' }); + this._token = `${signingInput}.${base64url(signature)}`; + this._tokenIssuedAt = Date.now(); + return this._token; + } +} diff --git a/src/ParsePushAdapter.js b/src/ParsePushAdapter.js index 4fbfe99..661cc27 100644 --- a/src/ParsePushAdapter.js +++ b/src/ParsePushAdapter.js @@ -2,6 +2,7 @@ import Parse from 'parse/node'; import log from 'npmlog'; import APNS from './APNS.js'; +import APNSNative from './APNSNative.js'; import FCM from './FCM.js'; import WEB from './WEB.js'; import EXPO from './EXPO.js'; @@ -35,6 +36,8 @@ export default class ParsePushAdapter { case 'osx': if (pushConfig[pushType].hasOwnProperty('firebaseServiceAccount')) { this.senderMap[pushType] = new FCM(pushConfig[pushType], 'apple'); + } else if (pushConfig[pushType].useNativeAPNs === true) { + this.senderMap[pushType] = new APNSNative(pushConfig[pushType]); } else { this.senderMap[pushType] = new APNS(pushConfig[pushType]); } diff --git a/src/index.js b/src/index.js index ab64a85..9f9f135 100644 --- a/src/index.js +++ b/src/index.js @@ -13,10 +13,11 @@ if (booleanParser(process.env.VERBOSE || process.env.VERBOSE_PARSE_SERVER_PUSH_A import ParsePushAdapter from './ParsePushAdapter.js'; import APNS from './APNS.js'; +import APNSNative from './APNSNative.js'; import WEB from './WEB.js'; import FCM from './FCM.js'; import EXPO from './EXPO.js'; import * as utils from './PushAdapterUtils.js'; export default ParsePushAdapter; -export { ParsePushAdapter, APNS, WEB, EXPO, FCM, utils }; +export { ParsePushAdapter, APNS, APNSNative, WEB, EXPO, FCM, utils };