@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 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.
@@ -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
+ }