ai-resumable-stream 1.1.1 → 1.3.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.
package/README.md CHANGED
@@ -38,7 +38,7 @@ sequenceDiagram
38
38
  Server->>Server: streamText()
39
39
  Server->>Redis: Subscribe to stop channel
40
40
  Server->>Redis: Subscribe to stream channel
41
- Server->>Redis: Store chunks
41
+ Server->>Redis: Store chunks (UI-to-SSE conversion)
42
42
  Server-->>Client: Stream chunks
43
43
  end
44
44
 
@@ -47,9 +47,9 @@ sequenceDiagram
47
47
  Client->>Server: resumeMessage()
48
48
  Server->>Redis: Subscribe to stream channel
49
49
  Redis-->>Server: Replay stored chunks
50
- Server-->>Client: Stream past chunks
50
+ Server-->>Client: Stream past chunks (SSE-to-UI conversion)
51
51
  Redis-->>Server: Receive new chunks
52
- Server-->>Client: Stream new chunks
52
+ Server-->>Client: Stream new chunks (SSE-to-UI conversion)
53
53
  end
54
54
 
55
55
  rect rgb(255, 248, 240)
@@ -309,6 +309,7 @@ await subscriber.quit();
309
309
  async function createResumableUIMessageStream(options: CreateResumableUIMessageStream): Promise<{
310
310
  startStream: (
311
311
  stream: ReadableStream<UIMessageChunk>,
312
+ options?: { onFlush?: () => void | Promise<void> },
312
313
  ) => Promise<AsyncIterableStream<UIMessageChunk>>;
313
314
  resumeStream: () => Promise<AsyncIterableStream<UIMessageChunk> | null>;
314
315
  stopStream: () => Promise<void>;
@@ -330,10 +331,13 @@ type CreateResumableUIMessageStream = {
330
331
  ```typescript
331
332
  async function startStream(
332
333
  stream: ReadableStream<UIMessageChunk>,
334
+ options?: { onFlush?: () => void | Promise<void> },
333
335
  ): Promise<AsyncIterableStream<UIMessageChunk>>;
334
336
  ```
335
337
 
336
- Starts a new resumable stream. The input stream is tee'd—one branch goes to the client, the other is persisted to Redis.
338
+ Starts a new resumable stream. A single drain loop reads from the source and sends chunks to both the client and Redis simultaneously. If the client disconnects, chunks continue flowing to Redis for resumability.
339
+
340
+ The optional `onFlush` callback is invoked after the stream finishes draining to Redis and cleanup is complete, regardless of how the stream ended (complete, error, or abort). Use it for cleanup tasks like removing the active stream ID from the database. Errors thrown by `onFlush` are silently caught.
337
341
 
338
342
  #### `resumeStream`
339
343
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { AsyncIterableStream } from "ai-stream-utils/utils";
2
1
  import { UIMessageChunk } from "ai";
2
+ import { AsyncIterableStream } from "ai-stream-utils";
3
3
  import { createClient } from "redis";
4
4
 
5
5
  //#region src/resumable-ui-message-stream.d.ts
@@ -35,9 +35,14 @@ type CreateResumableUIMessageStream = {
35
35
  };
36
36
  /**
37
37
  * Creates a resumable context for starting, resuming and stopping UI message streams.
38
+ *
39
+ * Leverages resumable-stream's internal eager drain pattern which drains the source
40
+ * eagerly and enqueues directly to the output stream.
38
41
  */
39
42
  declare function createResumableUIMessageStream(options: CreateResumableUIMessageStream): Promise<{
40
- startStream: (stream: ReadableStream<UIMessageChunk>) => Promise<AsyncIterableStream<UIMessageChunk>>;
43
+ startStream: (stream: ReadableStream<UIMessageChunk>, options?: {
44
+ onFlush?: () => void | Promise<void>;
45
+ }) => Promise<AsyncIterableStream<UIMessageChunk>>;
41
46
  resumeStream: () => Promise<AsyncIterableStream<UIMessageChunk> | null>;
42
47
  stopStream: () => Promise<void>;
43
48
  }>;
package/dist/index.mjs CHANGED
@@ -1,10 +1,33 @@
1
- import { convertSSEToUIMessageStream, convertUIMessageToSSEStream, createAsyncIterableStream } from "ai-stream-utils/utils";
1
+ import { JsonToSseTransformStream, parseJsonEventStream, uiMessageChunkSchema } from "ai";
2
+ import { createAsyncIterableStream } from "ai-stream-utils";
2
3
  import { createResumableStreamContext } from "resumable-stream";
3
4
 
5
+ //#region src/convert-sse-stream-to-ui-message-stream.ts
6
+ /**
7
+ * Converts an SSE stream to a UI message stream.
8
+ */
9
+ function convertSSEToUIMessageStream(stream, onFlush) {
10
+ return parseJsonEventStream({
11
+ stream: stream.pipeThrough(new TextEncoderStream()),
12
+ schema: uiMessageChunkSchema
13
+ }).pipeThrough(new TransformStream({
14
+ transform(result, controller) {
15
+ if (result.success) controller.enqueue(result.value);
16
+ },
17
+ flush() {
18
+ onFlush?.();
19
+ }
20
+ }));
21
+ }
22
+
23
+ //#endregion
4
24
  //#region src/resumable-ui-message-stream.ts
5
25
  const KEY_PREFIX = `ai-resumable-stream`;
6
26
  /**
7
27
  * Creates a resumable context for starting, resuming and stopping UI message streams.
28
+ *
29
+ * Leverages resumable-stream's internal eager drain pattern which drains the source
30
+ * eagerly and enqueues directly to the output stream.
8
31
  */
9
32
  async function createResumableUIMessageStream(options) {
10
33
  const { streamId, abortController, publisher, subscriber, waitUntil = null } = options;
@@ -39,25 +62,90 @@ async function createResumableUIMessageStream(options) {
39
62
  }
40
63
  /**
41
64
  * Start a new stream by creating a new resumable stream in Redis and returning a client stream for the UI.
65
+ *
66
+ * Uses a single drain loop that:
67
+ * 1. Reads from source stream
68
+ * 2. Sends UIMessageChunk directly to client stream (no conversion)
69
+ * 3. Sends SSE to Redis stream → resumable-stream → Redis
70
+ * 4. Propagates errors to both streams
42
71
  */
43
- async function startStream(stream) {
72
+ async function startStream(stream, options) {
73
+ const { onFlush } = options ?? {};
44
74
  /**
45
- * Tee the stream into two streams: one for the client and one for the resumable stream in Redis.
75
+ * Track client disconnect to avoid unbounded memory growth
46
76
  */
47
- const [clientStream, resumableStream] = stream.tee();
48
- const sseStream = convertUIMessageToSSEStream(resumableStream);
77
+ let clientCancelled = false;
49
78
  /**
50
- * Create a new resumable stream in Redis with the stream ID.
79
+ * Client stream for sending UI message chunks directly to the client without conversion.
51
80
  */
52
- await context.createNewResumableStream(streamId, () => sseStream);
53
- return createAsyncIterableStream(clientStream.pipeThrough(new TransformStream({
54
- transform(chunk, controller) {
55
- controller.enqueue(chunk);
81
+ let clientController;
82
+ const clientStream = new ReadableStream({
83
+ start(controller) {
84
+ clientController = controller;
56
85
  },
57
- flush() {
58
- unsubscribe();
86
+ cancel() {
87
+ clientCancelled = true;
88
+ }
89
+ });
90
+ /**
91
+ * Redis stream with SSE conversion for resumable-stream persistence in Redis.
92
+ * JsonToSseTransformStream converts UIMessageChunk → SSE string and adds [DONE] on flush.
93
+ */
94
+ let redisController;
95
+ const redisStream = new ReadableStream({ start(controller) {
96
+ redisController = controller;
97
+ } }).pipeThrough(new JsonToSseTransformStream());
98
+ /**
99
+ * Get reader synchronously to fail fast if stream is locked
100
+ */
101
+ const reader = stream.getReader();
102
+ /**
103
+ * Register Redis stream with resumable-stream for persistence.
104
+ * Release reader lock if registration fails to avoid locking the source stream.
105
+ */
106
+ try {
107
+ await context.createNewResumableStream(streamId, () => redisStream);
108
+ } catch (error) {
109
+ reader.releaseLock();
110
+ throw error;
111
+ }
112
+ /**
113
+ * Single drain loop.
114
+ * Continues draining to Redis even if client disconnects
115
+ */
116
+ (async () => {
117
+ try {
118
+ while (true) {
119
+ const { done, value } = await reader.read();
120
+ if (done) {
121
+ redisController.close();
122
+ if (!clientCancelled) clientController.close();
123
+ break;
124
+ }
125
+ /**
126
+ * Always enqueue to Redis for persistence
127
+ */
128
+ redisController.enqueue(value);
129
+ /**
130
+ * Only enqueue to client if still connected to avoid unbounded memory growth.
131
+ *
132
+ */
133
+ if (!clientCancelled) clientController.enqueue(value);
134
+ }
135
+ } catch (error) {
136
+ redisController.error(error);
137
+ if (!clientCancelled) clientController.error(error);
138
+ } finally {
139
+ reader.releaseLock();
140
+ try {
141
+ await unsubscribe();
142
+ } catch {}
143
+ try {
144
+ await onFlush?.();
145
+ } catch {}
59
146
  }
60
- })));
147
+ })();
148
+ return createAsyncIterableStream(clientStream);
61
149
  }
62
150
  /**
63
151
  * Resume an existing stream by fetching the resumable stream from Redis using the stream ID.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-resumable-stream",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "AI SDK: resume and stop UI message streams",
5
5
  "keywords": [
6
6
  "ai",
@@ -26,7 +26,7 @@
26
26
  "./package.json": "./package.json"
27
27
  },
28
28
  "dependencies": {
29
- "ai-stream-utils": "^1.6.0",
29
+ "ai-stream-utils": "^2.0.0",
30
30
  "resumable-stream": "^2.2.10"
31
31
  },
32
32
  "devDependencies": {
@@ -38,6 +38,7 @@
38
38
  "ai": "^6.0.79",
39
39
  "husky": "^9.1.7",
40
40
  "lint-staged": "^16.2.7",
41
+ "mitata": "^1.0.34",
41
42
  "oxfmt": "^0.31.0",
42
43
  "oxlint": "^1.46.0",
43
44
  "pkg-pr-new": "^0.0.63",
@@ -47,7 +48,8 @@
47
48
  "tsdown": "^0.20.3",
48
49
  "tsx": "^4.21.0",
49
50
  "typescript": "^5.9.3",
50
- "vitest": "^4.0.18"
51
+ "vitest": "^4.0.18",
52
+ "zod": "^4.3.6"
51
53
  },
52
54
  "peerDependencies": {
53
55
  "ai": "^6.0.0",