Package Exports
- durable-notifier/client
- durable-notifier/server
Readme
durable-notifier
Authenticated per-user realtime events on Cloudflare Workers. 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
});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);
},
});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.UserChannel
The Durable Object class. Re-export it from your worker entry point so wrangler can find it:
export { UserChannel } from "durable-notifier/server";
// or
export const UserChannel = notifier.UserChannel;NotifierEnv
Type for the required Durable Object binding. Merge it into your worker's Env:
import type { NotifierEnv } from "durable-notifier/server";
type Env = {
Bindings: NotifierEnv & {
// your other bindings
};
};The binding name must be USER_CHANNEL.
Client API
createNotifier<M>(url, options?)
import { createNotifier } from "durable-notifier/client";
const notifier = createNotifier<AppEvents>("/ws", {
lazy: true, // default — connect on first subscriber
});| Option | Type | Default | Description |
|---|---|---|---|
lazy |
boolean |
true |
When true, connects only when the first useEvent subscriber mounts. When false, connects immediately. |
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
console.log(data.from, data.text);
});- Multiple components can subscribe to the same event type
- Handler always uses the latest callback (no stale closures)
- Only
useEventtriggers a connection —useStatusanduseLastEventare passive
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.close()
Permanently shut down the connection. Sets status to "closed", clears all listeners and cached events, 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)
}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.
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()returnsnull- No WebSocket is created when
typeof WebSocket === "undefined"
Architecture
┌──────────┐ upgrade ┌──────────────┐
│ Client │ ──────────────────── │ Worker │
│ (React) │ WebSocket │ (entrypoint) │
└──────────┘ ▲ └──────┬───────┘
│ │ idFromName(userId)
│ ▼
│ ┌──────────────┐
└───────────── │ UserChannel │
fanout to │ (Durable Obj) │
all sockets └──────────────┘
▲
┌──────────┐ sendToUser(env, │
│ Any route│ ─── userId, event) ─────────┘
│ or queue │ (internal fetch)
└──────────┘- One Durable Object per user — derived from
idFromName(userId) - Multi-tab/device fanout — the DO holds all active sockets for a user and broadcasts to each
- Auth at the edge — unauthenticated requests are rejected before touching the DO
- Server-push only — clients subscribe, the server sends. The WebSocket is not bidirectional for application messages.
Examples
The repo includes working examples:
examples/basic-worker/— Hono server with authentication, WebSocket upgrade, and event sendingexamples/basic-react/— Vite + React app withuseEvent,useStatus, anduseLastEvent
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:
curl -X POST http://localhost:8787/send/test-user-1Exports
durable-notifier/server
| Export | Kind |
|---|---|
createServerNotifier |
function |
UserChannel |
class |
ServerNotifier |
type |
ServerNotifierOptions |
type |
AuthenticateFn |
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