# Persistent Text Streaming

Persisted text streaming

- Package: @bijectionhq/persistent-text-streaming
- Category: Collaboration
- Status: Preview
- License: Apache-2.0
- Bijection source (repository access required): https://github.com/bijectionhq/persistent-text-streaming/tree/17887b3dd8a19ed5db576acf592cca0adcef2d03
- Upstream: https://github.com/convex/persistent-text-streaming/tree/4154ffd3463948d70da62327592d1c49883cf8ba
- Documentation: https://docs.bijection.com/components/using
- Directory: https://bijection.com/components/persistent-text-streaming

## Get the source

Bijection repository access is required. Request access at https://bijection.com/contact.

```sh
git clone https://github.com/bijectionhq/persistent-text-streaming.git
cd persistent-text-streaming
git checkout 17887b3dd8a19ed5db576acf592cca0adcef2d03
```

## Before using this component

Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute.

## Upstream package documentation

Recorded 2026-10-23. This describes @convex-dev/persistent-text-streaming; check the Bijection adaptation separately.

This component combines HTTP streaming with database persistence to deliver real-time text updates while storing content permanently. It provides a React hook that streams text from HTTP actions while simultaneously persisting chunks to the database at sentence boundaries. Developers get low-latency streaming for the original client plus durable storage accessible by other users or after reconnection.

### Use cases

• **Building AI chat applications** where you want users to see tokens streaming in real-time but also need conversations persisted for chat history and sharing
• **Creating collaborative writing tools** where one user generates content that streams live to collaborators while being saved incrementally to the database
• **Implementing live transcription features** where audio is converted to text in real-time, streamed to the UI, and stored for later retrieval and searching
• **Building code generation interfaces** where AI-generated code streams to the developer while being saved to project files or revision history
• **Creating real-time document summarization** where large documents are processed and summaries stream to users while being persisted for team access

### How it works

The component exposes a `PersistentTextStreaming` class that manages stream lifecycle through `createStream()` and `getStreamBody()` methods. Developers create streams in mutations, then use HTTP actions with the `stream()` method to generate and append text chunks via a provided `chunkAppender` callback function.

On the frontend, the provided React hook `useStream()` connects to both the HTTP streaming endpoint and a Convex query simultaneously. The hook displays real-time tokens from the HTTP stream while the database receives updates at sentence boundaries, balancing performance with persistence.

Integration requires installing the component via `app.use()` in `convex.config.ts`, creating HTTP routes for streaming endpoints, and using the React hook with your streaming URL and query function. The component handles CORS headers and manages the connection between ephemeral HTTP streams and durable database storage.

### Upstream README

This Convex component enables persistent text streaming. It provides a React
hook for streaming text from HTTP actions while simultaneously storing the data
in the database. This persistence allows the text to be accessed after the
stream ends or by other users.

The most common use case is for AI chat applications. The example app (found in
the `example` directory) is a just such a simple chat app that demonstrates use
of the component.

Here's what you'll end up with! The left browser window is streaming the chat
body to the client, and the right browser window is subscribed to the chat body
via a database query. The message is only updated in the database on sentence
boundaries, whereas the HTTP stream sends tokens as they come:

![example-animation](./anim.gif)

## Pre-requisite: Convex

You'll need an existing Convex project to use the component. Convex is a hosted
backend platform, including a database, serverless functions, and a ton more you
can learn about [here](https://docs.convex.dev/get-started).

Run `npm create convex` or follow any of the
[quickstarts](https://docs.convex.dev/home) to set one up.

## Installation

See [`example/`](./example/convex/) for a working demo.

1. Install the Persistent Text Streaming component:

```bash
npm install @convex-dev/persistent-text-streaming
```

2. Create a [`convex.config.ts`](./example/convex/convex.config.ts) file in your
   app's `convex/` folder and install the component by calling `use`:

```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config.js";

const app = defineApp();
app.use(persistentTextStreaming);
export default app;
```

## Usage

Here's a simple example of how to use the component:

In `convex/chat.ts`:

```ts
const persistentTextStreaming = new PersistentTextStreaming(
  components.persistentTextStreaming,
);

// Create a stream using the component and store the id in the database with
// our chat message.
export const createChat = mutation({
  args: {
    prompt: v.string(),
  },
  handler: async (ctx, args) => {
    const streamId = await persistentTextStreaming.createStream(ctx);
    const chatId = await ctx.db.insert("chats", {
      title: "...",
      prompt: args.prompt,
      stream: streamId,
    });
    return chatId;
  },
});

// Create a query that returns the chat body.
export const getChatBody = query({
  args: {
    streamId: StreamIdValidator,
  },
  handler: async (ctx, args) => {
    return await persistentTextStreaming.getStreamBody(
      ctx,
      args.streamId as StreamId,
    );
  },
});

// Create an HTTP action that generates chunks of the chat body
// and uses the component to stream them to the client and save them to the database.
export const streamChat = httpAction(async (ctx, request) => {
  const body = (await request.json()) as { streamId: string };
  const generateChat = async (ctx, request, streamId, chunkAppender) => {
    await chunkAppender("Hi there!");
    await chunkAppender("How are you?");
    await chunkAppender("Pretend I'm an AI or something!");
  };

  const response = await persistentTextStreaming.stream(
    ctx,
    request,
    body.streamId as StreamId,
    generateChat,
  );

  // Set CORS headers appropriately.
  response.headers.set("Access-Control-Allow-Origin", "*");
  response.headers.set("Vary", "Origin");
  return response;
});
```

You need to expose this HTTP endpoint in your backend, so in `convex/http.ts`:

```ts
http.route({
  path: "/chat-stream",
  method: "POST",
  handler: streamChat,
});

// Handle CORS preflight requests so browsers will allow the POST above when
// your app is served from a different origin than your Convex deployment.
http.route({
  path: "/chat-stream",
  method: "OPTIONS",
  handler: httpAction(async (_, request) => {
    const headers = request.headers;
    if (
      headers.get("Origin") !== null &&
      headers.get("Access-Control-Request-Method") !== null &&
      headers.get("Access-Control-Request-Headers") !== null
    ) {
      return new Response(null, {
        headers: new Headers({
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Methods": "POST",
          "Access-Control-Allow-Headers": "Content-Type, Digest, Authorization",
          "Access-Control-Max-Age": "86400",
        }),
      });
    } else {
      return new Response();
    }
  }),
});
```

Finally, in your app, you can now create chats and them subscribe to them via
stream and/or database query as optimal:

```ts
// chat-input.tsx, maybe?
const createChat = useMutation(api.chat.createChat);
const formSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  const chatId = await createChat({
    prompt: inputValue,
  });
};

// chat-message.tsx, maybe?
import { useStream } from "@convex-dev/persistent-text-streaming/react";

// ...

// In our component:
const { text, status } = useStream(
  api.chat.getChatBody, // The query to call for the full stream body
  new URL(`${convexSiteUrl}/chat-stream`), // The HTTP endpoint for streaming
  driven, // True if this browser session created this chat and should generate the stream
  chat.streamId as StreamId, // The streamId from the chat database record
);
```

### Svelte (beta)

Svelte 5 bindings are community-supported and in beta. They need `svelte` and
[`convex-svelte`](https://github.com/get-convex/convex-svelte), with
`setupConvex` called in a parent component. Reactive inputs are passed as
getters. See [`example-svelte/`](./example-svelte/) for the chat app.

```svelte
<script lang="ts">
  import { useStream } from "@convex-dev/persistent-text-streaming/svelte";

  let { chat, driven }: { chat: Doc<"chats">; driven: boolean } = $props();

  const stream = useStream(
    api.chat.getChatBody,
    new URL(`${convexSiteUrl}/chat-stream`),
    () => driven,
    () => chat.streamId as StreamId,
    // Optional, read when a stream starts:
    // () => ({ authToken, headers }),
  );
</script>

<p>{stream.text}</p>
```

`stream.text` and `stream.status` update as the stream progresses. The HTTP
request only starts in the browser, never during server-side rendering.

## Design Philosophy

This component balances HTTP streaming with database persistence to try to
maximize the benefits of both. To understand why this balance is beneficial,
let's examine each approach in isolation.

- **HTTP streaming only**: If your app _only_ uses HTTP streaming, then the
  original browser that made the request will have a great, high-performance
  streaming experience. But if that HTTP connection is lost, if the browser
  window is reloaded, if other users want to view the same chat, or this users
  wants to revisit the conversation later, it won't be possible. The
  conversation is only ephemeral because it was never stored on the server.

- **Database Persistence Only**: If your app _only_ uses database persistence,
  it's true that the conversation will be available for as long as you want.
  Additionally, Convex's subscriptions will ensure the chat message is updated
  as new text chunks are generated. However, there are a few downsides: one, the
  entire chat body needs to be resent every time it is changed, which is a lot
  redundant bandwidth to push into the database and over the websockets to all
  connected clients. Two, you'll need to make a difficult tradeoff between
  interactivity and efficiency. If you write every single small chunk to the
  database, this will get quite slow and expensive. But if you batch up the
  chunks into, say, paragraphs, then the user experience will feel laggy.

This component combines the best of both worlds. The original browser that makes
the request will still have a great, high-performance streaming experience. But
the chat body is also stored in the database, so it can be accessed by the
client even after the stream has finished, or by other users, etc.

## Background

This component is largely based on the Stack post
[AI Chat with HTTP Streaming](https://stack.convex.dev/ai-chat-with-http-streaming).