@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.d.ts +424 -8
- package/dist/index.js +630 -87
- 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
529
|
-
const messagingSpecifier = "firebase-admin/messaging";
|
|
722
|
+
const specifier = "firebase-admin/messaging";
|
|
530
723
|
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);
|
|
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
|
-
|
|
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.
|
|
1007
|
+
key: this.key(),
|
|
750
1008
|
secret: this.options.secret ?? env("PUSHER_SECRET", ""),
|
|
751
|
-
cluster: this.
|
|
752
|
-
useTLS: this.
|
|
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
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|