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

u-wave / core / 23241118120

18 Mar 2026 10:51AM UTC coverage: 87.348% (-0.02%) from 87.368%
23241118120

Pull #758

github

web-flow
Merge 581a392de into e2167f6b6
Pull Request #758: Handle expected ChatMutedError when sending chat w/ websocket

1060 of 1255 branches covered (84.46%)

Branch coverage included in aggregate %.

6 of 9 new or added lines in 1 file covered. (66.67%)

10828 of 12355 relevant lines covered (87.64%)

108.27 hits per line

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

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

1✔
18
const { isEmpty } = lodash;
1✔
19

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

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

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

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

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

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

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

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

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

180✔
79
    uw.after(async () => {
180✔
80
      try {
180✔
81
        await uw.socketServer.initLostConnections();
180✔
82
      } catch (err) {
180!
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
    });
180✔
90

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

180✔
96
  #uw;
180✔
97

180✔
98
  #logger;
180✔
99

180✔
100
  #wss;
180✔
101

180✔
102
  #closing = false;
180✔
103

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

180✔
107
  #pinger;
180✔
108

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

180✔
112
  #guestCountDirty = true;
180✔
113

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

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

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

180✔
136
  #unsubscribe;
180✔
137

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

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

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

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

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

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

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

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

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

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

180✔
203
    this.#clientActions = {
180✔
204
      sendChat: (user, message) => {
180✔
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
          }
1✔
NEW
210

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

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

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

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

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

3✔
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
        });
3✔
439
      },
180✔
440
      /**
180✔
441
       * Broadcast an unban event.
180✔
442
       */
180✔
443
      'user:unban': ({ moderatorID, userID }) => {
180✔
444
        this.broadcast('unban', { moderatorID, userID });
1✔
445
      },
180✔
446
      /**
180✔
447
       * Force-close a connection.
180✔
448
       */
180✔
449
      'http-api:socket:close': (userID) => {
180✔
450
        this.#connections.forEach((connection) => {
×
451
          if ('user' in connection && connection.user.id === userID) {
×
452
            connection.close();
×
453
          }
×
454
        });
×
455
      },
180✔
456

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

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

180✔
476
    if (disconnectedIDs.length === 0) {
180✔
477
      return;
180✔
478
    }
180✔
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
  }
180✔
490

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

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

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

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

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

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

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

180✔
538
  /**
180✔
539
   * Create a connection instance for an unauthenticated user.
180✔
540
   *
180✔
541
   * @param {import('ws').WebSocket} socket
180✔
542
   * @private
180✔
543
   */
180✔
544
  createGuestConnection(socket) {
180✔
545
    const connection = new GuestConnection(
29✔
546
      this.#uw,
29✔
547
      this.options.sessionStore,
29✔
548
      socket,
29✔
549
      { authRegistry: this.authRegistry },
29✔
550
    );
29✔
551
    connection.on('close', () => {
29✔
552
      this.remove(connection);
×
553
    });
29✔
554
    connection.on('authenticate', async ({ user, sessionID, lastEventID }) => {
29✔
555
      const 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) this.remove(previousConnection);
2✔
560
      }
2✔
561

29✔
562
      this.replace(connection, this.createAuthedConnection(socket, user, sessionID, lastEventID));
29✔
563

29✔
564
      if (!isReconnect) {
29✔
565
        this.#uw.publish('user:join', { userID: user.id });
27✔
566
      }
27✔
567
    });
29✔
568
    return connection;
29✔
569
  }
29✔
570

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

11✔
611
          const action = this.#clientActions[command];
11✔
612
          // @ts-expect-error TS2345 `data` is validated
11✔
613
          action(user, data, connection);
11✔
614
        }
11✔
615
      },
29✔
616
    );
29✔
617
    return connection;
29✔
618
  }
29✔
619

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

180✔
650
  /**
180✔
651
   * Add a connection.
180✔
652
   *
180✔
653
   * @param {Connection} connection
180✔
654
   * @private
180✔
655
   */
180✔
656
  add(connection) {
180✔
657
    const userID = 'user' in connection ? connection.user.id : null;
60✔
658
    const sessionID = 'sessionID' in connection ? connection.sessionID : null;
60✔
659
    this.#logger.trace({ type: connection.constructor.name, userID, sessionID }, 'add connection');
60✔
660

60✔
661
    this.#connections.push(connection);
60✔
662
    this.#guestCountDirty = true;
60✔
663
  }
60✔
664

180✔
665
  /**
180✔
666
   * Remove a connection.
180✔
667
   *
180✔
668
   * @param {Connection} connection
180✔
669
   * @private
180✔
670
   */
180✔
671
  remove(connection) {
180✔
672
    const userID = 'user' in connection ? connection.user.id : null;
60✔
673
    const sessionID = 'sessionID' in connection ? connection.sessionID : null;
60✔
674
    this.#logger.trace({ type: connection.constructor.name, userID, sessionID }, 'remove connection');
60✔
675

60✔
676
    const i = this.#connections.indexOf(connection);
60✔
677
    this.#connections.splice(i, 1);
60✔
678

60✔
679
    connection.removed();
60✔
680
    this.#guestCountDirty = true;
60✔
681
  }
60✔
682

180✔
683
  /**
180✔
684
   * Replace a connection instance with another connection instance. Useful when
180✔
685
   * a connection changes "type", like GuestConnection → AuthedConnection.
180✔
686
   *
180✔
687
   * @param {Connection} oldConnection
180✔
688
   * @param {Connection} newConnection
180✔
689
   * @private
180✔
690
   */
180✔
691
  replace(oldConnection, newConnection) {
180✔
692
    this.remove(oldConnection);
29✔
693
    this.add(newConnection);
29✔
694
  }
29✔
695

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

263✔
708
    const action = this.#serverActions[command];
263✔
709
    if (action !== undefined) {
263✔
710
      action(data);
240✔
711
    }
240✔
712
  }
263✔
713

180✔
714
  /**
180✔
715
   * Stop the socket server.
180✔
716
   *
180✔
717
   * @returns {Promise<void>}
180✔
718
   */
180✔
719
  async destroy() {
180✔
720
    this.#closing = true;
180✔
721

180✔
722
    this.#unsubscribe();
180✔
723
    clearInterval(this.#pinger);
180✔
724
    clearInterval(this.#guestCountInterval);
180✔
725

180✔
726
    for (const connection of this.#connections) {
180✔
727
      connection.close();
28✔
728
    }
28✔
729

180✔
730
    const closeWsServer = promisify(this.#wss.close.bind(this.#wss));
180✔
731
    await closeWsServer();
180✔
732
  }
180✔
733

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

180✔
745
  ping() {
180✔
746
    this.#connections.forEach((connection) => {
×
747
      connection.ping();
×
748
    });
×
749

×
750
    this.#cleanupMessageQueue().catch((err) => {
×
751
      this.#logger.error({ err }, 'failed to clean up socket message queue');
×
752
    });
×
753
  }
×
754

180✔
755
  async #cleanupMessageQueue() {
180✔
756
    const oldestID = encodeTime(subMinutes(new Date(), 10).getTime());
×
757

×
758
    await this.#uw.db.deleteFrom('socketMessageQueue')
×
759
      .where('id', '<', oldestID)
×
760
      .execute();
×
761
  }
×
762

180✔
763
  /**
180✔
764
   * Broadcast a command to all connected clients.
180✔
765
   *
180✔
766
   * @param {string} command Command name.
180✔
767
   * @param {import('type-fest').JsonValue} data Command data.
180✔
768
   * @param {import('./schema.js').UserID | null} targetUserID
180✔
769
   */
180✔
770
  #recordMessage(command, data, targetUserID = null) {
180✔
771
    const id = ulid();
240✔
772

240✔
773
    this.#uw.db.insertInto('socketMessageQueue')
240✔
774
      .values({
240✔
775
        id,
240✔
776
        command,
240✔
777
        data: jsonb(data),
240✔
778
        targetUserID,
240✔
779
      })
240✔
780
      .execute();
240✔
781

240✔
782
    return id;
240✔
783
  }
240✔
784

180✔
785
  /**
180✔
786
   * Broadcast a command to all connected clients.
180✔
787
   *
180✔
788
   * @param {string} command Command name.
180✔
789
   * @param {import('type-fest').JsonValue} data Command data.
180✔
790
   */
180✔
791
  broadcast(command, data) {
180✔
792
    const id = this.#recordMessage(command, data);
222✔
793

222✔
794
    this.#logger.trace({
222✔
795
      id,
222✔
796
      command,
222✔
797
      data,
222✔
798
      to: this.#connections.map((connection) => (
222✔
799
        'user' in connection ? connection.user.id : null
121!
800
      )),
222✔
801
    }, 'broadcast');
222✔
802

222✔
803
    this.#connections.forEach((connection) => {
222✔
804
      connection.send(id, command, data);
121✔
805
    });
222✔
806
  }
222✔
807

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

18✔
819
    this.#connections.forEach((connection) => {
18✔
820
      if ('user' in connection && connection.user.id === userID) {
16✔
821
        connection.send(id, command, data);
13✔
822
      }
13✔
823
    });
18✔
824
  }
18✔
825

180✔
826
  #lastGuestCount = 0;
180✔
827

180✔
828
  /** The number of unauthenticated connections. */
180✔
829
  get guestCount() {
180✔
830
    return this.#connections.reduce((acc, connection) => {
7✔
831
      if (connection instanceof GuestConnection) {
4!
832
        return acc + 1;
×
833
      }
×
834
      return acc;
4✔
835
    }, 0);
7✔
836
  }
7✔
837

180✔
838
  #recountGuests() {
180✔
839
    const guests = this.guestCount;
×
840
    if (guests !== this.#lastGuestCount) {
×
841
      this.#lastGuestCount = guests;
×
842
      this.broadcast('guests', guests);
×
843
    }
×
844
  }
×
845
}
180✔
846

1✔
847
export default SocketServer;
1✔
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