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