ai-resumable-stream 1.2.1 → 1.4.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
@@ -301,6 +301,42 @@ await publisher.quit();
301
301
  await subscriber.quit();
302
302
  ```
303
303
 
304
+ ### Keep Alive
305
+
306
+ By default, the resumable-stream producer tears down immediately when the source stream ends. This means resume requests arriving during post-stream work (e.g. saving the assistant message to the database) will get `null` back, even though the full response is still in memory.
307
+
308
+ Pass a `keepAlive` promise to `startStream` to defer teardown until your post-stream work is complete:
309
+
310
+ ```typescript
311
+ const { promise, resolve } = Promise.withResolvers<void>();
312
+
313
+ const stream = await context.startStream(result.toUIMessageStream(), {
314
+ keepAlive: promise,
315
+ });
316
+
317
+ yield * stream;
318
+
319
+ // Producer stays alive — resume requests are served from the in-memory buffer
320
+ await saveAssistantMessage(result);
321
+
322
+ // Done — producer tears down
323
+ resolve();
324
+ ```
325
+
326
+ ### Flush
327
+
328
+ The `onFlush` callback is invoked after the producer has torn down (Redis stream closed, sentinel set to DONE). Use it for cleanup tasks like removing the active stream ID from the database. Errors thrown by `onFlush` are silently caught.
329
+
330
+ ```typescript
331
+ const stream = await context.startStream(result.toUIMessageStream(), {
332
+ onFlush: async () => {
333
+ await saveChat({ chatId, activeStreamId: null });
334
+ },
335
+ });
336
+ ```
337
+
338
+ When used together with `keepAlive`, `onFlush` fires after the `keepAlive` promise resolves and the producer tears down.
339
+
304
340
  ## API Reference
305
341
 
306
342
  ### `createResumableUIMessageStream`
@@ -309,6 +345,7 @@ await subscriber.quit();
309
345
  async function createResumableUIMessageStream(options: CreateResumableUIMessageStream): Promise<{
310
346
  startStream: (
311
347
  stream: ReadableStream<UIMessageChunk>,
348
+ options?: StartStreamOptions,
312
349
  ) => Promise<AsyncIterableStream<UIMessageChunk>>;
313
350
  resumeStream: () => Promise<AsyncIterableStream<UIMessageChunk> | null>;
314
351
  stopStream: () => Promise<void>;
@@ -321,6 +358,11 @@ type CreateResumableUIMessageStream = {
321
358
  abortController?: AbortController;
322
359
  waitUntil?: (promise: Promise<unknown>) => void;
323
360
  };
361
+
362
+ type StartStreamOptions = {
363
+ keepAlive?: Promise<void>;
364
+ onFlush?: () => void | Promise<void>;
365
+ };
324
366
  ```
325
367
 
326
368
  ### Return Values
@@ -330,11 +372,29 @@ type CreateResumableUIMessageStream = {
330
372
  ```typescript
331
373
  async function startStream(
332
374
  stream: ReadableStream<UIMessageChunk>,
375
+ options?: StartStreamOptions,
333
376
  ): Promise<AsyncIterableStream<UIMessageChunk>>;
377
+
378
+ type StartStreamOptions = {
379
+ keepAlive?: Promise<void>;
380
+ onFlush?: () => void | Promise<void>;
381
+ };
334
382
  ```
335
383
 
336
384
  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.
337
385
 
386
+ ##### `keepAlive`
387
+
388
+ A promise that defers closing the Redis stream until it resolves. This keeps the resumable-stream producer alive after the source stream ends, so late resume requests (e.g. during post-stream DB writes) can still be served from the in-memory chunk buffer.
389
+
390
+ If the promise rejects, the producer tears down normally. If the source stream errors, the `keepAlive` promise is not awaited.
391
+
392
+ ##### `onFlush`
393
+
394
+ A callback invoked after the Redis stream is closed and the producer has torn down, 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.
395
+
396
+ When used together with `keepAlive`, `onFlush` fires after the `keepAlive` promise resolves and the producer tears down.
397
+
338
398
  #### `resumeStream`
339
399
 
340
400
  ```typescript
package/dist/index.d.mts CHANGED
@@ -33,6 +33,29 @@ type CreateResumableUIMessageStream = {
33
33
  */
34
34
  waitUntil?: (promise: Promise<unknown>) => void;
35
35
  };
36
+ type StartStreamOptions = {
37
+ /**
38
+ * A promise that is awaited before closing the Redis stream, keeping the
39
+ * resumable-stream producer alive so late resume requests can be served
40
+ * from the in-memory buffer. The producer tears down when the promise resolves.
41
+ *
42
+ * Use `Promise.withResolvers()` to create a deferred promise and resolve it
43
+ * after post-stream work (e.g. DB writes) is complete:
44
+ * ```ts
45
+ * const { promise, resolve } = Promise.withResolvers<void>();
46
+ * const stream = await ctx.startStream(input, { keepAlive: promise });
47
+ * for await (const chunk of stream) { ... }
48
+ * await saveToDb();
49
+ * resolve();
50
+ * ```
51
+ */
52
+ keepAlive?: Promise<void>;
53
+ /**
54
+ * Called after the Redis stream is closed and the producer has torn down.
55
+ * Use for post-teardown cleanup.
56
+ */
57
+ onFlush?: () => void | Promise<void>;
58
+ };
36
59
  /**
37
60
  * Creates a resumable context for starting, resuming and stopping UI message streams.
38
61
  *
@@ -40,7 +63,7 @@ type CreateResumableUIMessageStream = {
40
63
  * eagerly and enqueues directly to the output stream.
41
64
  */
42
65
  declare function createResumableUIMessageStream(options: CreateResumableUIMessageStream): Promise<{
43
- startStream: (stream: ReadableStream<UIMessageChunk>) => Promise<AsyncIterableStream<UIMessageChunk>>;
66
+ startStream: (stream: ReadableStream<UIMessageChunk>, options?: StartStreamOptions) => Promise<AsyncIterableStream<UIMessageChunk>>;
44
67
  resumeStream: () => Promise<AsyncIterableStream<UIMessageChunk> | null>;
45
68
  stopStream: () => Promise<void>;
46
69
  }>;
package/dist/index.mjs CHANGED
@@ -6,7 +6,7 @@ import { createResumableStreamContext } from "resumable-stream";
6
6
  /**
7
7
  * Converts an SSE stream to a UI message stream.
8
8
  */
9
- function convertSSEToUIMessageStream(stream, onComplete) {
9
+ function convertSSEToUIMessageStream(stream, onFlush) {
10
10
  return parseJsonEventStream({
11
11
  stream: stream.pipeThrough(new TextEncoderStream()),
12
12
  schema: uiMessageChunkSchema
@@ -15,7 +15,7 @@ function convertSSEToUIMessageStream(stream, onComplete) {
15
15
  if (result.success) controller.enqueue(result.value);
16
16
  },
17
17
  flush() {
18
- onComplete?.();
18
+ onFlush?.();
19
19
  }
20
20
  }));
21
21
  }
@@ -67,9 +67,13 @@ async function createResumableUIMessageStream(options) {
67
67
  * 1. Reads from source stream
68
68
  * 2. Sends UIMessageChunk directly to client stream (no conversion)
69
69
  * 3. Sends SSE to Redis stream → resumable-stream → Redis
70
- * 4. Propagates errors to both streams
70
+ * 4. If `keepAlive` is provided, awaits it before closing the Redis stream
71
+ * 5. Closes the Redis stream, triggering resumable-stream teardown
72
+ * 6. Calls `onFlush` after teardown
73
+ * 7. Propagates errors to both streams
71
74
  */
72
- async function startStream(stream) {
75
+ async function startStream(stream, options) {
76
+ const { keepAlive = Promise.resolve(), onFlush } = options ?? {};
73
77
  /**
74
78
  * Track client disconnect to avoid unbounded memory growth
75
79
  */
@@ -113,12 +117,18 @@ async function createResumableUIMessageStream(options) {
113
117
  * Continues draining to Redis even if client disconnects
114
118
  */
115
119
  (async () => {
120
+ /**
121
+ * Tracks whether the source stream completed successfully (reader.read() returned done: true).
122
+ * Used to guard the keepAlive await in finally — on error, the caller may never resolve
123
+ * the keepAlive promise, so awaiting it would hang the drain loop forever.
124
+ */
125
+ let sourceDone = false;
116
126
  try {
117
127
  while (true) {
118
128
  const { done, value } = await reader.read();
119
129
  if (done) {
120
- redisController.close();
121
130
  if (!clientCancelled) clientController.close();
131
+ sourceDone = true;
122
132
  break;
123
133
  }
124
134
  /**
@@ -139,6 +149,23 @@ async function createResumableUIMessageStream(options) {
139
149
  try {
140
150
  await unsubscribe();
141
151
  } catch {}
152
+ /**
153
+ * Await keepAlive promise before closing the Redis stream.
154
+ * This keeps the resumable-stream producer alive so late resume requests
155
+ * can be served from the in-memory chunk buffer.
156
+ * Defaults to a resolved promise (no-op) when not provided.
157
+ * Only await on successful source completion — on error the caller
158
+ * may never resolve the promise, which would hang the drain loop.
159
+ */
160
+ if (sourceDone) try {
161
+ await keepAlive;
162
+ } catch {}
163
+ try {
164
+ redisController.close();
165
+ } catch {}
166
+ try {
167
+ await onFlush?.();
168
+ } catch {}
142
169
  }
143
170
  })();
144
171
  return createAsyncIterableStream(clientStream);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-resumable-stream",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "AI SDK: resume and stop UI message streams",
5
5
  "keywords": [
6
6
  "ai",