Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`
Expand All @@ -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.

Expand All @@ -89,6 +92,7 @@ All optional properties are dynamically added to Firebase initialization if defi
### Message Format

Firebase Admin SDK expects:

```javascript
{
tokens: [...],
Expand Down Expand Up @@ -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.)
Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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`
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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'];
Expand Down
16 changes: 13 additions & 3 deletions src/sendFCM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 = [];
Expand Down
167 changes: 123 additions & 44 deletions test/send/sendFCM.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand All @@ -208,10 +169,9 @@ describe('push-notifications-fcm', () => {

const fcmOptsWithProxy = {
fcm: {
name: 'testAppNameProxyBoth',
name: 'testAppNameProxyHttpsViaHttpAgent',
credential: { getAccessToken: () => Promise.resolve({}) },
httpAgent: mockHttpAgent,
httpsAgent: mockHttpsAgent,
},
};

Expand All @@ -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();
})
Expand Down Expand Up @@ -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);
});
});
});
});