Channel
elements man channel Read as markdownA channel is a way to broadcast a message from the server to every browser
listening on it. Broadcasting on a channel is called notifying the channel. For
example, when someone posts a new comment, the server notifies the comments
channel with the new comment, and every browser listening on comments receives
it as it arrives.
How It Works
Elements channels are built on Postgres LISTEN/NOTIFY. By default, you send
a notification by calling the notify method on a channel. That sends a
notification through the underlying Postgres channel, which every app server is
listening on. Each server then forwards the notification to its connected
browsers.
Postgres itself can also notify a channel. A trigger that calls pg_notify lets
the database notify a channel when a row changes, so events that originate
outside the app reach listeners too.
At a Glance
A route handler creates a listener and passes it to the rendered page.
// app/shared/services/alerts.ts
import { Channel } from "@elements/app";
export interface Alert {
userId: string;
level: "info" | "warning";
text: string;
}
export const alerts = new Channel<Alert>("alerts");
// app/pages/home/index.ts
import { alerts } from "#app/shared/services/alerts";
export default function route(req, res) {
return new html({
alerts: alerts.listen(),
});
}
The route handler returns a page with a listener attached. The listener serializes over the wire as part of the response. When the page attaches in the browser, the listener connects to the server over a WebSocket and starts receiving notifications.
The template subscribes to the listener and accumulates each notification into a reactive array.
import { alerts } from "#app/shared/services/alerts";
import type { Listener } from "@elements/app";
<html (alerts: Listener<Alert>, private messages: Alert[] = [])
oninit={() => alerts.on("notify", (a) => messages.push(a))}>
<li e:for={msg of messages}>[{msg.level}] {msg.text}</li>
</html>
Every listener on the channel receives the notification.
listen() returns a listener handle. Listener is a type-only export: import
it with import type { Listener } from "@elements/app" to name the handle in a
template attribute, as above. You get instances from channel.listen(); you
never construct one yourself.
Creating a Channel
const chat = new Channel<ChatMessage>("chat");
The Channel constructor takes one argument: the channel name. Channels are
server-only.
Listen
const listener = channel.listen(); // broadcast: every listener receives
const listener = channel.listen({ filter: (msg) => msg.roomId === id }); // per-room: only matching messages
listen() returns a listener handle. Returning it from an @rpc or route
handler sends it to the browser as data, where it connects over the WebSocket
and begins receiving notifications.
filter runs on the server before the message goes over the wire, so a
particular client only sees the messages that match the predicate.
Omit filter to deliver every notification to every connected listener: the
broadcast pattern, used for shared cursors, drawing strokes, presence updates,
and anywhere else every listener needs every message.
Filter
channel.filter(fn) returns a derived channel with a server-side predicate
applied. The original channel is unaffected. This is the idiomatic way to scope
a channel per request before passing it into a page. Pass the filtered channel
straight into an html attribute and Elements materializes the per-request
listener for you.
import { alerts } from "#app/shared/services/alerts";
import { session } from "@elements/app";
export default function route(req, res) {
return new html({
alerts: alerts.filter((a) => a.userId === session.getOrThrow("userId")),
});
}
filter composes: chaining another filter ANDs the predicates. Filters passed
to listen({ filter }) and filters on the channel both apply.
Listen Before Select
When you want to merge a one-shot query with live notifications, call listen()
first and the query second. listen() returns only after the postgres LISTEN
has committed, so anything NOTIFY'd after that point is queued on the
listener. The query runs against the same database; any row that arrived between
LISTEN commit and the query reaching the server is in the query result. The
browser-side store (e.g. LiveTable's row index, keyed by id) dedupes when
both arrive. An insert echo for a row already present is a no-op.
import { Channel, session, sql } from "@elements/app";
interface Alert {
id: string;
userId: string;
text: string;
}
const alerts = new Channel<Alert>("alerts");
export default function route(req, res) {
const listener = alerts.listen();
const initial = sql<Alert>(`select * from alerts where userId = ${session.getOrThrow("userId")} order by createdAt`).all();
return new html({ listener, alerts: initial });
}
The order matters. If you query first and then call listen(), a NOTIFY fired
in the gap is lost: postgres has no listeners on the channel yet, and it is not
in your SELECT either, because the inserting transaction committed before
SELECT started. The row never reaches the user. Always call .listen() first.
Notify
channel.notify({ userId, level: "warning", text: "rate limit hit" });
channel.notify participates in the async/await transform. The compiler
rewrites the call to await channel.notifyAsync(...) and propagates async up
the call stack at build time. Write it sync-style. The same idiom applies to
sql, tx, @rpc, and LiveTable.
The call notifies all listeners on all app servers via Postgres NOTIFY.
notify works the same in any server context: @rpc bodies, route handlers,
Job.run(), even module-top-level scripts. The example below pushes computed
stats from a background job to every connected dashboard:
import { Channel, Job, sql } from "@elements/app";
interface DashboardStats {
activeUsers: number;
revenue: number;
}
const dashboardChannel = new Channel<DashboardStats>("dashboard");
/** @job */
export class RefreshStatsJob extends Job {
run() {
let stats = sql<DashboardStats>(
`select count(*) filter (where active) as activeUsers, sum(amount) as revenue from orders`,
).first()!;
dashboardChannel.notify(stats);
}
}
The Listener
The browser-side listener handle exposes a small event API and a stop method.
listener.on("notify", (data) => { ... });
listener.on("error", (err) => { ... });
listener.off("notify", callback);
listener.stop();
on('notify', cb) fires for each notification as it arrives.
on('error', cb) fires when the listener fails to connect or its subscription
errors. Use it to surface a failure to the user or to retry.
stop() ends the subscription. Listeners also clean up automatically when their
WebSocket closes (page navigation, tab close), so explicit stop() is only
needed when ending a subscription mid-page.
A listener handle is also a reactive value, so reading it inside a template binding tracks the dependency and the rendered DOM updates on each notification.
<p>{listener}</p>
The right pattern depends on what each notification represents.
When a notification is one item in a stream: a chat message, a new comment: push it to a reactive array.
import { messages as msgChannel } from "#app/shared/services/messages";
import type { Listener } from "@elements/app";
<html (listener: Listener<Msg>, private messages: Msg[] = [])
oninit={() => listener.on("notify", (m) => messages.push(m))}>
<li e:for={m of messages}>{m.text}</li>
</html>
When a notification replaces the previous value: updated stats, the current user list: reassign it.
import { dashboardChannel } from "#app/shared/services/dashboard";
import type { Listener } from "@elements/app";
<html (listener: Listener<DashboardStats>,
stats: DashboardStats,
private current: DashboardStats = stats)
oninit={() => listener.on("notify", (s) => current = s)}>
<p>{current.activeUsers} active users</p>
<p>{current.revenue} revenue</p>
</html>
Live Aggregates
Some data isn't a stream of rows or a single replaceable value: it's an
aggregate computed from many rows (count, sum, group by, top-N). LiveTable
streams row changes; aggregates don't fit.
Two patterns, depending on whether the aggregate is the same for every subscriber.
Same value for every subscriber. A background job computes the aggregate,
broadcasts the result. Every connected listener gets the same payload: the
RefreshStatsJob example above. The job's sql(...) runs once, every browser
receives the new value, the template reassigns.
Different value per subscriber. Each user sees their own aggregate (their
unread count, their team's leaderboard, their account balance). The server
broadcasts a "something changed" signal; each browser re-fetches its own value
via @rpc.
// app/pages/inbox/services.ts
import { Channel, session, sql } from "@elements/app";
export interface InboxStats {
unread: number;
flagged: number;
}
export const inboxChannel = new Channel<{ event: "refresh" }>("inbox");
/** @rpc */
export function loadInboxStats(): InboxStats {
session.isLoggedInOrThrow();
return sql<InboxStats>(
`select count(*) filter (where read = false) as unread,
count(*) filter (where flagged = true) as flagged
from messages
where userId = ${session.getOrThrow("userId")}`,
).first()!;
}
// app/pages/inbox/index.ts
import { session } from "@elements/app";
import inbox from "./template";
import { inboxChannel, loadInboxStats } from "./services";
export default function route(req, res) {
session.isLoggedInOrThrow();
return new inbox({
listener: inboxChannel.listen(),
stats: loadInboxStats(),
});
}
<!-- app/pages/inbox/template.html -->
import { inboxChannel, loadInboxStats } from "./services";
import type { Listener } from "@elements/app";
import type { InboxStats } from "./services";
<html (listener: Listener<{ event: "refresh" }>,
stats: InboxStats,
private current: InboxStats = stats)
oninit={() => listener.on("notify", () => current = loadInboxStats())}>
<p>{current.unread} unread</p>
<p>{current.flagged} flagged</p>
</html>
When a new message arrives, the writer (@rpc or Job.run()) calls
inboxChannel.notify({ event: "refresh" }). Every connected inbox page re-runs
loadInboxStats() for its own user.
Database Triggers
Postgres can also notify a channel directly. A trigger that calls pg_notify
lets the database notify a channel when a row changes. This is useful when
events originate outside the app, such as a psql command, a scheduled job, or
another service writing to the database.
Elements hashes channel names through the channel_name(text) SQL function so
Postgres' 63-char identifier limit doesn't truncate them. Triggers that fire on
Elements channels must use the same hash.
A trigger written directly in psql uses real Postgres function and identifier names (snake_case). The camelCase-to-snake_case translation Elements applies to app SQL and migrations does not run here:
create or replace function notify_alerts() returns trigger as $$
begin
perform pg_notify(
channel_name('alerts'),
json_build_object(
'op', TG_OP,
'data', row_to_json(case when TG_OP = 'DELETE' then old else new end)
)::text
);
return new;
end;
$$ language plpgsql;
create trigger alerts_notify after insert or update or delete
on alerts for each row execute function notify_alerts();
channel_name(text) ships with the Elements baseline schema and produces the
same hash the runtime uses, so triggers and the app route to the same channel.
Channel Name Uniqueness
Two Channel instances with the same name in different files share the same
Postgres channel. To make two declarations independent, give them different
names.
Related
livetable: higher-level CRUD with optimistic UI on top ofChannel.database: the PostgresLISTEN/NOTIFYwire Elements uses.