• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

u-wave / core / 25866259812

14 May 2026 02:38PM UTC coverage: 68.383% (+0.02%) from 68.367%
25866259812

Pull #775

github

web-flow
Merge 1d9353c4b into aee30e868
Pull Request #775: Emit a `user:join` on reconnection if we don't have a lost connection

685 of 1169 branches covered (58.6%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 1 file covered. (100.0%)

8 existing lines in 1 file now uncovered.

2025 of 2794 relevant lines covered (72.48%)

173.93 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

71.19
/src/SocketServer.js
1
import { promisify } from 'node:util';
2
import lodash from 'lodash';
3
import { WebSocketServer } from 'ws';
4
import { Ajv } from 'ajv';
5
import { stdSerializers } from 'pino';
6
import { ulid, encodeTime } from 'ulid';
7
import { socketVote } from './controllers/booth.js';
8
import { disconnectUser } from './controllers/users.js';
9
import AuthRegistry from './AuthRegistry.js';
10
import GuestConnection from './sockets/GuestConnection.js';
11
import AuthedConnection from './sockets/AuthedConnection.js';
12
import LostConnection from './sockets/LostConnection.js';
13
import { serializeUser } from './utils/serialize.js';
14
import { jsonb } from './utils/sqlite.js';
15
import { subMinutes } from './utils/date.js';
16
import { ChatMutedError } from './errors/index.js';
17

18
const { isEmpty } = lodash;
14✔
19

20
export const KEY_ACTIVE_SESSIONS = 'users';
14✔
21

22
const PING_INTERVAL = 10_000;
14✔
23
const GUEST_COUNT_INTERVAL = 2_000;
14✔
24

25
/**
26
 * @typedef {import('./schema.js').User} User
27
 */
28

29
/**
30
 * @typedef {GuestConnection | AuthedConnection | LostConnection} Connection
31
 */
32

33
/**
34
 * @typedef {object} ClientActionParameters
35
 * @prop {string} sendChat
36
 * @prop {-1 | 1} vote
37
 * @prop {undefined} logout
38
 */
39

40
/**
41
 * @typedef {{
42
 *   [Name in keyof ClientActionParameters]: (
43
 *     user: User,
44
 *     parameter: ClientActionParameters[Name],
45
 *     connection: AuthedConnection
46
 *   ) => void
47
 * }} ClientActions
48
 */
49

50
const ajv = new Ajv({
14✔
51
  coerceTypes: false,
52
  ownProperties: true,
53
  removeAdditional: true,
54
  useDefaults: false,
55
});
56

57
/**
58
 * @template {object} T
59
 * @param {T} object
60
 * @param {PropertyKey} property
61
 * @returns {property is keyof T}
62
 */
63
function has(object, property) {
64
  return Object.prototype.hasOwnProperty.call(object, property);
11✔
65
}
66

67
class SocketServer {
68
  /**
69
   * @param {import('./Uwave.js').Boot} uw
70
   * @param {{ secret: Buffer|string, sessionStore: import('express-session').Store }} options
71
   */
72
  static async plugin(uw, options) {
73
    uw.socketServer = new SocketServer(uw, {
180✔
74
      secret: options.secret,
75
      sessionStore: options.sessionStore,
76
      server: uw.server,
77
    });
78

79
    uw.after(async () => {
180✔
80
      try {
180✔
81
        await uw.socketServer.initLostConnections();
180✔
82
      } catch (err) {
83
        // No need to prevent startup for this.
84
        // We do need to clear the `users` list because the lost connection handlers
85
        // will not do so.
86
        uw.socketServer.#logger.warn({ err }, 'could not initialise lost connections');
×
87
        await uw.keyv.delete(KEY_ACTIVE_SESSIONS);
×
88
      }
89
    });
90

91
    uw.onClose(async () => {
180✔
92
      await uw.socketServer.destroy();
180✔
93
    });
94
  }
95

96
  #uw;
97

98
  #logger;
99

100
  #wss;
101

102
  #closing = false;
180✔
103

104
  /** @type {Connection[]} */
105
  #connections = [];
180✔
106

107
  #pinger;
108

109
  /** Update online guests count and broadcast an update if necessary. */
110
  #guestCountInterval;
111

112
  #guestCountDirty = true;
180✔
113

114
  /**
115
   * Handlers for commands that come in from clients.
116
   *
117
   * @type {ClientActions}
118
   */
119
  #clientActions;
120

121
  /**
122
   * @type {{
123
   *   [K in keyof ClientActionParameters]:
124
   *     import('ajv').ValidateFunction<ClientActionParameters[K]>
125
   * }}
126
   */
127
  #clientActionSchemas;
128

129
  /**
130
   * Handlers for commands that come in from the server side.
131
   *
132
   * @type {import('./redisMessages.js').ServerActions}
133
   */
134
  #serverActions;
135

136
  #unsubscribe;
137

138
  /**
139
   * Create a socket server.
140
   *
141
   * @param {import('./Uwave.js').Boot} uw üWave Core instance.
142
   * @param {object} options Socket server options.
143
   * @param {number} [options.timeout] Time in seconds to wait for disconnected
144
   *     users to reconnect before removing them.
145
   * @param {Buffer|string} options.secret
146
   * @param {import('express-session').Store} options.sessionStore
147
   * @param {import('http').Server | import('https').Server} [options.server]
148
   * @param {number} [options.port]
149
   * @private
150
   */
151
  constructor(uw, options) {
152
    if (!uw) {
180!
153
      throw new TypeError(`The "uw" argument must be of type UwaveServer. Received ${typeof uw}`);
×
154
    }
155

156
    this.#uw = uw;
180✔
157
    this.#logger = uw.logger.child({ ns: 'uwave:sockets' }, {
180✔
158
      serializers: {
159
        req: stdSerializers.req,
160
      },
161
    });
162

163
    this.options = {
180✔
164
      /** @type {(_socket: import('ws').WebSocket | undefined, err: Error) => void} */
165
      onError: (_socket, err) => {
166
        throw err;
×
167
      },
168
      timeout: 30,
169
      ...options,
170
    };
171

172
    // TODO put this behind a symbol, it's just public for tests
173
    this.authRegistry = new AuthRegistry(uw.db);
180✔
174

175
    this.#wss = new WebSocketServer({
180✔
176
      server: options.server,
177
      port: options.server ? undefined : options.port,
180!
178
    });
179

180
    this.#unsubscribe = uw.events.onAny((command, data) => {
180✔
181
      this.#onServerMessage(command, data);
263✔
182
    });
183

184
    this.#wss.on('error', (error) => {
180✔
185
      this.onError(error);
×
186
    });
187
    this.#wss.on('connection', (socket, request) => {
180✔
188
      this.onSocketConnected(socket, request);
29✔
189
    });
190

191
    this.#pinger = setInterval(() => {
180✔
192
      this.ping();
×
193
    }, PING_INTERVAL);
194

195
    this.#guestCountInterval = setInterval(() => {
180✔
196
      if (!this.#guestCountDirty) {
×
197
        return;
×
198
      }
199

200
      this.#recountGuests();
×
201
    }, GUEST_COUNT_INTERVAL);
202

203
    this.#clientActions = {
180✔
204
      sendChat: (user, message) => {
205
        this.#logger.trace({ user, message }, 'sendChat');
11✔
206
        this.#uw.chat.send(user, message).catch((err) => {
11✔
207
          if (err instanceof ChatMutedError) {
1!
208
            return;
1✔
209
          }
210

211
          this.#logger.warn({ err }, 'could not send chat message');
×
212
          // TODO: should this return the error to the client?
213
        });
214
      },
215
      vote: (user, direction) => {
216
        socketVote(this.#uw, user.id, direction);
×
217
      },
218
      logout: (user, _, connection) => {
219
        this.replace(connection, this.createGuestConnection(connection.socket));
×
220
        if (!this.connection(user)) {
×
221
          disconnectUser(this.#uw, user.id);
×
222
        }
223
      },
224
    };
225

226
    this.#clientActionSchemas = {
180✔
227
      sendChat: ajv.compile({
228
        type: 'string',
229
      }),
230
      vote: ajv.compile({
231
        type: 'integer',
232
        enum: [-1, 1],
233
      }),
234
      logout: ajv.compile(true),
235
    };
236

237
    this.#serverActions = {
180✔
238
      /**
239
       * Broadcast the next track.
240
       */
241
      'advance:complete': (next) => {
242
        if (next) {
18!
243
          this.broadcast('advance', {
18✔
244
            historyID: next.historyID,
245
            userID: next.userID,
246
            media: next.media,
247
            playedAt: new Date(next.playedAt).getTime(),
248
          });
249
        } else {
250
          this.broadcast('advance', null);
×
251
        }
252
      },
253
      /**
254
       * Broadcast a skip notification.
255
       */
256
      'booth:skip': ({ moderatorID, userID, reason }) => {
257
        this.broadcast('skip', { moderatorID, userID, reason });
×
258
      },
259
      /**
260
       * Broadcast a chat message.
261
       */
262
      'chat:message': (message) => {
263
        this.broadcast('chatMessage', message);
12✔
264
      },
265
      /**
266
       * Delete chat messages. The delete filter can have an _id property to
267
       * delete a specific message, a userID property to delete messages by a
268
       * user, or be empty to delete all messages.
269
       */
270
      'chat:delete': ({ moderatorID, filter }) => {
271
        if ('id' in filter) {
6✔
272
          this.broadcast('chatDeleteByID', {
2✔
273
            moderatorID,
274
            _id: filter.id,
275
          });
276
        } else if ('userID' in filter) {
4✔
277
          this.broadcast('chatDeleteByUser', {
2✔
278
            moderatorID,
279
            userID: filter.userID,
280
          });
281
        } else if (isEmpty(filter)) {
2!
282
          this.broadcast('chatDelete', { moderatorID });
2✔
283
        }
284
      },
285
      /**
286
       * Broadcast that a user was muted in chat.
287
       */
288
      'chat:mute': ({ moderatorID, userID, duration }) => {
289
        this.broadcast('chatMute', {
2✔
290
          userID,
291
          moderatorID,
292
          expiresAt: Date.now() + duration,
293
        });
294
      },
295
      /**
296
       * Broadcast that a user was unmuted in chat.
297
       */
298
      'chat:unmute': ({ moderatorID, userID }) => {
299
        this.broadcast('chatUnmute', { userID, moderatorID });
×
300
      },
301
      /**
302
       * Broadcast a vote for the current track.
303
       */
304
      'booth:vote': ({ userID, direction }) => {
305
        this.broadcast('vote', {
2✔
306
          _id: userID,
307
          value: direction,
308
        });
309
      },
310
      /**
311
       * Broadcast a favorite for the current track.
312
       */
313
      'booth:favorite': ({ userID }) => {
314
        this.broadcast('favorite', { userID });
1✔
315
      },
316
      /**
317
       * Cycle a single user's playlist.
318
       */
319
      'playlist:cycle': ({ userID, playlistID }) => {
320
        this.sendTo(userID, 'playlistCycle', { playlistID });
18✔
321
      },
322
      /**
323
       * Broadcast that a user joined the waitlist.
324
       */
325
      'waitlist:join': ({ userID, waitlist }) => {
326
        this.broadcast('waitlistJoin', { userID, waitlist });
31✔
327
      },
328
      /**
329
       * Broadcast that a user left the waitlist.
330
       */
331
      'waitlist:leave': ({ userID, waitlist }) => {
332
        this.broadcast('waitlistLeave', { userID, waitlist });
1✔
333
      },
334
      /**
335
       * Broadcast that a user was added to the waitlist.
336
       */
337
      'waitlist:add': ({
338
        userID, moderatorID, position, waitlist,
339
      }) => {
340
        this.broadcast('waitlistAdd', {
1✔
341
          userID, moderatorID, position, waitlist,
342
        });
343
      },
344
      /**
345
       * Broadcast that a user was removed from the waitlist.
346
       */
347
      'waitlist:remove': ({ userID, moderatorID, waitlist }) => {
348
        this.broadcast('waitlistRemove', { userID, moderatorID, waitlist });
×
349
      },
350
      /**
351
       * Broadcast that a user was moved in the waitlist.
352
       */
353
      'waitlist:move': ({
354
        userID, moderatorID, position, waitlist,
355
      }) => {
356
        this.broadcast('waitlistMove', {
3✔
357
          userID, moderatorID, position, waitlist,
358
        });
359
      },
360
      /**
361
       * Broadcast a waitlist update.
362
       */
363
      'waitlist:update': (waitlist) => {
364
        this.broadcast('waitlistUpdate', waitlist);
18✔
365
      },
366
      /**
367
       * Broadcast that the waitlist was cleared.
368
       */
369
      'waitlist:clear': ({ moderatorID }) => {
370
        this.broadcast('waitlistClear', { moderatorID });
×
371
      },
372
      /**
373
       * Broadcast that the waitlist was locked.
374
       */
375
      'waitlist:lock': ({ moderatorID, locked }) => {
376
        this.broadcast('waitlistLock', { moderatorID, locked });
6✔
377
      },
378

379
      'acl:allow': ({ userID, roles }) => {
380
        this.broadcast('acl:allow', { userID, roles });
84✔
381
      },
382
      'acl:disallow': ({ userID, roles }) => {
383
        this.broadcast('acl:disallow', { userID, roles });
2✔
384
      },
385

386
      'user:update': ({ userID, moderatorID, new: update }) => {
387
        // TODO Remove this remnant of the old roles system
388
        if ('role' in update) {
3!
389
          this.broadcast('roleChange', {
×
390
            moderatorID,
391
            userID,
392
            role: update.role,
393
          });
394
        }
395
        if ('username' in update) {
3!
396
          this.broadcast('nameChange', {
3✔
397
            moderatorID,
398
            userID,
399
            username: update.username,
400
          });
401
        }
402
      },
403
      'user:join': async ({ userID }) => {
404
        const { users, keyv } = this.#uw;
28✔
405
        const user = await users.getUser(userID);
28✔
406
        if (user) {
28!
407
          // TODO this should not be the socket server code's responsibility
408
          const userIDs = /** @type {import('./schema.js').UserID[] | null} */ (
28✔
409
            await keyv.get(KEY_ACTIVE_SESSIONS)
410
          ) ?? [];
411
          userIDs.push(user.id);
28✔
412
          await keyv.set(KEY_ACTIVE_SESSIONS, userIDs);
28✔
413
          this.broadcast('join', serializeUser(user));
28✔
414
        }
415
      },
416
      /**
417
       * Broadcast that a user left the server.
418
       */
419
      'user:leave': ({ userID }) => {
420
        this.broadcast('leave', userID);
×
421
      },
422
      /**
423
       * Broadcast a ban event.
424
       */
425
      'user:ban': ({
426
        moderatorID, userID, permanent = false, duration, expiresAt,
3✔
427
      }) => {
428
        this.broadcast('ban', {
3✔
429
          moderatorID, userID, permanent, duration, expiresAt,
430
        });
431

432
        this.#connections.forEach((connection) => {
3✔
433
          if (connection instanceof AuthedConnection && connection.user.id === userID) {
×
434
            connection.ban();
×
435
          } else if (connection instanceof LostConnection && connection.user.id === userID) {
×
436
            connection.close();
×
437
          }
438
        });
439
      },
440
      /**
441
       * Broadcast an unban event.
442
       */
443
      'user:unban': ({ moderatorID, userID }) => {
444
        this.broadcast('unban', { moderatorID, userID });
1✔
445
      },
446
      /**
447
       * Force-close a connection.
448
       */
449
      'http-api:socket:close': (userID) => {
450
        this.#connections.forEach((connection) => {
×
451
          if ('user' in connection && connection.user.id === userID) {
×
452
            connection.close();
×
453
          }
454
        });
455
      },
456

457
      'emotes:reload': () => {
458
        this.broadcast('reloadEmotes', null);
×
459
      },
460
    };
461
  }
462

463
  /**
464
   * Create `LostConnection`s for every user that's known to be online, but that
465
   * is not currently connected to the socket server.
466
   *
467
   * @private
468
   */
469
  async initLostConnections() {
470
    const { db, keyv } = this.#uw;
180✔
471
    const userIDs = /** @type {import('./schema.js').UserID[] | null} */ (
180✔
472
      await keyv.get(KEY_ACTIVE_SESSIONS)
473
    ) ?? [];
474
    const disconnectedIDs = userIDs.filter((userID) => !this.connection(userID));
180✔
475

476
    if (disconnectedIDs.length === 0) {
180!
477
      return;
180✔
478
    }
479

480
    const disconnectedUsers = await db.selectFrom('users')
×
481
      .where('id', 'in', disconnectedIDs)
482
      .selectAll()
483
      .execute();
484
    disconnectedUsers.forEach((_user) => {
×
485
      // TODO (commented out but it already didn't really work)
486
      void _user;
×
487
      // this.add(this.createLostConnection(user, 'TODO: Actual session ID!!', null));
488
    });
489
  }
490

491
  /**
492
   * @param {import('ws').WebSocket} socket
493
   * @param {import('http').IncomingMessage} request
494
   * @private
495
   */
496
  onSocketConnected(socket, request) {
497
    this.#logger.info({ req: request }, 'new connection');
29✔
498

499
    socket.on('error', (error) => {
29✔
500
      this.onSocketError(socket, error);
×
501
    });
502
    this.add(this.createGuestConnection(socket));
29✔
503
  }
504

505
  /**
506
   * @param {import('ws').WebSocket} socket
507
   * @param {Error} error
508
   * @private
509
   */
510
  onSocketError(socket, error) {
511
    this.#logger.warn({ err: error }, 'socket error');
×
512

513
    this.options.onError(socket, error);
×
514
  }
515

516
  /**
517
   * @param {Error} error
518
   * @private
519
   */
520
  onError(error) {
521
    this.#logger.error({ err: error }, 'server error');
×
522

523
    this.options.onError(undefined, error);
×
524
  }
525

526
  /**
527
   * Get a LostConnection for a user, if one exists.
528
   *
529
   * @param {string} sessionID
530
   * @private
531
   */
532
  getLostConnection(sessionID) {
533
    return this.#connections.find((connection) => (
2✔
534
      connection instanceof LostConnection && connection.sessionID === sessionID
2✔
535
    ));
536
  }
537

538
  /**
539
   * Create a connection instance for an unauthenticated user.
540
   *
541
   * @param {import('ws').WebSocket} socket
542
   * @private
543
   */
544
  createGuestConnection(socket) {
545
    const connection = new GuestConnection(
29✔
546
      this.#uw,
547
      this.options.sessionStore,
548
      socket,
549
      { authRegistry: this.authRegistry },
550
    );
551
    connection.on('close', () => {
29✔
552
      this.remove(connection);
×
553
    });
554
    connection.on('authenticate', async ({ user, sessionID, lastEventID }) => {
29✔
555
      let isReconnect = await connection.isReconnect(sessionID);
29✔
556
      this.#logger.info({ userId: user.id, isReconnect, lastEventID }, 'authenticated socket');
29✔
557
      if (isReconnect) {
29✔
558
        const previousConnection = this.getLostConnection(sessionID);
2✔
559
        if (previousConnection) {
2✔
560
          this.remove(previousConnection);
1✔
561
        } else {
562
          // If there's actually no lost connection, we might've derived the reconnection flag from stale state?
563
          // XXX(@goto-bus-stop): validate this
564
          isReconnect = false;
1✔
565
        }
566
      }
567

568
      this.replace(connection, this.createAuthedConnection(socket, user, sessionID, lastEventID));
29✔
569

570
      if (!isReconnect) {
29✔
571
        this.#uw.publish('user:join', { userID: user.id });
28✔
572
      }
573
    });
574
    return connection;
29✔
575
  }
576

577
  /**
578
   * Create a connection instance for an authenticated user.
579
   *
580
   * @param {import('ws').WebSocket} socket
581
   * @param {User} user
582
   * @param {string} sessionID
583
   * @param {string|null} lastEventID
584
   * @returns {AuthedConnection}
585
   * @private
586
   */
587
  createAuthedConnection(socket, user, sessionID, lastEventID) {
588
    const connection = new AuthedConnection(
29✔
589
      this.#uw,
590
      this.options.sessionStore,
591
      socket,
592
      user,
593
      sessionID,
594
      lastEventID,
595
    );
596
    connection.on('close', ({ banned, lastEventID }) => {
29✔
597
      if (banned) {
29!
UNCOV
598
        this.#logger.info({ userId: user.id }, 'removing connection after ban');
×
599
        disconnectUser(this.#uw, user.id);
×
600
      } else if (!this.#closing) {
29✔
601
        this.#logger.info({ userId: user.id }, 'lost connection');
2✔
602
        this.add(this.createLostConnection(user, sessionID, lastEventID));
2✔
603
      }
604
      this.remove(connection);
29✔
605
    });
606
    connection.on(
29✔
607
      'command',
608
      ({ command, data }) => {
609
        this.#logger.trace({ userId: user.id, command, data }, 'command');
11✔
610
        if (has(this.#clientActions, command)) {
11!
611
          // Ignore incorrect input
612
          const validate = this.#clientActionSchemas[command];
11✔
613
          if (validate && !validate(data)) {
11!
UNCOV
614
            return;
×
615
          }
616

617
          const action = this.#clientActions[command];
11✔
618
          // @ts-expect-error TS2345 `data` is validated
619
          action(user, data, connection);
11✔
620
        }
621
      },
622
    );
623
    return connection;
29✔
624
  }
625

626
  /**
627
   * Create a connection instance for a user who disconnected.
628
   *
629
   * @param {User} user
630
   * @param {string} sessionID
631
   * @param {string|null} lastEventID
632
   * @returns {LostConnection}
633
   * @private
634
   */
635
  createLostConnection(user, sessionID, lastEventID) {
636
    const connection = new LostConnection(
2✔
637
      this.#uw,
638
      this.options.sessionStore,
639
      user,
640
      sessionID,
641
      lastEventID,
642
      this.options.timeout,
643
    );
644
    connection.on('close', () => {
2✔
645
      this.#logger.info({ userId: user.id }, 'user left');
1✔
646
      this.remove(connection);
1✔
647
      // Only register that the user left if they didn't have another connection
648
      // still open.
649
      if (!this.connection(user)) {
1!
650
        disconnectUser(this.#uw, user.id);
1✔
651
      }
652
    });
653
    return connection;
2✔
654
  }
655

656
  /**
657
   * Add a connection.
658
   *
659
   * @param {Connection} connection
660
   * @private
661
   */
662
  add(connection) {
663
    const userID = 'user' in connection ? connection.user.id : null;
60✔
664
    const sessionID = 'sessionID' in connection ? connection.sessionID : null;
60✔
665
    this.#logger.trace({ type: connection.constructor.name, userID, sessionID }, 'add connection');
60✔
666

667
    this.#connections.push(connection);
60✔
668
    this.#guestCountDirty = true;
60✔
669
  }
670

671
  /**
672
   * Remove a connection.
673
   *
674
   * @param {Connection} connection
675
   * @private
676
   */
677
  remove(connection) {
678
    const userID = 'user' in connection ? connection.user.id : null;
60✔
679
    const sessionID = 'sessionID' in connection ? connection.sessionID : null;
60✔
680
    this.#logger.trace({ type: connection.constructor.name, userID, sessionID }, 'remove connection');
60✔
681

682
    const i = this.#connections.indexOf(connection);
60✔
683
    this.#connections.splice(i, 1);
60✔
684

685
    connection.removed();
60✔
686
    this.#guestCountDirty = true;
60✔
687
  }
688

689
  /**
690
   * Replace a connection instance with another connection instance. Useful when
691
   * a connection changes "type", like GuestConnection → AuthedConnection.
692
   *
693
   * @param {Connection} oldConnection
694
   * @param {Connection} newConnection
695
   * @private
696
   */
697
  replace(oldConnection, newConnection) {
698
    this.remove(oldConnection);
29✔
699
    this.add(newConnection);
29✔
700
  }
701

702
  /**
703
   * Handle command messages coming in from elsewhere in the app.
704
   * Some commands are intended to broadcast immediately to all connected
705
   * clients, but others require special action.
706
   *
707
   * @template {keyof import('./redisMessages.js').ServerActionParameters} K
708
   * @param {K} command
709
   * @param {import('./redisMessages.js').ServerActionParameters[K]} data
710
   */
711
  #onServerMessage(command, data) {
712
    this.#logger.trace({ channel: command, command, data }, 'server message');
263✔
713

714
    const action = this.#serverActions[command];
263✔
715
    if (action !== undefined) {
263✔
716
      action(data);
240✔
717
    }
718
  }
719

720
  /**
721
   * Stop the socket server.
722
   *
723
   * @returns {Promise<void>}
724
   */
725
  async destroy() {
726
    this.#closing = true;
180✔
727

728
    this.#unsubscribe();
180✔
729
    clearInterval(this.#pinger);
180✔
730
    clearInterval(this.#guestCountInterval);
180✔
731

732
    for (const connection of this.#connections) {
180✔
733
      connection.close();
28✔
734
    }
735

736
    const closeWsServer = promisify(this.#wss.close.bind(this.#wss));
180✔
737
    await closeWsServer();
180✔
738
  }
739

740
  /**
741
   * Get the connection instance for a specific user.
742
   *
743
   * @param {User|import('./schema.js').UserID} user The user.
744
   * @returns {Connection|undefined}
745
   */
746
  connection(user) {
747
    const userID = typeof user === 'object' ? user.id : user;
1!
748
    return this.#connections.find((connection) => 'user' in connection && connection.user.id === userID);
1✔
749
  }
750

751
  ping() {
UNCOV
752
    this.#connections.forEach((connection) => {
×
753
      connection.ping();
×
754
    });
755

UNCOV
756
    this.#cleanupMessageQueue().catch((err) => {
×
757
      this.#logger.error({ err }, 'failed to clean up socket message queue');
×
758
    });
759
  }
760

761
  async #cleanupMessageQueue() {
UNCOV
762
    const oldestID = encodeTime(subMinutes(new Date(), 10).getTime());
×
763

UNCOV
764
    await this.#uw.db.deleteFrom('socketMessageQueue')
×
765
      .where('id', '<', oldestID)
766
      .execute();
767
  }
768

769
  /**
770
   * Broadcast a command to all connected clients.
771
   *
772
   * @param {string} command Command name.
773
   * @param {import('type-fest').JsonValue} data Command data.
774
   * @param {import('./schema.js').UserID | null} targetUserID
775
   */
776
  #recordMessage(command, data, targetUserID = null) {
240✔
777
    const id = ulid();
240✔
778

779
    this.#uw.db.insertInto('socketMessageQueue')
240✔
780
      .values({
781
        id,
782
        command,
783
        data: jsonb(data),
784
        targetUserID,
785
      })
786
      .execute();
787

788
    return id;
240✔
789
  }
790

791
  /**
792
   * Broadcast a command to all connected clients.
793
   *
794
   * @param {string} command Command name.
795
   * @param {import('type-fest').JsonValue} data Command data.
796
   */
797
  broadcast(command, data) {
798
    const id = this.#recordMessage(command, data);
222✔
799

800
    this.#logger.trace({
222✔
801
      id,
802
      command,
803
      data,
804
      to: this.#connections.map((connection) => (
805
        'user' in connection ? connection.user.id : null
121!
806
      )),
807
    }, 'broadcast');
808

809
    this.#connections.forEach((connection) => {
222✔
810
      connection.send(id, command, data);
121✔
811
    });
812
  }
813

814
  /**
815
   * Send a command to a single user.
816
   *
817
   * @param {User|import('./schema.js').UserID} user User or user ID to send the command to.
818
   * @param {string} command Command name.
819
   * @param {import('type-fest').JsonValue} data Command data.
820
   */
821
  sendTo(user, command, data) {
822
    const userID = typeof user === 'object' ? user.id : user;
18!
823
    const id = this.#recordMessage(command, data, userID);
18✔
824

825
    this.#connections.forEach((connection) => {
18✔
826
      if ('user' in connection && connection.user.id === userID) {
16✔
827
        connection.send(id, command, data);
13✔
828
      }
829
    });
830
  }
831

832
  #lastGuestCount = 0;
180✔
833

834
  /** The number of unauthenticated connections. */
835
  get guestCount() {
836
    return this.#connections.reduce((acc, connection) => {
7✔
837
      if (connection instanceof GuestConnection) {
4!
UNCOV
838
        return acc + 1;
×
839
      }
840
      return acc;
4✔
841
    }, 0);
842
  }
843

844
  #recountGuests() {
UNCOV
845
    const guests = this.guestCount;
×
846
    if (guests !== this.#lastGuestCount) {
×
847
      this.#lastGuestCount = guests;
×
848
      this.broadcast('guests', guests);
×
849
    }
850
  }
851
}
852

853
export default SocketServer;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc