durable-notifier
v0.3.0
Published
Authenticated per-user realtime events on Cloudflare Workers with React hooks
Downloads
417
Maintainers
Readme
durable-notifier
Realtime events on Cloudflare Workers — per-user and per-channel. Tiny server API, React hooks on the client, Durable Objects hidden.
npm install durable-notifierQuick start
Server
import { createServerNotifier, UserChannel } from "durable-notifier/server";
const notifier = createServerNotifier({
authenticate: async (request) => {
const session = await getSession(request);
if (!session) return null;
return session.user.id;
},
});
// Hono example — any framework works
app.get("/ws", (c) => notifier.upgrade(c.req.raw, c.env));
// Send events from any route or queue handler
await notifier.sendToUser(env, userId, {
type: "inbox.invalidate",
data: { inboxId: "main" },
});
// Re-export the Durable Object class for wrangler
export { UserChannel };Client
import { createNotifier } from "durable-notifier/client";
const notifier = createNotifier("/ws");
function Inbox() {
notifier.useEvent("inbox.invalidate", () => {
queryClient.invalidateQueries({ queryKey: ["inbox"] });
});
const status = notifier.useStatus();
// "idle" | "connecting" | "connected" | "reconnecting" | "closed"
return <div>Connection: {status}</div>;
}wrangler.jsonc
{
"durable_objects": {
"bindings": [
{ "name": "USER_CHANNEL", "class_name": "UserChannel" }
]
},
"migrations": [
{ "tag": "v1", "new_classes": ["UserChannel"] }
]
}Type-safe events
Define an event map and pass it as a generic to both server and client:
type AppEvents = {
"order.updated": { orderId: string; status: string };
"chat.message": { from: string; text: string };
"inbox.invalidate": { inboxId: string };
};
// Server — data is type-checked
const notifier = createServerNotifier<AppEvents>({ authenticate });
await notifier.sendToUser(env, userId, {
type: "order.updated",
data: { orderId: "abc", status: "shipped" }, // ✓ typed
});
// Client — handler receives typed data
const client = createNotifier<AppEvents>("/ws");
client.useEvent("order.updated", (data) => {
console.log(data.orderId); // string
console.log(data.status); // string
});Channels
Channels let multiple users subscribe to a shared topic and receive the same events. A single user can be subscribed to many channels — all events arrive over their one WebSocket connection.
Channel types
| Prefix | Auth required | Description |
|--------|--------------|-------------|
| (none) | No | Public channel. Any authenticated user can subscribe. |
| private- | Yes | Private channel. authorizeChannel must return truthy. |
| presence- | Yes | Reserved for future presence support. Gated by authorizeChannel. |
Server setup
import { createServerNotifier, UserChannel, Channel } from "durable-notifier/server";
const notifier = createServerNotifier<AppEvents>({
authenticate: async (request) => {
const session = await getSession(request);
return session?.user.id ?? null;
},
// Optional — required for private-* and presence-* channels
authorizeChannel: async (userId, channel) => {
// e.g. check if user is a member of this org
const orgId = channel.replace("private-org-", "");
return db.isOrgMember(userId, orgId);
},
});
// WebSocket upgrade (existing)
app.get("/ws", (c) => notifier.upgrade(c.req.raw, c.env));
// Channel subscribe/unsubscribe endpoints (new)
app.post("/channels/subscribe", (c) => notifier.subscribe(c.req.raw, c.env));
app.post("/channels/unsubscribe", (c) => notifier.unsubscribe(c.req.raw, c.env));
// Send an event to everyone in a channel
await notifier.sendToChannel(env, "private-org-42", {
type: "chat.message",
data: { from: "alice", text: "hello team" },
});
// Re-export both DO classes
export { UserChannel, Channel };Client setup
import { createNotifier } from "durable-notifier/client";
const notifier = createNotifier<AppEvents>("/ws", {
channelEndpoint: "/channels",
// Optional — add auth headers for channel HTTP requests
getHeaders: () => ({
Authorization: `Bearer ${getToken()}`,
}),
});
function ChatRoom({ roomId }: { roomId: string }) {
// Subscribe on mount, unsubscribe on unmount
notifier.useChannel(`private-org-${roomId}`);
notifier.useEvent("chat.message", (data, event) => {
// event.channel tells you which channel it came from
console.log(`[${event.channel}] ${data.from}: ${data.text}`);
});
return <div>Room: {roomId}</div>;
}wrangler.jsonc (with channels)
{
"durable_objects": {
"bindings": [
{ "name": "USER_CHANNEL", "class_name": "UserChannel" },
{ "name": "CHANNEL", "class_name": "Channel" }
]
},
"migrations": [
{ "tag": "v1", "new_classes": ["UserChannel"] },
{ "tag": "v2", "new_classes": ["Channel"] }
]
}How channels work
┌──────────┐ POST /channels/subscribe ┌──────────────┐
│ Client │ ────────────────────────── │ Worker │
│ (React) │ HTTP │ (entrypoint) │
└──────────┘ └──────┬───────┘
▲ │ │
│ events over existing WebSocket │ │
│ ▼ ▼
┌──────────────┐ ┌──────────────────┐
│ UserChannel │ │ Channel │
│ (per user) │ │ (per channel) │
│ WebSockets │ │ member list │
└──────────────┘ └──────────────────┘- Client subscribes to a channel via HTTP POST (auth runs server-side)
- The Worker tells the
ChannelDO to store the user ID in its member list - When
sendToChannelis called, the Worker reads the member list from the Channel DO, then sends the event to each member'sUserChannelDO - Each
UserChannelpushes the event to all of that user's WebSocket connections - The event includes a
channelfield so the client knows where it came from
One WebSocket per user, regardless of how many channels they're in.
Upgrading from v0.2.x (adding channels)
Channels are fully opt-in. Existing code works without changes. To add channel support:
1. Add the Channel DO binding to wrangler.jsonc:
{
"durable_objects": {
"bindings": [
{ "name": "USER_CHANNEL", "class_name": "UserChannel" },
{ "name": "CHANNEL", "class_name": "Channel" } // ← add
]
},
"migrations": [
{ "tag": "v1", "new_classes": ["UserChannel"] },
{ "tag": "v2", "new_classes": ["Channel"] } // ← add
]
}2. Re-export the Channel class from your worker:
// Before
export { UserChannel } from "durable-notifier/server";
// After
export { UserChannel, Channel } from "durable-notifier/server";3. Add subscribe/unsubscribe routes:
app.post("/channels/subscribe", (c) => notifier.subscribe(c.req.raw, c.env));
app.post("/channels/unsubscribe", (c) => notifier.unsubscribe(c.req.raw, c.env));4. (Optional) Add authorizeChannel for private channels:
const notifier = createServerNotifier({
authenticate: ...,
authorizeChannel: async (userId, channel) => {
return await hasAccess(userId, channel);
},
});5. On the client, pass channelEndpoint:
const notifier = createNotifier<AppEvents>("/ws", {
channelEndpoint: "/channels",
});6. Use channels:
// Hook — subscribes on mount, unsubscribes on unmount
notifier.useChannel("room-42");
// Or imperative
await notifier.subscribe("room-42");
await notifier.unsubscribe("room-42");No changes needed to existing sendToUser, useEvent, useStatus, or useLastEvent code.
Server API
createServerNotifier<M>(options)
import { createServerNotifier } from "durable-notifier/server";
const notifier = createServerNotifier<AppEvents>({
authenticate: async (request: Request) => {
// Return a userId string, or null to reject (401)
return getUserIdFromCookie(request);
},
// Optional — required for private-* and presence-* channels
authorizeChannel: async (userId: string, channel: string) => {
// Return true to allow, falsy to deny
return checkAccess(userId, channel);
},
});| Option | Type | Required | Description |
|--------|------|----------|-------------|
| authenticate | (request) => string \| null | Yes | Return a user ID or null to reject. |
| authorizeChannel | (userId, channel) => boolean \| null | No | Gate private- and presence- channels. Not called for public channels. If not provided, all private/presence subscriptions are rejected. |
notifier.upgrade(request, env)
Handle a WebSocket upgrade request. Authenticates, then routes to the user's Durable Object.
app.get("/ws", (c) => notifier.upgrade(c.req.raw, c.env));Returns:
101— WebSocket established401— Authentication failed426— Not a WebSocket upgrade request
notifier.sendToUser(env, userId, event)
Send an event to all of a user's active connections (multiple tabs, devices).
await notifier.sendToUser(env, "user-123", {
type: "chat.message",
data: { from: "alice", text: "hello" },
});The id (UUID) and ts (timestamp) fields are auto-generated if not provided. Throws if delivery to the Durable Object fails.
notifier.sendToUsers(env, userIds, event)
Send to multiple users concurrently. Uses Promise.allSettled — one failure won't block others.
const results = await notifier.sendToUsers(env, ["user-1", "user-2", "user-3"], {
type: "announcement",
data: { text: "Server maintenance at 2am" },
});
for (const r of results) {
if (r.status === "rejected") console.error(r.reason);
}notifier.sendToChannel(env, channel, event)
Send an event to all members of a channel. The event is delivered to each member's UserChannel DO with a channel field added. Uses Promise.allSettled internally — individual delivery failures are silently ignored.
await notifier.sendToChannel(env, "room-42", {
type: "chat.message",
data: { from: "alice", text: "hello room" },
});All recipients receive the same id and ts.
notifier.subscribe(request, env)
Handle an incoming channel subscribe HTTP request. Authenticates the request, checks authorizeChannel for private/presence channels, then adds the user to the channel's member list.
The request body must be JSON with { channel: string }.
app.post("/channels/subscribe", (c) => notifier.subscribe(c.req.raw, c.env));Returns:
200{ ok: true }— Subscribed400— Missing or invalid channel name401— Authentication failed403— Channel authorization denied (orauthorizeChannelnot configured for private channels)
notifier.unsubscribe(request, env)
Handle an incoming channel unsubscribe HTTP request. Authenticates, then removes the user from the channel.
The request body must be JSON with { channel: string }.
app.post("/channels/unsubscribe", (c) => notifier.unsubscribe(c.req.raw, c.env));notifier.getChannelMembers(env, channel)
Get the list of user IDs subscribed to a channel.
const members = await notifier.getChannelMembers(env, "room-42");
// ["user-1", "user-2"]notifier.disconnectUser(env, userId, reason?)
Close all of a user's active WebSocket connections.
await notifier.disconnectUser(env, "user-123", "Session expired");notifier.getPresence(env, userId)
Check whether a user has any active connections.
const online = await notifier.getPresence(env, "user-123");
// true if at least one WebSocket is opennotifier.UserChannel
The per-user Durable Object class. Re-export it from your worker entry point.
notifier.Channel
The per-channel Durable Object class. Re-export it from your worker entry point when using channels.
export { UserChannel, Channel } from "durable-notifier/server";NotifierEnv
Type for the required Durable Object bindings. Merge it into your worker's Env:
import type { NotifierEnv } from "durable-notifier/server";
type Env = {
Bindings: NotifierEnv & {
// your other bindings
};
};The USER_CHANNEL binding is always required. The CHANNEL binding is only needed when using channel features.
Client API
createNotifier<M>(url, options?)
import { createNotifier } from "durable-notifier/client";
const notifier = createNotifier<AppEvents>("/ws", {
lazy: true, // default — connect on first subscriber
channelEndpoint: "/channels", // enables channel support
getHeaders: () => ({ // custom headers for channel HTTP requests
Authorization: `Bearer ${getToken()}`,
}),
});| Option | Type | Default | Description |
|--------|------|---------|-------------|
| lazy | boolean | true | Connect only when the first useEvent subscriber mounts. |
| channelEndpoint | string | — | Base URL for channel HTTP endpoints. Required for channel support. |
| getHeaders | () => Record<string, string> | — | Extra headers for channel HTTP requests (e.g. auth tokens). Cookies are sent automatically. |
Call this once at module scope — all hooks share the single instance.
notifier.useEvent(type, handler)
Subscribe to events. Cleans up on unmount.
notifier.useEvent("chat.message", (data, event) => {
// data: { from: string; text: string } — typed from your EventMap
// event: WireEvent — full envelope with type, data, id, ts, channel
console.log(data.from, data.text);
if (event.channel) {
console.log("from channel:", event.channel);
}
});- Multiple components can subscribe to the same event type
- Handler always uses the latest callback (no stale closures)
- Only
useEventtriggers a connection —useStatusanduseLastEventare passive - Events from channels include
event.channelwith the channel name
notifier.useStatus()
Reactive connection status.
const status = notifier.useStatus();
// "idle" | "connecting" | "connected" | "reconnecting" | "closed"| Status | Meaning |
|--------|---------|
| idle | No connection. Either no subscribers (lazy) or not yet started. |
| connecting | WebSocket handshake in progress. |
| connected | Connection open and receiving events. |
| reconnecting | Disconnected unexpectedly, attempting to reconnect. |
| closed | Permanently closed via close(). Will not reconnect. |
notifier.useLastEvent(type?)
Read the most recent event, optionally filtered by type.
const lastMessage = notifier.useLastEvent("chat.message");
// WireEvent<"chat.message"> | nullReturns null until the first matching event arrives. The returned reference is stable — same object until a new event arrives, so it works with React.memo and dependency arrays.
notifier.useChannel(channel)
Subscribe to a channel on mount, unsubscribe on unmount. Automatically re-subscribes on WebSocket reconnect.
function ChatRoom({ roomId }: { roomId: string }) {
notifier.useChannel(`room-${roomId}`);
notifier.useEvent("chat.message", (data) => { ... });
return <div>...</div>;
}Requires channelEndpoint to be set in options. The channel subscription is managed via HTTP requests to the server — the WebSocket only carries events.
notifier.subscribe(channel) / notifier.unsubscribe(channel)
Imperative channel subscription. Use when you need to control timing or handle errors.
try {
await notifier.subscribe("private-org-42");
} catch (err) {
console.error("Subscribe failed:", err.message);
}
await notifier.unsubscribe("private-org-42");Subscribed channels are tracked and automatically re-subscribed on WebSocket reconnect.
notifier.close()
Permanently shut down the connection. Sets status to "closed", clears all listeners, cached events, and channel subscriptions, prevents reconnection.
notifier.close();notifier.clearEventCache()
Clear cached events without closing the connection. Useful on logout or user switch to prevent useLastEvent from returning stale data.
notifier.clearEventCache();Wire protocol
All messages are JSON with this envelope:
interface WireEvent {
type: string; // event name
data?: unknown; // payload
id?: string; // unique ID (UUID, server-generated)
ts?: number; // unix ms timestamp (server-generated)
channel?: string; // channel name (present for channel events)
}Internal protocol messages (ping, pong) are filtered and never exposed to event handlers or cached.
Connection behavior
Lazy mode (default)
- Connection opens when the first
useEventsubscriber mounts - Connection closes when the last subscriber unmounts
- Re-opens automatically when a new subscriber appears
Eager mode (lazy: false)
- Connection opens immediately on
createNotifier - Stays connected even with zero subscribers
- Reconnects after disconnect regardless of subscriber count
Reconnection
Automatic exponential backoff with jitter:
| Attempt | Base delay | Range (with ±25% jitter) | |---------|-----------|--------------------------| | 1 | 1s | 0.75s – 1.25s | | 2 | 2s | 1.5s – 2.5s | | 3 | 4s | 3s – 5s | | 4 | 8s | 6s – 10s | | 5+ | 30s (cap) | 22.5s – 37.5s |
Reconnection resets to attempt 1 on successful connect. Channel subscriptions are automatically re-established on reconnect.
Heartbeat
The client sends a ping every 30 seconds. The server replies with pong. This keeps the connection alive through proxies and load balancers that drop idle WebSockets.
SSR safety
All hooks are safe to use during server-side rendering:
useStatus()returns"idle"useLastEvent()returnsnulluseChannel()is a no-op (no HTTP calls)- No WebSocket is created when
typeof WebSocket === "undefined"
Architecture
┌──────────┐ WebSocket ┌──────────────┐ ┌──────────────┐
│ Client │ ◄─────────────► │ UserChannel │ │ Channel │
│ (React) │ events │ (per user DO) │ │(per chan. DO)│
└─────┬────┘ └──────▲───────┘ └──────▲───────┘
│ │ │
│ POST /channels/subscribe │ fan out to each │ get members
└─────────────────────────►┌───┴─────────────────────┴──┐
HTTP │ Worker │
│ (entrypoint) │
└─────────────────────────────┘
sendToUser / sendToChannelsendToUser flow: Worker → UserChannel DO → WebSocket(s)
sendToChannel flow: Worker → Channel DO (get members) → Worker → each UserChannel DO → WebSocket(s)
- One Durable Object per user — derived from
idFromName(userId) - One Durable Object per channel — derived from
idFromName(channelName), stores member list - Worker coordinates everything — the Channel DO is a membership store; the Worker reads members and fans out to UserChannel DOs
- Multi-tab/device fanout — the UserChannel DO holds all active sockets for a user and broadcasts to each
- Auth at the edge — unauthenticated requests are rejected before touching any DO
- Server-push only — clients subscribe, the server sends. The WebSocket is not bidirectional for application messages
- Single WebSocket per user — channel subscriptions are managed via HTTP; events for all channels arrive over the one connection
Examples
The repo includes working examples:
examples/basic-worker/— Hono server with authentication, WebSocket upgrade, channel subscribe/unsubscribe, and event sendingexamples/basic-react/— Vite + React app withuseEvent,useStatus,useLastEvent, anduseChannel
Run them locally:
pnpm install
pnpm turbo devThe worker runs on http://localhost:8787, the React app on http://localhost:5173.
Send a test event to a user:
curl -X POST http://localhost:8787/send/test-user-1Subscribe a user to a channel and send to the channel:
# Subscribe
curl -X POST http://localhost:8787/channels/subscribe?userId=test-user-1 \
-H "Content-Type: application/json" \
-d '{"channel": "general"}'
# Send to channel
curl -X POST http://localhost:8787/channels/general/send
# Check members
curl http://localhost:8787/channels/general/membersSharing types between server and client
Define your event map once and import it in both your worker and your React app:
// shared/events.ts
export type AppEvents = {
"order.updated": { orderId: string; status: string };
"chat.message": { from: string; text: string };
};// worker
import type { AppEvents } from "../shared/events";
const notifier = createServerNotifier<AppEvents>({ authenticate });// client
import type { AppEvents } from "../shared/events";
const notifier = createNotifier<AppEvents>("/ws", {
channelEndpoint: "/channels",
});This gives you compile-time safety across the entire pipeline — if you rename an event type or change its payload shape, TypeScript will catch mismatches on both sides.
In a monorepo (like this repo's examples/shared/events.ts), just import directly. For separate repos, publish the type file as a shared package or copy the type definition.
Exports
durable-notifier/server
| Export | Kind |
|--------|------|
| createServerNotifier | function |
| UserChannel | class |
| Channel | class |
| ServerNotifier | type |
| ServerNotifierOptions | type |
| AuthenticateFn | type |
| AuthorizeChannelFn | type |
| NotifierEnv | type |
| EventMap | type |
| WireEvent | type |
durable-notifier/client
| Export | Kind |
|--------|------|
| createNotifier | function |
| Notifier | type |
| NotifierOptions | type |
| EventHandler | type |
| ConnectionStatus | type |
| EventMap | type |
| WireEvent | type |
License
MIT
