Skip to content
result-rpc
Esc
navigateopen⌘Jpreview
On this page

Subscriptions

Streams under the same union, with connection lifecycle separate from failure.

Declare the stream in the shared contract and attach its generator only on the server:

import { serverRpc } from "result-rpc/server";

export const docEventsContract = app
  .procedure()
  .input(wire.object({ docId: wire.string }))
  .output(DocEvent)
  .errors({ Unauthorized, DocNotFound })
  .subscription();

const server = serverRpc.context<AppContext>();
export const docEvents = server
  .implement(docEventsContract)
  .use(authenticated)
  .stream(async function* ({ input, errors, context }) {
    const doc = await context.docs.find(input.docId);
    if (!doc) {
      yield err(errors.DocNotFound({ docId: input.docId }));
      return;
    }

    for await (const event of context.docs.events(input.docId)) {
      yield ok(event);
    }
  });

The direct client is an async iterable of the same Result union. React observes connection state independently from the latest event or terminal failure:

const events = useResultSubscription(client.doc.events, { docId });

events.connection; // "connecting" | "open" | "reconnecting" | "paused" | "closed"
events.result; // Ok<DocEvent> | Err<GetDocEventsError> | undefined

Subscriptions are the one hook that keeps a result envelope. Queries and mutations flatten to value/error because state discriminates them; connection is deliberately orthogonal to the latest outcome, so here Result | undefined is the honest shape — flat value/error fields would be exactly the independently-nullable pair this library exists to avoid.

AuthShell.useSubscription narrows the same way: a claimed terminal failure leaves connection at "paused" with no result, and the owning shell reacts. A retryable disconnect moves through reconnecting and does not publish a temporary Err; if retry policy is exhausted, the final connection error appears in events.result. Every frame is sequence-checked and independently encoded by the same versioned serializer as unary and batched responses.

Resuming an interrupted stream

By default a reconnect reopens the stream from the top: the handler runs again from its first line, so events emitted during the gap are lost and events already delivered may arrive twice. .resumable() closes that gap.

const feed = app
  .procedure()
  .input(wire.object({ roomId: wire.string }))
  .output(Message)
  .resumable({ eventId: (message) => message.id })
  .subscription();
server.implement(feed).stream(async function* ({ input, lastEventId }) {
  // undefined on a first connect; the last event this client saw on a reconnect
  for await (const message of room(input.roomId, { after: lastEventId })) {
    yield ok(message);
  }
});

eventId derives a resume token from an event’s value. Both sides run the same declared function, so the client computes the token from the event it just decoded and sends it back on the next connect — no event id rides the wire frame, and the procedure’s input codec is unchanged. Resuming is automatic: the subscription runtime already reconnects on a retryable failure, and this only decides where the new connection starts.

Three rules worth knowing:

A changed input is a new stream, not a resumed one. The resume point is dropped whenever the subscription is created or its input changes; resuming one room’s position into another room would be wrong.

A procedure that did not declare .resumable() never receives a resume point, even if a client sends one. Undeclared handlers cannot be handed client-supplied state they never asked for.

.resumable() is in the contract digest. A client and server that disagreed about it would silently re-deliver events after a deploy, which is exactly the class of bug the digest exists to catch.

Whether a drop reconnects at all is still the declared RetryPolicy of the error that ended the stream — transient and after reconnect, never does not. Resumability decides where a reconnect resumes, not whether one happens.

Transports

Subscriptions run over the streaming HTTP transport. Both fetchTransport and batchFetchTransport implement this streaming path; the latter batches unary calls while opening subscriptions as individual streams. Use the same client instance for both.

Was this page helpful?