@arkstack/notifications 0.18.12 → 0.19.1

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RequestException, config as config$1, env, getModel } from "@arkstack/common";
1
+ import { RequestException, config, env, getModel } from "@arkstack/common";
2
2
  import { Job } from "@arkstack/jobs";
3
3
  import { mkdir, writeFile } from "node:fs/promises";
4
4
  import path, { join } from "node:path";
@@ -34,7 +34,7 @@ var SendNotification = class extends Job {
34
34
  //#region src/config.ts
35
35
  const configure = (key, defaultValue) => {
36
36
  try {
37
- return config$1(`notifications.${key}`, defaultValue);
37
+ return config(`notifications.${key}`, defaultValue);
38
38
  } catch {
39
39
  return defaultValue;
40
40
  }
@@ -77,18 +77,17 @@ var UserNotificationCenter = class {
77
77
  return getModel("UserNotification");
78
78
  }
79
79
  /**
80
- * Create a database notification then broadcast it using the configured realtime driver
81
- *
82
- * @param user
83
- * @param payload
80
+ * Store a notification, broadcast it on the user's channel, and push it to
81
+ * their devices when `drivers.realtime.push` is configured.
82
+ *
83
+ * @param user
84
+ * @param payload
85
+ * @param devices Push to these tokens instead of the user's own.
84
86
  */
85
- static async send(user, payload, channel = []) {
86
- return await Notification.realtime().store().channel(this.resolvePushTokens(user.pushTokens ?? channel)).meta(payload.meta).type(payload.type).subject(payload.title).action(payload.actionText, payload.actionLink).send(payload.description, payload.title, void 0, payload);
87
- }
88
- static resolvePushTokens(pushTokens) {
89
- if (!pushTokens) return [];
90
- if (Array.isArray(pushTokens)) return pushTokens.map((e) => typeof e === "string" ? e : e.token);
91
- return [typeof pushTokens === "string" ? pushTokens : pushTokens.token];
87
+ static async send(user, payload, devices) {
88
+ const notification = Notification.realtime().store().recipient(user).meta(payload.meta).type(payload.type).subject(payload.title).action(payload.actionText, payload.actionLink);
89
+ if (devices?.length) notification.devices(devices);
90
+ return await notification.send(payload.description, payload.title, void 0, payload);
92
91
  }
93
92
  /**
94
93
  * Create a database notification
@@ -111,11 +110,8 @@ var UserNotificationCenter = class {
111
110
  return await (await this.getModel()).query().where({ userId: user.id }).get();
112
111
  }
113
112
  static async unreadForUser(user, perPage) {
114
- const query = (await this.getModel()).query().where({
115
- userId: user.id,
116
- readAt: null
117
- });
118
- if (perPage) return await query.paginate();
113
+ const query = (await this.getModel()).query().where({ userId: user.id }).whereNull("readAt");
114
+ if (perPage) return await query.paginate(perPage);
119
115
  return await query.get();
120
116
  }
121
117
  /**
@@ -136,12 +132,8 @@ var UserNotificationCenter = class {
136
132
  * @param user
137
133
  */
138
134
  static async markAllRead(user, ids) {
139
- const query = (await this.getModel()).query().where({
140
- userId: user.id,
141
- readAt: null
142
- });
143
- if (ids) query.whereIn("id", ids);
144
- await query.update({ readAt: /* @__PURE__ */ new Date() });
135
+ const query = (await this.getModel()).query().where({ userId: user.id }).whereNull("readAt");
136
+ await (ids ? query.whereIn("id", ids) : query).updateFrom({ readAt: /* @__PURE__ */ new Date() });
145
137
  }
146
138
  /**
147
139
  * Delete the indicated notifications
@@ -204,9 +196,11 @@ var DbNotification = class extends NotificationContract {
204
196
  * Broadcast the notification using the default realtime driver.
205
197
  *
206
198
  * @param realtime Set to false to disable realtime broadcast
199
+ * @returns
207
200
  */
208
201
  broadcast(realtime = true) {
209
202
  this.realtime = realtime;
203
+ return this;
210
204
  }
211
205
  async create(user, payload) {
212
206
  getModel("UserNotification");
@@ -423,7 +417,7 @@ var MailNotification = class extends NotificationContract {
423
417
  */
424
418
  async send(message, subject, recipient, data) {
425
419
  const mergedData = {
426
- app_name: config$1("app.name", "Arkstack"),
420
+ app_name: config("app.name", "Arkstack"),
427
421
  ...this.mergeData(data)
428
422
  };
429
423
  const resolvedSubject = interpolate(subject ?? this.subjectLine ?? "", mergedData);
@@ -499,6 +493,206 @@ var MailNotification = class extends NotificationContract {
499
493
  }
500
494
  };
501
495
  //#endregion
496
+ //#region src/drivers/realtime/firebaseAdmin.ts
497
+ /**
498
+ * Initialize, or reuse, a named Firebase Admin app.
499
+ *
500
+ * Credentials come from the service account file at `admin_sdk_path` when it
501
+ * exists, otherwise from the inline `project_id` / `client_email` / `private_key`.
502
+ *
503
+ * @param options
504
+ * @param suffix Distinguishes apps that need different init options.
505
+ * @param extra Extra `initializeApp` options, such as `databaseURL`.
506
+ * @returns
507
+ */
508
+ const firebaseAdminApp = async (options, suffix = "", extra = {}) => {
509
+ const app = await import("firebase-admin/app").catch(() => {
510
+ throw new Error("The \"firebase-admin\" package is required for the Firebase realtime transports. Install it with `npm i firebase-admin`.");
511
+ });
512
+ const adminsdk = path.join(process.cwd(), options.admin_sdk_path ?? env("FIREBASE_ADMINSDK", "firebase-adminsdk.json"));
513
+ let serviceAccount;
514
+ try {
515
+ if (existsSync(adminsdk)) serviceAccount = JSON.parse(readFileSync(adminsdk, { encoding: "utf-8" }));
516
+ } catch {}
517
+ const credential = app.cert(serviceAccount ?? {
518
+ projectId: options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
519
+ clientEmail: options.client_email ?? env("FIREBASE_CLIENT_EMAIL", ""),
520
+ privateKey: (options.private_key ?? env("FIREBASE_PRIVATE_KEY", ""))?.replace(/\\n/g, "\n")
521
+ });
522
+ const name = String(options.app_name ?? env("FIREBASE_APP_NAME", "arkstack-realtime")).replaceAll(" ", "-").toLowerCase() + suffix;
523
+ return app.getApps().find((a) => a.name === name) ?? app.initializeApp({
524
+ credential,
525
+ ...extra
526
+ }, name);
527
+ };
528
+ /**
529
+ * The public web app settings a browser Firebase client is initialized with.
530
+ *
531
+ * @param options
532
+ * @returns
533
+ */
534
+ const firebaseWebConfig = (options) => ({
535
+ apiKey: options.web?.api_key ?? env("FIREBASE_API_KEY", ""),
536
+ authDomain: options.web?.auth_domain ?? env("FIREBASE_AUTH_DOMAIN", ""),
537
+ projectId: options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
538
+ appId: options.web?.app_id ?? env("FIREBASE_APP_ID", ""),
539
+ messagingSenderId: options.web?.messaging_sender_id ?? env("FIREBASE_MESSAGING_SENDER_ID", ""),
540
+ vapidKey: options.web?.vapid_key ?? env("FIREBASE_VAPID_KEY", "")
541
+ });
542
+ //#endregion
543
+ //#region src/drivers/realtime/FirebaseDatabaseRealtimeDriver.ts
544
+ /** Characters Realtime Database keys cannot contain. */
545
+ const FORBIDDEN_KEY = /[.#$[\]/]/;
546
+ /**
547
+ * Encode a channel name into a single Realtime Database key.
548
+ *
549
+ * @param channel
550
+ * @returns
551
+ */
552
+ const firebaseChannelKey = (channel) => encodeURIComponent(channel).replace(/\./g, "%2E");
553
+ /**
554
+ * Broadcasts over [Firebase Realtime Database](https://firebase.google.com/docs/database).
555
+ *
556
+ * Each event is written to `{root}/{channel}/{key}` as `{ event, payload, at }`,
557
+ * and indexed under `{index_root}/{key}` so {@link prune} can clear old events in
558
+ * bounded batches. Clients sign in with a custom token from {@link auth}, which
559
+ * also writes a time-limited grant to `{grants_root}/{uid}/{channel}` that the
560
+ * database security rules check before allowing a read.
561
+ *
562
+ * `firebase-admin` is an optional peer dependency, imported lazily.
563
+ */
564
+ var FirebaseDatabaseRealtimeDriver = class {
565
+ options;
566
+ servicesPromise;
567
+ constructor(options = {}) {
568
+ this.options = options;
569
+ }
570
+ get root() {
571
+ return this.options.root ?? "arkstack/channels";
572
+ }
573
+ get indexRoot() {
574
+ return `${this.root}-index`;
575
+ }
576
+ get grantsRoot() {
577
+ return this.options.grants_root ?? "arkstack/grants";
578
+ }
579
+ get databaseURL() {
580
+ return this.options.database_url ?? env("FIREBASE_DATABASE_URL", "");
581
+ }
582
+ services() {
583
+ const databaseSpecifier = "firebase-admin/database";
584
+ const authSpecifier = "firebase-admin/auth";
585
+ this.servicesPromise ??= (async () => {
586
+ const [instance, database, auth] = await Promise.all([
587
+ firebaseAdminApp(this.options, "-database", { databaseURL: this.databaseURL }),
588
+ import(databaseSpecifier),
589
+ import(authSpecifier)
590
+ ]);
591
+ return {
592
+ database: database.getDatabase(instance),
593
+ auth: auth.getAuth(instance)
594
+ };
595
+ })();
596
+ return this.servicesPromise;
597
+ }
598
+ /**
599
+ * Write the event to every channel in one multi-path update.
600
+ *
601
+ * `delivery` is accepted for contract parity and ignored.
602
+ *
603
+ * @param channel
604
+ * @param event
605
+ * @param payload
606
+ * @param _delivery
607
+ * @returns
608
+ */
609
+ async broadcast(channel, event, payload, _delivery) {
610
+ const { database } = await this.services();
611
+ const channels = Array.isArray(channel) ? channel : [channel];
612
+ const at = { ".sv": "timestamp" };
613
+ const updates = {};
614
+ const keys = [];
615
+ for (const name of channels) {
616
+ const key = database.ref(this.root).push().key;
617
+ const channelKey = firebaseChannelKey(name);
618
+ updates[`${this.root}/${channelKey}/${key}`] = {
619
+ event,
620
+ payload,
621
+ at
622
+ };
623
+ updates[`${this.indexRoot}/${key}`] = {
624
+ channel: channelKey,
625
+ at
626
+ };
627
+ keys.push(key);
628
+ }
629
+ await database.ref().update(updates);
630
+ return keys;
631
+ }
632
+ /**
633
+ * Grant `uid` read access to an already authorized channel and mint the
634
+ * custom token the client signs in with.
635
+ *
636
+ * @param uid
637
+ * @param channel
638
+ * @returns
639
+ */
640
+ async auth(uid, channel) {
641
+ if (!uid || FORBIDDEN_KEY.test(uid)) throw new Error(`Realtime: "${uid}" cannot be used as a Firebase uid`);
642
+ const { database, auth } = await this.services();
643
+ const expiresAt = Date.now() + (this.options.grant_ttl ?? 3600) * 1e3;
644
+ await database.ref().update({ [`${this.grantsRoot}/${uid}/${firebaseChannelKey(channel)}`]: expiresAt });
645
+ return {
646
+ token: await auth.createCustomToken(uid),
647
+ expires_at: expiresAt
648
+ };
649
+ }
650
+ /**
651
+ * Delete events older than `maxAgeSeconds`, at most `limit` per call. Call it
652
+ * from a scheduled job until it returns 0.
653
+ *
654
+ * @param maxAgeSeconds
655
+ * @param limit
656
+ * @returns The number of events deleted.
657
+ */
658
+ async prune(maxAgeSeconds, limit = 500) {
659
+ const { database } = await this.services();
660
+ const cutoff = Date.now() - maxAgeSeconds * 1e3;
661
+ const snapshot = await database.ref(this.indexRoot).orderByChild("at").endAt(cutoff).limitToFirst(limit).once("value");
662
+ const updates = {};
663
+ snapshot.forEach((child) => {
664
+ const entry = child.val();
665
+ if (child.key && entry?.channel) {
666
+ updates[`${this.root}/${entry.channel}/${child.key}`] = null;
667
+ updates[`${this.indexRoot}/${child.key}`] = null;
668
+ }
669
+ });
670
+ const deleted = Object.keys(updates).length / 2;
671
+ if (deleted > 0) await database.ref().update(updates);
672
+ return deleted;
673
+ }
674
+ /**
675
+ * Public settings a browser client needs to read channels.
676
+ *
677
+ * @returns
678
+ */
679
+ clientConfig() {
680
+ return {
681
+ ...firebaseWebConfig(this.options),
682
+ databaseURL: this.databaseURL,
683
+ root: this.root
684
+ };
685
+ }
686
+ /**
687
+ * Use `Broadcast.routes()`, which authorizes channels through the registry.
688
+ *
689
+ * @param _authEndpoint
690
+ * @param _middleware
691
+ * @param _channelPrefix
692
+ */
693
+ async registerAuthRoute(_authEndpoint, _middleware, _channelPrefix) {}
694
+ };
695
+ //#endregion
502
696
  //#region src/drivers/realtime/FirebaseRealtimeDriver.ts
503
697
  /** FCM caps a multicast at 500 tokens per call. */
504
698
  const MULTICAST_LIMIT = 500;
@@ -525,24 +719,9 @@ var FirebaseRealtimeDriver = class {
525
719
  this.options = options;
526
720
  }
527
721
  messaging() {
528
- const appSpecifier = "firebase-admin/app";
529
- const messagingSpecifier = "firebase-admin/messaging";
722
+ const specifier = "firebase-admin/messaging";
530
723
  this.messagingPromise ??= (async () => {
531
- const [app, messaging] = await Promise.all([import(appSpecifier), import(messagingSpecifier)]).catch(() => {
532
- throw new Error("The \"firebase-admin\" package is required for the Firebase realtime transport. Install it with `npm i firebase-admin`.");
533
- });
534
- const adminsdk = path.join(process.cwd(), this.options.admin_sdk_path ?? env("FIREBASE_ADMINSDK", "firebase-adminsdk.json"));
535
- let serviceAccount;
536
- try {
537
- if (existsSync(adminsdk)) serviceAccount = JSON.parse(readFileSync(adminsdk, { encoding: "utf-8" }));
538
- } catch {}
539
- const credential = app.cert(serviceAccount ?? {
540
- projectId: this.options.project_id ?? env("FIREBASE_PROJECT_ID", ""),
541
- clientEmail: this.options.client_email ?? env("FIREBASE_CLIENT_EMAIL", ""),
542
- privateKey: (this.options.private_key ?? env("FIREBASE_PRIVATE_KEY", ""))?.replace(/\\n/g, "\n")
543
- });
544
- const name = String(this.options.app_name ?? env("FIREBASE_APP_NAME", "arkstack-realtime")).replaceAll(" ", "-").toLowerCase();
545
- const instance = app.getApps().find((a) => a.name === name) ?? app.initializeApp({ credential }, name);
724
+ const [instance, messaging] = await Promise.all([firebaseAdminApp(this.options), import(specifier)]);
546
725
  return messaging.getMessaging(instance);
547
726
  })();
548
727
  return this.messagingPromise;
@@ -674,6 +853,15 @@ var FirebaseRealtimeDriver = class {
674
853
  return Object.keys(block).length > 0 ? { [key]: block } : {};
675
854
  }
676
855
  /**
856
+ * Public Firebase web settings for clients that receive messages or register
857
+ * device tokens.
858
+ *
859
+ * @returns
860
+ */
861
+ clientConfig() {
862
+ return firebaseWebConfig(this.options);
863
+ }
864
+ /**
677
865
  * Firebase channels do not require server-side authorization.
678
866
  *
679
867
  * @param _socketId
@@ -725,6 +913,74 @@ var FirebaseRealtimeDriver = class {
725
913
  }
726
914
  };
727
915
  //#endregion
916
+ //#region src/drivers/realtime/MemoryRealtimeDriver.ts
917
+ const BUFFER_KEY = Symbol.for("arkstack.realtime.memory");
918
+ /**
919
+ * The recorded broadcasts. Kept on `globalThis` so code loaded through separate
920
+ * module graphs (an app's routes and its tests, say) shares one buffer.
921
+ *
922
+ * @returns
923
+ */
924
+ const buffer = () => {
925
+ const store = globalThis;
926
+ return store[BUFFER_KEY] ??= [];
927
+ };
928
+ /**
929
+ * Records every broadcast in process instead of sending it, for tests and local
930
+ * development. Read them with {@link MemoryRealtimeDriver.broadcasts}.
931
+ */
932
+ var MemoryRealtimeDriver = class {
933
+ /**
934
+ * Every broadcast recorded since the last {@link clear}.
935
+ *
936
+ * @returns
937
+ */
938
+ static broadcasts() {
939
+ return buffer();
940
+ }
941
+ /**
942
+ * Forget all recorded broadcasts.
943
+ */
944
+ static clear() {
945
+ buffer().length = 0;
946
+ }
947
+ async broadcast(channel, event, payload, delivery) {
948
+ buffer().push({
949
+ channel,
950
+ event,
951
+ payload,
952
+ delivery
953
+ });
954
+ }
955
+ async auth(_socketId, channel) {
956
+ return { auth: `memory:${channel}` };
957
+ }
958
+ async registerAuthRoute() {}
959
+ clientConfig() {
960
+ return {};
961
+ }
962
+ };
963
+ //#endregion
964
+ //#region src/drivers/realtime/PollingRealtimeDriver.ts
965
+ /**
966
+ * A transport that pushes nothing. Clients are told to poll on an interval
967
+ * instead, so an app works without realtime infrastructure.
968
+ */
969
+ var PollingRealtimeDriver = class {
970
+ options;
971
+ constructor(options = {}) {
972
+ this.options = options;
973
+ }
974
+ async broadcast(_channel, _event, _payload, _delivery) {}
975
+ async auth() {
976
+ return {};
977
+ }
978
+ async registerAuthRoute() {}
979
+ clientConfig() {
980
+ return { interval: this.options.interval ?? 5e3 };
981
+ }
982
+ };
983
+ //#endregion
728
984
  //#region src/drivers/realtime/PusherRealtimeDriver.ts
729
985
  /**
730
986
  * Broadcasts notifications over [Pusher Channels](https://pusher.com/channels).
@@ -744,16 +1000,56 @@ var PusherRealtimeDriver = class PusherRealtimeDriver {
744
1000
  const mod = await import(specifier).catch(() => {
745
1001
  throw new Error("The \"pusher\" package is required for the Pusher realtime transport. Install it with `npm i pusher`.");
746
1002
  });
747
- return new (mod.default ?? mod)({
1003
+ const Pusher = mod.default ?? mod;
1004
+ const { host, port } = this.server();
1005
+ return new Pusher({
748
1006
  appId: this.options.app_id ?? env("PUSHER_APP_ID", ""),
749
- key: this.options.key ?? env("PUSHER_KEY", ""),
1007
+ key: this.key(),
750
1008
  secret: this.options.secret ?? env("PUSHER_SECRET", ""),
751
- cluster: this.options.cluster ?? env("PUSHER_CLUSTER", "mt1"),
752
- useTLS: this.options.use_tls ?? true
1009
+ cluster: this.cluster(),
1010
+ useTLS: this.useTLS(),
1011
+ ...host ? {
1012
+ host,
1013
+ port: port ? String(port) : void 0
1014
+ } : {}
753
1015
  });
754
1016
  })();
755
1017
  return this.clientPromise;
756
1018
  }
1019
+ key() {
1020
+ return this.options.key ?? env("PUSHER_KEY", "");
1021
+ }
1022
+ cluster() {
1023
+ return this.options.cluster ?? env("PUSHER_CLUSTER", "mt1");
1024
+ }
1025
+ useTLS() {
1026
+ return this.options.use_tls ?? true;
1027
+ }
1028
+ server() {
1029
+ return {
1030
+ host: this.options.host ?? (env("PUSHER_HOST", "") || void 0),
1031
+ port: this.options.port ?? (Number(env("PUSHER_PORT", 0)) || void 0)
1032
+ };
1033
+ }
1034
+ /**
1035
+ * Public settings a `pusher-js` client needs, including the self-hosted
1036
+ * server address when `host` is set.
1037
+ *
1038
+ * @returns
1039
+ */
1040
+ clientConfig() {
1041
+ const { host, port } = this.server();
1042
+ return {
1043
+ key: this.key(),
1044
+ cluster: this.cluster(),
1045
+ forceTLS: this.useTLS(),
1046
+ ...host ? {
1047
+ wsHost: host,
1048
+ wsPort: port,
1049
+ wssPort: port
1050
+ } : {}
1051
+ };
1052
+ }
757
1053
  /**
758
1054
  * `delivery` is accepted for contract parity and ignored: Pusher pushes over a
759
1055
  * connection the client already holds open, so there is no dozing radio to
@@ -797,28 +1093,257 @@ var PusherRealtimeDriver = class PusherRealtimeDriver {
797
1093
  * @param channelPrefix
798
1094
  */
799
1095
  async registerAuthRoute(authEndpoint = "/realtime/auth", middleware, channelPrefix) {
800
- const client = await this.client();
801
- for (const [name, path] of Object.entries({
802
- express: "@arkstack/driver-express",
803
- h3: "@arkstack/driver-express"
804
- })) {
805
- const midsPath = `${path}/middlewares`;
806
- try {
807
- const { Router } = await import(path);
808
- const { auth } = await import(midsPath);
809
- const middlewares = new Set(Array.isArray(middleware) ? middleware.concat(auth) : [middleware, auth]);
810
- Router.post(authEndpoint, ({ clearRequest, req }) => {
811
- const channel = clearRequest.input("channel_name");
812
- const socketId = clearRequest.input("socket_id");
813
- const expected = channelPrefix ?? `${config("notifications.drivers.realtime.channel_prefix", "user.")}${req.user?.id ?? clearRequest.user?.id}`;
814
- RequestException.abortIf(channel !== expected, "Channel access denied", 403);
815
- return client.authorizeChannel(socketId, channel);
816
- }).middleware(Array.from(middlewares));
817
- break;
818
- } catch (e) {
819
- console.error(`Failed to register auth route for ${name}: ${e instanceof Error ? e.message : e}`);
1096
+ for (const path of ["@arkstack/driver-express", "@arkstack/driver-h3"]) try {
1097
+ const { Router } = await import(path);
1098
+ const { auth } = await import(`${path}/middlewares`);
1099
+ Router.post(authEndpoint, async ({ clearRequest, req }) => {
1100
+ const user = req?.user ?? clearRequest.user;
1101
+ const channel = clearRequest.input("channel_name");
1102
+ const socketId = clearRequest.input("socket_id");
1103
+ const allowed = channelPrefix ? channel === channelPrefix : await Broadcast.authorize(user, channel);
1104
+ RequestException.abortIf(!allowed, "Channel access denied", 403);
1105
+ return new Response(JSON.stringify(await this.auth(socketId, channel)), {
1106
+ status: 200,
1107
+ headers: { "content-type": "application/json" }
1108
+ });
1109
+ }).middleware([middleware ?? []].flat().concat(auth));
1110
+ return;
1111
+ } catch (e) {
1112
+ console.error(`Failed to register auth route for ${path}: ${e instanceof Error ? e.message : e}`);
1113
+ }
1114
+ }
1115
+ };
1116
+ //#endregion
1117
+ //#region src/Broadcast.ts
1118
+ /**
1119
+ * The server side of realtime: builds transports from config, broadcasts
1120
+ * arbitrary events, and authorizes channel subscriptions against registered
1121
+ * channel rules for every transport.
1122
+ */
1123
+ var Broadcast = class {
1124
+ static rules = [];
1125
+ static settings() {
1126
+ return configure("drivers.realtime", {}) ?? {};
1127
+ }
1128
+ /**
1129
+ * The configured streaming transport name.
1130
+ *
1131
+ * @returns
1132
+ */
1133
+ static transport() {
1134
+ return this.settings().transport ?? "pusher";
1135
+ }
1136
+ /**
1137
+ * The prefix of the per-user notification channel.
1138
+ *
1139
+ * @returns
1140
+ */
1141
+ static channelPrefix() {
1142
+ return this.settings().channel_prefix ?? "user.";
1143
+ }
1144
+ /**
1145
+ * Register who may subscribe to channels matching `pattern`. `{name}`
1146
+ * placeholders match one dot-free segment and are passed to the authorizer.
1147
+ * A later registration of the same pattern replaces the earlier one.
1148
+ *
1149
+ * @param pattern e.g. `private-team.{teamId}`
1150
+ * @param authorizer
1151
+ * @returns
1152
+ */
1153
+ static channel(pattern, authorizer) {
1154
+ const keys = [];
1155
+ const source = pattern.split(/(\{[^}]+\})/).map((part) => {
1156
+ const key = /^\{([^}]+)\}$/.exec(part)?.[1];
1157
+ if (key) {
1158
+ keys.push(key);
1159
+ return "([^.]+)";
820
1160
  }
1161
+ return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1162
+ }).join("");
1163
+ this.rules = this.rules.filter((rule) => rule.pattern !== pattern);
1164
+ this.rules.push({
1165
+ pattern,
1166
+ regex: new RegExp(`^${source}$`),
1167
+ keys,
1168
+ authorizer
1169
+ });
1170
+ return this;
1171
+ }
1172
+ /**
1173
+ * Remove every registered channel rule.
1174
+ */
1175
+ static forgetChannels() {
1176
+ this.rules = [];
1177
+ }
1178
+ /**
1179
+ * Whether `subject` may subscribe to `channel`. The first matching rule
1180
+ * decides. The per-user notification channel is allowed for its own user
1181
+ * unless a rule overrides it. Unmatched channels are denied.
1182
+ *
1183
+ * @param subject
1184
+ * @param channel
1185
+ * @returns
1186
+ */
1187
+ static async authorize(subject, channel) {
1188
+ for (const rule of this.rules) {
1189
+ const match = rule.regex.exec(channel);
1190
+ if (match) {
1191
+ const params = Object.fromEntries(rule.keys.map((key, index) => [key, match[index + 1]]));
1192
+ return Boolean(await rule.authorizer(subject, params));
1193
+ }
1194
+ }
1195
+ const id = subject?.id;
1196
+ return id !== void 0 && id !== null && channel === `${this.channelPrefix()}${id}`;
1197
+ }
1198
+ /**
1199
+ * Build a transport. Uses the configured `driverFactory` when there is one,
1200
+ * otherwise the named or configured built-in.
1201
+ *
1202
+ * @param options
1203
+ * @returns
1204
+ */
1205
+ static driver(options = {}) {
1206
+ const factory = options.driverFactory ?? this.settings().driverFactory;
1207
+ if (factory) return factory();
1208
+ const transport = options.transport ?? this.transport();
1209
+ return this.make(transport, options[transport]);
1210
+ }
1211
+ /**
1212
+ * Build a named built-in transport from its `transports.*` config.
1213
+ *
1214
+ * @param transport
1215
+ * @param overrides
1216
+ * @returns
1217
+ */
1218
+ static make(transport, overrides = {}) {
1219
+ const config = {
1220
+ ...configure(`transports.${transport}`, {}),
1221
+ ...overrides
1222
+ };
1223
+ switch (transport) {
1224
+ case "firebase": return new FirebaseRealtimeDriver(config);
1225
+ case "firebase-database": return new FirebaseDatabaseRealtimeDriver(config);
1226
+ case "polling": return new PollingRealtimeDriver(config);
1227
+ case "memory": return new MemoryRealtimeDriver();
1228
+ case "pusher": return new PusherRealtimeDriver(config);
1229
+ default: throw new Error(`Realtime: unknown transport "${transport}"`);
1230
+ }
1231
+ }
1232
+ /**
1233
+ * Broadcast an arbitrary event on one or more channels over the configured
1234
+ * transport.
1235
+ *
1236
+ * @param channel
1237
+ * @param event
1238
+ * @param payload
1239
+ * @param delivery
1240
+ * @returns
1241
+ */
1242
+ static async broadcast(channel, event, payload, delivery) {
1243
+ if (Array.isArray(channel) && channel.length === 0) return;
1244
+ return await this.driver().broadcast(channel, event, payload, delivery);
1245
+ }
1246
+ /**
1247
+ * The device push transport, or null when push is off. When `push` is unset,
1248
+ * apps streaming over `firebase` keep pushing to devices as they always have.
1249
+ *
1250
+ * @returns
1251
+ */
1252
+ static pushDriver() {
1253
+ const { push } = this.settings();
1254
+ return push === "firebase" || push === void 0 && this.transport() === "firebase" ? this.make("firebase") : null;
1255
+ }
1256
+ /**
1257
+ * A user's device tokens, from the configured `push_tokens` resolver or the
1258
+ * user's `pushTokens` property.
1259
+ *
1260
+ * @param user
1261
+ * @returns
1262
+ */
1263
+ static async pushTokens(user) {
1264
+ const resolver = this.settings().push_tokens;
1265
+ const tokens = resolver ? await resolver(user) : user?.pushTokens;
1266
+ if (!tokens) return [];
1267
+ return (Array.isArray(tokens) ? tokens : [tokens]).map((token) => typeof token === "string" ? token : token?.token).filter((token) => !!token);
1268
+ }
1269
+ /**
1270
+ * Push an event to device tokens over the push transport. Returns null when
1271
+ * push is off or there are no tokens.
1272
+ *
1273
+ * @param tokens
1274
+ * @param event
1275
+ * @param payload
1276
+ * @param delivery
1277
+ * @returns
1278
+ */
1279
+ static async push(tokens, event, payload, delivery) {
1280
+ const driver = this.pushDriver();
1281
+ if (!driver || tokens.length === 0) return null;
1282
+ return await driver.broadcast(tokens, event, payload, delivery);
1283
+ }
1284
+ /**
1285
+ * Authorize `subject` for the requested channel and return what the active
1286
+ * transport needs: a Pusher signature, a Firebase custom token, or nothing.
1287
+ *
1288
+ * @param subject Usually the authenticated user.
1289
+ * @param request The client's `socket_id` and `channel_name`.
1290
+ * @param options `uid` overrides the Firebase uid (defaults to `subject.id`).
1291
+ * @returns
1292
+ */
1293
+ static async handleAuth(subject, request, options = {}) {
1294
+ const channel = request.channel_name;
1295
+ RequestException.abortIf(!channel, "Missing channel_name", 422);
1296
+ RequestException.abortIf(!await this.authorize(subject, channel), "Channel access denied", 403);
1297
+ const driver = options.driver ?? this.driver();
1298
+ if (driver instanceof FirebaseDatabaseRealtimeDriver) return await driver.auth(options.uid ?? String(subject?.id ?? ""), channel);
1299
+ RequestException.abortIf(driver instanceof PusherRealtimeDriver && !request.socket_id, "Missing socket_id", 422);
1300
+ return await driver.auth(request.socket_id ?? "", channel);
1301
+ }
1302
+ /**
1303
+ * Public settings a `@arkstack/realtime` client is created from.
1304
+ *
1305
+ * @returns
1306
+ */
1307
+ static clientConfig() {
1308
+ const transport = this.transport();
1309
+ const settings = this.settings();
1310
+ const config = {
1311
+ transport,
1312
+ event: settings.event ?? "notification",
1313
+ channelPrefix: this.channelPrefix()
1314
+ };
1315
+ config[transport] = this.driver().clientConfig?.() ?? {};
1316
+ if (settings.push === "firebase" && transport !== "firebase") config.firebase = this.make("firebase").clientConfig?.() ?? {};
1317
+ return config;
1318
+ }
1319
+ /**
1320
+ * Register `POST {prefix}/auth` and `GET {prefix}/config`, both behind the
1321
+ * framework `auth` middleware.
1322
+ *
1323
+ * @param options
1324
+ */
1325
+ static async routes(options = {}) {
1326
+ const prefix = options.prefix ?? "/realtime";
1327
+ for (const path of ["@arkstack/driver-express", "@arkstack/driver-h3"]) try {
1328
+ const { Router } = await import(path);
1329
+ const { auth } = await import(`${path}/middlewares`);
1330
+ const middleware = [options.middleware ?? []].flat().concat(auth);
1331
+ Router.post(`${prefix}/auth`, async ({ clearRequest, req }) => {
1332
+ const result = await this.handleAuth(req?.user ?? clearRequest.user, {
1333
+ socket_id: clearRequest.input("socket_id"),
1334
+ channel_name: clearRequest.input("channel_name")
1335
+ });
1336
+ return new Response(JSON.stringify(result ?? {}), {
1337
+ status: 200,
1338
+ headers: { "content-type": "application/json" }
1339
+ });
1340
+ }).middleware(middleware);
1341
+ Router.get(`${prefix}/config`, () => this.clientConfig()).middleware(middleware);
1342
+ return;
1343
+ } catch {
1344
+ continue;
821
1345
  }
1346
+ throw new Error("Realtime: no supported HTTP driver is installed to register routes on");
822
1347
  }
823
1348
  };
824
1349
  //#endregion
@@ -842,29 +1367,22 @@ var RealtimeNotification = class extends NotificationContract {
842
1367
  shouldStore;
843
1368
  tagName;
844
1369
  isRetraction = false;
1370
+ shouldPush;
1371
+ deviceTokens;
845
1372
  deliveryOptions;
846
1373
  payload = {};
847
1374
  constructor(options = {}) {
848
1375
  super();
849
1376
  const driverConfig = configure("drivers.realtime", {});
850
- const transport = options.transport ?? driverConfig?.transport ?? "pusher";
851
- const transportConfig = configure(`transports.${transport}`, {});
852
1377
  this.channelName = options.channel;
853
1378
  this.eventName = options.event ?? driverConfig?.event ?? "notification";
854
- this.channelPrefix = driverConfig?.channel_prefix ?? "user.";
1379
+ this.channelPrefix = Broadcast.channelPrefix();
855
1380
  this.shouldStore = options.store ?? driverConfig?.store ?? false;
856
1381
  this.deliveryOptions = {
857
1382
  ...driverConfig?.delivery,
858
1383
  ...options.delivery
859
1384
  };
860
- const factory = options.driverFactory ?? driverConfig?.driverFactory;
861
- this.driver = factory ? factory() : transport === "firebase" ? new FirebaseRealtimeDriver({
862
- ...transportConfig,
863
- ...options.firebase
864
- }) : new PusherRealtimeDriver({
865
- ...transportConfig,
866
- ...options.pusher
867
- });
1385
+ this.driver = Broadcast.driver(options);
868
1386
  }
869
1387
  from(_) {
870
1388
  return this;
@@ -881,14 +1399,7 @@ var RealtimeNotification = class extends NotificationContract {
881
1399
  * @returns
882
1400
  */
883
1401
  transport(transport, options) {
884
- const transportConfig = configure(`transports.${transport}`, {});
885
- this.driver = transport === "firebase" ? new FirebaseRealtimeDriver({
886
- ...transportConfig,
887
- ...options
888
- }) : new PusherRealtimeDriver({
889
- ...transportConfig,
890
- ...options
891
- });
1402
+ this.driver = Broadcast.make(transport, options);
892
1403
  return this;
893
1404
  }
894
1405
  /**
@@ -1043,6 +1554,35 @@ var RealtimeNotification = class extends NotificationContract {
1043
1554
  this.isRetraction = retract;
1044
1555
  return this;
1045
1556
  }
1557
+ /**
1558
+ * Also push this notification to the recipient's devices over the
1559
+ * `drivers.realtime.push` transport. On by default for a `User` recipient
1560
+ * when push is configured; pass `false` to skip it for this send.
1561
+ *
1562
+ * @param push
1563
+ * @returns
1564
+ */
1565
+ push(push = true) {
1566
+ this.shouldPush = push;
1567
+ return this;
1568
+ }
1569
+ /**
1570
+ * Push to these device tokens instead of the recipient's own.
1571
+ *
1572
+ * @param tokens
1573
+ * @returns
1574
+ */
1575
+ devices(tokens) {
1576
+ this.deviceTokens = tokens;
1577
+ this.shouldPush ??= true;
1578
+ return this;
1579
+ }
1580
+ async resolveDevices() {
1581
+ if (this.shouldPush === false) return [];
1582
+ if (this.deviceTokens) return this.deviceTokens;
1583
+ if (this.user) return await Broadcast.pushTokens(this.user);
1584
+ return [];
1585
+ }
1046
1586
  resolveChannel() {
1047
1587
  if (this.channelName !== void 0) return this.channelName;
1048
1588
  if (this.user) return `${this.channelPrefix}${this.user.id}`;
@@ -1083,11 +1623,14 @@ var RealtimeNotification = class extends NotificationContract {
1083
1623
  created_at: stored?.createdAt ? new Date(stored.createdAt).toISOString() : (/* @__PURE__ */ new Date()).toISOString()
1084
1624
  };
1085
1625
  if (channel && channel.length > 0) await this.driver.broadcast(channel, this.eventName, payload, this.deliveryOptions);
1626
+ const devices = await this.resolveDevices();
1627
+ const push = await Broadcast.push(devices, this.eventName, payload, this.deliveryOptions);
1086
1628
  return {
1087
1629
  channel,
1088
1630
  event: this.eventName,
1089
1631
  payload,
1090
- stored
1632
+ stored,
1633
+ ...push ? { push } : {}
1091
1634
  };
1092
1635
  }
1093
1636
  };
@@ -1284,4 +1827,4 @@ var UserNotification = class extends Model {
1284
1827
  casts = { meta: "json" };
1285
1828
  };
1286
1829
  //#endregion
1287
- export { AfricasTalkingSmsDriver, DbNotification, FirebaseRealtimeDriver, MailNotification, Notification, NotificationContract, PusherRealtimeDriver, RealtimeNotification, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, interpolate };
1830
+ export { AfricasTalkingSmsDriver, Broadcast, DbNotification, FirebaseDatabaseRealtimeDriver, FirebaseRealtimeDriver, MailNotification, MemoryRealtimeDriver, Notification, NotificationContract, PollingRealtimeDriver, PusherRealtimeDriver, RealtimeNotification, SmsNotification, TwilioSmsDriver, UserNotification, UserNotificationCenter, configure, firebaseChannelKey, interpolate };