@qoniai/qoni 0.4.1 → 0.4.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ This project follows semantic versioning. Before `1.0.0`, minor versions may
6
6
  include SDK API changes, while patch versions are reserved for backward
7
7
  compatible fixes.
8
8
 
9
+ ## 0.4.3 - 2026-09-09
10
+
11
+ ### Fixed
12
+
13
+ - Treat malformed or empty screenshot data URIs as undecodable instead of
14
+ emitting invalid screenshot bytes or interrupting `wait()`. Preserve the
15
+ existing progress/raw fallback with screenshot capture enabled or disabled.
16
+ - Keep valid padded, unpadded, whitespace-containing and URL-safe base64
17
+ screenshots compatible.
18
+
19
+ ## 0.4.2 - 2026-09-09
20
+
21
+ ### Fixed
22
+
23
+ - Require authoritative run details when settling `wait()`: preserve detail
24
+ errors and reject unconfirmed terminal states instead of inventing success.
25
+ - Reuse terminal details read after a clean stream close and resume from every
26
+ wire event cursor, including frames filtered from semantic events.
27
+ - Propagate wait cancellation to detail and artifact-list GETs. Bound complete
28
+ JSON, NDJSON, binary, and HTTP error body reads by the request timeout, cancel
29
+ stalled readers, and discard late responses without starting replacement runs.
30
+ - Clean up wait timers and SSE abort listeners. Cancel idle SSE readers directly
31
+ and release their locks without waiting indefinitely for source cancellation.
32
+ - Retry HTTP 408 stream failures within the configured reconnect budget and stop
33
+ delivering buffered events after cancellation.
34
+
35
+ ### Added
36
+
37
+ - Optional abort signals on product run-detail and artifact-list wire reads;
38
+ existing calls without a signal remain compatible.
39
+
9
40
  ## Unreleased
10
41
 
11
42
  ### Breaking
package/README.md CHANGED
@@ -161,6 +161,8 @@ console.log(result.output);
161
161
 
162
162
  `run()` returns a `RunHandle` typed to the product: `qoni.deepResearch.run()` is a `RunHandle<DeepResearchEvent>`, so its events are narrowed to what Deep Research actually emits. `capture: { screenshots: true }` opts into per-step screenshots, decoded to bytes for you. `wait()` drives the run to its terminal state and settles the result (`result.raw` carries cost, step counts, token usage).
163
163
 
164
+ Undecodable or empty screenshot data URIs never interrupt `wait()` or invoke `onScreenshot`, regardless of capture settings. They follow the existing progress fallback when a human-readable message is available; `onWireEvent` always retains the original payload, including frames with no progress text.
165
+
164
166
  > **Best practice — match `event.type` against the `QoniEventTypes` constants.**
165
167
  > Each product handle is typed to its own event set, so in TypeScript a case
166
168
  > for another product's event is a compile error and autocomplete shows only
@@ -512,7 +514,7 @@ const qoni = new Qoni({
512
514
  | `secretKey` | `string` | Yes | Qoni secret key from Qoni Console. |
513
515
  | `host` | `string` | No | Optional Qoni Console/SDK gateway override for private or local deployments. |
514
516
  | `fetch` | `typeof fetch` | No | Custom transport implementation. |
515
- | `timeoutMs` | `number` | No | Per-request timeout (connect + response headers). Defaults to `30000`. |
517
+ | `timeoutMs` | `number` | No | Per-request timeout (connect + headers + body for one-shot requests; headers only for successful SSE connections). Defaults to `30000`. |
516
518
 
517
519
  > **`timeoutMs` does not bound a long-running run.** It applies to individual
518
520
  > one-shot HTTP requests (delegation, `status()`, a single POST). The live
@@ -550,6 +552,26 @@ await run.cancel(reason?) // idempotent
550
552
 
551
553
  `await run.wait(...)` settles to a `RunResult`:
552
554
 
555
+ The run-detail response must confirm a terminal state. Detail read errors propagate
556
+ unchanged; `wait()` never substitutes an SSE completion payload for the authoritative
557
+ result. A nonterminal or incomplete detail rejects with a retryable `upstream.failed`
558
+ error; use `status()` or re-attach to recover.
559
+
560
+ `timeoutMs` and `signal` stop waiting for detail/artifact reads and promises returned
561
+ by `onEvent`, `onWireEvent`, `onInteraction`, and `onScreenshot`. They cannot preempt
562
+ synchronous JavaScript or stop work already started inside a callback. `onReconnect`
563
+ is synchronous telemetry: its return value is not awaited and returned promise
564
+ rejections are not handled. Do not pass an async callback there; synchronous throws
565
+ propagate. Reconnect backoff remains abortable and subject to the wait deadline.
566
+
567
+ Detail/artifact-list GETs receive the wait signal: aborting or timing out a wait aborts
568
+ the request and cancels its body reader. The client's per-request timer also covers
569
+ complete JSON/NDJSON and binary body reads, including HTTP error bodies. A late response
570
+ from a custom fetch that ignores abort has its body canceled and cannot resume settlement
571
+ or start subsequent run requests. Custom transports must honor `RequestInit.signal` to
572
+ stop their own in-flight network work. Stopping a wait neither cancels the server-side
573
+ run nor creates a replacement task; use `cancel()` explicitly to stop the existing run.
574
+
553
575
  ```ts
554
576
  interface RunResult {
555
577
  runId: string;
package/README.zh-CN.md CHANGED
@@ -154,6 +154,8 @@ console.log(result.output);
154
154
 
155
155
  `run()` 返回一个 `RunHandle`。核心事件(状态、动作、交互、完成)始终默认推送;`capture: { screenshots: true }` 开启逐步截图,并且 SDK 已经把图片解码成字节。`wait()` 把任务跑到终态,并从权威 run envelope 结算结果(成本、步数、token 用量都在 `result.raw` 上)。
156
156
 
157
+ 无论是否开启 capture,不可解码或空的截图 data URI 都不会中断 `wait()`,也不会触发 `onScreenshot`。有可读消息时按既有契约降级为 progress;`onWireEvent` 始终保留原始 payload,包括没有 progress 文本的帧。
158
+
157
159
  需要逐步控制时,直接消费事件流,`switch (event.type)` 用 `QoniEventTypes` 常量匹配。每个产品的 handle 按产品分型(`qoni.deepResearch.run()` 出来是 `RunHandle<DeepResearchEvent>`),TS 里写错产品的事件会编译报错、补全只列本产品的。内部细节(supervisor 编排、子 agent 点击/输入、telemetry)统一折叠成 `QoniEventTypes.Progress`;匹配到 `case` 后 `event.data` 就是该类型的固定形状。原始 wire 类型不导出。
158
160
 
159
161
  ```ts
@@ -482,7 +484,7 @@ const qoni = new Qoni({
482
484
  | `secretKey` | `string` | 是 | Qoni Console 创建的 secret key。 |
483
485
  | `host` | `string` | 否 | 私有化或本地部署时可选的 Qoni Console/SDK 网关覆盖地址。 |
484
486
  | `fetch` | `typeof fetch` | 否 | 自定义传输实现。 |
485
- | `timeoutMs` | `number` | 否 | 单次请求超时(连接 + 响应头),默认 `30000`。 |
487
+ | `timeoutMs` | `number` | 否 | 单次请求超时(一次性请求覆盖连接、响应头及响应体;成功建立的 SSE 仅覆盖到响应头),默认 `30000`。 |
486
488
 
487
489
  > **`timeoutMs` 不会限制长任务。** 它只作用于单次一次性 HTTP 请求(委托、`status()`、单个 POST)。事件流(`events()` / `wait()`)**不受它约束**——流的响应头一到,这个单次计时器就被清掉了,所以一个跑一小时的 Deep Research 在默认 30s 客户端超时下也能正常流式。要限制等待时长用 `wait({ timeoutMs })`;不传则 `wait()` 一直等到终态。`wait()` 超时**不会停止 run**——它在服务端继续跑,可以再 `attach()` 回去。
488
490
 
@@ -512,6 +514,19 @@ await run.cancel(reason?) // 幂等
512
514
 
513
515
  `await run.wait(...)` 结算成一个 `RunResult`:
514
516
 
517
+ 必须由 run-detail 响应确认终态。详情读取错误原样抛出,`wait()` 不会用 SSE 完成事件替代权威结果。
518
+ 详情仍为非终态或缺少终态信息时,抛出可重试的 `upstream.failed`;可通过 `status()` 或重新 `attach()` 恢复。
519
+
520
+ `timeoutMs` 与 `signal` 会结束详情、交付物读取及 `onEvent`、`onWireEvent`、`onInteraction`、
521
+ `onScreenshot` 返回的 Promise 的等待,但不能抢占同步 JavaScript,也不能停止回调内部已启动的工作。
522
+ `onReconnect` 是同步遥测通知:返回值不被等待,返回的 Promise 的 rejection 也不被处理,因此不要传 async
523
+ 回调;同步抛错会向上传播。重连退避等待仍可被 signal 或 wait 超时中断。
524
+
525
+ 详情/交付物列表 GET 会接收 wait signal:wait 中止或超时时会中止请求、取消并释放响应体 reader。
526
+ 客户端单次请求计时器也覆盖完整 JSON/NDJSON、二进制响应体及 HTTP 错误体读取。即使自定义 fetch 忽略
527
+ abort,SDK 也会取消其迟到响应体,不恢复结算或启动后续 run 请求;自定义传输须遵守 `RequestInit.signal`
528
+ 才能停止其内部网络工作。停止 wait 既不会取消服务端 run,也不会创建替代任务;需要停止原 run 时须显式调用 `cancel()`。
529
+
515
530
  ```ts
516
531
  interface RunResult {
517
532
  runId: string;
package/dist/index.cjs CHANGED
@@ -397,8 +397,14 @@ function decodeDataUri(uri) {
397
397
  const match = /^data:([^;,]*)?(;base64)?,(.*)$/s.exec(uri);
398
398
  if (!match) return void 0;
399
399
  const [, mime, isBase64, body] = match;
400
- const bytes = isBase64 ? new Uint8Array(Buffer.from(body, "base64")) : new TextEncoder().encode(decodeURIComponent(body));
401
- return { bytes, mime: mime || "application/octet-stream" };
400
+ const encoded = isBase64 ? body.replace(/\s/g, "") : body;
401
+ if (isBase64 && (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded) || encoded.length % 4 === 1 || encoded.includes("=") && encoded.length % 4 !== 0)) return void 0;
402
+ try {
403
+ const bytes = isBase64 ? new Uint8Array(Buffer.from(encoded, "base64")) : new TextEncoder().encode(decodeURIComponent(encoded));
404
+ return bytes.length ? { bytes, mime: mime || "application/octet-stream" } : void 0;
405
+ } catch {
406
+ return void 0;
407
+ }
402
408
  }
403
409
  function asString(value) {
404
410
  return typeof value === "string" && value ? value : void 0;
@@ -816,11 +822,11 @@ var RunHandle = class {
816
822
  }
817
823
  /** Drive the run to a terminal state and return the settled result. */
818
824
  async wait(opts = {}) {
819
- const { signal, timedOut } = withTimeout(opts.signal, opts.timeoutMs);
825
+ const { signal, timedOut, dispose } = withTimeout(opts.signal, opts.timeoutMs);
820
826
  let lastEventId;
821
827
  let eventIdAtLastClose;
822
828
  let sawCleanClose = false;
823
- let terminalPayload;
829
+ let terminalDetail;
824
830
  let terminalDoneReason;
825
831
  let screenshotIndex = 0;
826
832
  try {
@@ -828,24 +834,28 @@ var RunHandle = class {
828
834
  for await (const event of this.events({
829
835
  signal,
830
836
  lastEventId,
831
- onWireEvent: opts.onWireEvent,
837
+ onWireEvent: (wire) => waitWithSignal(() => {
838
+ if (wire.id !== void 0) lastEventId = wire.id;
839
+ return opts.onWireEvent?.(wire);
840
+ }, signal),
832
841
  onReconnect: opts.onReconnect
833
842
  })) {
834
- if (event.raw.id) lastEventId = event.raw.id;
835
- await opts.onEvent?.(event);
843
+ await waitWithSignal(() => opts.onEvent?.(event), signal);
836
844
  if (event.type === "screenshot") {
837
- await opts.onScreenshot?.(event.image, screenshotIndex++);
845
+ await waitWithSignal(() => opts.onScreenshot?.(event.image, screenshotIndex++), signal);
838
846
  } else if (event.type === "interaction") {
839
- await opts.onInteraction?.(this.interactionHandle(event.data), event);
847
+ await waitWithSignal(() => opts.onInteraction?.(this.interactionHandle(event.data), event), signal);
840
848
  }
841
849
  if (event.isTerminal) {
842
- terminalPayload = isJsonObject(event.raw.data) ? event.raw.data : void 0;
843
850
  if (event.type === "done") terminalDoneReason = event.data.terminalReason || void 0;
844
851
  break settled;
845
852
  }
846
853
  }
847
- const detail2 = await this.ops.getDetail();
848
- if (isTerminalRunStatus(detail2.status)) break;
854
+ const detail2 = await waitWithSignal(() => this.ops.getDetail(signal), signal);
855
+ if (isTerminalRunStatus(normalizeRunStatus(this.id, detail2).status)) {
856
+ terminalDetail = detail2;
857
+ break;
858
+ }
849
859
  if (sawCleanClose && lastEventId === eventIdAtLastClose) {
850
860
  throw new QoniError(
851
861
  `run event stream ended before a terminal event while the run is still "${String(detail2.status)}" \u2014 re-attach or poll status()`,
@@ -855,6 +865,11 @@ var RunHandle = class {
855
865
  sawCleanClose = true;
856
866
  eventIdAtLastClose = lastEventId;
857
867
  }
868
+ const detail = terminalDetail ?? await waitWithSignal(() => this.ops.getDetail(signal), signal);
869
+ this.#adoptSessionRef(detail);
870
+ const result = settleRunResult(this.id, detail, [], terminalDoneReason);
871
+ result.artifacts = this.ops.listArtifacts ? await waitWithSignal(() => this.ops.listArtifacts(signal), signal) : [];
872
+ return result;
858
873
  } catch (err) {
859
874
  if (timedOut() && !opts.signal?.aborted) {
860
875
  throw new QoniTimeoutError(
@@ -863,17 +878,9 @@ var RunHandle = class {
863
878
  );
864
879
  }
865
880
  throw err;
881
+ } finally {
882
+ dispose();
866
883
  }
867
- let detail;
868
- try {
869
- detail = await this.ops.getDetail();
870
- } catch (err) {
871
- if (!terminalPayload) throw err;
872
- detail = terminalPayload;
873
- }
874
- this.#adoptSessionRef(detail);
875
- const artifacts = this.ops.listArtifacts ? await this.ops.listArtifacts() : [];
876
- return settleRunResult(this.id, detail, artifacts, terminalDoneReason);
877
884
  }
878
885
  /**
879
886
  * Wrap an {@link Interaction} (from `event.data` of an `interaction` event)
@@ -926,15 +933,12 @@ function normalizeRunStatus(id, detail) {
926
933
  function settleRunResult(runId, detail, artifacts, fallbackTerminalReason) {
927
934
  const terminalReason = (typeof detail.terminal_reason === "string" ? detail.terminal_reason : void 0) ?? fallbackTerminalReason;
928
935
  const isTaskSuccessful = typeof detail.is_task_successful === "boolean" ? detail.is_task_successful : void 0;
929
- let status;
930
- if (isTerminalRunStatus(detail.status)) {
931
- status = detail.status;
932
- } else if (terminalReason === "canceled") {
933
- status = "canceled";
934
- } else if (terminalReason === "failed" || terminalReason === "expired" || isTaskSuccessful === false) {
935
- status = "failed";
936
- } else {
937
- status = "succeeded";
936
+ const status = normalizeRunStatus(runId, detail).status;
937
+ if (!isTerminalRunStatus(status)) {
938
+ throw new QoniError(
939
+ "run detail did not confirm a terminal state \u2014 re-attach or poll status()",
940
+ { code: "upstream.failed", retryable: true, body: detail }
941
+ );
938
942
  }
939
943
  return {
940
944
  runId,
@@ -961,19 +965,40 @@ function isVideoFrame(wire) {
961
965
  return type === "browser_video_frame" || type === "run.browser_video_frame";
962
966
  }
963
967
  function withTimeout(signal, timeoutMs) {
964
- if (!timeoutMs) return { signal, timedOut: () => false };
968
+ if (!timeoutMs) return { signal, timedOut: () => false, dispose: () => {
969
+ } };
965
970
  const controller = new AbortController();
966
971
  let fired = false;
967
972
  const timer = setTimeout(() => {
968
973
  fired = true;
969
974
  controller.abort();
970
975
  }, timeoutMs);
971
- controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
976
+ const onAbort = () => controller.abort(signal?.reason);
977
+ const dispose = () => {
978
+ clearTimeout(timer);
979
+ signal?.removeEventListener("abort", onAbort);
980
+ };
981
+ controller.signal.addEventListener("abort", dispose, { once: true });
972
982
  if (signal) {
973
- if (signal.aborted) controller.abort();
974
- else signal.addEventListener("abort", () => controller.abort(), { once: true });
983
+ if (signal.aborted) onAbort();
984
+ else signal.addEventListener("abort", onAbort, { once: true });
975
985
  }
976
- return { signal: controller.signal, timedOut: () => fired };
986
+ return { signal: controller.signal, timedOut: () => fired, dispose };
987
+ }
988
+ function waitWithSignal(operation, signal) {
989
+ if (!signal) return Promise.resolve().then(operation);
990
+ return new Promise((resolve, reject) => {
991
+ const onAbort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
992
+ if (signal.aborted) {
993
+ onAbort();
994
+ return;
995
+ }
996
+ signal.addEventListener("abort", onAbort, { once: true });
997
+ Promise.resolve().then(() => {
998
+ signal.throwIfAborted();
999
+ return operation();
1000
+ }).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
1001
+ });
977
1002
  }
978
1003
 
979
1004
  // src/deep-research.ts
@@ -988,6 +1013,7 @@ function createDeepResearchNamespace(transport) {
988
1013
  `/deep_research/runs/${encodeURIComponent(input.runId)}`,
989
1014
  input.token,
990
1015
  {
1016
+ signal: input.signal,
991
1017
  requiredScopes: ["webagent.deep_research:read"]
992
1018
  }
993
1019
  ),
@@ -1033,6 +1059,7 @@ function createDeepResearchNamespace(transport) {
1033
1059
  `/deep_research/runs/${encodeURIComponent(input.runId)}/artifacts`,
1034
1060
  input.token,
1035
1061
  {
1062
+ signal: input.signal,
1036
1063
  requiredScopes: ["webagent.deep_research:read"]
1037
1064
  }
1038
1065
  ),
@@ -1047,7 +1074,7 @@ function createDeepResearchNamespace(transport) {
1047
1074
  };
1048
1075
  function buildOps(token, runId) {
1049
1076
  return {
1050
- getDetail: async () => asRecord((await api.get({ token, runId })).data),
1077
+ getDetail: async (signal) => asRecord((await api.get({ token, runId, signal })).data),
1051
1078
  streamEvents: (opts) => api.events({
1052
1079
  token,
1053
1080
  runId,
@@ -1067,8 +1094,8 @@ function createDeepResearchNamespace(transport) {
1067
1094
  );
1068
1095
  },
1069
1096
  cancel: async () => asRecord((await api.cancel({ token, runId })).data),
1070
- listArtifacts: async () => {
1071
- const res = await api.listArtifacts({ token, runId });
1097
+ listArtifacts: async (signal) => {
1098
+ const res = await api.listArtifacts({ token, runId, signal });
1072
1099
  const items = Array.isArray(res.data?.items) ? res.data.items : [];
1073
1100
  return items.filter(isJsonObject).map((item) => toArtifact(item, token, runId));
1074
1101
  }
@@ -1188,6 +1215,7 @@ function createDoAnythingNamespace(transport) {
1188
1215
  `/do_anything/runs/${encodeURIComponent(input.runId)}`,
1189
1216
  input.token,
1190
1217
  {
1218
+ signal: input.signal,
1191
1219
  requiredScopes: ["webagent.do_anything:read"]
1192
1220
  }
1193
1221
  ),
@@ -1245,7 +1273,7 @@ function createDoAnythingNamespace(transport) {
1245
1273
  };
1246
1274
  function buildOps(token, runId, capture) {
1247
1275
  return {
1248
- getDetail: async () => asRecord2((await api.getRun({ token, runId })).data),
1276
+ getDetail: async (signal) => asRecord2((await api.getRun({ token, runId, signal })).data),
1249
1277
  streamEvents: (opts) => api.events({
1250
1278
  token,
1251
1279
  runId,
@@ -1839,6 +1867,7 @@ function createWebSearchNamespace(transport) {
1839
1867
  `/web_search/runs/${encodeURIComponent(input.runId)}`,
1840
1868
  input.token,
1841
1869
  {
1870
+ signal: input.signal,
1842
1871
  requiredScopes: ["webagent.web_search:read"]
1843
1872
  }
1844
1873
  ),
@@ -1864,7 +1893,7 @@ function createWebSearchNamespace(transport) {
1864
1893
  };
1865
1894
  function buildOps(token, runId) {
1866
1895
  return {
1867
- getDetail: async () => asRecord4((await api.get({ token, runId })).data),
1896
+ getDetail: async (signal) => asRecord4((await api.get({ token, runId, signal })).data),
1868
1897
  streamEvents: (opts) => api.events({
1869
1898
  token,
1870
1899
  runId,
@@ -2137,7 +2166,9 @@ var Qoni = class {
2137
2166
  }
2138
2167
  }
2139
2168
  async webAgentJson(method, path, token, payload = {}) {
2169
+ payload.signal?.throwIfAborted();
2140
2170
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
2171
+ payload.signal?.throwIfAborted();
2141
2172
  const baseUrl = await this.baseUrlFor("webagent");
2142
2173
  return this.jsonRequest(
2143
2174
  baseUrl,
@@ -2148,9 +2179,11 @@ var Qoni = class {
2148
2179
  // Binary GET against the WebAgent API — artifact content downloads. JSON
2149
2180
  // error payloads still map to typed QoniErrors.
2150
2181
  async webAgentBytes(path, token, payload = {}) {
2182
+ payload.signal?.throwIfAborted();
2151
2183
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
2184
+ payload.signal?.throwIfAborted();
2152
2185
  const baseUrl = await this.baseUrlFor("webagent");
2153
- const response = await this.fetchWithTimeout(
2186
+ return this.fetchWithTimeout(
2154
2187
  urlWithBasePath(baseUrl, this.webAgentPath(runtimeToken, path), payload.query),
2155
2188
  {
2156
2189
  method: "GET",
@@ -2159,20 +2192,22 @@ var Qoni = class {
2159
2192
  authorization: `Bearer ${runtimeToken}`,
2160
2193
  ...payload.headers || {}
2161
2194
  }
2162
- }
2195
+ },
2196
+ async (response, signal) => {
2197
+ if (!response.ok) {
2198
+ throw errorFromPayload(
2199
+ response.status,
2200
+ await readJson(response, signal),
2201
+ `Qoni request failed with HTTP ${response.status}`
2202
+ );
2203
+ }
2204
+ return {
2205
+ bytes: await readBody(response, signal),
2206
+ mime: response.headers.get("content-type") || "application/octet-stream"
2207
+ };
2208
+ },
2209
+ payload.signal
2163
2210
  );
2164
- if (!response.ok) {
2165
- throw errorFromPayload(
2166
- response.status,
2167
- await readJson(response),
2168
- `Qoni request failed with HTTP ${response.status}`
2169
- );
2170
- }
2171
- const buffer = await response.arrayBuffer();
2172
- return {
2173
- bytes: new Uint8Array(buffer),
2174
- mime: response.headers.get("content-type") || "application/octet-stream"
2175
- };
2176
2211
  }
2177
2212
  async *webAgentSSE(path, token, payload = {}) {
2178
2213
  const runtimeToken = await this.productTokenFor("webagent", token, payload.requiredScopes);
@@ -2212,15 +2247,16 @@ var Qoni = class {
2212
2247
  });
2213
2248
  }
2214
2249
  async jsonRequest(baseUrl, pathname, options) {
2215
- const response = await this.fetchWithTimeout(
2250
+ const { response, payload } = await this.fetchWithTimeout(
2216
2251
  urlWithBasePath(baseUrl, pathname, options.query),
2217
2252
  {
2218
2253
  method: options.method,
2219
2254
  headers: requestHeaders(options),
2220
2255
  body: requestBody(options.body)
2221
- }
2256
+ },
2257
+ async (response2, signal) => ({ response: response2, payload: await readJson(response2, signal) }),
2258
+ options.signal
2222
2259
  );
2223
- const payload = await readJson(response);
2224
2260
  if (!response.ok) {
2225
2261
  throw errorFromPayload(
2226
2262
  response.status,
@@ -2270,45 +2306,55 @@ var Qoni = class {
2270
2306
  }
2271
2307
  }
2272
2308
  async *sseConnectOnce(baseUrl, pathname, options) {
2273
- const response = await this.fetchWithTimeout(
2274
- urlWithBasePath(baseUrl, pathname, options.query),
2275
- {
2276
- method: "GET",
2277
- headers: {
2278
- accept: "text/event-stream",
2279
- authorization: `Bearer ${options.token}`,
2280
- ...options.headers || {},
2281
- ...options.lastEventId ? { "last-event-id": options.lastEventId } : {}
2282
- }
2283
- },
2284
- options.signal,
2285
- { streamBody: true }
2286
- );
2287
- if (!response.ok) {
2288
- throw errorFromPayload(
2289
- response.status,
2290
- await readJson(response),
2291
- `Qoni stream failed with HTTP ${response.status}`
2292
- );
2293
- }
2294
- if (!response.body) {
2295
- throw errorFromPayload(502, void 0, "Qoni stream response has no body");
2296
- }
2297
- const reader = response.body.getReader();
2309
+ let cleanup;
2310
+ let reader;
2311
+ const onAbort = () => {
2312
+ void reader?.cancel(options.signal?.reason).catch(() => void 0);
2313
+ };
2298
2314
  const decoder = new TextDecoder();
2299
2315
  let buffer = "";
2300
2316
  try {
2317
+ const response = await this.fetchWithTimeout(
2318
+ urlWithBasePath(baseUrl, pathname, options.query),
2319
+ {
2320
+ method: "GET",
2321
+ headers: {
2322
+ accept: "text/event-stream",
2323
+ authorization: `Bearer ${options.token}`,
2324
+ ...options.headers || {},
2325
+ ...options.lastEventId ? { "last-event-id": options.lastEventId } : {}
2326
+ }
2327
+ },
2328
+ async (response2, signal) => {
2329
+ if (!response2.ok) {
2330
+ throw errorFromPayload(
2331
+ response2.status,
2332
+ await readJson(response2, signal),
2333
+ `Qoni stream failed with HTTP ${response2.status}`
2334
+ );
2335
+ }
2336
+ return response2;
2337
+ },
2338
+ options.signal,
2339
+ { streamCleanup: (dispose) => {
2340
+ cleanup = dispose;
2341
+ } }
2342
+ );
2343
+ if (!response.body) {
2344
+ throw errorFromPayload(502, void 0, "Qoni stream response has no body");
2345
+ }
2346
+ reader = response.body.getReader();
2347
+ options.signal?.addEventListener("abort", onAbort, { once: true });
2301
2348
  while (true) {
2302
- if (options.signal?.aborted) {
2303
- await reader.cancel().catch(() => void 0);
2304
- throw options.signal.reason || new DOMException("Aborted", "AbortError");
2305
- }
2349
+ options.signal?.throwIfAborted();
2306
2350
  const { done, value } = await reader.read();
2351
+ options.signal?.throwIfAborted();
2307
2352
  if (done) break;
2308
2353
  buffer += decoder.decode(value, { stream: true });
2309
2354
  const chunks = buffer.split(/\r?\n\r?\n/);
2310
2355
  buffer = chunks.pop() || "";
2311
2356
  for (const chunk of chunks) {
2357
+ options.signal?.throwIfAborted();
2312
2358
  const event = parseSSEEvent(chunk);
2313
2359
  if (event) yield event;
2314
2360
  }
@@ -2316,14 +2362,16 @@ var Qoni = class {
2316
2362
  const finalEvent = parseSSEEvent(buffer);
2317
2363
  if (finalEvent) yield finalEvent;
2318
2364
  } finally {
2319
- await reader.cancel().catch(() => void 0);
2365
+ cleanup?.();
2366
+ options.signal?.removeEventListener("abort", onAbort);
2367
+ void reader?.cancel().catch(() => void 0);
2320
2368
  try {
2321
- reader.releaseLock();
2369
+ reader?.releaseLock();
2322
2370
  } catch {
2323
2371
  }
2324
2372
  }
2325
2373
  }
2326
- async fetchWithTimeout(input, init, signal, opts) {
2374
+ async fetchWithTimeout(input, init, consume, signal, opts) {
2327
2375
  if (signal?.aborted) throw signal.reason || new DOMException("Aborted", "AbortError");
2328
2376
  const controller = new AbortController();
2329
2377
  const resolved = resolveLocalGenauthRequest(input);
@@ -2338,17 +2386,39 @@ var Qoni = class {
2338
2386
  didTimeout = true;
2339
2387
  controller.abort();
2340
2388
  }, this.timeoutMs);
2389
+ let response;
2341
2390
  try {
2342
- return await this.fetchImpl(resolved.url, { ...requestInit, signal: controller.signal });
2391
+ response = await new Promise((resolve, reject) => {
2392
+ const onAbort = () => reject(controller.signal.reason);
2393
+ controller.signal.addEventListener("abort", onAbort, { once: true });
2394
+ Promise.resolve().then(() => {
2395
+ controller.signal.throwIfAborted();
2396
+ return this.fetchImpl(resolved.url, { ...requestInit, signal: controller.signal });
2397
+ }).then((response2) => {
2398
+ if (controller.signal.aborted) {
2399
+ void response2.body?.cancel(controller.signal.reason).catch(() => void 0);
2400
+ reject(controller.signal.reason);
2401
+ } else {
2402
+ resolve(response2);
2403
+ }
2404
+ }, reject).finally(() => controller.signal.removeEventListener("abort", onAbort));
2405
+ });
2406
+ const result = await consume(response, controller.signal);
2407
+ controller.signal.throwIfAborted();
2408
+ return result;
2343
2409
  } catch (error) {
2344
- if (isAbortError(error) && didTimeout || isConnectTimeoutError(error)) {
2410
+ void response?.body?.cancel().catch(() => void 0);
2411
+ if (signal?.aborted) throw signal.reason;
2412
+ if (didTimeout || isConnectTimeoutError(error)) {
2345
2413
  throw timeoutError(error);
2346
2414
  }
2347
2415
  if (isFetchNetworkError(error)) throw networkError(error);
2348
2416
  throw error;
2349
2417
  } finally {
2350
2418
  clearTimeout(timeout);
2351
- if (!opts?.streamBody) signal?.removeEventListener("abort", abortFromSignal);
2419
+ const cleanup = () => signal?.removeEventListener("abort", abortFromSignal);
2420
+ if (opts?.streamCleanup) opts.streamCleanup(cleanup);
2421
+ else cleanup();
2352
2422
  }
2353
2423
  }
2354
2424
  async baseUrlFor(service) {
@@ -2404,7 +2474,7 @@ var Qoni = class {
2404
2474
  pathname,
2405
2475
  {}
2406
2476
  );
2407
- const response = await this.fetchWithTimeout(
2477
+ const { response, payload } = await this.fetchWithTimeout(
2408
2478
  urlWithBasePath(this.host, pathname),
2409
2479
  {
2410
2480
  method: "GET",
@@ -2412,9 +2482,9 @@ var Qoni = class {
2412
2482
  accept: "application/json",
2413
2483
  ...headers
2414
2484
  }
2415
- }
2485
+ },
2486
+ async (response2, signal) => ({ response: response2, payload: await readJson(response2, signal) })
2416
2487
  );
2417
- const payload = await readJson(response);
2418
2488
  if (!response.ok) {
2419
2489
  throw errorFromPayload(
2420
2490
  response.status,
@@ -2508,18 +2578,52 @@ function requestBody(body) {
2508
2578
  function isBodyInit(value) {
2509
2579
  return typeof value === "string" || value instanceof ArrayBuffer || value instanceof Blob || value instanceof FormData || value instanceof URLSearchParams;
2510
2580
  }
2511
- async function readJson(response) {
2581
+ async function readBody(response, signal) {
2582
+ if (!response.body) {
2583
+ signal.throwIfAborted();
2584
+ return new Uint8Array();
2585
+ }
2586
+ const reader = response.body.getReader();
2587
+ const onAbort = () => {
2588
+ void reader.cancel(signal.reason).catch(() => void 0);
2589
+ };
2590
+ signal.addEventListener("abort", onAbort, { once: true });
2591
+ try {
2592
+ const chunks = [];
2593
+ let size = 0;
2594
+ for (; ; ) {
2595
+ signal.throwIfAborted();
2596
+ const { done, value } = await reader.read();
2597
+ signal.throwIfAborted();
2598
+ if (done) break;
2599
+ chunks.push(value);
2600
+ size += value.byteLength;
2601
+ }
2602
+ const bytes = new Uint8Array(size);
2603
+ let offset = 0;
2604
+ for (const chunk of chunks) {
2605
+ bytes.set(chunk, offset);
2606
+ offset += chunk.byteLength;
2607
+ }
2608
+ return bytes;
2609
+ } finally {
2610
+ signal.removeEventListener("abort", onAbort);
2611
+ void reader.cancel().catch(() => void 0);
2612
+ reader.releaseLock();
2613
+ }
2614
+ }
2615
+ async function readJson(response, signal) {
2512
2616
  const contentType = response.headers.get("content-type")?.toLowerCase() || "";
2513
- if (contentType.includes("ndjson") || contentType.includes("jsonl")) {
2514
- const body = await response.text();
2515
- if (!body.trim()) return [];
2516
- try {
2617
+ const body = new TextDecoder().decode(await readBody(response, signal));
2618
+ try {
2619
+ if (contentType.includes("ndjson") || contentType.includes("jsonl")) {
2620
+ if (!body.trim()) return [];
2517
2621
  return body.split(/\r?\n/).filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
2518
- } catch {
2519
- return void 0;
2520
2622
  }
2623
+ return JSON.parse(body);
2624
+ } catch {
2625
+ return void 0;
2521
2626
  }
2522
- return response.json().catch(() => void 0);
2523
2627
  }
2524
2628
  function normalizeResponse(payload, _service) {
2525
2629
  if (isJsonObject(payload) && ("data" in payload || "meta" in payload)) {
@@ -2762,6 +2866,7 @@ function isRetryableStreamError(err) {
2762
2866
  if (err && typeof err === "object") {
2763
2867
  const e = err;
2764
2868
  if (e.name === "AbortError") return false;
2869
+ if (e.status === 408) return true;
2765
2870
  if (typeof e.retryable === "boolean") return e.retryable;
2766
2871
  if (typeof e.status === "number") {
2767
2872
  return e.status >= 500 || e.status === 408 || e.status === 429;
@@ -2868,9 +2973,6 @@ function normalizeDelegateAgentTokenResponse(response) {
2868
2973
  }
2869
2974
  };
2870
2975
  }
2871
- function isAbortError(error) {
2872
- return error instanceof DOMException && error.name === "AbortError";
2873
- }
2874
2976
  function isConnectTimeoutError(error) {
2875
2977
  const cause = errorCause(error);
2876
2978
  return errorCode(cause) === "UND_ERR_CONNECT_TIMEOUT" || errorName(cause) === "ConnectTimeoutError";