@codespar/sdk 0.11.0 → 0.12.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 (73) hide show
  1. package/README.md +69 -0
  2. package/dist/__tests__/abort.test.d.ts +2 -0
  3. package/dist/__tests__/abort.test.d.ts.map +1 -0
  4. package/dist/__tests__/abort.test.js +70 -0
  5. package/dist/__tests__/abort.test.js.map +1 -0
  6. package/dist/__tests__/api-client.test.d.ts +7 -0
  7. package/dist/__tests__/api-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/api-client.test.js +286 -0
  9. package/dist/__tests__/api-client.test.js.map +1 -0
  10. package/dist/__tests__/close-timeout.test.d.ts +2 -0
  11. package/dist/__tests__/close-timeout.test.d.ts.map +1 -0
  12. package/dist/__tests__/close-timeout.test.js +57 -0
  13. package/dist/__tests__/close-timeout.test.js.map +1 -0
  14. package/dist/__tests__/codespar.test.js +11 -0
  15. package/dist/__tests__/codespar.test.js.map +1 -1
  16. package/dist/__tests__/connections-timeout.test.d.ts +2 -0
  17. package/dist/__tests__/connections-timeout.test.d.ts.map +1 -0
  18. package/dist/__tests__/connections-timeout.test.js +58 -0
  19. package/dist/__tests__/connections-timeout.test.js.map +1 -0
  20. package/dist/__tests__/fetch-timeout.test.d.ts +2 -0
  21. package/dist/__tests__/fetch-timeout.test.d.ts.map +1 -0
  22. package/dist/__tests__/fetch-timeout.test.js +137 -0
  23. package/dist/__tests__/fetch-timeout.test.js.map +1 -0
  24. package/dist/__tests__/openapi-snapshot.test.d.ts +13 -0
  25. package/dist/__tests__/openapi-snapshot.test.d.ts.map +1 -0
  26. package/dist/__tests__/openapi-snapshot.test.js +79 -0
  27. package/dist/__tests__/openapi-snapshot.test.js.map +1 -0
  28. package/dist/__tests__/shop.test.js +1 -1
  29. package/dist/__tests__/shop.test.js.map +1 -1
  30. package/dist/__tests__/stream-timeout.test.d.ts +2 -0
  31. package/dist/__tests__/stream-timeout.test.d.ts.map +1 -0
  32. package/dist/__tests__/stream-timeout.test.js +335 -0
  33. package/dist/__tests__/stream-timeout.test.js.map +1 -0
  34. package/dist/api/client.d.ts +56 -0
  35. package/dist/api/client.d.ts.map +1 -0
  36. package/dist/api/client.js +221 -0
  37. package/dist/api/client.js.map +1 -0
  38. package/dist/api/types.d.ts +110 -0
  39. package/dist/api/types.d.ts.map +1 -0
  40. package/dist/api/types.js +10 -0
  41. package/dist/api/types.js.map +1 -0
  42. package/dist/errors.d.ts +5 -0
  43. package/dist/errors.d.ts.map +1 -1
  44. package/dist/errors.js +9 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/generated/openapi.d.ts +22004 -0
  47. package/dist/generated/openapi.d.ts.map +1 -0
  48. package/dist/generated/openapi.js +2 -0
  49. package/dist/generated/openapi.js.map +1 -0
  50. package/dist/generated/operations.d.ts +1084 -0
  51. package/dist/generated/operations.d.ts.map +1 -0
  52. package/dist/generated/operations.js +225 -0
  53. package/dist/generated/operations.js.map +1 -0
  54. package/dist/index.d.ts +24 -4
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +32 -3
  57. package/dist/index.js.map +1 -1
  58. package/dist/internal/abort.d.ts +18 -0
  59. package/dist/internal/abort.d.ts.map +1 -0
  60. package/dist/internal/abort.js +42 -0
  61. package/dist/internal/abort.js.map +1 -0
  62. package/dist/internal/fetch.d.ts +23 -0
  63. package/dist/internal/fetch.d.ts.map +1 -0
  64. package/dist/internal/fetch.js +57 -0
  65. package/dist/internal/fetch.js.map +1 -0
  66. package/dist/session.d.ts +3 -2
  67. package/dist/session.d.ts.map +1 -1
  68. package/dist/session.js +339 -161
  69. package/dist/session.js.map +1 -1
  70. package/dist/types.d.ts +3 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/package.json +7 -3
package/dist/session.js CHANGED
@@ -1,23 +1,35 @@
1
1
  /**
2
2
  * Session implementation for the CodeSpar managed runtime.
3
3
  */
4
- import { networkErrorToApiError, throwFromResponse } from "./errors.js";
5
- // Wraps `fetch` so network rejections become CodesparApiError
6
- // (status: 0) at the very edge of the SDK, rather than bubbling
7
- // raw TypeError / DOMException up to the caller. Every transport
8
- // site in this file goes through here.
9
- async function safeFetch(input, init, what) {
4
+ import { CodesparApiError, networkErrorToApiError, throwFromResponse, TimeoutError } from "./errors.js";
5
+ import { fetchWithTimeout, validateTimeout } from "./internal/fetch.js";
6
+ import { mergeSignals } from "./internal/abort.js";
7
+ // Every unary transport site funnels through here. The `consume`
8
+ // callback reads the body inside the timeout/abort budget (via
9
+ // fetchWithTimeout), so a backend that stalls the body still hits the
10
+ // deadline. Network rejections become CodesparApiError (status: 0);
11
+ // typed timeouts and caller aborts propagate verbatim; structured API
12
+ // errors raised inside `consume` (throwFromResponse) pass through
13
+ // unwrapped. Streaming sites use raw fetch (SSE idle-timeout lives at
14
+ // the stream layer), not this helper.
15
+ async function safeFetch(input, init, what, timeoutOpts, consume) {
10
16
  try {
11
- return await fetch(input, init);
17
+ return await fetchWithTimeout(input, init, timeoutOpts, consume);
12
18
  }
13
19
  catch (cause) {
20
+ if (cause instanceof TimeoutError)
21
+ throw cause;
22
+ if (cause instanceof CodesparApiError)
23
+ throw cause;
24
+ if (timeoutOpts.signal?.aborted)
25
+ throw cause;
14
26
  throw networkErrorToApiError(cause, what);
15
27
  }
16
28
  }
17
29
  // The concrete session object satisfies the Session interface and carries extra
18
30
  // internal methods (tools, findTools) that the free functions in tools.ts access
19
31
  // via duck-typing. They are intentionally not declared on the Session interface.
20
- export async function createSession(userId, config, deps) {
32
+ export async function createSession(userId, config, deps, createOpts) {
21
33
  const { baseUrl, apiKey } = deps;
22
34
  const projectId = config.projectId ?? deps.projectId;
23
35
  const headers = {
@@ -31,6 +43,12 @@ export async function createSession(userId, config, deps) {
31
43
  metadata: config.metadata,
32
44
  projectId: config.projectId ?? deps.projectId,
33
45
  };
46
+ // Resolve per-call timeout/abort against the client default. Kept local
47
+ // so every method funnels through one place if CallOptions grows.
48
+ const callOpts = (o) => ({
49
+ timeout: o?.timeout ?? deps.timeout,
50
+ signal: o?.signal,
51
+ });
34
52
  // Conditional spread keeps the wire body byte-identical to the
35
53
  // pre-PRD shape when the caller omits mocks (R18 wire-neutrality).
36
54
  // The field is forwarded verbatim — no canonical-name rewriting on
@@ -42,15 +60,15 @@ export async function createSession(userId, config, deps) {
42
60
  user_id: userId,
43
61
  ...(config.mocks !== undefined ? { mocks: config.mocks } : {}),
44
62
  };
45
- const res = await safeFetch(`${baseUrl}/v1/sessions`, {
63
+ const data = await safeFetch(`${baseUrl}/v1/sessions`, {
46
64
  method: "POST",
47
65
  headers,
48
66
  body: JSON.stringify(wireBody),
49
- }, "createSession");
50
- if (!res.ok) {
51
- await throwFromResponse(res, "createSession");
52
- }
53
- const data = (await res.json());
67
+ }, "createSession", callOpts(createOpts), async (res) => {
68
+ if (!res.ok)
69
+ await throwFromResponse(res, "createSession");
70
+ return (await res.json());
71
+ });
54
72
  let cachedTools = null;
55
73
  let cachedConnections = null;
56
74
  const session = {
@@ -80,29 +98,29 @@ export async function createSession(userId, config, deps) {
80
98
  return all.filter((t) => t.name.toLowerCase().includes(q) ||
81
99
  t.description.toLowerCase().includes(q));
82
100
  },
83
- async execute(toolName, params) {
101
+ async execute(toolName, params, opts) {
84
102
  const start = Date.now();
85
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/execute`, {
103
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/execute`, {
86
104
  method: "POST",
87
105
  headers,
88
106
  body: JSON.stringify({ tool: toolName, input: params }),
89
- }, "execute");
90
- if (!r.ok) {
91
- const body = await r.text();
92
- return {
93
- success: false,
94
- data: null,
95
- error: `${r.status}: ${body}`,
96
- duration: Date.now() - start,
97
- server: "",
98
- tool: toolName,
99
- };
100
- }
101
- const result = (await r.json());
102
- return result;
107
+ }, "execute", callOpts(opts), async (r) => {
108
+ if (!r.ok) {
109
+ const body = await r.text();
110
+ return {
111
+ success: false,
112
+ data: null,
113
+ error: `${r.status}: ${body}`,
114
+ duration: Date.now() - start,
115
+ server: "",
116
+ tool: toolName,
117
+ };
118
+ }
119
+ return (await r.json());
120
+ });
103
121
  },
104
- async proxyExecute(request) {
105
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/proxy_execute`, {
122
+ async proxyExecute(request, opts) {
123
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/proxy_execute`, {
106
124
  method: "POST",
107
125
  headers,
108
126
  body: JSON.stringify({
@@ -113,45 +131,72 @@ export async function createSession(userId, config, deps) {
113
131
  params: request.params,
114
132
  headers: request.headers,
115
133
  }),
116
- }, "proxyExecute");
117
- if (!r.ok) {
118
- await throwFromResponse(r, "proxyExecute");
119
- }
120
- return (await r.json());
134
+ }, "proxyExecute", callOpts(opts), async (r) => {
135
+ if (!r.ok)
136
+ await throwFromResponse(r, "proxyExecute");
137
+ return (await r.json());
138
+ });
121
139
  },
122
- async send(message) {
123
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
140
+ async send(message, opts) {
141
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
124
142
  method: "POST",
125
143
  headers: { ...headers, Accept: "application/json" },
126
144
  body: JSON.stringify({ message }),
127
- }, "send");
128
- if (!r.ok) {
129
- await throwFromResponse(r, "send");
130
- }
131
- return (await r.json());
145
+ }, "send", callOpts(opts), async (r) => {
146
+ if (!r.ok)
147
+ await throwFromResponse(r, "send");
148
+ return (await r.json());
149
+ });
132
150
  },
133
- async *sendStream(message) {
134
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
135
- method: "POST",
136
- headers: { ...headers, Accept: "text/event-stream" },
137
- body: JSON.stringify({ message }),
138
- }, "sendStream");
139
- if (!r.ok || !r.body) {
140
- await throwFromResponse(r, "sendStream");
151
+ async *sendStream(message, opts) {
152
+ const ms = opts?.timeout ?? deps.timeout;
153
+ validateTimeout(ms);
154
+ const idleAc = new AbortController();
155
+ const merged = mergeSignals([idleAc.signal, opts?.signal]);
156
+ const idle = makeIdle(idleAc, ms);
157
+ try {
158
+ // Arm the timeout before the fetch so the connect/headers phase
159
+ // is covered too — not just post-body idle. Otherwise a backend
160
+ // that accepts the socket but never sends headers hangs forever.
161
+ idle.reset();
162
+ const r = await fetch(`${baseUrl}/v1/sessions/${data.id}/send`, {
163
+ method: "POST",
164
+ headers: { ...headers, Accept: "text/event-stream" },
165
+ body: JSON.stringify({ message }),
166
+ signal: merged.signal,
167
+ });
168
+ if (!r.ok || !r.body) {
169
+ await throwFromResponse(r, "sendStream");
170
+ }
171
+ // r.body is non-null here — throwFromResponse always throws.
172
+ yield* parseSseStream(r.body, idle, merged.signal);
173
+ }
174
+ catch (err) {
175
+ if (opts?.signal?.aborted)
176
+ throw opts.signal.reason;
177
+ if (idleAc.signal.aborted)
178
+ throw new TimeoutError(ms);
179
+ // Structured API errors propagate; only raw transport rejections
180
+ // collapse to CodesparApiError(status: 0), matching safeFetch.
181
+ if (err instanceof CodesparApiError)
182
+ throw err;
183
+ throw networkErrorToApiError(err, "sendStream");
184
+ }
185
+ finally {
186
+ idle.pause();
187
+ merged.cleanup();
141
188
  }
142
- // Once we're past the !r.ok branch, r.body is guaranteed non-null.
143
- yield* parseSseStream(r.body);
144
189
  },
145
190
  /**
146
191
  * codespar_discover wrapper. Same wire as
147
192
  * `execute("codespar_discover", {...})` but returns a typed
148
193
  * DiscoverResult so the caller doesn't have to cast.
149
194
  */
150
- async discover(useCase, options) {
195
+ async discover(useCase, options, opts) {
151
196
  const result = await session.execute("codespar_discover", {
152
197
  use_case: useCase,
153
198
  ...(options ?? {}),
154
- });
199
+ }, opts);
155
200
  if (!result.success) {
156
201
  throw new Error(`discover failed: ${result.error ?? "unknown"}`);
157
202
  }
@@ -165,8 +210,8 @@ export async function createSession(userId, config, deps) {
165
210
  * when no server_id is given, "status" otherwise (matching the
166
211
  * backend default).
167
212
  */
168
- async connectionWizard(options) {
169
- const result = await session.execute("codespar_manage_connections", options);
213
+ async connectionWizard(options, opts) {
214
+ const result = await session.execute("codespar_manage_connections", options, opts);
170
215
  if (!result.success) {
171
216
  throw new Error(`connectionWizard failed: ${result.error ?? "unknown"}`);
172
217
  }
@@ -179,8 +224,8 @@ export async function createSession(userId, config, deps) {
179
224
  * ToolResult.data. Distinct from the legacy `codespar_pay` rail
180
225
  * (which routes to outbound transfers/payouts).
181
226
  */
182
- async charge(args) {
183
- const result = await session.execute("codespar_charge", args);
227
+ async charge(args, opts) {
228
+ const result = await session.execute("codespar_charge", args, opts);
184
229
  if (!result.success) {
185
230
  throw new Error(`charge failed: ${result.error ?? "unknown"}`);
186
231
  }
@@ -193,8 +238,8 @@ export async function createSession(userId, config, deps) {
193
238
  * ShipResult so the caller doesn't have to cast through
194
239
  * ToolResult.data.
195
240
  */
196
- async ship(args) {
197
- const result = await session.execute("codespar_ship", args);
241
+ async ship(args, opts) {
242
+ const result = await session.execute("codespar_ship", args, opts);
198
243
  if (!result.success) {
199
244
  throw new Error(`ship failed: ${result.error ?? "unknown"}`);
200
245
  }
@@ -208,8 +253,8 @@ export async function createSession(userId, config, deps) {
208
253
  * LedgerResult so the caller doesn't have to cast through
209
254
  * ToolResult.data.
210
255
  */
211
- async ledger(args) {
212
- const result = await session.execute("codespar_ledger", args);
256
+ async ledger(args, opts) {
257
+ const result = await session.execute("codespar_ledger", args, opts);
213
258
  if (!result.success) {
214
259
  throw new Error(`ledger failed: ${result.error ?? "unknown"}`);
215
260
  }
@@ -221,8 +266,8 @@ export async function createSession(userId, config, deps) {
221
266
  * Pomelo card-issuing program. Same wire as
222
267
  * `execute("codespar_issue", {...})` but returns a typed IssueResult.
223
268
  */
224
- async issue(args) {
225
- const result = await session.execute("codespar_issue", args);
269
+ async issue(args, opts) {
270
+ const result = await session.execute("codespar_issue", args, opts);
226
271
  if (!result.success) {
227
272
  throw new Error(`issue failed: ${result.error ?? "unknown"}`);
228
273
  }
@@ -241,81 +286,123 @@ export async function createSession(userId, config, deps) {
241
286
  * (a registered implementation behind the contract); a self-hosted
242
287
  * OSS runtime with none returns "Tool not registered".
243
288
  */
244
- async shop(args) {
245
- const result = await session.execute("codespar_shop", args);
289
+ async shop(args, opts) {
290
+ const result = await session.execute("codespar_shop", args, opts);
246
291
  if (!result.success) {
247
292
  throw new Error(`shop failed: ${result.error ?? "unknown"}`);
248
293
  }
249
294
  return result.data;
250
295
  },
251
- async paymentStatus(toolCallId) {
252
- const r = await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status`, { headers }, "paymentStatus");
253
- if (!r.ok) {
254
- await throwFromResponse(r, "paymentStatus");
255
- }
256
- return (await r.json());
296
+ async paymentStatus(toolCallId, opts) {
297
+ return await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status`, { headers }, "paymentStatus", callOpts(opts), async (r) => {
298
+ if (!r.ok)
299
+ await throwFromResponse(r, "paymentStatus");
300
+ return (await r.json());
301
+ });
257
302
  },
258
- async verificationStatus(toolCallId) {
259
- const r = await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status`, { headers }, "verificationStatus");
260
- if (!r.ok) {
261
- await throwFromResponse(r, "verificationStatus");
262
- }
263
- return (await r.json());
303
+ async verificationStatus(toolCallId, opts) {
304
+ return await safeFetch(`${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status`, { headers }, "verificationStatus", callOpts(opts), async (r) => {
305
+ if (!r.ok)
306
+ await throwFromResponse(r, "verificationStatus");
307
+ return (await r.json());
308
+ });
264
309
  },
265
310
  async paymentStatusStream(toolCallId, options = {}) {
266
- const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status/stream`;
267
- const r = await safeFetch(url, {
268
- headers: { ...headers, Accept: "text/event-stream" },
269
- signal: options.signal,
270
- }, "paymentStatusStream");
271
- if (!r.ok || !r.body) {
272
- await throwFromResponse(r, "paymentStatusStream");
273
- // unreachable — throwFromResponse always throws
274
- throw new Error("unreachable");
275
- }
276
- let last = null;
277
- for await (const frame of parseStatusSseStream(r.body)) {
278
- if (frame.event === "snapshot" || frame.event === "update") {
279
- last = frame.data;
280
- options.onUpdate?.(last);
311
+ const ms = options.timeout ?? deps.timeout;
312
+ validateTimeout(ms);
313
+ const idleAc = new AbortController();
314
+ const merged = mergeSignals([idleAc.signal, options.signal]);
315
+ const idle = makeIdle(idleAc, ms);
316
+ try {
317
+ // Arm the timeout before the fetch so connect/headers are covered.
318
+ idle.reset();
319
+ const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/payment-status/stream`;
320
+ const r = await fetch(url, {
321
+ headers: { ...headers, Accept: "text/event-stream" },
322
+ signal: merged.signal,
323
+ });
324
+ if (!r.ok || !r.body) {
325
+ await throwFromResponse(r, "paymentStatusStream");
326
+ }
327
+ let last = null;
328
+ // r.body is non-null here — throwFromResponse always throws.
329
+ for await (const frame of parseStatusSseStream(r.body, idle, merged.signal)) {
330
+ if (frame.event === "snapshot" || frame.event === "update") {
331
+ last = frame.data;
332
+ options.onUpdate?.(last);
333
+ }
334
+ else if (frame.event === "done") {
335
+ break;
336
+ }
281
337
  }
282
- else if (frame.event === "done") {
283
- break;
338
+ if (!last) {
339
+ throw new Error("paymentStatusStream: stream closed before snapshot");
284
340
  }
341
+ return last;
285
342
  }
286
- if (!last) {
287
- throw new Error("paymentStatusStream: stream closed before snapshot");
343
+ catch (err) {
344
+ if (options.signal?.aborted)
345
+ throw options.signal.reason;
346
+ if (idleAc.signal.aborted)
347
+ throw new TimeoutError(ms);
348
+ if (err instanceof CodesparApiError)
349
+ throw err;
350
+ throw networkErrorToApiError(err, "paymentStatusStream");
351
+ }
352
+ finally {
353
+ idle.pause();
354
+ merged.cleanup();
288
355
  }
289
- return last;
290
356
  },
291
357
  async verificationStatusStream(toolCallId, options = {}) {
292
- const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status/stream`;
293
- const r = await safeFetch(url, {
294
- headers: { ...headers, Accept: "text/event-stream" },
295
- signal: options.signal,
296
- }, "verificationStatusStream");
297
- if (!r.ok || !r.body) {
298
- await throwFromResponse(r, "verificationStatusStream");
299
- // unreachable — throwFromResponse always throws
300
- throw new Error("unreachable");
301
- }
302
- let last = null;
303
- for await (const frame of parseStatusSseStream(r.body)) {
304
- if (frame.event === "snapshot" || frame.event === "update") {
305
- last = frame.data;
306
- options.onUpdate?.(last);
358
+ const ms = options.timeout ?? deps.timeout;
359
+ validateTimeout(ms);
360
+ const idleAc = new AbortController();
361
+ const merged = mergeSignals([idleAc.signal, options.signal]);
362
+ const idle = makeIdle(idleAc, ms);
363
+ try {
364
+ // Arm the timeout before the fetch so connect/headers are covered.
365
+ idle.reset();
366
+ const url = `${baseUrl}/v1/tool-calls/${encodeURIComponent(toolCallId)}/verification-status/stream`;
367
+ const r = await fetch(url, {
368
+ headers: { ...headers, Accept: "text/event-stream" },
369
+ signal: merged.signal,
370
+ });
371
+ if (!r.ok || !r.body) {
372
+ await throwFromResponse(r, "verificationStatusStream");
373
+ }
374
+ let last = null;
375
+ // r.body is non-null here — throwFromResponse always throws.
376
+ for await (const frame of parseStatusSseStream(r.body, idle, merged.signal)) {
377
+ if (frame.event === "snapshot" || frame.event === "update") {
378
+ last = frame.data;
379
+ options.onUpdate?.(last);
380
+ }
381
+ else if (frame.event === "done") {
382
+ break;
383
+ }
307
384
  }
308
- else if (frame.event === "done") {
309
- break;
385
+ if (!last) {
386
+ throw new Error("verificationStatusStream: stream closed before snapshot");
310
387
  }
388
+ return last;
311
389
  }
312
- if (!last) {
313
- throw new Error("verificationStatusStream: stream closed before snapshot");
390
+ catch (err) {
391
+ if (options.signal?.aborted)
392
+ throw options.signal.reason;
393
+ if (idleAc.signal.aborted)
394
+ throw new TimeoutError(ms);
395
+ if (err instanceof CodesparApiError)
396
+ throw err;
397
+ throw networkErrorToApiError(err, "verificationStatusStream");
398
+ }
399
+ finally {
400
+ idle.pause();
401
+ merged.cleanup();
314
402
  }
315
- return last;
316
403
  },
317
- async authorize(serverId, config) {
318
- const r = await safeFetch(`${baseUrl}/v1/connect/start`, {
404
+ async authorize(serverId, config, opts) {
405
+ const payload = await safeFetch(`${baseUrl}/v1/connect/start`, {
319
406
  method: "POST",
320
407
  headers,
321
408
  body: JSON.stringify({
@@ -324,43 +411,59 @@ export async function createSession(userId, config, deps) {
324
411
  redirect_uri: config.redirectUri,
325
412
  scopes: config.scopes,
326
413
  }),
327
- }, "authorize");
328
- if (!r.ok) {
329
- await throwFromResponse(r, "authorize");
330
- }
331
- const payload = (await r.json());
414
+ }, "authorize", callOpts(opts), async (r) => {
415
+ if (!r.ok)
416
+ await throwFromResponse(r, "authorize");
417
+ return (await r.json());
418
+ });
332
419
  return {
333
420
  linkToken: payload.link_token,
334
421
  authorizeUrl: payload.authorize_url,
335
422
  expiresAt: payload.expires_at,
336
423
  };
337
424
  },
338
- async connections() {
425
+ async connections(opts) {
426
+ // Caller misconfiguration is NOT best-effort — validate the
427
+ // effective timeout before the swallow (parity with close()), so
428
+ // an invalid value surfaces instead of silently returning the
429
+ // cached list.
430
+ const resolved = callOpts(opts);
431
+ validateTimeout(resolved.timeout);
339
432
  // Best-effort — both transport failures (CodesparApiError from
340
433
  // safeFetch) and non-2xx responses fall back to the cached
341
434
  // payload so a transient blip doesn't crater the session.
342
435
  try {
343
- const r = await safeFetch(`${baseUrl}/v1/sessions/${data.id}/connections`, { headers }, "connections");
344
- if (!r.ok)
345
- return cachedConnections ?? [];
346
- const payload = (await r.json());
347
- cachedConnections = payload.servers;
348
- cachedTools = payload.tools;
349
- return payload.servers;
436
+ return await safeFetch(`${baseUrl}/v1/sessions/${data.id}/connections`, { headers }, "connections", resolved, async (r) => {
437
+ if (!r.ok)
438
+ return cachedConnections ?? [];
439
+ const payload = (await r.json());
440
+ cachedConnections = payload.servers;
441
+ cachedTools = payload.tools;
442
+ return payload.servers;
443
+ });
350
444
  }
351
445
  catch {
352
446
  return cachedConnections ?? [];
353
447
  }
354
448
  },
355
- async close() {
356
- // Best-effort — the backend reaps stale sessions on a timer,
357
- // so a network failure here shouldn't surface to the caller.
449
+ async close(opts) {
450
+ // Caller misconfiguration is NOT best-effort — validate the
451
+ // effective timeout before the swallow, so an invalid value
452
+ // surfaces (parity with Python's ConfigError) instead of
453
+ // silently skipping the DELETE.
454
+ const { timeout } = callOpts(opts);
455
+ validateTimeout(timeout);
456
+ // Best-effort: the DELETE is bounded by the timeout/abort budget
457
+ // so close() can't hang, but its outcome is swallowed so a slow
458
+ // or failing backend cleanup never throws from a caller's
459
+ // teardown/finally. Parity with the Python client (suppresses
460
+ // ApiError + TimeoutError) and the managed-agents adapter (close
461
+ // never throws). The backend reaps stale sessions on a timer.
358
462
  try {
359
- await safeFetch(`${baseUrl}/v1/sessions/${data.id}`, { method: "DELETE", headers }, "close");
463
+ await safeFetch(`${baseUrl}/v1/sessions/${data.id}`, { method: "DELETE", headers }, "close", callOpts(opts), async () => undefined);
360
464
  }
361
465
  catch {
362
- // Intentional swallow — matches the previous fire-and-forget
363
- // contract; close() never threw.
466
+ // intentionally ignored — see above
364
467
  }
365
468
  },
366
469
  };
@@ -379,24 +482,43 @@ export async function createSession(userId, config, deps) {
379
482
  }
380
483
  return session;
381
484
  }
382
- /**
383
- * Parse a Server-Sent Events stream into typed StreamEvent objects.
384
- *
385
- * The backend emits events shaped like:
386
- * event: assistant_text
387
- * data: {"content":"...","iteration":1}
388
- *
389
- * which we map to discriminated-union StreamEvent values that the SDK
390
- * exports. We intentionally keep parsing tiny: split on double newlines,
391
- * pick out `event:` and `data:` lines, JSON-parse the data.
392
- */
393
- async function* parseSseStream(body) {
485
+ function makeIdle(idleAc, ms) {
486
+ let timer;
487
+ return {
488
+ reset() {
489
+ if (timer)
490
+ clearTimeout(timer);
491
+ timer = setTimeout(() => idleAc.abort(), ms);
492
+ },
493
+ pause() {
494
+ if (timer) {
495
+ clearTimeout(timer);
496
+ timer = undefined;
497
+ }
498
+ },
499
+ };
500
+ }
501
+ async function* parseSseStream(body, idle, signal) {
394
502
  const reader = body.getReader();
395
503
  const decoder = new TextDecoder();
396
504
  let buffer = "";
505
+ // Single abort promise + single listener per parser call — avoids
506
+ // per-iteration listener accumulation and MaxListeners warnings.
507
+ let abortReject;
508
+ const abortPromise = new Promise((_resolve, reject) => {
509
+ abortReject = reject;
510
+ });
511
+ const onAbort = () => abortReject(signal.reason);
512
+ if (signal.aborted) {
513
+ queueMicrotask(() => abortReject(signal.reason));
514
+ }
515
+ else {
516
+ signal.addEventListener("abort", onAbort, { once: true });
517
+ }
397
518
  try {
519
+ idle.reset();
398
520
  while (true) {
399
- const { done, value } = await reader.read();
521
+ const { done, value } = await Promise.race([reader.read(), abortPromise]);
400
522
  if (done)
401
523
  break;
402
524
  buffer += decoder.decode(value, { stream: true });
@@ -404,18 +526,42 @@ async function* parseSseStream(body) {
404
526
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
405
527
  const chunk = buffer.slice(0, sep);
406
528
  buffer = buffer.slice(sep + 2);
529
+ // A complete SSE comment frame (":..." heartbeat) carries no
530
+ // data but IS protocol liveness — reset idle. Only an
531
+ // incomplete byte trickle (no "\n\n" yet) must still time out.
532
+ if (chunk.startsWith(":")) {
533
+ idle.reset();
534
+ continue;
535
+ }
407
536
  const event = parseSseChunk(chunk);
408
- if (event)
537
+ if (event) {
538
+ // Pause while the consumer processes the event so its
539
+ // processing time is not counted as transport idle; re-arm
540
+ // once control returns, before the next read.
541
+ idle.pause();
409
542
  yield event;
543
+ idle.reset();
544
+ }
410
545
  }
411
546
  }
412
547
  if (buffer.trim()) {
413
548
  const event = parseSseChunk(buffer);
414
- if (event)
549
+ if (event) {
550
+ // Same invariant as the inner loop: pause the idle timer
551
+ // while the consumer holds control. The stream has ended, so
552
+ // there is no read to re-arm for afterwards.
553
+ idle.pause();
415
554
  yield event;
555
+ }
416
556
  }
417
557
  }
418
558
  finally {
559
+ idle.pause();
560
+ signal.removeEventListener("abort", onAbort);
561
+ // Best-effort, NON-blocking: a degraded ReadableStream may return a
562
+ // cancel() promise that never settles; awaiting it here would delay
563
+ // delivery of the very TimeoutError/abort the caller is waiting for.
564
+ void Promise.resolve(reader.cancel()).catch(() => { });
419
565
  reader.releaseLock();
420
566
  }
421
567
  }
@@ -491,13 +637,27 @@ const PRESET_SERVERS = {
491
637
  * just need raw `{event, data}` pairs the caller's typed wrapper
492
638
  * casts via the route-level envelope shape.
493
639
  */
494
- async function* parseStatusSseStream(body) {
640
+ async function* parseStatusSseStream(body, idle, signal) {
495
641
  const reader = body.getReader();
496
642
  const decoder = new TextDecoder();
497
643
  let buffer = "";
644
+ // Single abort promise + single listener per parser call — avoids
645
+ // per-iteration listener accumulation and MaxListeners warnings.
646
+ let abortReject;
647
+ const abortPromise = new Promise((_resolve, reject) => {
648
+ abortReject = reject;
649
+ });
650
+ const onAbort = () => abortReject(signal.reason);
651
+ if (signal.aborted) {
652
+ queueMicrotask(() => abortReject(signal.reason));
653
+ }
654
+ else {
655
+ signal.addEventListener("abort", onAbort, { once: true });
656
+ }
498
657
  try {
658
+ idle.reset();
499
659
  while (true) {
500
- const { done, value } = await reader.read();
660
+ const { done, value } = await Promise.race([reader.read(), abortPromise]);
501
661
  if (done)
502
662
  break;
503
663
  buffer += decoder.decode(value, { stream: true });
@@ -505,9 +665,16 @@ async function* parseStatusSseStream(body) {
505
665
  while ((sep = buffer.indexOf("\n\n")) !== -1) {
506
666
  const chunk = buffer.slice(0, sep);
507
667
  buffer = buffer.slice(sep + 2);
508
- // Comment frames (heartbeats) start with ":" and carry no data.
509
- if (chunk.startsWith(":"))
668
+ // Comment frames (heartbeats) start with ":" and carry no
669
+ // data, but a complete heartbeat frame IS protocol liveness —
670
+ // reset idle so healthy long-pending streams don't time out
671
+ // (parity with Python httpx, whose read timeout any byte
672
+ // resets). An incomplete byte trickle still times out because
673
+ // it never forms a "\n\n" frame.
674
+ if (chunk.startsWith(":")) {
675
+ idle.reset();
510
676
  continue;
677
+ }
511
678
  let eventName = "message";
512
679
  let dataLine = "";
513
680
  for (const line of chunk.split("\n")) {
@@ -518,16 +685,27 @@ async function* parseStatusSseStream(body) {
518
685
  }
519
686
  if (!dataLine)
520
687
  continue;
688
+ let parsed;
521
689
  try {
522
- yield { event: eventName, data: JSON.parse(dataLine) };
690
+ parsed = JSON.parse(dataLine);
523
691
  }
524
692
  catch {
525
693
  continue;
526
694
  }
695
+ // Pause while the consumer processes the frame; re-arm after.
696
+ idle.pause();
697
+ yield { event: eventName, data: parsed };
698
+ idle.reset();
527
699
  }
528
700
  }
529
701
  }
530
702
  finally {
703
+ idle.pause();
704
+ signal.removeEventListener("abort", onAbort);
705
+ // Best-effort, NON-blocking: a degraded ReadableStream may return a
706
+ // cancel() promise that never settles; awaiting it here would delay
707
+ // delivery of the very TimeoutError/abort the caller is waiting for.
708
+ void Promise.resolve(reader.cancel()).catch(() => { });
531
709
  reader.releaseLock();
532
710
  }
533
711
  }