Skip to content
tako
Esc
↑↓navigate↵open⌘Jpreview
On this page

Channels

Define typed SSE and WebSocket channels with authorization, replay, browser clients, server-side publishing, and multi-server Postgres storage.

Tako Channels are broadcast streams for live app events over SSE or WebSocket. Channels are durable by default: Tako stores each message before fanout and retains a bounded replay window. Set ephemeral: true for memory-only delivery on a single server.

JavaScript and Go can register channel definitions. JavaScript additionally provides the server-side publish helper. Rust currently has no general Channels registration or publishing API; its Presence and Stores registries are separate capabilities. Browser and React examples below use the JavaScript client with any supported backend definition.

Local channel subscriptions bypass authorization callbacks. Test private access rules in a deployed test environment. Params do not isolate tenants or rooms; the stream-isolation rules below apply even when params validate.

Choose Broadcast Semantics

Channels are pub-sub, not work queues. Every authorized subscriber to a channel reads the same messages and, for durable channels, keeps an independent replay cursor. A message is not claimed, acknowledged, or removed when one subscriber receives it.

Use a channel for live dashboards, chat delivery, presence updates, notifications, and collaborative updates. Use a workflow when work should be claimed and executed with retries instead of broadcast to every subscriber.

For automatic online membership, typing status, and cursor metadata, use Presence with definePresence and usePresence from tako.sh/react.

For backend data that stays synchronized with the UI, use Stores with defineStore and useStore. Stores add authorized reads, scoped invalidation, reconnect catch-up, and optional offline caching on top of channels.

Every delivered message has this wire shape:

type ChannelMessage<T> = {
  id: string; // replay cursor for durable channels; "" for ephemeral events
  channel: string; // exact wire channel name
  type: string; // application-defined message type
  data: T;
};

Define A Channel

In JavaScript, put one default export in each <app_root>/channels/*.ts file. The first argument to defineChannel is the explicit wire name and is the source of truth for the public route; it does not have to match the file name. Use .$messageTypes<M>() to declare the payload for each message type without changing runtime behavior. In Go, register the same wire name before starting tako.ListenAndServe:

// <app_root>/channels/site-events.ts
import { defineChannel } from "tako.sh";
import { readSession } from "../lib/auth";

type SiteMessages = {
  notification: { id: string; text: string };
  presence: { online: number };
};

export default defineChannel("site-events", {
  auth: {
    async verify({ header }) {
      const session = await readSession(header);
      if (!session) return false;
      return { subject: session.userId };
    },
  },
  transport: "ws",
}).$messageTypes<SiteMessages>();
import "tako.sh"

func registerSiteEvents() {
    tako.Channels.Register("site-events", tako.ChannelDefinition{
        Auth: &tako.ChannelAuthScheme{HeaderName: "authorization"},
        Verify: func(input tako.VerifyInput) tako.ChannelAuthDecision {
            userID, ok := readSession(input.Header)
            if !ok { return tako.RejectChannel() }
            return tako.AllowChannel(tako.ChannelGrant{Subject: userID})
        },
        Transport: tako.ChannelTransportWS,
    })
}

readSession is application-owned authentication code. The JavaScript helper receives a parsed header; the Go helper above accepts *tako.ChannelHeaderValue and returns the verified user ID and a success flag. Go’s SDK serves discovery and authorization through ListenAndServe; it does not provide the JavaScript handle or browser-client methods.

Use Memory-Only Channels

Set ephemeral: true when only current subscribers need an event, such as live progress or transient telemetry. Omit it or set it to false for durable delivery.

import { defineChannel } from "tako.sh";

export default defineChannel("live-progress", {
  ephemeral: true,
  // Add transport: "ws" to enable browser publishing.
});
import "tako.sh"

tako.Channels.Register("live-progress", tako.ChannelDefinition{
    ChannelLifecycleConfig: tako.ChannelLifecycleConfig{Ephemeral: true},
})

Ephemeral messages never enter the channel database or replay log. They reach only subscribers currently connected to the same Tako server process. Messages published without subscribers, during disconnects, or before a server restart are lost. Each active channel has a bounded 64-message buffer; slow subscribers skip events that have been evicted. The buffer is released when the last subscriber disconnects.

Ephemeral messages have id: ""; SSE frames omit the id field. Reconnects start with live events and do not resume from a replay cursor. Authorization, params, SSE/WebSocket transport, keepalives, and connection lifetime still apply. Setting replayWindowMs together with ephemeral: true is rejected; in Go, leave ReplayWindowMs at its zero value.

Ephemeral channels currently support local development and single-server production without a postgres_url credential. Shared Postgres deployments reject ephemeral publishing and subscriptions because the current cross-server transport requires persisted messages. A channel can be durable or ephemeral independently of other channels in the app. Use Presence for membership and current typing/cursor metadata.

To verify, connect two clients and publish an event. Both should receive it. Disconnect, publish another event, then reconnect: the disconnected event must not replay.

Understand Channel Params

JavaScript’s paramsSchema is a TypeBox schema for typed query parameters. Go’s ParamsSchema accepts JSON Schema as json.RawMessage. Tako validates the values before authorization and passes them to the verifier.

Params do not change the stored channel name or partition replay and fanout. Every authorized parameter binding for one wire channel receives the same stream. Use params only as connection context when every authorized binding may receive every event. Do not use roomId, tenantId, or similar params as an isolation boundary.

For example, a shared announcements stream can reject unsupported client versions without creating one stream per version:

export default defineChannel("announcements", {
  paramsSchema: (t) =>
    t.Object({
      clientVersion: t.String({ minLength: 1 }),
    }),
  auth: {
    verify({ params }) {
      return supportsAnnouncements(params.clientVersion);
    },
  },
});
import (
    "encoding/json"
    "tako.sh"
)

func registerAnnouncements() {
    tako.Channels.Register("announcements", tako.ChannelDefinition{
        ParamsSchema: json.RawMessage(`{
            "type":"object",
            "properties":{"clientVersion":{"type":"string","minLength":1}},
            "required":["clientVersion"]
        }`),
        Auth: &tako.ChannelAuthScheme{HeaderName: "authorization"},
        Verify: func(input tako.VerifyInput) tako.ChannelAuthDecision {
            var params struct { ClientVersion string `json:"clientVersion"` }
            if json.Unmarshal(input.Params, &params) != nil || !supportsAnnouncements(params.ClientVersion) {
                return tako.RejectChannel()
            }
            return tako.AllowChannel(tako.ChannelGrant{})
        },
    })
}

supportsAnnouncements is an app-owned compatibility check. The Go registration uses the default SSE transport.

Omit the params schema for an unparameterized channel. In JavaScript, parameterized exports are callable; unparameterized exports are already bound handles:

import announcements from "../channels/announcements";
import siteEvents from "../channels/site-events";

const currentAnnouncements = announcements({ clientVersion: "2.4.0" });
const subscription = currentAnnouncements.subscribe();

await siteEvents.publish({ type: "presence", data: { online: 42 } });

Authorize And Choose A Transport

Omit auth or set it to false for a public channel. Otherwise, auth.verify receives the validated params, exact channel, requested operation, and any configured header or cookie. Production subscribe and connect requests pass "subscribe" or "connect" as the operation. The callback must return:

  • false to deny access.
  • true to allow access without an identity.
  • { subject: "user-id" } to allow access with a stable identity.

headerName defaults to authorization. Set headerName: false with cookieName for cookie-only auth. Cookie-authenticated WebSockets require an Origin matching the application’s public scheme, host, and port; cross-origin or missing-origin handshakes are rejected. Nonbrowser clients using cookies must also send this header. Browser clients can pass authorization: token; Tako sends Authorization: Bearer <token> for SSE and the equivalent auth envelope for WebSocket.

The transport option chooses the live transport:

Need Definition Browser API
Server-to-browser updates Omit transport; the channel uses receive-only SSE subscribe()
Browser-to-server messages Set transport: "ws" connect() / send()

WebSocket client frames are { type, data }; the proxy stores and broadcasts them as sent. Channels do not run application callbacks for incoming messages.

Publish From The Server

The JavaScript SDK provides server-side publishing. Import the channel definition, bind any params, and publish a typed event:

import siteEvents from "../channels/site-events";

const published = await siteEvents.publish({
  type: "presence",
  data: { online: 42 },
});

publish() requires the Tako server runtime and returns the published ChannelMessage. Browser code does not call it. An SSE browser only receives events, while a WebSocket browser sends { type, data } through its connection.

Consume In The Browser

Use the browser-safe tako.sh/client entry point outside React. Pass the exact wire name, "ws" for a WebSocket channel, and the channel params:

import { Channel, type ChannelMessage } from "tako.sh/client";

const events = new Channel("site-events", "ws");
const connection = events.connect({ authorization: token });

(connection.raw as WebSocket).addEventListener("message", (event) => {
  const message = JSON.parse(event.data) as ChannelMessage;
  console.log(message.type, message.data);
});

For an SSE channel, omit the transport and call subscribe():

const updates = new Channel("announcements", undefined, { clientVersion: "2.4.0" });
const subscription = updates.subscribe({ authorization: token });

React apps can use useChannel from tako.sh/react. SSE is the default; set transport: "ws" to receive a send function:

import { useChannel } from "tako.sh/react";

function LiveStatus({ token }: { token: string }) {
  const { messages, status, send } = useChannel("site-events", {
    transport: "ws",
    authorization: token,
  });

  return (
    <button
      disabled={status !== "open"}
      onClick={() => send({ type: "presence", data: { online: 42 } })}
    >
      Send presence event ({messages.length} received)
    </button>
  );
}

Recover After Disconnects

For durable channels, each publish is inserted before delivery. replayWindowMs defaults to 10 minutes, so a reconnect can bridge a browser reload, laptop sleep, network change, server restart, or rolling deploy.

Replay cleanup runs every second, including when no clients are connected. Channel definitions carry their lifecycle settings with server-side publishes, so retention applies before the first subscription. A committed publish immediately notifies subscribers using the same store. An app-wide poller checks for commits from other processes or servers every 100 ms; database reads run outside the async request workers. Shared Postgres stores detect publishes from every server.

Local replay uses SQLite WAL with synchronous=NORMAL: committed messages survive an application process crash, but recent commits can be lost after a host crash or power failure. Replay is a short delivery buffer; keep canonical history in the app database as described below.

  • SSE resumes with Last-Event-ID; subscribe({ lastEventId }) sets an initial cursor. The built-in fetch-based SSE reader reconnects until close() is called and carries its latest message id forward.
  • WebSocket resumes with last_message_id; connect({ lastMessageId }) sets it. useChannel tracks the latest id and reconnects WebSockets with bounded backoff and jitter. A lower-level connect() call returns one socket, so non-React code owns WebSocket reconnection.
  • With no cursor, Tako starts at the latest retained message and then follows live traffic.

If an SSE cursor is older than retained replay, Tako responds with 410 Gone; WebSocket closes with code 4410 and reason replay-too-old. Reload current state from the app’s normal data API, then create a fresh subscription rather than assuming the missing events still exist.

Deploy Replay Storage

Storage is a deployment choice; application publish and subscribe code stays the same.

Deployment Replay storage
Local development In-memory for the current dev daemon process
One production server Tako-managed SQLite (channels.sqlite) by default
Multiple production servers Shared Postgres schema tako_channels, keyed by deployed app id

A multi-server environment with durable channels requires the shared postgres_url environment credential so publishes and replay are visible on every server:

tako credentials set postgres_url --env production

Tako validates this for discovered JavaScript channel definitions before build and deploy work starts. Inline Go registrations are not detected by that preflight, so explicitly configure postgres_url before deploying them to multiple servers. It is an encrypted provider credential, not a tako.toml field or an app secret. A single-server environment can also use Postgres by setting the credential.

Keep Canonical History In Your App Database

The replay store is a short delivery buffer, not permanent product history. Chat messages, document operations, audit events, and anything else that must remain queryable should be committed to your app database before publishing the corresponding channel event.

const saved = await db.notifications.create({ userId, text });

await siteEvents.publish({
  type: "notification",
  data: saved,
});

For canonical writes, use a normal authenticated app endpoint to commit the record, then publish the resulting channel event from server-side code. Treat browser-sent WebSocket frames as transient live signals. The app database remains the source of truth; channel replay only closes short delivery gaps.

Verify

Connect two authorized clients and confirm both receive the same published event. Test a denied client in a deployed environment, reconnect with a replay cursor, and handle expired replay by reloading canonical app state. On multiple servers, publish through one and subscribe through another. Do not treat a successful local connection as proof of production authorization.

Last updated on

Was this page helpful?