@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.d.ts +424 -8
- package/dist/index.js +626 -76
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RequestException, config
|
|
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
|
|
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
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* @param
|
|
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,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
529
|
-
const messagingSpecifier = "firebase-admin/messaging";
|
|
729
|
+
const specifier = "firebase-admin/messaging";
|
|
530
730
|
this.messagingPromise ??= (async () => {
|
|
531
|
-
const [
|
|
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
|
-
|
|
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.
|
|
1014
|
+
key: this.key(),
|
|
750
1015
|
secret: this.options.secret ?? env("PUSHER_SECRET", ""),
|
|
751
|
-
cluster: this.
|
|
752
|
-
useTLS: this.
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|