ai 7.0.34 → 7.0.36

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 (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/index.d.ts +26 -10
  3. package/dist/index.js +144 -47
  4. package/dist/index.js.map +1 -1
  5. package/dist/internal/index.d.ts +5 -2
  6. package/dist/internal/index.js +26 -3
  7. package/dist/internal/index.js.map +1 -1
  8. package/docs/03-ai-sdk-core/25-settings.mdx +15 -2
  9. package/docs/03-ai-sdk-core/65-devtools.mdx +6 -0
  10. package/docs/03-ai-sdk-harnesses/02-harness-agent.mdx +42 -0
  11. package/docs/04-ai-sdk-ui/03-chatbot-tool-usage.mdx +29 -0
  12. package/docs/04-ai-sdk-ui/21-error-handling.mdx +10 -4
  13. package/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx +4 -4
  14. package/docs/07-reference/01-ai-sdk-core/16-tool-loop-agent.mdx +10 -8
  15. package/docs/07-reference/02-ai-sdk-ui/42-pipe-ui-message-stream-to-response.mdx +6 -1
  16. package/docs/08-migration-guides/24-migration-guide-6-0.mdx +28 -0
  17. package/package.json +2 -2
  18. package/src/agent/agent.ts +4 -1
  19. package/src/agent/pipe-agent-ui-stream-to-response.ts +1 -1
  20. package/src/generate-object/stream-object-result.ts +4 -1
  21. package/src/generate-object/stream-object.ts +1 -1
  22. package/src/generate-text/generate-text-events.ts +2 -1
  23. package/src/generate-text/stream-text-result.ts +5 -2
  24. package/src/generate-text/stream-text.ts +113 -33
  25. package/src/generate-text/tool-approval-signature.ts +54 -2
  26. package/src/prompt/index.ts +1 -0
  27. package/src/prompt/request-options.ts +20 -2
  28. package/src/text-stream/pipe-text-stream-to-response.ts +3 -2
  29. package/src/ui-message-stream/pipe-ui-message-stream-to-response.ts +3 -2
  30. package/src/util/create-stitchable-stream.ts +41 -15
  31. package/src/util/write-to-server-response.ts +2 -2
@@ -34,6 +34,7 @@ import { prepareTools } from '../prompt/prepare-tools';
34
34
  import type { Prompt } from '../prompt/prompt';
35
35
  import {
36
36
  getChunkTimeoutMs,
37
+ getFirstChunkTimeoutMs,
37
38
  getStepTimeoutMs,
38
39
  getTotalTimeoutMs,
39
40
  type RequestOptions,
@@ -157,28 +158,29 @@ const originalGenerateCallId = createIdGenerator({
157
158
  size: 24,
158
159
  });
159
160
 
160
- // chunk types that count as model output; used to distinguish empty
161
- // incomplete streams from incomplete streams with partial results.
162
- // exhaustive so that new chunk types must be classified explicitly:
161
+ // Chunk types that contain semantic model output. This classification is used
162
+ // for first-content and inter-content timeouts as well as to distinguish empty
163
+ // incomplete streams from incomplete streams with partial results. It is
164
+ // exhaustive so that new chunk types must be classified explicitly.
163
165
  const isOutputChunkType = {
164
166
  file: true,
165
- custom: true,
166
- source: true,
167
- 'text-start': true,
168
- 'text-end': true,
167
+ custom: false,
168
+ source: false,
169
+ 'text-start': false,
170
+ 'text-end': false,
169
171
  'text-delta': true,
170
- 'reasoning-start': true,
171
- 'reasoning-end': true,
172
+ 'reasoning-start': false,
173
+ 'reasoning-end': false,
172
174
  'reasoning-delta': true,
173
175
  'reasoning-file': true,
174
- 'tool-input-start': true,
175
- 'tool-input-end': true,
176
+ 'tool-input-start': false,
177
+ 'tool-input-end': false,
176
178
  'tool-input-delta': true,
177
- 'tool-approval-request': true,
178
- 'tool-approval-response': true,
179
+ 'tool-approval-request': false,
180
+ 'tool-approval-response': false,
179
181
  'tool-call': true,
180
- 'tool-result': true,
181
- 'tool-error': true,
182
+ 'tool-result': false,
183
+ 'tool-error': false,
182
184
  'tool-execution-end': false,
183
185
  'model-call-start': false,
184
186
  'model-call-response-metadata': false,
@@ -187,6 +189,26 @@ const isOutputChunkType = {
187
189
  raw: false,
188
190
  } as const satisfies Record<ExecuteToolsStreamPart['type'], boolean>;
189
191
 
192
+ function isOutputChunk(chunk: ExecuteToolsStreamPart): boolean {
193
+ if (!isOutputChunkType[chunk.type]) {
194
+ return false;
195
+ }
196
+
197
+ switch (chunk.type) {
198
+ case 'text-delta':
199
+ case 'reasoning-delta':
200
+ return chunk.text.length > 0;
201
+ case 'tool-input-delta':
202
+ return chunk.delta.length > 0;
203
+ case 'file':
204
+ case 'reasoning-file':
205
+ case 'tool-call':
206
+ return true;
207
+ default:
208
+ return false;
209
+ }
210
+ }
211
+
190
212
  export type StreamTextInclude = {
191
213
  /**
192
214
  * Whether to retain the request body in step results.
@@ -716,9 +738,12 @@ export function streamText<
716
738
  }): StreamTextResult<TOOLS, RUNTIME_CONTEXT, OUTPUT> {
717
739
  const totalTimeoutMs = getTotalTimeoutMs(timeout);
718
740
  const stepTimeoutMs = getStepTimeoutMs(timeout);
741
+ const firstChunkTimeoutMs = getFirstChunkTimeoutMs(timeout);
719
742
  const chunkTimeoutMs = getChunkTimeoutMs(timeout);
720
743
  const stepAbortController =
721
744
  stepTimeoutMs != null ? new AbortController() : undefined;
745
+ const firstChunkAbortController =
746
+ firstChunkTimeoutMs != null ? new AbortController() : undefined;
722
747
  const chunkAbortController =
723
748
  chunkTimeoutMs != null ? new AbortController() : undefined;
724
749
  const resolvedOnStart = onStart ?? experimental_onStart;
@@ -742,10 +767,13 @@ export function streamText<
742
767
  abortSignal,
743
768
  totalTimeoutMs,
744
769
  stepAbortController?.signal,
770
+ firstChunkAbortController?.signal,
745
771
  chunkAbortController?.signal,
746
772
  ),
747
773
  stepTimeoutMs,
748
774
  stepAbortController,
775
+ firstChunkTimeoutMs,
776
+ firstChunkAbortController,
749
777
  chunkTimeoutMs,
750
778
  chunkAbortController,
751
779
  instructions,
@@ -930,6 +958,10 @@ class DefaultStreamTextResult<
930
958
 
931
959
  private readonly addStream: (
932
960
  stream: ReadableStream<TextStreamPart<TOOLS>>,
961
+ callbacks?: {
962
+ onError?: (error: unknown) => void;
963
+ onCancel?: () => void;
964
+ },
933
965
  ) => void;
934
966
 
935
967
  private readonly closeStream: () => void;
@@ -951,6 +983,8 @@ class DefaultStreamTextResult<
951
983
  abortSignal,
952
984
  stepTimeoutMs,
953
985
  stepAbortController,
986
+ firstChunkTimeoutMs,
987
+ firstChunkAbortController,
954
988
  chunkTimeoutMs,
955
989
  chunkAbortController,
956
990
  instructions,
@@ -1000,6 +1034,8 @@ class DefaultStreamTextResult<
1000
1034
  abortSignal: AbortSignal | undefined;
1001
1035
  stepTimeoutMs: number | undefined;
1002
1036
  stepAbortController: AbortController | undefined;
1037
+ firstChunkTimeoutMs: number | undefined;
1038
+ firstChunkAbortController: AbortController | undefined;
1003
1039
  chunkTimeoutMs: number | undefined;
1004
1040
  chunkAbortController: AbortController | undefined;
1005
1041
  toolsContext: InferToolSetContext<TOOLS>;
@@ -1771,7 +1807,32 @@ class DefaultStreamTextResult<
1771
1807
  timeoutMs: stepTimeoutMs,
1772
1808
  });
1773
1809
 
1774
- // Set up chunk timeout tracking (will be reset on each chunk)
1810
+ // The first-content timeout is armed when the provider response stream
1811
+ // starts and is cleared by the first semantic output chunk.
1812
+ let firstChunkTimeoutId: ReturnType<typeof setTimeout> | undefined =
1813
+ undefined;
1814
+
1815
+ function startFirstChunkTimeout() {
1816
+ if (abortSignal?.aborted) {
1817
+ return;
1818
+ }
1819
+
1820
+ firstChunkTimeoutId = setAbortTimeout({
1821
+ abortController: firstChunkAbortController,
1822
+ label: 'First chunk',
1823
+ timeoutMs: firstChunkTimeoutMs,
1824
+ });
1825
+ }
1826
+
1827
+ function clearFirstChunkTimeout() {
1828
+ if (firstChunkTimeoutId != null) {
1829
+ clearTimeout(firstChunkTimeoutId);
1830
+ firstChunkTimeoutId = undefined;
1831
+ }
1832
+ }
1833
+
1834
+ // Chunk timeout tracking starts after semantic output begins and is
1835
+ // reset only by subsequent semantic output chunks.
1775
1836
  let chunkTimeoutId: ReturnType<typeof setTimeout> | undefined =
1776
1837
  undefined;
1777
1838
 
@@ -1799,12 +1860,24 @@ class DefaultStreamTextResult<
1799
1860
  }
1800
1861
  }
1801
1862
 
1863
+ function clearStepTimeouts() {
1864
+ clearStepTimeout();
1865
+ clearFirstChunkTimeout();
1866
+ clearChunkTimeout();
1867
+ }
1868
+
1869
+ function cleanupStepTimeouts() {
1870
+ abortSignal?.removeEventListener('abort', cleanupStepTimeouts);
1871
+ clearStepTimeouts();
1872
+ }
1873
+
1802
1874
  // The step's stream is registered lazily and consumed long after this
1803
- // function returns, so the step timer must stay armed past setup. When
1804
- // the merged abort signal fires (any step/chunk/total timeout or caller
1805
- // abort), drop both step-scoped timers so neither outlives the step.
1806
- abortSignal?.addEventListener('abort', clearStepTimeout);
1807
- abortSignal?.addEventListener('abort', clearChunkTimeout);
1875
+ // function returns, so its timers must stay armed past setup. When the
1876
+ // merged abort signal fires, drop all step-scoped timers so none
1877
+ // outlives the step.
1878
+ abortSignal?.addEventListener('abort', cleanupStepTimeouts, {
1879
+ once: true,
1880
+ });
1808
1881
 
1809
1882
  try {
1810
1883
  stepFinish = new DelayedPromise<void>();
@@ -1967,6 +2040,8 @@ class DefaultStreamTextResult<
1967
2040
  ),
1968
2041
  );
1969
2042
 
2043
+ startFirstChunkTimeout();
2044
+
1970
2045
  const streamAfterToolCallbackInvocation =
1971
2046
  invokeToolCallbacksFromStream({
1972
2047
  stream: languageModelStream,
@@ -2076,8 +2151,6 @@ class DefaultStreamTextResult<
2076
2151
  TextStreamPart<TOOLS>
2077
2152
  >({
2078
2153
  async transform(chunk, controller): Promise<void> {
2079
- resetChunkTimeout();
2080
-
2081
2154
  if (chunk.type === 'model-call-start') {
2082
2155
  warnings = chunk.warnings;
2083
2156
  return; // stream start chunks are sent immediately and do not count as first chunk
@@ -2096,8 +2169,14 @@ class DefaultStreamTextResult<
2096
2169
 
2097
2170
  const chunkType = chunk.type;
2098
2171
 
2099
- if (isOutputChunkType[chunkType]) {
2172
+ if (isOutputChunk(chunk)) {
2173
+ if (!hasReceivedOutputChunk) {
2174
+ // Clear before forwarding the first output so a timeout
2175
+ // cannot race with already-visible generated content.
2176
+ clearFirstChunkTimeout();
2177
+ }
2100
2178
  hasReceivedOutputChunk = true;
2179
+ resetChunkTimeout();
2101
2180
  }
2102
2181
 
2103
2182
  switch (chunkType) {
@@ -2217,8 +2296,7 @@ class DefaultStreamTextResult<
2217
2296
  }),
2218
2297
  });
2219
2298
 
2220
- clearStepTimeout();
2221
- clearChunkTimeout();
2299
+ cleanupStepTimeouts();
2222
2300
  self.closeStream();
2223
2301
  return;
2224
2302
  }
@@ -2298,9 +2376,8 @@ class DefaultStreamTextResult<
2298
2376
  }
2299
2377
  }
2300
2378
 
2301
- // Clear the step and chunk timeouts before the next step is started
2302
- clearStepTimeout();
2303
- clearChunkTimeout();
2379
+ // Clear this step's timeouts before the next step is started.
2380
+ cleanupStepTimeouts();
2304
2381
 
2305
2382
  if (
2306
2383
  // Continue if:
@@ -2345,12 +2422,15 @@ class DefaultStreamTextResult<
2345
2422
  },
2346
2423
  }),
2347
2424
  ),
2425
+ {
2426
+ onError: cleanupStepTimeouts,
2427
+ onCancel: cleanupStepTimeouts,
2428
+ },
2348
2429
  );
2349
2430
  } catch (error) {
2350
2431
  // Setup failed before the stream was registered, so neither the
2351
2432
  // stream's flush nor an abort will clear the timers — clear them here.
2352
- clearStepTimeout();
2353
- clearChunkTimeout();
2433
+ cleanupStepTimeouts();
2354
2434
  throw error;
2355
2435
  }
2356
2436
  }
@@ -2678,7 +2758,7 @@ class DefaultStreamTextResult<
2678
2758
  ...init
2679
2759
  }: UIMessageStreamResponseInit & UIMessageStreamOptions<UI_MESSAGE> = {},
2680
2760
  ) {
2681
- pipeUIMessageStreamToResponse({
2761
+ return pipeUIMessageStreamToResponse({
2682
2762
  response,
2683
2763
  stream: this.toUIMessageStream({
2684
2764
  originalMessages,
@@ -2696,7 +2776,7 @@ class DefaultStreamTextResult<
2696
2776
  }
2697
2777
 
2698
2778
  pipeTextStreamToResponse(response: ServerResponse, init?: ResponseInit) {
2699
- pipeTextStreamToResponse({
2779
+ return pipeTextStreamToResponse({
2700
2780
  response,
2701
2781
  stream: this.textStream,
2702
2782
  ...init,
@@ -18,11 +18,37 @@ async function importKey(secret: string | Uint8Array): Promise<CryptoKey> {
18
18
  );
19
19
  }
20
20
 
21
+ // Serialize with JSON so the encoding is injective: fields may contain any
22
+ // character (including newlines), and escaping + array structure keeps field
23
+ // boundaries unambiguous. The version prefix provides domain separation.
21
24
  function buildPayload(
22
25
  approvalId: string,
23
26
  toolCallId: string,
24
27
  toolName: string,
25
28
  inputDigest: string,
29
+ ): Uint8Array {
30
+ return encoder.encode(
31
+ JSON.stringify([
32
+ 'ai-sdk-tool-approval-v1',
33
+ approvalId,
34
+ toolCallId,
35
+ toolName,
36
+ inputDigest,
37
+ ]),
38
+ );
39
+ }
40
+
41
+ // Legacy newline-joined payload. Ambiguous when any field contains the `\n`
42
+ // delimiter, which is why it was replaced; only used as a verify-time fallback
43
+ // guarded on delimiter-free fields.
44
+ // TODO(#17494): remove in v8 when backwards compatibility with pre-JSON
45
+ // signatures (approvals signed before the injective format) is no longer
46
+ // needed.
47
+ function buildLegacyPayload(
48
+ approvalId: string,
49
+ toolCallId: string,
50
+ toolName: string,
51
+ inputDigest: string,
26
52
  ): Uint8Array {
27
53
  return encoder.encode(
28
54
  `${approvalId}\n${toolCallId}\n${toolName}\n${inputDigest}`,
@@ -66,9 +92,35 @@ export async function verifyToolApprovalSignature({
66
92
  }): Promise<boolean> {
67
93
  const key = await importKey(secret);
68
94
  const inputDigest = await hashCanonical(input);
69
- const payload = buildPayload(approvalId, toolCallId, toolName, inputDigest);
70
95
  const sigBytes = fromBase64url(signature);
71
- return crypto.subtle.verify('HMAC', key, sigBytes, payload);
96
+
97
+ const payload = buildPayload(approvalId, toolCallId, toolName, inputDigest);
98
+ if (await crypto.subtle.verify('HMAC', key, sigBytes, payload)) {
99
+ return true;
100
+ }
101
+
102
+ // Backwards compatibility: accept a signature produced by the legacy
103
+ // newline-joined format, but only when no field contains the `\n` delimiter
104
+ // — the exact condition that made that format ambiguous. This keeps the
105
+ // retupling collision closed (the attack requires a newline in a field)
106
+ // while still verifying benign approvals signed by an older version, e.g. a
107
+ // pending approval that straddles an upgrade.
108
+ // TODO(#17494): remove in v8 (drop buildLegacyPayload and this fallback).
109
+ if (
110
+ !approvalId.includes('\n') &&
111
+ !toolCallId.includes('\n') &&
112
+ !toolName.includes('\n')
113
+ ) {
114
+ const legacyPayload = buildLegacyPayload(
115
+ approvalId,
116
+ toolCallId,
117
+ toolName,
118
+ inputDigest,
119
+ );
120
+ return crypto.subtle.verify('HMAC', key, sigBytes, legacyPayload);
121
+ }
122
+
123
+ return false;
72
124
  }
73
125
 
74
126
  export async function maybeSignApproval({
@@ -10,6 +10,7 @@ export type CallSettings = LanguageModelCallOptions &
10
10
  export {
11
11
  getTotalTimeoutMs,
12
12
  getStepTimeoutMs,
13
+ getFirstChunkTimeoutMs,
13
14
  getChunkTimeoutMs,
14
15
  getToolTimeoutMs,
15
16
  } from './request-options';
@@ -5,7 +5,8 @@ import type { ToolSet } from '@ai-sdk/provider-utils';
5
5
  * - A number representing milliseconds
6
6
  * - An object with `totalMs` property for the total timeout in milliseconds
7
7
  * - An object with `stepMs` property for the timeout of each step in milliseconds
8
- * - An object with `chunkMs` property for the timeout between stream chunks (streaming only)
8
+ * - An object with `firstChunkMs` property for the timeout until the first content chunk of each step (streaming only)
9
+ * - An object with `chunkMs` property for the timeout between content chunks (streaming only)
9
10
  * - An object with `toolMs` property for the default timeout for all tool executions
10
11
  * - An object with `tools` property for per-tool timeout overrides using `{toolName}Ms` keys
11
12
  */
@@ -14,6 +15,7 @@ export type TimeoutConfiguration<TOOLS extends ToolSet> =
14
15
  | {
15
16
  totalMs?: number;
16
17
  stepMs?: number;
18
+ firstChunkMs?: number;
17
19
  chunkMs?: number;
18
20
  toolMs?: number;
19
21
  tools?: Partial<Record<`${keyof TOOLS & string}Ms`, number>>;
@@ -52,9 +54,25 @@ export function getStepTimeoutMs(
52
54
  return timeout.stepMs;
53
55
  }
54
56
 
57
+ /**
58
+ * Extracts the first chunk timeout value in milliseconds from a TimeoutConfiguration.
59
+ * This timeout is for streaming only - it aborts if no content chunk is received within the specified duration.
60
+ *
61
+ * @param timeout - The timeout configuration.
62
+ * @returns The first chunk timeout in milliseconds, or undefined if no first chunk timeout is configured.
63
+ */
64
+ export function getFirstChunkTimeoutMs(
65
+ timeout: TimeoutConfiguration<any> | undefined,
66
+ ): number | undefined {
67
+ if (timeout == null || typeof timeout === 'number') {
68
+ return undefined;
69
+ }
70
+ return timeout.firstChunkMs;
71
+ }
72
+
55
73
  /**
56
74
  * Extracts the chunk timeout value in milliseconds from a TimeoutConfiguration.
57
- * This timeout is for streaming only - it aborts if no new chunk is received within the specified duration.
75
+ * This timeout is for streaming only - it aborts if no new content chunk is received within the specified duration.
58
76
  *
59
77
  * @param timeout - The timeout configuration.
60
78
  * @returns The chunk timeout in milliseconds, or undefined if no chunk timeout is configured.
@@ -13,6 +13,7 @@ import { writeToServerResponse } from '../util/write-to-server-response';
13
13
  * @param options.statusText - Optional HTTP status text.
14
14
  * @param options.headers - Optional response headers.
15
15
  * @param options.stream - The text stream to pipe.
16
+ * @returns A promise that resolves when the stream has been written.
16
17
  */
17
18
  export function pipeTextStreamToResponse({
18
19
  response,
@@ -23,8 +24,8 @@ export function pipeTextStreamToResponse({
23
24
  }: {
24
25
  response: ServerResponse;
25
26
  stream: ReadableStream<string>;
26
- } & ResponseInit): void {
27
- writeToServerResponse({
27
+ } & ResponseInit): Promise<void> {
28
+ return writeToServerResponse({
28
29
  response,
29
30
  status,
30
31
  statusText,
@@ -16,6 +16,7 @@ import type { UIMessageStreamResponseInit } from './ui-message-stream-response-i
16
16
  * @param options.headers - Additional HTTP headers to include in the response.
17
17
  * @param options.stream - The UI message chunk stream to send.
18
18
  * @param options.consumeSseStream - Optional callback to consume a copy of the SSE stream independently.
19
+ * @returns A promise that resolves when the stream has been written.
19
20
  */
20
21
  export function pipeUIMessageStreamToResponse({
21
22
  response,
@@ -27,7 +28,7 @@ export function pipeUIMessageStreamToResponse({
27
28
  }: {
28
29
  response: ServerResponse;
29
30
  stream: ReadableStream<UIMessageChunk>;
30
- } & UIMessageStreamResponseInit): void {
31
+ } & UIMessageStreamResponseInit): Promise<void> {
31
32
  let sseStream = stream.pipeThrough(new JsonToSseTransformStream());
32
33
 
33
34
  // when the consumeSseStream is provided, we need to tee the stream
@@ -39,7 +40,7 @@ export function pipeUIMessageStreamToResponse({
39
40
  consumeSseStream({ stream: stream2 }); // no await (do not block the response)
40
41
  }
41
42
 
42
- writeToServerResponse({
43
+ return writeToServerResponse({
43
44
  response,
44
45
  status,
45
46
  statusText,
@@ -8,11 +8,21 @@ import { createResolvablePromise } from './create-resolvable-promise';
8
8
  */
9
9
  export function createStitchableStream<T>(): {
10
10
  stream: ReadableStream<T>;
11
- addStream: (innerStream: ReadableStream<T>) => void;
11
+ addStream: (
12
+ innerStream: ReadableStream<T>,
13
+ callbacks?: {
14
+ onError?: (error: unknown) => void;
15
+ onCancel?: () => void;
16
+ },
17
+ ) => void;
12
18
  close: () => void;
13
19
  terminate: () => void;
14
20
  } {
15
- let innerStreamReaders: ReadableStreamDefaultReader<T>[] = [];
21
+ let innerStreams: Array<{
22
+ reader: ReadableStreamDefaultReader<T>;
23
+ onError?: (error: unknown) => void;
24
+ onCancel?: () => void;
25
+ }> = [];
16
26
  let controller: ReadableStreamDefaultController<T> | null = null;
17
27
  let isClosed = false;
18
28
  let waitForNewStream = createResolvablePromise<void>();
@@ -21,34 +31,39 @@ export function createStitchableStream<T>(): {
21
31
  isClosed = true;
22
32
  waitForNewStream.resolve();
23
33
 
24
- innerStreamReaders.forEach(reader => reader.cancel());
25
- innerStreamReaders = [];
34
+ innerStreams.forEach(({ reader, onCancel }) => {
35
+ onCancel?.();
36
+ reader.cancel();
37
+ });
38
+ innerStreams = [];
26
39
  controller?.close();
27
40
  };
28
41
 
29
42
  const processPull = async () => {
30
43
  // Case 1: Outer stream is closed and no more inner streams
31
- if (isClosed && innerStreamReaders.length === 0) {
44
+ if (isClosed && innerStreams.length === 0) {
32
45
  controller?.close();
33
46
  return;
34
47
  }
35
48
 
36
49
  // Case 2: No inner streams available, but outer stream is open
37
50
  // wait for a new inner stream to be added or the outer stream to close
38
- if (innerStreamReaders.length === 0) {
51
+ if (innerStreams.length === 0) {
39
52
  waitForNewStream = createResolvablePromise<void>();
40
53
  await waitForNewStream.promise;
41
54
  return await processPull();
42
55
  }
43
56
 
57
+ const currentStream = innerStreams[0];
58
+
44
59
  try {
45
- const { value, done } = await innerStreamReaders[0].read();
60
+ const { value, done } = await currentStream.reader.read();
46
61
 
47
62
  if (done) {
48
63
  // Case 3: Current inner stream is done
49
- innerStreamReaders.shift(); // Remove the finished stream
64
+ innerStreams.shift(); // Remove the finished stream
50
65
 
51
- if (innerStreamReaders.length === 0 && isClosed) {
66
+ if (innerStreams.length === 0 && isClosed) {
52
67
  // when closed and no more inner streams, stop pulling
53
68
  controller?.close();
54
69
  } else {
@@ -61,8 +76,9 @@ export function createStitchableStream<T>(): {
61
76
  }
62
77
  } catch (error) {
63
78
  // Case 5: Current inner stream throws an error
79
+ currentStream.onError?.(error);
64
80
  controller?.error(error);
65
- innerStreamReaders.shift(); // Remove the errored stream
81
+ innerStreams.shift(); // Remove the errored stream
66
82
  terminate(); // we have errored, terminate all streams
67
83
  }
68
84
  };
@@ -74,19 +90,29 @@ export function createStitchableStream<T>(): {
74
90
  },
75
91
  pull: processPull,
76
92
  async cancel() {
77
- for (const reader of innerStreamReaders) {
93
+ for (const { reader, onCancel } of innerStreams) {
94
+ onCancel?.();
78
95
  await reader.cancel();
79
96
  }
80
- innerStreamReaders = [];
97
+ innerStreams = [];
81
98
  isClosed = true;
82
99
  },
83
100
  }),
84
- addStream: (innerStream: ReadableStream<T>) => {
101
+ addStream: (
102
+ innerStream: ReadableStream<T>,
103
+ callbacks?: {
104
+ onError?: (error: unknown) => void;
105
+ onCancel?: () => void;
106
+ },
107
+ ) => {
85
108
  if (isClosed) {
86
109
  throw new Error('Cannot add inner stream: outer stream is closed');
87
110
  }
88
111
 
89
- innerStreamReaders.push(innerStream.getReader());
112
+ innerStreams.push({
113
+ reader: innerStream.getReader(),
114
+ ...callbacks,
115
+ });
90
116
  waitForNewStream.resolve();
91
117
  },
92
118
 
@@ -98,7 +124,7 @@ export function createStitchableStream<T>(): {
98
124
  isClosed = true;
99
125
  waitForNewStream.resolve();
100
126
 
101
- if (innerStreamReaders.length === 0) {
127
+ if (innerStreams.length === 0) {
102
128
  controller?.close();
103
129
  }
104
130
  },
@@ -19,7 +19,7 @@ export function writeToServerResponse({
19
19
  statusText?: string;
20
20
  headers?: Record<string, string | number | string[]>;
21
21
  stream: ReadableStream<Uint8Array>;
22
- }): void {
22
+ }): Promise<void> {
23
23
  const statusCode = status ?? 200;
24
24
  if (statusText !== undefined) {
25
25
  response.writeHead(statusCode, statusText, headers);
@@ -54,5 +54,5 @@ export function writeToServerResponse({
54
54
  }
55
55
  };
56
56
 
57
- read();
57
+ return read();
58
58
  }