Build your first channel

Define a counter, mount it at a URI, start a hub, and commit the first action.

On this page 5
One definition, every runtime
Stateschema + initial
Actionspure reducers
Commandsserver work
Notificationslive only

This guide builds a stateful singleton at counter://. Its single client-safe action adds a positive integer to the shared count.

Import the building blocks

Install the published package, then import it by name:

npm install durable-channel
# or: deno add npm:durable-channel
import * as v from "valibot";
import {
  durableChannel,
  DurableChannelHub,
  durableRoutes,
  MemoryStorage,
  RejectAction,
} from "durable-channel";

Define state and one action

The reducer is synchronous and pure. Marking the action with .client() lets connected clients dispatch it and apply it optimistically.

const counter = durableChannel()
  .state(v.object({ count: v.number() }), { count: 0 })
  .action((action) =>
    action
      .name("counter/incremented")
      .payload(v.object({ by: v.pipe(v.number(), v.integer()) }))
      .client()
      .reduce((state, { by }) => {
        if (by <= 0) throw new RejectAction("by must be positive");
        return { count: state.count + by };
      }),
  )
  .build();

Mount the route and create the hub

A template with no named parameters is a singleton. Its initial state is created on first touch.

const routes = durableRoutes()
  .route("counter://", counter)
  .build();

const hub = new DurableChannelHub(routes, {
  storage: new MemoryStorage(),
  env: {},
  replayLimit: 1024,
});

Connect, subscribe, and dispatch

The connection receives every message for its subscriptions, including the envelope originating from itself.

const connection = hub.connect({ id: "alice-tab", send: console.log });

await hub.subscribe(connection, "counter://");
await hub.dispatchFrom(
  connection,
  "counter://",
  "counter/incremented",
  { by: 2 },
  1,
);

await hub.get("counter://"); // { count: 2 }

Check the contract

Payloads are parsed before the reducer runs, and the reducer result is validated against the state schema before persistence. A RejectAction still produces an ordered envelope, but leaves state unchanged and carries a rejectionReason.

Continue with Core concepts to see how state, actions, commands, and notifications divide responsibility.