Skip to content
brought to you byVoidZero
Private Beta:Void is currently in Private Beta. We do not recommend using it for mission-critical production workloads yet. Please back up your data regularly.

Live Event Streams ​

void/live provides Durable Object-backed fanout over Server-Sent Events. Use it when one browser tab needs a single SSE connection that can subscribe and unsubscribe from many application topics over time.

Use void/sse for request-owned streams where the producer lives inside the same route handler. Use void/live when later requests, mutations, scheduled jobs, or queue consumers need to publish to clients that are already connected.

Define A Stream ​

Create a server-only live stream descriptor:

ts
// src/live.ts
import { defineLiveStream } from 'void/live';

export const live = defineLiveStream({
  id: 'app',
  allowAnonymousControl: true,
});

Expose it from a normal route:

ts
// routes/live.ts
import { defineHandler } from 'void';
import { live } from '../src/live';

export const GET = defineHandler((c) => live.connect(c));
export const POST = defineHandler((c) => live.control(c));

GET opens the SSE connection. POST accepts subscribe and unsubscribe control operations for that connection.

Subscribe From The Browser ​

Use the browser helper from void/live/client:

ts
import { connectLiveStream } from 'void/live/client';

const stream = connectLiveStream('/live', {
  withCredentials: true,
  retryDelay: 1_000,
  onError(error) {
    console.error(error);
  },
});

const unsubscribePost = await stream.subscribe({
  id: 'post-card',
  topic: 'post:12',
  onEvent(event) {
    if (event.type === 'updated') {
      console.log(event.data);
    }
  },
});

const unsubscribeComments = await stream.subscribe({
  id: 'comments',
  topic: 'comment:2323',
});

await unsubscribeComments();
await unsubscribePost();
stream.close();

The helper opens one native EventSource, waits for it to open, then sends batched POST control operations. Subscription ids are scoped to the connection. Reusing an id replaces the previous subscription with that id. If the transport drops, the helper reconnects and resubmits active subscriptions with their latest eventId as lastEventId.

Publish ​

Publish from any server-side code that has a Void runtime env:

ts
import { live } from '../src/live';

await live.publish(
  'post:12',
  { title: 'Updated title' },
  { type: 'updated', eventId: 'post-12-v8' },
);

Topics are opaque strings. Payloads are application-owned JSON. type and eventId are copied into the JSON envelope; void/live does not interpret them, persist them, or use native SSE id fields.

If you are outside an active request/runtime context, pass env explicitly:

ts
await live.withEnv(env).publish('post:12', { title: 'Updated title' });

Authorization ​

Routes can authorize before calling connect() and control(). For stream-local subscription rules, use onSubscribe:

ts
export const live = defineLiveStream({
  id: 'app',
  async onSubscribe(ctx) {
    const match = /^post:(.+)$/.exec(ctx.topic);
    if (match && !(await canReadPost(ctx.env, ctx.user, match[1]))) {
      return new Response('Forbidden', { status: 403 });
    }
  },
});

Control requests are bound to the same owner as the open SSE connection. With Void auth, the owner is derived from the authenticated user. Custom auth can pass an explicit owner key:

ts
return live.connect(c, { owner: `user:${session.userId}` });
return live.control(c, { owner: `user:${session.userId}` });

Delivery Semantics ​

void/live is an at-most-once live fanout primitive:

  • one SSE connection can hold many topic subscriptions
  • publish cost is proportional to subscribers of the topic
  • events are ordered within one topic
  • clients using the raw HTTP protocol must resubscribe after reconnect
  • deploys, rollbacks, Worker restarts, browser reconnects, and network changes can drop live state
  • durable replay, cache invalidation, live queries, and client state management belong in userland

Use an application database, queue, or custom Durable Object for replay. Use eventId and caller-provided lastEventId values as application protocol fields when you build that layer.