@vex-chat/spire 1.10.4 → 1.11.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.
@@ -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, NotifyMsg, User } from "@vex-chat/types";
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
- const msg: NotifyMsg = {
1080
+ this.notifications.notify({
1004
1081
  data,
1005
1082
  event,
1083
+ ...(headlessPushUserID ? { headlessPushUserID } : {}),
1006
1084
  transmissionID,
1007
- type: "notify",
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 {