@ai-sdk/rsc 2.0.44 → 2.0.46
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/CHANGELOG.md +13 -0
- package/package.json +3 -2
- package/src/ai-state.test.ts +146 -0
- package/src/ai-state.tsx +210 -0
- package/src/index.ts +20 -0
- package/src/provider.tsx +149 -0
- package/src/rsc-client.ts +8 -0
- package/src/rsc-server.ts +5 -0
- package/src/rsc-shared.mts +11 -0
- package/src/shared-client/context.tsx +226 -0
- package/src/shared-client/index.ts +11 -0
- package/src/stream-ui/__snapshots__/render.ui.test.tsx.snap +91 -0
- package/src/stream-ui/__snapshots__/stream-ui.ui.test.tsx.snap +213 -0
- package/src/stream-ui/index.tsx +1 -0
- package/src/stream-ui/stream-ui.tsx +419 -0
- package/src/stream-ui/stream-ui.ui.test.tsx +321 -0
- package/src/streamable-ui/create-streamable-ui.tsx +148 -0
- package/src/streamable-ui/create-streamable-ui.ui.test.tsx +354 -0
- package/src/streamable-ui/create-suspended-chunk.tsx +84 -0
- package/src/streamable-value/create-streamable-value.test.tsx +179 -0
- package/src/streamable-value/create-streamable-value.ts +296 -0
- package/src/streamable-value/is-streamable-value.ts +10 -0
- package/src/streamable-value/read-streamable-value.tsx +113 -0
- package/src/streamable-value/read-streamable-value.ui.test.tsx +165 -0
- package/src/streamable-value/streamable-value.ts +37 -0
- package/src/streamable-value/use-streamable-value.tsx +91 -0
- package/src/types/index.ts +1 -0
- package/src/types.test-d.ts +17 -0
- package/src/types.ts +71 -0
- package/src/util/constants.ts +5 -0
- package/src/util/create-resolvable-promise.ts +28 -0
- package/src/util/is-async-generator.ts +7 -0
- package/src/util/is-function.ts +8 -0
- package/src/util/is-generator.ts +5 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { LanguageModelV3Usage } from '@ai-sdk/provider';
|
|
2
|
+
import { delay } from '@ai-sdk/provider-utils';
|
|
3
|
+
import { convertArrayToReadableStream } from '@ai-sdk/provider-utils/test';
|
|
4
|
+
import { asLanguageModelUsage } from 'ai/internal';
|
|
5
|
+
import { MockLanguageModelV3 } from 'ai/test';
|
|
6
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
import { z } from 'zod/v4';
|
|
8
|
+
import { streamUI } from './stream-ui';
|
|
9
|
+
|
|
10
|
+
async function recursiveResolve(val: any): Promise<any> {
|
|
11
|
+
if (val && typeof val === 'object' && typeof val.then === 'function') {
|
|
12
|
+
return await recursiveResolve(await val);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(val)) {
|
|
16
|
+
return await Promise.all(val.map(recursiveResolve));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (val && typeof val === 'object') {
|
|
20
|
+
const result: any = {};
|
|
21
|
+
for (const key in val) {
|
|
22
|
+
result[key] = await recursiveResolve(val[key]);
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return val;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function simulateFlightServerRender(node: React.ReactNode) {
|
|
31
|
+
async function traverse(node: any): Promise<any> {
|
|
32
|
+
if (!node) return {};
|
|
33
|
+
|
|
34
|
+
// Let's only do one level of promise resolution here. As it's only for testing purposes.
|
|
35
|
+
const props = await recursiveResolve({ ...node.props });
|
|
36
|
+
|
|
37
|
+
const { type } = node;
|
|
38
|
+
const { children, ...otherProps } = props;
|
|
39
|
+
const typeName = typeof type === 'function' ? type.name : String(type);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
type: typeName,
|
|
43
|
+
props: otherProps,
|
|
44
|
+
children:
|
|
45
|
+
typeof children === 'string'
|
|
46
|
+
? children
|
|
47
|
+
: Array.isArray(children)
|
|
48
|
+
? children.map(traverse)
|
|
49
|
+
: await traverse(children),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return traverse(node);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const testUsage: LanguageModelV3Usage = {
|
|
57
|
+
inputTokens: {
|
|
58
|
+
total: 3,
|
|
59
|
+
noCache: 3,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheWrite: 0,
|
|
62
|
+
},
|
|
63
|
+
outputTokens: {
|
|
64
|
+
total: 10,
|
|
65
|
+
text: 10,
|
|
66
|
+
reasoning: 0,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const mockTextModel = new MockLanguageModelV3({
|
|
71
|
+
doStream: async () => {
|
|
72
|
+
return {
|
|
73
|
+
stream: convertArrayToReadableStream([
|
|
74
|
+
{ type: 'text-start', id: '0' },
|
|
75
|
+
{ type: 'text-delta', id: '0', delta: '{ ' },
|
|
76
|
+
{ type: 'text-delta', id: '0', delta: '"content": ' },
|
|
77
|
+
{ type: 'text-delta', id: '0', delta: `"Hello, ` },
|
|
78
|
+
{ type: 'text-delta', id: '0', delta: `world` },
|
|
79
|
+
{ type: 'text-delta', id: '0', delta: `!"` },
|
|
80
|
+
{ type: 'text-delta', id: '0', delta: ' }' },
|
|
81
|
+
{ type: 'text-end', id: '0' },
|
|
82
|
+
{
|
|
83
|
+
type: 'finish',
|
|
84
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
85
|
+
usage: testUsage,
|
|
86
|
+
},
|
|
87
|
+
]),
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const mockToolModel = new MockLanguageModelV3({
|
|
93
|
+
doStream: async () => {
|
|
94
|
+
return {
|
|
95
|
+
stream: convertArrayToReadableStream([
|
|
96
|
+
{
|
|
97
|
+
type: 'tool-call',
|
|
98
|
+
toolCallType: 'function',
|
|
99
|
+
toolCallId: 'call-1',
|
|
100
|
+
toolName: 'tool1',
|
|
101
|
+
input: `{ "value": "value" }`,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
type: 'finish',
|
|
105
|
+
finishReason: { unified: 'stop', raw: 'stop' },
|
|
106
|
+
usage: testUsage,
|
|
107
|
+
},
|
|
108
|
+
]),
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('result.value', () => {
|
|
114
|
+
it('should render text', async () => {
|
|
115
|
+
const result = await streamUI({
|
|
116
|
+
model: mockTextModel,
|
|
117
|
+
prompt: '',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const rendered = await simulateFlightServerRender(result.value);
|
|
121
|
+
expect(rendered).toMatchSnapshot();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should render text function returned ui', async () => {
|
|
125
|
+
const result = await streamUI({
|
|
126
|
+
model: mockTextModel,
|
|
127
|
+
prompt: '',
|
|
128
|
+
text: ({ content }) => <h1>{content}</h1>,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const rendered = await simulateFlightServerRender(result.value);
|
|
132
|
+
expect(rendered).toMatchSnapshot();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should render tool call results', async () => {
|
|
136
|
+
const result = await streamUI({
|
|
137
|
+
model: mockToolModel,
|
|
138
|
+
prompt: '',
|
|
139
|
+
tools: {
|
|
140
|
+
tool1: {
|
|
141
|
+
description: 'test tool 1',
|
|
142
|
+
inputSchema: z.object({
|
|
143
|
+
value: z.string(),
|
|
144
|
+
}),
|
|
145
|
+
generate: async ({ value }) => {
|
|
146
|
+
await delay(100);
|
|
147
|
+
return <div>tool1: {value}</div>;
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const rendered = await simulateFlightServerRender(result.value);
|
|
154
|
+
expect(rendered).toMatchSnapshot();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should render tool call results with generator render function', async () => {
|
|
158
|
+
const result = await streamUI({
|
|
159
|
+
model: mockToolModel,
|
|
160
|
+
prompt: '',
|
|
161
|
+
tools: {
|
|
162
|
+
tool1: {
|
|
163
|
+
description: 'test tool 1',
|
|
164
|
+
inputSchema: z.object({
|
|
165
|
+
value: z.string(),
|
|
166
|
+
}),
|
|
167
|
+
generate: async function* ({ value }) {
|
|
168
|
+
yield <div>Loading...</div>;
|
|
169
|
+
await delay(100);
|
|
170
|
+
return <div>tool: {value}</div>;
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const rendered = await simulateFlightServerRender(result.value);
|
|
177
|
+
expect(rendered).toMatchSnapshot();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should show better error messages if legacy options are passed', async () => {
|
|
181
|
+
try {
|
|
182
|
+
await streamUI({
|
|
183
|
+
model: mockToolModel,
|
|
184
|
+
prompt: '',
|
|
185
|
+
tools: {
|
|
186
|
+
tool1: {
|
|
187
|
+
description: 'test tool 1',
|
|
188
|
+
inputSchema: z.object({
|
|
189
|
+
value: z.string(),
|
|
190
|
+
}),
|
|
191
|
+
// @ts-expect-error
|
|
192
|
+
render: async function* () {},
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
} catch (e) {
|
|
197
|
+
expect(e).toMatchSnapshot();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('rsc - streamUI() onFinish callback', () => {
|
|
203
|
+
let result: Parameters<
|
|
204
|
+
Required<Parameters<typeof streamUI>[0]>['onFinish']
|
|
205
|
+
>[0];
|
|
206
|
+
|
|
207
|
+
beforeEach(async () => {
|
|
208
|
+
const ui = await streamUI({
|
|
209
|
+
model: mockToolModel,
|
|
210
|
+
prompt: '',
|
|
211
|
+
tools: {
|
|
212
|
+
tool1: {
|
|
213
|
+
description: 'test tool 1',
|
|
214
|
+
inputSchema: z.object({
|
|
215
|
+
value: z.string(),
|
|
216
|
+
}),
|
|
217
|
+
generate: async ({ value }) => {
|
|
218
|
+
await delay(100);
|
|
219
|
+
return <div>tool1: {value}</div>;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
onFinish: event => {
|
|
224
|
+
result = event;
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// consume stream
|
|
229
|
+
await simulateFlightServerRender(ui.value);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should contain token usage', () => {
|
|
233
|
+
expect(result.usage).toStrictEqual(asLanguageModelUsage(testUsage));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should contain finish reason', async () => {
|
|
237
|
+
expect(result.finishReason).toBe('stop');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should contain final React node', async () => {
|
|
241
|
+
expect(result.value).toMatchSnapshot();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('options.headers', () => {
|
|
246
|
+
it('should pass headers to model', async () => {
|
|
247
|
+
const result = await streamUI({
|
|
248
|
+
model: new MockLanguageModelV3({
|
|
249
|
+
doStream: async ({ headers }) => {
|
|
250
|
+
expect(headers).toStrictEqual({
|
|
251
|
+
'custom-request-header': 'request-header-value',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
stream: convertArrayToReadableStream([
|
|
256
|
+
{ type: 'text-start', id: '0' },
|
|
257
|
+
{
|
|
258
|
+
type: 'text-delta',
|
|
259
|
+
id: '0',
|
|
260
|
+
delta: '{ "content": "headers test" }',
|
|
261
|
+
},
|
|
262
|
+
{ type: 'text-end', id: '0' },
|
|
263
|
+
{
|
|
264
|
+
type: 'finish',
|
|
265
|
+
finishReason: {
|
|
266
|
+
unified: 'stop',
|
|
267
|
+
raw: 'stop',
|
|
268
|
+
},
|
|
269
|
+
usage: testUsage,
|
|
270
|
+
},
|
|
271
|
+
]),
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
prompt: '',
|
|
276
|
+
headers: { 'custom-request-header': 'request-header-value' },
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(await simulateFlightServerRender(result.value)).toMatchSnapshot();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe('options.providerMetadata', () => {
|
|
284
|
+
it('should pass provider metadata to model', async () => {
|
|
285
|
+
const result = await streamUI({
|
|
286
|
+
model: new MockLanguageModelV3({
|
|
287
|
+
doStream: async ({ providerOptions }) => {
|
|
288
|
+
expect(providerOptions).toStrictEqual({
|
|
289
|
+
aProvider: { someKey: 'someValue' },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
stream: convertArrayToReadableStream([
|
|
294
|
+
{ type: 'text-start', id: '0' },
|
|
295
|
+
{
|
|
296
|
+
type: 'text-delta',
|
|
297
|
+
id: '0',
|
|
298
|
+
delta: '{ "content": "provider metadata test" }',
|
|
299
|
+
},
|
|
300
|
+
{ type: 'text-end', id: '0' },
|
|
301
|
+
{
|
|
302
|
+
type: 'finish',
|
|
303
|
+
usage: testUsage,
|
|
304
|
+
finishReason: {
|
|
305
|
+
unified: 'stop',
|
|
306
|
+
raw: 'stop',
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
]),
|
|
310
|
+
};
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
prompt: '',
|
|
314
|
+
providerOptions: {
|
|
315
|
+
aProvider: { someKey: 'someValue' },
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(await simulateFlightServerRender(result.value)).toMatchSnapshot();
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { HANGING_STREAM_WARNING_TIME_MS } from '../util/constants';
|
|
2
|
+
import { createResolvablePromise } from '../util/create-resolvable-promise';
|
|
3
|
+
import { createSuspendedChunk } from './create-suspended-chunk';
|
|
4
|
+
|
|
5
|
+
// It's necessary to define the type manually here, otherwise TypeScript compiler
|
|
6
|
+
// will not be able to infer the correct return type as it's circular.
|
|
7
|
+
type StreamableUIWrapper = {
|
|
8
|
+
/**
|
|
9
|
+
* The value of the streamable UI. This can be returned from a Server Action and received by the client.
|
|
10
|
+
*/
|
|
11
|
+
readonly value: React.ReactNode;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* This method updates the current UI node. It takes a new UI node and replaces the old one.
|
|
15
|
+
*/
|
|
16
|
+
update(value: React.ReactNode): StreamableUIWrapper;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* This method is used to append a new UI node to the end of the old one.
|
|
20
|
+
* Once appended a new UI node, the previous UI node cannot be updated anymore.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```jsx
|
|
24
|
+
* const ui = createStreamableUI(<div>hello</div>)
|
|
25
|
+
* ui.append(<div>world</div>)
|
|
26
|
+
*
|
|
27
|
+
* // The UI node will be:
|
|
28
|
+
* // <>
|
|
29
|
+
* // <div>hello</div>
|
|
30
|
+
* // <div>world</div>
|
|
31
|
+
* // </>
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
append(value: React.ReactNode): StreamableUIWrapper;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* This method is used to signal that there is an error in the UI stream.
|
|
38
|
+
* It will be thrown on the client side and caught by the nearest error boundary component.
|
|
39
|
+
*/
|
|
40
|
+
error(error: any): StreamableUIWrapper;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* This method marks the UI node as finalized. You can either call it without any parameters or with a new UI node as the final state.
|
|
44
|
+
* Once called, the UI node cannot be updated or appended anymore.
|
|
45
|
+
*
|
|
46
|
+
* This method is always **required** to be called, otherwise the response will be stuck in a loading state.
|
|
47
|
+
*/
|
|
48
|
+
done(...args: [React.ReactNode] | []): StreamableUIWrapper;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a piece of changeable UI that can be streamed to the client.
|
|
53
|
+
* On the client side, it can be rendered as a normal React node.
|
|
54
|
+
*/
|
|
55
|
+
function createStreamableUI(initialValue?: React.ReactNode) {
|
|
56
|
+
let currentValue = initialValue;
|
|
57
|
+
let closed = false;
|
|
58
|
+
let { row, resolve, reject } = createSuspendedChunk(initialValue);
|
|
59
|
+
|
|
60
|
+
function assertStream(method: string) {
|
|
61
|
+
if (closed) {
|
|
62
|
+
throw new Error(method + ': UI stream is already closed.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let warningTimeout: NodeJS.Timeout | undefined;
|
|
67
|
+
function warnUnclosedStream() {
|
|
68
|
+
if (process.env.NODE_ENV === 'development') {
|
|
69
|
+
if (warningTimeout) {
|
|
70
|
+
clearTimeout(warningTimeout);
|
|
71
|
+
}
|
|
72
|
+
warningTimeout = setTimeout(() => {
|
|
73
|
+
console.warn(
|
|
74
|
+
'The streamable UI has been slow to update. This may be a bug or a performance issue or you forgot to call `.done()`.',
|
|
75
|
+
);
|
|
76
|
+
}, HANGING_STREAM_WARNING_TIME_MS);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
warnUnclosedStream();
|
|
80
|
+
|
|
81
|
+
const streamable: StreamableUIWrapper = {
|
|
82
|
+
value: row,
|
|
83
|
+
update(value: React.ReactNode) {
|
|
84
|
+
assertStream('.update()');
|
|
85
|
+
|
|
86
|
+
// There is no need to update the value if it's referentially equal.
|
|
87
|
+
if (value === currentValue) {
|
|
88
|
+
warnUnclosedStream();
|
|
89
|
+
return streamable;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resolvable = createResolvablePromise();
|
|
93
|
+
currentValue = value;
|
|
94
|
+
|
|
95
|
+
resolve({ value: currentValue, done: false, next: resolvable.promise });
|
|
96
|
+
resolve = resolvable.resolve;
|
|
97
|
+
reject = resolvable.reject;
|
|
98
|
+
|
|
99
|
+
warnUnclosedStream();
|
|
100
|
+
|
|
101
|
+
return streamable;
|
|
102
|
+
},
|
|
103
|
+
append(value: React.ReactNode) {
|
|
104
|
+
assertStream('.append()');
|
|
105
|
+
|
|
106
|
+
const resolvable = createResolvablePromise();
|
|
107
|
+
currentValue = value;
|
|
108
|
+
|
|
109
|
+
resolve({ value, done: false, append: true, next: resolvable.promise });
|
|
110
|
+
resolve = resolvable.resolve;
|
|
111
|
+
reject = resolvable.reject;
|
|
112
|
+
|
|
113
|
+
warnUnclosedStream();
|
|
114
|
+
|
|
115
|
+
return streamable;
|
|
116
|
+
},
|
|
117
|
+
error(error: any) {
|
|
118
|
+
assertStream('.error()');
|
|
119
|
+
|
|
120
|
+
if (warningTimeout) {
|
|
121
|
+
clearTimeout(warningTimeout);
|
|
122
|
+
}
|
|
123
|
+
closed = true;
|
|
124
|
+
reject(error);
|
|
125
|
+
|
|
126
|
+
return streamable;
|
|
127
|
+
},
|
|
128
|
+
done(...args: [] | [React.ReactNode]) {
|
|
129
|
+
assertStream('.done()');
|
|
130
|
+
|
|
131
|
+
if (warningTimeout) {
|
|
132
|
+
clearTimeout(warningTimeout);
|
|
133
|
+
}
|
|
134
|
+
closed = true;
|
|
135
|
+
if (args.length) {
|
|
136
|
+
resolve({ value: args[0], done: true });
|
|
137
|
+
return streamable;
|
|
138
|
+
}
|
|
139
|
+
resolve({ value: currentValue, done: true });
|
|
140
|
+
|
|
141
|
+
return streamable;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return streamable;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { createStreamableUI };
|