@arkstack/notifications 0.16.10 → 0.16.11

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/dist/index.d.ts CHANGED
@@ -67,6 +67,8 @@ type FirebaseTransportConfig = {
67
67
  project_id?: string;
68
68
  client_email?: string;
69
69
  private_key?: string;
70
+ app_name?: string;
71
+ admin_sdk_path?: string;
70
72
  };
71
73
  type RealtimeDriverOptions = {
72
74
  transport?: RealtimeDriverName; /** Channel/topic to broadcast on. Defaults to `${channel_prefix}${user.id}`. */
@@ -90,7 +92,7 @@ type RealtimeNotificationPayload = {
90
92
  };
91
93
  /** The result of a realtime broadcast (plus the stored record when `store` is on). */
92
94
  type RealtimeBroadcastResult = {
93
- channel: string;
95
+ channel: string | string[];
94
96
  event: string;
95
97
  payload: RealtimeNotificationPayload;
96
98
  stored?: UserNotification;
@@ -186,6 +188,10 @@ interface NotificationConfig {
186
188
  project_id: string;
187
189
  client_email: string;
188
190
  private_key: string;
191
+ app_name?: string;
192
+ } | {
193
+ app_name?: string;
194
+ admin_sdk_path: string;
189
195
  };
190
196
  };
191
197
  }
@@ -290,9 +296,13 @@ declare class MailNotification extends NotificationContract {
290
296
  /**
291
297
  * A realtime transport (Pusher, Firebase, …) broadcasts a notification payload
292
298
  * to a channel/topic that connected clients subscribe to.
299
+ *
300
+ * `channel` may be an array: for Pusher it fans out to multiple channels, and
301
+ * for Firebase it is treated as a list of device registration tokens delivered
302
+ * via a multicast send.
293
303
  */
294
304
  interface RealtimeDriver {
295
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
305
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
296
306
  }
297
307
  //#endregion
298
308
  //#region src/drivers/RealtimeNotification.d.ts
@@ -316,7 +326,8 @@ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcas
316
326
  from(_from: string): this;
317
327
  subject(subject: string): this;
318
328
  /**
319
- * Set the recipient: a `User` (derives the channel) or an explicit channel string.
329
+ * Set the recipient: a `User` (derives the channel), an explicit channel
330
+ * string, or an array of channels (Pusher) / device tokens (Firebase).
320
331
  *
321
332
  * @param recipient
322
333
  * @returns
@@ -324,11 +335,14 @@ declare class RealtimeNotification extends NotificationContract<RealtimeBroadcas
324
335
  recipient(recipient: NotificationRecipient | User): this;
325
336
  /**
326
337
  * Broadcast on an explicit channel/topic instead of the per-user default.
327
- *
328
- * @param channel
329
- * @returns
330
- */
338
+ * An array broadcasts to multiple Pusher channels, or — for Firebase — to a
339
+ * list of device registration tokens (multicast).
340
+ *
341
+ * @param channel
342
+ * @returns
343
+ */
331
344
  channel(channel: string): this;
345
+ channel(channel: string[]): this;
332
346
  /**
333
347
  * The event name clients subscribe to (default `notification`).
334
348
  *
@@ -430,13 +444,26 @@ declare class PusherRealtimeDriver implements RealtimeDriver {
430
444
  private clientPromise?;
431
445
  constructor(options?: PusherTransportConfig);
432
446
  private client;
433
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
447
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<unknown>;
434
448
  }
435
449
  //#endregion
436
450
  //#region src/drivers/realtime/FirebaseRealtimeDriver.d.ts
437
451
  /**
438
- * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
439
- * topics (the channel name maps to an FCM topic).
452
+ * The outcome of a token multicast: totals plus the tokens FCM rejected as dead.
453
+ */
454
+ interface FirebaseMulticastResult {
455
+ successCount: number;
456
+ failureCount: number;
457
+ /**
458
+ * Tokens FCM reported as unregistered/invalid — delete these from your store.
459
+ */
460
+ invalidTokens: string[];
461
+ }
462
+ /**
463
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
464
+ * A single channel maps to an FCM topic; an array of channels is treated as
465
+ * device registration tokens and delivered via a multicast send (chunked to
466
+ * FCM's 500-token limit), returning the tokens that should be pruned.
440
467
  *
441
468
  * `firebase-admin` is an optional peer dependency, imported lazily so the
442
469
  * package installs without it; it is only required when this transport is used.
@@ -447,10 +474,15 @@ declare class FirebaseRealtimeDriver implements RealtimeDriver {
447
474
  private messagingPromise?;
448
475
  constructor(options?: FirebaseTransportConfig);
449
476
  private messaging;
450
- broadcast(channel: string, event: string, payload: RealtimeNotificationPayload): Promise<string>;
477
+ broadcast(channel: string | string[], event: string, payload: RealtimeNotificationPayload): Promise<string | FirebaseMulticastResult>;
478
+ /**
479
+ * Send to many device tokens at once, chunked to FCM's 500-token limit, and
480
+ * collect the tokens FCM rejects as dead so the caller can prune them.
481
+ */
482
+ private multicast;
451
483
  }
452
484
  //#endregion
453
485
  //#region src/utils/template.d.ts
454
486
  declare const interpolate: (value: string, data?: NotificationData) => string;
455
487
  //#endregion
456
- export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastResult, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
488
+ export { AfricasTalkingSmsDriver, DbNotification, DbNotificationPayload, DbNotificationType, DriverResult, FirebaseMulticastResult, FirebaseRealtimeDriver, FirebaseTransportConfig, MailDriverOptions, MailNotification, MailRecipient, MailRecipientAddress, MergedTransportConfig, Notification, NotificationChannel, NotificationConfig, NotificationContract, NotificationData, NotificationDriverMap, NotificationRecipient, PusherRealtimeDriver, PusherTransportConfig, RealtimeBroadcastResult, RealtimeDriver, RealtimeDriverName, RealtimeDriverOptions, RealtimeNotification, RealtimeNotificationPayload, SmsDriverName, SmsDriverOptions, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { config, env, getModel } from "@arkstack/common";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import path, { join } from "node:path";
4
4
  import nodemailer from "nodemailer";
5
5
  import { Arkstack } from "@arkstack/contract";
6
+ import { existsSync, readFileSync } from "node:fs";
6
7
  import { randomUUID } from "node:crypto";
7
8
  import africastalking from "africastalking";
8
9
  import twilio from "twilio";
@@ -323,9 +324,19 @@ var MailNotification = class extends NotificationContract {
323
324
  };
324
325
  //#endregion
325
326
  //#region src/drivers/realtime/FirebaseRealtimeDriver.ts
327
+ /** FCM caps a multicast at 500 tokens per call. */
328
+ const MULTICAST_LIMIT = 500;
329
+ /** Error codes that mean a token is dead and should be pruned by the app. */
330
+ const DEAD_TOKEN_CODES = /* @__PURE__ */ new Set([
331
+ "messaging/registration-token-not-registered",
332
+ "messaging/invalid-registration-token",
333
+ "messaging/invalid-argument"
334
+ ]);
326
335
  /**
327
- * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging)
328
- * topics (the channel name maps to an FCM topic).
336
+ * Broadcasts notifications over [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging).
337
+ * A single channel maps to an FCM topic; an array of channels is treated as
338
+ * device registration tokens and delivered via a multicast send (chunked to
339
+ * FCM's 500-token limit), returning the tokens that should be pruned.
329
340
  *
330
341
  * `firebase-admin` is an optional peer dependency, imported lazily so the
331
342
  * package installs without it; it is only required when this transport is used.
@@ -338,16 +349,23 @@ var FirebaseRealtimeDriver = class {
338
349
  this.options = options;
339
350
  }
340
351
  messaging() {
352
+ const appSpecifier = "firebase-admin/app";
353
+ const messagingSpecifier = "firebase-admin/messaging";
341
354
  this.messagingPromise ??= (async () => {
342
- const [app, messaging] = await Promise.all([import("firebase-admin/app"), import("firebase-admin/messaging")]).catch(() => {
355
+ const [app, messaging] = await Promise.all([import(appSpecifier), import(messagingSpecifier)]).catch(() => {
343
356
  throw new Error("The \"firebase-admin\" package is required for the Firebase realtime transport. Install it with `npm i firebase-admin`.");
344
357
  });
345
- const credential = app.cert({
358
+ const adminsdk = path.join(process.cwd(), this.options.admin_sdk_path ?? env("FIREBASE_ADMINSDK", "firebase-adminsdk.json"));
359
+ let serviceAccount;
360
+ try {
361
+ if (existsSync(adminsdk)) serviceAccount = JSON.parse(readFileSync(adminsdk, { encoding: "utf-8" }));
362
+ } catch {}
363
+ const credential = app.cert(serviceAccount ?? {
346
364
  projectId: this.options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
347
365
  clientEmail: this.options.client_email ?? env("FIREBASE_CLIENT_EMAIL", ""),
348
366
  privateKey: (this.options.private_key ?? env("FIREBASE_PRIVATE_KEY", ""))?.replace(/\\n/g, "\n")
349
367
  });
350
- const name = "arkstack-realtime";
368
+ const name = String(this.options.app_name ?? env("FIREBASE_APP_NAME", "arkstack-realtime")).replaceAll(" ", "-").toLowerCase();
351
369
  const instance = app.getApps().find((a) => a.name === name) ?? app.initializeApp({ credential }, name);
352
370
  return messaging.getMessaging(instance);
353
371
  })();
@@ -355,15 +373,41 @@ var FirebaseRealtimeDriver = class {
355
373
  }
356
374
  async broadcast(channel, event, payload) {
357
375
  const messaging = await this.messaging();
376
+ const data = {
377
+ event,
378
+ payload: JSON.stringify(payload)
379
+ };
380
+ if (Array.isArray(channel)) return await this.multicast(messaging, channel, data);
358
381
  const topic = channel.replace(/[^a-zA-Z0-9-_.~%]/g, "_");
359
382
  return await messaging.send({
360
383
  topic,
361
- data: {
362
- event,
363
- payload: JSON.stringify(payload)
364
- }
384
+ data
365
385
  });
366
386
  }
387
+ /**
388
+ * Send to many device tokens at once, chunked to FCM's 500-token limit, and
389
+ * collect the tokens FCM rejects as dead so the caller can prune them.
390
+ */
391
+ async multicast(messaging, tokens, data) {
392
+ const result = {
393
+ successCount: 0,
394
+ failureCount: 0,
395
+ invalidTokens: []
396
+ };
397
+ for (let i = 0; i < tokens.length; i += MULTICAST_LIMIT) {
398
+ const batch = tokens.slice(i, i + MULTICAST_LIMIT);
399
+ const response = await messaging.sendEachForMulticast({
400
+ tokens: batch,
401
+ data
402
+ });
403
+ result.successCount += response.successCount;
404
+ result.failureCount += response.failureCount;
405
+ response.responses.forEach((res, index) => {
406
+ if (!res.success && res.error?.code && DEAD_TOKEN_CODES.has(res.error.code)) result.invalidTokens.push(batch[index]);
407
+ });
408
+ }
409
+ return result;
410
+ }
367
411
  };
368
412
  //#endregion
369
413
  //#region src/drivers/realtime/PusherRealtimeDriver.ts
@@ -380,8 +424,9 @@ var PusherRealtimeDriver = class {
380
424
  this.options = options;
381
425
  }
382
426
  client() {
427
+ const specifier = "pusher";
383
428
  this.clientPromise ??= (async () => {
384
- const mod = await import("pusher").catch(() => {
429
+ const mod = await import(specifier).catch(() => {
385
430
  throw new Error("The \"pusher\" package is required for the Pusher realtime transport. Install it with `npm i pusher`.");
386
431
  });
387
432
  return new (mod.default ?? mod)({
@@ -440,29 +485,24 @@ var RealtimeNotification = class extends NotificationContract {
440
485
  this.payload.title = subject;
441
486
  return this;
442
487
  }
443
- /**
444
- * Set the recipient: a `User` (derives the channel) or an explicit channel string.
445
- *
446
- * @param recipient
447
- * @returns
488
+ /**
489
+ * Set the recipient: a `User` (derives the channel), an explicit channel
490
+ * string, or an array of channels (Pusher) / device tokens (Firebase).
491
+ *
492
+ * @param recipient
493
+ * @returns
448
494
  */
449
495
  recipient(recipient) {
450
496
  if (typeof recipient === "object" && !Array.isArray(recipient) && typeof recipient.id !== "undefined") {
451
497
  this.user = recipient;
452
498
  return this;
453
499
  }
454
- if (typeof recipient === "string") {
500
+ if (typeof recipient === "string" || Array.isArray(recipient)) {
455
501
  this.channelName = recipient;
456
502
  return this;
457
503
  }
458
504
  throw new Error("Realtime notifications require a user recipient or a channel name");
459
505
  }
460
- /**
461
- * Broadcast on an explicit channel/topic instead of the per-user default.
462
- *
463
- * @param channel
464
- * @returns
465
- */
466
506
  channel(channel) {
467
507
  this.channelName = channel;
468
508
  return this;
@@ -501,7 +541,7 @@ var RealtimeNotification = class extends NotificationContract {
501
541
  return this;
502
542
  }
503
543
  resolveChannel() {
504
- if (this.channelName) return this.channelName;
544
+ if (this.channelName !== void 0) return this.channelName;
505
545
  if (this.user) return `${this.channelPrefix}${this.user.id}`;
506
546
  throw new Error("No channel resolved for realtime notification (provide a user or channel)");
507
547
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/notifications",
3
- "version": "0.16.10",
3
+ "version": "0.16.11",
4
4
  "type": "module",
5
5
  "description": "Framework-agnostic notification module for Arkstack and Nodejs, providing support for multi-channel notification delivery.",
6
6
  "homepage": "https://arkstack.toneflix.net/guide/notifications",
@@ -34,14 +34,14 @@
34
34
  "africastalking": "^0.8.0",
35
35
  "nodemailer": "^8.0.7",
36
36
  "twilio": "^6.0.0",
37
- "@arkstack/common": "^0.16.10"
37
+ "@arkstack/common": "^0.16.11"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@kanun-hq/plugin-phone": "^0.1.8",
41
41
  "firebase-admin": "^13.0.0",
42
42
  "pusher": "^5.2.0",
43
- "@arkstack/contract": "^0.16.10",
44
- "@arkstack/database": "^0.16.10"
43
+ "@arkstack/contract": "^0.16.11",
44
+ "@arkstack/database": "^0.16.11"
45
45
  },
46
46
  "peerDependenciesMeta": {
47
47
  "pusher": {