Realtime Channels#
Realtime channels let you push live data from the server to the client without polling. A Channel is a typed address; publish(topic, message) fires from a server action after a mutation; route.loader(liveStream({ topic, load })) subscribes any connected client to that topic and pushes a fresh data snapshot on every publish. When the event itself is the data, eventStream(topic, signal) yields each published payload directly to a streaming loader.
Example: live shared counter#
A single-instance signal channel (no route params, no payload) that signals all live loaders to refetch the current count.
counter-channel.ts (shared between server and action):
import { defineChannel } from 'hono-preact';
// A signal channel: defineChannel('name')<void>(). No payload; the subscriber
// calls load() for the fresh value. Published with no message argument.
export const counterChannel = defineChannel('counter')<void>();
counter.server.ts (server module for the /counter route):
import { serverRoute, liveStream } from 'hono-preact';
import { counterChannel } from './counter-channel.js';
import { getCount } from './counter-db.js';
const route = serverRoute('/counter');
export const serverLoaders = {
count: route.loader(
liveStream({
// topic(ctx) returns the Topic this loader subscribes to.
topic: (_ctx) => counterChannel.key(),
// load(ctx) is called on connect and on every publish to the topic.
load: async (_ctx) => getCount(),
})
),
};
counter.tsx (page component):
import type { StreamStatus } from 'hono-preact';
import { serverLoaders } from './counter.server.js';
const countLoader = serverLoaders.count;
// The accumulating .View form folds every pushed chunk into `data`.
// `initial` seeds the value before the first chunk arrives;
// `reduce` folds each chunk. For a simple replace-on-every-push pattern,
// reduce just returns the latest chunk.
// Branch on `status`: `connecting` (pre-first-chunk) and a cold `error` (the
// connect failed before any chunk) carry no data, so guard them first.
const CountDisplay = countLoader.View<number>(
(s) => {
if (s.status === 'error') return <p>Disconnected.</p>;
if (s.status === 'connecting') return <p>Connecting...</p>;
return (
<div>
<p>Count: {s.data}</p>
<p>Status: {s.status}</p>
</div>
);
},
{
initial: 0,
reduce: (_acc, chunk) => chunk,
}
);
export default function CounterPage() {
return (
<main>
<CountDisplay />
</main>
);
}
counter.action.ts (server action that mutates and publishes):
import { defineAction } from 'hono-preact';
import { publish } from 'hono-preact';
import { counterChannel } from './counter-channel.js';
import { incrementCount } from './counter-db.js';
export const increment = defineAction(async () => {
await incrementCount();
// Signal channel: no message argument.
publish(counterChannel.key());
});
Every client with the counter page open receives the new count within milliseconds of the action completing, without polling.
How it works#
Three pieces cooperate to wire up a live shared counter (or any data that changes on server events):
- Define a channel with
defineChannel. The name uses the same/:paramgrammar as route paths;channel.key(params)builds a brandedTopic<Payload>that ties publish and subscribe together at the type level. - Subscribe from a
serverLoadersentry by composingliveStream({ topic, load })intoroute.loader(...). The loader runsloadonce on connect and again on every publish totopic.liveStreamis inherently live, so no{ live: true }flag is needed. The framework streams the results to the client over SSE so you consume them with the accumulatingloader.View(render, { initial, reduce })form. - Publish from a server action by calling
publish(channel.key(params), message). Every connected live loader subscribed to that topic re-runsloadand pushes the new value.
Parameterized channels#
When the same data shape is segmented per resource, include the resource id in the channel name:
import { defineChannel, type Channel, type Topic } from 'hono-preact';
// Type is Channel<'board/:boardId', { taskId: string; to: string }>
export const boardChannel = defineChannel('board/:boardId')<{
taskId: string;
to: string;
}>();
// In a server action: boardChannel.key({ boardId }) is a Topic<{ taskId, to }>
import { publish } from 'hono-preact';
publish(boardChannel.key({ boardId: 'b1' }), { taskId: 't7', to: 'done' });
The topic and load functions in liveStream receive the same ctx, so both can read ctx.location.pathParams to key the subscription to the route:
import { serverRoute, liveStream } from 'hono-preact';
const route = serverRoute('/board/:boardId');
export const serverLoaders = {
tasks: route.loader(
liveStream({
topic: (ctx) =>
boardChannel.key({ boardId: ctx.location.pathParams.boardId }),
load: async (ctx) => getTasks(ctx.location.pathParams.boardId),
})
),
};
Cross-connection fan-out#
Live loaders are server-to-client over SSE. Publishing to a topic fans out to every live loader subscribed to that topic. On Node, the in-process bus reaches all connections on the same instance. On Cloudflare Workers, each request runs in an isolated Worker instance, so fan-out is backed by a Durable Object: a subscribe holds a Worker-to-DO socket and publish() POSTs to the topic's DO, which fans the event out to every subscriber across isolates. This uses the same HONO_PREACT_REALTIME Durable Object binding rooms use; see Cloudflare setup for the binding. Note publish() syncs the event, not your state: subscribers re-run their load() to read the current shared state.
API reference#
defineChannel(name)<Payload>()#
Defines a typed channel. The name uses the /:param grammar. The Payload type parameter sets the message type. A void payload (the default) is a signal channel that publishes with no message.
const c = defineChannel('board/:boardId')<{ taskId: string }>();
| Type | Description | |
|---|---|---|
name | string | Channel address, e.g. 'board/:boardId'. Params use :name syntax. |
Payload | type param | Message type. Defaults to void (signal channel, no message). |
Each : in name starts a param whose own name must be one or more of [A-Za-z0-9_], optionally followed by a single ?, *, or + modifier (e.g. :id, :id?, :rest*, :rest+). defineChannel throws immediately, at definition time, if any segment carries a : outside that grammar, including a : that appears mid-segment rather than at its start (e.g. 'board:boardId').
Returns a Channel<Name, Payload> with one method:
| Method | Signature | Description |
|---|---|---|
channel.key(params?) | (...args) => Topic<Payload> | Builds a branded Topic<Payload>. For a param-less name the argument is omitted; for a name with params the argument is { [paramName]: string }. |
publish(topic, message?)#
Publishes to a typed topic from a server action or server agent. Every live loader subscribed to topic re-runs its load and pushes the result to connected clients.
publish addresses live loaders, not rooms. The two are separate namespaces even when they share a channel key, so publishing to the key a room is bound to does not reach that room's members, and a room's own traffic does not re-run live loaders on the same key. To send to a room's members, broadcast from inside the room (conn.broadcast).
import { publish } from 'hono-preact';
publish(boardChannel.key({ boardId }), { taskId, to }); // payload channel
publish(counterChannel.key()); // signal channel
| Argument | Type | Description |
|---|---|---|
topic | Topic<P> | The topic to publish to. Built with channel.key(params). |
message | P | Required for payload channels; omitted for void (signal) channels. |
liveStream({ topic, load })#
A generator helper for channel-driven live loaders. Yields load(ctx) once on connect, then re-runs and pushes the result on every publish to topic(ctx). Because liveStream produces an inherently unbounded subscription, liveness is implied: compose it directly as route.loader(liveStream({ topic, load })) or defineLoader(liveStream({ topic, load })) without any { live: true } flag.
| Option | Type | Description |
|---|---|---|
topic | (ctx: LoaderCtx) => Topic<unknown> | Returns the topic this loader subscribes to. Called with the same context as load. |
load | (ctx: LoaderCtx) => Promise<T> | Produces the data snapshot. Called on connect and on every publish. |
The composed loader returns a LoaderRef<T, true>. Consume it with the accumulating form ref.View(render, { initial, reduce }). The StreamStatus and .View option table are described on the Live Loaders page.
eventStream(topic, signal)#
Subscribe to a topic as an async generator of its published payloads. Where liveStream re-runs a load snapshot on every publish (and discards the message), eventStream delivers each published payload itself, in order: the right shape for feeds and tickers where the event is the data.
import { defineChannel, defineLoader, eventStream } from 'hono-preact';
const activityChannel = defineChannel('activity')<ActivityEvent>();
export const serverLoaders = {
activity: defineLoader(
async function* ({ signal }) {
for await (const e of eventStream(activityChannel.key(), signal)) {
yield e;
}
},
{ live: true }
),
};
| Argument | Type | Description |
|---|---|---|
topic | Topic<Payload> | The channel topic to subscribe to. Build it with channel.key(...). |
signal | AbortSignal | Ends the stream and removes the subscription when aborted. Pass the loader's signal. |
Returns AsyncGenerator<Serialize<Payload>, void, unknown>. Payloads arrive in publish order and buffer while the consumer is busy. The yield type is the JSON wire shape (Serialize<Payload>): on Cloudflare a payload crosses isolates as JSON, so a Date arrives as its ISO string. If the underlying subscription drops, the generator throws, terminating the stream rather than going silently stale.
The per-subscription buffer is capped at 128 payloads. A stalled consumer (a streaming loader that stops pulling) can't grow the queue without bound: once the buffer is full, further publishes are dropped (the newest is dropped, the oldest kept) and a one-time warning is logged. A loader consuming eventStream should drain the generator continuously rather than pausing for long stretches between reads.
Type exports#
import type { Channel, Topic } from 'hono-preact';
import type { StreamStatus } from 'hono-preact';
See also#
- Live Loaders: the persistent-layout streaming pattern,
.Viewaccumulating form, andStreamStatusreference. - Server Loaders: non-live loaders and the full
defineLoaderoption table. - Server Actions: where
publishis typically called.