crossws

repository·main·Indexed 20 days ago

https://github.com/h3js/crossws

A 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.

Tokens
31K
Snippets
95
Records
116
Agent score
71%

What's inside crossws

  1. What is crossws?

    main
    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.
  2. Use Pub/Sub to manage topics and broadcasts

    main

    crossws 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");
      },
    });
  3. Use built-in adapters to integrate with different runtimes

    main
    crossws 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.
  4. Understand cross-instance delivery semantics

    main

    Cross-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 the onError option 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);
      },
    });
  5. Access client information via Peer properties

    main

    The Peer instance 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 underlying WebSocket instance (wrapped in a proxy for stability).
    • peer.context: An object containing arbitrary request information. You can extend the PeerContext type 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[];
      }
    }
  6. Understand the Peer object

    main
    The Peer object is the primary interface for interacting with connected clients in crossws. When a client connects, a Peer instance 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).
  7. Attach data to a connection using context

    main

    You can persist data across the lifetime of a connection by returning a context object from the upgrade hook. This data is then accessible via peer.context in all subsequent lifecycle hooks (like open, message, or close).

    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
      },
    });
  8. How to sync pub/sub across Cloudflare instances

    main

    On Cloudflare, the need for a sync backplane depends on how you distribute connections across Durable Objects:

    1. Single Durable Object (Default): If all connections land on the same Durable Object instance (e.g., using the default crossws instance name), peer.publish() is cluster-global by default. No sync backplane is required.
    2. Sharded Durable Objects: If you distribute connections across multiple instances (e.g., one Durable Object per room via resolveDurableStub), you must provide a sync backplane 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 redis or pgsql drivers, as they require persistent connections not supported by workerd. Instead, implement a custom SyncAdapter using 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
    });
  9. How Sync Backplanes work in crossws

    main

    By 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 subscribe and publish to 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" }),
    });
  10. Cross-runtime WebSocket client behavior

    main

    The crossws/websocket client uses export conditions to resolve the correct implementation for your runtime, ensuring efficient tree-shaking.

    RuntimeImplementation Details
    BunUses global WebSocket. Relays options into Bun's second-argument form.
    Node.jsUses global WebSocket (undici) for ws:/wss:. Uses ws (bundled) if options are passed or if the target is ws+unix:. Also used on Node < 22.
    DenoUses global WebSocket. Relays options into Deno's second-argument form. Uses Deno.createHttpClient for ws+unix: (requires --unstable-net).
    Browser / Workers / edgeUses the standard WHATWG WebSocket. Note: Custom headers in options are silently ignored in these environments.
  11. Supported Runtimes and Adapters for crossws

    main

    crossws integrates seamlessly with several runtimes and web frameworks through specific adapters.

    Supported runtimes include:

    • Bun
    • Cloudflare Workers
    • Deno
    • Node.js (supports ws prebundled, or the faster uWebSockets adapter)

    It is designed to be extremely lightweight, tree-shakable, and high-performance using a hooks API that avoids per-connection callback creation.

  12. Integrate the SSE adapter into a Web Server handler

    main

    To handle the crossws upgrade in a standard Web-standard fetch handler, check if the request contains the text/event-stream accept header or the x-crossws-id header. If it does, delegate the request to ws.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")
    }