Channel
Environment: server.
Beta — Telefunc Stream is in beta: breaking changes may occur in any version update.
The following low-level primitives enable all kinds of stream use case.
new Channel()— direct messages between one server and one client.Broadcast— a keyed pub/sub bus (publish()/subscribe()), server-side.new BroadcastChannel()— broadcast also on the client-side.
You can use them for one-way streams (aka streaming) as well as two-way streams (aka real-time):
- Chat rooms
- Live feeds (sports scores, stock tickers)
- Collaborative editing
- Multiplayer games
- Live dashboards & metrics
- Notifications & activity feeds
For short-lived callbacks (e.g. progress updates), function passing is usually simpler.
By default, channels and broadcasts use SSE and work without extra server setup. When WebSocket is enabled, the client starts on SSE and seamlessly upgrades to WebSocket in the background.
Needs a long-running server. A channel holds a connection open for its entire lifetime, so channels and broadcasts don't work on serverless platforms that enforce a short time limit on connections (e.g. Vercel, AWS Lambda). See Stream on Cloudflare for using channels on Cloudflare Workers, and Stream at Scale for running multiple server instances.
For channel configurations, see:
new Channel()
It creates a two-way message pipe between the server and the one client that called the telefunction.
Simple example:
// Clock.telefunc.js
// Environment: server
import { Channel } from 'telefunc'
export async function onClock() {
const channel = new Channel()
const interval = setInterval(() => channel.send(Date.now()), 1000)
channel.onClose(() => clearInterval(interval))
return { channel: channel.client }
}The server returns
channel.client: the client gets the same API but with message directions flipped.
// Clock.jsx
// Environment: client
import { onClock } from './Clock.telefunc'
const { channel } = await onClock()
channel.listen((time) => console.log(time)) // 1700000000000, 1700000001000, ...// Clock.tsx
// Environment: client
import { onClock } from './Clock.telefunc'
const { channel } = await onClock()
channel.listen((time) => console.log(time)) // 1700000000000, 1700000001000, ...The channel outlives the telefunction call: both ends can
send()andlisten()until either side closes it.
Two-way example:
// Echo.telefunc.js
// Environment: server
import { Channel } from 'telefunc'
export async function onEcho() {
const channel = new Channel()
channel.listen((text) => channel.send(`echo: ${text}`))
return { channel: channel.client }
}// Echo.jsx
// Environment: client
import { onEcho } from './Echo.telefunc'
const { channel } = await onEcho()
channel.listen((text) => console.log(text)) // 'echo: Hello!'
channel.send('Hello!')// Echo.tsx
// Environment: client
import { onEcho } from './Echo.telefunc'
const { channel } = await onEcho()
channel.listen((text) => console.log(text)) // 'echo: Hello!'
channel.send('Hello!')Channel methods
| Method | Description |
|---|---|
send(data) | Send a message. Await to apply backpressure. |
send(data, { ack: true }) | Send and await acknowledgement. |
sendBinary(data) | Send binary data. Await to apply backpressure. |
sendBinary(data, { ack: true }) | Send binary and await acknowledgement. |
listen(cb) | Receive messages. Return a value to ack. Returns an unlisten function. |
listenBinary(cb) | Receive binary. Return a value to ack. Returns an unlisten function. |
onOpen(cb) | Called when the channel opens. |
onClose(cb) | Called when the channel closes. |
close() | Close gracefully. |
abort(value?) | Terminate immediately. |
TypeScript
new Channel<ClientToServer, ServerToClient>() takes two generic type parameters. Each is a function signature:
- The argument is the message type.
- The return type is the acknowledgement type (
voidif no ack).
// Room.telefunc.js
// Environment: server
import { Channel } from 'telefunc'
export async function onJoinRoom() {
const channel = new Channel({ ack: true })
channel.listen((message) => {
if (message.type === 'join') {
channel.send({ type: 'system', text: `Joined ${message.roomId}` })
return { accepted: true }
}
channel.send({ type: 'chat', text: `echo: ${message.text}` })
return { accepted: true }
})
return { channel: channel.client }
}// Room.telefunc.ts
// Environment: server
import { Channel } from 'telefunc'
type ServerMessage =
| { type: 'system'; text: string }
| { type: 'chat'; text: string }
type ClientMessage =
| { type: 'join'; roomId: string }
| { type: 'chat'; text: string }
type ClientToServer = (message: ClientMessage) => { accepted: true }
type ServerToClient = (message: ServerMessage) => { delivered: true }
export async function onJoinRoom() {
const channel = new Channel<ClientToServer, ServerToClient>({ ack: true })
channel.listen((message) => {
if (message.type === 'join') {
channel.send({ type: 'system', text: `Joined ${message.roomId}` })
return { accepted: true }
}
channel.send({ type: 'chat', text: `echo: ${message.text}` })
return { accepted: true }
})
return { channel: channel.client }
}// Room.jsx
// Environment: client
import { onJoinRoom } from './Room.telefunc'
const { channel } = await onJoinRoom()
const unlisten = channel.listen((message) => {
console.log(message.text)
return { delivered: true }
})
// unlisten() to stop receiving
const ack = await channel.send({ type: 'join', roomId: 'general' })
ack.accepted // true// Room.tsx
// Environment: client
import { onJoinRoom } from './Room.telefunc'
const { channel } = await onJoinRoom()
const unlisten = channel.listen((message) => {
console.log(message.text)
return { delivered: true }
})
// unlisten() to stop receiving
const ack = await channel.send({ type: 'join', roomId: 'general' })
ack.accepted // trueOn the server, send() sends ServerMessage. On the client, send() sends ClientMessage. The channel.client type flips the message types.
Acknowledgements
Channel-wide
const channel = new Channel({ ack: true })
// Every send() returns a Promise with the listener's return value
const ack = await channel.send({ type: 'status', text: 'OK' })const channel = new Channel<ClientToServer, ServerToClient>({ ack: true })
// Every send() returns a Promise with the listener's return value
const ack = await channel.send({ type: 'status', text: 'OK' })Per-send
const channel = new Channel()
// Opt in for a single send
const ack = await channel.send(data, { ack: true })
// Opt out on an ack-default channel
await channel.send(data, { ack: false }) // Promise<void>, no ack value
// Binary acks work the same way
const binaryAck = await channel.sendBinary(frame, { ack: true })const channel = new Channel<ClientToServer, ServerToClient>()
// Opt in for a single send
const ack = await channel.send(data, { ack: true })
// Opt out on an ack-default channel
await channel.send(data, { ack: false }) // Promise<void>, no ack value
// Binary acks work the same way
const binaryAck = await channel.sendBinary(frame, { ack: true })A channel-wide
{ ack: true }only applies tosend()—sendBinary()always opts in per call, so pass{ ack: true }to eachsendBinary()that needs an acknowledgement.
Binary data
Send and receive raw binary alongside structured messages:
// Server
await channel.sendBinary(new Uint8Array([1, 2, 3]))
const unlisten = channel.listenBinary((data) => {
console.log(data.byteLength)
return { processed: true } // ack value (optional)
})
unlisten() // stop listening
// Client — same API
await channel.sendBinary(new Uint8Array(buffer))
channel.listenBinary((data) => {
console.log(data.byteLength)
})
// Binary acks
const ack = await channel.sendBinary(frame, { ack: true })Backpressure
Both send() and sendBinary() return a Promise that resolves when the receiver has capacity for more data. Await them in a loop to apply backpressure:
for (const frame of frames) {
await channel.sendBinary(frame) // blocks until the receiver is ready
}Fire-and-forget is also fine — data is always sent immediately regardless of whether you await:
channel.send({ type: 'ping' }) // sent immediately, no await neededBroadcast
Broadcast is a keyed pub/sub bus: a message published to a key reaches every subscriber of that key — server-side subscribers (via Broadcast.subscribe()) and clients bridged into the key (via new BroadcastChannel()). Publishers and subscribers are decoupled; all they share is the key string. It's the fan-out layer that new BroadcastChannel() is built on.
The static methods run purely on the server — no client, no handle, no lifecycle — and take the key as their first argument:
import { Broadcast } from 'telefunc'
// Publish to every subscriber of the key
Broadcast.publish('room:lobby', { text: 'Hello!' })
// Subscribe on the server
const unsub = Broadcast.subscribe('room:lobby', (msg, info) => {
console.log(msg, info.seq)
})publish() returns a receipt and subscribe() receives the same info:
info.key // 'room:lobby' — the broadcast key
info.seq // monotonic sequence number for this key
info.timestamp // server timestamp (Unix epoch ms)seq is monotonic per key, which is useful for ordering and gap detection. publish() resolves to info once the message is accepted.
By default, broadcast is in-memory — messages only reach subscribers on the same server. This works out-of-the-box for single-server deployments.
For scaling horizontally, you must configure
Broadcastto publish across all server instances — see Stream at Scale > Broadcast
new BroadcastChannel()
new BroadcastChannel({ key }) lets you broadcast from and to the client-side (whereas Broadcast is server-side only).
It creates a
Channelto the one client that received it, and bridges that client onto aBroadcastkey.Return it from a telefunction and the receiving client can
publish()/subscribe()on the key — every message reaches every member of the group (both client-side and server-side).
⚠️Keys are capabilities — anyone who knows the
keyjoins the group. Secure a broadcast in one of two ways:
- Guard the key: derive it from authorized server-side context (e.g. the user ID from
getContext()), never from client-side input.- Guard the payload: whatever you
publish()reaches every subscriber, so broadcast only non-sensitive data. That's how@telefunc/tanstack-querystays safe — it broadcasts only a "refetch" signal, and each client loads the actual data through its own authorized telefunction.See also:
Chat example
// Chat.telefunc.js
// Environment: server
import { BroadcastChannel } from 'telefunc'
export async function onJoinChat(room, username) {
const chat = new BroadcastChannel({ key: `chat:${room}` })
chat.onOpen(() => {
chat.publish({ user: 'system', text: `${username} joined` })
})
chat.onClose(() => {
chat.publish({ user: 'system', text: `${username} left` })
})
return chat
}// Chat.telefunc.ts
// Environment: server
import { BroadcastChannel } from 'telefunc'
type ChatMessage = { user: string; text: string }
export async function onJoinChat(room: string, username: string) {
const chat = new BroadcastChannel<ChatMessage>({ key: `chat:${room}` })
chat.onOpen(() => {
chat.publish({ user: 'system', text: `${username} joined` })
})
chat.onClose(() => {
chat.publish({ user: 'system', text: `${username} left` })
})
return chat
}Unlike channels (
new Channel()), no.clientis needed: broadcast is symmetric — the same instance has the same message type on both ends (no directional flip).
// Chat.jsx
// Environment: client
import { onJoinChat } from './Chat.telefunc'
const chat = await onJoinChat('lobby', 'Alice')
// Receive messages published to the key, including this instance's own publishes
chat.subscribe((msg, info) => {
console.log(`#${info.seq} ${msg.user}: ${msg.text}`)
})
// Publish to every subscriber of the key
chat.publish({ user: 'Alice', text: 'Hello!' })// Chat.tsx
// Environment: client
import { onJoinChat } from './Chat.telefunc'
const chat = await onJoinChat('lobby', 'Alice')
// Receive messages published to the key, including this instance's own publishes
chat.subscribe((msg, info) => {
console.log(`#${info.seq} ${msg.user}: ${msg.text}`)
})
// Publish to every subscriber of the key
chat.publish({ user: 'Alice', text: 'Hello!' })Notifications
A per-user key with a subscribe-only client — the server returns a BroadcastChannel keyed to the user, and the client only listens:
// Notifications.telefunc.js
// Environment: server
import { BroadcastChannel, getContext } from 'telefunc'
export async function onListenNotifications() {
const context = getContext()
const broadcast = new BroadcastChannel({ key: `notifications:${context.userId}` })
// Send a toast to *all* clients matching the user id, i.e. on all the tabs of
// all the user's devices.
setTimeout(() => broadcast.publish('hello'), 1000)
setTimeout(() => broadcast.publish('world'), 2000)
return broadcast
}// Notifications.telefunc.ts
// Environment: server
import { BroadcastChannel, getContext } from 'telefunc'
export async function onListenNotifications() {
const context = getContext()
const broadcast = new BroadcastChannel({ key: `notifications:${context.userId}` })
// Send a toast to *all* clients matching the user id, i.e. on all the tabs of
// all the user's devices.
setTimeout(() => broadcast.publish('hello'), 1000)
setTimeout(() => broadcast.publish('world'), 2000)
return broadcast
}// Notifications.jsx
// Environment: client
const broadcast = await onListenNotifications()
broadcast.subscribe((n) => showToast(n))// Notifications.tsx
// Environment: client
const broadcast = await onListenNotifications()
broadcast.subscribe((n) => showToast(n))BroadcastChannel methods
| Method | Description |
|---|---|
publish(data) | Publish to every subscriber of the key. Returns a receipt. |
subscribe(cb) | Receive (data, info). Returns an unsubscribe function. |
publishBinary(data) | Publish raw binary to every subscriber. Returns a receipt. |
subscribeBinary(cb) | Receive (data, info) for binary messages. Returns an unsubscribe function. |
onOpen(cb) | Called when the channel opens. |
onClose(cb) | Called when the channel closes. |
close() | Close gracefully. |
abort(abortValue?) | Terminate immediately. |
The instance is bound to its
key, sopublish(data)/subscribe(cb)take no key. The staticBroadcast.publish(key, data)/Broadcast.subscribe(key, cb)have noonOpen/onClose/close/abortsince there's no instance to manage.
new BroadcastChannel() VS new Channel()
Fundamentally
The difference is what a message is addressed to: a channel message is addressed to someone, a broadcast message is addressed to a topic (the key).
A channel is a conversation, like a phone call: two fixed ends (the server and the one client that received the channel), private to them, ending when either side hangs up.
A broadcast is an announcement, like a radio frequency: whoever tunes in to the key receives everything published to it, members come and go, and the key belongs to no one.
Only the two ends of a channel can use it, while anything that knows a broadcast's key can join it. A channel ends when either side closes it, while a broadcast outlives any single member.
Technically
new BroadcastChannel() is essentially new Channel() combined with the static Broadcast.publish() / Broadcast.subscribe(): the server holds a private channel to the one client that received it, then bridges that client into the keyed broadcast group.
Comparison
new Channel() | new BroadcastChannel() | |
|---|---|---|
| Topology | One server ↔ one client | All members sharing a key (server and client) |
| Methods | send() / listen() | publish() / subscribe() |
| Delivery | The other end | Every subscriber of the key, including the publisher |
| Message type | Asymmetric:
| Symmetric:
|
| Message return | The other end's reply (if { ack: true }) | A receipt: { key, seq, timestamp } |
close() | Closes the channel for both ends | Detaches this member; the group lives on |
| Multi-server | Sticky sessions | Broadcast transport |
Errors
Channels and broadcasts signal failure through four errors:
| Error | When | Channel state | Recovery |
|---|---|---|---|
Abort | Either side called abort(value), or a callback threw Abort | Closed | Inspect err.abortValue |
NetworkError (isChannel: true) | Connection lost beyond reconnectTimeout | Closed | Auto-closed — recreate if needed |
ChannelClosedError | send() on a closed channel | Closed | Recreate the channel |
ChannelOverflowError | A buffered send was dropped — the buffer exceeded config.channel.bufferLimit | Open | Only the dropped send() rejects; await your sends to apply backpressure |
A graceful
close()isn't an error (onClose(err)receivesundefined).
See also:
Reconnection
If the connection drops, Telefunc reconnects automatically and resumes existing channels and broadcasts:
- Messages sent while offline are buffered and delivered in order on reconnect.
- Both sides keep a bounded replay buffer; after reconnect, missing frames are replayed.
onOpen()fires only on initial open,onClose()only on permanent close.
Reconnection is automatic — you don't need to handle it in your application code.