@openmdm/push-fcm 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.js +254 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/index.ts +414 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present OpenMDM Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import * as admin from 'firebase-admin';
|
|
2
|
+
import { DatabaseAdapter, PushAdapter } from '@openmdm/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenMDM FCM Push Adapter
|
|
6
|
+
*
|
|
7
|
+
* Firebase Cloud Messaging adapter for sending push notifications to Android devices.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { createMDM } from '@openmdm/core';
|
|
12
|
+
* import { fcmPushAdapter } from '@openmdm/push-fcm';
|
|
13
|
+
*
|
|
14
|
+
* const mdm = createMDM({
|
|
15
|
+
* database: drizzleAdapter(db),
|
|
16
|
+
* push: fcmPushAdapter({
|
|
17
|
+
* credential: admin.credential.cert(serviceAccount),
|
|
18
|
+
* // or: credentialPath: './service-account.json',
|
|
19
|
+
* }),
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
interface FCMAdapterOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Firebase Admin credential object
|
|
27
|
+
* Use admin.credential.cert(serviceAccount) or admin.credential.applicationDefault()
|
|
28
|
+
*/
|
|
29
|
+
credential?: admin.credential.Credential;
|
|
30
|
+
/**
|
|
31
|
+
* Path to service account JSON file
|
|
32
|
+
* Alternative to providing credential directly
|
|
33
|
+
*/
|
|
34
|
+
credentialPath?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Firebase project ID (optional, usually inferred from credential)
|
|
37
|
+
*/
|
|
38
|
+
projectId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Database adapter for storing/retrieving push tokens
|
|
41
|
+
*/
|
|
42
|
+
database?: DatabaseAdapter;
|
|
43
|
+
/**
|
|
44
|
+
* Whether to use data-only messages (default: true)
|
|
45
|
+
* Data-only messages wake the app even when in background
|
|
46
|
+
*/
|
|
47
|
+
dataOnly?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Default TTL for messages in seconds (default: 3600 = 1 hour)
|
|
50
|
+
*/
|
|
51
|
+
defaultTtl?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Android-specific notification options
|
|
54
|
+
*/
|
|
55
|
+
android?: {
|
|
56
|
+
priority?: 'high' | 'normal';
|
|
57
|
+
restrictedPackageName?: string;
|
|
58
|
+
directBootOk?: boolean;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Create an FCM push adapter for OpenMDM
|
|
63
|
+
*/
|
|
64
|
+
declare function fcmPushAdapter(options: FCMAdapterOptions): PushAdapter;
|
|
65
|
+
/**
|
|
66
|
+
* Create FCM adapter from environment variables
|
|
67
|
+
*
|
|
68
|
+
* Expects GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
69
|
+
* or FIREBASE_PROJECT_ID for application default credentials
|
|
70
|
+
*/
|
|
71
|
+
declare function fcmPushAdapterFromEnv(options?: Partial<FCMAdapterOptions>): PushAdapter;
|
|
72
|
+
/**
|
|
73
|
+
* Create FCM adapter from service account JSON
|
|
74
|
+
*/
|
|
75
|
+
declare function fcmPushAdapterFromServiceAccount(serviceAccount: admin.ServiceAccount | string, options?: Partial<FCMAdapterOptions>): PushAdapter;
|
|
76
|
+
|
|
77
|
+
export { type FCMAdapterOptions, fcmPushAdapter, fcmPushAdapterFromEnv, fcmPushAdapterFromServiceAccount };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// src/index.ts
|
|
9
|
+
import * as admin from "firebase-admin";
|
|
10
|
+
function fcmPushAdapter(options) {
|
|
11
|
+
let app2;
|
|
12
|
+
try {
|
|
13
|
+
app2 = admin.app("[openmdm]");
|
|
14
|
+
} catch {
|
|
15
|
+
const initOptions = {};
|
|
16
|
+
if (options.credential) {
|
|
17
|
+
initOptions.credential = options.credential;
|
|
18
|
+
} else if (options.credentialPath) {
|
|
19
|
+
const serviceAccount = __require(options.credentialPath);
|
|
20
|
+
initOptions.credential = admin.credential.cert(serviceAccount);
|
|
21
|
+
} else {
|
|
22
|
+
initOptions.credential = admin.credential.applicationDefault();
|
|
23
|
+
}
|
|
24
|
+
if (options.projectId) {
|
|
25
|
+
initOptions.projectId = options.projectId;
|
|
26
|
+
}
|
|
27
|
+
app2 = admin.initializeApp(initOptions, "[openmdm]");
|
|
28
|
+
}
|
|
29
|
+
const messaging = app2.messaging();
|
|
30
|
+
const database = options.database;
|
|
31
|
+
const dataOnly = options.dataOnly ?? true;
|
|
32
|
+
const defaultTtl = options.defaultTtl ?? 3600;
|
|
33
|
+
const tokenCache = /* @__PURE__ */ new Map();
|
|
34
|
+
async function getToken(deviceId) {
|
|
35
|
+
if (tokenCache.has(deviceId)) {
|
|
36
|
+
return tokenCache.get(deviceId);
|
|
37
|
+
}
|
|
38
|
+
if (database) {
|
|
39
|
+
const pushToken = await database.findPushToken(deviceId, "fcm");
|
|
40
|
+
if (pushToken?.token) {
|
|
41
|
+
tokenCache.set(deviceId, pushToken.token);
|
|
42
|
+
return pushToken.token;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
function buildMessage(token, message) {
|
|
48
|
+
const fcmMessage = {
|
|
49
|
+
token,
|
|
50
|
+
android: {
|
|
51
|
+
priority: message.priority === "high" ? "high" : "normal",
|
|
52
|
+
ttl: (message.ttl ?? defaultTtl) * 1e3,
|
|
53
|
+
// Convert to milliseconds
|
|
54
|
+
restrictedPackageName: options.android?.restrictedPackageName,
|
|
55
|
+
directBootOk: options.android?.directBootOk
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
if (dataOnly) {
|
|
59
|
+
fcmMessage.data = {
|
|
60
|
+
type: message.type,
|
|
61
|
+
payload: message.payload ? JSON.stringify(message.payload) : "{}",
|
|
62
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
63
|
+
};
|
|
64
|
+
if (message.collapseKey) {
|
|
65
|
+
fcmMessage.android.collapseKey = message.collapseKey;
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
fcmMessage.notification = {
|
|
69
|
+
title: "MDM Command",
|
|
70
|
+
body: message.type
|
|
71
|
+
};
|
|
72
|
+
fcmMessage.data = {
|
|
73
|
+
type: message.type,
|
|
74
|
+
payload: message.payload ? JSON.stringify(message.payload) : "{}"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return fcmMessage;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
async send(deviceId, message) {
|
|
81
|
+
try {
|
|
82
|
+
const token = await getToken(deviceId);
|
|
83
|
+
if (!token) {
|
|
84
|
+
console.warn(`[OpenMDM FCM] No token found for device ${deviceId}`);
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
error: "No FCM token registered for device"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const fcmMessage = buildMessage(token, message);
|
|
91
|
+
const messageId = await messaging.send(fcmMessage);
|
|
92
|
+
console.log(
|
|
93
|
+
`[OpenMDM FCM] Sent to ${deviceId}: ${message.type} (${messageId})`
|
|
94
|
+
);
|
|
95
|
+
return {
|
|
96
|
+
success: true,
|
|
97
|
+
messageId
|
|
98
|
+
};
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error(`[OpenMDM FCM] Error sending to ${deviceId}:`, error);
|
|
101
|
+
if (error.code === "messaging/invalid-registration-token" || error.code === "messaging/registration-token-not-registered") {
|
|
102
|
+
tokenCache.delete(deviceId);
|
|
103
|
+
if (database) {
|
|
104
|
+
await database.deletePushToken(deviceId, "fcm");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
error: error.message || "FCM send failed"
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
async sendBatch(deviceIds, message) {
|
|
114
|
+
const results = [];
|
|
115
|
+
let successCount = 0;
|
|
116
|
+
let failureCount = 0;
|
|
117
|
+
const tokensMap = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const deviceId of deviceIds) {
|
|
119
|
+
const token = await getToken(deviceId);
|
|
120
|
+
if (token) {
|
|
121
|
+
tokensMap.set(deviceId, token);
|
|
122
|
+
} else {
|
|
123
|
+
results.push({
|
|
124
|
+
deviceId,
|
|
125
|
+
result: {
|
|
126
|
+
success: false,
|
|
127
|
+
error: "No FCM token registered"
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
failureCount++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (tokensMap.size === 0) {
|
|
134
|
+
return { successCount, failureCount, results };
|
|
135
|
+
}
|
|
136
|
+
const messages = [];
|
|
137
|
+
const deviceIdOrder = [];
|
|
138
|
+
for (const [deviceId, token] of tokensMap) {
|
|
139
|
+
messages.push(buildMessage(token, message));
|
|
140
|
+
deviceIdOrder.push(deviceId);
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const batchSize = 500;
|
|
144
|
+
for (let i = 0; i < messages.length; i += batchSize) {
|
|
145
|
+
const batch = messages.slice(i, i + batchSize);
|
|
146
|
+
const batchDeviceIds = deviceIdOrder.slice(i, i + batchSize);
|
|
147
|
+
const response = await messaging.sendEach(batch);
|
|
148
|
+
response.responses.forEach((resp, index) => {
|
|
149
|
+
const deviceId = batchDeviceIds[index];
|
|
150
|
+
if (resp.success) {
|
|
151
|
+
results.push({
|
|
152
|
+
deviceId,
|
|
153
|
+
result: {
|
|
154
|
+
success: true,
|
|
155
|
+
messageId: resp.messageId
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
successCount++;
|
|
159
|
+
} else {
|
|
160
|
+
const error = resp.error;
|
|
161
|
+
if (error?.code === "messaging/invalid-registration-token" || error?.code === "messaging/registration-token-not-registered") {
|
|
162
|
+
tokenCache.delete(deviceId);
|
|
163
|
+
if (database) {
|
|
164
|
+
database.deletePushToken(deviceId, "fcm").catch(() => {
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
results.push({
|
|
169
|
+
deviceId,
|
|
170
|
+
result: {
|
|
171
|
+
success: false,
|
|
172
|
+
error: error?.message || "FCM send failed"
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
failureCount++;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
console.log(
|
|
180
|
+
`[OpenMDM FCM] Batch sent: ${successCount} success, ${failureCount} failed`
|
|
181
|
+
);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
console.error("[OpenMDM FCM] Batch send error:", error);
|
|
184
|
+
for (const deviceId of deviceIdOrder) {
|
|
185
|
+
if (!results.find((r) => r.deviceId === deviceId)) {
|
|
186
|
+
results.push({
|
|
187
|
+
deviceId,
|
|
188
|
+
result: {
|
|
189
|
+
success: false,
|
|
190
|
+
error: error.message || "FCM batch send failed"
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
failureCount++;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { successCount, failureCount, results };
|
|
198
|
+
},
|
|
199
|
+
async registerToken(deviceId, token) {
|
|
200
|
+
tokenCache.set(deviceId, token);
|
|
201
|
+
if (database) {
|
|
202
|
+
await database.upsertPushToken({
|
|
203
|
+
deviceId,
|
|
204
|
+
provider: "fcm",
|
|
205
|
+
token
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
console.log(`[OpenMDM FCM] Registered token for device ${deviceId}`);
|
|
209
|
+
},
|
|
210
|
+
async unregisterToken(deviceId) {
|
|
211
|
+
tokenCache.delete(deviceId);
|
|
212
|
+
if (database) {
|
|
213
|
+
await database.deletePushToken(deviceId, "fcm");
|
|
214
|
+
}
|
|
215
|
+
console.log(`[OpenMDM FCM] Unregistered token for device ${deviceId}`);
|
|
216
|
+
},
|
|
217
|
+
async subscribe(deviceId, topic) {
|
|
218
|
+
const token = await getToken(deviceId);
|
|
219
|
+
if (!token) {
|
|
220
|
+
throw new Error(`No FCM token for device ${deviceId}`);
|
|
221
|
+
}
|
|
222
|
+
await messaging.subscribeToTopic(token, topic);
|
|
223
|
+
console.log(`[OpenMDM FCM] Subscribed ${deviceId} to topic ${topic}`);
|
|
224
|
+
},
|
|
225
|
+
async unsubscribe(deviceId, topic) {
|
|
226
|
+
const token = await getToken(deviceId);
|
|
227
|
+
if (!token) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
await messaging.unsubscribeFromTopic(token, topic);
|
|
231
|
+
console.log(`[OpenMDM FCM] Unsubscribed ${deviceId} from topic ${topic}`);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function fcmPushAdapterFromEnv(options) {
|
|
236
|
+
return fcmPushAdapter({
|
|
237
|
+
...options,
|
|
238
|
+
credential: admin.credential.applicationDefault(),
|
|
239
|
+
projectId: process.env.FIREBASE_PROJECT_ID
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
function fcmPushAdapterFromServiceAccount(serviceAccount, options) {
|
|
243
|
+
const credential2 = typeof serviceAccount === "string" ? admin.credential.cert(JSON.parse(serviceAccount)) : admin.credential.cert(serviceAccount);
|
|
244
|
+
return fcmPushAdapter({
|
|
245
|
+
...options,
|
|
246
|
+
credential: credential2
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
export {
|
|
250
|
+
fcmPushAdapter,
|
|
251
|
+
fcmPushAdapterFromEnv,
|
|
252
|
+
fcmPushAdapterFromServiceAccount
|
|
253
|
+
};
|
|
254
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * OpenMDM FCM Push Adapter\n *\n * Firebase Cloud Messaging adapter for sending push notifications to Android devices.\n *\n * @example\n * ```typescript\n * import { createMDM } from '@openmdm/core';\n * import { fcmPushAdapter } from '@openmdm/push-fcm';\n *\n * const mdm = createMDM({\n * database: drizzleAdapter(db),\n * push: fcmPushAdapter({\n * credential: admin.credential.cert(serviceAccount),\n * // or: credentialPath: './service-account.json',\n * }),\n * });\n * ```\n */\n\nimport * as admin from 'firebase-admin';\nimport type {\n PushAdapter,\n PushMessage,\n PushResult,\n PushBatchResult,\n DatabaseAdapter,\n} from '@openmdm/core';\n\nexport interface FCMAdapterOptions {\n /**\n * Firebase Admin credential object\n * Use admin.credential.cert(serviceAccount) or admin.credential.applicationDefault()\n */\n credential?: admin.credential.Credential;\n\n /**\n * Path to service account JSON file\n * Alternative to providing credential directly\n */\n credentialPath?: string;\n\n /**\n * Firebase project ID (optional, usually inferred from credential)\n */\n projectId?: string;\n\n /**\n * Database adapter for storing/retrieving push tokens\n */\n database?: DatabaseAdapter;\n\n /**\n * Whether to use data-only messages (default: true)\n * Data-only messages wake the app even when in background\n */\n dataOnly?: boolean;\n\n /**\n * Default TTL for messages in seconds (default: 3600 = 1 hour)\n */\n defaultTtl?: number;\n\n /**\n * Android-specific notification options\n */\n android?: {\n priority?: 'high' | 'normal';\n restrictedPackageName?: string;\n directBootOk?: boolean;\n };\n}\n\n/**\n * Create an FCM push adapter for OpenMDM\n */\nexport function fcmPushAdapter(options: FCMAdapterOptions): PushAdapter {\n // Initialize Firebase Admin if not already initialized\n let app: admin.app.App;\n\n try {\n app = admin.app('[openmdm]');\n } catch {\n // App doesn't exist, create it\n const initOptions: admin.AppOptions = {};\n\n if (options.credential) {\n initOptions.credential = options.credential;\n } else if (options.credentialPath) {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const serviceAccount = require(options.credentialPath);\n initOptions.credential = admin.credential.cert(serviceAccount);\n } else {\n // Use application default credentials\n initOptions.credential = admin.credential.applicationDefault();\n }\n\n if (options.projectId) {\n initOptions.projectId = options.projectId;\n }\n\n app = admin.initializeApp(initOptions, '[openmdm]');\n }\n\n const messaging = app.messaging();\n const database = options.database;\n const dataOnly = options.dataOnly ?? true;\n const defaultTtl = options.defaultTtl ?? 3600;\n\n // Token cache: deviceId -> FCM token\n const tokenCache = new Map<string, string>();\n\n /**\n * Get FCM token for a device\n */\n async function getToken(deviceId: string): Promise<string | null> {\n // Check cache first\n if (tokenCache.has(deviceId)) {\n return tokenCache.get(deviceId)!;\n }\n\n // Get from database if available\n if (database) {\n const pushToken = await database.findPushToken(deviceId, 'fcm');\n if (pushToken?.token) {\n tokenCache.set(deviceId, pushToken.token);\n return pushToken.token;\n }\n }\n\n return null;\n }\n\n /**\n * Build FCM message from OpenMDM push message\n */\n function buildMessage(\n token: string,\n message: PushMessage\n ): admin.messaging.Message {\n const fcmMessage: admin.messaging.Message = {\n token,\n android: {\n priority: message.priority === 'high' ? 'high' : 'normal',\n ttl: (message.ttl ?? defaultTtl) * 1000, // Convert to milliseconds\n restrictedPackageName: options.android?.restrictedPackageName,\n directBootOk: options.android?.directBootOk,\n },\n };\n\n if (dataOnly) {\n // Data-only message - always wakes the app\n fcmMessage.data = {\n type: message.type,\n payload: message.payload ? JSON.stringify(message.payload) : '{}',\n timestamp: new Date().toISOString(),\n };\n\n if (message.collapseKey) {\n fcmMessage.android!.collapseKey = message.collapseKey;\n }\n } else {\n // Notification + data message\n fcmMessage.notification = {\n title: 'MDM Command',\n body: message.type,\n };\n fcmMessage.data = {\n type: message.type,\n payload: message.payload ? JSON.stringify(message.payload) : '{}',\n };\n }\n\n return fcmMessage;\n }\n\n return {\n async send(deviceId: string, message: PushMessage): Promise<PushResult> {\n try {\n const token = await getToken(deviceId);\n if (!token) {\n console.warn(`[OpenMDM FCM] No token found for device ${deviceId}`);\n return {\n success: false,\n error: 'No FCM token registered for device',\n };\n }\n\n const fcmMessage = buildMessage(token, message);\n const messageId = await messaging.send(fcmMessage);\n\n console.log(\n `[OpenMDM FCM] Sent to ${deviceId}: ${message.type} (${messageId})`\n );\n\n return {\n success: true,\n messageId,\n };\n } catch (error: any) {\n console.error(`[OpenMDM FCM] Error sending to ${deviceId}:`, error);\n\n // Handle invalid token\n if (\n error.code === 'messaging/invalid-registration-token' ||\n error.code === 'messaging/registration-token-not-registered'\n ) {\n // Remove invalid token from cache and database\n tokenCache.delete(deviceId);\n if (database) {\n await database.deletePushToken(deviceId, 'fcm');\n }\n }\n\n return {\n success: false,\n error: error.message || 'FCM send failed',\n };\n }\n },\n\n async sendBatch(\n deviceIds: string[],\n message: PushMessage\n ): Promise<PushBatchResult> {\n const results: Array<{ deviceId: string; result: PushResult }> = [];\n let successCount = 0;\n let failureCount = 0;\n\n // Get tokens for all devices\n const tokensMap = new Map<string, string>();\n for (const deviceId of deviceIds) {\n const token = await getToken(deviceId);\n if (token) {\n tokensMap.set(deviceId, token);\n } else {\n results.push({\n deviceId,\n result: {\n success: false,\n error: 'No FCM token registered',\n },\n });\n failureCount++;\n }\n }\n\n if (tokensMap.size === 0) {\n return { successCount, failureCount, results };\n }\n\n // Build messages for batch send\n const messages: admin.messaging.Message[] = [];\n const deviceIdOrder: string[] = [];\n\n for (const [deviceId, token] of tokensMap) {\n messages.push(buildMessage(token, message));\n deviceIdOrder.push(deviceId);\n }\n\n try {\n // Send batch (max 500 messages per call)\n const batchSize = 500;\n for (let i = 0; i < messages.length; i += batchSize) {\n const batch = messages.slice(i, i + batchSize);\n const batchDeviceIds = deviceIdOrder.slice(i, i + batchSize);\n\n const response = await messaging.sendEach(batch);\n\n response.responses.forEach((resp, index) => {\n const deviceId = batchDeviceIds[index];\n\n if (resp.success) {\n results.push({\n deviceId,\n result: {\n success: true,\n messageId: resp.messageId,\n },\n });\n successCount++;\n } else {\n const error = resp.error;\n\n // Handle invalid token\n if (\n error?.code === 'messaging/invalid-registration-token' ||\n error?.code === 'messaging/registration-token-not-registered'\n ) {\n tokenCache.delete(deviceId);\n if (database) {\n database.deletePushToken(deviceId, 'fcm').catch(() => {});\n }\n }\n\n results.push({\n deviceId,\n result: {\n success: false,\n error: error?.message || 'FCM send failed',\n },\n });\n failureCount++;\n }\n });\n }\n\n console.log(\n `[OpenMDM FCM] Batch sent: ${successCount} success, ${failureCount} failed`\n );\n } catch (error: any) {\n console.error('[OpenMDM FCM] Batch send error:', error);\n\n // Mark all remaining as failed\n for (const deviceId of deviceIdOrder) {\n if (!results.find((r) => r.deviceId === deviceId)) {\n results.push({\n deviceId,\n result: {\n success: false,\n error: error.message || 'FCM batch send failed',\n },\n });\n failureCount++;\n }\n }\n }\n\n return { successCount, failureCount, results };\n },\n\n async registerToken(deviceId: string, token: string): Promise<void> {\n // Update cache\n tokenCache.set(deviceId, token);\n\n // Store in database if available\n if (database) {\n await database.upsertPushToken({\n deviceId,\n provider: 'fcm',\n token,\n });\n }\n\n console.log(`[OpenMDM FCM] Registered token for device ${deviceId}`);\n },\n\n async unregisterToken(deviceId: string): Promise<void> {\n // Remove from cache\n tokenCache.delete(deviceId);\n\n // Remove from database if available\n if (database) {\n await database.deletePushToken(deviceId, 'fcm');\n }\n\n console.log(`[OpenMDM FCM] Unregistered token for device ${deviceId}`);\n },\n\n async subscribe(deviceId: string, topic: string): Promise<void> {\n const token = await getToken(deviceId);\n if (!token) {\n throw new Error(`No FCM token for device ${deviceId}`);\n }\n\n await messaging.subscribeToTopic(token, topic);\n console.log(`[OpenMDM FCM] Subscribed ${deviceId} to topic ${topic}`);\n },\n\n async unsubscribe(deviceId: string, topic: string): Promise<void> {\n const token = await getToken(deviceId);\n if (!token) {\n return; // Nothing to unsubscribe\n }\n\n await messaging.unsubscribeFromTopic(token, topic);\n console.log(`[OpenMDM FCM] Unsubscribed ${deviceId} from topic ${topic}`);\n },\n };\n}\n\n/**\n * Create FCM adapter from environment variables\n *\n * Expects GOOGLE_APPLICATION_CREDENTIALS environment variable\n * or FIREBASE_PROJECT_ID for application default credentials\n */\nexport function fcmPushAdapterFromEnv(\n options?: Partial<FCMAdapterOptions>\n): PushAdapter {\n return fcmPushAdapter({\n ...options,\n credential: admin.credential.applicationDefault(),\n projectId: process.env.FIREBASE_PROJECT_ID,\n });\n}\n\n/**\n * Create FCM adapter from service account JSON\n */\nexport function fcmPushAdapterFromServiceAccount(\n serviceAccount: admin.ServiceAccount | string,\n options?: Partial<FCMAdapterOptions>\n): PushAdapter {\n const credential =\n typeof serviceAccount === 'string'\n ? admin.credential.cert(JSON.parse(serviceAccount))\n : admin.credential.cert(serviceAccount);\n\n return fcmPushAdapter({\n ...options,\n credential,\n });\n}\n"],"mappings":"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","names":["app","credential"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openmdm/push-fcm",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Firebase Cloud Messaging push adapter for OpenMDM",
|
|
5
|
+
"author": "OpenMDM Contributors",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"firebase-admin": "^13.0.0",
|
|
23
|
+
"@openmdm/core": "0.2.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsup": "^8.0.0",
|
|
27
|
+
"typescript": "^5.5.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"firebase-admin": ">=12.0.0"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"openmdm",
|
|
34
|
+
"fcm",
|
|
35
|
+
"firebase",
|
|
36
|
+
"push",
|
|
37
|
+
"notifications"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/azoila/openmdm.git",
|
|
43
|
+
"directory": "packages/push/fcm"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://openmdm.dev",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/azoila/openmdm/issues"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsup",
|
|
54
|
+
"dev": "tsup --watch",
|
|
55
|
+
"typecheck": "tsc --noEmit",
|
|
56
|
+
"clean": "rm -rf dist"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM FCM Push Adapter
|
|
3
|
+
*
|
|
4
|
+
* Firebase Cloud Messaging adapter for sending push notifications to Android devices.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { createMDM } from '@openmdm/core';
|
|
9
|
+
* import { fcmPushAdapter } from '@openmdm/push-fcm';
|
|
10
|
+
*
|
|
11
|
+
* const mdm = createMDM({
|
|
12
|
+
* database: drizzleAdapter(db),
|
|
13
|
+
* push: fcmPushAdapter({
|
|
14
|
+
* credential: admin.credential.cert(serviceAccount),
|
|
15
|
+
* // or: credentialPath: './service-account.json',
|
|
16
|
+
* }),
|
|
17
|
+
* });
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as admin from 'firebase-admin';
|
|
22
|
+
import type {
|
|
23
|
+
PushAdapter,
|
|
24
|
+
PushMessage,
|
|
25
|
+
PushResult,
|
|
26
|
+
PushBatchResult,
|
|
27
|
+
DatabaseAdapter,
|
|
28
|
+
} from '@openmdm/core';
|
|
29
|
+
|
|
30
|
+
export interface FCMAdapterOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Firebase Admin credential object
|
|
33
|
+
* Use admin.credential.cert(serviceAccount) or admin.credential.applicationDefault()
|
|
34
|
+
*/
|
|
35
|
+
credential?: admin.credential.Credential;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Path to service account JSON file
|
|
39
|
+
* Alternative to providing credential directly
|
|
40
|
+
*/
|
|
41
|
+
credentialPath?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Firebase project ID (optional, usually inferred from credential)
|
|
45
|
+
*/
|
|
46
|
+
projectId?: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Database adapter for storing/retrieving push tokens
|
|
50
|
+
*/
|
|
51
|
+
database?: DatabaseAdapter;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether to use data-only messages (default: true)
|
|
55
|
+
* Data-only messages wake the app even when in background
|
|
56
|
+
*/
|
|
57
|
+
dataOnly?: boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Default TTL for messages in seconds (default: 3600 = 1 hour)
|
|
61
|
+
*/
|
|
62
|
+
defaultTtl?: number;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Android-specific notification options
|
|
66
|
+
*/
|
|
67
|
+
android?: {
|
|
68
|
+
priority?: 'high' | 'normal';
|
|
69
|
+
restrictedPackageName?: string;
|
|
70
|
+
directBootOk?: boolean;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create an FCM push adapter for OpenMDM
|
|
76
|
+
*/
|
|
77
|
+
export function fcmPushAdapter(options: FCMAdapterOptions): PushAdapter {
|
|
78
|
+
// Initialize Firebase Admin if not already initialized
|
|
79
|
+
let app: admin.app.App;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
app = admin.app('[openmdm]');
|
|
83
|
+
} catch {
|
|
84
|
+
// App doesn't exist, create it
|
|
85
|
+
const initOptions: admin.AppOptions = {};
|
|
86
|
+
|
|
87
|
+
if (options.credential) {
|
|
88
|
+
initOptions.credential = options.credential;
|
|
89
|
+
} else if (options.credentialPath) {
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
91
|
+
const serviceAccount = require(options.credentialPath);
|
|
92
|
+
initOptions.credential = admin.credential.cert(serviceAccount);
|
|
93
|
+
} else {
|
|
94
|
+
// Use application default credentials
|
|
95
|
+
initOptions.credential = admin.credential.applicationDefault();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (options.projectId) {
|
|
99
|
+
initOptions.projectId = options.projectId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
app = admin.initializeApp(initOptions, '[openmdm]');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const messaging = app.messaging();
|
|
106
|
+
const database = options.database;
|
|
107
|
+
const dataOnly = options.dataOnly ?? true;
|
|
108
|
+
const defaultTtl = options.defaultTtl ?? 3600;
|
|
109
|
+
|
|
110
|
+
// Token cache: deviceId -> FCM token
|
|
111
|
+
const tokenCache = new Map<string, string>();
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get FCM token for a device
|
|
115
|
+
*/
|
|
116
|
+
async function getToken(deviceId: string): Promise<string | null> {
|
|
117
|
+
// Check cache first
|
|
118
|
+
if (tokenCache.has(deviceId)) {
|
|
119
|
+
return tokenCache.get(deviceId)!;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Get from database if available
|
|
123
|
+
if (database) {
|
|
124
|
+
const pushToken = await database.findPushToken(deviceId, 'fcm');
|
|
125
|
+
if (pushToken?.token) {
|
|
126
|
+
tokenCache.set(deviceId, pushToken.token);
|
|
127
|
+
return pushToken.token;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Build FCM message from OpenMDM push message
|
|
136
|
+
*/
|
|
137
|
+
function buildMessage(
|
|
138
|
+
token: string,
|
|
139
|
+
message: PushMessage
|
|
140
|
+
): admin.messaging.Message {
|
|
141
|
+
const fcmMessage: admin.messaging.Message = {
|
|
142
|
+
token,
|
|
143
|
+
android: {
|
|
144
|
+
priority: message.priority === 'high' ? 'high' : 'normal',
|
|
145
|
+
ttl: (message.ttl ?? defaultTtl) * 1000, // Convert to milliseconds
|
|
146
|
+
restrictedPackageName: options.android?.restrictedPackageName,
|
|
147
|
+
directBootOk: options.android?.directBootOk,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (dataOnly) {
|
|
152
|
+
// Data-only message - always wakes the app
|
|
153
|
+
fcmMessage.data = {
|
|
154
|
+
type: message.type,
|
|
155
|
+
payload: message.payload ? JSON.stringify(message.payload) : '{}',
|
|
156
|
+
timestamp: new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (message.collapseKey) {
|
|
160
|
+
fcmMessage.android!.collapseKey = message.collapseKey;
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
// Notification + data message
|
|
164
|
+
fcmMessage.notification = {
|
|
165
|
+
title: 'MDM Command',
|
|
166
|
+
body: message.type,
|
|
167
|
+
};
|
|
168
|
+
fcmMessage.data = {
|
|
169
|
+
type: message.type,
|
|
170
|
+
payload: message.payload ? JSON.stringify(message.payload) : '{}',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return fcmMessage;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
async send(deviceId: string, message: PushMessage): Promise<PushResult> {
|
|
179
|
+
try {
|
|
180
|
+
const token = await getToken(deviceId);
|
|
181
|
+
if (!token) {
|
|
182
|
+
console.warn(`[OpenMDM FCM] No token found for device ${deviceId}`);
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
error: 'No FCM token registered for device',
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fcmMessage = buildMessage(token, message);
|
|
190
|
+
const messageId = await messaging.send(fcmMessage);
|
|
191
|
+
|
|
192
|
+
console.log(
|
|
193
|
+
`[OpenMDM FCM] Sent to ${deviceId}: ${message.type} (${messageId})`
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
success: true,
|
|
198
|
+
messageId,
|
|
199
|
+
};
|
|
200
|
+
} catch (error: any) {
|
|
201
|
+
console.error(`[OpenMDM FCM] Error sending to ${deviceId}:`, error);
|
|
202
|
+
|
|
203
|
+
// Handle invalid token
|
|
204
|
+
if (
|
|
205
|
+
error.code === 'messaging/invalid-registration-token' ||
|
|
206
|
+
error.code === 'messaging/registration-token-not-registered'
|
|
207
|
+
) {
|
|
208
|
+
// Remove invalid token from cache and database
|
|
209
|
+
tokenCache.delete(deviceId);
|
|
210
|
+
if (database) {
|
|
211
|
+
await database.deletePushToken(deviceId, 'fcm');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
error: error.message || 'FCM send failed',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async sendBatch(
|
|
223
|
+
deviceIds: string[],
|
|
224
|
+
message: PushMessage
|
|
225
|
+
): Promise<PushBatchResult> {
|
|
226
|
+
const results: Array<{ deviceId: string; result: PushResult }> = [];
|
|
227
|
+
let successCount = 0;
|
|
228
|
+
let failureCount = 0;
|
|
229
|
+
|
|
230
|
+
// Get tokens for all devices
|
|
231
|
+
const tokensMap = new Map<string, string>();
|
|
232
|
+
for (const deviceId of deviceIds) {
|
|
233
|
+
const token = await getToken(deviceId);
|
|
234
|
+
if (token) {
|
|
235
|
+
tokensMap.set(deviceId, token);
|
|
236
|
+
} else {
|
|
237
|
+
results.push({
|
|
238
|
+
deviceId,
|
|
239
|
+
result: {
|
|
240
|
+
success: false,
|
|
241
|
+
error: 'No FCM token registered',
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
failureCount++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (tokensMap.size === 0) {
|
|
249
|
+
return { successCount, failureCount, results };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Build messages for batch send
|
|
253
|
+
const messages: admin.messaging.Message[] = [];
|
|
254
|
+
const deviceIdOrder: string[] = [];
|
|
255
|
+
|
|
256
|
+
for (const [deviceId, token] of tokensMap) {
|
|
257
|
+
messages.push(buildMessage(token, message));
|
|
258
|
+
deviceIdOrder.push(deviceId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Send batch (max 500 messages per call)
|
|
263
|
+
const batchSize = 500;
|
|
264
|
+
for (let i = 0; i < messages.length; i += batchSize) {
|
|
265
|
+
const batch = messages.slice(i, i + batchSize);
|
|
266
|
+
const batchDeviceIds = deviceIdOrder.slice(i, i + batchSize);
|
|
267
|
+
|
|
268
|
+
const response = await messaging.sendEach(batch);
|
|
269
|
+
|
|
270
|
+
response.responses.forEach((resp, index) => {
|
|
271
|
+
const deviceId = batchDeviceIds[index];
|
|
272
|
+
|
|
273
|
+
if (resp.success) {
|
|
274
|
+
results.push({
|
|
275
|
+
deviceId,
|
|
276
|
+
result: {
|
|
277
|
+
success: true,
|
|
278
|
+
messageId: resp.messageId,
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
successCount++;
|
|
282
|
+
} else {
|
|
283
|
+
const error = resp.error;
|
|
284
|
+
|
|
285
|
+
// Handle invalid token
|
|
286
|
+
if (
|
|
287
|
+
error?.code === 'messaging/invalid-registration-token' ||
|
|
288
|
+
error?.code === 'messaging/registration-token-not-registered'
|
|
289
|
+
) {
|
|
290
|
+
tokenCache.delete(deviceId);
|
|
291
|
+
if (database) {
|
|
292
|
+
database.deletePushToken(deviceId, 'fcm').catch(() => {});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
results.push({
|
|
297
|
+
deviceId,
|
|
298
|
+
result: {
|
|
299
|
+
success: false,
|
|
300
|
+
error: error?.message || 'FCM send failed',
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
failureCount++;
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
console.log(
|
|
309
|
+
`[OpenMDM FCM] Batch sent: ${successCount} success, ${failureCount} failed`
|
|
310
|
+
);
|
|
311
|
+
} catch (error: any) {
|
|
312
|
+
console.error('[OpenMDM FCM] Batch send error:', error);
|
|
313
|
+
|
|
314
|
+
// Mark all remaining as failed
|
|
315
|
+
for (const deviceId of deviceIdOrder) {
|
|
316
|
+
if (!results.find((r) => r.deviceId === deviceId)) {
|
|
317
|
+
results.push({
|
|
318
|
+
deviceId,
|
|
319
|
+
result: {
|
|
320
|
+
success: false,
|
|
321
|
+
error: error.message || 'FCM batch send failed',
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
failureCount++;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { successCount, failureCount, results };
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async registerToken(deviceId: string, token: string): Promise<void> {
|
|
333
|
+
// Update cache
|
|
334
|
+
tokenCache.set(deviceId, token);
|
|
335
|
+
|
|
336
|
+
// Store in database if available
|
|
337
|
+
if (database) {
|
|
338
|
+
await database.upsertPushToken({
|
|
339
|
+
deviceId,
|
|
340
|
+
provider: 'fcm',
|
|
341
|
+
token,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
console.log(`[OpenMDM FCM] Registered token for device ${deviceId}`);
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
async unregisterToken(deviceId: string): Promise<void> {
|
|
349
|
+
// Remove from cache
|
|
350
|
+
tokenCache.delete(deviceId);
|
|
351
|
+
|
|
352
|
+
// Remove from database if available
|
|
353
|
+
if (database) {
|
|
354
|
+
await database.deletePushToken(deviceId, 'fcm');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
console.log(`[OpenMDM FCM] Unregistered token for device ${deviceId}`);
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
async subscribe(deviceId: string, topic: string): Promise<void> {
|
|
361
|
+
const token = await getToken(deviceId);
|
|
362
|
+
if (!token) {
|
|
363
|
+
throw new Error(`No FCM token for device ${deviceId}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await messaging.subscribeToTopic(token, topic);
|
|
367
|
+
console.log(`[OpenMDM FCM] Subscribed ${deviceId} to topic ${topic}`);
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
async unsubscribe(deviceId: string, topic: string): Promise<void> {
|
|
371
|
+
const token = await getToken(deviceId);
|
|
372
|
+
if (!token) {
|
|
373
|
+
return; // Nothing to unsubscribe
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
await messaging.unsubscribeFromTopic(token, topic);
|
|
377
|
+
console.log(`[OpenMDM FCM] Unsubscribed ${deviceId} from topic ${topic}`);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Create FCM adapter from environment variables
|
|
384
|
+
*
|
|
385
|
+
* Expects GOOGLE_APPLICATION_CREDENTIALS environment variable
|
|
386
|
+
* or FIREBASE_PROJECT_ID for application default credentials
|
|
387
|
+
*/
|
|
388
|
+
export function fcmPushAdapterFromEnv(
|
|
389
|
+
options?: Partial<FCMAdapterOptions>
|
|
390
|
+
): PushAdapter {
|
|
391
|
+
return fcmPushAdapter({
|
|
392
|
+
...options,
|
|
393
|
+
credential: admin.credential.applicationDefault(),
|
|
394
|
+
projectId: process.env.FIREBASE_PROJECT_ID,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Create FCM adapter from service account JSON
|
|
400
|
+
*/
|
|
401
|
+
export function fcmPushAdapterFromServiceAccount(
|
|
402
|
+
serviceAccount: admin.ServiceAccount | string,
|
|
403
|
+
options?: Partial<FCMAdapterOptions>
|
|
404
|
+
): PushAdapter {
|
|
405
|
+
const credential =
|
|
406
|
+
typeof serviceAccount === 'string'
|
|
407
|
+
? admin.credential.cert(JSON.parse(serviceAccount))
|
|
408
|
+
: admin.credential.cert(serviceAccount);
|
|
409
|
+
|
|
410
|
+
return fcmPushAdapter({
|
|
411
|
+
...options,
|
|
412
|
+
credential,
|
|
413
|
+
});
|
|
414
|
+
}
|