TypebaseTypebase
Actions

Streaming

Push a sequence of events to clients over SSE with .stream(), instead of answering once and closing.

Last updated on

A handler answers once. A stream keeps answering: it yields events for as long as the client is listening, over Server-Sent Events. Live lists, notifications, progress on a long job, and anything driven by a publisher are all the same shape.

Swap .handler() for .stream() and write an async generator:

typebase/actions/queries/jobs.ts
import { ServerError } from 'typebase-io/server';
import { z } from 'zod';

import { action } from '../../_generated/server.ts';

export const progress = action
  .input(z.object({ id: z.number() }))
  .output(z.object({ done: z.number(), total: z.number() }))
  .stream(async function* ({ db, input, signal }) {
    while (signal?.aborted !== true) {
      const job = await db.query.jobs.findFirst({ where: { id: input.id } });

      if (!job) {
        throw new ServerError('NOT_FOUND');
      }

      yield { done: job.done, total: job.total };

      if (job.done === job.total) {
        return;
      }

      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  });

Everything you know from .handler() carries over: .input() validates what comes in, errors are thrown the same way, and you get the whole handler context: db, env, auth, publisher, input, reqHeaders. A stream adds two values a handler has no use for:

ValueDescription
signalAbortSignal | undefined. When present, it fires on disconnect. See Ending a stream.
lastEventIdstring | undefined. The last event id received by a reconnecting client. See Resuming.

lastEventId is undefined on a first connection. signal may be undefined when the client or runtime cannot provide a cancellation signal. A stream that ignores both still works, but it cannot stop work promptly on disconnect or resume missed events.

What .output() means on a stream

On a handler, .output() describes the response. On a stream, it describes one event, and every event is validated against it as you yield:

.output(z.object({ done: z.number(), total: z.number() }))

The client receives an async iterable of that shape. You don't wrap it in anything yourself; .stream() handles that. .output() stays optional, leave it off and the event type is inferred from what you yield, exactly as a handler's return type is inferred:

export const ticks = action.stream(async function* () {
  yield { at: Date.now() };
});

Rules the compiler enforces

Three, all reported at compile time:

A stream must yield. A generator that never yields sends the client nothing, so it's rejected rather than silently connecting to a stream that produces no events:

// Error: a stream must yield at least one event
export const nothing = action.output(shape).stream(async function* () {});

Events are yielded, never returned. A returned value ends the stream without sending anything, which is almost always a mistake:

export const wrong = action.output(shape).stream(async function* () {
  return { done: 1, total: 1 }; // Error
});

export const right = action.output(shape).stream(async function* () {
  yield { done: 1, total: 1 };

  return; // fine: a bare return just ends the stream
});

Every event matches .output(). Yield something else and it's a type error, the same as returning the wrong shape from a handler.

Ending a stream

A stream ends when the generator returns, or when the client goes away. Every stream receives signal: AbortSignal | undefined; when the runtime provides it, the signal fires on disconnect:

export const live = action.stream(async function* ({ publisher, signal }) {
  const created = await publisher.subscribe('todo.created', { signal });

  for await (const todo of created) {
    yield todo;
  }
});

Pass signal to anything that would otherwise keep running: a publisher.subscribe, a fetch, a database call that takes a while. Without it, a client closing the tab leaves your loop running until the platform kills the function.

Cleanup belongs in finally, which runs whether the client disconnected or the generator returned on its own:

export const watched = action.stream(async function* ({ signal }) {
  const watcher = openWatcher();

  try {
    while (signal?.aborted !== true) {
      yield await watcher.next();
    }
  } finally {
    watcher.close();
  }
});

Event metadata

Every event can carry an id and a retry, the two fields SSE defines. Attach them with withEventMeta:

import { withEventMeta } from 'typebase-io/server';

export const live = action.stream(async function* ({ db }) {
  for await (const row of rows(db)) {
    yield withEventMeta({ id: row.id, value: row.value }, { id: String(row.id), retry: 5_000 });
  }
});
FieldMeaning
idIdentifies the event. The client sends the last one it saw back on reconnect. Must be a string.
retryHow long the client should wait before reconnecting, in milliseconds.

withEventMeta returns the same value with the metadata attached, so what the client receives is unchanged. Reading it back is getEventMeta, which returns { id?, retry? } or undefined for an event that was never tagged:

import { getEventMeta, withEventMeta } from 'typebase-io/server';

for await (const todo of created) {
  const meta = getEventMeta(todo);

  yield withEventMeta(todo, { id: meta?.id });
}

A publisher already tags what it delivers with the event's row id, so streams built on publisher.subscribe get ids without doing anything.

Resuming after a disconnect

When a client reconnects, it sends the id of the last event it received. That arrives as lastEventId on the stream's context:

export const live = action.stream(async function* ({ publisher, signal, lastEventId }) {
  const created = await publisher.subscribe('todo.created', { signal, lastEventId });

  for await (const todo of created) {
    yield todo;
  }
});

It's undefined on a first connection and a string on a resume. Handing it to publisher.subscribe replays what was published while the client was away; ignoring it means the client silently misses those events. What you can replay depends on what you kept, which for the db publisher is however much of the events table you haven't pruned.

Clients do not reconnect on their own by default, so a dropped connection stays dropped and lastEventId never arrives. See Reconnecting below.

Consuming a stream

First create or import a client with createRouterClient or createTanstackQueryClient. On the client, a stream resolves to an async iterable. With the router client, call the action directly; with the TanStack client, use .call():

ClientStream call
createRouterClientclient.queries.todos.getMany()
createTanstackQueryClientclient.queries.todos.getMany.call()

You can for await the resulting async iterable directly. For UI code, consumeStream inverts it into callbacks and hands you back an unsubscribe function. Consumption starts immediately, and every call opens a separate stream.

Do not call consumeStream while a UI component is rendering. Start it from the framework's mount lifecycle and call the returned unsubscribe function during cleanup. In React, use useEffect.

With createRouterClient

import { consumeStream, createRouterClient } from 'typebase-io/client';
import type { Router } from '../../typebase/_generated/server';

const client = createRouterClient<Router>({
  url: process.env.NEXT_PUBLIC_TYPEBASE_APP_URL || '',
});

const unsubscribe = consumeStream(client.queries.todos.getMany(), {
  onEvent: (todos) => render(todos),
  onError: (error) => console.error(error),
  onSuccess: () => console.log('stream ended'),
});

// When the code that owns this subscription stops
void unsubscribe();

With createTanstackQueryClient

import { consumeStream, createTanstackQueryClient } from 'typebase-io/client';
import type { Router } from '../../typebase/_generated/server';

const client = createTanstackQueryClient<Router>({
  url: process.env.NEXT_PUBLIC_TYPEBASE_APP_URL || '',
});

const unsubscribe = consumeStream(client.queries.todos.getMany.call(), {
  onEvent: (todos) => render(todos),
  onError: (error) => console.error(error),
  onSuccess: () => console.log('stream ended'),
});

// When the code that owns this subscription stops
void unsubscribe();
React Example

For example, using React with a TanStack client:

useEffect(() => {
  const unsubscribe = consumeStream(client.queries.todos.getMany.call(), {
    onEvent: (todos) => setTodos(todos),
    onError: (error) => console.error(error),
  });

  return () => {
    void unsubscribe();
  };
}, []);

This creates one subscription while the component is mounted. With createRouterClient, remove .call() and pass client.queries.todos.getMany(). See the complete React component example.

CallbackWhen
onEventEach event.
onErrorThe stream failed.
onSuccessThe stream ended normally, including after unsubscribe.
onFinishAfter onError or onSuccess, whichever happened.
Provide onError or onFinish. Without either, a failing stream throws an unhandled rejection.

Typing an event

A stream's entry in RouterOutputs is the async iterable, not the event. InferStreamEvent unwraps it:

import type { InferStreamEvent } from 'typebase-io/server';
import type { RouterOutputs } from '../typebase/_generated/server';

type Todos = InferStreamEvent<RouterOutputs['queries']['todos']['getMany']>;

This works whether or not the action declares .output(); both report the same type. See Typing action inputs and outputs.

Reconnecting

Add the retry plugin to your client to make dropped streams reconnect, and to make lastEventId reach the server:

lib/typebase.ts
import { createRouterClient } from 'typebase-io/client';
import { ClientRetryPlugin } from 'typebase-io/client/plugins';

import type { Router } from '../typebase/_generated/server.ts';

export const client = createRouterClient<Router>({
  url: process.env.NEXT_PUBLIC_TYPEBASE_URL,
  plugins: [new ClientRetryPlugin({ default: { retry: Number.POSITIVE_INFINITY } })],
});

retry is 0 by default, which is why a stream that drops simply stops. The delay between attempts comes from the last event's retry metadata, falling back to two seconds.

Regenerating types

Converting an action in an existing file from .handler() to .stream() does not require codegen; its type flows through the imports already in _generated/server.ts. Run codegen only if you created, removed, or renamed the action file itself:

npx typebase-io-cli codegen

npx typebase-io-cli deploy and generate-server rerun codegen before every build.

On this page