ai-resumable-stream 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Chris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ <div align='center'>
2
+
3
+ # ai-resumable-stream
4
+
5
+ <p align="center">AI SDK: Resume and stop UI message streams</p>
6
+ <p align="center">
7
+ <a href="https://www.npmjs.com/package/ai-resumable-stream" alt="ai-resumable-stream"><img src="https://img.shields.io/npm/dt/ai-resumable-stream?label=ai-resumable-stream"></a> <a href="https://github.com/zirkelc/ai-resumable-stream/actions/workflows/ci.yml" alt="CI"><img src="https://img.shields.io/github/actions/workflow/status/zirkelc/ai-resumable-stream/ci.yml?branch=main"></a>
8
+ </p>
9
+
10
+ </div>
11
+
12
+ This library provides resumable streaming for UI message streams created by [`streamText()`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) in the AI SDK. It uses Redis to persist stream data, allowing clients to resume interrupted streams or stop active streams from anywhere.
13
+
14
+ **Why?**
15
+
16
+ Streams are ephemeral — once data flows through, it's gone. This creates two hard problems:
17
+
18
+ **Resume is hard** because the server doesn't track what's been sent. If a client disconnects (network issue, page reload, tab switch), the stream keeps running on the server but the client loses all that data. When reconnecting, there's no way to replay missed chunks without persisting them somewhere.
19
+
20
+ **Stop is hard** because the client requesting "stop" isn't the same request that started the stream. The user clicks "Stop generating", which fires a new HTTP request, but the original stream is running in a different request/process. Without a central coordination point, you can't signal across requests.
21
+
22
+ This library implements resumable streams with Redis to support both:
23
+
24
+ - **Resuming**: Chunks are stored as they arrive, enabling replay on reconnect
25
+ - **Stopping**: Stop signals are broadcast to any process running the stream
26
+
27
+ ## How It Works
28
+
29
+ ```mermaid
30
+ sequenceDiagram
31
+ participant Client
32
+ participant Server
33
+ participant Redis
34
+
35
+ rect rgb(240, 248, 255)
36
+ Note over Client,Redis: Start Stream
37
+ Client->>Server: sendMessage()
38
+ Server->>Server: streamText()
39
+ Server->>Redis: Subscribe to stop channel
40
+ Server->>Redis: Subscribe to stream channel
41
+ Server->>Redis: Store chunks
42
+ Server-->>Client: Stream chunks
43
+ end
44
+
45
+ rect rgb(240, 255, 240)
46
+ Note over Client,Redis: Resume Stream
47
+ Client->>Server: resumeMessage()
48
+ Server->>Redis: Subscribe to stream channel
49
+ Redis-->>Server: Replay stored chunks
50
+ Server-->>Client: Stream past chunks
51
+ Redis-->>Server: Receive new chunks
52
+ Server-->>Client: Stream new chunks
53
+ end
54
+
55
+ rect rgb(255, 248, 240)
56
+ Note over Client,Redis: Stop Stream
57
+ Client->>Server: stopStream()
58
+ Server->>Redis: Publish to stop channel
59
+ Redis-->>Server: Notify subscriber
60
+ Server->>Server: AbortController.abort()
61
+ end
62
+ ```
63
+
64
+ ## Installation
65
+
66
+ This library requires a [Redis](https://github.com/redis/node-redis) client.
67
+
68
+ ```bash
69
+ npm install ai-resumable-stream redis
70
+ ```
71
+
72
+ ## Usage
73
+
74
+ The library requires two Redis clients (pub/sub needs separate connections). The clients will connect automatically, if not already connected, but the library won't disconnect them afterwards. That means you can manage the connection lifecycle in your application and reuse clients across multiple streams.
75
+
76
+ ```typescript
77
+ import { createClient } from "redis";
78
+ import { createResumableUIMessageStream } from "ai-resumable-stream";
79
+
80
+ const publisher = createClient({ url: process.env.REDIS_URL });
81
+ const subscriber = createClient({ url: process.env.REDIS_URL });
82
+
83
+ // Optional: connect clients immediately or let the library connect on demand
84
+ await publisher.connect();
85
+ await subscriber.connect();
86
+
87
+ const context = await createResumableUIMessageStream({
88
+ streamId: `stream-123`,
89
+ publisher,
90
+ subscriber,
91
+ });
92
+ ```
93
+
94
+ ### `startStream`
95
+
96
+ Start a new stream and persist chunks to Redis. Returns a client stream that can be consumed immediately.
97
+
98
+ > [!TIP]
99
+ > The stream returned by `startStream` is both a readable stream and an async iterable.
100
+ > That means you can use `return stream` or `yield* stream`.
101
+
102
+ ```typescript
103
+ import { streamText } from "ai";
104
+
105
+ async function sendMessage() {
106
+ // Optional: create AbortController to enable stopStream
107
+ const abortController = new AbortController();
108
+
109
+ // Create resumable stream context
110
+ const context = await createResumableUIMessageStream({
111
+ streamId: `stream-123`,
112
+ publisher,
113
+ subscriber,
114
+ abortController,
115
+ });
116
+
117
+ const result = streamText({
118
+ model: `gpt-4o`,
119
+ prompt: `Tell me a story`,
120
+ // Optional: pass abort signal to enable stopping
121
+ abortSignal: abortController.signal,
122
+ });
123
+
124
+ // Start streaming - chunks are stored in Redis as they arrive
125
+ const stream = await context.startStream(result.toUIMessageStream());
126
+
127
+ // Return stream to client
128
+ return stream;
129
+ }
130
+ ```
131
+
132
+ ### `resumeStream`
133
+
134
+ Resume an existing stream from Redis. Returns all past chunks followed by any remaining chunks, or `null` if no active stream exists.
135
+
136
+ > [!TIP]
137
+ > The stream returned by `resumeStream` is both a readable stream and an async iterable.
138
+ > That means you can use `return stream` or `yield* stream`.
139
+
140
+ ```typescript
141
+ async function resumeMessage() {
142
+ // Create resumable stream context
143
+ const context = await createResumableUIMessageStream({
144
+ streamId: `stream-123`,
145
+ publisher,
146
+ subscriber,
147
+ });
148
+
149
+ // Try to resume an existing stream
150
+ const stream = await context.resumeStream();
151
+
152
+ // If no stream exists, return early
153
+ if (!stream) {
154
+ console.log("No active stream to resume");
155
+ return;
156
+ }
157
+
158
+ // Return resumed stream to client
159
+ return stream;
160
+ }
161
+ ```
162
+
163
+ ### `stopStream`
164
+
165
+ Stop an active stream from any client. Requires an `AbortController` to be passed when creating the context.
166
+
167
+ ```typescript
168
+ async function stopMessage() {
169
+ const context = await createResumableUIMessageStream({
170
+ streamId: `stream-123`,
171
+ publisher,
172
+ subscriber,
173
+ });
174
+
175
+ await context.stopStream();
176
+ }
177
+ ```
178
+
179
+ ## Examples
180
+
181
+ ### tRPC
182
+
183
+ Server-side tRPC procedures for sending, resuming, and stopping streams:
184
+
185
+ ```typescript
186
+ // server/router.ts
187
+ import { z } from "zod";
188
+ import { streamText, type UIMessage, type UIMessageChunk } from "ai";
189
+ import { createClient } from "redis";
190
+ import { createResumableUIMessageStream } from "ai-resumable-stream";
191
+ import { publicProcedure, router } from "./trpc";
192
+
193
+ const publisher = createClient({ url: process.env.REDIS_URL });
194
+ const subscriber = createClient({ url: process.env.REDIS_URL });
195
+
196
+ export const appRouter = router({
197
+ sendMessage: publicProcedure
198
+ .input(z.object({ chatId: z.string(), message: z.custom<UIMessage>() }))
199
+ .mutation(async function* ({ input }): AsyncGenerator<UIMessageChunk> {
200
+ const { chatId, message } = input;
201
+
202
+ // TODO: Generate and save active stream ID for the chat
203
+ const activeStreamId = randomUUID();
204
+ await saveChat({ chatId, activeStreamId });
205
+
206
+ const abortController = new AbortController();
207
+
208
+ const context = await createResumableUIMessageStream({
209
+ streamId: activeStreamId,
210
+ publisher,
211
+ subscriber,
212
+ abortController,
213
+ });
214
+
215
+ const result = streamText({
216
+ model: openai("gpt-4o"),
217
+ messages: [message],
218
+ abortSignal: abortController.signal,
219
+ onFinish: async () => {
220
+ // TODO: Clear the active stream when finished
221
+ await saveChat({ chatId, activeStreamId: null });
222
+ },
223
+ });
224
+
225
+ const stream = await context.startStream(result.toUIMessageStream());
226
+
227
+ yield* stream;
228
+ }),
229
+
230
+ resumeMessage: publicProcedure.input(z.object({ chatId: z.string() })).mutation(async function* ({
231
+ input,
232
+ }): AsyncGenerator<UIMessageChunk> {
233
+ const { chatId } = input;
234
+
235
+ // TODO: Get active stream ID for the chat
236
+ const { activeStreamId } = await getChat(chatId);
237
+
238
+ const context = await createResumableUIMessageStream({
239
+ streamId: activeStreamId,
240
+ publisher,
241
+ subscriber,
242
+ });
243
+
244
+ const stream = await context.resumeStream();
245
+ if (!stream) return;
246
+
247
+ yield* stream;
248
+ }),
249
+
250
+ stopStream: publicProcedure
251
+ .input(z.object({ chatId: z.string() }))
252
+ .mutation(async ({ input }) => {
253
+ const { chatId } = input;
254
+
255
+ // TODO: Get active stream ID for the chat
256
+ const { activeStreamId } = await getChat(chatId);
257
+
258
+ const context = await createResumableUIMessageStream({
259
+ streamId: activeStreamId,
260
+ publisher,
261
+ subscriber,
262
+ });
263
+
264
+ await context.stopStream();
265
+
266
+ return { success: true };
267
+ }),
268
+ });
269
+ ```
270
+
271
+ ## Configuration
272
+
273
+ ### Options
274
+
275
+ | Option | Type | Required | Description |
276
+ | ----------------- | ---------------------------- | -------- | -------------------------------------------------------------- |
277
+ | `streamId` | `string` | Yes | Unique identifier for the stream |
278
+ | `publisher` | `Redis` | Yes | Redis client for publishing |
279
+ | `subscriber` | `Redis` | Yes | Redis client for subscribing (must be separate from publisher) |
280
+ | `abortController` | `AbortController` | No | Controller to enable `stopStream` functionality |
281
+ | `waitUntil` | `(promise: Promise) => void` | No | Keep serverless function alive until stream completes |
282
+
283
+ ### Redis Connection
284
+
285
+ The library automatically connects Redis clients if they're not already connected. Clients are **not** disconnected after stream completion.
286
+
287
+ ```typescript
288
+ // Clients can be connected or disconnected
289
+ const publisher = createClient({ url: redisUrl });
290
+ const subscriber = createClient({ url: redisUrl });
291
+
292
+ // Library connects if needed
293
+ const context = await createResumableUIMessageStream({
294
+ streamId: "stream-123",
295
+ publisher, // Will connect if not already connected
296
+ subscriber, // Will connect if not already connected
297
+ });
298
+
299
+ // Manage disconnection yourself when appropriate
300
+ await publisher.quit();
301
+ await subscriber.quit();
302
+ ```
303
+
304
+ ## API Reference
305
+
306
+ ### `createResumableUIMessageStream`
307
+
308
+ ```typescript
309
+ async function createResumableUIMessageStream(options: CreateResumableUIMessageStream): Promise<{
310
+ startStream: (
311
+ stream: ReadableStream<UIMessageChunk>,
312
+ ) => Promise<AsyncIterableStream<UIMessageChunk>>;
313
+ resumeStream: () => Promise<AsyncIterableStream<UIMessageChunk> | null>;
314
+ stopStream: () => Promise<void>;
315
+ }>;
316
+
317
+ type CreateResumableUIMessageStream = {
318
+ streamId: string;
319
+ publisher: Redis;
320
+ subscriber: Redis;
321
+ abortController?: AbortController;
322
+ waitUntil?: (promise: Promise<unknown>) => void;
323
+ };
324
+ ```
325
+
326
+ ### Return Values
327
+
328
+ #### `startStream`
329
+
330
+ ```typescript
331
+ async function startStream(
332
+ stream: ReadableStream<UIMessageChunk>,
333
+ ): Promise<AsyncIterableStream<UIMessageChunk>>;
334
+ ```
335
+
336
+ Starts a new resumable stream. The input stream is tee'd—one branch goes to the client, the other is persisted to Redis.
337
+
338
+ #### `resumeStream`
339
+
340
+ ```typescript
341
+ async function resumeStream(): Promise<AsyncIterableStream<UIMessageChunk> | null>;
342
+ ```
343
+
344
+ Resumes an existing stream. Returns `null` if:
345
+
346
+ - No stream exists for the given `streamId`
347
+ - The stream has already completed
348
+ - The stream TTL has expired
349
+
350
+ #### `stopStream`
351
+
352
+ ```typescript
353
+ async function stopStream(): Promise<void>;
354
+ ```
355
+
356
+ Publishes a stop message via Redis pub/sub. If an `abortController` was provided during creation, the stream's `AbortController.abort()` is called, ending the stream with an `abort` chunk.
package/dist/index.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import { convertSSEToUIMessageStream, convertUIMessageToSSEStream, createAsyncIterableStream } from "ai-stream-utils/utils";
2
+ import { createResumableStreamContext } from "resumable-stream";
3
+
4
+ //#region src/resumable-ui-message-stream.ts
5
+ const KEY_PREFIX = `ai-resumable-stream`;
6
+ /**
7
+ * Creates a resumable context for starting, resuming and stopping UI message streams.
8
+ */
9
+ async function createResumableUIMessageStream(options) {
10
+ const { streamId, abortController, publisher, subscriber, waitUntil = null } = options;
11
+ const stopChannel = `${KEY_PREFIX}:stop:${streamId}`;
12
+ const context = createResumableStreamContext({
13
+ waitUntil,
14
+ publisher,
15
+ subscriber,
16
+ keyPrefix: KEY_PREFIX
17
+ });
18
+ await Promise.all([publisher.isOpen ? Promise.resolve() : publisher.connect(), subscriber.isOpen ? Promise.resolve() : subscriber.connect()]);
19
+ /**
20
+ * Unsubscribe from stop channel
21
+ */
22
+ async function unsubscribe() {
23
+ if (!abortController) return;
24
+ await subscriber.unsubscribe(stopChannel);
25
+ }
26
+ /**
27
+ * Set up stop subscription if abortController provided
28
+ */
29
+ if (abortController) {
30
+ await subscriber.subscribe(stopChannel, () => {
31
+ abortController.abort();
32
+ });
33
+ /**
34
+ * Cleanup when abort signal fires
35
+ */
36
+ abortController.signal.addEventListener(`abort`, () => {
37
+ unsubscribe();
38
+ }, { once: true });
39
+ }
40
+ /**
41
+ * Start a new stream by creating a new resumable stream in Redis and returning a client stream for the UI.
42
+ */
43
+ async function startStream(stream) {
44
+ /**
45
+ * Tee the stream into two streams: one for the client and one for the resumable stream in Redis.
46
+ */
47
+ const [clientStream, resumableStream] = stream.tee();
48
+ const sseStream = convertUIMessageToSSEStream(resumableStream);
49
+ /**
50
+ * Create a new resumable stream in Redis with the stream ID.
51
+ */
52
+ await context.createNewResumableStream(streamId, () => sseStream);
53
+ return createAsyncIterableStream(clientStream.pipeThrough(new TransformStream({
54
+ transform(chunk, controller) {
55
+ controller.enqueue(chunk);
56
+ },
57
+ flush() {
58
+ unsubscribe();
59
+ }
60
+ })));
61
+ }
62
+ /**
63
+ * Resume an existing stream by fetching the resumable stream from Redis using the stream ID.
64
+ */
65
+ async function resumeStream() {
66
+ /**
67
+ * Resume the existing stream from Redis using the stream ID.
68
+ */
69
+ const resumableStream = await context.resumeExistingStream(streamId);
70
+ if (!resumableStream) return null;
71
+ return createAsyncIterableStream(convertSSEToUIMessageStream(resumableStream));
72
+ }
73
+ /**
74
+ * Publish a stop message to the stop channel, which will trigger the abortController to abort the stream.
75
+ */
76
+ async function stopStream() {
77
+ await publisher.publish(stopChannel, `stop`);
78
+ }
79
+ return {
80
+ startStream,
81
+ resumeStream,
82
+ stopStream
83
+ };
84
+ }
85
+
86
+ //#endregion
87
+ export { createResumableUIMessageStream };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "ai-resumable-stream",
3
+ "version": "1.0.0",
4
+ "description": "AI SDK: resume and stop UI message streams",
5
+ "keywords": [
6
+ "ai",
7
+ "ai-sdk",
8
+ "redis",
9
+ "resumable",
10
+ "resumable-stream",
11
+ "stream"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Chris Cook",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/zirkelc/ai-resumable-stream.git"
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "type": "module",
23
+ "exports": {
24
+ ".": "./dist/index.mjs",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "scripts": {
28
+ "prepublishOnly": "pnpm build",
29
+ "build": "tsdown",
30
+ "test": "vitest",
31
+ "lint": "oxlint --fix",
32
+ "lint:ci": "oxlint",
33
+ "format": "oxfmt --write",
34
+ "format:ci": "oxfmt --check",
35
+ "prepare": "husky"
36
+ },
37
+ "dependencies": {
38
+ "ai-stream-utils": "^1.5.0",
39
+ "resumable-stream": "^2.2.10"
40
+ },
41
+ "devDependencies": {
42
+ "@ai-sdk/provider": "^3.0.8",
43
+ "@arethetypeswrong/cli": "^0.18.2",
44
+ "@total-typescript/tsconfig": "^1.0.4",
45
+ "@types/node": "^25.2.3",
46
+ "@vitest/coverage-v8": "^4.0.18",
47
+ "ai": "^6.0.79",
48
+ "husky": "^9.1.7",
49
+ "lint-staged": "^16.2.7",
50
+ "oxfmt": "^0.31.0",
51
+ "oxlint": "^1.46.0",
52
+ "pkg-pr-new": "^0.0.63",
53
+ "publint": "^0.3.17",
54
+ "redis": "^5.10.0",
55
+ "redis-memory-server": "^0.16.0",
56
+ "tsdown": "^0.20.3",
57
+ "tsx": "^4.21.0",
58
+ "typescript": "^5.9.3",
59
+ "vitest": "^4.0.18"
60
+ },
61
+ "lint-staged": {
62
+ "*.{js,ts,tsx,jsx}": [
63
+ "pnpm lint",
64
+ "pnpm format"
65
+ ],
66
+ "*.{json,jsonc}": [
67
+ "pnpm format"
68
+ ]
69
+ },
70
+ "packageManager": "pnpm@10.0.0"
71
+ }