@komaa/standin-sdk 0.1.0
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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/ambient.d.ts +80 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/ambient.js +281 -0
- package/dist/ambient.js.map +1 -0
- package/dist/attachments.d.ts +149 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +451 -0
- package/dist/attachments.js.map +1 -0
- package/dist/audio.d.ts +80 -0
- package/dist/audio.d.ts.map +1 -0
- package/dist/audio.js +170 -0
- package/dist/audio.js.map +1 -0
- package/dist/avatar.d.ts +111 -0
- package/dist/avatar.d.ts.map +1 -0
- package/dist/avatar.js +201 -0
- package/dist/avatar.js.map +1 -0
- package/dist/callServer.d.ts +151 -0
- package/dist/callServer.d.ts.map +1 -0
- package/dist/callServer.js +1097 -0
- package/dist/callServer.js.map +1 -0
- package/dist/callTools.d.ts +154 -0
- package/dist/callTools.d.ts.map +1 -0
- package/dist/callTools.js +255 -0
- package/dist/callTools.js.map +1 -0
- package/dist/chat.d.ts +226 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +574 -0
- package/dist/chat.js.map +1 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +86 -0
- package/dist/config.js.map +1 -0
- package/dist/consult.d.ts +147 -0
- package/dist/consult.d.ts.map +1 -0
- package/dist/consult.js +388 -0
- package/dist/consult.js.map +1 -0
- package/dist/delivery.d.ts +81 -0
- package/dist/delivery.d.ts.map +1 -0
- package/dist/delivery.js +187 -0
- package/dist/delivery.js.map +1 -0
- package/dist/echoGuard.d.ts +56 -0
- package/dist/echoGuard.d.ts.map +1 -0
- package/dist/echoGuard.js +57 -0
- package/dist/echoGuard.js.map +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +10 -0
- package/dist/errors.js.map +1 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +240 -0
- package/dist/fetch.js.map +1 -0
- package/dist/gate.d.ts +80 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +249 -0
- package/dist/gate.js.map +1 -0
- package/dist/handler.d.ts +283 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +4 -0
- package/dist/handler.js.map +1 -0
- package/dist/hmac.d.ts +32 -0
- package/dist/hmac.d.ts.map +1 -0
- package/dist/hmac.js +108 -0
- package/dist/hmac.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/lane.d.ts +92 -0
- package/dist/lane.d.ts.map +1 -0
- package/dist/lane.js +269 -0
- package/dist/lane.js.map +1 -0
- package/dist/lipsync.d.ts +136 -0
- package/dist/lipsync.d.ts.map +1 -0
- package/dist/lipsync.js +310 -0
- package/dist/lipsync.js.map +1 -0
- package/dist/log.d.ts +18 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/media.d.ts +43 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +190 -0
- package/dist/media.js.map +1 -0
- package/dist/minutes.d.ts +398 -0
- package/dist/minutes.d.ts.map +1 -0
- package/dist/minutes.js +805 -0
- package/dist/minutes.js.map +1 -0
- package/dist/outbound.d.ts +388 -0
- package/dist/outbound.d.ts.map +1 -0
- package/dist/outbound.js +933 -0
- package/dist/outbound.js.map +1 -0
- package/dist/plugins/cartesia/agent.d.ts +58 -0
- package/dist/plugins/cartesia/agent.d.ts.map +1 -0
- package/dist/plugins/cartesia/agent.js +254 -0
- package/dist/plugins/cartesia/agent.js.map +1 -0
- package/dist/plugins/cartesia/cli.d.ts +3 -0
- package/dist/plugins/cartesia/cli.d.ts.map +1 -0
- package/dist/plugins/cartesia/cli.js +10 -0
- package/dist/plugins/cartesia/cli.js.map +1 -0
- package/dist/plugins/cartesia/config.d.ts +30 -0
- package/dist/plugins/cartesia/config.d.ts.map +1 -0
- package/dist/plugins/cartesia/config.js +29 -0
- package/dist/plugins/cartesia/config.js.map +1 -0
- package/dist/plugins/cartesia/handler.d.ts +40 -0
- package/dist/plugins/cartesia/handler.d.ts.map +1 -0
- package/dist/plugins/cartesia/handler.js +142 -0
- package/dist/plugins/cartesia/handler.js.map +1 -0
- package/dist/plugins/cartesia/index.d.ts +6 -0
- package/dist/plugins/cartesia/index.d.ts.map +1 -0
- package/dist/plugins/cartesia/index.js +54 -0
- package/dist/plugins/cartesia/index.js.map +1 -0
- package/dist/plugins/deepgram/agent.d.ts +60 -0
- package/dist/plugins/deepgram/agent.d.ts.map +1 -0
- package/dist/plugins/deepgram/agent.js +302 -0
- package/dist/plugins/deepgram/agent.js.map +1 -0
- package/dist/plugins/deepgram/cli.d.ts +3 -0
- package/dist/plugins/deepgram/cli.d.ts.map +1 -0
- package/dist/plugins/deepgram/cli.js +10 -0
- package/dist/plugins/deepgram/cli.js.map +1 -0
- package/dist/plugins/deepgram/config.d.ts +39 -0
- package/dist/plugins/deepgram/config.d.ts.map +1 -0
- package/dist/plugins/deepgram/config.js +66 -0
- package/dist/plugins/deepgram/config.js.map +1 -0
- package/dist/plugins/deepgram/handler.d.ts +87 -0
- package/dist/plugins/deepgram/handler.d.ts.map +1 -0
- package/dist/plugins/deepgram/handler.js +268 -0
- package/dist/plugins/deepgram/handler.js.map +1 -0
- package/dist/plugins/deepgram/index.d.ts +11 -0
- package/dist/plugins/deepgram/index.d.ts.map +1 -0
- package/dist/plugins/deepgram/index.js +76 -0
- package/dist/plugins/deepgram/index.js.map +1 -0
- package/dist/plugins/echo/cli.d.ts +3 -0
- package/dist/plugins/echo/cli.d.ts.map +1 -0
- package/dist/plugins/echo/cli.js +10 -0
- package/dist/plugins/echo/cli.js.map +1 -0
- package/dist/plugins/echo/index.d.ts +37 -0
- package/dist/plugins/echo/index.d.ts.map +1 -0
- package/dist/plugins/echo/index.js +61 -0
- package/dist/plugins/echo/index.js.map +1 -0
- package/dist/plugins/elevenlabs/agent.d.ts +72 -0
- package/dist/plugins/elevenlabs/agent.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/agent.js +305 -0
- package/dist/plugins/elevenlabs/agent.js.map +1 -0
- package/dist/plugins/elevenlabs/cli.d.ts +3 -0
- package/dist/plugins/elevenlabs/cli.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/cli.js +10 -0
- package/dist/plugins/elevenlabs/cli.js.map +1 -0
- package/dist/plugins/elevenlabs/config.d.ts +27 -0
- package/dist/plugins/elevenlabs/config.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/config.js +42 -0
- package/dist/plugins/elevenlabs/config.js.map +1 -0
- package/dist/plugins/elevenlabs/handler.d.ts +67 -0
- package/dist/plugins/elevenlabs/handler.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/handler.js +419 -0
- package/dist/plugins/elevenlabs/handler.js.map +1 -0
- package/dist/plugins/elevenlabs/index.d.ts +11 -0
- package/dist/plugins/elevenlabs/index.d.ts.map +1 -0
- package/dist/plugins/elevenlabs/index.js +72 -0
- package/dist/plugins/elevenlabs/index.js.map +1 -0
- package/dist/plugins/livekit/cli.d.ts +3 -0
- package/dist/plugins/livekit/cli.d.ts.map +1 -0
- package/dist/plugins/livekit/cli.js +10 -0
- package/dist/plugins/livekit/cli.js.map +1 -0
- package/dist/plugins/livekit/config.d.ts +47 -0
- package/dist/plugins/livekit/config.d.ts.map +1 -0
- package/dist/plugins/livekit/config.js +46 -0
- package/dist/plugins/livekit/config.js.map +1 -0
- package/dist/plugins/livekit/handler.d.ts +59 -0
- package/dist/plugins/livekit/handler.d.ts.map +1 -0
- package/dist/plugins/livekit/handler.js +211 -0
- package/dist/plugins/livekit/handler.js.map +1 -0
- package/dist/plugins/livekit/index.d.ts +6 -0
- package/dist/plugins/livekit/index.d.ts.map +1 -0
- package/dist/plugins/livekit/index.js +68 -0
- package/dist/plugins/livekit/index.js.map +1 -0
- package/dist/plugins/livekit/room.d.ts +117 -0
- package/dist/plugins/livekit/room.d.ts.map +1 -0
- package/dist/plugins/livekit/room.js +449 -0
- package/dist/plugins/livekit/room.js.map +1 -0
- package/dist/plugins/openai/agent.d.ts +74 -0
- package/dist/plugins/openai/agent.d.ts.map +1 -0
- package/dist/plugins/openai/agent.js +264 -0
- package/dist/plugins/openai/agent.js.map +1 -0
- package/dist/plugins/openai/cli.d.ts +3 -0
- package/dist/plugins/openai/cli.d.ts.map +1 -0
- package/dist/plugins/openai/cli.js +10 -0
- package/dist/plugins/openai/cli.js.map +1 -0
- package/dist/plugins/openai/config.d.ts +29 -0
- package/dist/plugins/openai/config.d.ts.map +1 -0
- package/dist/plugins/openai/config.js +37 -0
- package/dist/plugins/openai/config.js.map +1 -0
- package/dist/plugins/openai/handler.d.ts +65 -0
- package/dist/plugins/openai/handler.d.ts.map +1 -0
- package/dist/plugins/openai/handler.js +297 -0
- package/dist/plugins/openai/handler.js.map +1 -0
- package/dist/plugins/openai/index.d.ts +6 -0
- package/dist/plugins/openai/index.d.ts.map +1 -0
- package/dist/plugins/openai/index.js +72 -0
- package/dist/plugins/openai/index.js.map +1 -0
- package/dist/plugins/openclaw/allowlist.d.ts +9 -0
- package/dist/plugins/openclaw/allowlist.d.ts.map +1 -0
- package/dist/plugins/openclaw/allowlist.js +11 -0
- package/dist/plugins/openclaw/allowlist.js.map +1 -0
- package/dist/plugins/openclaw/config.d.ts +50 -0
- package/dist/plugins/openclaw/config.d.ts.map +1 -0
- package/dist/plugins/openclaw/config.js +53 -0
- package/dist/plugins/openclaw/config.js.map +1 -0
- package/dist/plugins/openclaw/handler.d.ts +90 -0
- package/dist/plugins/openclaw/handler.d.ts.map +1 -0
- package/dist/plugins/openclaw/handler.js +146 -0
- package/dist/plugins/openclaw/handler.js.map +1 -0
- package/dist/plugins/openclaw/index.d.ts +55 -0
- package/dist/plugins/openclaw/index.d.ts.map +1 -0
- package/dist/plugins/openclaw/index.js +89 -0
- package/dist/plugins/openclaw/index.js.map +1 -0
- package/dist/plugins/openclaw/openclaw.plugin.json +74 -0
- package/dist/plugins/openclaw/package.json +14 -0
- package/dist/plugins/openclaw/realtime.d.ts +82 -0
- package/dist/plugins/openclaw/realtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/realtime.js +238 -0
- package/dist/plugins/openclaw/realtime.js.map +1 -0
- package/dist/plugins/openclaw/runtime.d.ts +19 -0
- package/dist/plugins/openclaw/runtime.d.ts.map +1 -0
- package/dist/plugins/openclaw/runtime.js +160 -0
- package/dist/plugins/openclaw/runtime.js.map +1 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +95 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +44 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +77 -0
- package/dist/protocol.js.map +1 -0
- package/dist/protocolRuntime.d.ts +19 -0
- package/dist/protocolRuntime.d.ts.map +1 -0
- package/dist/protocolRuntime.js +78 -0
- package/dist/protocolRuntime.js.map +1 -0
- package/dist/smoke.d.ts +65 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +293 -0
- package/dist/smoke.js.map +1 -0
- package/dist/startup.d.ts +74 -0
- package/dist/startup.d.ts.map +1 -0
- package/dist/startup.js +112 -0
- package/dist/startup.js.map +1 -0
- package/dist/tile.d.ts +101 -0
- package/dist/tile.d.ts.map +1 -0
- package/dist/tile.js +195 -0
- package/dist/tile.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/dist/vision.d.ts +160 -0
- package/dist/vision.d.ts.map +1 -0
- package/dist/vision.js +301 -0
- package/dist/vision.js.map +1 -0
- package/dist/visionTools.d.ts +237 -0
- package/dist/visionTools.d.ts.map +1 -0
- package/dist/visionTools.js +578 -0
- package/dist/visionTools.js.map +1 -0
- package/dist/voice.d.ts +143 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +403 -0
- package/dist/voice.js.map +1 -0
- package/package.json +129 -0
|
@@ -0,0 +1,1097 @@
|
|
|
1
|
+
// Copyright (c) 2026 Komaa DigiTech
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The call listener every StandIn plugin shares.
|
|
5
|
+
*
|
|
6
|
+
* StandIn dials `wss://<your-host>/msteams/calling/{callId}` once
|
|
7
|
+
* per call. This server answers that dial, authenticates it, speaks the call
|
|
8
|
+
* wire protocol, and hands each call to a {@link CallHandler} supplied by a
|
|
9
|
+
* plugin. Everything here is the same whichever agent framework is on the other
|
|
10
|
+
* side, which is exactly why it lives in the SDK and not in the plugins:
|
|
11
|
+
*
|
|
12
|
+
* - the HMAC handshake, its freshness window and its single-use replay guard
|
|
13
|
+
* - capacity, draining, and the one-live-session-per-callId rule
|
|
14
|
+
* - the frame loop
|
|
15
|
+
* - outbound sequence numbers and the audio timeline
|
|
16
|
+
* - the pre-start watchdog and the caller-audio idle watchdog
|
|
17
|
+
* - idempotent teardown that always frees the slot
|
|
18
|
+
*
|
|
19
|
+
* The Python SDK's `standin/sdk/call_server.py` is the same server, method for
|
|
20
|
+
* method. Behavioural parity is asserted by the shared conformance vectors.
|
|
21
|
+
*/
|
|
22
|
+
import { createServer, } from "node:http";
|
|
23
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
24
|
+
import { expression as buildExpression, speechMarks as buildSpeechMarks, } from "./avatar.js";
|
|
25
|
+
import { StandInError } from "./errors.js";
|
|
26
|
+
import { logger } from "./log.js";
|
|
27
|
+
import { REPLAY_WINDOW_MS, SIGNATURE_HEADER, SIGNATURE_V2_HEADER, TIMESTAMP_HEADER, nowMs, signRequest, verifyHandshake, } from "./hmac.js";
|
|
28
|
+
import { SAMPLE_RATE_HZ, assistantCancel, audioFrame, contextSentences, decodePcm, parseMessage, parseSessionStart, pong, sessionEnd, } from "./protocol.js";
|
|
29
|
+
import { displayFrame as buildDisplayFrame, displayImage as buildDisplayImage, parseVideoFrame, } from "./vision.js";
|
|
30
|
+
/** How often the single-use handshake cache is swept for expired entries. */
|
|
31
|
+
const PRUNE_INTERVAL_MS = 1_000;
|
|
32
|
+
/**
|
|
33
|
+
* Teardown must never be held hostage by a peer that stopped reading. Until the
|
|
34
|
+
* close settles the callId is still occupied - so every retry for that call
|
|
35
|
+
* 409s, and at maxConnections the whole listener stops accepting. The advisory
|
|
36
|
+
* `session.end` is written before we wait, so a peer that is still listening has
|
|
37
|
+
* what it needs either way.
|
|
38
|
+
*/
|
|
39
|
+
const CLOSE_TIMEOUT_MS = 2_000;
|
|
40
|
+
/** 2 MB bounds a single inbound message, matching the Python SDK. */
|
|
41
|
+
const MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* An outcome report is a handful of JSON fields. The cap matters because the
|
|
44
|
+
* body must be read before the signature over its hash can be checked, so this
|
|
45
|
+
* is the bound on what an unauthenticated peer can make the worker read.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_OUTCOME_BYTES = 8 * 1024;
|
|
48
|
+
/** Constant-time compare of two lowercase hex digests of the same length. */
|
|
49
|
+
function timingSafeEqualHex(a, b) {
|
|
50
|
+
if (a.length !== b.length)
|
|
51
|
+
return false;
|
|
52
|
+
let diff = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i += 1)
|
|
54
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
55
|
+
return diff === 0;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Log-safe rendering of an attacker-influenceable id: control characters (CR/LF
|
|
59
|
+
* forge log lines) replaced, length bounded.
|
|
60
|
+
*/
|
|
61
|
+
function safe(value) {
|
|
62
|
+
// eslint-disable-next-line no-control-regex
|
|
63
|
+
return value.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Call one optional handler method. A {@link CallHandler} is an interface with
|
|
67
|
+
* every member optional, so a plugin implements only what it cares about and a
|
|
68
|
+
* missing method is a no-op. Sync implementations are accepted too.
|
|
69
|
+
*/
|
|
70
|
+
async function dispatch(handler, method, ...args) {
|
|
71
|
+
const fn = handler[method];
|
|
72
|
+
if (typeof fn !== "function")
|
|
73
|
+
return;
|
|
74
|
+
await fn.apply(handler, args);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Outbound bytes past which agent audio is dropped rather than queued.
|
|
78
|
+
*
|
|
79
|
+
* A slow or wedged peer turns "send everything" into an unbounded queue, and
|
|
80
|
+
* that queue is what stalls the provider receive loop feeding it. Shedding keeps
|
|
81
|
+
* the loop moving: the caller hears a gap rather than the call wedging.
|
|
82
|
+
*
|
|
83
|
+
* Audio only. Control frames are what END a call, and a call that cannot be
|
|
84
|
+
* ended is the failure this exists to prevent.
|
|
85
|
+
*/
|
|
86
|
+
export const MAX_AUDIO_BUFFER_BYTES = 1024 * 1024;
|
|
87
|
+
/**
|
|
88
|
+
* How long a call may go without an agent ever answering it.
|
|
89
|
+
*
|
|
90
|
+
* The one gap none of the other watchdogs cover. `preStartTimeoutMs` watches for
|
|
91
|
+
* session.start, which arrived. `onStartTimeoutMs` bounds onStart, which
|
|
92
|
+
* succeeded. `audioIdleTimeoutMs` is satisfied because the CALLER is still
|
|
93
|
+
* talking. So StandIn is on the call, the caller hears nothing, and nothing ends
|
|
94
|
+
* it.
|
|
95
|
+
*/
|
|
96
|
+
export const STALE_CALL_REAPER_MS = 120_000;
|
|
97
|
+
/**
|
|
98
|
+
* How often to look. Coarse on purpose: this is a grace period, not something
|
|
99
|
+
* anybody measures to the second.
|
|
100
|
+
*/
|
|
101
|
+
export const REAPER_CHECK_INTERVAL_MS = 15_000;
|
|
102
|
+
export const REAPER_MIN_INTERVAL_MS = 50;
|
|
103
|
+
/** Milliseconds from a clock that cannot step backwards. */
|
|
104
|
+
function monotonicMs() {
|
|
105
|
+
return Number(process.hrtime.bigint() / 1000000n);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether this call has run past its grace with nothing answering it.
|
|
109
|
+
*
|
|
110
|
+
* Pure, with the clock passed in, so the boundary is testable without waiting.
|
|
111
|
+
* Strictly greater, so a tick landing exactly on the grace does not reap a call
|
|
112
|
+
* one instant early.
|
|
113
|
+
*/
|
|
114
|
+
export function isUnanswered(call, staleMs, now) {
|
|
115
|
+
return (call.answeredAtMs === undefined &&
|
|
116
|
+
staleMs > 0 &&
|
|
117
|
+
now - call.startedAtMs > staleMs);
|
|
118
|
+
}
|
|
119
|
+
/** One live call: the StandIn socket on one side, a plugin's handler on the other. */
|
|
120
|
+
class Call {
|
|
121
|
+
#server;
|
|
122
|
+
#callId;
|
|
123
|
+
#ws;
|
|
124
|
+
#handler;
|
|
125
|
+
#start;
|
|
126
|
+
#seq = 0;
|
|
127
|
+
#sentMs = 0;
|
|
128
|
+
// The tile stream has its own sequence: it is a separate stream from the
|
|
129
|
+
// audio, and a receiver drops out-of-order frames per stream.
|
|
130
|
+
#tileSeq = 0;
|
|
131
|
+
#lastAudio;
|
|
132
|
+
// One recording flag, kept current here so no plugin has to re-derive it
|
|
133
|
+
// from the context sentence it happened to see.
|
|
134
|
+
#recording = false;
|
|
135
|
+
// Whether a recording.status frame has actually said so. session.start OMITS
|
|
136
|
+
// the field when the state was unknown at answer time, and an omitted field is
|
|
137
|
+
// not "not recording": a recording.status that lands first would otherwise be
|
|
138
|
+
// overwritten by the absent snapshot, and every recording-gated capability
|
|
139
|
+
// would stay shut for the rest of the call with nothing said.
|
|
140
|
+
#recordingReported = false;
|
|
141
|
+
// The active speaker, when StandIn sends unmixed audio. Undefined on the
|
|
142
|
+
// mixed path, which is most calls.
|
|
143
|
+
#speaker;
|
|
144
|
+
#participants = 0;
|
|
145
|
+
#audioDropped = 0;
|
|
146
|
+
#lastDropLog = 0;
|
|
147
|
+
// Monotonic, not wall clock: a clock step forward larger than the grace
|
|
148
|
+
// period would otherwise reap every live unanswered call at once.
|
|
149
|
+
startedAtMs = monotonicMs();
|
|
150
|
+
answeredAtMs;
|
|
151
|
+
// Latest frame per source, and only the latest: frames arrive sparsely and a
|
|
152
|
+
// held history would be an unbounded buffer of the caller's screen. Never
|
|
153
|
+
// written to disk.
|
|
154
|
+
#frames = new Map();
|
|
155
|
+
#closed = false;
|
|
156
|
+
/** Set only AFTER onStart resolves; the pre-start watchdog keys on this. */
|
|
157
|
+
#started = false;
|
|
158
|
+
#inStart = false;
|
|
159
|
+
#queue = Promise.resolve();
|
|
160
|
+
#closingReason = "call-ended";
|
|
161
|
+
#closePromise;
|
|
162
|
+
#idleTimer;
|
|
163
|
+
#durationTimer;
|
|
164
|
+
#preStartTimer;
|
|
165
|
+
constructor(server, callId, ws) {
|
|
166
|
+
this.#server = server;
|
|
167
|
+
this.#callId = callId;
|
|
168
|
+
this.#ws = ws;
|
|
169
|
+
}
|
|
170
|
+
// ---- the CallSession surface handed to the plugin ----
|
|
171
|
+
get callId() {
|
|
172
|
+
return this.#callId;
|
|
173
|
+
}
|
|
174
|
+
get start() {
|
|
175
|
+
if (this.#start === undefined)
|
|
176
|
+
throw new StandInError("the call has not started yet");
|
|
177
|
+
return this.#start;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Send agent audio to the caller. The server owns `seq` and the timeline, so a
|
|
181
|
+
* handler that swaps or re-publishes its audio source cannot make
|
|
182
|
+
* `timestampMs` jump backwards while `seq` keeps climbing.
|
|
183
|
+
*/
|
|
184
|
+
/** Whether this call is being recorded, right now. */
|
|
185
|
+
get recordingActive() {
|
|
186
|
+
return this.#recording;
|
|
187
|
+
}
|
|
188
|
+
/** Whether anything has actually taken this call yet. */
|
|
189
|
+
get answered() {
|
|
190
|
+
return this.answeredAtMs !== undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Say that an agent has taken the call. Stamped once, never re-stamped.
|
|
194
|
+
*
|
|
195
|
+
* A plugin that joins a room calls this when the agent's own audio track
|
|
196
|
+
* appears, not when a participant connects: monitors, recorders and avatar
|
|
197
|
+
* workers all connect, and none of them is an agent answering.
|
|
198
|
+
*
|
|
199
|
+
* A plugin that never calls it is still covered, because sending audio counts.
|
|
200
|
+
* The explicit call exists for an agent that joins and listens before it says
|
|
201
|
+
* anything.
|
|
202
|
+
*/
|
|
203
|
+
markAnswered() {
|
|
204
|
+
this.answeredAtMs ??= monotonicMs();
|
|
205
|
+
}
|
|
206
|
+
/** Who is speaking, when StandIn sends unmixed audio. */
|
|
207
|
+
get speaker() {
|
|
208
|
+
return this.#speaker;
|
|
209
|
+
}
|
|
210
|
+
/** How many people are on the call. Zero until StandIn says. */
|
|
211
|
+
get participantCount() {
|
|
212
|
+
return this.#participants;
|
|
213
|
+
}
|
|
214
|
+
/** Outbound bytes the socket has not flushed yet, straight off the socket. */
|
|
215
|
+
get bufferedBytes() {
|
|
216
|
+
return this.#ws.bufferedAmount;
|
|
217
|
+
}
|
|
218
|
+
/** The outbound audio timeline this call stamps its audio with. */
|
|
219
|
+
get mediaTimeMs() {
|
|
220
|
+
return this.#sentMs;
|
|
221
|
+
}
|
|
222
|
+
async sendAudio(pcm) {
|
|
223
|
+
// Gated on the SOCKET, not on #closed: aclose sets #closed BEFORE teardown
|
|
224
|
+
// dispatches the handler's aclose, so gating on the flag would silently
|
|
225
|
+
// break the guarantee that a handler can still speak on the way out.
|
|
226
|
+
if (this.#ws.readyState !== WebSocket.OPEN || pcm.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
// Sending audio IS answering, so every plugin is covered by the reaper
|
|
229
|
+
// without doing anything.
|
|
230
|
+
this.markAnswered();
|
|
231
|
+
const timestampMs = this.#sentMs;
|
|
232
|
+
// The timeline advances whether or not this frame goes out. It is the
|
|
233
|
+
// CALLER's clock: a dropped frame is a gap in what they hear, not a rewind,
|
|
234
|
+
// and stalling the clock would make every later frame claim a time that has
|
|
235
|
+
// already passed.
|
|
236
|
+
this.#seq += 1;
|
|
237
|
+
// Integer division at every step, matching Python's
|
|
238
|
+
// `(len(pcm) // 2) * 1000 // SAMPLE_RATE_HZ` exactly. A float here would
|
|
239
|
+
// drift the two SDKs' timelines apart over a long call.
|
|
240
|
+
this.#sentMs += Math.floor((Math.floor(pcm.length / 2) * 1000) / SAMPLE_RATE_HZ);
|
|
241
|
+
if (this.#overAudioBudget())
|
|
242
|
+
return;
|
|
243
|
+
this.#send(audioFrame(this.#seq, timestampMs, pcm));
|
|
244
|
+
}
|
|
245
|
+
/** Whether the socket is too far behind to take another audio frame. */
|
|
246
|
+
#overAudioBudget() {
|
|
247
|
+
if (this.bufferedBytes <= MAX_AUDIO_BUFFER_BYTES)
|
|
248
|
+
return false;
|
|
249
|
+
this.#audioDropped += 1;
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
if (now - this.#lastDropLog >= 5_000) {
|
|
252
|
+
logger.warn(`standin: call ${safe(this.#callId)} is shedding agent audio to keep the loop ` +
|
|
253
|
+
`moving (${this.#audioDropped} frames so far); the peer is not reading`);
|
|
254
|
+
this.#lastDropLog = now;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Drop whatever agent audio StandIn still has buffered.
|
|
260
|
+
*
|
|
261
|
+
* The only lever that un-sends audio already handed to the service: it
|
|
262
|
+
* flushes the platform player, so the caller stops hearing the turn they just
|
|
263
|
+
* interrupted. Without it a barge-in stops the MODEL but the bot keeps talking
|
|
264
|
+
* for the length of the buffered PCM.
|
|
265
|
+
*
|
|
266
|
+
* Call it the moment your provider reports the caller started speaking, before
|
|
267
|
+
* you cancel the response upstream.
|
|
268
|
+
*/
|
|
269
|
+
async cancelPlayback() {
|
|
270
|
+
this.#send(assistantCancel(this.#seq));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Ask for the call to end.
|
|
274
|
+
*
|
|
275
|
+
* Awaiting teardown from INSIDE onStart would deadlock: teardown waits for
|
|
276
|
+
* onStart to return before dispatching the handler's aclose, and onStart
|
|
277
|
+
* would be waiting for teardown. Refusing a call is a normal thing to do from
|
|
278
|
+
* onStart - an allowlist rejection, a busy runtime, a provider that will not
|
|
279
|
+
* connect - so it is made safe here: ask for the close, return immediately,
|
|
280
|
+
* and let it run once onStart unwinds.
|
|
281
|
+
*/
|
|
282
|
+
latestVideoFrame(source) {
|
|
283
|
+
if (source !== undefined)
|
|
284
|
+
return this.#frames.get(source);
|
|
285
|
+
return this.#frames.get("screenshare") ?? this.#frames.get("camera");
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Draw an image on the bot's video tile.
|
|
289
|
+
*
|
|
290
|
+
* Gated on the socket for the same reason sendAudio is: a handler is allowed
|
|
291
|
+
* to show something on its way out.
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* Send one frame of continuous avatar video.
|
|
295
|
+
*
|
|
296
|
+
* Gated on the socket, like sendAudio: a handler may still be showing
|
|
297
|
+
* something on its way out.
|
|
298
|
+
*/
|
|
299
|
+
async sendTileFrame(jpeg, width, height) {
|
|
300
|
+
if (this.#ws.readyState !== this.#ws.OPEN || jpeg.length === 0)
|
|
301
|
+
return;
|
|
302
|
+
this.#tileSeq += 1;
|
|
303
|
+
this.#send(buildDisplayFrame(this.#tileSeq, this.#sentMs, jpeg, {
|
|
304
|
+
mime: "image/jpeg",
|
|
305
|
+
width,
|
|
306
|
+
height,
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
async displayImage(image, options = {}) {
|
|
310
|
+
if (this.#ws.readyState !== this.#ws.OPEN)
|
|
311
|
+
return;
|
|
312
|
+
this.#send(buildDisplayImage(image, options));
|
|
313
|
+
}
|
|
314
|
+
/** Hint the avatar's emotion. Video only, and never fatal. */
|
|
315
|
+
async express(emotion) {
|
|
316
|
+
if (this.#ws.readyState !== this.#ws.OPEN)
|
|
317
|
+
return;
|
|
318
|
+
this.#send(buildExpression(emotion));
|
|
319
|
+
}
|
|
320
|
+
/** Send one utterance's viseme timeline for avatar lip-sync. */
|
|
321
|
+
async sendSpeechMarks(marks) {
|
|
322
|
+
if (this.#ws.readyState !== this.#ws.OPEN)
|
|
323
|
+
return;
|
|
324
|
+
this.#send(buildSpeechMarks(marks));
|
|
325
|
+
}
|
|
326
|
+
async end(reason) {
|
|
327
|
+
if (this.#inStart) {
|
|
328
|
+
this.#closingReason =
|
|
329
|
+
this.#closePromise === undefined ? reason : this.#closingReason;
|
|
330
|
+
if (this.#closePromise === undefined) {
|
|
331
|
+
this.#closed = true;
|
|
332
|
+
this.#closePromise = this.#teardown();
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
await this.aclose(reason);
|
|
337
|
+
}
|
|
338
|
+
// ---- lifecycle ----
|
|
339
|
+
/** Wire the socket up. Resolves when the call has fully ended. */
|
|
340
|
+
run() {
|
|
341
|
+
this.#preStartTimer = setTimeout(() => {
|
|
342
|
+
if (!this.#started && !this.#closed) {
|
|
343
|
+
logger.warn(`standin: call ${safe(this.#callId)} never sent session.start`);
|
|
344
|
+
void this.aclose("pre-start-timeout");
|
|
345
|
+
}
|
|
346
|
+
}, this.#server.preStartTimeoutMs);
|
|
347
|
+
// Never keep the process alive for a watchdog.
|
|
348
|
+
this.#preStartTimer.unref?.();
|
|
349
|
+
// SERIALIZED, matching Python's `async for msg in self._ws`. Unawaited,
|
|
350
|
+
// any audio.frame packed into the same TCP read as session.start is
|
|
351
|
+
// dispatched while #onSessionStart is still awaiting onStart - so the
|
|
352
|
+
// handler sees audio before it is built, which handler.ts documents as
|
|
353
|
+
// impossible. Python honours it by awaiting serially; without this queue the
|
|
354
|
+
// two SDKs disagree on the one ordering guarantee the seam makes.
|
|
355
|
+
this.#ws.on("message", (data, isBinary) => {
|
|
356
|
+
if (isBinary)
|
|
357
|
+
return;
|
|
358
|
+
this.#queue = this.#queue
|
|
359
|
+
.then(() => this.#onFrame(data))
|
|
360
|
+
.catch(() => undefined);
|
|
361
|
+
});
|
|
362
|
+
this.#ws.on("error", (err) => {
|
|
363
|
+
logger.error(`standin: call ${safe(this.#callId)} socket error: ${String(err)}`);
|
|
364
|
+
void this.aclose("transport-failure");
|
|
365
|
+
});
|
|
366
|
+
this.#ws.on("close", () => {
|
|
367
|
+
void this.aclose();
|
|
368
|
+
});
|
|
369
|
+
return new Promise((resolve) => {
|
|
370
|
+
this.#onEnded = resolve;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
#onEnded;
|
|
374
|
+
async #onFrame(data) {
|
|
375
|
+
const frame = parseMessage(data);
|
|
376
|
+
if (frame === undefined)
|
|
377
|
+
return;
|
|
378
|
+
const kind = frame.type;
|
|
379
|
+
try {
|
|
380
|
+
if (kind === "session.start") {
|
|
381
|
+
if (this.#start !== undefined)
|
|
382
|
+
return; // a second start is a sender bug
|
|
383
|
+
await this.#onSessionStart(parseSessionStart(frame));
|
|
384
|
+
}
|
|
385
|
+
else if (kind === "audio.frame") {
|
|
386
|
+
this.#lastAudio = Date.now();
|
|
387
|
+
await this.#onCallerAudio(frame);
|
|
388
|
+
}
|
|
389
|
+
else if (kind === "video.frame") {
|
|
390
|
+
await this.#onVideoFrame(frame);
|
|
391
|
+
}
|
|
392
|
+
else if (kind === "ping") {
|
|
393
|
+
this.#send(pong(frame.ts));
|
|
394
|
+
}
|
|
395
|
+
else if (kind === "participants") {
|
|
396
|
+
// The same sentences the Python SDK publishes, so agents written against
|
|
397
|
+
// either read identical context.
|
|
398
|
+
if (typeof frame.count === "number") {
|
|
399
|
+
this.#participants = Math.max(0, Math.trunc(frame.count));
|
|
400
|
+
await this.#onContext(contextSentences.participants(frame.count));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (kind === "dtmf") {
|
|
404
|
+
if (typeof frame.digit === "string" && frame.digit !== "") {
|
|
405
|
+
await this.#onContext(contextSentences.dtmf(frame.digit));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
else if (kind === "recording.status") {
|
|
409
|
+
if (typeof frame.status === "string") {
|
|
410
|
+
this.#recording = frame.status === "active";
|
|
411
|
+
this.#recordingReported = true;
|
|
412
|
+
await this.#onContext(contextSentences.recording(frame.status));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else if (kind === "assistant.say") {
|
|
416
|
+
const text = frame.text;
|
|
417
|
+
if (typeof text === "string" && text.trim() !== "") {
|
|
418
|
+
// Flush the worker's queued agent playback FIRST: without the cancel,
|
|
419
|
+
// the goodbye publishes behind seconds of already-buffered audio and
|
|
420
|
+
// the call is torn down before it plays.
|
|
421
|
+
this.#send(assistantCancel(this.#seq));
|
|
422
|
+
await this.#guard("onGoodbye", text);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else if (kind === "session.end") {
|
|
426
|
+
const reason = typeof frame.reason === "string" && frame.reason
|
|
427
|
+
? frame.reason
|
|
428
|
+
: "call-ended";
|
|
429
|
+
await this.aclose(reason);
|
|
430
|
+
}
|
|
431
|
+
// Anything else (the avatar surface included) is ignored by contract, so an
|
|
432
|
+
// older plugin and a newer StandIn interoperate.
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
logger.error(`standin: call ${safe(this.#callId)} failed: ${String(err)}`);
|
|
436
|
+
await this.aclose("transport-failure");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
async #onSessionStart(start) {
|
|
440
|
+
if (start.callId !== this.#callId) {
|
|
441
|
+
// The URL path is what the HMAC signed. A body that disagrees is either a
|
|
442
|
+
// bug or an attempt to ride one call's signature into another's session.
|
|
443
|
+
throw new StandInError(`session.start callId ${JSON.stringify(start.callId)} does not match the authenticated path`);
|
|
444
|
+
}
|
|
445
|
+
clearTimeout(this.#preStartTimer);
|
|
446
|
+
this.#start = start;
|
|
447
|
+
// Only when nothing has reported the real state yet. recording.status can
|
|
448
|
+
// land before session.start, and the snapshot is omitted when the state was
|
|
449
|
+
// unknown at answer time, so seeding unconditionally turns a live ACTIVE
|
|
450
|
+
// into false for the whole call.
|
|
451
|
+
if (!this.#recordingReported)
|
|
452
|
+
this.#recording = start.recordingStatus === "active";
|
|
453
|
+
this.#handler = this.#server.buildHandler();
|
|
454
|
+
this.#lastAudio = Date.now();
|
|
455
|
+
// Armed BEFORE onStart, not after: onStart does real network work, and while
|
|
456
|
+
// it is awaited the frame loop is queued behind it, so session.end is never
|
|
457
|
+
// read. Armed after, a hung onStart has no watchdog at all and the callId
|
|
458
|
+
// 409s forever - one leaked slot per inbound call, up to maxConnections.
|
|
459
|
+
this.#armIdleWatchdog();
|
|
460
|
+
this.#armDurationCeiling();
|
|
461
|
+
this.#inStart = true;
|
|
462
|
+
try {
|
|
463
|
+
await this.#guardStart();
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
this.#inStart = false;
|
|
467
|
+
}
|
|
468
|
+
this.#started = true;
|
|
469
|
+
logger.info(`standin: call ${safe(this.#callId)} started (${start.direction}, caller ` +
|
|
470
|
+
`${safe(start.caller.displayName ?? "unknown")})`);
|
|
471
|
+
}
|
|
472
|
+
/** onStart with its own timeout and its own close reason. */
|
|
473
|
+
async #guardStart() {
|
|
474
|
+
const handler = this.#handler;
|
|
475
|
+
if (handler === undefined)
|
|
476
|
+
return;
|
|
477
|
+
const timeout = this.#server.onStartTimeoutMs;
|
|
478
|
+
try {
|
|
479
|
+
if (timeout > 0) {
|
|
480
|
+
let timer;
|
|
481
|
+
const expiry = new Promise((_, reject) => {
|
|
482
|
+
timer = setTimeout(() => reject(new Error("on-start-timeout")), timeout);
|
|
483
|
+
timer.unref?.();
|
|
484
|
+
});
|
|
485
|
+
try {
|
|
486
|
+
await Promise.race([dispatch(handler, "onStart", this), expiry]);
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
clearTimeout(timer);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
await dispatch(handler, "onStart", this);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch (err) {
|
|
497
|
+
const timedOut = String(err).includes("on-start-timeout");
|
|
498
|
+
logger.error(`standin: handler.onStart ${timedOut ? "timed out" : "failed"} on call ` +
|
|
499
|
+
`${safe(this.#callId)}: ${String(err)}`);
|
|
500
|
+
// A third-party outage inside a plugin must not be reported to StandIn as
|
|
501
|
+
// StandIn's own socket failing - that sends both sides debugging the wrong
|
|
502
|
+
// system.
|
|
503
|
+
await this.aclose(timedOut ? "handler-start-timeout" : "handler-start-failure");
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
async #onCallerAudio(frame) {
|
|
507
|
+
if (this.#handler === undefined || this.#closed)
|
|
508
|
+
return;
|
|
509
|
+
let pcm;
|
|
510
|
+
try {
|
|
511
|
+
pcm = decodePcm(frame.payloadBase64);
|
|
512
|
+
}
|
|
513
|
+
catch (err) {
|
|
514
|
+
logger.warn(`standin: dropping caller frame: ${String(err)}`);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
await this.#noteSpeaker(frame.speakerName);
|
|
518
|
+
await this.#guard("onCallerAudio", pcm);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Remember who is talking, and say so once when it changes.
|
|
522
|
+
*
|
|
523
|
+
* Absent on the mixed path, which is most calls, so this is additive: a
|
|
524
|
+
* handler that never looks at it behaves exactly as before. The callback
|
|
525
|
+
* fires on CHANGE only. It rides every audio frame, and a model told forty
|
|
526
|
+
* times a second who is speaking would hear nothing else.
|
|
527
|
+
*/
|
|
528
|
+
async #noteSpeaker(name) {
|
|
529
|
+
if (typeof name !== "string")
|
|
530
|
+
return;
|
|
531
|
+
const speaker = name.trim();
|
|
532
|
+
if (speaker === "" || speaker === this.#speaker)
|
|
533
|
+
return;
|
|
534
|
+
this.#speaker = speaker;
|
|
535
|
+
// #guard already no-ops on a handler that does not implement it.
|
|
536
|
+
await this.#guard("onSpeakerChange", speaker);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Store the latest frame per source, then offer it to the handler.
|
|
540
|
+
*
|
|
541
|
+
* Stored even when the handler implements no callback, because
|
|
542
|
+
* latestVideoFrame is the way most plugins use this lane: the model asks
|
|
543
|
+
* to look long after the frame arrived.
|
|
544
|
+
*/
|
|
545
|
+
async #onVideoFrame(frame) {
|
|
546
|
+
if (this.#handler === undefined || this.#closed)
|
|
547
|
+
return;
|
|
548
|
+
const parsed = parseVideoFrame(frame);
|
|
549
|
+
// Sparse and best-effort by contract. One unusable frame is not worth a log
|
|
550
|
+
// line per frame, let alone ending the call.
|
|
551
|
+
if (parsed === undefined)
|
|
552
|
+
return;
|
|
553
|
+
this.#frames.set(parsed.source, parsed);
|
|
554
|
+
await this.#guard("onVideoFrame", parsed);
|
|
555
|
+
}
|
|
556
|
+
async #onContext(text) {
|
|
557
|
+
// Context can arrive before session.start on a fast dial. Dropping it is
|
|
558
|
+
// correct: there is no handler to receive it, and the server does not queue
|
|
559
|
+
// on a plugin's behalf.
|
|
560
|
+
if (this.#handler === undefined)
|
|
561
|
+
return;
|
|
562
|
+
await this.#guard("onContext", text);
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Run one handler callback. A plugin throwing must end its own call, not the
|
|
566
|
+
* worker, and not the frame loop mid-utterance.
|
|
567
|
+
*/
|
|
568
|
+
async #guard(method, ...args) {
|
|
569
|
+
const handler = this.#handler;
|
|
570
|
+
if (handler === undefined)
|
|
571
|
+
return;
|
|
572
|
+
try {
|
|
573
|
+
await dispatch(handler, method, ...args);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
logger.error(`standin: handler.${String(method)} failed on call ${safe(this.#callId)}: ${String(err)}`);
|
|
577
|
+
await this.aclose("handler-failure");
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* End the call when the caller's audio stops arriving.
|
|
582
|
+
*
|
|
583
|
+
* A live Microsoft Teams call delivers PCM continuously - silence is still frames - so
|
|
584
|
+
* audio going quiet for this long means the call is gone on the far side and
|
|
585
|
+
* nobody told us. That happens: the peer keeps the socket open (and even keeps
|
|
586
|
+
* pinging) while its own teardown is wedged, and without this backstop the
|
|
587
|
+
* handler's session burns until someone notices.
|
|
588
|
+
*/
|
|
589
|
+
/**
|
|
590
|
+
* End a call that has run past its ceiling, still going.
|
|
591
|
+
*
|
|
592
|
+
* The idle watchdog ends a call that went QUIET. This one ends a call that
|
|
593
|
+
* has not: a caller who will not hang up, a model looping at itself, an
|
|
594
|
+
* automated system that dialled and never stopped talking. Each of those bills
|
|
595
|
+
* a provider by the minute for as long as the socket lives, and none of them
|
|
596
|
+
* trips a silence check.
|
|
597
|
+
*
|
|
598
|
+
* The goodbye goes through the handler's `onGoodbye`, the same callback
|
|
599
|
+
* StandIn's own closing line uses, so no plugin needs new code for this.
|
|
600
|
+
* Playback is flushed FIRST, or the line queues behind however many seconds of
|
|
601
|
+
* agent audio the service still holds and the call ends before anyone hears it.
|
|
602
|
+
*/
|
|
603
|
+
#armDurationCeiling() {
|
|
604
|
+
const limit = this.#server.maxCallMs;
|
|
605
|
+
if (limit <= 0)
|
|
606
|
+
return;
|
|
607
|
+
this.#durationTimer = setTimeout(() => void this.#endOnCeiling(limit), limit);
|
|
608
|
+
this.#durationTimer.unref?.();
|
|
609
|
+
}
|
|
610
|
+
async #endOnCeiling(limit) {
|
|
611
|
+
if (this.#closed || this.#ws.readyState !== WebSocket.OPEN)
|
|
612
|
+
return;
|
|
613
|
+
logger.info(`standin: call ${safe(this.#callId)} reached its ${limit}ms limit; saying goodbye`);
|
|
614
|
+
try {
|
|
615
|
+
await this.cancelPlayback();
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// A dying socket is teardown's business, not the ceiling's.
|
|
619
|
+
}
|
|
620
|
+
const text = this.#server.goodbyeText.trim();
|
|
621
|
+
if (text !== "") {
|
|
622
|
+
await this.#guard("onGoodbye", text);
|
|
623
|
+
// Bounded whatever the handler does with it. A plugin that hangs in
|
|
624
|
+
// onGoodbye must not turn a time-limited call into an endless one, which
|
|
625
|
+
// is the exact failure this exists to prevent.
|
|
626
|
+
await new Promise((resolve) => {
|
|
627
|
+
const timer = setTimeout(resolve, Math.max(0, this.#server.goodbyeGraceMs));
|
|
628
|
+
timer.unref?.();
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
await this.aclose("call-duration-limit");
|
|
632
|
+
}
|
|
633
|
+
#armIdleWatchdog() {
|
|
634
|
+
const idle = this.#server.audioIdleTimeoutMs;
|
|
635
|
+
if (idle <= 0)
|
|
636
|
+
return;
|
|
637
|
+
const tick = Math.min(idle / 4, 10_000);
|
|
638
|
+
this.#idleTimer = setInterval(() => {
|
|
639
|
+
if (this.#closed)
|
|
640
|
+
return;
|
|
641
|
+
const last = this.#lastAudio;
|
|
642
|
+
if (last !== undefined && Date.now() - last > idle) {
|
|
643
|
+
logger.warn(`standin: call ${safe(this.#callId)} got no caller audio for ${idle}ms; ending it`);
|
|
644
|
+
void this.aclose("caller-idle-timeout");
|
|
645
|
+
}
|
|
646
|
+
}, tick);
|
|
647
|
+
this.#idleTimer.unref?.();
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Idempotent teardown, and the FIRST reason wins - a cascade of close causes
|
|
651
|
+
* must not overwrite the one that actually ended the call. Every caller awaits
|
|
652
|
+
* the same promise.
|
|
653
|
+
*/
|
|
654
|
+
async aclose(reason) {
|
|
655
|
+
if (this.#closePromise === undefined) {
|
|
656
|
+
this.#closingReason = reason ?? this.#closingReason;
|
|
657
|
+
this.#closed = true;
|
|
658
|
+
this.#closePromise = this.#teardown();
|
|
659
|
+
}
|
|
660
|
+
await this.#closePromise;
|
|
661
|
+
}
|
|
662
|
+
async #teardown() {
|
|
663
|
+
try {
|
|
664
|
+
clearTimeout(this.#preStartTimer);
|
|
665
|
+
clearInterval(this.#idleTimer);
|
|
666
|
+
clearTimeout(this.#durationTimer);
|
|
667
|
+
// A handler may refuse the call from inside onStart by awaiting
|
|
668
|
+
// session.end(...). Dispatching aclose while onStart is still on the stack
|
|
669
|
+
// tears down half-built state, and then onStart RESUMES and finishes
|
|
670
|
+
// building a provider session nothing will ever close - one leaked socket
|
|
671
|
+
// per refusal. Wait for it, bounded.
|
|
672
|
+
for (let i = 0; this.#inStart && i < 200; i++) {
|
|
673
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
674
|
+
}
|
|
675
|
+
// The plugin releases its side BEFORE the socket closes, so a handler that
|
|
676
|
+
// wants to say something on the way out still can.
|
|
677
|
+
const handler = this.#handler;
|
|
678
|
+
this.#handler = undefined;
|
|
679
|
+
if (handler !== undefined) {
|
|
680
|
+
try {
|
|
681
|
+
await dispatch(handler, "aclose", this.#closingReason);
|
|
682
|
+
}
|
|
683
|
+
catch (err) {
|
|
684
|
+
logger.error(`standin: handler.aclose failed: ${String(err)}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (this.#ws.readyState === WebSocket.OPEN) {
|
|
688
|
+
this.#send(sessionEnd(this.#closingReason));
|
|
689
|
+
await this.#closeSocket();
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
finally {
|
|
693
|
+
// Unconditional: whatever failed above, the slot is released and the
|
|
694
|
+
// callId becomes usable again.
|
|
695
|
+
this.#server.release(this.#callId);
|
|
696
|
+
logger.info(`standin: call ${safe(this.#callId)} ended (${this.#closingReason})`);
|
|
697
|
+
this.#onEnded?.();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
/** Bounded: see CLOSE_TIMEOUT_MS. Releasing the slot matters more than a clean
|
|
701
|
+
* close handshake with an absent peer. */
|
|
702
|
+
#closeSocket() {
|
|
703
|
+
return new Promise((resolve) => {
|
|
704
|
+
const done = () => {
|
|
705
|
+
clearTimeout(timer);
|
|
706
|
+
resolve();
|
|
707
|
+
};
|
|
708
|
+
const timer = setTimeout(() => {
|
|
709
|
+
this.#ws.terminate();
|
|
710
|
+
resolve();
|
|
711
|
+
}, CLOSE_TIMEOUT_MS);
|
|
712
|
+
timer.unref?.();
|
|
713
|
+
this.#ws.once("close", done);
|
|
714
|
+
try {
|
|
715
|
+
this.#ws.close();
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
done();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
#send(text) {
|
|
723
|
+
if (this.#ws.readyState !== WebSocket.OPEN)
|
|
724
|
+
return;
|
|
725
|
+
try {
|
|
726
|
+
this.#ws.send(text);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// A send failing on a dying socket is not an error worth surfacing; the
|
|
730
|
+
// close handler is already on its way.
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Answers the socket StandIn dials, and hands each call to a plugin.
|
|
736
|
+
*
|
|
737
|
+
* Defaults match the StandIn plugin layout: port 9442, path `/msteams/calling`.
|
|
738
|
+
* `0.0.0.0` because the worker usually runs in a container behind an ingress;
|
|
739
|
+
* bind `127.0.0.1` when only a local tunnel should reach the listener (the
|
|
740
|
+
* upgrade is HMAC-authenticated either way).
|
|
741
|
+
*/
|
|
742
|
+
export class CallServer {
|
|
743
|
+
#handlerFactory;
|
|
744
|
+
#onCallOutcome;
|
|
745
|
+
#secret;
|
|
746
|
+
#host;
|
|
747
|
+
#port;
|
|
748
|
+
#wsPath;
|
|
749
|
+
#maxConnections;
|
|
750
|
+
preStartTimeoutMs;
|
|
751
|
+
audioIdleTimeoutMs;
|
|
752
|
+
onStartTimeoutMs;
|
|
753
|
+
maxCallMs;
|
|
754
|
+
goodbyeText;
|
|
755
|
+
goodbyeGraceMs;
|
|
756
|
+
staleCallReaperMs;
|
|
757
|
+
#reaper;
|
|
758
|
+
#calls = new Map();
|
|
759
|
+
/**
|
|
760
|
+
* fingerprint -> signing timestamp (ms). Pruned by AGE, never wholesale:
|
|
761
|
+
* clearing the map would reopen the replay window for every handshake still
|
|
762
|
+
* inside it.
|
|
763
|
+
*/
|
|
764
|
+
#usedSignatures = new Map();
|
|
765
|
+
#lastPrune = nowMs();
|
|
766
|
+
#http;
|
|
767
|
+
#wss;
|
|
768
|
+
/** Stop accepting new calls; live ones continue. */
|
|
769
|
+
draining = false;
|
|
770
|
+
constructor(options) {
|
|
771
|
+
if (typeof options.handlerFactory !== "function") {
|
|
772
|
+
throw new StandInError("handlerFactory must be a function that builds one handler per call");
|
|
773
|
+
}
|
|
774
|
+
this.#handlerFactory = options.handlerFactory;
|
|
775
|
+
this.#onCallOutcome = options.onCallOutcome;
|
|
776
|
+
this.#secret = options.secret ?? process.env.STANDIN_SECRET ?? "";
|
|
777
|
+
if (!this.#secret) {
|
|
778
|
+
throw new StandInError("a StandIn connection secret is required: pass secret or set STANDIN_SECRET");
|
|
779
|
+
}
|
|
780
|
+
this.#host = options.host ?? process.env.STANDIN_HOST ?? "0.0.0.0";
|
|
781
|
+
this.#port = options.port ?? Number(process.env.STANDIN_PORT ?? 9442);
|
|
782
|
+
const path = options.wsPath ?? process.env.STANDIN_WS_PATH ?? "/msteams/calling";
|
|
783
|
+
this.#wsPath = "/" + path.trim().replace(/^\/+|\/+$/g, "");
|
|
784
|
+
if (this.#wsPath === "/") {
|
|
785
|
+
throw new StandInError("wsPath must be a real path such as /msteams/calling");
|
|
786
|
+
}
|
|
787
|
+
this.#maxConnections = options.maxConnections ?? 64;
|
|
788
|
+
this.preStartTimeoutMs = options.preStartTimeoutMs ?? 10_000;
|
|
789
|
+
this.audioIdleTimeoutMs = options.audioIdleTimeoutMs ?? 45_000;
|
|
790
|
+
this.onStartTimeoutMs = options.onStartTimeoutMs ?? 15_000;
|
|
791
|
+
this.maxCallMs = options.maxCallMs ?? 0;
|
|
792
|
+
this.goodbyeText =
|
|
793
|
+
options.goodbyeText ??
|
|
794
|
+
"We are out of time on this call, so I have to stop here. Goodbye.";
|
|
795
|
+
this.goodbyeGraceMs = options.goodbyeGraceMs ?? 6_000;
|
|
796
|
+
this.staleCallReaperMs = options.staleCallReaperMs ?? STALE_CALL_REAPER_MS;
|
|
797
|
+
}
|
|
798
|
+
get wsPath() {
|
|
799
|
+
return this.#wsPath;
|
|
800
|
+
}
|
|
801
|
+
/** The interface the listener is bound to, or will be. */
|
|
802
|
+
get host() {
|
|
803
|
+
return this.#host;
|
|
804
|
+
}
|
|
805
|
+
get port() {
|
|
806
|
+
return this.#port;
|
|
807
|
+
}
|
|
808
|
+
get activeCalls() {
|
|
809
|
+
return this.#calls.size;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether the listener is actually bound.
|
|
813
|
+
*
|
|
814
|
+
* A host that calls connect twice today binds a second listener and leaks the
|
|
815
|
+
* first, and has no way to ask whether the bind succeeded, so it reports a
|
|
816
|
+
* dead platform as connected.
|
|
817
|
+
*/
|
|
818
|
+
get running() {
|
|
819
|
+
return this.#http !== undefined;
|
|
820
|
+
}
|
|
821
|
+
/** @internal */
|
|
822
|
+
buildHandler() {
|
|
823
|
+
return this.#handlerFactory();
|
|
824
|
+
}
|
|
825
|
+
/** @internal */
|
|
826
|
+
release(callId) {
|
|
827
|
+
this.#calls.delete(callId);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* How an outbound call ended, reported by StandIn.
|
|
831
|
+
*
|
|
832
|
+
* Only reached when a plugin asked for it. The route carries the only signal
|
|
833
|
+
* that nobody answered, and without it an unanswered call waits out the ring
|
|
834
|
+
* timeout before anything can be said about it.
|
|
835
|
+
*
|
|
836
|
+
* The body is read and capped BEFORE the signature is checked, because v2
|
|
837
|
+
* signs a hash of the body: there is nothing to verify until the bytes are in
|
|
838
|
+
* hand. The cap is what stops that being a way to make the worker read an
|
|
839
|
+
* unbounded request from an unauthenticated peer.
|
|
840
|
+
*/
|
|
841
|
+
async #onOutcome(req, res, path, callId) {
|
|
842
|
+
const declared = Number(req.headers["content-length"] ?? NaN);
|
|
843
|
+
if (Number.isFinite(declared) && declared > MAX_OUTCOME_BYTES) {
|
|
844
|
+
res.writeHead(413).end();
|
|
845
|
+
req.destroy();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const chunks = [];
|
|
849
|
+
let total = 0;
|
|
850
|
+
let tooBig = false;
|
|
851
|
+
for await (const chunk of req) {
|
|
852
|
+
total += chunk.length;
|
|
853
|
+
if (total > MAX_OUTCOME_BYTES) {
|
|
854
|
+
tooBig = true;
|
|
855
|
+
break;
|
|
856
|
+
}
|
|
857
|
+
chunks.push(chunk);
|
|
858
|
+
}
|
|
859
|
+
if (tooBig) {
|
|
860
|
+
res.writeHead(413).end();
|
|
861
|
+
req.destroy();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const raw = Buffer.concat(chunks);
|
|
865
|
+
const timestamp = String(req.headers[TIMESTAMP_HEADER] ?? "");
|
|
866
|
+
const signature = String(req.headers[SIGNATURE_V2_HEADER] ?? "");
|
|
867
|
+
const expected = signRequest(this.#secret, timestamp, "POST", path, raw);
|
|
868
|
+
if (!signature ||
|
|
869
|
+
!timingSafeEqualHex(signature.trim().toLowerCase(), expected)) {
|
|
870
|
+
logger.warn(`standin: refused an unsigned call outcome for ${safe(callId)}`);
|
|
871
|
+
res.writeHead(401).end();
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const sent = Number(timestamp);
|
|
875
|
+
if (!Number.isFinite(sent) || Math.abs(nowMs() - sent) > REPLAY_WINDOW_MS) {
|
|
876
|
+
res.writeHead(401).end();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
let outcome = "";
|
|
880
|
+
try {
|
|
881
|
+
const parsed = raw.length
|
|
882
|
+
? JSON.parse(raw.toString("utf8"))
|
|
883
|
+
: {};
|
|
884
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
885
|
+
const body = parsed;
|
|
886
|
+
outcome = String(body.outcome ?? body.reason ?? "");
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
outcome = "";
|
|
891
|
+
}
|
|
892
|
+
try {
|
|
893
|
+
await this.#onCallOutcome?.(callId, outcome);
|
|
894
|
+
}
|
|
895
|
+
catch (err) {
|
|
896
|
+
// A plugin failing to handle an outcome must not make StandIn retry
|
|
897
|
+
// forever. Log it and acknowledge.
|
|
898
|
+
logger.error(`standin: handling the outcome for ${safe(callId)} failed: ${String(err)}`);
|
|
899
|
+
}
|
|
900
|
+
res.writeHead(204).end();
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Bind the listener. Transactional: either it is listening when this resolves,
|
|
904
|
+
* or nothing of it survives.
|
|
905
|
+
*/
|
|
906
|
+
async start() {
|
|
907
|
+
if (this.#http !== undefined)
|
|
908
|
+
throw new StandInError("this listener is already running");
|
|
909
|
+
const outcomePrefix = `${this.#wsPath}/outcome/`;
|
|
910
|
+
const http = createServer((req, res) => {
|
|
911
|
+
if (req.url === "/healthz") {
|
|
912
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
913
|
+
res.end(JSON.stringify({ ok: true, calls: this.#calls.size }));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const path = (req.url ?? "").split("?")[0] ?? "";
|
|
917
|
+
if (this.#onCallOutcome !== undefined &&
|
|
918
|
+
req.method === "POST" &&
|
|
919
|
+
path.startsWith(outcomePrefix)) {
|
|
920
|
+
void this.#onOutcome(req, res, path, path.slice(outcomePrefix.length));
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
res.writeHead(404).end();
|
|
924
|
+
});
|
|
925
|
+
const wss = new WebSocketServer({
|
|
926
|
+
noServer: true,
|
|
927
|
+
maxPayload: MAX_PAYLOAD_BYTES,
|
|
928
|
+
});
|
|
929
|
+
http.on("upgrade", (req, socket, head) => {
|
|
930
|
+
this.#onUpgrade(req, socket, head, wss);
|
|
931
|
+
});
|
|
932
|
+
try {
|
|
933
|
+
await new Promise((resolve, reject) => {
|
|
934
|
+
http.once("error", reject);
|
|
935
|
+
http.listen(this.#port, this.#host, () => {
|
|
936
|
+
http.removeListener("error", reject);
|
|
937
|
+
resolve();
|
|
938
|
+
});
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
catch (err) {
|
|
942
|
+
http.close();
|
|
943
|
+
throw err;
|
|
944
|
+
}
|
|
945
|
+
const address = http.address();
|
|
946
|
+
if (address !== null && typeof address === "object")
|
|
947
|
+
this.#port = address.port;
|
|
948
|
+
this.#http = http;
|
|
949
|
+
this.#wss = wss;
|
|
950
|
+
this.#armReaper();
|
|
951
|
+
logger.info(`standin: answering Microsoft Teams calls on ${this.#host}:${this.#port}${this.#wsPath}`);
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* End calls that nothing ever answered.
|
|
955
|
+
*
|
|
956
|
+
* An agent dispatch that never lands is the commonest misconfiguration there
|
|
957
|
+
* is, and it is invisible to every other watchdog: session.start arrived,
|
|
958
|
+
* onStart succeeded, and the caller keeps sending audio the whole time.
|
|
959
|
+
* Without this the caller sits on a live call hearing nothing and the worker
|
|
960
|
+
* holds the slot until somebody notices.
|
|
961
|
+
*/
|
|
962
|
+
#armReaper() {
|
|
963
|
+
const stale = this.staleCallReaperMs;
|
|
964
|
+
if (stale <= 0)
|
|
965
|
+
return;
|
|
966
|
+
const interval = Math.max(REAPER_MIN_INTERVAL_MS, Math.min(REAPER_CHECK_INTERVAL_MS, stale));
|
|
967
|
+
const reaped = new Set();
|
|
968
|
+
this.#reaper = setInterval(() => {
|
|
969
|
+
const now = monotonicMs();
|
|
970
|
+
// A SNAPSHOT: ending a call removes it from the registry being iterated.
|
|
971
|
+
const live = [...this.#calls.entries()];
|
|
972
|
+
// Bounded by what is actually running, so a long-lived worker does not
|
|
973
|
+
// accumulate ids for ever.
|
|
974
|
+
for (const id of [...reaped]) {
|
|
975
|
+
if (!this.#calls.has(id))
|
|
976
|
+
reaped.delete(id);
|
|
977
|
+
}
|
|
978
|
+
for (const [callId, call] of live) {
|
|
979
|
+
if (reaped.has(callId) || !isUnanswered(call, stale, now))
|
|
980
|
+
continue;
|
|
981
|
+
reaped.add(callId);
|
|
982
|
+
logger.warn(`standin: nothing answered call ${safe(callId)} within ${stale}ms; ending it`);
|
|
983
|
+
void call.aclose("no-agent-answered");
|
|
984
|
+
}
|
|
985
|
+
}, interval);
|
|
986
|
+
this.#reaper.unref?.();
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Drain live calls, then stop listening. Awaits the calls' REAL teardown - a
|
|
990
|
+
* close that early-returns on an in-flight closer would let the process exit
|
|
991
|
+
* with teardown still pending, leaking sessions.
|
|
992
|
+
*/
|
|
993
|
+
async aclose() {
|
|
994
|
+
clearInterval(this.#reaper);
|
|
995
|
+
this.#reaper = undefined;
|
|
996
|
+
const calls = [...this.#calls.values()];
|
|
997
|
+
await Promise.allSettled(calls.map((c) => c.aclose("server-shutdown")));
|
|
998
|
+
this.#calls.clear();
|
|
999
|
+
const wss = this.#wss;
|
|
1000
|
+
this.#wss = undefined;
|
|
1001
|
+
if (wss !== undefined)
|
|
1002
|
+
await new Promise((resolve) => wss.close(() => resolve()));
|
|
1003
|
+
const http = this.#http;
|
|
1004
|
+
this.#http = undefined;
|
|
1005
|
+
if (http !== undefined)
|
|
1006
|
+
await new Promise((resolve) => http.close(() => resolve()));
|
|
1007
|
+
}
|
|
1008
|
+
#reject(socket, status, text) {
|
|
1009
|
+
const body = Buffer.from(text, "utf8");
|
|
1010
|
+
socket.write(`HTTP/1.1 ${status} ${text}\r\n` +
|
|
1011
|
+
"connection: close\r\n" +
|
|
1012
|
+
"content-type: text/plain; charset=utf-8\r\n" +
|
|
1013
|
+
`content-length: ${body.length}\r\n\r\n`);
|
|
1014
|
+
socket.end(body);
|
|
1015
|
+
socket.destroy();
|
|
1016
|
+
}
|
|
1017
|
+
#onUpgrade(req, socket, head, wss) {
|
|
1018
|
+
const prefix = `${this.#wsPath}/`;
|
|
1019
|
+
let callId;
|
|
1020
|
+
try {
|
|
1021
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1022
|
+
if (!url.pathname.startsWith(prefix)) {
|
|
1023
|
+
this.#reject(socket, 404, "not found");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
callId = decodeURIComponent(url.pathname.slice(prefix.length));
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
// This event listener runs before authentication. A malformed URL or
|
|
1030
|
+
// percent escape must reject one request, never escape and kill Node.
|
|
1031
|
+
this.#reject(socket, 400, "malformed request path");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (!callId || callId.includes("/")) {
|
|
1035
|
+
this.#reject(socket, 400, "missing callId");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
// Draining: live calls continue, new ones are refused so a worker that is
|
|
1039
|
+
// winding down does not accept calls it will never serve.
|
|
1040
|
+
if (this.draining) {
|
|
1041
|
+
this.#reject(socket, 503, "draining");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
// Capacity is checked BEFORE any crypto, so a flood cannot make us spend CPU
|
|
1045
|
+
// on signatures for calls we were never going to accept.
|
|
1046
|
+
if (this.#calls.size >= this.#maxConnections) {
|
|
1047
|
+
logger.warn(`standin: refusing ${safe(callId)}, at capacity`);
|
|
1048
|
+
this.#reject(socket, 503, "at capacity");
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const timestamp = req.headers[TIMESTAMP_HEADER];
|
|
1052
|
+
const signature = req.headers[SIGNATURE_HEADER];
|
|
1053
|
+
if (!verifyHandshake(this.#secret, timestamp, callId, signature)) {
|
|
1054
|
+
this.#reject(socket, 401, "unauthorized");
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
// Single-use handshake: a correctly signed upgrade replayed inside the
|
|
1058
|
+
// freshness window must not open a second socket. The fingerprint uses the
|
|
1059
|
+
// NORMALIZED signature - verify accepts case/whitespace variants, so keying
|
|
1060
|
+
// on the raw header would let the same capture replay once per casing.
|
|
1061
|
+
const sigNorm = (signature ?? "").trim().toLowerCase();
|
|
1062
|
+
const fingerprint = `${timestamp}.${sigNorm}`;
|
|
1063
|
+
const now = nowMs();
|
|
1064
|
+
if (this.#usedSignatures.has(fingerprint)) {
|
|
1065
|
+
this.#reject(socket, 401, "handshake already used");
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
// Key on the SIGNING timestamp, never the arrival time: verification accepts
|
|
1069
|
+
// a timestamp up to REPLAY_WINDOW_MS in the FUTURE, so an entry aged from
|
|
1070
|
+
// arrival can be pruned while its signature is still valid - reopening the
|
|
1071
|
+
// exact replay this guard exists to close.
|
|
1072
|
+
this.#usedSignatures.set(fingerprint, timestamp ? Number(timestamp) : now);
|
|
1073
|
+
// Prune on a time throttle, not a size threshold: rebuilding once the map
|
|
1074
|
+
// passes a watermark makes every later request O(n). Only correctly signed,
|
|
1075
|
+
// not-yet-seen handshakes reach this line, so the map tracks StandIn's real
|
|
1076
|
+
// call rate rather than attacker traffic.
|
|
1077
|
+
if (now - this.#lastPrune >= PRUNE_INTERVAL_MS) {
|
|
1078
|
+
this.#lastPrune = now;
|
|
1079
|
+
const cutoff = now - REPLAY_WINDOW_MS;
|
|
1080
|
+
for (const [fp, ts] of this.#usedSignatures) {
|
|
1081
|
+
if (ts < cutoff)
|
|
1082
|
+
this.#usedSignatures.delete(fp);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
if (this.#calls.has(callId)) {
|
|
1086
|
+
this.#reject(socket, 409, "call already has a live session");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1090
|
+
const call = new Call(this, callId, ws);
|
|
1091
|
+
this.#calls.set(callId, call);
|
|
1092
|
+
logger.info(`standin: call ${safe(callId)} connected`);
|
|
1093
|
+
void call.run();
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
//# sourceMappingURL=callServer.js.map
|