openai-ws-opencode 0.1.6 → 0.1.8

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 (48) hide show
  1. package/README.md +3 -3
  2. package/SECURITY.md +1 -1
  3. package/bin/setup.js +17 -43
  4. package/dist/constants.d.ts +12 -1
  5. package/dist/constants.d.ts.map +1 -1
  6. package/dist/constants.js +12 -1
  7. package/dist/constants.js.map +1 -1
  8. package/dist/models/catalog.d.ts +39 -0
  9. package/dist/models/catalog.d.ts.map +1 -0
  10. package/dist/models/catalog.js +89 -0
  11. package/dist/models/catalog.js.map +1 -0
  12. package/dist/models/defaults.d.ts.map +1 -1
  13. package/dist/models/defaults.js +15 -43
  14. package/dist/models/defaults.js.map +1 -1
  15. package/dist/models/resolve.d.ts +3 -26
  16. package/dist/models/resolve.d.ts.map +1 -1
  17. package/dist/models/resolve.js +59 -40
  18. package/dist/models/resolve.js.map +1 -1
  19. package/dist/plugin.d.ts.map +1 -1
  20. package/dist/plugin.js +15 -7
  21. package/dist/plugin.js.map +1 -1
  22. package/dist/testing.d.ts +4 -2
  23. package/dist/testing.d.ts.map +1 -1
  24. package/dist/testing.js +4 -2
  25. package/dist/testing.js.map +1 -1
  26. package/dist/transport/body.d.ts +3 -2
  27. package/dist/transport/body.d.ts.map +1 -1
  28. package/dist/transport/body.js +28 -15
  29. package/dist/transport/body.js.map +1 -1
  30. package/dist/transport/bridge.d.ts.map +1 -1
  31. package/dist/transport/bridge.js +75 -25
  32. package/dist/transport/bridge.js.map +1 -1
  33. package/dist/transport/bun-websocket.d.ts +21 -0
  34. package/dist/transport/bun-websocket.d.ts.map +1 -0
  35. package/dist/transport/bun-websocket.js +281 -0
  36. package/dist/transport/bun-websocket.js.map +1 -0
  37. package/dist/transport/config.d.ts +14 -0
  38. package/dist/transport/config.d.ts.map +1 -0
  39. package/dist/transport/config.js +14 -0
  40. package/dist/transport/config.js.map +1 -0
  41. package/dist/transport/headers.d.ts.map +1 -1
  42. package/dist/transport/headers.js +23 -6
  43. package/dist/transport/headers.js.map +1 -1
  44. package/dist/transport/pool.d.ts +20 -16
  45. package/dist/transport/pool.d.ts.map +1 -1
  46. package/dist/transport/pool.js +394 -93
  47. package/dist/transport/pool.js.map +1 -1
  48. package/package.json +1 -1
@@ -1,14 +1,13 @@
1
1
  import crypto from "node:crypto";
2
- import WebSocket from "ws";
3
- const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
4
- const MAX_RECONNECT_ATTEMPTS = 3;
5
- const RECONNECT_DELAY_MS = 100;
6
- const IDLE_EVICT_MS = 120_000;
7
- const STALE_REUSE_MS = 60_000;
8
- const HEARTBEAT_INTERVAL_MS = 30_000;
9
- const PONG_TIMEOUT_MS = 10_000;
10
- let WebSocketImpl = WebSocket;
2
+ import { OPENAI_MODEL_HEADER, OPENAI_WS_INSTALLATION_ID_ENV, RESPONSE_PROCESSED_ENV, X_CODEX_INSTALLATION_ID_HEADER, X_CODEX_TURN_STATE_HEADER, X_CODEX_WINDOW_ID_HEADER, X_MODELS_ETAG_HEADER, X_OPENAI_SUBAGENT_HEADER, X_REASONING_INCLUDED_HEADER, } from "../constants.js";
3
+ import { loadDefaultWebSocketConstructor } from "./bun-websocket.js";
4
+ import { transportConfig } from "./config.js";
5
+ let WebSocketImpl = loadDefaultWebSocketConstructor();
11
6
  export const connectionPool = [];
7
+ const acquisitionQueues = new Map();
8
+ const turnStateByContext = new Map();
9
+ const lastResponseIDByContext = new Map();
10
+ let nextConnectionID = 1;
12
11
  export const readyState = {
13
12
  CONNECTING: 0,
14
13
  OPEN: 1,
@@ -27,13 +26,53 @@ function off(ws, event, listener) {
27
26
  else
28
27
  ws.removeEventListener?.(event, listener);
29
28
  }
30
- function scopeKey(wsUrl, headers) {
29
+ function authScopeHash(headers) {
31
30
  const auth = Object.entries(headers)
32
31
  .filter(([key]) => ["authorization", "chatgpt-account-id", "originator", "openai-beta"].includes(key.toLowerCase()))
33
32
  .sort(([a], [b]) => a.localeCompare(b))
34
33
  .map(([key, value]) => `${key}:${value}`)
35
34
  .join("\n");
36
- return `${wsUrl}::${crypto.createHash("sha256").update(auth).digest("hex")}`;
35
+ return crypto.createHash("sha256").update(auth).digest("hex");
36
+ }
37
+ function scopeKey(wsUrl, headers) {
38
+ return `${wsUrl}::${authScopeHash(headers)}`;
39
+ }
40
+ function contextScopeKey(wsUrl, headers, context) {
41
+ return `${scopeKey(wsUrl, headers)}::session:${context.sessionID ?? ""}::agent:${context.agent ?? ""}`;
42
+ }
43
+ function headersForConnection(wsUrl, baseHeaders, context, key) {
44
+ const headers = { ...baseHeaders };
45
+ if (context.sessionID) {
46
+ headers.session_id = context.sessionID;
47
+ headers["session-id"] = context.sessionID;
48
+ headers.thread_id = context.sessionID;
49
+ headers["thread-id"] = context.sessionID;
50
+ headers["x-client-request-id"] = crypto.createHash("sha256").update(`${wsUrl}:${context.sessionID}`).digest("hex").slice(0, 32);
51
+ headers[X_CODEX_WINDOW_ID_HEADER] = context.sessionID;
52
+ }
53
+ if (context.agent && context.agent !== "primary")
54
+ headers[X_OPENAI_SUBAGENT_HEADER] = context.agent;
55
+ const installationID = process.env[OPENAI_WS_INSTALLATION_ID_ENV];
56
+ if (installationID)
57
+ headers[X_CODEX_INSTALLATION_ID_HEADER] = installationID;
58
+ const turnState = turnStateByContext.get(key);
59
+ if (turnState)
60
+ headers[X_CODEX_TURN_STATE_HEADER] = turnState;
61
+ return headers;
62
+ }
63
+ function headerValue(source, name) {
64
+ const headers = source?.headers;
65
+ if (!headers)
66
+ return undefined;
67
+ if (headers instanceof Headers)
68
+ return headers.get(name) ?? undefined;
69
+ if (typeof headers.get === "function") {
70
+ const value = headers.get(name);
71
+ return Array.isArray(value) ? String(value[0]) : value === undefined || value === null ? undefined : String(value);
72
+ }
73
+ const record = headers;
74
+ const value = record[name] ?? record[name.toLowerCase()];
75
+ return Array.isArray(value) ? String(value[0]) : value === undefined || value === null ? undefined : String(value);
37
76
  }
38
77
  function clearTimer(timer) {
39
78
  if (timer)
@@ -52,6 +91,19 @@ function clearHeartbeat(conn) {
52
91
  conn.heartbeatTimer = null;
53
92
  conn.pongTimer = null;
54
93
  }
94
+ function clearPendingTimer(pending) {
95
+ if (!pending)
96
+ return;
97
+ clearTimer(pending.idleTimer);
98
+ pending.idleTimer = null;
99
+ }
100
+ function finalizePending(pending) {
101
+ if (!pending)
102
+ return;
103
+ clearPendingTimer(pending);
104
+ pending.onFinalize?.();
105
+ pending.onFinalize = undefined;
106
+ }
55
107
  function detach(conn) {
56
108
  if (conn.detach) {
57
109
  conn.detach();
@@ -71,10 +123,40 @@ function remove(conn) {
71
123
  connectionPool.splice(index, 1);
72
124
  detach(conn);
73
125
  }
126
+ function retryDelay(attempt) {
127
+ const exponential = Math.min(transportConfig.reconnectMaxDelayMs, transportConfig.reconnectBaseDelayMs * 2 ** Math.max(0, attempt - 1));
128
+ return exponential;
129
+ }
130
+ function closeSocket(conn, code, reason) {
131
+ try {
132
+ conn.ws?.terminate?.();
133
+ }
134
+ catch { }
135
+ try {
136
+ conn.ws?.close(code, reason);
137
+ }
138
+ catch { }
139
+ }
140
+ function terminalRelease(conn) {
141
+ if (conn.turnState) {
142
+ finalizePending(conn.pending);
143
+ conn.pending = null;
144
+ conn.activeSessionID = undefined;
145
+ conn.activeAgent = undefined;
146
+ conn.busy = false;
147
+ removeAndDrain(conn);
148
+ closeSocket(conn, 1000, "turn complete");
149
+ return;
150
+ }
151
+ release(conn);
152
+ drainQueue(conn.scopeKey);
153
+ }
154
+ function removeAndDrain(conn) {
155
+ remove(conn);
156
+ drainQueue(conn.scopeKey);
157
+ }
74
158
  function scheduleHeartbeat(conn) {
75
159
  clearHeartbeat(conn);
76
- if (conn.busy || conn.pending)
77
- return;
78
160
  const ws = conn.ws;
79
161
  if (!ws || ws.readyState !== readyState.OPEN)
80
162
  return;
@@ -83,7 +165,7 @@ function scheduleHeartbeat(conn) {
83
165
  const generation = conn.generation;
84
166
  conn.heartbeatTimer = unrefTimer(setTimeout(() => {
85
167
  conn.heartbeatTimer = null;
86
- if (generation !== conn.generation || conn.busy || !conn.ws)
168
+ if (generation !== conn.generation || !conn.ws)
87
169
  return;
88
170
  if (conn.ws.readyState !== readyState.OPEN)
89
171
  return;
@@ -91,47 +173,42 @@ function scheduleHeartbeat(conn) {
91
173
  conn.ws.ping?.();
92
174
  }
93
175
  catch {
94
- remove(conn);
95
- try {
96
- conn.ws?.terminate?.();
97
- }
98
- catch { }
176
+ handleSocketLoss(conn);
99
177
  return;
100
178
  }
101
179
  conn.pongTimer = unrefTimer(setTimeout(() => {
102
180
  conn.pongTimer = null;
103
- if (generation !== conn.generation || conn.busy)
181
+ if (generation !== conn.generation)
104
182
  return;
105
- remove(conn);
106
- try {
107
- conn.ws?.terminate?.();
108
- }
109
- catch { }
110
- try {
111
- conn.ws?.close(1001, "pong timeout");
112
- }
113
- catch { }
114
- }, PONG_TIMEOUT_MS));
115
- }, HEARTBEAT_INTERVAL_MS));
183
+ conn.lastCloseCode = 1006;
184
+ conn.lastCloseReason = "pong timeout";
185
+ handleSocketLoss(conn);
186
+ }, transportConfig.pongTimeoutMs));
187
+ }, transportConfig.heartbeatIntervalMs));
116
188
  }
117
189
  function release(conn) {
118
190
  conn.lastSessionID = conn.activeSessionID;
119
191
  conn.lastAgent = conn.activeAgent;
192
+ finalizePending(conn.pending);
120
193
  conn.pending = null;
121
194
  conn.activeSessionID = undefined;
122
195
  conn.activeAgent = undefined;
123
196
  conn.busy = false;
124
197
  conn.reconnectAttempts = 0;
125
- conn.idleTimer = unrefTimer(setTimeout(() => {
126
- conn.idleTimer = null;
127
- if (!conn.busy) {
128
- remove(conn);
129
- try {
130
- conn.ws?.close(1000, "idle eviction");
198
+ clearTimer(conn.idleTimer);
199
+ conn.idleTimer = null;
200
+ if (!conn.warm) {
201
+ conn.idleTimer = unrefTimer(setTimeout(() => {
202
+ conn.idleTimer = null;
203
+ if (!conn.busy) {
204
+ removeAndDrain(conn);
205
+ try {
206
+ conn.ws?.close(1000, "idle eviction");
207
+ }
208
+ catch { }
131
209
  }
132
- catch { }
133
- }
134
- }, IDLE_EVICT_MS));
210
+ }, transportConfig.idleEvictMs));
211
+ }
135
212
  scheduleHeartbeat(conn);
136
213
  }
137
214
  function formatFailureMessage(conn, reason) {
@@ -139,13 +216,14 @@ function formatFailureMessage(conn, reason) {
139
216
  const closeReason = JSON.stringify(conn.lastCloseReason ?? "");
140
217
  const lastError = conn.lastErrorMessage ? JSON.stringify(conn.lastErrorMessage) : "none";
141
218
  return (`WebSocket closed before response completed; cannot retry because ${reason}; ` +
142
- `reconnectAttempts=${conn.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}; ` +
219
+ `reconnectAttempts=${conn.reconnectAttempts}/${transportConfig.maxReconnectAttempts}; ` +
143
220
  `closeCode=${code}; closeReason=${closeReason}; lastError=${lastError}`);
144
221
  }
145
- function fail(conn, error, closeSocket) {
222
+ function fail(conn, error, shouldCloseSocket) {
146
223
  const pending = conn.pending;
147
224
  if (pending && !pending.done) {
148
225
  pending.done = true;
226
+ finalizePending(pending);
149
227
  try {
150
228
  pending.controller.error(error);
151
229
  }
@@ -155,17 +233,44 @@ function fail(conn, error, closeSocket) {
155
233
  conn.busy = false;
156
234
  conn.activeSessionID = undefined;
157
235
  conn.activeAgent = undefined;
158
- if (closeSocket) {
159
- remove(conn);
160
- try {
161
- conn.ws?.terminate?.();
162
- }
163
- catch { }
164
- try {
165
- conn.ws?.close(1000, "aborted");
166
- }
167
- catch { }
236
+ if (shouldCloseSocket) {
237
+ removeAndDrain(conn);
238
+ closeSocket(conn, 1000, "aborted");
239
+ }
240
+ else {
241
+ drainQueue(conn.scopeKey);
242
+ }
243
+ }
244
+ function cacheResponseMetadata(pending, frame) {
245
+ const response = frame.response;
246
+ if (response && typeof response === "object") {
247
+ const value = response;
248
+ if (typeof value.id === "string")
249
+ pending.metadata.responseId = value.id;
168
250
  }
251
+ if (typeof frame.response_id === "string")
252
+ pending.metadata.responseId = frame.response_id;
253
+ }
254
+ function isTerminalEvent(eventType) {
255
+ return ["response.completed", "response.failed", "response.incomplete"].includes(eventType);
256
+ }
257
+ function schedulePendingIdleTimeout(conn, reason) {
258
+ const pending = conn.pending;
259
+ if (!pending || pending.done)
260
+ return;
261
+ clearPendingTimer(pending);
262
+ const generation = conn.generation;
263
+ pending.idleTimer = unrefTimer(setTimeout(() => {
264
+ if (generation !== conn.generation)
265
+ return;
266
+ const current = conn.pending;
267
+ if (!current || current !== pending || current.done)
268
+ return;
269
+ current.idleTimer = null;
270
+ conn.lastCloseCode = 1006;
271
+ conn.lastCloseReason = "response idle timeout";
272
+ fail(conn, new Error(formatFailureMessage(conn, `response idle timeout after ${reason}`)), true);
273
+ }, transportConfig.responseIdleTimeoutMs));
169
274
  }
170
275
  function parseFrames(data) {
171
276
  const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
@@ -185,11 +290,56 @@ function parseFrames(data) {
185
290
  }
186
291
  return frames;
187
292
  }
293
+ function numberFrom(value) {
294
+ if (typeof value === "number")
295
+ return value;
296
+ if (typeof value === "string" && value.trim()) {
297
+ const parsed = Number(value);
298
+ if (Number.isFinite(parsed))
299
+ return parsed;
300
+ }
301
+ return undefined;
302
+ }
303
+ function wrappedWebSocketError(frame) {
304
+ if (frame.type !== "error")
305
+ return undefined;
306
+ const error = frame.error && typeof frame.error === "object" ? frame.error : {};
307
+ const code = typeof error.code === "string" ? error.code : typeof frame.code === "string" ? frame.code : undefined;
308
+ const message = typeof error.message === "string" ? error.message : typeof frame.message === "string" ? frame.message : code;
309
+ if (code === "websocket_connection_limit_reached") {
310
+ return new Error(message ?? "Responses websocket connection limit reached (60 minutes). Create a new websocket connection to continue.");
311
+ }
312
+ const status = numberFrom(frame.status) ?? numberFrom(frame.status_code) ?? numberFrom(error.status) ?? numberFrom(error.status_code);
313
+ if (status !== undefined && status >= 200 && status < 300)
314
+ return undefined;
315
+ return new Error(`OpenAI WebSocket error${status !== undefined ? ` ${status}` : ""}${code ? ` ${code}` : ""}${message ? `: ${message}` : ""}`);
316
+ }
317
+ function sendResponseProcessed(conn, pending, eventType) {
318
+ if (eventType !== "response.completed")
319
+ return;
320
+ if (process.env[RESPONSE_PROCESSED_ENV] !== "1")
321
+ return;
322
+ if (pending.processedAckSent || !pending.metadata.responseId)
323
+ return;
324
+ try {
325
+ conn.ws?.send(JSON.stringify({ type: "response.processed", response_id: pending.metadata.responseId }));
326
+ pending.processedAckSent = true;
327
+ }
328
+ catch (error) {
329
+ conn.lastErrorMessage = error instanceof Error ? error.message : String(error);
330
+ }
331
+ }
188
332
  function enqueueSSE(conn, frame) {
189
333
  const pending = conn.pending;
190
334
  if (!pending || pending.done)
191
335
  return;
192
336
  const eventType = typeof frame.type === "string" ? frame.type : "message";
337
+ cacheResponseMetadata(pending, frame);
338
+ const mappedError = wrappedWebSocketError(frame);
339
+ if (mappedError) {
340
+ fail(conn, mappedError, true);
341
+ return;
342
+ }
193
343
  const encoded = new TextEncoder().encode(`event: ${eventType}\ndata: ${JSON.stringify(frame)}\n\n`);
194
344
  try {
195
345
  pending.controller.enqueue(encoded);
@@ -197,14 +347,21 @@ function enqueueSSE(conn, frame) {
197
347
  catch {
198
348
  return;
199
349
  }
200
- pending.forwarded = true;
201
- if (["response.completed", "response.failed", "response.incomplete", "error"].includes(eventType)) {
350
+ if (!isTerminalEvent(eventType))
351
+ schedulePendingIdleTimeout(conn, eventType);
352
+ if (isTerminalEvent(eventType)) {
353
+ finalizePending(pending);
202
354
  pending.done = true;
355
+ sendResponseProcessed(conn, pending, eventType);
203
356
  try {
204
357
  pending.controller.close();
205
358
  }
206
359
  catch { }
207
- release(conn);
360
+ if (pending.metadata.responseId) {
361
+ conn.lastResponseID = pending.metadata.responseId;
362
+ lastResponseIDByContext.set(conn.contextKey, pending.metadata.responseId);
363
+ }
364
+ terminalRelease(conn);
208
365
  }
209
366
  }
210
367
  export function sendPending(conn) {
@@ -215,7 +372,10 @@ export function sendPending(conn) {
215
372
  if (!ws || ws.readyState !== readyState.OPEN)
216
373
  return false;
217
374
  try {
218
- ws.send(JSON.stringify({ type: "response.create", ...pending.body }));
375
+ const body = { ...pending.body };
376
+ if (body.previous_response_id === undefined && conn.lastResponseID)
377
+ body.previous_response_id = conn.lastResponseID;
378
+ ws.send(JSON.stringify({ ...body, type: "response.create" }));
219
379
  }
220
380
  catch (error) {
221
381
  conn.lastErrorMessage = error instanceof Error ? error.message : String(error);
@@ -223,6 +383,7 @@ export function sendPending(conn) {
223
383
  return false;
224
384
  }
225
385
  pending.sent = true;
386
+ schedulePendingIdleTimeout(conn, "response.create");
226
387
  return true;
227
388
  }
228
389
  function normalizeCloseReason(reason) {
@@ -238,17 +399,17 @@ function normalizeCloseReason(reason) {
238
399
  }
239
400
  function connect(conn) {
240
401
  clearHeartbeat(conn);
241
- const ws = new WebSocketImpl(conn.wsUrl, { headers: conn.headers });
242
402
  const generation = ++conn.generation;
243
- conn.ws = ws;
244
403
  clearTimer(conn.retryTimer);
245
404
  conn.retryTimer = null;
246
405
  clearTimer(conn.connectTimer);
247
406
  const connectTimer = setTimeout(() => {
248
- if (generation !== conn.generation || ws.readyState === readyState.OPEN)
407
+ if (generation !== conn.generation || conn.ws?.readyState === readyState.OPEN)
249
408
  return;
250
- fail(conn, new Error("WebSocket connection timed out"), true);
251
- }, DEFAULT_CONNECT_TIMEOUT_MS);
409
+ conn.lastCloseCode = 1006;
410
+ conn.lastCloseReason = "connection timed out";
411
+ handleSocketLoss(conn);
412
+ }, transportConfig.connectTimeoutMs);
252
413
  conn.connectTimer = unrefTimer(connectTimer);
253
414
  const handleOpen = () => {
254
415
  if (generation !== conn.generation)
@@ -256,12 +417,29 @@ function connect(conn) {
256
417
  clearTimer(conn.connectTimer);
257
418
  conn.connectTimer = null;
258
419
  conn.lastActivityAt = Date.now();
420
+ scheduleHeartbeat(conn);
259
421
  sendPending(conn);
260
422
  };
261
- const handleMessage = (data) => {
423
+ const handleUpgrade = (response) => {
424
+ if (generation !== conn.generation)
425
+ return;
426
+ const turnState = headerValue(response, X_CODEX_TURN_STATE_HEADER);
427
+ if (turnState) {
428
+ conn.turnState = turnState;
429
+ turnStateByContext.set(conn.contextKey, turnState);
430
+ }
431
+ conn.serverReasoningIncluded = headerValue(response, X_REASONING_INCLUDED_HEADER) !== undefined;
432
+ conn.modelsEtag = headerValue(response, X_MODELS_ETAG_HEADER);
433
+ conn.serverModel = headerValue(response, OPENAI_MODEL_HEADER);
434
+ };
435
+ const handleMessage = (data, isBinary) => {
262
436
  if (generation !== conn.generation)
263
437
  return;
264
438
  conn.lastActivityAt = Date.now();
439
+ if (isBinary) {
440
+ fail(conn, new Error("unexpected binary websocket event"), true);
441
+ return;
442
+ }
265
443
  for (const frame of parseFrames(data))
266
444
  enqueueSSE(conn, frame);
267
445
  };
@@ -271,8 +449,7 @@ function connect(conn) {
271
449
  conn.lastActivityAt = Date.now();
272
450
  clearTimer(conn.pongTimer);
273
451
  conn.pongTimer = null;
274
- if (!conn.busy && !conn.pending)
275
- scheduleHeartbeat(conn);
452
+ scheduleHeartbeat(conn);
276
453
  };
277
454
  const handleError = (error) => {
278
455
  if (generation !== conn.generation)
@@ -291,18 +468,31 @@ function connect(conn) {
291
468
  conn.lastCloseReason = normalizedReason;
292
469
  handleSocketLoss(conn);
293
470
  };
471
+ const ws = new WebSocketImpl(conn.wsUrl, {
472
+ headers: conn.headers,
473
+ perMessageDeflate: true,
474
+ finishRequest(request) {
475
+ request.on?.("upgrade", handleUpgrade);
476
+ request.end?.();
477
+ },
478
+ });
479
+ conn.ws = ws;
294
480
  on(ws, "open", handleOpen);
481
+ on(ws, "upgrade", handleUpgrade);
295
482
  on(ws, "message", handleMessage);
296
483
  on(ws, "pong", handlePong);
297
484
  on(ws, "error", handleError);
298
485
  on(ws, "close", handleClose);
299
486
  conn.detach = () => {
300
487
  off(ws, "open", handleOpen);
488
+ off(ws, "upgrade", handleUpgrade);
301
489
  off(ws, "message", handleMessage);
302
490
  off(ws, "pong", handlePong);
303
491
  off(ws, "error", handleError);
304
492
  off(ws, "close", handleClose);
305
493
  };
494
+ if (ws.readyState === readyState.OPEN)
495
+ queueMicrotask(handleOpen);
306
496
  }
307
497
  function handleSocketLoss(conn) {
308
498
  clearTimer(conn.connectTimer);
@@ -310,17 +500,18 @@ function handleSocketLoss(conn) {
310
500
  clearHeartbeat(conn);
311
501
  const pending = conn.pending;
312
502
  if (!pending || pending.done) {
313
- remove(conn);
314
- try {
315
- conn.ws?.terminate?.();
316
- }
317
- catch { }
503
+ finalizePending(pending);
504
+ removeAndDrain(conn);
505
+ closeSocket(conn, 1000, "socket lost");
318
506
  return;
319
507
  }
320
- const canRetry = !pending.forwarded && conn.reconnectAttempts < MAX_RECONNECT_ATTEMPTS;
508
+ if (pending.sent) {
509
+ fail(conn, new Error(formatFailureMessage(conn, "response.create was already sent")), true);
510
+ return;
511
+ }
512
+ const canRetry = conn.reconnectAttempts < transportConfig.maxReconnectAttempts;
321
513
  if (canRetry) {
322
514
  conn.reconnectAttempts++;
323
- pending.sent = false;
324
515
  const priorWs = conn.ws;
325
516
  detach(conn);
326
517
  try {
@@ -334,27 +525,32 @@ function handleSocketLoss(conn) {
334
525
  conn.ws = null;
335
526
  conn.retryTimer = unrefTimer(setTimeout(() => {
336
527
  conn.retryTimer = null;
337
- if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.forwarded)
528
+ if (!connectionPool.includes(conn) || !conn.pending || conn.pending.done || conn.pending.sent)
338
529
  return;
339
530
  connect(conn);
340
- }, RECONNECT_DELAY_MS));
531
+ }, retryDelay(conn.reconnectAttempts)));
341
532
  return;
342
533
  }
343
- const reason = pending.forwarded
344
- ? "stream already forwarded data"
345
- : "retry limit reached before any frame was forwarded";
346
- fail(conn, new Error(formatFailureMessage(conn, reason)), true);
534
+ fail(conn, new Error(formatFailureMessage(conn, "retry limit reached before response.create was sent")), true);
347
535
  }
348
- function create(wsUrl, headers) {
536
+ function create(wsUrl, headers, context = {}, warm = false) {
537
+ const hash = authScopeHash(headers);
538
+ const key = contextScopeKey(wsUrl, headers, context);
349
539
  const conn = {
540
+ id: `ws-${nextConnectionID++}`,
350
541
  ws: null,
351
542
  wsUrl,
352
- headers,
353
- scopeKey: scopeKey(wsUrl, headers),
543
+ headers: headersForConnection(wsUrl, headers, context, key),
544
+ scopeKey: key,
545
+ scopeHash: hash,
546
+ contextKey: key,
354
547
  busy: false,
548
+ warm,
355
549
  pending: null,
550
+ lastResponseID: lastResponseIDByContext.get(key),
356
551
  generation: 0,
357
552
  reconnectAttempts: 0,
553
+ createdAt: Date.now(),
358
554
  lastActivityAt: Date.now(),
359
555
  idleTimer: null,
360
556
  connectTimer: null,
@@ -372,9 +568,11 @@ function isReusable(conn, key, context, now) {
372
568
  return false;
373
569
  if (conn.scopeKey !== key)
374
570
  return false;
375
- if (conn.ws?.readyState !== readyState.OPEN)
571
+ if (conn.ws?.readyState !== readyState.OPEN && conn.ws?.readyState !== readyState.CONNECTING)
376
572
  return false;
377
- if (now - conn.lastActivityAt > STALE_REUSE_MS)
573
+ if (now - conn.lastActivityAt > transportConfig.staleReuseMs)
574
+ return false;
575
+ if (now - conn.createdAt > transportConfig.connectionMaxAgeMs)
378
576
  return false;
379
577
  if (context.sessionID && conn.lastSessionID && conn.lastSessionID !== context.sessionID)
380
578
  return false;
@@ -382,13 +580,16 @@ function isReusable(conn, key, context, now) {
382
580
  return false;
383
581
  return true;
384
582
  }
385
- export function acquireConnection(wsUrl, headers, context) {
386
- const key = scopeKey(wsUrl, headers);
387
- const now = Date.now();
583
+ function activeCount(key) {
584
+ return connectionPool.filter((conn) => conn.scopeKey === key && (conn.busy || conn.ws?.readyState === readyState.CONNECTING)).length;
585
+ }
586
+ function cleanupStaleConnections(key, now) {
388
587
  for (const candidate of [...connectionPool]) {
389
588
  if (candidate.busy || candidate.scopeKey !== key)
390
589
  continue;
391
- if (candidate.ws?.readyState === readyState.OPEN && now - candidate.lastActivityAt > STALE_REUSE_MS) {
590
+ if (candidate.ws?.readyState === readyState.OPEN &&
591
+ (now - candidate.lastActivityAt > transportConfig.staleReuseMs ||
592
+ now - candidate.createdAt > transportConfig.connectionMaxAgeMs)) {
392
593
  remove(candidate);
393
594
  try {
394
595
  candidate.ws?.close(1000, "stale idle");
@@ -396,34 +597,134 @@ export function acquireConnection(wsUrl, headers, context) {
396
597
  catch { }
397
598
  }
398
599
  }
600
+ }
601
+ function reserveConnection(wsUrl, headers, context) {
602
+ const key = contextScopeKey(wsUrl, headers, context);
603
+ const now = Date.now();
604
+ cleanupStaleConnections(key, now);
399
605
  const reusable = connectionPool.find((conn) => isReusable(conn, key, context, now));
400
- const conn = reusable ?? create(wsUrl, headers);
401
- clearTimer(conn.idleTimer);
402
- conn.idleTimer = null;
403
- clearHeartbeat(conn);
606
+ if (reusable) {
607
+ clearTimer(reusable.idleTimer);
608
+ reusable.idleTimer = null;
609
+ clearHeartbeat(reusable);
610
+ reusable.busy = true;
611
+ reusable.warm = false;
612
+ return reusable;
613
+ }
614
+ if (activeCount(key) >= transportConfig.maxConnectionsPerScope)
615
+ return null;
616
+ const conn = create(wsUrl, headers, context);
404
617
  conn.busy = true;
405
618
  return conn;
406
619
  }
407
- export function closeConnections(predicate = () => true) {
620
+ function queueAcquire(wsUrl, headers, context, signal) {
621
+ const key = contextScopeKey(wsUrl, headers, context);
622
+ return new Promise((resolve, reject) => {
623
+ const entry = {
624
+ wsUrl,
625
+ headers,
626
+ context,
627
+ resolve,
628
+ reject,
629
+ signal,
630
+ onAbort: () => {
631
+ const queue = acquisitionQueues.get(key);
632
+ if (queue) {
633
+ const index = queue.indexOf(entry);
634
+ if (index >= 0)
635
+ queue.splice(index, 1);
636
+ if (queue.length === 0)
637
+ acquisitionQueues.delete(key);
638
+ }
639
+ reject(signal?.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError"));
640
+ },
641
+ };
642
+ if (signal?.aborted) {
643
+ entry.onAbort();
644
+ return;
645
+ }
646
+ signal?.addEventListener("abort", entry.onAbort, { once: true });
647
+ const queue = acquisitionQueues.get(key) ?? [];
648
+ queue.push(entry);
649
+ acquisitionQueues.set(key, queue);
650
+ });
651
+ }
652
+ function drainQueue(key) {
653
+ const queue = acquisitionQueues.get(key);
654
+ if (!queue?.length)
655
+ return;
656
+ while (queue.length) {
657
+ const entry = queue[0];
658
+ if (entry.signal?.aborted) {
659
+ queue.shift();
660
+ entry.onAbort();
661
+ continue;
662
+ }
663
+ const conn = reserveConnection(entry.wsUrl, entry.headers, entry.context);
664
+ if (!conn)
665
+ break;
666
+ queue.shift();
667
+ entry.signal?.removeEventListener("abort", entry.onAbort);
668
+ entry.resolve(conn);
669
+ }
670
+ if (queue.length === 0)
671
+ acquisitionQueues.delete(key);
672
+ }
673
+ export function acquireConnection(wsUrl, headers, context, signal) {
674
+ const conn = reserveConnection(wsUrl, headers, context);
675
+ if (conn)
676
+ return conn;
677
+ return queueAcquire(wsUrl, headers, context, signal);
678
+ }
679
+ export function ensureWarmConnection(wsUrl, headers) {
680
+ const key = contextScopeKey(wsUrl, headers, {});
681
+ const now = Date.now();
682
+ cleanupStaleConnections(key, now);
683
+ const existing = connectionPool.find((conn) => conn.scopeKey === key && !conn.busy && conn.ws?.readyState === readyState.OPEN);
684
+ if (existing) {
685
+ existing.warm = true;
686
+ clearTimer(existing.idleTimer);
687
+ existing.idleTimer = null;
688
+ scheduleHeartbeat(existing);
689
+ return existing;
690
+ }
691
+ if (activeCount(key) >= transportConfig.maxConnectionsPerScope)
692
+ return undefined;
693
+ return create(wsUrl, headers, {}, true);
694
+ }
695
+ export function closeConnections(predicate = () => true, message = "Session disposed") {
408
696
  for (const conn of [...connectionPool]) {
409
697
  if (!predicate(conn))
410
698
  continue;
411
- fail(conn, new Error("Session disposed"), true);
699
+ fail(conn, new Error(message), true);
412
700
  }
413
701
  }
414
702
  export function resetPoolForTesting() {
415
703
  for (const conn of [...connectionPool]) {
704
+ try {
705
+ conn.ws?.terminate?.();
706
+ }
707
+ catch { }
416
708
  try {
417
709
  conn.ws?.close(1000, "test reset");
418
710
  }
419
711
  catch { }
420
712
  remove(conn);
421
713
  }
714
+ for (const queue of acquisitionQueues.values()) {
715
+ for (const entry of queue) {
716
+ entry.signal?.removeEventListener("abort", entry.onAbort);
717
+ entry.reject(new Error("Pool reset"));
718
+ }
719
+ }
720
+ acquisitionQueues.clear();
721
+ turnStateByContext.clear();
722
+ lastResponseIDByContext.clear();
422
723
  }
423
724
  export function setWebSocketConstructorForTesting(ctor) {
424
725
  WebSocketImpl = ctor;
425
726
  }
426
727
  export function resetWebSocketConstructorForTesting() {
427
- WebSocketImpl = WebSocket;
728
+ WebSocketImpl = loadDefaultWebSocketConstructor();
428
729
  }
429
730
  //# sourceMappingURL=pool.js.map