diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 79281bb..b217984 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,6 +36,7 @@ This repository implements a Node.js module for sending push notifications acros ### Message Building (src/utils/tools.js) **buildAndroidMessage(data, options)** + - Converts unified notification data to Firebase Admin SDK AndroidMessage format - Returns plain JavaScript object (no wrapper functions) - Properties mapped to camelCase (Firebase SDK standard) @@ -44,6 +45,7 @@ This repository implements a Node.js module for sending push notifications acros - Supports all 20+ AndroidNotification properties **buildAndroidNotification(data)** + - Maps input `data` object to AndroidNotification interface - Supported properties: - Basic: `title`, `body`, `icon`, `color`, `sound`, `tag`, `imageUrl` @@ -59,14 +61,15 @@ This repository implements a Node.js module for sending push notifications acros ### FCM Configuration (src/sendFCM.js) **Initialization Options:** + - `credential` or `serviceAccountKey` (required) - Firebase authentication - `projectId` (optional) - Explicit Google Cloud project ID - `databaseURL` (optional) - Realtime Database URL - `storageBucket` (optional) - Cloud Storage bucket - `serviceAccountId` (optional) - Service account email - `databaseAuthVariableOverride` (optional) - Auth override for RTDB rules -- `httpAgent` (optional) - HTTP proxy agent for network requests -- `httpsAgent` (optional) - HTTPS proxy agent for network requests +- `httpAgent` (optional) - HTTP/HTTPS proxy agent for network requests (use HttpProxyAgent for HTTP proxies, HttpsProxyAgent for HTTPS proxies) +- `legacyHttpTransport` (optional) - Enable HTTP/1.1 transport instead of HTTP/2 (for compatibility with older Node.js or network restrictions, required for proper proxy support) All optional properties are dynamically added to Firebase initialization if defined. @@ -89,6 +92,7 @@ All optional properties are dynamically added to Firebase initialization if defi ### Message Format Firebase Admin SDK expects: + ```javascript { tokens: [...], @@ -134,12 +138,14 @@ Firebase Admin SDK expects: ### What Changed **Removed:** + - `buildGcmMessage()` function (wrapper pattern with toJson() method) - `buildGcmNotification()` function - Post-processing delete statements for undefined properties - References to legacy `node-gcm` library **Added:** + - `buildAndroidMessage()` - Direct Firebase Admin SDK compatible builder - `buildAndroidNotification()` - Proper Android notification interface - 15+ new Android notification properties (ticker, sticky, visibility, LED settings, etc.) @@ -150,12 +156,14 @@ Firebase Admin SDK expects: ### Migration Pattern **Before (Legacy GCM):** + ```javascript const message = buildGcmMessage(data).toJson(); // Result: wrapper object with toJson() method ``` **After (Firebase Admin SDK):** + ```javascript const message = buildAndroidMessage(data); // Result: plain object directly compatible with firebase-admin @@ -164,6 +172,7 @@ const message = buildAndroidMessage(data); ### Property Naming All properties now use **camelCase** (Firebase Admin SDK standard): + - `android_channel_id` → `channelId` - `title_loc_key` → `titleLocKey` - `body_loc_key` → `bodyLocKey` @@ -173,6 +182,7 @@ All properties now use **camelCase** (Firebase Admin SDK standard): ### Testing New test suite added with 7 test cases: + - `should accept projectId in settings` - `should accept databaseURL in settings` - `should accept storageBucket in settings` @@ -186,6 +196,7 @@ All 87 tests passing, zero regressions. ## Example Usage See `README.md` for complete examples including: + - FCM settings configuration with all AppOptions - Notification data with all supported properties - Network proxy agent setup diff --git a/README.md b/README.md index 900e80a..bca1cee 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ const settings = { databaseURL: 'https://your-database.firebaseio.com', // Realtime Database URL (optional) storageBucket: 'your-bucket.appspot.com', // Cloud Storage bucket (optional) serviceAccountId: 'your-email@your-project.iam.gserviceaccount.com', // Service account email (optional) - httpAgent: undefined, // HTTP Agent for proxy support (optional) - httpsAgent: undefined, // HTTPS Agent for proxy support (optional) + httpAgent: undefined, // HTTP Agent for proxy support - use HttpProxyAgent for HTTP proxies, HttpsProxyAgent for HTTPS proxies (optional) + legacyHttpTransport: false, // Enable HTTP/1.1 instead of HTTP/2 (optional) }, apn: { token: { @@ -474,8 +474,8 @@ The following Firebase Admin SDK `AppOptions` are supported and can be passed in - `storageBucket` - Cloud Storage bucket name (optional) - `serviceAccountId` - Service account email (optional) - `databaseAuthVariableOverride` - Auth variable override for Realtime Database (optional) -- `httpAgent` - HTTP Agent for proxy support (optional, see [Proxy](#proxy) section) -- `httpsAgent` - HTTPS Agent for proxy support (optional, see [Proxy](#proxy) section) +- `httpAgent` - HTTP/HTTPS Agent for proxy support (optional, use HttpProxyAgent for HTTP proxies, HttpsProxyAgent for HTTPS proxies, see [Proxy](#proxy) section) +- `legacyHttpTransport` - Enable HTTP/1.1 transport instead of HTTP/2 (optional, for compatibility with older Node.js versions or network restrictions) ```js const tokens = ['e..Gwso:APA91.......7r910HljzGUVS_f...kbyIFk2sK6......D2s6XZWn2E21x']; diff --git a/src/sendFCM.js b/src/sendFCM.js index 69b6f67..d89dcb8 100644 --- a/src/sendFCM.js +++ b/src/sendFCM.js @@ -67,15 +67,19 @@ const sendChunk = (firebaseApp, recipients, message) => { const sendFCM = (regIds, data, settings) => { const appName = `${settings.fcm.appName}`; + + // Get or create credential with httpAgent for proper proxy support in authentication + const credential = + settings.fcm.credential || + firebaseAdmin.credential.cert(settings.fcm.serviceAccountKey, settings.fcm.httpAgent); + const opts = { - credential: - settings.fcm.credential || firebaseAdmin.credential.cert(settings.fcm.serviceAccountKey), + credential, }; // Add optional Firebase AppOptions properties if provided const optionalProps = [ 'httpAgent', - 'httpsAgent', 'projectId', 'databaseURL', 'storageBucket', @@ -89,6 +93,12 @@ const sendFCM = (regIds, data, settings) => { }); const firebaseApp = firebaseAdmin.initializeApp(opts, appName); + + // Enable legacy HTTP/1.1 transport if requested + if (settings.fcm.legacyHttpTransport) { + firebaseAdmin.messaging(firebaseApp).enableLegacyHttpTransport(); + } + firebaseAdmin.INTERNAL.appStore.removeApp(appName); const promises = []; diff --git a/test/send/sendFCM.js b/test/send/sendFCM.js index fe5b74a..1fd5442 100644 --- a/test/send/sendFCM.js +++ b/test/send/sendFCM.js @@ -154,47 +154,8 @@ describe('push-notifications-fcm', () => { }); }); - it('should accept httpsAgent in settings', (done) => { - const mockHttpsAgent = {}; - const mockInitializeApp = sinon.stub(firebaseAdmin, 'initializeApp').returns({ - messaging: () => ({ - sendEachForMulticast: () => - Promise.resolve({ - successCount: 1, - failureCount: 0, - responses: [{ error: null }], - }), - }), - }); - - const fcmOptsWithProxy = { - fcm: { - name: 'testAppNameProxyHttps', - credential: { getAccessToken: () => Promise.resolve({}) }, - httpsAgent: mockHttpsAgent, - }, - }; - - const pnWithProxy = new PN(fcmOptsWithProxy); - - pnWithProxy - .send(regIds, message) - .then(() => { - // Verify that initializeApp was called with httpsAgent - const callArgs = mockInitializeApp.getCall(0).args[0]; - expect(callArgs.httpsAgent).to.equal(mockHttpsAgent); - mockInitializeApp.restore(); - done(); - }) - .catch((err) => { - mockInitializeApp.restore(); - done(err); - }); - }); - - it('should accept both httpAgent and httpsAgent in settings', (done) => { + it('should accept httpAgent in settings for both HTTP and HTTPS', (done) => { const mockHttpAgent = {}; - const mockHttpsAgent = {}; const mockInitializeApp = sinon.stub(firebaseAdmin, 'initializeApp').returns({ messaging: () => ({ sendEachForMulticast: () => @@ -208,10 +169,9 @@ describe('push-notifications-fcm', () => { const fcmOptsWithProxy = { fcm: { - name: 'testAppNameProxyBoth', + name: 'testAppNameProxyHttpsViaHttpAgent', credential: { getAccessToken: () => Promise.resolve({}) }, httpAgent: mockHttpAgent, - httpsAgent: mockHttpsAgent, }, }; @@ -220,10 +180,10 @@ describe('push-notifications-fcm', () => { pnWithProxy .send(regIds, message) .then(() => { - // Verify that initializeApp was called with both agents + // Verify that initializeApp was called with httpAgent + // httpAgent handles both HTTP and HTTPS connections in Firebase Admin SDK const callArgs = mockInitializeApp.getCall(0).args[0]; expect(callArgs.httpAgent).to.equal(mockHttpAgent); - expect(callArgs.httpsAgent).to.equal(mockHttpsAgent); mockInitializeApp.restore(); done(); }) @@ -468,4 +428,123 @@ describe('push-notifications-fcm', () => { }); }); }); + + describe('Legacy HTTP transport support', () => { + it('should enable legacyHttpTransport when configured', (done) => { + const mockEnableLegacyHttpTransport = sinon.stub(); + + // Stub messaging to track calls and return mock instance + const mockMessagingStub = sinon.stub().returns({ + enableLegacyHttpTransport: mockEnableLegacyHttpTransport, + sendEachForMulticast: () => + Promise.resolve({ + successCount: 1, + failureCount: 0, + responses: [{ error: null }], + }), + }); + + // Use Object.defineProperty to override the messaging getter + const proto = Object.getPrototypeOf(firebaseAdmin); + const propertyDescriptor = Object.getOwnPropertyDescriptor(proto, 'messaging'); + + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', { + value: mockMessagingStub, + configurable: true, + writable: true, + }); + + sinon.stub(firebaseAdmin, 'initializeApp').returns({}); + sinon.stub(firebaseAdmin.INTERNAL.appStore, 'removeApp'); + + const fcmOptsWithLegacy = { + fcm: { + name: 'testAppNameLegacy', + credential: { getAccessToken: () => Promise.resolve({}) }, + legacyHttpTransport: true, + }, + }; + + const pnWithLegacy = new PN(fcmOptsWithLegacy); + + pnWithLegacy + .send(regIds, message) + .then(() => { + expect(mockEnableLegacyHttpTransport.called).to.be.true; + // Restore + firebaseAdmin.initializeApp.restore(); + firebaseAdmin.INTERNAL.appStore.removeApp.restore(); + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', propertyDescriptor); + done(); + }) + .catch((err) => { + // Restore + firebaseAdmin.initializeApp.restore(); + firebaseAdmin.INTERNAL.appStore.removeApp.restore(); + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', propertyDescriptor); + done(err); + }); + }); + + it('should not enable legacyHttpTransport when not configured', (done) => { + const mockEnableLegacyHttpTransport = sinon.stub(); + + // Stub messaging to track calls and return mock instance + const mockMessagingStub = sinon.stub().returns({ + enableLegacyHttpTransport: mockEnableLegacyHttpTransport, + sendEachForMulticast: () => + Promise.resolve({ + successCount: 1, + failureCount: 0, + responses: [{ error: null }], + }), + }); + + // Use Object.defineProperty to override the messaging getter + const proto = Object.getPrototypeOf(firebaseAdmin); + const propertyDescriptor = Object.getOwnPropertyDescriptor(proto, 'messaging'); + + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', { + value: mockMessagingStub, + configurable: true, + writable: true, + }); + + sinon.stub(firebaseAdmin, 'initializeApp').returns({}); + sinon.stub(firebaseAdmin.INTERNAL.appStore, 'removeApp'); + + const fcmOptsWithoutLegacy = { + fcm: { + name: 'testAppNameNoLegacy', + credential: { getAccessToken: () => Promise.resolve({}) }, + }, + }; + + const pnWithoutLegacy = new PN(fcmOptsWithoutLegacy); + + pnWithoutLegacy + .send(regIds, message) + .then(() => { + expect(mockEnableLegacyHttpTransport.called).to.be.false; + // Restore + firebaseAdmin.initializeApp.restore(); + firebaseAdmin.INTERNAL.appStore.removeApp.restore(); + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', propertyDescriptor); + done(); + }) + .catch((err) => { + // Restore + firebaseAdmin.initializeApp.restore(); + firebaseAdmin.INTERNAL.appStore.removeApp.restore(); + // eslint-disable-next-line no-import-assign + Object.defineProperty(firebaseAdmin, 'messaging', propertyDescriptor); + done(err); + }); + }); + }); });