buzz-me-in 2.2.1 → 2.2.2

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/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![buzz-me-in: answer your apartment intercom from Apple Home](https://raw.githubusercontent.com/atdr/buzz-me-in/main/docs/assets/banner.png)
2
+
1
3
  # buzz-me-in
2
4
 
3
5
  [![npm](https://img.shields.io/npm/v/buzz-me-in.svg?style=flat-square)](https://www.npmjs.com/package/buzz-me-in)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
5
  "keywords": [
6
6
  "homekit",
@@ -57,14 +57,14 @@
57
57
  "hap-nodejs": "^0.14.2",
58
58
  "qrcode-terminal": "^0.12.0",
59
59
  "twilio": "^6.1.0",
60
- "websocket": "^1.0.35",
60
+ "ws": "^8.22.0",
61
61
  "zod": "^4.5.4"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@commitlint/cli": "^21.2.2",
65
65
  "@commitlint/config-conventional": "^21.0.2",
66
66
  "@eslint/js": "^10.0.1",
67
- "@types/websocket": "^1.0.10",
67
+ "@types/ws": "^8.18.1",
68
68
  "eslint": "^10.9.1",
69
69
  "eslint-config-prettier": "^10.1.8",
70
70
  "globals": "^17.11.0",
package/server.js CHANGED
@@ -29,7 +29,7 @@ if (require.main === module) {
29
29
 
30
30
  const http = require('http');
31
31
  const twilio = require('twilio');
32
- const WebSocketServer = require('websocket').server;
32
+ const { WebSocketServer } = require('ws');
33
33
 
34
34
  const config = require('./src/core/config');
35
35
  const state = require('./src/core/state');
@@ -45,7 +45,7 @@ const homekit = require('./homekit');
45
45
  const { createLogger } = require('./src/core/log');
46
46
  const { safeEqualString } = require('./src/core/safe-equal');
47
47
  const { MediaStream } = require('./src/core/media-stream');
48
- /** @import { request as WebSocketRequest } from 'websocket' */
48
+ /** @import { Duplex } from 'stream' */
49
49
  /** @import { HandshakeVerificationError } from './src/core/types' */
50
50
 
51
51
  const HTTP_SERVER_PORT = config.port;
@@ -82,17 +82,19 @@ const RINGTONE_WAV = createRingbackWav();
82
82
 
83
83
  const wsserver = http.createServer(handleRequest);
84
84
 
85
- // Enforce message bounds in the websocket library itself so oversized
86
- // frames are rejected before assembly, instead of relying only on the
87
- // per-message check in MediaStream (library defaults allow 1 MiB).
85
+ // Enforce message bounds in the ws library itself so oversized frames are
86
+ // rejected before assembly, instead of relying only on the per-message check
87
+ // in MediaStream (the library default allows 100 MiB).
88
88
  const WS_LIBRARY_MAX_BYTES = MAX_WS_UTF8_BYTES * 4;
89
89
  const MAX_CONCURRENT_WS_CONNECTIONS = config.wsMaxConnections;
90
90
 
91
+ // noServer: the upgrade handler below runs the handshake checks and only then
92
+ // hands the socket to ws. Sockets are tracked in activeWsConnections instead of
93
+ // by the library.
91
94
  const mediaws = new WebSocketServer({
92
- httpServer: wsserver,
93
- autoAcceptConnections: false,
94
- maxReceivedFrameSize: WS_LIBRARY_MAX_BYTES,
95
- maxReceivedMessageSize: WS_LIBRARY_MAX_BYTES,
95
+ noServer: true,
96
+ clientTracking: false,
97
+ maxPayload: WS_LIBRARY_MAX_BYTES,
96
98
  });
97
99
 
98
100
  state.setOnSessionStale((session) => {
@@ -294,28 +296,64 @@ GET_ROUTES.set('/readyz', function (_req, res) {
294
296
  // WebSocket media stream
295
297
  // ---------------------------------------------------------------------------
296
298
 
297
- mediaws.on('request', function (request) {
298
- /** @type {WebSocketRequest} */
299
- const wsRequest = request;
299
+ /**
300
+ * Refuse a WebSocket upgrade with a plain HTTP response, before any WebSocket
301
+ * handshake is written, then drop the socket.
302
+ *
303
+ * @param {Duplex} socket
304
+ * @param {number} status
305
+ * @param {string} reason
306
+ * @returns {void}
307
+ */
308
+ function rejectUpgrade(socket, status, reason) {
309
+ socket.end(
310
+ `HTTP/1.1 ${status} ${http.STATUS_CODES[status]}\r\n` +
311
+ 'Connection: close\r\n' +
312
+ 'Content-Type: text/plain\r\n' +
313
+ `Content-Length: ${Buffer.byteLength(reason)}\r\n` +
314
+ '\r\n' +
315
+ reason
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Handshake checks for the `/media` WebSocket, run before ws takes the socket.
321
+ * Exported so tests can mount it on their own HTTP server.
322
+ *
323
+ * @param {import('http').IncomingMessage} request
324
+ * @param {Duplex} socket
325
+ * @param {Buffer} head
326
+ * @returns {void}
327
+ */
328
+ function handleMediaUpgrade(request, socket, head) {
329
+ // A client that resets mid-handshake would otherwise raise an unhandled
330
+ // 'error' and take the process down via the uncaughtException handler.
331
+ socket.on('error', () => socket.destroy());
300
332
  if (shuttingDown) {
301
- wsRequest.reject(503, 'Server shutting down');
333
+ rejectUpgrade(socket, 503, 'Server shutting down');
334
+ return;
335
+ }
336
+ // Node accepts an absolute-form target such as `http://[`, which URL cannot
337
+ // parse. Thrown here, it would exit the process via uncaughtException.
338
+ let path;
339
+ try {
340
+ path = new URL(request.url || '/', 'http://localhost').pathname;
341
+ } catch {
342
+ rejectUpgrade(socket, 400, 'Bad request');
302
343
  return;
303
344
  }
304
- const path = wsRequest.resourceURL && wsRequest.resourceURL.pathname;
305
345
  if (path !== STREAM_PATH) {
306
- wsRequest.reject(404, 'Not found');
346
+ rejectUpgrade(socket, 404, 'Not found');
307
347
  return;
308
348
  }
309
349
  // Twilio signs the Media Streams handshake, so an unauthenticated socket can
310
- // be refused here rather than after accept(). Without this the server accepts
311
- // the connection and allocates a MediaStream before any auth runs, because
312
- // the stream token only arrives later in the `start` frame. Defence in depth:
313
- // the one-time token below remains the authority, since this signature is an
314
- // HMAC over a constant URL and so never varies between calls.
350
+ // be refused here rather than after the upgrade. Without this the server
351
+ // accepts the connection and allocates a MediaStream before any auth runs,
352
+ // because the stream token only arrives later in the `start` frame. Defence
353
+ // in depth: the one-time token below remains the authority, since this
354
+ // signature is an HMAC over a constant URL and so never varies between calls.
315
355
  if (MEDIA_SIGNATURE_MODE !== 'off') {
316
- const handshake = verifyStreamHandshakeSignature(
317
- wsRequest.httpRequest.headers['x-twilio-signature']
318
- );
356
+ const handshake = verifyStreamHandshakeSignature(request.headers['x-twilio-signature']);
319
357
  if (!handshake.ok) {
320
358
  // strict:false disables discriminated-union narrowing; cast as elsewhere.
321
359
  const handshakeError = /** @type {HandshakeVerificationError} */ (handshake);
@@ -325,7 +363,7 @@ mediaws.on('request', function (request) {
325
363
  enforced: MEDIA_SIGNATURE_MODE === 'enforce',
326
364
  });
327
365
  if (MEDIA_SIGNATURE_MODE === 'enforce') {
328
- wsRequest.reject(403, 'Forbidden');
366
+ rejectUpgrade(socket, 403, 'Forbidden');
329
367
  return;
330
368
  }
331
369
  } else {
@@ -348,18 +386,31 @@ mediaws.on('request', function (request) {
348
386
  event: 'media-ws-rejected',
349
387
  reason: 'too-many-connections',
350
388
  });
351
- wsRequest.reject(503, 'Too many connections');
389
+ rejectUpgrade(socket, 503, 'Too many connections');
352
390
  return;
353
391
  }
354
392
 
355
- const connection = wsRequest.accept(null, wsRequest.origin);
356
- activeWsConnections.add(connection);
357
- connection.on('close', () => activeWsConnections.delete(connection));
358
- mediaWsLogger.info('Media websocket connection accepted', {
359
- event: 'media-ws-accepted',
393
+ mediaws.handleUpgrade(request, socket, head, function (connection) {
394
+ activeWsConnections.add(connection);
395
+ connection.on('close', () => activeWsConnections.delete(connection));
396
+ // ws emits 'error' for protocol violations and frames over maxPayload, then
397
+ // closes the socket itself; 'close' still fires, so MediaStream tears down.
398
+ // Unhandled, the error would exit the process.
399
+ connection.on('error', (err) => {
400
+ mediaWsLogger.warn('Media websocket protocol error', {
401
+ event: 'media-ws-error',
402
+ reason: /** @type {NodeJS.ErrnoException} */ (err).code || 'ws-error',
403
+ error: err,
404
+ });
405
+ });
406
+ mediaWsLogger.info('Media websocket connection accepted', {
407
+ event: 'media-ws-accepted',
408
+ });
409
+ new MediaStream(connection, mediaStreamDeps);
360
410
  });
361
- new MediaStream(connection, mediaStreamDeps);
362
- });
411
+ }
412
+
413
+ wsserver.on('upgrade', handleMediaUpgrade);
363
414
 
364
415
  /** @type {import('./src/core/media-stream').MediaStreamDeps} */
365
416
  const mediaStreamDeps = {
@@ -543,6 +594,7 @@ module.exports = {
543
594
  beginShutdown,
544
595
  buildTwiml,
545
596
  describeCaller,
597
+ handleMediaUpgrade,
546
598
  handleRequest,
547
599
  isAuthorizedForStatus,
548
600
  isValidTwilioRequest,
@@ -13,7 +13,7 @@ const { PassThrough } = require('stream');
13
13
  const { parseTwilioWsEvent, parseTwilioMediaPayload } = require('./ws-events-schema');
14
14
  const { createRingbackMulawCycle, sendMulawAudio } = require('./mulaw-audio');
15
15
 
16
- /** @import { connection, Message } from 'websocket' */
16
+ /** @import { WebSocket } from 'ws' */
17
17
  /** @import { WsEventParseOkSupported, StreamTokenVerificationResult, TokenVerificationError, WsEventParseError, MediaPayloadParseError } from './types' */
18
18
 
19
19
  /**
@@ -31,7 +31,7 @@ const { createRingbackMulawCycle, sendMulawAudio } = require('./mulaw-audio');
31
31
 
32
32
  class MediaStream {
33
33
  /**
34
- * @param {connection} connection
34
+ * @param {WebSocket} connection
35
35
  * @param {MediaStreamDeps} deps
36
36
  */
37
37
  constructor(connection, deps) {
@@ -67,11 +67,12 @@ class MediaStream {
67
67
  }
68
68
 
69
69
  /**
70
- * @param {Message} message
70
+ * @param {Buffer} data
71
+ * @param {boolean} isBinary
71
72
  */
72
- processMessage(message) {
73
- if (message.type !== 'utf8') return;
74
- if (message.utf8Data.length > this.deps.maxUtf8Bytes) {
73
+ processMessage(data, isBinary) {
74
+ if (isBinary) return;
75
+ if (data.length > this.deps.maxUtf8Bytes) {
75
76
  this.logger.warn('Media websocket message too large', {
76
77
  event: 'media-ws-message-too-large',
77
78
  reason: 'max-message-bytes-exceeded',
@@ -82,7 +83,7 @@ class MediaStream {
82
83
 
83
84
  let rawData;
84
85
  try {
85
- rawData = JSON.parse(message.utf8Data);
86
+ rawData = JSON.parse(data.toString('utf8'));
86
87
  } catch {
87
88
  this.logger.warn('Media websocket invalid JSON', {
88
89
  event: 'media-ws-invalid-json',
@@ -174,14 +174,14 @@ function createDualToneMulaw(lowFrequency, highFrequency, durationMs, amplitude)
174
174
  }
175
175
 
176
176
  /**
177
- * @param {{ streamSid: string, wsConnection: { sendUTF: (message: string) => void } }} activeCall
177
+ * @param {{ streamSid: string, wsConnection: { send: (message: string) => void } }} activeCall
178
178
  * @param {Buffer} payload
179
179
  */
180
180
  function sendMulawAudio(activeCall, payload) {
181
181
  if (!activeCall || !activeCall.streamSid || !activeCall.wsConnection) {
182
182
  throw new Error('Cannot send audio without an active media stream');
183
183
  }
184
- activeCall.wsConnection.sendUTF(
184
+ activeCall.wsConnection.send(
185
185
  JSON.stringify({
186
186
  event: 'media',
187
187
  streamSid: activeCall.streamSid,
@@ -191,7 +191,7 @@ function sendMulawAudio(activeCall, payload) {
191
191
  }
192
192
 
193
193
  /**
194
- * @param {{ streamSid: string, wsConnection: { sendUTF: (message: string) => void } }} activeCall
194
+ * @param {{ streamSid: string, wsConnection: { send: (message: string) => void } }} activeCall
195
195
  * @param {string} digits
196
196
  * @param {{ chunkMs?: number, toneMs?: number, trailingSilenceMs?: number }} [options]
197
197
  */
package/src/core/state.js CHANGED
@@ -4,7 +4,7 @@ const config = require('./config');
4
4
  const { createLogger } = require('./log');
5
5
  const logger = createLogger({ component: 'state' });
6
6
 
7
- /** @import { connection as WebSocketConnection } from 'websocket' */
7
+ /** @import { WebSocket as WebSocketConnection } from 'ws' */
8
8
  /** @import { CallSession, CallStatus, StartCallResult, ClearCallResult, ClearedCallSession } from './types.js' */
9
9
 
10
10
  class CallSessionManager {
package/src/core/types.js CHANGED
@@ -118,7 +118,7 @@
118
118
  * @typedef {{
119
119
  * callSid: string,
120
120
  * streamSid: string,
121
- * wsConnection: import('websocket').connection,
121
+ * wsConnection: import('ws').WebSocket,
122
122
  * createdAtMs: number,
123
123
  * lastEventAtMs: number,
124
124
  * lastEvent: string