crossws
repository·main·Indexed 20 days ago
https://github.com/h3js/crosswsA cross-platform WebSocket toolkit providing a unified, typed API for implementing WebSocket servers across Bun, Deno, Cloudflare Workers, and Node.js. It features a hooks-based API for managing connection lifecycles (upgrade, open, message, close, error), a Peer object for client interaction, and built-in Pub/Sub capabilities.
What's inside crossws
- crossws is an elegant, typed, and simple toolkit designed to implement cross-platform WebSocket servers. It provides a unified API that works across different runtimes and frameworks, allowing you to write WebSocket logic once and deploy it anywhere.
Use Pub/Sub to manage topics and broadcasts
maincrossws provides a native pub-sub API that allows peers to subscribe to named topics and broadcast messages to those topics.
Key Behaviors:
- Subscription: A peer joins a topic using
peer.subscribe(<name>). - Publishing: A peer sends a message to a topic using
peer.publish(<name>, <message>). - Self-Exclusion: The peer that performs the
publish()operation is automatically excluded from the broadcast and will not receive its own message. This behavior is consistent across all adapters. - Unsubscribing: Use
peer.unsubscribe(<name>)to stop receiving messages for a topic.
To scale pub-sub across multiple server instances (a cluster), you must use a sync adapter to relay messages over a shared backplane.
import { defineHooks } from "crossws"; const hooks = defineHooks({ open(peer) { // Join new client to the "chat" topic peer.subscribe("chat"); // Notify every other connected client peer.publish("chat", `[system] ${peer} joined!`); }, message(peer, message) { // The server re-broadcasts incoming messages to everyone peer.publish("chat", `[${peer}] ${message}`); }, close(peer) { peer.publish("chat", `[system] ${peer} has left the chat!`); peer.unsubscribe("chat"); }, });- Subscription: A peer joins a topic using
Use built-in adapters to integrate with different runtimes
maincrossws provides built-in adapters that allow you to integrate WebSocket hooks with various runtimes and platforms (such as Node.js, Bun, Deno, uWebSockets, or Bunny). Using adapters ensures that your WebSocket logic remains consistent even if you switch the underlying runtime.Understand cross-instance delivery semantics
mainCross-instance relaying is best-effort and fire-and-forget. When designing your topics, keep the following in mind:
- At-most-once and unordered: Messages may arrive at most once and ordering across different instances is not guaranteed. Use sequence numbers in your payloads if ordering is critical.
- No replay or buffering: If an instance is disconnected or hasn't finished subscribing during startup, it will miss messages published during that window.
- Local delivery is independent:
peer.publish()always reaches local subscribers synchronously, even if the backplane is down. - Failures are isolated: Backplane errors will not throw into your
publish()call. To monitor the health of your backplane, use theonErroroption in your adapter configuration.
const ws = nodeAdapter({ hooks, sync: redis({ client: new Redis(), channel: "my-app" }), onError(error, { stage }) { // stage: "subscribe" (initial connect) | "publish" (relay out) | "delivery" (fan-in) metrics.increment(`crossws.sync.error.${stage}`); console.error("[crossws] sync error", stage, error); }, });Access client information via Peer properties
mainThe
Peerinstance exposes several properties to inspect the connection:peer.id: A unique UUID v4 identifier for the peer.peer.request?: Access to the upgrade request (headers, cookies, etc.). Note: This is emulated in Node.js and may be unavailable in some runtimes.peer.remoteAddress?: The client's IP address (availability depends on the adapter).peer.websocket: Direct access to the underlyingWebSocketinstance (wrapped in a proxy for stability).peer.context: An object containing arbitrary request information. You can extend thePeerContexttype to add custom properties.peer.topics: A list of all topics the peer is currently subscribed to.peer.namespace: The pubsub namespace of the peer.peer.bufferedAmount: The number of bytes queued for transmission but not yet flushed. Use this to monitor backpressure.
// Extending PeerContext with custom data declare module "crossws" { interface PeerContext { customData?: string[]; } }Understand the Peer object
mainThePeerobject is the primary interface for interacting with connected clients incrossws. When a client connects, aPeerinstance is created, providing access to client information (like ID, IP address, and request headers) and methods to communicate with them (sending messages, subscribing to topics, or closing the connection).Attach data to a connection using context
mainYou can persist data across the lifetime of a connection by returning a
contextobject from theupgradehook. This data is then accessible viapeer.contextin all subsequent lifecycle hooks (likeopen,message, orclose).Warning: Context can be volatile in certain environments, such as
cloudflare-durable.import { defineHooks } from "crossws"; const hooks = defineHooks({ upgrade(req) { return { context: { data: "myData" }, }; }, open(peer) { console.log(peer.context.data); // myData }, message(peer, message) { console.log(peer.context.data); // myData }, close(peer, details) { console.log(peer.context.data); // myData }, });How to sync pub/sub across Cloudflare instances
mainOn Cloudflare, the need for a sync backplane depends on how you distribute connections across Durable Objects:
- Single Durable Object (Default): If all connections land on the same Durable Object instance (e.g., using the default
crosswsinstance name),peer.publish()is cluster-global by default. No sync backplane is required. - Sharded Durable Objects: If you distribute connections across multiple instances (e.g., one Durable Object per room via
resolveDurableStub), you must provide asyncbackplane to bridge them.
Important Caveats for Cloudflare Sync:
- Inbound Delivery: Delivery into a Durable Object is best-effort. If a Durable Object is hibernated or evicted, it may lose the in-memory peer map, causing it to miss some sockets even if they survive via
ctx.getWebSockets(). - Outbound Delivery:
peer.publish()calls originating from a Durable Object and reaching the backplane are reliable. - Drivers: Do not use built-in
redisorpgsqldrivers, as they require persistent connections not supported byworkerd. Instead, implement a customSyncAdapterusing Cloudflare-native transports like Queues, a coordinator Durable Object, or fetch-based pub/sub.
import crossws from "crossws/adapters/cloudflare"; import type { SyncAdapter } from "crossws"; const ws = crossws({ hooks, sync: myBackplane, // a custom SyncAdapter });- Single Durable Object (Default): If all connections land on the same Durable Object instance (e.g., using the default
How Sync Backplanes work in crossws
mainBy default, crossws pub/sub is in-memory and local to a single instance. If you run multiple instances (multiple processes, replicas, or regions), a
peer.publish()call only reaches subscribers on that specific instance.A sync adapter (or backplane) bridges this gap by relaying messages between instances over a shared medium (like Redis or Postgres). This allows
subscribeandpublishto work across your entire cluster without changing your existing hooks.Key Lifecycle Note: When you call
await ws.close(), crossws closes all connected peers and tears down the backplane automatically. However, any external clients you passed in (like a Redis or Postgres client) are not closed by crossws; you are responsible for managing their lifecycle.import nodeAdapter from "crossws/adapters/node"; import { redis } from "crossws/sync"; import Redis from "ioredis"; const ws = nodeAdapter({ hooks, sync: redis({ client: new Redis(), channel: "my-app" }), });Cross-runtime WebSocket client behavior
mainThe
crossws/websocketclient uses export conditions to resolve the correct implementation for your runtime, ensuring efficient tree-shaking.Runtime Implementation Details Bun Uses global WebSocket. Relaysoptionsinto Bun's second-argument form.Node.js Uses global WebSocket(undici) forws:/wss:. Usesws(bundled) ifoptionsare passed or if the target isws+unix:. Also used on Node < 22.Deno Uses global WebSocket. Relaysoptionsinto Deno's second-argument form. UsesDeno.createHttpClientforws+unix:(requires--unstable-net).Browser / Workers / edge Uses the standard WHATWG WebSocket. Note: Customheadersinoptionsare silently ignored in these environments.Supported Runtimes and Adapters for crossws
maincrossws integrates seamlessly with several runtimes and web frameworks through specific adapters.
Supported runtimes include:
- Bun
- Cloudflare Workers
- Deno
- Node.js (supports
wsprebundled, or the fasteruWebSocketsadapter)
It is designed to be extremely lightweight, tree-shakable, and high-performance using a hooks API that avoids per-connection callback creation.
Integrate the SSE adapter into a Web Server handler
mainTo handle the
crosswsupgrade in a standard Web-standard fetch handler, check if the request contains thetext/event-streamaccept header or thex-crossws-idheader. If it does, delegate the request tows.fetch(request).async fetch(request) { // Handle crossws upgrade if ( request.headers.get("accept") === "text/event-stream" || request.headers.has("x-crossws-id") ) { return ws.fetch(request); } // Your normal application logic return new Response("default page") }