Edit

Channel

Environment: server.

BetaTelefunc Stream is in beta: breaking changes may occur in any version update.

The following low-level primitives enable all kinds of stream use case.

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() and listen() 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

MethodDescription
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 (void if 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 // true

On 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 to send()sendBinary() always opts in per call, so pass { ack: true } to each sendBinary() 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 needed

Broadcast

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 Broadcast to 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 Channel to the one client that received it, and bridges that client onto a Broadcast key.

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 key joins 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-query stays 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 .client is 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

MethodDescription
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, so publish(data) / subscribe(cb) take no key. The static Broadcast.publish(key, data) / Broadcast.subscribe(key, cb) have no onOpen / onClose / close / abort since 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()
TopologyOne server ↔ one clientAll members sharing a key (server and client)
Methodssend() / listen()publish() / subscribe()
DeliveryThe other endEvery subscriber of the key, including the publisher
Message typeAsymmetric:
  • Two different types: Channel<ClientMessageType, ServerMessageType>
  • The server uses the new Channel() instance, and returns channel.client to the client
Symmetric:
  • Same type for every member: BroadcastChannel<MessageType>
  • The server uses and returns the same BroadcastChannel instance
Message returnThe other end's reply (if { ack: true })A receipt: { key, seq, timestamp }
close()Closes the channel for both endsDetaches this member; the group lives on
Multi-serverSticky sessionsBroadcast transport

Errors

Channels and broadcasts signal failure through four errors:

ErrorWhenChannel stateRecovery
AbortEither side called abort(value), or a callback threw AbortClosedInspect err.abortValue
NetworkError (isChannel: true)Connection lost beyond reconnectTimeoutClosedAuto-closed — recreate if needed
ChannelClosedErrorsend() on a closed channelClosedRecreate the channel
ChannelOverflowErrorA buffered send was dropped — the buffer exceeded config.channel.bufferLimitOpenOnly the dropped send() rejects; await your sends to apply backpressure

A graceful close() isn't an error (onClose(err) receives undefined).

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.

See also