Hooks

Using hooks, you can define a WebSocket server that works across runtimes with the same syntax.

Crossws provides a cross-platform API to define WebSocket servers. An implementation with these hooks works across runtimes without needing you to go into details of each of them. You only define the life-cycle hooks that you only need.

Using defineHooks() wrapper is optional and for type support and code auto completion.
import { defineHooks } from "crossws";

const hooks = defineHooks({
  upgrade(req) {
    console.log(`[ws] upgrading ${req.url}...`);
    return {
      // namespace: new URL(req.url).pathname
      headers: {},
    };
  },

  open(peer) {
    console.log(`[ws] open: ${peer}`);
  },

  message(peer, message) {
    console.log("[ws] message", peer, message);
    if (message.text().includes("ping")) {
      peer.send("pong");
    }
  },

  close(peer, details) {
    console.log("[ws] close", peer, details);
  },

  error(peer, error) {
    console.log("[ws] error", peer, error);
  },

  drain(peer) {
    // Send buffer drained after backpressure — safe to resume sending.
    // Pair with `peer.bufferedAmount`. Not all adapters emit this.
    console.log("[ws] drain", peer);
  },

  ping(peer, data) {
    // An application-level ping control frame arrived from the peer.
    // Not all adapters emit this.
    console.log("[ws] ping", peer, data);
  },

  pong(peer, data) {
    // An application-level pong control frame arrived from the peer,
    // typically in reply to `peer.ping()`. Not all adapters emit this.
    console.log("[ws] pong", peer, data);
  },
});

Context

You can attach data to the connection by returning a context object from the upgrade hook. This data is then available as peer.context in all other hooks for the lifetime of the connection.

Context can be volatile in some environments like 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
  },
});

Authentication

During the upgrade hook it is possible to authenticate the user before upgrading the connection. If the user is not authenticated, you can return (or throw) a Response to prevent the connection from being upgraded.

import { defineHooks } from "crossws";

const hooks = defineHooks({
  upgrade(req) {
    const authHeader = req.headers.get("Authorization");

    if (!authHeader || !authHeader.startsWith("Basic ")) {
      return new Response("Unauthorized", {
        status: 401,
        headers: {
          "WWW-Authenticate":
            'Basic realm="Websocket Authentication", charset="UTF-8"',
        },
      });
    }

    const base64Credentials = authHeader.split(" ")[1];
    const [username, password] = atob(base64Credentials).split(":");

    if (username !== "myUsername" || password !== "myPassword") {
      return new Response("Unauthorized", {
        status: 401,
        headers: {
          "WWW-Authenticate":
            'Basic realm="Websocket Authentication", charset="UTF-8"',
        },
      });
    }

    return {
      headers: {}, // Optionally return custom headers
    };
  },
});

Subprotocol negotiation

When a client opens a connection with subprotocols (new WebSocket(url, ["graphql-transport-ws"])), it sends them in the Sec-WebSocket-Protocol request header. Browsers reject the connection unless the server's handshake response accepts one of the offered subprotocols. By default crossws accepts none — a server never claims to speak a protocol you didn't opt into — so you have to select one.

Return protocol from the upgrade hook to accept a subprotocol for that connection. It should be one of the values the client offered.

import { defineHooks } from "crossws";

const hooks = defineHooks({
  upgrade(req) {
    const offered = req.headers.get("sec-websocket-protocol"); // "graphql-transport-ws, graphql-ws"
    if (offered?.split(",").some((p) => p.trim() === "graphql-transport-ws")) {
      return { protocol: "graphql-transport-ws" };
    }
  },
});

For a global default that applies to every connection, use the handleProtocols adapter option instead. It receives the set of offered subprotocols and returns the one to accept (or false for none); a protocol returned from the upgrade hook takes precedence over it.

crossws({
  handleProtocols: (protocols) =>
    protocols.has("graphql-transport-ws") ? "graphql-transport-ws" : false,
  hooks,
});
Support is consistent across the Node, Bun and Deno adapters. Bun additionally echoes the first offered subprotocol even when none is selected (oven-sh/bun#18243).