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

@vibedout/realtime

v0.1.0

Published

Realtime rooms for Cloudflare Workers: a RealtimeRoom Durable Object base class on the WebSocket Hibernation API (Zod message protocol, presence derived from the sockets' attachments, numbered events in SQLite with catch-up, rate limits, broadcast over RP

Readme

@vibedout/realtime

Realtime rooms for Cloudflare Workers: any page can be shared live by many people. A Durable Object room holds the sockets, the people, and a short numbered history; a Worker helper decides who may connect; a React client keeps a page in step with it. Workers + WebCrypto on the server, React 19 and TanStack Query on the client. partysocket is the only runtime dependency; Zod is a peer (the schemas you write are checked by the same copy the package uses). The room is our own class on the Durable Objects WebSocket Hibernation API and partysocket, not PartyServer. Its socket plumbing (quiet send and close, fan-out, the token bucket, the size-capped JSON reader, the keepalive auto-response) is bundled into dist/, so it adds no dependency.

| Entry point | For | Runs in | |---|---|---| | @vibedout/realtime / /protocol | the message schemas, defineRoom, roles, close codes | anywhere | | @vibedout/realtime/server | RealtimeRoom, the Durable Object base class | Workers | | @vibedout/realtime/connect | who may connect: session hook, access hook, 60-second room tickets | Workers | | @vibedout/realtime/hono | the connect helper as a ready-made Hono app | Workers | | @vibedout/realtime/client | one connection to one room, no UI framework | browser, Node | | @vibedout/realtime/react | <Realtime.Room> and the hooks | browser (React 19) |

Three kinds of traffic

| | Durable events | Ephemeral signals | Presence | |---|---|---|---| | For | things that changed (a task moved, a message sent) | cursors, typing | who is here | | Stored | yes, in the room's SQLite, with a sequence number | never | in the sockets' attachments, never in storage | | Replayed after a reconnect | yes, in order | no | the welcome carries the current list | | Sent to | everyone, including the sender (its clientId is the confirmation) | everyone except the sender | everyone | | Billing that matters | row writes (about $1 per million) | incoming messages at 20:1 | none |

Only durable events write to storage, so a board with twenty cursors at 20 Hz costs almost nothing.

The room (/server)

import { z } from "zod";
import { defineRoom } from "@vibedout/realtime/protocol";
import { type Participant, RealtimeRoom, RoomError } from "@vibedout/realtime/server";

export const board = defineRoom({
  events: { "task.move": z.object({ id: z.string(), to: z.string() }) },     // durable, validated, numbered
  signals: { cursor: z.object({ x: z.number(), y: z.number() }) },          // ephemeral
  minRole: { "task.move": "member", cursor: "viewer" },                      // default is "member" for everything
});

export class BoardRoom extends RealtimeRoom<Env, typeof board> {
  readonly definition = board;
  // Optional hooks: refuse or reshape an event before it is stored, react to people arriving and leaving.
  protected override onEvent(type: string, payload: unknown, who: Participant) {
    if (type === "task.move" && /* some rule */ false) throw new RoomError("locked", "that task is locked");
    return undefined; // or return a replacement payload
  }
}

wrangler.jsonc:

{
  "compatibility_date": "2026-09-26",
  "durable_objects": { "bindings": [{ "name": "BOARD", "class_name": "BoardRoom" }] },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["BoardRoom"] }]
}

What the room guarantees

| Behaviour | How | |---|---| | Hibernation, no timers | Hibernation API only (acceptWebSocket, attachments, tags). A ping is answered by the runtime (setWebSocketAutoResponse) without waking the room. The one alarm prunes the log and closes sockets whose session ended; it re-arms itself only while something older than the newest event remains or a socket still has an end to wait for | | Presence survives hibernation | Derived from the sockets' attachments, one entry per user however many tabs; join is announced for a user's first socket, leave for their last. A user can set status and meta (16 flat keys); it applies to all of their tabs | | Numbered events and catch-up | Each event is a row in rt_events with a seq. A client connecting with ?since=<last seq> receives the welcome, then every later event in order, in one synchronous step, so nothing can slip between the snapshot and the replay. If the gap cannot be replayed (pruned, more than maxReplay, or the client is ahead of the room) it gets resync and refetches over HTTP | | Per-connection rate limit | Three token buckets in memory: a coarse flood bucket checked before parsing, one for events (30 per 10 s by default), one for signals and presence updates (60 per s). Refused events get an error with their clientId; signals over the bucket are dropped silently, and a signal the role may not send (or the room does not know) is answered once per type, then dropped, without counting against the connection. 20 refused messages (bad frames, invalid payloads, events over the limit) within one 10-second window close the socket (1008); a slow trickle of mistakes never adds up. An oversized frame (maxMessageChars, 64 K characters by default) closes it (1009) | | Order | The events of one connection are handled one at a time, in the order they were sent, even when onEvent waits on a database; events from different connections are numbered in the order the room handles them | | Roles | viewer, member, admin, from the access hook. Each event or signal names the weakest role that may send it (default member, so a viewer can watch and set their presence but not write) | | Session end | The identity carries exp. The first message after it closes the socket with 4001, and the alarm closes a socket that only listens (it wakes the room once, at the earliest session end); the client fetches a fresh ticket and reconnects. No timer | | Removing someone | disconnectUser(userId) over RPC closes all of their sockets with 4003, which tells their client not to reconnect on its own. The access hook runs only at connect, so call this when you revoke access | | Limits on sockets | maxConnections per room (the next upgrade answers 503), maxConnectionsPerUser (the user's oldest is closed with 4009) | | Broadcast from Worker code | Durable Object RPC: publish(type, payload, { actor, clientId }) (durable, validated like a client's event), broadcast(type, payload) (ephemeral, to every socket), invalidate(queryKey), patch(queryKey, op, entity), disconnectUser(userId), getPresence(), getLastSeq(). A published event may not serialize longer than maxMessageChars (a replay of 500 of them stays bounded) |

// An ordinary HTTP mutation: write to D1, then tell the room.
app.post("/api/projects/:id/tasks", async (c) => {
  const task = await createTask(c);
  await c.env.BOARD.getByName(`board:${c.req.param("id")}`).publish("task.moved", task, { actor: c.get("userId") });
  return c.json(task);
});

Room names are <type>:<id> (board:proj_9, chat:org_12:general), the same names the connect helper derives from the URL.

Trust and cost notes

  • The room trusts one header, x-rt-identity, which only the Worker's connect helper sets. Every method on a Durable Object class is callable over RPC by whoever holds its namespace binding, protected ones included (TypeScript's protected does not exist at runtime: commit stores an event with no schema check, fanOut sends anything, limits reads the configuration), so never hand the room's namespace to code that serves untrusted callers; keep it in the Worker that has the connect routes and your own server code.
  • The room's storage uses one table, rt_events. A subclass that stores its own data (a chat's messages table, say) uses other names, and a subclass that uses its own alarm must call super.alarm().
  • The log keeps 1,000 events or 7 days (retention), whichever is smaller, and never the newest event, so numbering does not restart and a client's saved since stays meaningful. Pruning is soft: the table can pass the limit until the next alarm (every 6 hours by default).
  • Presence is as stale as the runtime's idea of the socket. A user is announced as gone when the runtime reports their last socket closed. A connection that dies without a close (a network cut) lingers until the runtime notices; the client's ping every 30 s and its own silence check make that rare, but there is no server-side sweep of quiet sockets.
  • ws.send failures are ignored (a closing socket is cleaned up by its close handler). webSocketClose completes the close handshake for compatibility dates before 2026-04-07.

Connecting and auth (/connect, /hono)

The upgrade is verified in the Worker before it is forwarded to the room. A refused request never creates the room.

import { realtimeHandlers, TICKET_PATH, UPGRADE_PATH } from "@vibedout/realtime/connect";

const realtime = realtimeHandlers<Env>({
  rooms: { board: (env) => env.BOARD },                 // room type -> Durable Object namespace
  authenticate: (request, env) => sessionFrom(request), // { userId, name, expiresAt } or null: your session, your auth library
  authorize: (identity, room, env) => roleFor(identity.userId, room), // "viewer" | "member" | "admin" | null
  ticket: { secret: (env) => env.REALTIME_TICKET_SECRET },             // optional: enables room tickets
  allowedOrigins: ["https://widget.example"],                           // optional: other origins that may use the cookie
});
app.get(UPGRADE_PATH, realtime.upgrade);   // GET /parties/:type/:room
app.post(TICKET_PATH, realtime.ticket);    // POST /realtime/ticket  { type, room } -> { ticket, expiresAt, role }

@vibedout/realtime/hono gives the same as one call, app.route("/", realtimeRoutes({ ... })). It returns a Hono of the version this package resolved, so if your app resolves another version TypeScript will object; realtimeHandlers has no such coupling (it is typed on a small structural context) and is the one to use in an app.

| Step | Result | |---|---| | Not a GET / no Upgrade: websocket | 405 / 426 | | Unknown room type, or an id that is not [A-Za-z0-9][A-Za-z0-9_.:-]{0,127} | 404 / 400 | | Cookie path: the browser's Origin is neither this Worker's own (scheme, host, port) nor allowed | 403 origin_not_allowed (blocks cross-site WebSocket hijacking) | | authenticate returns nothing, or a session that has ended | 401 unauthorized / session_expired | | authorize returns null | 403 forbidden | | Otherwise | the request is rebuilt (the cookie, Authorization, the ticket and every x-rt-* header the browser sent are dropped; the identity header is set from what the Worker proved) and forwarded |

A display name longer than 80 characters is cut, and a colour that is not a short string is replaced by a generated one; a userId or an end time that is not usable is a bug in the hook and answers 500 (and is logged), not a silent 401.

A route of your own that serves a room's data: admit

A page often loads a room's data over plain HTTP too (the pins a board shows before the live events, a room's history). That route must let in exactly the people the socket lets in, so it does not repeat the checks; it asks for the same admission:

app.get("/boards/:room/pins", async (c) => {
  const admitted = await realtime.admit(c, { type: "board", room: c.req.param("room") });
  if (!admitted.ok) return admitted.response;            // or answer in your own error shape from admitted.status / admitted.error
  const { identity, room, role } = admitted;             // who, which room (room.name is the Durable Object name), and as what
  return c.json(await c.env.BOARD.getByName(room.name).listPins());
});

The checks and their order are the upgrade's, with the ticket route's rule that who is asking comes before anything about rooms: Origin (403 origin_not_allowed), the session (401 unauthorized, 401 session_expired), the room type and id (404 unknown_room_type, 400 invalid_room_id), then authorize (403 forbidden). admitted.response is the same { "error": "<code>" } JSON the upgrade answers. A ?ticket= is for opening the socket and is not read here. Doing the steps by hand (authenticate then authorize) is the mistake this exists to prevent: it skips the origin check and the end of the session.

authenticate and authorize are your functions: this package knows nothing about Better Auth or any session store. With Better Auth, authenticate is (request, env) => getSession(request.headers) mapped to { userId, name, expiresAt }, and authorize is a membership query.

Two ways to prove who you are

  1. Same origin (the default). The browser sends the session cookie on the upgrade. Nothing else to do on the client.
  2. A room ticket for a widget on another origin, a native app, or any client that cannot send the cookie. The app's POST /realtime/ticket authenticates the user and checks access, then returns a ticket good for 60 seconds (5 minutes at most) and one room; the client puts it in the URL (?ticket=), which is why it is short-lived. The ticket proves who the user is and when their session ends; the role is decided again by authorize at connect, so a removed member is out at the next reconnect. A ticket can be used again within its minute (that is how a dropped connection reconnects); it is not single-use.

Tickets are v1.<payload>.<signature>, HMAC-SHA256 over the payload with the secret (at least 32 characters; pass an array to rotate: the first signs, all verify). The ticket endpoint authenticates the caller before it reads the request, so an anonymous caller learns nothing about which rooms exist, and it refuses bodies over 1 KB. mintTicket(secret, claims, { ttlSeconds, now }) and verifyTicket(secret, ticket, { room }, { now }) are exported for custom flows; verifyTicket checks the signature first and returns a reason (malformed, bad_signature, expired, not_yet_valid, wrong_room) instead of throwing. Cross-origin use of the ticket route needs Hono's own cors() in front of it; the cookie is never sent cross-origin.

Add the Workers Rate Limiting binding in front of UPGRADE_PATH for a per-user or per-IP cap on upgrades; the room's own limits start only after a socket is open.

The client (/client, /react)

import { useQuery } from "@tanstack/react-query";
import { Realtime, useBroadcast, usePresence, useRoomMutation, useSignal } from "@vibedout/realtime/react";

export function BoardPage({ projectId }: { projectId: string }) {
  return (
    <Realtime.Room type="board" id={projectId} ticketUrl="/realtime/ticket"> {/* omit ticketUrl for same-origin cookie auth */}
      <Avatars />
      <Tasks projectId={projectId} />
    </Realtime.Room>
  );
}

function Avatars() {
  const people = usePresence();                              // one entry per user; re-renders only when the list changes
  return <>{people.map((p) => <span key={p.userId} style={{ color: p.color }}>{p.name}</span>)}</>;
}

function Tasks({ projectId }: { projectId: string }) {
  const tasks = useQuery({ queryKey: ["tasks", projectId], queryFn: () => api.tasks(projectId) }); // a plain query
  const move = useRoomMutation<{ id: string; to: string }>("task.move", { queryKey: ["tasks", projectId] }); // refetches when anyone moves a task
  const cursor = useBroadcast<{ x: number; y: number }>("cursor");                                          // ~20 per second at most
  useSignal<{ x: number; y: number }>("cursor", (point, from) => showCursor(from, point));
  // ...
}

| Export | What it does | |---|---| | <Realtime.Room type id> | Connects after mount (server rendering reads idle), reconnects with catch-up, closes on unmount, switches rooms when type or id changes. Props: ticketUrl or getTicket (called on every connect and reconnect), host, protocol, prefix, query, patch, resyncQueryKeys, queryClient, advanced | | useRoom() | { status, connId, userId, role, lastSeq, presence, error, send, signal, setPresence, client }. Re-renders on every change; the narrower hooks avoid that | | usePresence() | Presence[], deduplicated by user | | useBroadcast(type, { intervalMs }) | A stable function that sends an ephemeral signal, throttled (leading edge, and the last value is always delivered) | | useSignal(type, handler), useRoomEvent(type, handler) | Receive signals / durable events of one type (handlers may change identity freely) | | useRoomMutation(type, { queryKey, mode, apply }) | Sends a durable event and keeps a query in step: mode: "refetch" (default) invalidates the query when the event arrives, from anyone; mode: "patch" with apply updates the cache at once for your own send (rolled back, then refetched, if the server refuses) and on arrival for everyone else's |

Every context hook throws useX must be used inside <Realtime.Room> outside a room, naming the hook.

How it stays correct (all in RealtimeClient, tested with a fake socket):

  • It remembers the newest event number and reconnects with ?since=; an event it has seen is dropped, a hole in the numbering makes it reconnect to be replayed, and the welcome does not skip it ahead over the replay that follows.
  • resync resets it to the room's number and refetches [type, id] (or resyncQueryKeys).
  • With tickets, a ticket is requested for each attempt. A 401 or 403 from the ticket endpoint (RealtimeAuthError) stops it with status unauthorized; any other failure is retried with backoff.
  • send resolves when the server echoes the event (matched by clientId) with its seq, and rejects with RealtimeMutationError (forbidden, invalid_payload, ..., not_connected, disconnected, timeout). Nothing is queued while offline; an app that wants an outbox builds it on that rejection.
  • A close with code 4009 (this tab was the oldest of too many from one user) ends in status replaced, and 4003 (disconnectUser) in unauthorized; neither reconnects on its own. Any other drop reconnects with backoff.
  • A signal the room said this role may not send (error with type) is not sent again on that connection, so a viewer's cursor costs one reply.
  • An event the client cannot read is skipped and refetched (resync) rather than mistaken for a hole; a second hole within two seconds of a hole-triggered reconnect is also a resync, so a broken stream cannot loop.
  • Server invalidate messages refetch the query. Server patch messages refetch too, unless <Realtime.Room patch> opts in, in which case a list in the cache is upserted or deleted by id (and anything that is not a list is refetched instead).
  • A ping every 30 s keeps idle sockets alive through proxies; silence for 2.5 intervals reconnects.

Without React, RealtimeClient from @vibedout/realtime/client is the same thing (connect(), close(), send, onEvent, getSnapshot...), and works in Node with WebSocket: require("ws").

Developing and testing

  • vite dev with @cloudflare/vite-plugin carries the WebSockets, the hibernation API and the auto-response with no extra setup. One dev-only difference: an upgrade the Worker refuses is answered by dropping the connection, not by relaying the 401/403 (wrangler dev relays it).
  • Tests use vitest projects (pnpm test): workers runs the room, the connect helper and the protocol in workerd with @cloudflare/vitest-pool-workers (including evictDurableObject for hibernation), and dom runs the client and hooks in jsdom with a fake WebSocket. The test Worker config pins an older compatibility_date than production (the pool's workerd is older than the Vite plugin's). Test refusals of RPC methods with runInDurableObject: an error thrown across the RPC boundary fails the pool's run.
  • pnpm typecheck runs two configs (Workers types for the server half, DOM for the client half; they cannot share globals). pnpm build writes dist/ with tsdown.

Not included

Shared text editing (Yjs) and a chat layer.

Changelog

  • 0.1.0: the room, the connect helper (with admit, the socket's admission for an HTTP route of your own that serves a room's data) and the React client.