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 +2 -0
- package/package.json +3 -3
- package/server.js +84 -32
- package/src/core/media-stream.js +8 -7
- package/src/core/mulaw-audio.js +3 -3
- package/src/core/state.js +1 -1
- package/src/core/types.js +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
# buzz-me-in
|
|
2
4
|
|
|
3
5
|
[](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.
|
|
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
|
-
"
|
|
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/
|
|
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('
|
|
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 {
|
|
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
|
|
86
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
311
|
-
// the connection and allocates a MediaStream before any auth runs,
|
|
312
|
-
// the stream token only arrives later in the `start` frame. Defence
|
|
313
|
-
// the one-time token below remains the authority, since this
|
|
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
|
-
|
|
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
|
-
|
|
389
|
+
rejectUpgrade(socket, 503, 'Too many connections');
|
|
352
390
|
return;
|
|
353
391
|
}
|
|
354
392
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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,
|
package/src/core/media-stream.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
70
|
+
* @param {Buffer} data
|
|
71
|
+
* @param {boolean} isBinary
|
|
71
72
|
*/
|
|
72
|
-
processMessage(
|
|
73
|
-
if (
|
|
74
|
-
if (
|
|
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(
|
|
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',
|
package/src/core/mulaw-audio.js
CHANGED
|
@@ -174,14 +174,14 @@ function createDualToneMulaw(lowFrequency, highFrequency, durationMs, amplitude)
|
|
|
174
174
|
}
|
|
175
175
|
|
|
176
176
|
/**
|
|
177
|
-
* @param {{ streamSid: string, wsConnection: {
|
|
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.
|
|
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: {
|
|
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 {
|
|
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