@livekit/agents 1.0.27 → 1.0.30
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/dist/connection_pool.cjs +242 -0
- package/dist/connection_pool.cjs.map +1 -0
- package/dist/connection_pool.d.cts +123 -0
- package/dist/connection_pool.d.ts +123 -0
- package/dist/connection_pool.d.ts.map +1 -0
- package/dist/connection_pool.js +218 -0
- package/dist/connection_pool.js.map +1 -0
- package/dist/connection_pool.test.cjs +256 -0
- package/dist/connection_pool.test.cjs.map +1 -0
- package/dist/connection_pool.test.js +255 -0
- package/dist/connection_pool.test.js.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/inference/tts.cjs +172 -56
- package/dist/inference/tts.cjs.map +1 -1
- package/dist/inference/tts.d.cts +3 -0
- package/dist/inference/tts.d.ts +3 -0
- package/dist/inference/tts.d.ts.map +1 -1
- package/dist/inference/tts.js +173 -57
- package/dist/inference/tts.js.map +1 -1
- package/dist/utils.cjs +20 -0
- package/dist/utils.cjs.map +1 -1
- package/dist/utils.d.cts +7 -0
- package/dist/utils.d.ts +7 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +19 -0
- package/dist/utils.js.map +1 -1
- package/dist/voice/agent_activity.cjs +3 -1
- package/dist/voice/agent_activity.cjs.map +1 -1
- package/dist/voice/agent_activity.d.ts.map +1 -1
- package/dist/voice/agent_activity.js +3 -1
- package/dist/voice/agent_activity.js.map +1 -1
- package/dist/voice/agent_session.cjs +4 -1
- package/dist/voice/agent_session.cjs.map +1 -1
- package/dist/voice/agent_session.d.ts.map +1 -1
- package/dist/voice/agent_session.js +4 -1
- package/dist/voice/agent_session.js.map +1 -1
- package/dist/voice/avatar/datastream_io.cjs +1 -1
- package/dist/voice/avatar/datastream_io.cjs.map +1 -1
- package/dist/voice/avatar/datastream_io.js +1 -1
- package/dist/voice/avatar/datastream_io.js.map +1 -1
- package/dist/voice/background_audio.cjs +77 -37
- package/dist/voice/background_audio.cjs.map +1 -1
- package/dist/voice/background_audio.d.cts +10 -3
- package/dist/voice/background_audio.d.ts +10 -3
- package/dist/voice/background_audio.d.ts.map +1 -1
- package/dist/voice/background_audio.js +78 -37
- package/dist/voice/background_audio.js.map +1 -1
- package/dist/voice/index.cjs +1 -0
- package/dist/voice/index.cjs.map +1 -1
- package/dist/voice/index.d.cts +1 -0
- package/dist/voice/index.d.ts +1 -0
- package/dist/voice/index.d.ts.map +1 -1
- package/dist/voice/index.js +1 -0
- package/dist/voice/index.js.map +1 -1
- package/dist/voice/io.cjs +10 -1
- package/dist/voice/io.cjs.map +1 -1
- package/dist/voice/io.d.cts +18 -1
- package/dist/voice/io.d.ts +18 -1
- package/dist/voice/io.d.ts.map +1 -1
- package/dist/voice/io.js +10 -1
- package/dist/voice/io.js.map +1 -1
- package/dist/voice/recorder_io/recorder_io.cjs +1 -1
- package/dist/voice/recorder_io/recorder_io.cjs.map +1 -1
- package/dist/voice/recorder_io/recorder_io.js +1 -1
- package/dist/voice/recorder_io/recorder_io.js.map +1 -1
- package/dist/voice/room_io/_output.cjs +1 -1
- package/dist/voice/room_io/_output.cjs.map +1 -1
- package/dist/voice/room_io/_output.js +1 -1
- package/dist/voice/room_io/_output.js.map +1 -1
- package/dist/voice/transcription/synchronizer.cjs +1 -1
- package/dist/voice/transcription/synchronizer.cjs.map +1 -1
- package/dist/voice/transcription/synchronizer.js +1 -1
- package/dist/voice/transcription/synchronizer.js.map +1 -1
- package/dist/worker.cjs +4 -6
- package/dist/worker.cjs.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +4 -6
- package/dist/worker.js.map +1 -1
- package/package.json +3 -3
- package/src/connection_pool.test.ts +346 -0
- package/src/connection_pool.ts +307 -0
- package/src/index.ts +1 -0
- package/src/inference/tts.ts +206 -63
- package/src/utils.ts +25 -0
- package/src/voice/agent_activity.ts +7 -1
- package/src/voice/agent_session.ts +4 -1
- package/src/voice/avatar/datastream_io.ts +1 -1
- package/src/voice/background_audio.ts +95 -55
- package/src/voice/index.ts +1 -0
- package/src/voice/io.ts +24 -0
- package/src/voice/recorder_io/recorder_io.ts +1 -1
- package/src/voice/room_io/_output.ts +1 -1
- package/src/voice/transcription/synchronizer.ts +1 -1
- package/src/worker.ts +4 -7
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { ConnectionPool } from './connection_pool.js';
|
|
6
|
+
|
|
7
|
+
describe('ConnectionPool', () => {
|
|
8
|
+
const makeConnectCb = () => {
|
|
9
|
+
let n = 0;
|
|
10
|
+
return vi.fn(async (_timeout: number): Promise<string> => `conn_${++n}`);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('basic operations', () => {
|
|
14
|
+
it('should create and return a connection', async () => {
|
|
15
|
+
const connections: string[] = [];
|
|
16
|
+
const connectCb = vi.fn(async (_timeout: number): Promise<string> => {
|
|
17
|
+
const conn = `conn_${connections.length}`;
|
|
18
|
+
connections.push(conn);
|
|
19
|
+
return conn;
|
|
20
|
+
});
|
|
21
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
22
|
+
// Mock close
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const pool = new ConnectionPool<string>({
|
|
26
|
+
connectCb,
|
|
27
|
+
closeCb,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const conn = await pool.get();
|
|
31
|
+
expect(conn).toBe('conn_0');
|
|
32
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
33
|
+
|
|
34
|
+
pool.put(conn);
|
|
35
|
+
const conn2 = await pool.get();
|
|
36
|
+
expect(conn2).toBe('conn_0'); // Should reuse
|
|
37
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should create new connection when none available', async () => {
|
|
41
|
+
const connectCb = makeConnectCb();
|
|
42
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
43
|
+
// Mock close
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const pool = new ConnectionPool<string>({
|
|
47
|
+
connectCb,
|
|
48
|
+
closeCb,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const conn1 = await pool.get();
|
|
52
|
+
pool.put(conn1);
|
|
53
|
+
const conn2 = await pool.get();
|
|
54
|
+
expect(conn1).toBe(conn2); // Should reuse
|
|
55
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should remove connection from pool', async () => {
|
|
59
|
+
const connectCb = makeConnectCb();
|
|
60
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
61
|
+
// Mock close
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const pool = new ConnectionPool<string>({
|
|
65
|
+
connectCb,
|
|
66
|
+
closeCb,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const conn = await pool.get();
|
|
70
|
+
pool.put(conn);
|
|
71
|
+
pool.remove(conn);
|
|
72
|
+
|
|
73
|
+
const conn2 = await pool.get();
|
|
74
|
+
expect(conn2).not.toBe(conn); // Should create new connection
|
|
75
|
+
expect(connectCb).toHaveBeenCalledTimes(2);
|
|
76
|
+
expect(closeCb).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('maxSessionDuration', () => {
|
|
81
|
+
it('should expire connections after maxSessionDuration', async () => {
|
|
82
|
+
const connectCb = makeConnectCb();
|
|
83
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
84
|
+
// Mock close
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const pool = new ConnectionPool<string>({
|
|
88
|
+
connectCb,
|
|
89
|
+
closeCb,
|
|
90
|
+
maxSessionDuration: 100, // 100ms
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const conn1 = await pool.get();
|
|
94
|
+
pool.put(conn1);
|
|
95
|
+
|
|
96
|
+
// Wait for expiration
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
98
|
+
|
|
99
|
+
const conn2 = await pool.get();
|
|
100
|
+
expect(conn2).not.toBe(conn1); // Should create new connection
|
|
101
|
+
expect(connectCb).toHaveBeenCalledTimes(2);
|
|
102
|
+
expect(closeCb).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should refresh connection timestamp when markRefreshedOnGet is true', async () => {
|
|
106
|
+
const connectCb = makeConnectCb();
|
|
107
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
108
|
+
// Mock close
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const pool = new ConnectionPool<string>({
|
|
112
|
+
connectCb,
|
|
113
|
+
closeCb,
|
|
114
|
+
maxSessionDuration: 200, // 200ms
|
|
115
|
+
markRefreshedOnGet: true,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const conn1 = await pool.get();
|
|
119
|
+
pool.put(conn1);
|
|
120
|
+
|
|
121
|
+
// Wait 100ms (less than expiration)
|
|
122
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
123
|
+
|
|
124
|
+
// Get again - should refresh timestamp
|
|
125
|
+
const conn2 = await pool.get();
|
|
126
|
+
expect(conn2).toBe(conn1); // Should reuse
|
|
127
|
+
pool.put(conn2);
|
|
128
|
+
|
|
129
|
+
// Wait another 100ms (total 200ms, but refreshed at 100ms)
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
131
|
+
|
|
132
|
+
// Should still be valid
|
|
133
|
+
const conn3 = await pool.get();
|
|
134
|
+
expect(conn3).toBe(conn1); // Should still reuse
|
|
135
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('withConnection', () => {
|
|
140
|
+
it('should return connection to pool on success', async () => {
|
|
141
|
+
const connectCb = makeConnectCb();
|
|
142
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
143
|
+
// Mock close
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const pool = new ConnectionPool<string>({
|
|
147
|
+
connectCb,
|
|
148
|
+
closeCb,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
let capturedConn: string | undefined;
|
|
152
|
+
await pool.withConnection(async (conn) => {
|
|
153
|
+
capturedConn = conn;
|
|
154
|
+
return 'result';
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Connection should be returned to pool
|
|
158
|
+
const conn2 = await pool.get();
|
|
159
|
+
expect(conn2).toBe(capturedConn); // Should reuse
|
|
160
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should remove connection from pool on error', async () => {
|
|
164
|
+
const connectCb = makeConnectCb();
|
|
165
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
166
|
+
// Mock close
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const pool = new ConnectionPool<string>({
|
|
170
|
+
connectCb,
|
|
171
|
+
closeCb,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
let capturedConn: string | undefined;
|
|
175
|
+
try {
|
|
176
|
+
await pool.withConnection(async (conn) => {
|
|
177
|
+
capturedConn = conn;
|
|
178
|
+
throw new Error('test error');
|
|
179
|
+
});
|
|
180
|
+
} catch (e) {
|
|
181
|
+
// Expected
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Connection should be removed from pool
|
|
185
|
+
const conn2 = await pool.get();
|
|
186
|
+
expect(conn2).not.toBe(capturedConn); // Should create new connection
|
|
187
|
+
expect(connectCb).toHaveBeenCalledTimes(2);
|
|
188
|
+
expect(closeCb).toHaveBeenCalledTimes(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle abort signal', async () => {
|
|
192
|
+
const connectCb = makeConnectCb();
|
|
193
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
194
|
+
// Mock close
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const pool = new ConnectionPool<string>({
|
|
198
|
+
connectCb,
|
|
199
|
+
closeCb,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const abortController = new AbortController();
|
|
203
|
+
let capturedConn: string | undefined;
|
|
204
|
+
|
|
205
|
+
const promise = pool.withConnection(
|
|
206
|
+
async (conn) => {
|
|
207
|
+
capturedConn = conn;
|
|
208
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
209
|
+
return 'result';
|
|
210
|
+
},
|
|
211
|
+
{ signal: abortController.signal },
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Abort after a short delay
|
|
215
|
+
setTimeout(() => abortController.abort(), 10);
|
|
216
|
+
|
|
217
|
+
await expect(promise).rejects.toThrow();
|
|
218
|
+
|
|
219
|
+
// Connection should be removed from pool
|
|
220
|
+
const conn2 = await pool.get();
|
|
221
|
+
expect(conn2).not.toBe(capturedConn); // Should create new connection
|
|
222
|
+
expect(closeCb).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('prewarm', () => {
|
|
227
|
+
it('should create connection in background', async () => {
|
|
228
|
+
let n = 0;
|
|
229
|
+
const connectCb = vi.fn(async (_timeout: number): Promise<string> => {
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
231
|
+
return `conn_${++n}`;
|
|
232
|
+
});
|
|
233
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
234
|
+
// Mock close
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const pool = new ConnectionPool<string>({
|
|
238
|
+
connectCb,
|
|
239
|
+
closeCb,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
pool.prewarm();
|
|
243
|
+
|
|
244
|
+
// Wait for prewarm to complete
|
|
245
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
246
|
+
|
|
247
|
+
const conn = await pool.get();
|
|
248
|
+
expect(conn).toBeDefined();
|
|
249
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should not prewarm if connections already exist', async () => {
|
|
253
|
+
const connectCb = makeConnectCb();
|
|
254
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
255
|
+
// Mock close
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const pool = new ConnectionPool<string>({
|
|
259
|
+
connectCb,
|
|
260
|
+
closeCb,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Create a connection first
|
|
264
|
+
const conn1 = await pool.get();
|
|
265
|
+
pool.put(conn1);
|
|
266
|
+
|
|
267
|
+
pool.prewarm(); // Should not create new connection
|
|
268
|
+
|
|
269
|
+
const conn2 = await pool.get();
|
|
270
|
+
expect(conn2).toBe(conn1); // Should reuse existing
|
|
271
|
+
expect(connectCb).toHaveBeenCalledTimes(1);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('close', () => {
|
|
276
|
+
it('should close all connections', async () => {
|
|
277
|
+
const connectCb = makeConnectCb();
|
|
278
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
279
|
+
// Mock close
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const pool = new ConnectionPool<string>({
|
|
283
|
+
connectCb,
|
|
284
|
+
closeCb,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Create two distinct connections by checking out both before returning either.
|
|
288
|
+
const conn1 = await pool.get();
|
|
289
|
+
const conn2 = await pool.get();
|
|
290
|
+
pool.put(conn1);
|
|
291
|
+
pool.put(conn2);
|
|
292
|
+
|
|
293
|
+
await pool.close();
|
|
294
|
+
|
|
295
|
+
expect(closeCb).toHaveBeenCalledTimes(2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should invalidate all connections', async () => {
|
|
299
|
+
const connectCb = makeConnectCb();
|
|
300
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
301
|
+
// Mock close
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const pool = new ConnectionPool<string>({
|
|
305
|
+
connectCb,
|
|
306
|
+
closeCb,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Create two distinct connections by checking out both before returning either.
|
|
310
|
+
const conn1 = await pool.get();
|
|
311
|
+
const conn2 = await pool.get();
|
|
312
|
+
pool.put(conn1);
|
|
313
|
+
pool.put(conn2);
|
|
314
|
+
|
|
315
|
+
pool.invalidate();
|
|
316
|
+
await pool.close(); // Drain to close
|
|
317
|
+
|
|
318
|
+
expect(closeCb).toHaveBeenCalledTimes(2);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('concurrent access', () => {
|
|
323
|
+
it('should handle concurrent get requests', async () => {
|
|
324
|
+
const connectCb = vi.fn(async (_timeout: number): Promise<string> => {
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
326
|
+
return `conn_${Date.now()}_${Math.random()}`;
|
|
327
|
+
});
|
|
328
|
+
const closeCb = vi.fn(async (_conn: string) => {
|
|
329
|
+
// Mock close
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const pool = new ConnectionPool<string>({
|
|
333
|
+
connectCb,
|
|
334
|
+
closeCb,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const promises = Array.from({ length: 5 }, () => pool.get());
|
|
338
|
+
const connections = await Promise.all(promises);
|
|
339
|
+
|
|
340
|
+
// All should be different connections
|
|
341
|
+
const uniqueConnections = new Set(connections);
|
|
342
|
+
expect(uniqueConnections.size).toBe(5);
|
|
343
|
+
expect(connectCb).toHaveBeenCalledTimes(5);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
import { Mutex } from '@livekit/mutex';
|
|
5
|
+
import { waitForAbort } from './utils.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper class to manage persistent connections like websockets.
|
|
9
|
+
*/
|
|
10
|
+
export interface ConnectionPoolOptions<T> {
|
|
11
|
+
/**
|
|
12
|
+
* Maximum duration in milliseconds before forcing reconnection.
|
|
13
|
+
* If not set, connections will never expire based on duration.
|
|
14
|
+
*/
|
|
15
|
+
maxSessionDuration?: number;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* If true, the session will be marked as fresh when get() is called.
|
|
19
|
+
* Only used when maxSessionDuration is set.
|
|
20
|
+
*/
|
|
21
|
+
markRefreshedOnGet?: boolean;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Async callback to create new connections.
|
|
25
|
+
* @param timeout - Connection timeout in milliseconds
|
|
26
|
+
* @returns A new connection object
|
|
27
|
+
*/
|
|
28
|
+
connectCb: (timeout: number) => Promise<T>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Optional async callback to close connections.
|
|
32
|
+
* @param conn - The connection to close
|
|
33
|
+
*/
|
|
34
|
+
closeCb?: (conn: T) => Promise<void>;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default connection timeout in milliseconds.
|
|
38
|
+
* Defaults to 10000 (10 seconds).
|
|
39
|
+
*/
|
|
40
|
+
connectTimeout?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Connection pool for managing persistent WebSocket connections.
|
|
45
|
+
*
|
|
46
|
+
* Reuses connections efficiently and automatically refreshes them after max duration.
|
|
47
|
+
* Prevents creating too many connections in a single conversation.
|
|
48
|
+
*/
|
|
49
|
+
export class ConnectionPool<T> {
|
|
50
|
+
private readonly maxSessionDuration?: number;
|
|
51
|
+
private readonly markRefreshedOnGet: boolean;
|
|
52
|
+
private readonly connectCb: (timeout: number) => Promise<T>;
|
|
53
|
+
private readonly closeCb?: (conn: T) => Promise<void>;
|
|
54
|
+
private readonly connectTimeout: number;
|
|
55
|
+
|
|
56
|
+
// Track connections and their creation timestamps
|
|
57
|
+
private readonly connections: Map<T, number> = new Map();
|
|
58
|
+
// Available connections ready for reuse
|
|
59
|
+
private readonly available: Set<T> = new Set();
|
|
60
|
+
// Connections queued for closing
|
|
61
|
+
private readonly toClose: Set<T> = new Set();
|
|
62
|
+
// Mutex for connection operations
|
|
63
|
+
private readonly connectLock = new Mutex();
|
|
64
|
+
// Prewarm task reference
|
|
65
|
+
private prewarmController?: AbortController;
|
|
66
|
+
|
|
67
|
+
constructor(options: ConnectionPoolOptions<T>) {
|
|
68
|
+
this.maxSessionDuration = options.maxSessionDuration;
|
|
69
|
+
this.markRefreshedOnGet = options.markRefreshedOnGet ?? false;
|
|
70
|
+
this.connectCb = options.connectCb;
|
|
71
|
+
this.closeCb = options.closeCb;
|
|
72
|
+
this.connectTimeout = options.connectTimeout ?? 10_000;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a new connection.
|
|
77
|
+
*
|
|
78
|
+
* @param timeout - Connection timeout in milliseconds
|
|
79
|
+
* @returns The new connection object
|
|
80
|
+
* @throws If connectCb is not provided or connection fails
|
|
81
|
+
*/
|
|
82
|
+
private async _connect(timeout: number): Promise<T> {
|
|
83
|
+
const connection = await this.connectCb(timeout);
|
|
84
|
+
this.connections.set(connection, Date.now());
|
|
85
|
+
return connection;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Drain and close all connections queued for closing.
|
|
90
|
+
*/
|
|
91
|
+
private async _drainToClose(): Promise<void> {
|
|
92
|
+
const connectionsToClose = Array.from(this.toClose);
|
|
93
|
+
this.toClose.clear();
|
|
94
|
+
|
|
95
|
+
for (const conn of connectionsToClose) {
|
|
96
|
+
await this._maybeCloseConnection(conn);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Close a connection if closeCb is provided.
|
|
102
|
+
*
|
|
103
|
+
* @param conn - The connection to close
|
|
104
|
+
*/
|
|
105
|
+
private async _maybeCloseConnection(conn: T): Promise<void> {
|
|
106
|
+
if (this.closeCb) {
|
|
107
|
+
await this.closeCb(conn);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private _abortError(): Error {
|
|
112
|
+
const error = new Error('The operation was aborted.');
|
|
113
|
+
error.name = 'AbortError';
|
|
114
|
+
return error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get an available connection or create a new one if needed.
|
|
119
|
+
*
|
|
120
|
+
* @param timeout - Connection timeout in milliseconds
|
|
121
|
+
* @returns An active connection object
|
|
122
|
+
*/
|
|
123
|
+
async get(timeout?: number): Promise<T> {
|
|
124
|
+
const unlock = await this.connectLock.lock();
|
|
125
|
+
try {
|
|
126
|
+
await this._drainToClose();
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
|
|
129
|
+
// Try to reuse an available connection that hasn't expired
|
|
130
|
+
while (this.available.size > 0) {
|
|
131
|
+
const conn = this.available.values().next().value as T;
|
|
132
|
+
this.available.delete(conn);
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
this.maxSessionDuration === undefined ||
|
|
136
|
+
now - (this.connections.get(conn) ?? 0) <= this.maxSessionDuration
|
|
137
|
+
) {
|
|
138
|
+
if (this.markRefreshedOnGet) {
|
|
139
|
+
this.connections.set(conn, now);
|
|
140
|
+
}
|
|
141
|
+
return conn;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Connection expired; close it now so callers observing get() see it closed promptly.
|
|
145
|
+
// (Also makes tests deterministic: closeCb should have been called by the time get() resolves.)
|
|
146
|
+
if (this.connections.has(conn)) {
|
|
147
|
+
this.connections.delete(conn);
|
|
148
|
+
}
|
|
149
|
+
this.toClose.delete(conn);
|
|
150
|
+
await this._maybeCloseConnection(conn);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return await this._connect(timeout ?? this.connectTimeout);
|
|
154
|
+
} finally {
|
|
155
|
+
unlock();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Mark a connection as available for reuse.
|
|
161
|
+
*
|
|
162
|
+
* If connection has been removed, it will not be added to the pool.
|
|
163
|
+
*
|
|
164
|
+
* @param conn - The connection to make available
|
|
165
|
+
*/
|
|
166
|
+
put(conn: T): void {
|
|
167
|
+
if (this.connections.has(conn)) {
|
|
168
|
+
this.available.add(conn);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Remove a specific connection from the pool.
|
|
175
|
+
*
|
|
176
|
+
* Marks the connection to be closed during the next drain cycle.
|
|
177
|
+
*
|
|
178
|
+
* @param conn - The connection to remove
|
|
179
|
+
*/
|
|
180
|
+
remove(conn: T): void {
|
|
181
|
+
this.available.delete(conn);
|
|
182
|
+
if (this.connections.has(conn)) {
|
|
183
|
+
this.toClose.add(conn);
|
|
184
|
+
this.connections.delete(conn);
|
|
185
|
+
// Important for Node websockets: if we just "mark to close later" but remove listeners,
|
|
186
|
+
// the ws library can buffer incoming frames in memory. Close ASAP in background.
|
|
187
|
+
void (async () => {
|
|
188
|
+
const unlock = await this.connectLock.lock();
|
|
189
|
+
try {
|
|
190
|
+
if (!this.toClose.has(conn)) return;
|
|
191
|
+
await this._maybeCloseConnection(conn);
|
|
192
|
+
this.toClose.delete(conn);
|
|
193
|
+
} finally {
|
|
194
|
+
unlock();
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Clear all existing connections.
|
|
202
|
+
*
|
|
203
|
+
* Marks all current connections to be closed during the next drain cycle.
|
|
204
|
+
*/
|
|
205
|
+
invalidate(): void {
|
|
206
|
+
for (const conn of this.connections.keys()) {
|
|
207
|
+
this.toClose.add(conn);
|
|
208
|
+
}
|
|
209
|
+
this.connections.clear();
|
|
210
|
+
this.available.clear();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Initiate prewarming of the connection pool without blocking.
|
|
215
|
+
*
|
|
216
|
+
* This method starts a background task that creates a new connection if none exist.
|
|
217
|
+
* The task automatically cleans itself up when the connection pool is closed.
|
|
218
|
+
*/
|
|
219
|
+
prewarm(): void {
|
|
220
|
+
if (this.prewarmController || this.connections.size > 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const controller = new AbortController();
|
|
225
|
+
this.prewarmController = controller;
|
|
226
|
+
|
|
227
|
+
// Start prewarm in background
|
|
228
|
+
this._prewarmImpl(controller.signal).catch(() => {
|
|
229
|
+
// Ignore errors during prewarm
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async _prewarmImpl(signal: AbortSignal): Promise<void> {
|
|
234
|
+
const unlock = await this.connectLock.lock();
|
|
235
|
+
try {
|
|
236
|
+
if (signal.aborted) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (this.connections.size === 0) {
|
|
241
|
+
const conn = await this._connect(this.connectTimeout);
|
|
242
|
+
this.available.add(conn);
|
|
243
|
+
}
|
|
244
|
+
} finally {
|
|
245
|
+
unlock();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Get a connection from the pool and automatically return it when done.
|
|
251
|
+
* Handles abort signals and ensures proper cleanup.
|
|
252
|
+
*
|
|
253
|
+
* @param fn - Function to execute with the connection
|
|
254
|
+
* @param options - Options including timeout and abort signal
|
|
255
|
+
* @returns The result of the function
|
|
256
|
+
*/
|
|
257
|
+
async withConnection<R>(
|
|
258
|
+
fn: (conn: T) => Promise<R>,
|
|
259
|
+
options?: {
|
|
260
|
+
timeout?: number;
|
|
261
|
+
signal?: AbortSignal;
|
|
262
|
+
},
|
|
263
|
+
): Promise<R> {
|
|
264
|
+
// Check if already aborted before getting connection
|
|
265
|
+
if (options?.signal?.aborted) {
|
|
266
|
+
throw this._abortError();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const conn = await this.get(options?.timeout);
|
|
270
|
+
|
|
271
|
+
const signal = options?.signal;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const fnPromise = fn(conn);
|
|
275
|
+
const result = signal
|
|
276
|
+
? await Promise.race([
|
|
277
|
+
fnPromise.then((value) => ({ type: 'result' as const, value })),
|
|
278
|
+
waitForAbort(signal).then(() => ({ type: 'abort' as const })),
|
|
279
|
+
]).then((r) => {
|
|
280
|
+
if (r.type === 'abort') throw this._abortError();
|
|
281
|
+
return r.value;
|
|
282
|
+
})
|
|
283
|
+
: await fnPromise;
|
|
284
|
+
// Return connection to pool on success
|
|
285
|
+
this.put(conn);
|
|
286
|
+
return result;
|
|
287
|
+
} catch (error) {
|
|
288
|
+
// Remove connection from pool on error (don't return it)
|
|
289
|
+
this.remove(conn);
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Close all connections, draining any pending connection closures.
|
|
296
|
+
*/
|
|
297
|
+
async close(): Promise<void> {
|
|
298
|
+
// Cancel prewarm task if running
|
|
299
|
+
if (this.prewarmController) {
|
|
300
|
+
this.prewarmController.abort();
|
|
301
|
+
this.prewarmController = undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.invalidate();
|
|
305
|
+
await this._drainToClose();
|
|
306
|
+
}
|
|
307
|
+
}
|
package/src/index.ts
CHANGED