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.liveLoader({ topic, load }) subscribes any connected client to that topic and pushes a fresh data snapshot on every publish.
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 } from 'hono-preact';
import { counterChannel } from './counter-channel.js';
import { getCount } from './counter-db.js';
const route = serverRoute('/counter');
export const serverLoaders = {
count: route.liveLoader({
// 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.
const CountDisplay = countLoader.View<number>(
({ data, status }) => (
<div>
<p>Count: {data}</p>
<p>Status: {status}</p>
</div>
),
{
initial: 0,
reduce: (_acc, chunk) => chunk,
fallback: <p>Connecting...</p>,
}
);
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 usingroute.liveLoader({ topic, load }). The loader runsloadonce on connect and again on every publish totopic. 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 function in liveLoader receives the same ctx as load, so it can read ctx.location.pathParams to key the subscription to the route:
const route = serverRoute('/board/:boardId');
export const serverLoaders = {
tasks: route.liveLoader({
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). |
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.
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. |
route.liveLoader({ topic, load })#
Defines a channel-driven live loader inside a serverLoaders object. Yields the result of load once on connect, then re-runs and pushes on every publish to topic.
| 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. |
use | ReadonlyArray<Middleware> | Optional guard/middleware chain run on the subscription (same inheritance as a non-live loader's use). |
cache | LoaderCache<T> | Optional cache shared across load calls. |
timeoutMs | number | false | Timeout per load call. Defaults to false (no cap). |
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.
Type exports#
import type { Channel, Topic, LiveLoaderOptions } 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.