@vex-chat/spire 1.10.4 → 1.11.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/ClientManager.d.ts +1 -1
- package/dist/ClientManager.js +12 -2
- package/dist/ClientManager.js.map +1 -1
- package/dist/Database.d.ts +31 -0
- package/dist/Database.js +83 -0
- package/dist/Database.js.map +1 -1
- package/dist/NotificationService.d.ts +35 -0
- package/dist/NotificationService.js +386 -0
- package/dist/NotificationService.js.map +1 -0
- package/dist/Spire.d.ts +1 -0
- package/dist/Spire.js +61 -39
- package/dist/Spire.js.map +1 -1
- package/dist/db/schema.d.ts +16 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.d.ts +8 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js +46 -0
- package/dist/migrations/2026-05-10_notification-subscriptions.js.map +1 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.d.ts +8 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js +28 -0
- package/dist/migrations/2026-05-15_notification-subscription-unique-key.js.map +1 -0
- package/package.json +5 -5
- package/src/ClientManager.ts +20 -2
- package/src/Database.ts +145 -0
- package/src/NotificationService.ts +535 -0
- package/src/Spire.ts +83 -40
- package/src/__tests__/Database.spec.ts +124 -0
- package/src/__tests__/clientManagerReceipt.spec.ts +106 -0
- package/src/__tests__/notifyFanout.spec.ts +397 -32
- package/src/db/schema.ts +21 -1
- package/src/migrations/2026-05-10_notification-subscriptions.ts +52 -0
- package/src/migrations/2026-05-15_notification-subscription-unique-key.ts +33 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2020-2026 Vex Heavy Industries LLC
|
|
3
|
+
* Licensed under AGPL-3.0. See LICENSE for details.
|
|
4
|
+
* Commercial licenses available at vex.wtf
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ClientManager } from "./ClientManager.ts";
|
|
8
|
+
import type { Database, NotificationSubscription } from "./Database.ts";
|
|
9
|
+
import type { NotifyMsg } from "@vex-chat/types";
|
|
10
|
+
|
|
11
|
+
const EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
|
|
12
|
+
const EXPO_RECEIPT_ENDPOINT = "https://exp.host/--/api/v2/push/getReceipts";
|
|
13
|
+
const EXPO_BATCH_SIZE = 100;
|
|
14
|
+
const EXPO_RECEIPT_DELAY_MS = 15 * 60 * 1000;
|
|
15
|
+
const EXPO_REQUEST_TIMEOUT_MS = 10_000;
|
|
16
|
+
const ANDROID_PUSH_CHANNEL_ID = "vex-push-messages-v2";
|
|
17
|
+
const MESSAGE_PUSH_COLLAPSE_ID = "vex-message-summary";
|
|
18
|
+
|
|
19
|
+
export interface NotificationDispatch {
|
|
20
|
+
data?: unknown;
|
|
21
|
+
deviceID?: string;
|
|
22
|
+
event: string;
|
|
23
|
+
headlessPushUserID?: string;
|
|
24
|
+
transmissionID: string;
|
|
25
|
+
userID: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ExpoErrorDetails = {
|
|
29
|
+
[key: string]: unknown;
|
|
30
|
+
error?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ExpoPushReceipt =
|
|
34
|
+
| {
|
|
35
|
+
details?: ExpoErrorDetails;
|
|
36
|
+
message?: string;
|
|
37
|
+
status: "error";
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
status: "ok";
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type ExpoPushTicket =
|
|
44
|
+
| {
|
|
45
|
+
details?: ExpoErrorDetails;
|
|
46
|
+
message?: string;
|
|
47
|
+
status: "error";
|
|
48
|
+
}
|
|
49
|
+
| {
|
|
50
|
+
id: string;
|
|
51
|
+
status: "ok";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
interface NotificationServiceOptions {
|
|
55
|
+
receiptDelayMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ParsedExpoPushResponse {
|
|
59
|
+
errors: unknown[];
|
|
60
|
+
tickets: ExpoPushTicket[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ParsedExpoReceiptResponse {
|
|
64
|
+
errors: unknown[];
|
|
65
|
+
receipts: Record<string, ExpoPushReceipt>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PendingReceipt {
|
|
69
|
+
event: string;
|
|
70
|
+
subscription: NotificationSubscription;
|
|
71
|
+
transmissionID: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class NotificationService {
|
|
75
|
+
private readonly clients: ClientManager[];
|
|
76
|
+
private readonly db: Database;
|
|
77
|
+
private readonly pendingReceipts = new Map<string, PendingReceipt>();
|
|
78
|
+
private readonly receiptDelayMs: number;
|
|
79
|
+
private readonly removeClient: (client: ClientManager) => void;
|
|
80
|
+
|
|
81
|
+
constructor(
|
|
82
|
+
db: Database,
|
|
83
|
+
clients: ClientManager[],
|
|
84
|
+
removeClient: (client: ClientManager) => void,
|
|
85
|
+
options: NotificationServiceOptions = {},
|
|
86
|
+
) {
|
|
87
|
+
this.clients = clients;
|
|
88
|
+
this.db = db;
|
|
89
|
+
this.receiptDelayMs = options.receiptDelayMs ?? EXPO_RECEIPT_DELAY_MS;
|
|
90
|
+
this.removeClient = removeClient;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public notify(dispatch: NotificationDispatch): void {
|
|
94
|
+
this.notifyWebSocket(dispatch);
|
|
95
|
+
void this.notifyPush(dispatch).catch((err: unknown) => {
|
|
96
|
+
// Push is best-effort; websocket/inbox delivery remain authoritative.
|
|
97
|
+
console.warn("[spire-notify] Expo push fanout failed", {
|
|
98
|
+
event: dispatch.event,
|
|
99
|
+
message: err instanceof Error ? err.message : String(err),
|
|
100
|
+
transmissionID: dispatch.transmissionID,
|
|
101
|
+
userID: dispatch.userID,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async checkExpoReceipts(receiptIDs: string[]): Promise<void> {
|
|
107
|
+
const pendingIDs = receiptIDs.filter((id) =>
|
|
108
|
+
this.pendingReceipts.has(id),
|
|
109
|
+
);
|
|
110
|
+
if (pendingIDs.length === 0) return;
|
|
111
|
+
|
|
112
|
+
let payload: ParsedExpoReceiptResponse;
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetchWithTimeout(
|
|
115
|
+
EXPO_RECEIPT_ENDPOINT,
|
|
116
|
+
{
|
|
117
|
+
body: JSON.stringify({ ids: pendingIDs }),
|
|
118
|
+
headers: {
|
|
119
|
+
Accept: "application/json",
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
},
|
|
122
|
+
method: "POST",
|
|
123
|
+
},
|
|
124
|
+
"Expo receipt request",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (!res.ok) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Expo receipt request failed with status ${res.status.toString()}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
payload = parseExpoReceiptResponse(await res.json());
|
|
134
|
+
} catch (err: unknown) {
|
|
135
|
+
this.dropPendingReceipts(pendingIDs);
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (payload.errors.length > 0) {
|
|
140
|
+
console.warn("[spire-notify] Expo receipt request errors", {
|
|
141
|
+
errors: payload.errors,
|
|
142
|
+
receiptIDs: pendingIDs,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const receipts = payload.receipts;
|
|
147
|
+
for (const receiptID of pendingIDs) {
|
|
148
|
+
const receipt = receipts[receiptID];
|
|
149
|
+
if (!receipt) {
|
|
150
|
+
console.warn("[spire-notify] Expo receipt missing", {
|
|
151
|
+
receiptID,
|
|
152
|
+
});
|
|
153
|
+
this.pendingReceipts.delete(receiptID);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pending = this.pendingReceipts.get(receiptID);
|
|
158
|
+
this.pendingReceipts.delete(receiptID);
|
|
159
|
+
if (!pending || receipt.status === "ok") continue;
|
|
160
|
+
|
|
161
|
+
await this.handleExpoDeliveryError(
|
|
162
|
+
"receipt",
|
|
163
|
+
pending.subscription,
|
|
164
|
+
{
|
|
165
|
+
event: pending.event,
|
|
166
|
+
transmissionID: pending.transmissionID,
|
|
167
|
+
},
|
|
168
|
+
receipt,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private dropPendingReceipts(receiptIDs: string[]): void {
|
|
174
|
+
for (const receiptID of receiptIDs) {
|
|
175
|
+
this.pendingReceipts.delete(receiptID);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async handleExpoDeliveryError(
|
|
180
|
+
stage: "receipt" | "ticket",
|
|
181
|
+
subscription: NotificationSubscription,
|
|
182
|
+
dispatch: Pick<NotificationDispatch, "event" | "transmissionID">,
|
|
183
|
+
err: { details?: ExpoErrorDetails; message?: string },
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const code = err.details?.error;
|
|
186
|
+
console.warn("[spire-notify] Expo push delivery error", {
|
|
187
|
+
code,
|
|
188
|
+
event: dispatch.event,
|
|
189
|
+
message: err.message,
|
|
190
|
+
stage,
|
|
191
|
+
subscriptionID: subscription.subscriptionID,
|
|
192
|
+
transmissionID: dispatch.transmissionID,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (code !== "DeviceNotRegistered") return;
|
|
196
|
+
|
|
197
|
+
await this.db.removeNotificationSubscription({
|
|
198
|
+
deviceID: subscription.deviceID,
|
|
199
|
+
subscriptionID: subscription.subscriptionID,
|
|
200
|
+
userID: subscription.userID,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async notifyPush(dispatch: NotificationDispatch): Promise<void> {
|
|
205
|
+
const query = {
|
|
206
|
+
event: dispatch.event,
|
|
207
|
+
userID: dispatch.userID,
|
|
208
|
+
};
|
|
209
|
+
const subscriptions = await this.db.retrieveNotificationSubscriptions(
|
|
210
|
+
dispatch.deviceID
|
|
211
|
+
? { ...query, deviceID: dispatch.deviceID }
|
|
212
|
+
: query,
|
|
213
|
+
);
|
|
214
|
+
const expoSubscriptions = subscriptions;
|
|
215
|
+
if (expoSubscriptions.length === 0) {
|
|
216
|
+
console.info("[spire-notify] no Expo push subscriptions", {
|
|
217
|
+
deviceScoped: Boolean(dispatch.deviceID),
|
|
218
|
+
event: dispatch.event,
|
|
219
|
+
transmissionID: dispatch.transmissionID,
|
|
220
|
+
userID: dispatch.userID,
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < expoSubscriptions.length; i += EXPO_BATCH_SIZE) {
|
|
226
|
+
const batch = expoSubscriptions.slice(i, i + EXPO_BATCH_SIZE);
|
|
227
|
+
await this.sendExpoBatch(batch, dispatch);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private notifyWebSocket(dispatch: NotificationDispatch): void {
|
|
232
|
+
const msg: NotifyMsg = {
|
|
233
|
+
data: dispatch.data,
|
|
234
|
+
event: dispatch.event,
|
|
235
|
+
transmissionID: dispatch.transmissionID,
|
|
236
|
+
type: "notify",
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const snapshot = this.clients.slice();
|
|
240
|
+
for (const client of snapshot) {
|
|
241
|
+
try {
|
|
242
|
+
if (client.hasFailed()) {
|
|
243
|
+
this.removeClient(client);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (dispatch.deviceID) {
|
|
248
|
+
const currentDeviceID = client.getDeviceID();
|
|
249
|
+
if (currentDeviceID === null) {
|
|
250
|
+
this.removeClient(client);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (currentDeviceID === dispatch.deviceID) {
|
|
254
|
+
client.send(msg);
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const currentUserID = client.getUserID();
|
|
260
|
+
if (currentUserID === null) {
|
|
261
|
+
this.removeClient(client);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (currentUserID === dispatch.userID) {
|
|
265
|
+
client.send(msg);
|
|
266
|
+
}
|
|
267
|
+
} catch (_err: unknown) {
|
|
268
|
+
this.removeClient(client);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private scheduleReceiptCheck(receiptIDs: string[]): void {
|
|
274
|
+
const timer = setTimeout(() => {
|
|
275
|
+
void this.checkExpoReceipts(receiptIDs).catch((err: unknown) => {
|
|
276
|
+
console.warn(
|
|
277
|
+
"[spire-notify] Expo receipt check failed",
|
|
278
|
+
err instanceof Error ? err.message : String(err),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
}, this.receiptDelayMs);
|
|
282
|
+
(timer as { unref?: () => void }).unref?.();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private async sendExpoBatch(
|
|
286
|
+
subscriptions: NotificationSubscription[],
|
|
287
|
+
dispatch: NotificationDispatch,
|
|
288
|
+
): Promise<void> {
|
|
289
|
+
const headless = shouldSendHeadlessPush(dispatch);
|
|
290
|
+
const messages = subscriptions.map((sub) =>
|
|
291
|
+
expoMessageForSubscription(sub, dispatch, headless),
|
|
292
|
+
);
|
|
293
|
+
console.info("[spire-notify] sending Expo push batch", {
|
|
294
|
+
channelIDs: [
|
|
295
|
+
...new Set(
|
|
296
|
+
messages
|
|
297
|
+
.map((msg) => msg["channelId"])
|
|
298
|
+
.filter((value): value is string => {
|
|
299
|
+
return typeof value === "string";
|
|
300
|
+
}),
|
|
301
|
+
),
|
|
302
|
+
],
|
|
303
|
+
event: dispatch.event,
|
|
304
|
+
headless,
|
|
305
|
+
platforms: [...new Set(subscriptions.map((sub) => sub.platform))],
|
|
306
|
+
size: subscriptions.length,
|
|
307
|
+
transmissionID: dispatch.transmissionID,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const res = await fetchWithTimeout(
|
|
311
|
+
EXPO_PUSH_ENDPOINT,
|
|
312
|
+
{
|
|
313
|
+
body: JSON.stringify(messages),
|
|
314
|
+
headers: {
|
|
315
|
+
Accept: "application/json",
|
|
316
|
+
"Content-Type": "application/json",
|
|
317
|
+
},
|
|
318
|
+
method: "POST",
|
|
319
|
+
},
|
|
320
|
+
"Expo push request",
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
const body = await res.text().catch(() => "");
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Expo push request failed with status ${res.status.toString()}${body ? `: ${body.slice(0, 500)}` : ""}`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const payload = parseExpoPushResponse(await res.json());
|
|
331
|
+
if (payload.errors.length > 0) {
|
|
332
|
+
console.warn("[spire-notify] Expo push request errors", {
|
|
333
|
+
errors: payload.errors,
|
|
334
|
+
event: dispatch.event,
|
|
335
|
+
transmissionID: dispatch.transmissionID,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const tickets = payload.tickets;
|
|
340
|
+
console.info("[spire-notify] Expo push tickets received", {
|
|
341
|
+
errors: tickets.filter((ticket) => ticket.status === "error")
|
|
342
|
+
.length,
|
|
343
|
+
event: dispatch.event,
|
|
344
|
+
ok: tickets.filter((ticket) => ticket.status === "ok").length,
|
|
345
|
+
transmissionID: dispatch.transmissionID,
|
|
346
|
+
});
|
|
347
|
+
for (const [idx, ticket] of tickets.entries()) {
|
|
348
|
+
const subscription = subscriptions[idx];
|
|
349
|
+
if (!subscription) continue;
|
|
350
|
+
|
|
351
|
+
if (ticket.status === "error") {
|
|
352
|
+
await this.handleExpoDeliveryError(
|
|
353
|
+
"ticket",
|
|
354
|
+
subscription,
|
|
355
|
+
dispatch,
|
|
356
|
+
ticket,
|
|
357
|
+
);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
this.pendingReceipts.set(ticket.id, {
|
|
362
|
+
event: dispatch.event,
|
|
363
|
+
subscription,
|
|
364
|
+
transmissionID: dispatch.transmissionID,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const receiptIDs = tickets.flatMap((ticket) =>
|
|
369
|
+
ticket.status === "ok" ? [ticket.id] : [],
|
|
370
|
+
);
|
|
371
|
+
if (receiptIDs.length > 0) {
|
|
372
|
+
this.scheduleReceiptCheck(receiptIDs);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function bodyForEvent(event: string): string | undefined {
|
|
378
|
+
if (event === "mail") return undefined;
|
|
379
|
+
if (event === "deviceRequest") return "Review the device request.";
|
|
380
|
+
if (event === "deviceListChanged") return "Your device list changed.";
|
|
381
|
+
return "Open Vex for the latest update.";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function expoMessageForSubscription(
|
|
385
|
+
subscription: NotificationSubscription,
|
|
386
|
+
dispatch: NotificationDispatch,
|
|
387
|
+
headless: boolean,
|
|
388
|
+
): Record<string, unknown> {
|
|
389
|
+
if (headless) {
|
|
390
|
+
return {
|
|
391
|
+
_contentAvailable: true,
|
|
392
|
+
data: {
|
|
393
|
+
deviceID: dispatch.deviceID ?? null,
|
|
394
|
+
event: dispatch.event,
|
|
395
|
+
headless: true,
|
|
396
|
+
transmissionID: dispatch.transmissionID,
|
|
397
|
+
},
|
|
398
|
+
priority: subscription.platform === "android" ? "high" : undefined,
|
|
399
|
+
to: subscription.token,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const title = titleForEvent(dispatch.event);
|
|
404
|
+
const body = bodyForEvent(dispatch.event);
|
|
405
|
+
const data = {
|
|
406
|
+
deviceID: dispatch.deviceID ?? null,
|
|
407
|
+
event: dispatch.event,
|
|
408
|
+
title,
|
|
409
|
+
transmissionID: dispatch.transmissionID,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
const message: Record<string, unknown> = {
|
|
413
|
+
channelId:
|
|
414
|
+
subscription.platform === "android"
|
|
415
|
+
? ANDROID_PUSH_CHANNEL_ID
|
|
416
|
+
: undefined,
|
|
417
|
+
data,
|
|
418
|
+
priority: subscription.platform === "android" ? "high" : undefined,
|
|
419
|
+
// Do not set `sound: "default"` here. Current mobile native
|
|
420
|
+
// notification modules can treat it as a custom sound resource named
|
|
421
|
+
// "default" instead of the platform default, which causes warnings when
|
|
422
|
+
// no such bundled resource exists.
|
|
423
|
+
title,
|
|
424
|
+
to: subscription.token,
|
|
425
|
+
};
|
|
426
|
+
if (body) {
|
|
427
|
+
message["body"] = body;
|
|
428
|
+
}
|
|
429
|
+
if (dispatch.event === "mail") {
|
|
430
|
+
message["collapseId"] = MESSAGE_PUSH_COLLAPSE_ID;
|
|
431
|
+
if (subscription.platform === "android") {
|
|
432
|
+
message["tag"] = MESSAGE_PUSH_COLLAPSE_ID;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return message;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function fetchWithTimeout(
|
|
439
|
+
input: string,
|
|
440
|
+
init: RequestInit,
|
|
441
|
+
label: string,
|
|
442
|
+
): Promise<Response> {
|
|
443
|
+
const controller = new AbortController();
|
|
444
|
+
const timer = setTimeout(() => {
|
|
445
|
+
controller.abort();
|
|
446
|
+
}, EXPO_REQUEST_TIMEOUT_MS);
|
|
447
|
+
(timer as { unref?: () => void }).unref?.();
|
|
448
|
+
try {
|
|
449
|
+
return await fetch(input, {
|
|
450
|
+
...init,
|
|
451
|
+
signal: controller.signal,
|
|
452
|
+
});
|
|
453
|
+
} catch (err: unknown) {
|
|
454
|
+
if (
|
|
455
|
+
err instanceof Error &&
|
|
456
|
+
(err.name === "AbortError" || err.name === "TimeoutError")
|
|
457
|
+
) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
`${label} timed out after ${EXPO_REQUEST_TIMEOUT_MS.toString()}ms`,
|
|
460
|
+
{ cause: err },
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
throw err;
|
|
464
|
+
} finally {
|
|
465
|
+
clearTimeout(timer);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isExpoPushReceipt(value: unknown): value is ExpoPushReceipt {
|
|
470
|
+
if (!isRecord(value)) return false;
|
|
471
|
+
return value["status"] === "ok" || value["status"] === "error";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function isExpoPushTicket(value: unknown): value is ExpoPushTicket {
|
|
475
|
+
if (!isRecord(value)) return false;
|
|
476
|
+
if (value["status"] === "ok") {
|
|
477
|
+
return typeof value["id"] === "string";
|
|
478
|
+
}
|
|
479
|
+
return value["status"] === "error";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
483
|
+
return typeof value === "object" && value !== null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function normalizeExpoTickets(data: unknown): ExpoPushTicket[] {
|
|
487
|
+
if (!data) return [];
|
|
488
|
+
const tickets = Array.isArray(data) ? data : [data];
|
|
489
|
+
return tickets.filter(isExpoPushTicket);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function parseExpoPushResponse(raw: unknown): ParsedExpoPushResponse {
|
|
493
|
+
if (!isRecord(raw)) {
|
|
494
|
+
return { errors: [], tickets: [] };
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
errors: Array.isArray(raw["errors"]) ? raw["errors"] : [],
|
|
498
|
+
tickets: normalizeExpoTickets(raw["data"]),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function parseExpoReceiptResponse(raw: unknown): ParsedExpoReceiptResponse {
|
|
503
|
+
if (!isRecord(raw)) {
|
|
504
|
+
return { errors: [], receipts: {} };
|
|
505
|
+
}
|
|
506
|
+
return {
|
|
507
|
+
errors: Array.isArray(raw["errors"]) ? raw["errors"] : [],
|
|
508
|
+
receipts: parseExpoReceipts(raw["data"]),
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function parseExpoReceipts(data: unknown): Record<string, ExpoPushReceipt> {
|
|
513
|
+
if (!isRecord(data)) return {};
|
|
514
|
+
const receipts: Record<string, ExpoPushReceipt> = {};
|
|
515
|
+
for (const [receiptID, receipt] of Object.entries(data)) {
|
|
516
|
+
if (isExpoPushReceipt(receipt)) {
|
|
517
|
+
receipts[receiptID] = receipt;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return receipts;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function shouldSendHeadlessPush(dispatch: NotificationDispatch): boolean {
|
|
524
|
+
return (
|
|
525
|
+
dispatch.headlessPushUserID !== undefined &&
|
|
526
|
+
dispatch.userID === dispatch.headlessPushUserID
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function titleForEvent(event: string): string {
|
|
531
|
+
if (event === "mail") return "New Message";
|
|
532
|
+
if (event === "deviceRequest") return "Device approval request";
|
|
533
|
+
if (event === "deviceListChanged") return "Vex device update";
|
|
534
|
+
return "Vex notification";
|
|
535
|
+
}
|
package/src/Spire.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Commercial licenses available at vex.wtf
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ActionToken, BaseMsg,
|
|
7
|
+
import type { ActionToken, BaseMsg, User } from "@vex-chat/types";
|
|
8
8
|
import type { IncomingMessage, Server } from "http";
|
|
9
9
|
|
|
10
10
|
import { EventEmitter } from "events";
|
|
@@ -39,6 +39,7 @@ import { z } from "zod/v4";
|
|
|
39
39
|
|
|
40
40
|
import { ClientManager } from "./ClientManager.ts";
|
|
41
41
|
import { Database, hashPasswordArgon2, verifyPassword } from "./Database.ts";
|
|
42
|
+
import { NotificationService } from "./NotificationService.ts";
|
|
42
43
|
import { initApp, protect } from "./server/index.ts";
|
|
43
44
|
import {
|
|
44
45
|
MailIngressValidationError,
|
|
@@ -100,6 +101,13 @@ const mailPostPayload = z.object({
|
|
|
100
101
|
mail: MailWSSchema,
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
const notificationSubscribePayload = z.object({
|
|
105
|
+
channel: z.literal("expo"),
|
|
106
|
+
events: z.array(z.string().min(1)).default(["mail"]),
|
|
107
|
+
platform: z.enum(["android", "ios", "web"]).optional(),
|
|
108
|
+
token: z.string().min(1),
|
|
109
|
+
});
|
|
110
|
+
|
|
103
111
|
const directories = ["files", "avatars", "emoji"];
|
|
104
112
|
for (const dir of directories) {
|
|
105
113
|
if (!fs.existsSync(dir)) {
|
|
@@ -189,6 +197,7 @@ export class Spire extends EventEmitter {
|
|
|
189
197
|
{ deviceID: string; nonce: string; time: number }
|
|
190
198
|
>();
|
|
191
199
|
private mailPruneInterval: null | ReturnType<typeof setInterval> = null;
|
|
200
|
+
private notifications: NotificationService;
|
|
192
201
|
private queuedRequestIncrements = 0;
|
|
193
202
|
|
|
194
203
|
private requestsTotal = 0;
|
|
@@ -228,6 +237,11 @@ export class Spire extends EventEmitter {
|
|
|
228
237
|
this.api.set("trust proxy", 1);
|
|
229
238
|
|
|
230
239
|
this.db = new Database(options);
|
|
240
|
+
this.notifications = new NotificationService(
|
|
241
|
+
this.db,
|
|
242
|
+
this.clients,
|
|
243
|
+
this.removeClient.bind(this),
|
|
244
|
+
);
|
|
231
245
|
this.db.on("ready", () => {
|
|
232
246
|
this.dbReady = true;
|
|
233
247
|
void this.db.pruneExpiredMail().catch(() => {
|
|
@@ -798,9 +812,71 @@ export class Spire extends EventEmitter {
|
|
|
798
812
|
crypto.randomUUID(),
|
|
799
813
|
null,
|
|
800
814
|
mail.recipient,
|
|
815
|
+
mail.authorID,
|
|
801
816
|
);
|
|
802
817
|
});
|
|
803
818
|
|
|
819
|
+
this.api.post(
|
|
820
|
+
"/device/:id/notifications/subscriptions",
|
|
821
|
+
protect,
|
|
822
|
+
async (req, res) => {
|
|
823
|
+
const device = req.device;
|
|
824
|
+
if (!device) {
|
|
825
|
+
res.sendStatus(401);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (device.deviceID !== getParam(req, "id")) {
|
|
829
|
+
res.sendStatus(403);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const parsed = notificationSubscribePayload.safeParse(req.body);
|
|
834
|
+
if (!parsed.success) {
|
|
835
|
+
res.status(400).json({
|
|
836
|
+
error: "Invalid notification subscription payload",
|
|
837
|
+
issues: parsed.error.issues,
|
|
838
|
+
});
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const subscription = await this.db.saveNotificationSubscription(
|
|
843
|
+
{
|
|
844
|
+
channel: parsed.data.channel,
|
|
845
|
+
deviceID: device.deviceID,
|
|
846
|
+
events: parsed.data.events,
|
|
847
|
+
platform: parsed.data.platform ?? null,
|
|
848
|
+
token: parsed.data.token,
|
|
849
|
+
userID: getUser(req).userID,
|
|
850
|
+
},
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
res.status(201).json(subscription);
|
|
854
|
+
},
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
this.api.delete(
|
|
858
|
+
"/device/:id/notifications/subscriptions/:subscriptionID",
|
|
859
|
+
protect,
|
|
860
|
+
async (req, res) => {
|
|
861
|
+
const device = req.device;
|
|
862
|
+
if (!device) {
|
|
863
|
+
res.sendStatus(401);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (device.deviceID !== getParam(req, "id")) {
|
|
867
|
+
res.sendStatus(403);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const removed = await this.db.removeNotificationSubscription({
|
|
872
|
+
deviceID: device.deviceID,
|
|
873
|
+
subscriptionID: getParam(req, "subscriptionID"),
|
|
874
|
+
userID: getUser(req).userID,
|
|
875
|
+
});
|
|
876
|
+
res.sendStatus(removed ? 204 : 404);
|
|
877
|
+
},
|
|
878
|
+
);
|
|
879
|
+
|
|
804
880
|
this.api.post("/auth", authLimiter, async (req, res) => {
|
|
805
881
|
const parsed = authPayload.safeParse(req.body);
|
|
806
882
|
if (!parsed.success) {
|
|
@@ -999,49 +1075,16 @@ export class Spire extends EventEmitter {
|
|
|
999
1075
|
transmissionID: string,
|
|
1000
1076
|
data?: unknown,
|
|
1001
1077
|
deviceID?: string,
|
|
1078
|
+
headlessPushUserID?: string,
|
|
1002
1079
|
): void {
|
|
1003
|
-
|
|
1080
|
+
this.notifications.notify({
|
|
1004
1081
|
data,
|
|
1005
1082
|
event,
|
|
1083
|
+
...(headlessPushUserID ? { headlessPushUserID } : {}),
|
|
1006
1084
|
transmissionID,
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
// Snapshot the array so that a synchronous fail/prune inside send
|
|
1011
|
-
// cannot corrupt the iteration. Each client is isolated so one stale
|
|
1012
|
-
// manager cannot abort delivery to later recipients.
|
|
1013
|
-
const snapshot = this.clients.slice();
|
|
1014
|
-
for (const client of snapshot) {
|
|
1015
|
-
try {
|
|
1016
|
-
if (client.hasFailed()) {
|
|
1017
|
-
this.removeClient(client);
|
|
1018
|
-
continue;
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
if (deviceID) {
|
|
1022
|
-
const currentDeviceID = client.getDeviceID();
|
|
1023
|
-
if (currentDeviceID === null) {
|
|
1024
|
-
this.removeClient(client);
|
|
1025
|
-
continue;
|
|
1026
|
-
}
|
|
1027
|
-
if (currentDeviceID === deviceID) {
|
|
1028
|
-
client.send(msg);
|
|
1029
|
-
}
|
|
1030
|
-
continue;
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
const currentUserID = client.getUserID();
|
|
1034
|
-
if (currentUserID === null) {
|
|
1035
|
-
this.removeClient(client);
|
|
1036
|
-
continue;
|
|
1037
|
-
}
|
|
1038
|
-
if (currentUserID === userID) {
|
|
1039
|
-
client.send(msg);
|
|
1040
|
-
}
|
|
1041
|
-
} catch (_err: unknown) {
|
|
1042
|
-
this.removeClient(client);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1085
|
+
userID,
|
|
1086
|
+
...(deviceID ? { deviceID } : {}),
|
|
1087
|
+
});
|
|
1045
1088
|
}
|
|
1046
1089
|
|
|
1047
1090
|
private removeClient(client: ClientManager): void {
|