@ai-sdk/rsc 2.0.45 → 2.0.47

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 (34) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +3 -2
  3. package/src/ai-state.test.ts +146 -0
  4. package/src/ai-state.tsx +210 -0
  5. package/src/index.ts +20 -0
  6. package/src/provider.tsx +149 -0
  7. package/src/rsc-client.ts +8 -0
  8. package/src/rsc-server.ts +5 -0
  9. package/src/rsc-shared.mts +11 -0
  10. package/src/shared-client/context.tsx +226 -0
  11. package/src/shared-client/index.ts +11 -0
  12. package/src/stream-ui/__snapshots__/render.ui.test.tsx.snap +91 -0
  13. package/src/stream-ui/__snapshots__/stream-ui.ui.test.tsx.snap +213 -0
  14. package/src/stream-ui/index.tsx +1 -0
  15. package/src/stream-ui/stream-ui.tsx +419 -0
  16. package/src/stream-ui/stream-ui.ui.test.tsx +321 -0
  17. package/src/streamable-ui/create-streamable-ui.tsx +148 -0
  18. package/src/streamable-ui/create-streamable-ui.ui.test.tsx +354 -0
  19. package/src/streamable-ui/create-suspended-chunk.tsx +84 -0
  20. package/src/streamable-value/create-streamable-value.test.tsx +179 -0
  21. package/src/streamable-value/create-streamable-value.ts +296 -0
  22. package/src/streamable-value/is-streamable-value.ts +10 -0
  23. package/src/streamable-value/read-streamable-value.tsx +113 -0
  24. package/src/streamable-value/read-streamable-value.ui.test.tsx +165 -0
  25. package/src/streamable-value/streamable-value.ts +37 -0
  26. package/src/streamable-value/use-streamable-value.tsx +91 -0
  27. package/src/types/index.ts +1 -0
  28. package/src/types.test-d.ts +17 -0
  29. package/src/types.ts +71 -0
  30. package/src/util/constants.ts +5 -0
  31. package/src/util/create-resolvable-promise.ts +28 -0
  32. package/src/util/is-async-generator.ts +7 -0
  33. package/src/util/is-function.ts +8 -0
  34. package/src/util/is-generator.ts +5 -0
@@ -0,0 +1,354 @@
1
+ import { delay } from '@ai-sdk/provider-utils';
2
+ import { createStreamableUI } from './create-streamable-ui';
3
+ import { describe, it, expect } from 'vitest';
4
+
5
+ // This is a workaround to render the Flight response in a test environment.
6
+ async function flightRender(node: React.ReactNode, byChunk?: boolean) {
7
+ const ReactDOM = require('react-dom');
8
+ ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactDOMCurrentDispatcher =
9
+ { current: {} };
10
+
11
+ const React = require('react');
12
+ React.__SECRET_SERVER_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
13
+ ReactSharedServerInternals: {},
14
+ ReactCurrentCache: {
15
+ current: null,
16
+ },
17
+ };
18
+
19
+ const {
20
+ renderToReadableStream,
21
+ } = require('react-server-dom-webpack/server.edge');
22
+
23
+ const stream = renderToReadableStream(node);
24
+ const reader = stream.getReader();
25
+
26
+ const chunks = [];
27
+ let result = '';
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+ if (done) {
31
+ break;
32
+ }
33
+
34
+ const decoded = new TextDecoder().decode(value);
35
+ if (byChunk) {
36
+ chunks.push(decoded);
37
+ } else {
38
+ result += decoded;
39
+ }
40
+ }
41
+
42
+ return byChunk ? chunks : result;
43
+ }
44
+
45
+ async function recursiveResolve(val: any): Promise<any> {
46
+ if (val && typeof val === 'object' && typeof val.then === 'function') {
47
+ return await recursiveResolve(await val);
48
+ }
49
+
50
+ if (Array.isArray(val)) {
51
+ return await Promise.all(val.map(recursiveResolve));
52
+ }
53
+
54
+ if (val && typeof val === 'object') {
55
+ const result: any = {};
56
+ for (const key in val) {
57
+ result[key] = await recursiveResolve(val[key]);
58
+ }
59
+ return result;
60
+ }
61
+
62
+ return val;
63
+ }
64
+
65
+ async function simulateFlightServerRender(node: React.ReactNode) {
66
+ async function traverse(node: any): Promise<any> {
67
+ if (!node) return {};
68
+
69
+ // Let's only do one level of promise resolution here. As it's only for testing purposes.
70
+ const props = await recursiveResolve({ ...node.props });
71
+
72
+ const { type } = node;
73
+ const { children, ...otherProps } = props;
74
+ const typeName = typeof type === 'function' ? type.name : String(type);
75
+
76
+ return {
77
+ type: typeName,
78
+ props: otherProps,
79
+ children:
80
+ typeof children === 'string'
81
+ ? children
82
+ : Array.isArray(children)
83
+ ? children.map(traverse)
84
+ : await traverse(children),
85
+ };
86
+ }
87
+
88
+ return traverse(node);
89
+ }
90
+
91
+ function getFinalValueFromResolved(node: any) {
92
+ if (!node) return node;
93
+ if (node.type === 'Symbol(react.suspense)') {
94
+ return getFinalValueFromResolved(node.children);
95
+ } else if (node.type === '') {
96
+ let wrapper;
97
+ let value = node.props.value;
98
+ let next = node.props.n;
99
+ let current = node.props.c;
100
+
101
+ while (next) {
102
+ if (next.append) {
103
+ if (wrapper === undefined) {
104
+ wrapper = current;
105
+ } else if (typeof current === 'string' && typeof wrapper === 'string') {
106
+ wrapper = wrapper + current;
107
+ } else {
108
+ wrapper = (
109
+ <>
110
+ {wrapper}
111
+ {current}
112
+ </>
113
+ );
114
+ }
115
+ }
116
+
117
+ value = next.value;
118
+ next = next.next;
119
+ current = value;
120
+ }
121
+
122
+ return getFinalValueFromResolved(
123
+ wrapper === undefined ? (
124
+ value
125
+ ) : typeof value === 'string' && typeof wrapper === 'string' ? (
126
+ wrapper + value
127
+ ) : (
128
+ <>
129
+ {wrapper}
130
+ {value}
131
+ </>
132
+ ),
133
+ );
134
+ }
135
+ return node;
136
+ }
137
+
138
+ describe('rsc - createStreamableUI()', () => {
139
+ it('should emit React Nodes that can be updated', async () => {
140
+ const ui = createStreamableUI(<div>1</div>);
141
+ ui.update(<div>2</div>);
142
+ ui.update(<div>3</div>);
143
+ ui.done();
144
+
145
+ const final = getFinalValueFromResolved(
146
+ await simulateFlightServerRender(ui.value),
147
+ );
148
+ expect(final).toMatchInlineSnapshot(`
149
+ <div>
150
+ 3
151
+ </div>
152
+ `);
153
+ });
154
+
155
+ it('should emit React Nodes that can be updated with .done()', async () => {
156
+ const ui = createStreamableUI(<div>1</div>);
157
+ ui.update(<div>2</div>);
158
+ ui.update(<div>3</div>);
159
+ ui.done(<div>4</div>);
160
+
161
+ const final = getFinalValueFromResolved(
162
+ await simulateFlightServerRender(ui.value),
163
+ );
164
+ expect(final).toMatchInlineSnapshot(`
165
+ <div>
166
+ 4
167
+ </div>
168
+ `);
169
+ });
170
+
171
+ it('should support .append()', async () => {
172
+ const ui = createStreamableUI(<div>1</div>);
173
+ ui.update(<div>2</div>);
174
+ ui.append(<div>3</div>);
175
+ ui.append(<div>4</div>);
176
+ ui.done();
177
+
178
+ const final = getFinalValueFromResolved(
179
+ await simulateFlightServerRender(ui.value),
180
+ );
181
+ expect(final).toMatchInlineSnapshot(`
182
+ <React.Fragment>
183
+ <React.Fragment>
184
+ <div>
185
+ 2
186
+ </div>
187
+ <div>
188
+ 3
189
+ </div>
190
+ </React.Fragment>
191
+ <div>
192
+ 4
193
+ </div>
194
+ </React.Fragment>
195
+ `);
196
+ });
197
+
198
+ it('should support streaming .append() result before .done()', async () => {
199
+ const ui = createStreamableUI(<div>1</div>);
200
+ ui.append(<div>2</div>);
201
+ ui.append(<div>3</div>);
202
+
203
+ const currentResolved = (ui.value as React.ReactElement).props.children
204
+ .props.n;
205
+ const tryResolve1 = await Promise.race([currentResolved, delay()]);
206
+ expect(tryResolve1).toBeDefined();
207
+ const tryResolve2 = await Promise.race([tryResolve1.next, delay()]);
208
+ expect(tryResolve2).toBeDefined();
209
+ expect(getFinalValueFromResolved(tryResolve2.value)).toMatchInlineSnapshot(`
210
+ <div>
211
+ 3
212
+ </div>
213
+ `);
214
+
215
+ ui.append(<div>4</div>);
216
+ ui.done();
217
+
218
+ const final = getFinalValueFromResolved(
219
+ await simulateFlightServerRender(ui.value),
220
+ );
221
+ expect(final).toMatchInlineSnapshot(`
222
+ <React.Fragment>
223
+ <React.Fragment>
224
+ <React.Fragment>
225
+ <div>
226
+ 1
227
+ </div>
228
+ <div>
229
+ 2
230
+ </div>
231
+ </React.Fragment>
232
+ <div>
233
+ 3
234
+ </div>
235
+ </React.Fragment>
236
+ <div>
237
+ 4
238
+ </div>
239
+ </React.Fragment>
240
+ `);
241
+ });
242
+
243
+ it('should support updating the appended ui', async () => {
244
+ const ui = createStreamableUI(<div>1</div>);
245
+ ui.update(<div>2</div>);
246
+ ui.append(<div>3</div>);
247
+ ui.done(<div>4</div>);
248
+
249
+ const final = getFinalValueFromResolved(
250
+ await simulateFlightServerRender(ui.value),
251
+ );
252
+ expect(final).toMatchInlineSnapshot(`
253
+ <React.Fragment>
254
+ <div>
255
+ 2
256
+ </div>
257
+ <div>
258
+ 4
259
+ </div>
260
+ </React.Fragment>
261
+ `);
262
+ });
263
+
264
+ it('should re-use the text node when appending strings', async () => {
265
+ const ui = createStreamableUI('hello');
266
+ ui.append(' world');
267
+ ui.append('!');
268
+ ui.done();
269
+
270
+ const final = getFinalValueFromResolved(
271
+ await simulateFlightServerRender(ui.value),
272
+ );
273
+ expect(final).toMatchInlineSnapshot('"hello world!"');
274
+ });
275
+
276
+ it('should send minimal incremental diffs when appending strings', async () => {
277
+ const ui = createStreamableUI('hello');
278
+ ui.append(' world');
279
+ ui.append(' and');
280
+ ui.append(' universe');
281
+ ui.done();
282
+
283
+ expect(await flightRender(ui.value)).toMatchInlineSnapshot(`
284
+ "1:"$Sreact.suspense"
285
+ 2:D{"name":"","env":"Server"}
286
+ 0:["$","$1",null,{"fallback":"hello","children":"$L2"}]
287
+ 3:D{"name":"","env":"Server"}
288
+ 2:["hello",["$","$1",null,{"fallback":" world","children":"$L3"}]]
289
+ 4:D{"name":"","env":"Server"}
290
+ 3:[" world",["$","$1",null,{"fallback":" and","children":"$L4"}]]
291
+ 5:D{"name":"","env":"Server"}
292
+ 4:[" and",["$","$1",null,{"fallback":" universe","children":"$L5"}]]
293
+ 5:" universe"
294
+ "
295
+ `);
296
+
297
+ const final = getFinalValueFromResolved(
298
+ await simulateFlightServerRender(ui.value),
299
+ );
300
+ expect(final).toStrictEqual('hello world and universe');
301
+ });
302
+
303
+ it('should error when updating a closed streamable', async () => {
304
+ const ui = createStreamableUI(<div>1</div>);
305
+ ui.done(<div>2</div>);
306
+
307
+ expect(() => {
308
+ ui.update(<div>3</div>);
309
+ }).toThrowErrorMatchingInlineSnapshot(
310
+ '[Error: .update(): UI stream is already closed.]',
311
+ );
312
+ });
313
+
314
+ it('should avoid sending data again if the same UI is passed', async () => {
315
+ const node = <div>1</div>;
316
+ const ui = createStreamableUI(node);
317
+ ui.update(node);
318
+ ui.update(node);
319
+ ui.update(node);
320
+ ui.update(node);
321
+ ui.update(node);
322
+ ui.update(node);
323
+ ui.done();
324
+
325
+ expect(await flightRender(ui.value)).toMatchInlineSnapshot(`
326
+ "1:"$Sreact.suspense"
327
+ 2:D{"name":"","env":"Server"}
328
+ 0:["$","$1",null,{"fallback":["$","div",null,{"children":"1"}],"children":"$L2"}]
329
+ 4:{"children":"1"}
330
+ 3:["$","div",null,"$4"]
331
+ 2:"$3"
332
+ "
333
+ `);
334
+ });
335
+
336
+ it('should return self', async () => {
337
+ const ui = createStreamableUI(<div>1</div>)
338
+ .update(<div>2</div>)
339
+ .update(<div>3</div>)
340
+ .done(<div>4</div>);
341
+
342
+ expect(await flightRender(ui.value)).toMatchInlineSnapshot(`
343
+ "1:"$Sreact.suspense"
344
+ 2:D{"name":"","env":"Server"}
345
+ 0:["$","$1",null,{"fallback":["$","div",null,{"children":"1"}],"children":"$L2"}]
346
+ 3:D{"name":"","env":"Server"}
347
+ 2:["$","$1",null,{"fallback":["$","div",null,{"children":"2"}],"children":"$L3"}]
348
+ 4:D{"name":"","env":"Server"}
349
+ 3:["$","$1",null,{"fallback":["$","div",null,{"children":"3"}],"children":"$L4"}]
350
+ 4:["$","div",null,{"children":"4"}]
351
+ "
352
+ `);
353
+ });
354
+ });
@@ -0,0 +1,84 @@
1
+ import React, { Suspense } from 'react';
2
+ import { createResolvablePromise } from '../util/create-resolvable-promise';
3
+
4
+ // Recursive type for the chunk.
5
+ type ChunkType =
6
+ | {
7
+ done: false;
8
+ value: React.ReactNode;
9
+ next: Promise<ChunkType>;
10
+ append?: boolean;
11
+ }
12
+ | {
13
+ done: true;
14
+ value: React.ReactNode;
15
+ };
16
+
17
+ // Use single letter names for the variables to reduce the size of the RSC payload.
18
+ // `R` for `Row`, `c` for `current`, `n` for `next`.
19
+ // Note: Array construction is needed to access the name R.
20
+ const R = [
21
+ (async ({
22
+ c: current,
23
+ n: next,
24
+ }: {
25
+ c: React.ReactNode;
26
+ n: Promise<ChunkType>;
27
+ }) => {
28
+ const chunk = await next;
29
+
30
+ if (chunk.done) {
31
+ return chunk.value;
32
+ }
33
+
34
+ if (chunk.append) {
35
+ return (
36
+ <>
37
+ {current}
38
+ <Suspense fallback={chunk.value}>
39
+ <R c={chunk.value} n={chunk.next} />
40
+ </Suspense>
41
+ </>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <Suspense fallback={chunk.value}>
47
+ <R c={chunk.value} n={chunk.next} />
48
+ </Suspense>
49
+ );
50
+ }) as unknown as React.FC<{
51
+ c: React.ReactNode;
52
+ n: Promise<ChunkType>;
53
+ }>,
54
+ ][0];
55
+
56
+ /**
57
+ * Creates a suspended chunk for React Server Components.
58
+ *
59
+ * This function generates a suspenseful React component that can be dynamically updated.
60
+ * It's useful for streaming updates to the client in a React Server Components context.
61
+ *
62
+ * @param {React.ReactNode} initialValue - The initial value to render while the promise is pending.
63
+ * @returns {Object} An object containing:
64
+ * - row: A React node that renders the suspenseful content.
65
+ * - resolve: A function to resolve the promise with a new value.
66
+ * - reject: A function to reject the promise with an error.
67
+ */
68
+ export function createSuspendedChunk(initialValue: React.ReactNode): {
69
+ row: React.ReactNode;
70
+ resolve: (value: ChunkType) => void;
71
+ reject: (error: unknown) => void;
72
+ } {
73
+ const { promise, resolve, reject } = createResolvablePromise<ChunkType>();
74
+
75
+ return {
76
+ row: (
77
+ <Suspense fallback={initialValue}>
78
+ <R c={initialValue} n={promise} />
79
+ </Suspense>
80
+ ),
81
+ resolve,
82
+ reject,
83
+ };
84
+ }
@@ -0,0 +1,179 @@
1
+ import { delay } from '@ai-sdk/provider-utils';
2
+ import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test';
3
+ import { createStreamableValue } from './create-streamable-value';
4
+ import { STREAMABLE_VALUE_TYPE, StreamableValue } from './streamable-value';
5
+ import { it, expect } from 'vitest';
6
+
7
+ async function getRawChunks(streamableValue: StreamableValue<any, any>) {
8
+ const chunks = [];
9
+ let currentValue = streamableValue;
10
+
11
+ while (true) {
12
+ const { next, ...otherFields } = currentValue;
13
+
14
+ chunks.push(otherFields);
15
+
16
+ if (!next) break;
17
+
18
+ currentValue = await next;
19
+ }
20
+
21
+ return chunks;
22
+ }
23
+
24
+ it('should return latest value', async () => {
25
+ const streamableValue = createStreamableValue(1).update(2).update(3).done(4);
26
+
27
+ expect(streamableValue.value.curr).toStrictEqual(4);
28
+ });
29
+
30
+ it('should be able to stream any JSON values', async () => {
31
+ const streamable = createStreamableValue();
32
+ streamable.update({ v: 123 });
33
+
34
+ expect(streamable.value.curr).toStrictEqual({ v: 123 });
35
+
36
+ streamable.done();
37
+ });
38
+
39
+ it('should support .error()', async () => {
40
+ const streamable = createStreamableValue();
41
+ streamable.error('This is an error');
42
+
43
+ expect(streamable.value).toStrictEqual({
44
+ error: 'This is an error',
45
+ type: STREAMABLE_VALUE_TYPE,
46
+ });
47
+ });
48
+
49
+ it('should directly emit the final value when reading .value', async () => {
50
+ const streamable = createStreamableValue('1');
51
+ streamable.update('2');
52
+ streamable.update('3');
53
+
54
+ expect(streamable.value.curr).toStrictEqual('3');
55
+
56
+ streamable.done('4');
57
+
58
+ expect(streamable.value.curr).toStrictEqual('4');
59
+ });
60
+
61
+ it('should be able to append strings as patch', async () => {
62
+ const streamable = createStreamableValue();
63
+ const value = streamable.value;
64
+
65
+ streamable.update('hello');
66
+ streamable.update('hello world');
67
+ streamable.update('hello world!');
68
+ streamable.update('new string');
69
+ streamable.done('new string with patch!');
70
+
71
+ expect(await getRawChunks(value)).toStrictEqual([
72
+ { curr: undefined, type: STREAMABLE_VALUE_TYPE },
73
+ { curr: 'hello' },
74
+ { diff: [0, ' world'] },
75
+ { diff: [0, '!'] },
76
+ { curr: 'new string' },
77
+ { diff: [0, ' with patch!'] },
78
+ ]);
79
+ });
80
+
81
+ it('should be able to call .append() to send patches', async () => {
82
+ const streamable = createStreamableValue();
83
+ const value = streamable.value;
84
+
85
+ streamable.append('hello');
86
+ streamable.append(' world');
87
+ streamable.append('!');
88
+ streamable.done();
89
+
90
+ expect(await getRawChunks(value)).toStrictEqual([
91
+ { curr: undefined, type: STREAMABLE_VALUE_TYPE },
92
+ { curr: 'hello' },
93
+ { diff: [0, ' world'] },
94
+ { diff: [0, '!'] },
95
+ {},
96
+ ]);
97
+ });
98
+
99
+ it('should be able to mix .update() and .append() with optimized payloads', async () => {
100
+ const streamable = createStreamableValue('hello');
101
+ const value = streamable.value;
102
+
103
+ streamable.append(' world');
104
+ streamable.update('hello world!!');
105
+ streamable.update('some new');
106
+ streamable.update('some new string');
107
+ streamable.append(' with patch!');
108
+ streamable.done();
109
+
110
+ expect(await getRawChunks(value)).toStrictEqual([
111
+ { curr: 'hello', type: STREAMABLE_VALUE_TYPE },
112
+ { diff: [0, ' world'] },
113
+ { diff: [0, '!!'] },
114
+ { curr: 'some new' },
115
+ { diff: [0, ' string'] },
116
+ { diff: [0, ' with patch!'] },
117
+ {},
118
+ ]);
119
+ });
120
+
121
+ it('should behave like .update() with .append() and .done()', async () => {
122
+ const streamable = createStreamableValue('hello');
123
+ const value = streamable.value;
124
+
125
+ streamable.append(' world');
126
+ streamable.done('fin');
127
+
128
+ expect(await getRawChunks(value)).toStrictEqual([
129
+ { curr: 'hello', type: STREAMABLE_VALUE_TYPE },
130
+ { diff: [0, ' world'] },
131
+ { curr: 'fin' },
132
+ ]);
133
+ });
134
+
135
+ it('should be able to accept readableStream as the source', async () => {
136
+ const streamable = createStreamableValue(
137
+ convertArrayToReadableStream(['hello', ' world', '!']),
138
+ );
139
+ const value = streamable.value;
140
+
141
+ expect(await getRawChunks(value)).toStrictEqual([
142
+ { curr: undefined, type: STREAMABLE_VALUE_TYPE },
143
+ { curr: 'hello' },
144
+ { diff: [0, ' world'] },
145
+ { diff: [0, '!'] },
146
+ {},
147
+ ]);
148
+ });
149
+
150
+ it('should accept readableStream with JSON payloads', async () => {
151
+ const streamable = createStreamableValue(
152
+ convertArrayToReadableStream([{ v: 1 }, { v: 2 }, { v: 3 }]),
153
+ );
154
+ const value = streamable.value;
155
+
156
+ expect(await getRawChunks(value)).toStrictEqual([
157
+ { curr: undefined, type: STREAMABLE_VALUE_TYPE },
158
+ { curr: { v: 1 } },
159
+ { curr: { v: 2 } },
160
+ { curr: { v: 3 } },
161
+ {},
162
+ ]);
163
+ });
164
+
165
+ it('should lock the streamable if from readableStream', async () => {
166
+ const streamable = createStreamableValue(
167
+ new ReadableStream({
168
+ async start(controller) {
169
+ await delay();
170
+ controller.enqueue('hello');
171
+ controller.close();
172
+ },
173
+ }),
174
+ );
175
+
176
+ expect(() => streamable.update('world')).toThrowErrorMatchingInlineSnapshot(
177
+ '[Error: .update(): Value stream is locked and cannot be updated.]',
178
+ );
179
+ });