@kuralle-syrinx/server-websocket 2.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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/package.json +31 -0
  3. package/src/admission-control.test.ts +247 -0
  4. package/src/browser-opus.test.ts +41 -0
  5. package/src/browser-opus.ts +59 -0
  6. package/src/browser-pacing.test.ts +440 -0
  7. package/src/edge-twilio.test.ts +215 -0
  8. package/src/edge-twilio.ts +285 -0
  9. package/src/edge.test.ts +272 -0
  10. package/src/edge.ts +652 -0
  11. package/src/graceful-drain.test.ts +306 -0
  12. package/src/inbound-audio.ts +102 -0
  13. package/src/index.test.ts +2071 -0
  14. package/src/index.ts +891 -0
  15. package/src/json-message.ts +39 -0
  16. package/src/outbound-playout-pipeline.test.ts +208 -0
  17. package/src/outbound-playout-pipeline.ts +167 -0
  18. package/src/paced-playout.test.ts +247 -0
  19. package/src/paced-playout.ts +161 -0
  20. package/src/playout-progress.test.ts +56 -0
  21. package/src/playout-progress.ts +67 -0
  22. package/src/session-store.test.ts +274 -0
  23. package/src/session-store.ts +123 -0
  24. package/src/smartpbx.test.ts +967 -0
  25. package/src/smartpbx.ts +531 -0
  26. package/src/telnyx.test.ts +1413 -0
  27. package/src/telnyx.ts +708 -0
  28. package/src/test-helpers.ts +264 -0
  29. package/src/transport-helpers.ts +78 -0
  30. package/src/transport-host.test.ts +51 -0
  31. package/src/transport-host.ts +213 -0
  32. package/src/turn-metrics.test.ts +327 -0
  33. package/src/turn-metrics.ts +193 -0
  34. package/src/twilio.test.ts +1275 -0
  35. package/src/twilio.ts +599 -0
  36. package/src/websocket-close.test.ts +63 -0
  37. package/src/websocket-close.ts +68 -0
  38. package/src/websocket-lifecycle.test.ts +49 -0
  39. package/src/websocket-lifecycle.ts +105 -0
  40. package/src/websocket-upgrade.ts +102 -0
  41. package/tsconfig.json +22 -0
  42. package/vitest.config.ts +7 -0
@@ -0,0 +1,161 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export interface PacedPlayoutFrame {
4
+ readonly send: () => void | boolean;
5
+ readonly afterSend?: () => void;
6
+ /** Context this frame belongs to, for realtime playout-position reporting. */
7
+ readonly contextId?: string;
8
+ /**
9
+ * When `false`, this frame survives a barge-in `clearInterruptible()`. Audio frames
10
+ * are always interruptible (left undefined), so speech is never replayed after an interrupt.
11
+ */
12
+ readonly interruptible?: boolean;
13
+ }
14
+
15
+ interface QueuedPlayoutFrame extends PacedPlayoutFrame {
16
+ readonly durationMs: number;
17
+ }
18
+
19
+ const DEADLINE_MISS_TOLERANCE_MS = 5;
20
+
21
+ export class PacedPlayoutQueue {
22
+ private readonly frames: QueuedPlayoutFrame[] = [];
23
+ private timer: ReturnType<typeof setTimeout> | null = null;
24
+ private pumping = false;
25
+ private queuedDurationMs = 0;
26
+ private closed = false;
27
+ private nextDeadlineMs = 0;
28
+
29
+ constructor(
30
+ private readonly frameDurationMs: number,
31
+ private readonly maxQueuedDurationMs: number,
32
+ private readonly onOverflow: (discardedDurationMs: number) => void,
33
+ private readonly onSendFailure: (discardedDurationMs: number) => void = () => undefined,
34
+ private readonly onDeadlineMiss: (lateMs: number) => void = () => undefined,
35
+ // Fires after each audio frame is paced to the wire — the realtime playout
36
+ // clock. durationMs is the frame's realtime length; contextId tags the turn.
37
+ private readonly onFramePlayed: (contextId: string | undefined, durationMs: number) => void = () => undefined,
38
+ ) {}
39
+
40
+ enqueue(frames: readonly PacedPlayoutFrame[]): boolean {
41
+ if (this.closed || frames.length === 0) return !this.closed;
42
+ const additionalDurationMs = frames.length * this.frameDurationMs;
43
+ if (this.queuedDurationMs + additionalDurationMs > this.maxQueuedDurationMs) {
44
+ // Overflow drops only the tail that does not fit. Never clear queued audio
45
+ // and never stop the stream: TTS providers burst faster than realtime
46
+ // (Deepgram delivers a whole reply at once) and a phone call must keep
47
+ // playing what it already has. onOverflow reports the dropped duration.
48
+ const roomMs = Math.max(0, this.maxQueuedDurationMs - this.queuedDurationMs);
49
+ const acceptCount = Math.min(frames.length, Math.floor(roomMs / this.frameDurationMs));
50
+ const accepted = frames.slice(0, acceptCount);
51
+ const droppedDurationMs = (frames.length - acceptCount) * this.frameDurationMs;
52
+ this.frames.push(...accepted.map((frame) => ({ ...frame, durationMs: this.frameDurationMs })));
53
+ this.queuedDurationMs += acceptCount * this.frameDurationMs;
54
+ this.onOverflow(droppedDurationMs);
55
+ this.maybePump();
56
+ return false;
57
+ }
58
+ this.frames.push(...frames.map((frame) => ({ ...frame, durationMs: this.frameDurationMs })));
59
+ this.queuedDurationMs += additionalDurationMs;
60
+ this.maybePump();
61
+ return true;
62
+ }
63
+
64
+ enqueueControl(send: () => void | boolean, opts?: { uninterruptible?: boolean }): void {
65
+ if (this.closed) return;
66
+ if (this.frames.length === 0 && this.timer === null && !this.pumping) {
67
+ send();
68
+ return;
69
+ }
70
+ this.frames.push({ send, durationMs: 0, interruptible: opts?.uninterruptible ? false : undefined });
71
+ this.maybePump();
72
+ }
73
+
74
+ clear(): number {
75
+ const removedAudioDurationMs = this.queuedDurationMs;
76
+ this.frames.length = 0;
77
+ this.queuedDurationMs = 0;
78
+ this.nextDeadlineMs = 0;
79
+ if (this.timer) {
80
+ clearTimeout(this.timer);
81
+ this.timer = null;
82
+ }
83
+ return removedAudioDurationMs;
84
+ }
85
+
86
+ /**
87
+ * Barge-in clear: drop interruptible frames (all audio + unmarked control frames),
88
+ * retaining frames enqueued as uninterruptible. Audio frames are always interruptible,
89
+ * so speech is never replayed. Returns the discarded audio duration in ms. With nothing
90
+ * marked uninterruptible this is equivalent to `clear()`.
91
+ */
92
+ clearInterruptible(): number {
93
+ const retained: QueuedPlayoutFrame[] = [];
94
+ let removedAudioDurationMs = 0;
95
+ for (const frame of this.frames) {
96
+ if (frame.interruptible === false) {
97
+ retained.push(frame);
98
+ } else {
99
+ removedAudioDurationMs += frame.durationMs;
100
+ }
101
+ }
102
+ this.frames.length = 0;
103
+ this.frames.push(...retained);
104
+ this.queuedDurationMs = retained.reduce((sum, frame) => sum + frame.durationMs, 0);
105
+ this.nextDeadlineMs = 0;
106
+ if (this.timer) {
107
+ clearTimeout(this.timer);
108
+ this.timer = null;
109
+ }
110
+ if (this.frames.length > 0) this.maybePump();
111
+ return removedAudioDurationMs;
112
+ }
113
+
114
+ close(): void {
115
+ this.closed = true;
116
+ this.clear();
117
+ }
118
+
119
+ private maybePump(): void {
120
+ if (this.pumping || this.timer || this.frames.length === 0) return;
121
+ this.pump();
122
+ }
123
+
124
+ private pump(): void {
125
+ if (this.closed) return;
126
+ this.timer = null;
127
+ const frame = this.frames.shift();
128
+ if (!frame) return;
129
+ this.pumping = true;
130
+ const now = Date.now();
131
+
132
+ // On the first frame after a clear/start, establish the deadline baseline.
133
+ if (this.nextDeadlineMs === 0) {
134
+ this.nextDeadlineMs = now;
135
+ } else if (now - this.nextDeadlineMs > DEADLINE_MISS_TOLERANCE_MS) {
136
+ this.onDeadlineMiss(now - this.nextDeadlineMs);
137
+ }
138
+
139
+ this.queuedDurationMs = Math.max(0, this.queuedDurationMs - frame.durationMs);
140
+ const sent = frame.send();
141
+ if (sent === false) {
142
+ const discardedDurationMs = frame.durationMs + this.clear();
143
+ this.pumping = false;
144
+ this.onSendFailure(discardedDurationMs);
145
+ return;
146
+ }
147
+ frame.afterSend?.();
148
+ if (frame.durationMs > 0) this.onFramePlayed(frame.contextId, frame.durationMs);
149
+ this.pumping = false;
150
+ if (this.frames.length > 0) {
151
+ this.nextDeadlineMs += frame.durationMs;
152
+ const delay = Math.max(0, this.nextDeadlineMs - Date.now());
153
+ this.timer = setTimeout(() => this.pump(), delay);
154
+ } else {
155
+ // Queue drained on a natural inter-sentence gap (no clear() — clear() only
156
+ // runs on overflow/send-failure/interrupt). Re-baseline so the next burst's
157
+ // first frame doesn't read the stale deadline as a huge spurious miss.
158
+ this.nextDeadlineMs = 0;
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { PipelineBusImpl } from "@kuralle-syrinx/core";
5
+ import { PlayoutProgressEmitter } from "./playout-progress.js";
6
+
7
+ async function withRunningBus(fn: (bus: PipelineBusImpl) => void | Promise<void>): Promise<void> {
8
+ const bus = new PipelineBusImpl();
9
+ const startP = bus.start();
10
+ await new Promise((resolve) => setTimeout(resolve, 5));
11
+ await fn(bus);
12
+ await new Promise((resolve) => setTimeout(resolve, 5));
13
+ bus.stop();
14
+ await startP;
15
+ }
16
+
17
+ describe("PlayoutProgressEmitter", () => {
18
+ it("emits playout_started on the first paced audio frame and throttles progress", async () => {
19
+ const startedAt: number[] = [];
20
+ const progress: number[] = [];
21
+
22
+ await withRunningBus((bus) => {
23
+ bus.on("tts.playout_started", (pkt) => {
24
+ startedAt.push((pkt as { timestampMs: number }).timestampMs);
25
+ });
26
+ bus.on("tts.playout_progress", (pkt) => {
27
+ progress.push((pkt as unknown as { playedOutMs: number }).playedOutMs);
28
+ });
29
+
30
+ const emitter = new PlayoutProgressEmitter(bus);
31
+ emitter.onFramePlayed("turn-a", 20);
32
+ emitter.onFramePlayed("turn-a", 20);
33
+ emitter.onFramePlayed("turn-a", 200);
34
+ });
35
+
36
+ expect(startedAt).toHaveLength(1);
37
+ expect(progress).toEqual([240]);
38
+ });
39
+
40
+ it("re-emits playout_started after discard resets context tracking", async () => {
41
+ let startedCount = 0;
42
+
43
+ await withRunningBus((bus) => {
44
+ bus.on("tts.playout_started", () => {
45
+ startedCount += 1;
46
+ });
47
+
48
+ const emitter = new PlayoutProgressEmitter(bus);
49
+ emitter.onFramePlayed("turn-a", 20);
50
+ emitter.discard("turn-a");
51
+ emitter.onFramePlayed("turn-a", 20);
52
+ });
53
+
54
+ expect(startedCount).toBe(2);
55
+ });
56
+ });
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { Route, type PipelineBus } from "@kuralle-syrinx/core";
4
+
5
+ const PROGRESS_THROTTLE_MS = 200;
6
+
7
+ /**
8
+ * Tracks realtime playout position per context for one connection and emits
9
+ * `tts.playout_progress` as paced audio reaches the wire. This makes the output
10
+ * transport the authoritative playout clock that the engine's turn-taking (and,
11
+ * later, recording) consume — instead of reconstructing timing from TTS
12
+ * generation arrival, which is provider-stream-rate dependent.
13
+ *
14
+ * - `onFramePlayed` is wired to the PacedPlayoutQueue and called per frame; it
15
+ * accumulates and emits throttled progress.
16
+ * - `complete` is called from the transport's end-of-context drain (the control
17
+ * frame that runs after all audio has been paced out) and is authoritative.
18
+ * - `discard` drops a context on interrupt/clear without emitting completion.
19
+ */
20
+ export class PlayoutProgressEmitter {
21
+ private readonly playedOutMs = new Map<string, number>();
22
+ private readonly lastEmittedMs = new Map<string, number>();
23
+ private readonly playoutStarted = new Set<string>();
24
+
25
+ constructor(private readonly bus: PipelineBus) {}
26
+
27
+ onFramePlayed = (contextId: string | undefined, durationMs: number): void => {
28
+ if (contextId === undefined || durationMs <= 0) return;
29
+ if (!this.playoutStarted.has(contextId)) {
30
+ this.playoutStarted.add(contextId);
31
+ this.bus.push(Route.Main, {
32
+ kind: "tts.playout_started",
33
+ contextId,
34
+ timestampMs: Date.now(),
35
+ });
36
+ }
37
+ const total = (this.playedOutMs.get(contextId) ?? 0) + durationMs;
38
+ this.playedOutMs.set(contextId, total);
39
+ const lastEmitted = this.lastEmittedMs.get(contextId) ?? 0;
40
+ if (total - lastEmitted >= PROGRESS_THROTTLE_MS) {
41
+ this.lastEmittedMs.set(contextId, total);
42
+ this.emit(contextId, total, false);
43
+ }
44
+ };
45
+
46
+ complete(contextId: string): void {
47
+ const total = this.playedOutMs.get(contextId) ?? 0;
48
+ this.discard(contextId);
49
+ this.emit(contextId, total, true);
50
+ }
51
+
52
+ discard(contextId: string): void {
53
+ this.playedOutMs.delete(contextId);
54
+ this.lastEmittedMs.delete(contextId);
55
+ this.playoutStarted.delete(contextId);
56
+ }
57
+
58
+ private emit(contextId: string, playedOutMs: number, complete: boolean): void {
59
+ this.bus.push(Route.Main, {
60
+ kind: "tts.playout_progress",
61
+ contextId,
62
+ timestampMs: Date.now(),
63
+ playedOutMs,
64
+ complete,
65
+ });
66
+ }
67
+ }
@@ -0,0 +1,274 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
5
+ import { createVoiceWebSocketServer } from "./index.js";
6
+ import {
7
+ InMemorySessionStore,
8
+ type ManagedSession,
9
+ type SessionStore,
10
+ } from "./session-store.js";
11
+ import { TurnMetricsTracker } from "./turn-metrics.js";
12
+ import {
13
+ openBrowserClientAndReadReady,
14
+ registerServer,
15
+ setupTransportTestCleanup,
16
+ waitForCondition,
17
+ } from "./test-helpers.js";
18
+
19
+ setupTransportTestCleanup();
20
+
21
+ function createManagedSession(id: string): ManagedSession {
22
+ return {
23
+ id,
24
+ session: new VoiceAgentSession({ plugins: {} }),
25
+ currentContextId: "turn-test",
26
+ contextSampleRates: new Map(),
27
+ inputSequence: { lastSequence: null },
28
+ turnMetricsTurns: new Map(),
29
+ closeTimer: null,
30
+ connectionCount: 1,
31
+ };
32
+ }
33
+
34
+ describe("InMemorySessionStore", () => {
35
+ it("leases a new session when none exists", async () => {
36
+ const store = new InMemorySessionStore();
37
+ const create = vi.fn(async () => createManagedSession("session-a"));
38
+
39
+ const leased = await store.lease("session-a", create);
40
+
41
+ expect(create).toHaveBeenCalledTimes(1);
42
+ expect(leased.resumed).toBe(false);
43
+ expect(leased.managed.id).toBe("session-a");
44
+ expect(await store.get("session-a")).toBe(leased.managed);
45
+ });
46
+
47
+ it("resumes an existing session within the retention window", async () => {
48
+ const store = new InMemorySessionStore();
49
+ const create = vi.fn(async () => createManagedSession("session-a"));
50
+ const first = await store.lease("session-a", create);
51
+ first.managed.connectionCount = 0;
52
+ await store.release("session-a", 200);
53
+
54
+ const second = await store.lease("session-a", create);
55
+
56
+ expect(create).toHaveBeenCalledTimes(1);
57
+ expect(second.resumed).toBe(true);
58
+ expect(second.managed).toBe(first.managed);
59
+ expect(second.managed.connectionCount).toBe(1);
60
+ expect(second.managed.closeTimer).toBeNull();
61
+ });
62
+
63
+ it("creates a fresh session after the retention window expires", async () => {
64
+ const store = new InMemorySessionStore();
65
+ const create = vi.fn(async () => createManagedSession("session-a"));
66
+ const first = await store.lease("session-a", create);
67
+ const firstSession = first.managed.session;
68
+ first.managed.connectionCount = 0;
69
+ await store.release("session-a", 10);
70
+ await new Promise((resolve) => setTimeout(resolve, 30));
71
+
72
+ const second = await store.lease("session-a", create);
73
+
74
+ expect(create).toHaveBeenCalledTimes(2);
75
+ expect(second.resumed).toBe(false);
76
+ expect(second.managed.session).not.toBe(firstSession);
77
+ expect(await store.get("session-a")).toBe(second.managed);
78
+ });
79
+
80
+ it("closes immediately when retainMs is zero", async () => {
81
+ const store = new InMemorySessionStore();
82
+ const managed = createManagedSession("session-a");
83
+ const closeSpy = vi.spyOn(managed.session, "close");
84
+ await store.lease("session-a", async () => managed);
85
+ managed.connectionCount = 0;
86
+ await store.release("session-a", 0);
87
+
88
+ expect(closeSpy).toHaveBeenCalledTimes(1);
89
+ expect(await store.get("session-a")).toBeNull();
90
+ });
91
+
92
+ it("does not release while connections remain active", async () => {
93
+ const store = new InMemorySessionStore();
94
+ const managed = createManagedSession("session-a");
95
+ const closeSpy = vi.spyOn(managed.session, "close");
96
+ await store.lease("session-a", async () => managed);
97
+
98
+ await store.release("session-a", 10);
99
+ await new Promise((resolve) => setTimeout(resolve, 30));
100
+
101
+ expect(closeSpy).not.toHaveBeenCalled();
102
+ expect(await store.get("session-a")).toBe(managed);
103
+ });
104
+
105
+ it("serializes concurrent leases so only one session is created", async () => {
106
+ const store = new InMemorySessionStore();
107
+ let createCount = 0;
108
+ const create = vi.fn(async () => {
109
+ createCount += 1;
110
+ await new Promise((resolve) => setTimeout(resolve, 30));
111
+ return createManagedSession("session-a");
112
+ });
113
+
114
+ const [first, second] = await Promise.all([
115
+ store.lease("session-a", create),
116
+ store.lease("session-a", create),
117
+ ]);
118
+
119
+ expect(create).toHaveBeenCalledTimes(1);
120
+ expect(createCount).toBe(1);
121
+ expect(first.managed).toBe(second.managed);
122
+ expect(first.resumed).toBe(false);
123
+ expect(second.resumed).toBe(true);
124
+ });
125
+
126
+ it("release with retainMs zero evicts immediately even when a retention timer is active", async () => {
127
+ const store = new InMemorySessionStore();
128
+ const managed = createManagedSession("session-a");
129
+ const closeSpy = vi.spyOn(managed.session, "close");
130
+ await store.lease("session-a", async () => managed);
131
+ managed.connectionCount = 0;
132
+ await store.release("session-a", 200);
133
+ expect(managed.closeTimer).not.toBeNull();
134
+
135
+ await store.release("session-a", 0);
136
+
137
+ expect(managed.closeTimer).toBeNull();
138
+ expect(closeSpy).toHaveBeenCalledTimes(1);
139
+ expect(await store.get("session-a")).toBeNull();
140
+ });
141
+
142
+ it("persists turn metrics state across resumed connections", async () => {
143
+ const store = new InMemorySessionStore();
144
+ const managed = createManagedSession("session-a");
145
+ const session = managed.session;
146
+ await store.lease("session-a", async () => managed);
147
+
148
+ const emitted: unknown[] = [];
149
+ const firstTracker = new TurnMetricsTracker(session.bus, (message) => emitted.push(message), managed.turnMetricsTurns);
150
+ const disposers: Array<() => void> = [];
151
+ firstTracker.wire(disposers);
152
+ void session.start();
153
+
154
+ session.bus.push(Route.Main, {
155
+ kind: "vad.speech_ended",
156
+ contextId: "turn-resume",
157
+ timestampMs: 100,
158
+ });
159
+ session.bus.push(Route.Main, {
160
+ kind: "stt.result",
161
+ contextId: "turn-resume",
162
+ timestampMs: 200,
163
+ text: "hello",
164
+ confidence: 0.99,
165
+ });
166
+ await waitForCondition(() => managed.turnMetricsTurns.has("turn-resume"));
167
+
168
+ for (const dispose of disposers.splice(0)) dispose();
169
+
170
+ const resumedEmitted: unknown[] = [];
171
+ const resumedTracker = new TurnMetricsTracker(session.bus, (message) => resumedEmitted.push(message), managed.turnMetricsTurns);
172
+ const resumedDisposers: Array<() => void> = [];
173
+ resumedTracker.wire(resumedDisposers);
174
+
175
+ session.bus.push(Route.Main, {
176
+ kind: "tts.playout_progress",
177
+ contextId: "turn-resume",
178
+ timestampMs: 500,
179
+ playedOutMs: 20,
180
+ complete: true,
181
+ });
182
+ await waitForCondition(() => resumedEmitted.length === 1);
183
+ expect(resumedEmitted[0]).toMatchObject({
184
+ type: "metrics",
185
+ turnId: "turn-resume",
186
+ speechEndMs: 100,
187
+ sttMs: 100,
188
+ });
189
+
190
+ for (const dispose of resumedDisposers.splice(0)) dispose();
191
+ });
192
+
193
+ it("update applies mutations through the store seam", async () => {
194
+ const store = new InMemorySessionStore();
195
+ const managed = createManagedSession("session-a");
196
+ await store.lease("session-a", async () => managed);
197
+
198
+ store.update("session-a", (session) => {
199
+ session.currentContextId = "turn-updated";
200
+ session.inputSequence.lastSequence = 7;
201
+ });
202
+
203
+ const loaded = await store.get("session-a");
204
+ expect(loaded?.currentContextId).toBe("turn-updated");
205
+ expect(loaded?.inputSequence.lastSequence).toBe(7);
206
+ });
207
+
208
+ it("lists and clears all sessions", async () => {
209
+ const store = new InMemorySessionStore();
210
+ await store.lease("session-a", async () => createManagedSession("session-a"));
211
+ await store.lease("session-b", async () => createManagedSession("session-b"));
212
+
213
+ expect((await store.listAll()).map((session) => session.id).sort()).toEqual(["session-a", "session-b"]);
214
+
215
+ await store.clear();
216
+ expect(await store.listAll()).toEqual([]);
217
+ });
218
+ });
219
+
220
+ describe("createVoiceWebSocketServer sessionStore seam", () => {
221
+ it("routes resume through an injected SessionStore", async () => {
222
+ const calls: Array<{ method: "lease" | "release" | "get"; sessionId: string; retainMs?: number }> = [];
223
+ const backing = new InMemorySessionStore();
224
+ const sessionStore: SessionStore = {
225
+ lease: async (sessionId, create) => {
226
+ calls.push({ method: "lease", sessionId });
227
+ return backing.lease(sessionId, create);
228
+ },
229
+ release: async (sessionId, retainMs) => {
230
+ calls.push({ method: "release", sessionId, retainMs });
231
+ await backing.release(sessionId, retainMs);
232
+ },
233
+ update: (sessionId, mutate) => backing.update(sessionId, mutate),
234
+ get: async (sessionId) => {
235
+ calls.push({ method: "get", sessionId });
236
+ return backing.get(sessionId);
237
+ },
238
+ listAll: () => backing.listAll(),
239
+ clear: () => backing.clear(),
240
+ };
241
+
242
+ let created = 0;
243
+ const session = new VoiceAgentSession({ plugins: {} });
244
+ const server = registerServer(await createVoiceWebSocketServer({
245
+ port: 0,
246
+ resumeWindowMs: 200,
247
+ sessionStore,
248
+ createSession: () => {
249
+ created += 1;
250
+ return session;
251
+ },
252
+ contextId: () => "turn-test",
253
+ }));
254
+ const address = server.address();
255
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
256
+
257
+ const sessionUrl = `ws://127.0.0.1:${String(address.port)}/ws?sessionId=fake-store-test`;
258
+ const [first, firstReady] = await openBrowserClientAndReadReady(sessionUrl);
259
+ expect(firstReady).toMatchObject({ sessionId: "fake-store-test", resumed: false });
260
+ first.close();
261
+ await new Promise((resolve) => setTimeout(resolve, 20));
262
+
263
+ const [, secondReady] = await openBrowserClientAndReadReady(sessionUrl);
264
+ expect(secondReady).toMatchObject({ sessionId: "fake-store-test", resumed: true });
265
+ expect(created).toBe(1);
266
+ expect(calls.filter((call) => call.method === "lease").map((call) => call.sessionId)).toEqual([
267
+ "fake-store-test",
268
+ "fake-store-test",
269
+ ]);
270
+ expect(calls.some((call) => call.method === "release" && call.sessionId === "fake-store-test" && call.retainMs === 200)).toBe(true);
271
+
272
+ await server.close();
273
+ });
274
+ });
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { VoiceAgentSession } from "@kuralle-syrinx/core";
4
+ import type { TurnTimestampState } from "./turn-metrics.js";
5
+
6
+ export interface AudioSequenceState {
7
+ lastSequence: number | null;
8
+ }
9
+
10
+ export interface ManagedSession {
11
+ readonly id: string;
12
+ readonly session: VoiceAgentSession;
13
+ currentContextId: string;
14
+ readonly contextSampleRates: Map<string, number>;
15
+ readonly inputSequence: AudioSequenceState;
16
+ readonly turnMetricsTurns: Map<string, TurnTimestampState>;
17
+ closeTimer: ReturnType<typeof setTimeout> | null;
18
+ connectionCount: number;
19
+ }
20
+
21
+ export interface ManagedSessionLease {
22
+ readonly managed: ManagedSession;
23
+ readonly resumed: boolean;
24
+ }
25
+
26
+ export interface SessionStore {
27
+ lease(sessionId: string, create: () => Promise<ManagedSession>): Promise<ManagedSessionLease>;
28
+ release(sessionId: string, retainMs: number): Promise<void>;
29
+ update(sessionId: string, mutate: (managed: ManagedSession) => void): void;
30
+ get(sessionId: string): Promise<ManagedSession | null>;
31
+ listAll(): Promise<readonly ManagedSession[]>;
32
+ clear(): Promise<void>;
33
+ }
34
+
35
+ interface PendingLease {
36
+ readonly promise: Promise<ManagedSession>;
37
+ waiters: number;
38
+ }
39
+
40
+ export class InMemorySessionStore implements SessionStore {
41
+ private readonly sessions = new Map<string, ManagedSession>();
42
+ private readonly pendingLeases = new Map<string, PendingLease>();
43
+
44
+ async lease(sessionId: string, create: () => Promise<ManagedSession>): Promise<ManagedSessionLease> {
45
+ const existing = this.sessions.get(sessionId);
46
+ if (existing) {
47
+ return this.attachConnection(existing, true);
48
+ }
49
+
50
+ let pending = this.pendingLeases.get(sessionId);
51
+ if (!pending) {
52
+ const promise = create()
53
+ .then((managed) => {
54
+ this.sessions.set(sessionId, managed);
55
+ return managed;
56
+ })
57
+ .finally(() => {
58
+ this.pendingLeases.delete(sessionId);
59
+ });
60
+ pending = { promise, waiters: 0 };
61
+ this.pendingLeases.set(sessionId, pending);
62
+ }
63
+
64
+ const waiterIndex = pending.waiters;
65
+ pending.waiters += 1;
66
+ const managed = await pending.promise;
67
+ if (waiterIndex === 0) {
68
+ return { managed, resumed: false };
69
+ }
70
+ return this.attachConnection(managed, true);
71
+ }
72
+
73
+ async release(sessionId: string, retainMs: number): Promise<void> {
74
+ const managed = this.sessions.get(sessionId);
75
+ if (!managed) return;
76
+
77
+ if (retainMs <= 0) {
78
+ if (managed.closeTimer) {
79
+ clearTimeout(managed.closeTimer);
80
+ managed.closeTimer = null;
81
+ }
82
+ if (managed.connectionCount > 0) return;
83
+ this.sessions.delete(sessionId);
84
+ await managed.session.close().catch(() => undefined);
85
+ return;
86
+ }
87
+
88
+ if (managed.connectionCount > 0 || managed.closeTimer) return;
89
+ managed.closeTimer = setTimeout(() => {
90
+ managed.closeTimer = null;
91
+ if (managed.connectionCount > 0) return;
92
+ this.sessions.delete(sessionId);
93
+ void managed.session.close().catch(() => undefined);
94
+ }, retainMs);
95
+ }
96
+
97
+ update(sessionId: string, mutate: (managed: ManagedSession) => void): void {
98
+ const managed = this.sessions.get(sessionId);
99
+ if (!managed) return;
100
+ mutate(managed);
101
+ }
102
+
103
+ async get(sessionId: string): Promise<ManagedSession | null> {
104
+ return this.sessions.get(sessionId) ?? null;
105
+ }
106
+
107
+ async listAll(): Promise<readonly ManagedSession[]> {
108
+ return [...this.sessions.values()];
109
+ }
110
+
111
+ async clear(): Promise<void> {
112
+ this.sessions.clear();
113
+ }
114
+
115
+ private attachConnection(managed: ManagedSession, resumed: boolean): ManagedSessionLease {
116
+ if (managed.closeTimer) {
117
+ clearTimeout(managed.closeTimer);
118
+ managed.closeTimer = null;
119
+ }
120
+ managed.connectionCount += 1;
121
+ return { managed, resumed };
122
+ }
123
+ }