@assistant-ui/mcp-docs-server 0.1.31 → 0.1.32
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/.docs/organized/code-examples/waterfall.md +8 -8
- package/.docs/organized/code-examples/with-a2a.md +10 -10
- package/.docs/organized/code-examples/with-ag-ui.md +11 -11
- package/.docs/organized/code-examples/with-ai-sdk-v6.md +13 -13
- package/.docs/organized/code-examples/with-artifacts.md +13 -13
- package/.docs/organized/code-examples/with-assistant-transport.md +10 -10
- package/.docs/organized/code-examples/with-browser-extension.md +345 -0
- package/.docs/organized/code-examples/with-chain-of-thought.md +54 -17
- package/.docs/organized/code-examples/with-cloud-standalone.md +13 -13
- package/.docs/organized/code-examples/with-cloud.md +13 -13
- package/.docs/organized/code-examples/with-custom-thread-list.md +13 -13
- package/.docs/organized/code-examples/with-elevenlabs-conversational.md +17 -16
- package/.docs/organized/code-examples/with-elevenlabs-scribe.md +17 -16
- package/.docs/organized/code-examples/with-expo.md +24 -24
- package/.docs/organized/code-examples/with-external-store.md +10 -10
- package/.docs/organized/code-examples/with-ffmpeg.md +13 -13
- package/.docs/organized/code-examples/with-generative-ui.md +210 -13
- package/.docs/organized/code-examples/with-google-adk.md +10 -10
- package/.docs/organized/code-examples/with-heat-graph.md +8 -8
- package/.docs/organized/code-examples/with-image-generation.md +454 -0
- package/.docs/organized/code-examples/with-interactables.md +13 -13
- package/.docs/organized/code-examples/with-langchain.md +12 -12
- package/.docs/organized/code-examples/with-langgraph.md +15 -12
- package/.docs/organized/code-examples/with-livekit.md +18 -17
- package/.docs/organized/code-examples/with-mcp.md +748 -0
- package/.docs/organized/code-examples/with-opencode.md +11 -11
- package/.docs/organized/code-examples/with-parent-id-grouping.md +11 -11
- package/.docs/organized/code-examples/with-react-hook-form.md +14 -14
- package/.docs/organized/code-examples/with-react-ink.md +4 -4
- package/.docs/organized/code-examples/with-react-router.md +15 -15
- package/.docs/organized/code-examples/with-resumable-stream.md +660 -0
- package/.docs/organized/code-examples/with-store.md +8 -8
- package/.docs/organized/code-examples/with-tanstack.md +14 -14
- package/.docs/organized/code-examples/with-tap-runtime.md +11 -10
- package/.docs/raw/docs/(docs)/cli.mdx +1 -0
- package/.docs/raw/docs/(docs)/index.mdx +2 -2
- package/.docs/raw/docs/(docs)/installation.mdx +1 -1
- package/.docs/raw/docs/(reference)/api-reference/adapters/attachments.mdx +26 -24
- package/.docs/raw/docs/(reference)/api-reference/adapters/feedback.mdx +20 -0
- package/.docs/raw/docs/(reference)/api-reference/adapters/index.mdx +20 -12
- package/.docs/raw/docs/(reference)/api-reference/adapters/model.mdx +44 -0
- package/.docs/raw/docs/(reference)/api-reference/adapters/persistence.mdx +37 -16
- package/.docs/raw/docs/(reference)/api-reference/adapters/runtime.mdx +12 -23
- package/.docs/raw/docs/(reference)/api-reference/adapters/suggestions.mdx +20 -0
- package/.docs/raw/docs/(reference)/api-reference/context-providers/assistant-runtime-provider.mdx +37 -8
- package/.docs/raw/docs/(reference)/api-reference/context-providers/index.mdx +11 -9
- package/.docs/raw/docs/(reference)/api-reference/context-providers/scoped-providers.mdx +64 -0
- package/.docs/raw/docs/(reference)/api-reference/external-store/index.mdx +22 -0
- package/.docs/raw/docs/(reference)/api-reference/external-store/message-conversion.mdx +52 -0
- package/.docs/raw/docs/(reference)/api-reference/external-store/runtime.mdx +36 -0
- package/.docs/raw/docs/(reference)/api-reference/hooks/composer-triggers.mdx +98 -0
- package/.docs/raw/docs/(reference)/api-reference/hooks/index.mdx +18 -13
- package/.docs/raw/docs/(reference)/api-reference/hooks/model-context.mdx +18 -57
- package/.docs/raw/docs/(reference)/api-reference/hooks/primitives.mdx +640 -0
- package/.docs/raw/docs/(reference)/api-reference/hooks/runtimes.mdx +15 -28
- package/.docs/raw/docs/(reference)/api-reference/hooks/state.mdx +64 -18
- package/.docs/raw/docs/(reference)/api-reference/hooks/utilities.mdx +434 -0
- package/.docs/raw/docs/(reference)/api-reference/integrations/cloud-ai-sdk.mdx +24 -0
- package/.docs/raw/docs/(reference)/api-reference/integrations/index.mdx +11 -12
- package/.docs/raw/docs/(reference)/api-reference/integrations/react-ai-sdk.mdx +79 -0
- package/.docs/raw/docs/(reference)/api-reference/model-context/context.mdx +52 -0
- package/.docs/raw/docs/(reference)/api-reference/model-context/index.mdx +22 -0
- package/.docs/raw/docs/(reference)/api-reference/model-context/registry.mdx +20 -0
- package/.docs/raw/docs/(reference)/api-reference/overview.mdx +110 -131
- package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar-more.mdx +78 -221
- package/.docs/raw/docs/(reference)/api-reference/primitives/action-bar.mdx +127 -242
- package/.docs/raw/docs/(reference)/api-reference/primitives/assistant-if.mdx +39 -20
- package/.docs/raw/docs/(reference)/api-reference/primitives/assistant-modal.mdx +66 -87
- package/.docs/raw/docs/(reference)/api-reference/primitives/attachment.mdx +50 -58
- package/.docs/raw/docs/(reference)/api-reference/primitives/branch-picker.mdx +80 -48
- package/.docs/raw/docs/(reference)/api-reference/primitives/chain-of-thought.mdx +67 -0
- package/.docs/raw/docs/(reference)/api-reference/primitives/composer.mdx +323 -461
- package/.docs/raw/docs/(reference)/api-reference/primitives/error.mdx +36 -43
- package/.docs/raw/docs/(reference)/api-reference/primitives/index.mdx +29 -24
- package/.docs/raw/docs/(reference)/api-reference/primitives/message-part.mdx +63 -245
- package/.docs/raw/docs/(reference)/api-reference/primitives/message.mdx +175 -591
- package/.docs/raw/docs/(reference)/api-reference/primitives/queue-item.mdx +65 -0
- package/.docs/raw/docs/(reference)/api-reference/primitives/selection-toolbar.mdx +35 -22
- package/.docs/raw/docs/(reference)/api-reference/primitives/suggestion.mdx +57 -140
- package/.docs/raw/docs/(reference)/api-reference/primitives/thread-list-item-more.mdx +70 -161
- package/.docs/raw/docs/(reference)/api-reference/primitives/thread-list-item.mdx +84 -108
- package/.docs/raw/docs/(reference)/api-reference/primitives/thread-list.mdx +77 -96
- package/.docs/raw/docs/(reference)/api-reference/primitives/thread.mdx +173 -349
- package/.docs/raw/docs/(reference)/api-reference/runtimes/assistant-runtime.mdx +9 -21
- package/.docs/raw/docs/(reference)/api-reference/runtimes/attachment-runtime.mdx +10 -21
- package/.docs/raw/docs/(reference)/api-reference/runtimes/composer-runtime.mdx +15 -70
- package/.docs/raw/docs/(reference)/api-reference/runtimes/index.mdx +18 -13
- package/.docs/raw/docs/(reference)/api-reference/runtimes/message-part-runtime.mdx +25 -28
- package/.docs/raw/docs/(reference)/api-reference/runtimes/message-runtime.mdx +11 -63
- package/.docs/raw/docs/(reference)/api-reference/runtimes/queue-state.mdx +20 -0
- package/.docs/raw/docs/(reference)/api-reference/runtimes/thread-list-item-runtime.mdx +11 -48
- package/.docs/raw/docs/(reference)/api-reference/runtimes/thread-list-runtime.mdx +10 -47
- package/.docs/raw/docs/(reference)/api-reference/runtimes/thread-runtime.mdx +18 -30
- package/.docs/raw/docs/(reference)/api-reference/tools/component-tools.mdx +68 -0
- package/.docs/raw/docs/(reference)/api-reference/tools/index.mdx +39 -0
- package/.docs/raw/docs/(reference)/api-reference/tools/rendering.mdx +79 -0
- package/.docs/raw/docs/(reference)/api-reference/tools/status.mdx +42 -0
- package/.docs/raw/docs/(reference)/api-reference/tools/toolkits.mdx +92 -0
- package/.docs/raw/docs/(reference)/api-reference/transport/assistant-transport.mdx +48 -0
- package/.docs/raw/docs/(reference)/api-reference/transport/frame.mdx +62 -0
- package/.docs/raw/docs/(reference)/api-reference/transport/index.mdx +22 -0
- package/.docs/raw/docs/(reference)/api-reference/utilities/index.mdx +19 -0
- package/.docs/raw/docs/(reference)/api-reference/utilities/miscellaneous.mdx +131 -0
- package/.docs/raw/docs/(reference)/api-reference/voice/index.mdx +22 -0
- package/.docs/raw/docs/(reference)/api-reference/voice/session.mdx +54 -0
- package/.docs/raw/docs/(reference)/api-reference/voice/speech-dictation.mdx +36 -0
- package/.docs/raw/docs/cloud/ai-sdk.mdx +1 -1
- package/.docs/raw/docs/cloud/index.mdx +2 -2
- package/.docs/raw/docs/guides/attachments.mdx +4 -4
- package/.docs/raw/docs/guides/branching.mdx +1 -1
- package/.docs/raw/docs/guides/chain-of-thought.mdx +5 -5
- package/.docs/raw/docs/guides/context-api.mdx +4 -4
- package/.docs/raw/docs/guides/dictation.mdx +2 -2
- package/.docs/raw/docs/guides/editing.mdx +1 -1
- package/.docs/raw/docs/guides/generative-ui.mdx +142 -0
- package/.docs/raw/docs/guides/image-generation.mdx +74 -0
- package/.docs/raw/docs/guides/index.mdx +2 -2
- package/.docs/raw/docs/guides/interactables.mdx +2 -2
- package/.docs/raw/docs/guides/latex.mdx +2 -2
- package/.docs/raw/docs/guides/mcp-apps.mdx +231 -0
- package/.docs/raw/docs/guides/mentions.mdx +2 -2
- package/.docs/raw/docs/guides/message-timing.mdx +38 -5
- package/.docs/raw/docs/guides/multi-agent.mdx +2 -2
- package/.docs/raw/docs/guides/quoting.mdx +1 -1
- package/.docs/raw/docs/guides/resumable-stream-deployment.mdx +212 -0
- package/.docs/raw/docs/guides/resumable-stream-stores.mdx +152 -0
- package/.docs/raw/docs/guides/resumable-streams.mdx +210 -0
- package/.docs/raw/docs/guides/slash-commands.mdx +1 -1
- package/.docs/raw/docs/guides/speech.mdx +2 -2
- package/.docs/raw/docs/guides/suggestions.mdx +88 -4
- package/.docs/raw/docs/guides/tool-ui.mdx +2 -2
- package/.docs/raw/docs/guides/tools.mdx +66 -5
- package/.docs/raw/docs/guides/voice.mdx +2 -2
- package/.docs/raw/docs/ink/adapters.mdx +37 -1
- package/.docs/raw/docs/ink/custom-backend.mdx +59 -8
- package/.docs/raw/docs/ink/index.mdx +10 -11
- package/.docs/raw/docs/ink/primitives.mdx +349 -8
- package/.docs/raw/docs/integrations/auth/better-auth.mdx +1 -1
- package/.docs/raw/docs/integrations/auth/clerk.mdx +1 -1
- package/.docs/raw/docs/integrations/auth/next-auth.mdx +1 -1
- package/.docs/raw/docs/integrations/frameworks/ai-sdk.mdx +2 -2
- package/.docs/raw/docs/integrations/frameworks/cloudflare-agents/overview.mdx +282 -0
- package/.docs/raw/docs/integrations/frameworks/mastra/overview.mdx +2 -2
- package/.docs/raw/docs/integrations/frameworks/mastra/separate-server.mdx +1 -1
- package/.docs/raw/docs/integrations/gateways/index.mdx +9 -4
- package/.docs/raw/docs/integrations/index.mdx +15 -3
- package/.docs/raw/docs/integrations/observability/langfuse.mdx +8 -1
- package/.docs/raw/docs/integrations/observability/langsmith.mdx +8 -4
- package/.docs/raw/docs/integrations/tools/react-mcp.mdx +337 -0
- package/.docs/raw/docs/primitives/composer.mdx +53 -0
- package/.docs/raw/docs/primitives/index.mdx +2 -2
- package/.docs/raw/docs/primitives/suggestion.mdx +9 -0
- package/.docs/raw/docs/react-native/hooks.mdx +2 -2
- package/.docs/raw/docs/react-native/index.mdx +4 -4
- package/.docs/raw/docs/runtimes/a2a/client-and-hooks.mdx +4 -4
- package/.docs/raw/docs/runtimes/a2a/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/ag-ui/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/ag-ui/runtime-options.mdx +32 -1
- package/.docs/raw/docs/runtimes/ai-sdk/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/ai-sdk/v4-legacy.mdx +7 -2
- package/.docs/raw/docs/runtimes/ai-sdk/v5-legacy.mdx +1 -1
- package/.docs/raw/docs/runtimes/ai-sdk/v6.mdx +7 -0
- package/.docs/raw/docs/runtimes/custom/data-stream.mdx +27 -2
- package/.docs/raw/docs/runtimes/custom/external-store.mdx +9 -1
- package/.docs/raw/docs/runtimes/custom/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/google-adk/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/langchain.mdx +2 -2
- package/.docs/raw/docs/runtimes/langgraph/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/opencode/overview.mdx +2 -2
- package/.docs/raw/docs/runtimes/opencode/quickstart.mdx +1 -1
- package/.docs/raw/docs/runtimes/pick-a-runtime.mdx +12 -5
- package/.docs/raw/docs/ui/composer-trigger-popover.mdx +11 -1
- package/.docs/raw/docs/ui/mcp-config.mdx +102 -0
- package/.docs/raw/docs/ui/model-selector.mdx +8 -8
- package/.docs/raw/docs/ui/sources.mdx +17 -0
- package/.docs/raw/docs/ui/streamdown.mdx +34 -2
- package/.docs/raw/docs/ui/thread-list.mdx +2 -2
- package/.docs/raw/docs/ui/thread.mdx +2 -2
- package/README.md +14 -72
- package/dist/constants.d.ts +12 -9
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +13 -9
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -24
- package/dist/index.js.map +1 -1
- package/dist/prepare-docs/code-examples.d.ts +4 -1
- package/dist/prepare-docs/code-examples.d.ts.map +1 -1
- package/dist/prepare-docs/code-examples.js +109 -121
- package/dist/prepare-docs/code-examples.js.map +1 -1
- package/dist/prepare-docs/copy-raw.d.ts +4 -1
- package/dist/prepare-docs/copy-raw.d.ts.map +1 -1
- package/dist/prepare-docs/copy-raw.js +45 -42
- package/dist/prepare-docs/copy-raw.js.map +1 -1
- package/dist/prepare-docs/prepare.d.ts +1 -2
- package/dist/prepare-docs/prepare.js +17 -17
- package/dist/prepare-docs/prepare.js.map +1 -1
- package/dist/stdio.d.ts +1 -3
- package/dist/stdio.js +6 -3
- package/dist/stdio.js.map +1 -1
- package/dist/tools/docs.d.ts +20 -15
- package/dist/tools/docs.d.ts.map +1 -1
- package/dist/tools/docs.js +140 -161
- package/dist/tools/docs.js.map +1 -1
- package/dist/tools/examples.d.ts +20 -15
- package/dist/tools/examples.d.ts.map +1 -1
- package/dist/tools/examples.js +74 -86
- package/dist/tools/examples.js.map +1 -1
- package/dist/tools/tests/test-setup.d.ts +5 -2
- package/dist/tools/tests/test-setup.d.ts.map +1 -1
- package/dist/tools/tests/test-setup.js +21 -28
- package/dist/tools/tests/test-setup.js.map +1 -1
- package/dist/utils/logger.d.ts +8 -5
- package/dist/utils/logger.d.ts.map +1 -1
- package/dist/utils/logger.js +17 -17
- package/dist/utils/logger.js.map +1 -1
- package/dist/utils/mcp-format.d.ts +8 -5
- package/dist/utils/mcp-format.d.ts.map +1 -1
- package/dist/utils/mcp-format.js +9 -9
- package/dist/utils/mcp-format.js.map +1 -1
- package/dist/utils/mdx.d.ts +8 -6
- package/dist/utils/mdx.d.ts.map +1 -1
- package/dist/utils/mdx.js +22 -22
- package/dist/utils/mdx.js.map +1 -1
- package/dist/utils/paths.d.ts +9 -6
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +66 -76
- package/dist/utils/paths.js.map +1 -1
- package/dist/utils/security.d.ts +4 -1
- package/dist/utils/security.d.ts.map +1 -1
- package/dist/utils/security.js +19 -40
- package/dist/utils/security.js.map +1 -1
- package/package.json +5 -5
- package/.docs/raw/docs/(reference)/api-reference/adapters/feedback-speech.mdx +0 -41
- package/.docs/raw/docs/(reference)/api-reference/context-providers/text-message-part-provider.mdx +0 -40
- package/.docs/raw/docs/(reference)/api-reference/integrations/react-data-stream.mdx +0 -260
- package/.docs/raw/docs/(reference)/api-reference/integrations/react-hook-form.mdx +0 -103
- package/.docs/raw/docs/(reference)/api-reference/integrations/vercel-ai-sdk.mdx +0 -254
- package/dist/prepare-docs/prepare.d.ts.map +0 -1
- package/dist/stdio.d.ts.map +0 -1
- /package/.docs/raw/docs/{(reference)/migrations → migrations}/deprecation-policy.mdx +0 -0
- /package/.docs/raw/docs/{(reference) → migrations}/react-compatibility.mdx +0 -0
- /package/.docs/raw/docs/{(reference)/migrations → migrations}/react-langgraph-v0-7.mdx +0 -0
- /package/.docs/raw/docs/{(reference)/migrations → migrations}/v0-11.mdx +0 -0
- /package/.docs/raw/docs/{(reference)/migrations → migrations}/v0-12.mdx +0 -0
- /package/.docs/raw/docs/{(reference)/migrations → migrations}/v0-14.mdx +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Resumable Stream Deployment
|
|
3
|
+
description: Production hardening for resumable streams. Authorization, serverless lifetimes, TTLs, key isolation, observability, resource limits, and incident response.
|
|
4
|
+
platforms: ["react"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
This guide assumes you have the basic wiring from [Resumable Streams](/docs/guides/resumable-streams) in place and focuses on what to add before serving production traffic.
|
|
8
|
+
|
|
9
|
+
## Authentication and authorization
|
|
10
|
+
|
|
11
|
+
The default resume endpoint serves any caller that knows the `streamId`. Treat the id as opaque, not as a credential. Bind every newly created `streamId` to the requesting user at acquire time and verify the binding on every resume.
|
|
12
|
+
|
|
13
|
+
Store the binding next to the rest of your session state, or in Redis under a separate key. The example below uses a parallel `<keyPrefix>:owner:<streamId>` entry that mirrors the TTL of the underlying stream.
|
|
14
|
+
|
|
15
|
+
```ts title="/lib/resumable-context.ts"
|
|
16
|
+
import { createResumableStreamContext } from "assistant-stream/resumable";
|
|
17
|
+
import { redis } from "@/lib/redis";
|
|
18
|
+
import { store } from "@/lib/resumable-store";
|
|
19
|
+
|
|
20
|
+
const OWNER_PREFIX = "aui:resumable:owner";
|
|
21
|
+
const OWNER_TTL_SEC = 24 * 60 * 60;
|
|
22
|
+
|
|
23
|
+
export const resumableContext = createResumableStreamContext({ store });
|
|
24
|
+
|
|
25
|
+
export async function bindStreamToUser(streamId: string, userId: string) {
|
|
26
|
+
await redis.set(`${OWNER_PREFIX}:${streamId}`, userId, { EX: OWNER_TTL_SEC });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function assertStreamOwner(streamId: string, userId: string) {
|
|
30
|
+
const owner = await redis.get(`${OWNER_PREFIX}:${streamId}`);
|
|
31
|
+
if (owner !== userId) {
|
|
32
|
+
throw new Response("Not Found", { status: 404 });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Wrap `resume` with the ownership check. Returning 404 (not 403) avoids confirming the existence of a stream the caller does not own.
|
|
38
|
+
|
|
39
|
+
```ts title="/app/api/chat/resume/[streamId]/route.ts"
|
|
40
|
+
import { assertStreamOwner, resumableContext } from "@/lib/resumable-context";
|
|
41
|
+
import { getSessionUserId } from "@/lib/auth";
|
|
42
|
+
|
|
43
|
+
export async function GET(
|
|
44
|
+
req: Request,
|
|
45
|
+
ctx: { params: Promise<{ streamId: string }> },
|
|
46
|
+
) {
|
|
47
|
+
const userId = await getSessionUserId(req);
|
|
48
|
+
if (!userId) return new Response("Unauthorized", { status: 401 });
|
|
49
|
+
|
|
50
|
+
const { streamId } = await ctx.params;
|
|
51
|
+
await assertStreamOwner(streamId, userId);
|
|
52
|
+
|
|
53
|
+
const stream = await resumableContext.resume(streamId);
|
|
54
|
+
if (!stream) return new Response("Not Found", { status: 404 });
|
|
55
|
+
|
|
56
|
+
return new Response(stream, {
|
|
57
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## `waitUntil` on serverless
|
|
63
|
+
|
|
64
|
+
On Vercel and Cloudflare the request handler is torn down once the response is returned, taking the producer task with it. Without a `waitUntil` hook the persisted stream stops growing the moment the originating request unwinds, so reconnects only see the bytes that happened to land before the response flushed.
|
|
65
|
+
|
|
66
|
+
On Vercel, pass `after` from `next/server`:
|
|
67
|
+
|
|
68
|
+
```ts title="/lib/resumable-context.ts"
|
|
69
|
+
import { after } from "next/server";
|
|
70
|
+
import { createResumableStreamContext } from "assistant-stream/resumable";
|
|
71
|
+
import { store } from "@/lib/resumable-store";
|
|
72
|
+
|
|
73
|
+
export const resumableContext = createResumableStreamContext({
|
|
74
|
+
store,
|
|
75
|
+
waitUntil: after,
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
On Cloudflare Workers, take the `ExecutionContext` from your handler and forward `ctx.waitUntil`:
|
|
80
|
+
|
|
81
|
+
```ts title="/src/worker.ts"
|
|
82
|
+
import { createResumableStreamContext } from "assistant-stream/resumable";
|
|
83
|
+
import { store } from "./resumable-store";
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
|
|
87
|
+
const resumableContext = createResumableStreamContext({
|
|
88
|
+
store,
|
|
89
|
+
waitUntil: (promise) => ctx.waitUntil(promise),
|
|
90
|
+
});
|
|
91
|
+
return handle(req, resumableContext);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
In long-lived Node servers (a custom Express app, a container) `waitUntil` can be omitted; the producer task runs on the same event loop as the handler and is not preempted.
|
|
97
|
+
|
|
98
|
+
## TTL strategy
|
|
99
|
+
|
|
100
|
+
Streams expire 24 hours after the last write. The default suits typical chat workloads where a user might reload after lunch, but every deployment should pick a number deliberately.
|
|
101
|
+
|
|
102
|
+
- Shorten when chunks contain sensitive payloads (PII, drafts, internal documents). A 5 to 30 minute window usually covers reload survival without leaving recoverable bytes around.
|
|
103
|
+
- Extend for long-running agent tasks that may legitimately stretch past a day. Set the TTL above the worst-case task duration so the producer can still finalize.
|
|
104
|
+
- Match TTLs across layers. The store TTL, the owner-binding TTL, and any signed cookie that references `streamId` should expire together; otherwise one outlives the other and either leaks or 404s unexpectedly.
|
|
105
|
+
|
|
106
|
+
Configure on the store for the global default and on the context for a per-deployment override:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import {
|
|
110
|
+
createInMemoryResumableStreamStore,
|
|
111
|
+
createResumableStreamContext,
|
|
112
|
+
} from "assistant-stream/resumable";
|
|
113
|
+
|
|
114
|
+
const store = createInMemoryResumableStreamStore({
|
|
115
|
+
defaultTtlMs: 30 * 60 * 1000,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const resumableContext = createResumableStreamContext({
|
|
119
|
+
store,
|
|
120
|
+
ttlMs: 30 * 60 * 1000,
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The Redis adapters accept the same `defaultTtlMs` option.
|
|
125
|
+
|
|
126
|
+
## Multi-tenant key isolation
|
|
127
|
+
|
|
128
|
+
When multiple apps or tenants share a Redis instance, set `keyPrefix` per environment so a misconfigured stream in one tenant cannot collide with, or be deleted alongside, another's. The prefix becomes the leading segment of every meta and data key.
|
|
129
|
+
|
|
130
|
+
```ts title="/lib/resumable-store.ts"
|
|
131
|
+
import { createClient } from "redis";
|
|
132
|
+
import { createRedisResumableStreamStore } from "assistant-stream/resumable/redis";
|
|
133
|
+
|
|
134
|
+
const client = createClient({ url: process.env.REDIS_URL });
|
|
135
|
+
await client.connect();
|
|
136
|
+
|
|
137
|
+
export const store = createRedisResumableStreamStore(client, {
|
|
138
|
+
keyPrefix: `aui:${process.env.APP_NAME}:${process.env.TENANT_ID}`,
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Per-tenant prefixes also make incident response cheaper. A `SCAN MATCH aui:app:tenant-42:*` lets you audit or purge a single tenant without touching the rest.
|
|
143
|
+
|
|
144
|
+
## Observability hooks
|
|
145
|
+
|
|
146
|
+
`ResumableStreamContextOptions` exposes lifecycle hooks for structured logging, metrics, and tracing. Each hook is invoked synchronously around the underlying store call; throwing inside a hook surfaces as a producer error.
|
|
147
|
+
|
|
148
|
+
```ts title="/lib/resumable-context.ts"
|
|
149
|
+
import { createResumableStreamContext } from "assistant-stream/resumable";
|
|
150
|
+
import { logger, metrics } from "@/lib/observability";
|
|
151
|
+
import { store } from "@/lib/resumable-store";
|
|
152
|
+
|
|
153
|
+
export const resumableContext = createResumableStreamContext({
|
|
154
|
+
store,
|
|
155
|
+
onAcquire: (streamId, role) => {
|
|
156
|
+
metrics.increment("resumable.acquire", { role });
|
|
157
|
+
logger.info("resumable.acquire", { streamId, role });
|
|
158
|
+
},
|
|
159
|
+
onAppend: (streamId, byteLength) => {
|
|
160
|
+
metrics.histogram("resumable.append.bytes", byteLength);
|
|
161
|
+
},
|
|
162
|
+
onFinalize: (streamId, status, error) => {
|
|
163
|
+
metrics.increment("resumable.finalize", { status });
|
|
164
|
+
logger.info("resumable.finalize", { streamId, status, error });
|
|
165
|
+
},
|
|
166
|
+
onError: (streamId, error) => {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
logger.error("resumable.error", { streamId, error: message });
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Keep hook bodies cheap. They run on the producer's hot path and any latency they add becomes streaming latency seen by the client.
|
|
174
|
+
|
|
175
|
+
## Resource limits
|
|
176
|
+
|
|
177
|
+
The in-memory store enforces three caps that the Redis adapters intentionally leave to the underlying database. Set them whenever your process can be reached by untrusted callers.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { createInMemoryResumableStreamStore } from "assistant-stream/resumable";
|
|
181
|
+
|
|
182
|
+
const store = createInMemoryResumableStreamStore({
|
|
183
|
+
maxChunkBytes: 64 * 1024,
|
|
184
|
+
maxEntriesPerStream: 5000,
|
|
185
|
+
maxStreams: 10_000,
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- `maxChunkBytes` rejects oversized writes from a misbehaving producer (a runaway tool result, a base64 blob accidentally piped through). The producer task fails fast instead of pinning memory.
|
|
190
|
+
- `maxEntriesPerStream` caps the per-stream entry count. This bounds how much any single stream can grow before it starts erroring; pair it with TTLs so finalized streams clear quickly.
|
|
191
|
+
- `maxStreams` caps total live streams. Useful as a backstop in shared development environments and in single-tenant containers; in serverless deployments the platform already constrains concurrency.
|
|
192
|
+
|
|
193
|
+
These limits exist on the in-memory store. For Redis, configure `maxmemory` and an eviction policy on the server, and rely on application-level rate limiting upstream.
|
|
194
|
+
|
|
195
|
+
## Incident response
|
|
196
|
+
|
|
197
|
+
The streamId leaks through response headers, browser session storage, server access logs, and (in some setups) error reports. If you suspect any of those channels were compromised, treat all in-flight stream ids as exposed.
|
|
198
|
+
|
|
199
|
+
What to log up front, so you have it when you need it:
|
|
200
|
+
|
|
201
|
+
- The acquiring user id, request id, and IP for every `acquire` call (via `onAcquire`).
|
|
202
|
+
- The finalize status (and any error) for every stream (via `onFinalize`).
|
|
203
|
+
- The owner-binding writes and reads, with the user id and the streamId.
|
|
204
|
+
|
|
205
|
+
What to rotate or invalidate during an incident:
|
|
206
|
+
|
|
207
|
+
- Bump `keyPrefix` on the store. Existing streams become unreachable and new ones land under the rotated namespace.
|
|
208
|
+
- Invalidate signed session cookies that reference any cached streamId.
|
|
209
|
+
- Drop the owner-binding keys for affected users (`DEL aui:resumable:owner:*` scoped by user) so resumes are forced through a fresh acquire.
|
|
210
|
+
- Shorten `defaultTtlMs` temporarily so any orphaned stream rolls off quickly.
|
|
211
|
+
|
|
212
|
+
After rotation, reissue stream ids server-side and redirect clients through a fresh acquire; do not trust any streamId the client already holds.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Custom Resumable Stream Stores
|
|
3
|
+
description: Implement the ResumableStreamStore interface to back resumable streams with Postgres, Cloudflare Durable Objects, Upstash REST, InstantDB, or any other backend.
|
|
4
|
+
platforms: ["react"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
The built-in InMemory and Redis adapters cover most deployments. Write your own `ResumableStreamStore` when you need a backend you already operate (Postgres, MySQL), an edge-native primitive (Cloudflare Durable Objects, Workers KV), an HTTP-only key-value service (Upstash REST), or a realtime database (InstantDB). The contract is six async methods over an opaque `streamId` and a monotonic byte log.
|
|
8
|
+
|
|
9
|
+
## Interface walkthrough
|
|
10
|
+
|
|
11
|
+
The full interface lives in `assistant-stream/resumable`:
|
|
12
|
+
|
|
13
|
+
```ts title="packages/assistant-stream/src/resumable/types.ts"
|
|
14
|
+
export interface ResumableStreamStore {
|
|
15
|
+
acquire(
|
|
16
|
+
streamId: string,
|
|
17
|
+
options?: ResumableStreamAcquireOptions,
|
|
18
|
+
): Promise<ResumableStreamRole>;
|
|
19
|
+
append(streamId: string, chunk: Uint8Array): Promise<void>;
|
|
20
|
+
finalize(
|
|
21
|
+
streamId: string,
|
|
22
|
+
status: "done" | "error",
|
|
23
|
+
error?: string,
|
|
24
|
+
): Promise<void>;
|
|
25
|
+
read(
|
|
26
|
+
streamId: string,
|
|
27
|
+
cursor: string,
|
|
28
|
+
signal: AbortSignal,
|
|
29
|
+
): AsyncIterable<ResumableStreamEntry>;
|
|
30
|
+
status(streamId: string): Promise<ResumableStreamStatus>;
|
|
31
|
+
delete(streamId: string): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`acquire(streamId, options?)` arbitrates ownership. The first caller for a given `streamId` resolves to `"producer"`; every later caller, including those arriving after `finalize`, resolves to `"consumer"`. Implementations must perform the check and the insert atomically (see below). `options.ttlMs` overrides the store default for this stream; honor it when you set the expiration timestamp.
|
|
36
|
+
|
|
37
|
+
`append(streamId, chunk)` adds a `Uint8Array` to the log under a fresh, monotonically increasing cursor. Callers expect the chunk to be observable to `read` before the promise resolves. Implementations should refresh the TTL on each call so a stream that is still actively producing does not expire mid-flight, and should reject when the stream is missing or already finalized.
|
|
38
|
+
|
|
39
|
+
`finalize(streamId, status, error?)` flips the stream into a terminal state. Pending and future `read` iterables drain buffered entries and then either complete (`"done"`) or throw with `error` (`"error"`). Implementations must make `finalize` idempotent: a duplicate call with the same status is a no-op, and the producer task may retry on transient errors.
|
|
40
|
+
|
|
41
|
+
`read(streamId, cursor, signal)` is the only streaming method. It yields every entry whose cursor sorts strictly after the supplied `cursor`, then waits for new appends, then completes when the stream finalizes. Aborting `signal` resolves the iterable cleanly without throwing. Networked stores typically combine a bounded fetch loop with pub/sub, long-poll, or notify wakeups; do not busy-loop.
|
|
42
|
+
|
|
43
|
+
`status(streamId)` returns one of `"streaming" | "done" | "error" | "missing"` synchronously with respect to the underlying store. It exists so the context can decide whether to start a new producer or attach a consumer without holding a `read` iterator open.
|
|
44
|
+
|
|
45
|
+
`delete(streamId)` removes all state for the stream. It must be a no-op when the stream does not exist, and it should cause active `read` iterables to terminate (treat outstanding readers as if the stream finalized).
|
|
46
|
+
|
|
47
|
+
## Acquire semantics
|
|
48
|
+
|
|
49
|
+
`acquire` is the only method that requires linearizability across processes. Two route handlers that race to start the same `streamId` must see exactly one `"producer"` result; the loser becomes a `"consumer"` and replays the winner's bytes. A single-process store can guard a `Map` with a synchronous `if (!map.has(id)) map.set(id, ...)`. Networked stores need a primitive that does the check and the insert in one round trip:
|
|
50
|
+
|
|
51
|
+
- Redis: `SET key value NX EX ttl`, or `INCR` against a per-stream counter.
|
|
52
|
+
- Postgres: `INSERT ... ON CONFLICT (stream_id) DO NOTHING RETURNING ...`.
|
|
53
|
+
- Durable Objects: a single object instance per `streamId` plus a boolean field.
|
|
54
|
+
- Upstash REST: `set` with `nx=true`.
|
|
55
|
+
|
|
56
|
+
If your backend cannot offer atomicity, do not paper over it with read-then-write; you will silently produce two writers for the same stream under contention, and consumers will observe interleaved bytes.
|
|
57
|
+
|
|
58
|
+
## The cursor contract
|
|
59
|
+
|
|
60
|
+
Cursors are opaque strings. Callers never inspect them; the store assigns them, the context echoes them back on the next `read` call, and the store uses them to resume from the correct position. Two rules:
|
|
61
|
+
|
|
62
|
+
- Cursors must be strictly monotonic per stream. Whatever scheme you pick (sequence number, ULID, Postgres `bigserial`, Redis stream id), entry N+1 sorts after entry N.
|
|
63
|
+
- The empty string means start from the beginning. `read(streamId, "", signal)` yields every entry the store has, oldest first.
|
|
64
|
+
|
|
65
|
+
You do not need cross-stream ordering. You do need a deterministic mapping from cursor back to position so that `read` can resume a consumer that disconnected mid-replay.
|
|
66
|
+
|
|
67
|
+
## A worked example
|
|
68
|
+
|
|
69
|
+
A `Map`-backed implementation suitable for a single-process server. It is deliberately small and skips TTL eviction; treat it as a starting point for a custom backend rather than a replacement for `createInMemoryResumableStreamStore`.
|
|
70
|
+
|
|
71
|
+
```ts title="/lib/map-resumable-store.ts"
|
|
72
|
+
import type { ResumableStreamStore } from "assistant-stream/resumable";
|
|
73
|
+
|
|
74
|
+
type State = {
|
|
75
|
+
entries: { cursor: string; chunk: Uint8Array }[];
|
|
76
|
+
seq: number;
|
|
77
|
+
final?: { status: "done" | "error"; error?: string };
|
|
78
|
+
waiters: Array<() => void>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function createMapResumableStreamStore(): ResumableStreamStore {
|
|
82
|
+
const streams = new Map<string, State>();
|
|
83
|
+
const wake = (s: State) => s.waiters.splice(0).forEach((fn) => fn());
|
|
84
|
+
return {
|
|
85
|
+
async acquire(id) {
|
|
86
|
+
if (streams.has(id)) return "consumer";
|
|
87
|
+
streams.set(id, { entries: [], seq: 0, waiters: [] });
|
|
88
|
+
return "producer";
|
|
89
|
+
},
|
|
90
|
+
async append(id, chunk) {
|
|
91
|
+
const s = streams.get(id);
|
|
92
|
+
if (!s || s.final) throw new Error(`Cannot append: ${id}`);
|
|
93
|
+
s.entries.push({ cursor: (++s.seq).toString(36), chunk });
|
|
94
|
+
wake(s);
|
|
95
|
+
},
|
|
96
|
+
async finalize(id, status, error) {
|
|
97
|
+
const s = streams.get(id);
|
|
98
|
+
if (!s || s.final) return;
|
|
99
|
+
s.final = { status, error };
|
|
100
|
+
wake(s);
|
|
101
|
+
},
|
|
102
|
+
async *read(id, cursor, signal) {
|
|
103
|
+
const s = streams.get(id);
|
|
104
|
+
if (!s) throw new Error(`Stream not found: ${id}`);
|
|
105
|
+
let i = cursor === "" ? 0 : Number.parseInt(cursor, 36);
|
|
106
|
+
while (!signal.aborted) {
|
|
107
|
+
while (i < s.entries.length) yield s.entries[i++]!;
|
|
108
|
+
if (s.final) {
|
|
109
|
+
if (s.final.status === "error") throw new Error(s.final.error);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await new Promise<void>((r) => {
|
|
113
|
+
s.waiters.push(r);
|
|
114
|
+
signal.addEventListener("abort", () => r(), { once: true });
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
async status(id) {
|
|
119
|
+
const s = streams.get(id);
|
|
120
|
+
return !s ? "missing" : s.final ? s.final.status : "streaming";
|
|
121
|
+
},
|
|
122
|
+
async delete(id) {
|
|
123
|
+
const s = streams.get(id);
|
|
124
|
+
if (!s) return;
|
|
125
|
+
streams.delete(id);
|
|
126
|
+
s.final ??= { status: "done" };
|
|
127
|
+
wake(s);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## TTL and eviction
|
|
134
|
+
|
|
135
|
+
`acquire` receives `options.ttlMs`; if absent, fall back to a store-level default (the built-in stores use 24 hours). Refresh the expiration on every `append` and on `finalize` so a stream that finishes near the deadline still has time to be consumed. Persist the TTL alongside the entries so a worker reading the stream much later can decide whether the data is still valid.
|
|
136
|
+
|
|
137
|
+
When a stream expires, treat it the same as `finalize(streamId, "error", "Stream expired")`: any active `read` iterable must throw or terminate, and `status` must transition to `"missing"` once the eviction has run. Stores backed by Redis or a similar TTL-aware engine can lean on the engine's own expiration; SQL-backed stores need a periodic sweep, and Durable Objects can use `setAlarm`.
|
|
138
|
+
|
|
139
|
+
## Wiring it up
|
|
140
|
+
|
|
141
|
+
`createResumableStreamContext` takes any object that satisfies `ResumableStreamStore`. There is no registry and no extra configuration; pass your instance as `store`:
|
|
142
|
+
|
|
143
|
+
```ts title="/lib/resumable-context.ts"
|
|
144
|
+
import { createResumableStreamContext } from "assistant-stream/resumable";
|
|
145
|
+
import { createMapResumableStreamStore } from "@/lib/map-resumable-store";
|
|
146
|
+
|
|
147
|
+
export const resumableContext = createResumableStreamContext({
|
|
148
|
+
store: createMapResumableStreamStore(),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
From this point the route handlers in [Resumable Streams](/docs/guides/resumable-streams) work unchanged: `resumableContext.run(streamId, makeStream)` calls your `acquire`, `append`, and `finalize`, and `resumableContext.resume(streamId)` calls your `read`.
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Resumable Streams"
|
|
3
|
+
description: Persist an in-flight LLM response on the server so the client can reload, lose its connection, or open a new tab and pick up the same stream.
|
|
4
|
+
platforms: ["react"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
`assistant-stream/resumable` lets you continue a streaming LLM response across client reconnects. The server keeps writing to a store while the original request is in flight; if the browser reloads or loses its connection, a follow-up request replays the persisted bytes plus any new ones until the producer finalizes.
|
|
8
|
+
|
|
9
|
+
It works with any encoder that already ships in `assistant-stream` (the AI SDK UI message stream, the data stream protocol, the assistant transport SSE format, or your own), because persistence happens at the byte level after encoding.
|
|
10
|
+
|
|
11
|
+
## What it solves
|
|
12
|
+
|
|
13
|
+
A user sends a long prompt, walks away, and reloads the tab. Without resumable streams the LLM call is wasted; with them the client picks up where it left off. The same flow handles dropped mobile connections and lets a stream started on one device be read on another, gated by an opaque stream id.
|
|
14
|
+
|
|
15
|
+
If your responses are short or you do not care about reload survival, the standard `streamText().toUIMessageStreamResponse()` path is enough.
|
|
16
|
+
|
|
17
|
+
## Server side: minimum wiring
|
|
18
|
+
|
|
19
|
+
Construct a `ResumableStreamContext` once per process and reuse it across requests. The context is the seam between your route handlers and the storage backend.
|
|
20
|
+
|
|
21
|
+
```ts title="/lib/resumable-context.ts"
|
|
22
|
+
import {
|
|
23
|
+
createInMemoryResumableStreamStore,
|
|
24
|
+
createResumableStreamContext,
|
|
25
|
+
} from "assistant-stream/resumable";
|
|
26
|
+
|
|
27
|
+
const store = createInMemoryResumableStreamStore();
|
|
28
|
+
export const resumableContext = createResumableStreamContext({ store });
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
In your chat route, wrap the response body in `ctx.run(streamId, makeStream)`. The first caller for `streamId` becomes the producer (your `makeStream` callback runs); later callers and reconnects become consumers that replay the persisted bytes.
|
|
32
|
+
|
|
33
|
+
```ts title="/app/api/chat/route.ts"
|
|
34
|
+
import { streamText } from "ai";
|
|
35
|
+
import { RESUMABLE_STREAM_ID_HEADER } from "assistant-stream/resumable";
|
|
36
|
+
import { resumableContext } from "@/lib/resumable-context";
|
|
37
|
+
|
|
38
|
+
export async function POST(req: Request) {
|
|
39
|
+
const { messages } = await req.json();
|
|
40
|
+
const streamId = crypto.randomUUID();
|
|
41
|
+
|
|
42
|
+
const result = streamText({ /* model, messages, tools, ... */ });
|
|
43
|
+
const sourceBody = result.toUIMessageStreamResponse().body!;
|
|
44
|
+
|
|
45
|
+
const stream = await resumableContext.run(streamId, () => sourceBody);
|
|
46
|
+
|
|
47
|
+
return new Response(stream, {
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "text/event-stream",
|
|
50
|
+
[RESUMABLE_STREAM_ID_HEADER]: streamId,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A separate GET endpoint replays the persisted bytes for reconnecting clients. `ctx.resume(streamId)` returns `null` when no stream exists; use `ctx.requireResume(streamId)` if you prefer to surface a `ResumableStreamError` with code `"missing"` instead.
|
|
57
|
+
|
|
58
|
+
```ts title="/app/api/chat/resume/[streamId]/route.ts"
|
|
59
|
+
import { RESUMABLE_STREAM_ID_HEADER } from "assistant-stream/resumable";
|
|
60
|
+
import { resumableContext } from "@/lib/resumable-context";
|
|
61
|
+
|
|
62
|
+
export async function GET(
|
|
63
|
+
_req: Request,
|
|
64
|
+
ctx: { params: Promise<{ streamId: string }> },
|
|
65
|
+
) {
|
|
66
|
+
const { streamId } = await ctx.params;
|
|
67
|
+
const stream = await resumableContext.resume(streamId);
|
|
68
|
+
if (!stream) {
|
|
69
|
+
return new Response(JSON.stringify({ error: "stream not found" }), {
|
|
70
|
+
status: 404,
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return new Response(stream, {
|
|
75
|
+
headers: {
|
|
76
|
+
"Content-Type": "text/event-stream",
|
|
77
|
+
[RESUMABLE_STREAM_ID_HEADER]: streamId,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The context exposes two more verbs: `ctx.status(streamId)` returns `"streaming" | "done" | "error" | "missing"`, and `ctx.delete(streamId)` removes all persisted state for a stream and terminates active readers. The remaining options on `createResumableStreamContext` (`onAcquire`, `onAppend`, `onFinalize`, `onError`) are observability hooks covered in [Resumable Stream Deployment](/docs/guides/resumable-stream-deployment).
|
|
84
|
+
|
|
85
|
+
## Client side: native integration
|
|
86
|
+
|
|
87
|
+
`@assistant-ui/react-ai-sdk` ships a `resumable` option on `AssistantChatTransport`. It captures the stream id from the response header, redirects `chat.resumeStream()` reconnects to your resume route, and clears the stored id when the response finishes naturally. Pair it with `useChatRuntime`, which fires `chat.resumeStream()` on mount whenever a pending id is present in storage.
|
|
88
|
+
|
|
89
|
+
```tsx title="/app/page.tsx"
|
|
90
|
+
"use client";
|
|
91
|
+
|
|
92
|
+
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
|
93
|
+
import {
|
|
94
|
+
AssistantChatTransport,
|
|
95
|
+
createResumableSessionStorage,
|
|
96
|
+
useChatRuntime,
|
|
97
|
+
} from "@assistant-ui/react-ai-sdk";
|
|
98
|
+
import { useMemo } from "react";
|
|
99
|
+
import { Thread } from "@/components/assistant-ui/thread";
|
|
100
|
+
|
|
101
|
+
const storage = createResumableSessionStorage();
|
|
102
|
+
|
|
103
|
+
export default function Page() {
|
|
104
|
+
const transport = useMemo(
|
|
105
|
+
() =>
|
|
106
|
+
new AssistantChatTransport({
|
|
107
|
+
api: "/api/chat",
|
|
108
|
+
resumable: {
|
|
109
|
+
storage,
|
|
110
|
+
resumeApi: (streamId) => `/api/chat/resume/${streamId}`,
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
[],
|
|
114
|
+
);
|
|
115
|
+
const runtime = useChatRuntime({ transport });
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
119
|
+
<Thread />
|
|
120
|
+
</AssistantRuntimeProvider>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`createResumableSessionStorage` returns a `ResumableClientStorage` backed by `window.sessionStorage`. Pass `{ key }` to namespace per route or per chat surface, or supply your own implementation of the three methods (`getStreamId`, `setStreamId`, `clear`). If you are running on a transport that already wraps `fetch` or `prepareReconnectToStreamRequest`, the `resumable` option composes with your existing handlers.
|
|
126
|
+
|
|
127
|
+
The default finish detector scans the SSE body for the AI SDK `"type":"finish"` marker. Override `isFinishEvent` on the `resumable` option when you ship a custom encoder.
|
|
128
|
+
|
|
129
|
+
## Storage choices
|
|
130
|
+
|
|
131
|
+
The core package ships `createInMemoryResumableStreamStore` for development and tests. State lives in a process-local `Map`, so it does not survive a server restart. Useful options include `defaultTtlMs`, `maxChunkBytes`, `maxEntriesPerStream`, `maxStreams`, and `gcIntervalMs` for periodic eviction.
|
|
132
|
+
|
|
133
|
+
For production, use one of the optional Redis adapters via the `assistant-stream/resumable/redis` (node-redis v5) or `assistant-stream/resumable/ioredis` sub-paths. Both adapters batch the per-append `XADD` and TTL refresh into a single pipelined round trip, store chunk values as binary, and accept the same `keyPrefix`, `defaultTtlMs`, `pollIntervalMs`, and `maxChunkBytes` options. Cluster routing works because each stream's keys share a `{streamId}` hash tag.
|
|
134
|
+
|
|
135
|
+
```ts title="/lib/resumable-context.ts"
|
|
136
|
+
import {
|
|
137
|
+
createResumableStreamContext,
|
|
138
|
+
type ResumableStreamStore,
|
|
139
|
+
} from "assistant-stream/resumable";
|
|
140
|
+
|
|
141
|
+
async function createStore(): Promise<ResumableStreamStore> {
|
|
142
|
+
if (!process.env.REDIS_URL) {
|
|
143
|
+
const { createInMemoryResumableStreamStore } = await import(
|
|
144
|
+
"assistant-stream/resumable"
|
|
145
|
+
);
|
|
146
|
+
return createInMemoryResumableStreamStore();
|
|
147
|
+
}
|
|
148
|
+
const { createClient } = await import("redis");
|
|
149
|
+
const { createRedisResumableStreamStore } = await import(
|
|
150
|
+
"assistant-stream/resumable/redis"
|
|
151
|
+
);
|
|
152
|
+
const client = createClient({ url: process.env.REDIS_URL });
|
|
153
|
+
await client.connect();
|
|
154
|
+
return createRedisResumableStreamStore(client);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const resumableContext = createResumableStreamContext({
|
|
158
|
+
store: await createStore(),
|
|
159
|
+
});
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
For Postgres, Cloudflare Durable Objects, Upstash REST, or any other backend, implement the `ResumableStreamStore` interface directly. See [Custom Resumable Stream Stores](/docs/guides/resumable-stream-stores) for the contract walkthrough and a worked example.
|
|
163
|
+
|
|
164
|
+
## Production checklist
|
|
165
|
+
|
|
166
|
+
- **Auth.** The resume route in the snippets above will serve any caller that knows the stream id. Bind `streamId` to the requesting user at acquire time and verify the binding inside the resume handler. Treat the id as opaque, not as a credential; it leaks via response headers, `sessionStorage`, browser history, and access logs.
|
|
167
|
+
- **`waitUntil` on serverless.** On Vercel and Cloudflare the request handler is killed once the response returns, which interrupts the producer task. Pass `after` from `next/server` (or your platform's `ctx.waitUntil`) when constructing the context so the task survives past the response: `createResumableStreamContext({ store, waitUntil: after })`.
|
|
168
|
+
- **TTL.** Streams expire 24 hours after the last write by default. Configure with `defaultTtlMs` on the store, or override per deployment via `ttlMs` on the context. Match TTLs across the store, any owner-binding key, and any signed cookie that references a `streamId`.
|
|
169
|
+
- **Stream id format.** The Redis adapters validate `streamId` against `/^[A-Za-z0-9_.:-]{1,256}$/` to keep keys well-formed. UUIDv4 is fine.
|
|
170
|
+
|
|
171
|
+
For the full treatment of authorization, multi-tenant key prefixes, observability hooks, resource limits, and incident response, see [Resumable Stream Deployment](/docs/guides/resumable-stream-deployment).
|
|
172
|
+
|
|
173
|
+
A new `ResumableStreamError` class is exported from `assistant-stream/resumable` with codes `"missing" | "exists" | "finalized" | "invalid-id"`; catch it in the resume route to distinguish "stream gone" from other failures.
|
|
174
|
+
|
|
175
|
+
## Helpers for `AssistantStreamController` callbacks
|
|
176
|
+
|
|
177
|
+
If you produce streams via `createAssistantStream` rather than the AI SDK, the package ships two helpers that bridge the controller-callback style and any encoder to the store:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import {
|
|
181
|
+
createResumableAssistantStreamResponse,
|
|
182
|
+
createResumeAssistantStreamResponse,
|
|
183
|
+
} from "assistant-stream/resumable";
|
|
184
|
+
import { resumableContext } from "@/lib/resumable-context";
|
|
185
|
+
|
|
186
|
+
// POST handler
|
|
187
|
+
return createResumableAssistantStreamResponse({
|
|
188
|
+
context: resumableContext,
|
|
189
|
+
streamId,
|
|
190
|
+
callback: (controller) => {
|
|
191
|
+
/* same shape as createAssistantStreamResponse */
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// GET resume handler
|
|
196
|
+
return createResumeAssistantStreamResponse({
|
|
197
|
+
context: resumableContext,
|
|
198
|
+
streamId,
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Both helpers default to the data-stream encoder; pass `encoder: () => new AssistantTransportEncoder()` (or any custom encoder) to override. They set the `x-resumable-stream-id` response header automatically, which is what `AssistantChatTransport`'s `resumable` adapter looks for.
|
|
203
|
+
|
|
204
|
+
## Example app
|
|
205
|
+
|
|
206
|
+
[`examples/with-resumable-stream`](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-resumable-stream) is a runnable Next.js app that uses `useChat`, the `resumable` transport option, and `useChatRuntime`. It falls back to a built-in mock when `OPENAI_API_KEY` is unset, and switches the store from in-memory to Redis when `REDIS_URL` is set.
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
npx assistant-ui create my-app -e with-resumable-stream
|
|
210
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Slash Commands
|
|
3
|
-
description:
|
|
3
|
+
description: Trigger predefined actions in your AI chat by typing / — slash command palette with popover, search, and action handlers in React via assistant-ui.
|
|
4
4
|
platforms: ["react"]
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Text-to-Speech
|
|
3
|
-
description: Read messages aloud with Web Speech API or a custom TTS adapter.
|
|
2
|
+
title: Text-to-Speech for Chat
|
|
3
|
+
description: Read AI chat messages aloud with the Web Speech API or a custom TTS adapter. Speech synthesis for React chat UIs, integrated with assistant-ui.
|
|
4
4
|
platforms: ["react"]
|
|
5
5
|
---
|
|
6
6
|
|