npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

durable-notifier

v0.3.0

Published

Authenticated per-user realtime events on Cloudflare Workers with React hooks

Downloads

417

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-notifier

Quick 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    │
└──────────────┘                   └──────────────────┘
  1. Client subscribes to a channel via HTTP POST (auth runs server-side)
  2. The Worker tells the Channel DO to store the user ID in its member list
  3. When sendToChannel is called, the Worker reads the member list from the Channel DO, then sends the event to each member's UserChannel DO
  4. Each UserChannel pushes the event to all of that user's WebSocket connections
  5. The event includes a channel field 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 established
  • 401 — Authentication failed
  • 426 — 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 } — Subscribed
  • 400 — Missing or invalid channel name
  • 401 — Authentication failed
  • 403 — Channel authorization denied (or authorizeChannel not 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 open

notifier.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 useEvent triggers a connection — useStatus and useLastEvent are passive
  • Events from channels include event.channel with 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"> | null

Returns 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 useEvent subscriber 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() returns null
  • useChannel() 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 / sendToChannel

sendToUser 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 sending
  • examples/basic-react/ — Vite + React app with useEvent, useStatus, useLastEvent, and useChannel

Run them locally:

pnpm install
pnpm turbo dev

The 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-1

Subscribe 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/members

Sharing 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