# crossws > elegant, typed, and simple toolkit to implement cross-platform WebSocket servers. --- # Getting Started > crossws provides a cross-platform toolkit to define well-typed WebSocket apps that can then be integrated into various WebSocket servers using built-in adapters. Writing a realtime WebSocket server that can work in different runtimes is challenging because there is no single standard for WebSocket servers. You often need to go into many details of different API implementations and it also makes switching from one runtime costly. crossws is a solution to this! ## Quick Start > [!TIP] > You can try crossws with [online playground](https://stackblitz.com/github/h3js/crossws/tree/main/playground). Install `crossws` and `srvx` in your project: :pm-install{name="crossws srvx"} A simple WebSocket server looks like this: ```js [server.mjs] // Works with Bun, Deno and Node.js (also Cloudflare or SSE as fallback) import { serve } from "crossws/server"; serve({ websocket: { open(peer) { console.log("[ws] open", peer); peer.send({ user: "server", message: `Welcome ${peer}!` }); }, message(peer, message) { console.log("[ws] message", message); if (message.text().includes("ping")) { peer.send({ user: "server", message: "pong" }); } else { peer.send({ user: peer.toString(), message: message.toString() }); } }, close(peer, event) { console.log("[ws] close", peer, event); }, error(peer, error) { console.log("[ws] error", peer, error); }, }, fetch: () => fetch( "https://raw.githubusercontent.com/h3js/crossws/refs/heads/main/playground/public/index.html", ).then((res) => new Response(res.body, { headers: { "Content-Type": "text/html" } })), }); ``` Then, run the server using your favorite runtime: ::code-group ```bash [node] node server.mjs ``` ```bash [deno] deno run --allow-env --allow-net server.mjs ``` ```bash [bun] bun run server.mjs ``` :: ::alert When using `crossws/server`, export conditions automatically resolve the right runtime adapter and integrate with [πŸ’₯ srvx](https://srvx.h3.dev). You can alternatively, manually integrate crossws with [Adapters](/adapters). :: ::read-more{to="/guide/hooks" title="Hooks"} See [Hooks](/guide/hooks) for more usage details. :: ::read-more{to="/adapters" title="Adapters"} Hooks API is exactly same on all runtimes. See [Adapters](/adapters) for integration details. :: --- # 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. > [!TIP] > Using `defineHooks()` wrapper is optional and for type support and code auto completion. ```ts 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. > [!NOTE] > Context can be volatile in some environments like `cloudflare-durable`. ```ts 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`](https://developer.mozilla.org/en-US/docs/Web/API/Response) to prevent the connection from being upgraded. ```ts 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. ```ts 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`](/adapters#options) 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. ```ts crossws({ handleProtocols: (protocols) => protocols.has("graphql-transport-ws") ? "graphql-transport-ws" : false, hooks, }); ``` > [!NOTE] > 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](https://github.com/oven-sh/bun/issues/18243)). --- # Peer > Peer object allows easily interacting with connected clients. When a new client connects to the server, crossws creates a peer instance that allows getting information from clients and sending messages to them. ## Instance properties ### `peer.id` Unique random identifier ([uuid v4](https://developer.mozilla.org/en-US/docs/Glossary/UUID)) for the peer. ### `peer.request?` Access to the upgrade request info. You can use it to do authentication and access users headers and cookies. > [!NOTE] > This property is compatible with web [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) interface, However interface is emulated for Node.js and sometimes unavailable. Refer to the [compatibility table](#compatibility) for more info. ### `peer.remoteAddress?` The IP address of the client. > [!NOTE] > Not all adapters provide this. Refer to the [compatibility table](#compatibility) for more info. ### `peer.websocket` Direct access to the [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) instance. > [!NOTE] > WebSocket properties vary across runtimes. When accessing `peer.websocket`, a lightweight proxy increases stability. Refer to the [compatibility table](#compatibility) for more info. ### `peer.context` The context is an object that contains arbitrary information about the request. You can augment the `PeerContext` interface types to add your properties. ```ts declare module "crossws" { interface PeerContext { customData?: string[]; } } ``` > [!NOTE] > context data can be volatile in some runtimes. ### `peer.topics` All topics, this peer has been subscribed to. ### `peer.namespace` Peer's pubsub namespace. ### `peer.bufferedAmount` Number of bytes queued for transmission but not yet flushed to the client. Use this to apply **backpressure**: pause sending while it grows past a high watermark and resume once it drops. This prevents a fast producer (for example a streaming async generator) from filling the adapter's send buffer faster than the client can drain it. The easiest way to wait is [`peer.waitForDrain()`](#peerwaitfordrain). > [!NOTE] > Not all adapters expose a buffer signal; those return `0`. Refer to the [compatibility table](#compatibility) for more info. ## Instance methods ### `peer.send(message, { compress? })` Send a message to the connected client. ### `peer.waitForDrain({ threshold?, pollInterval?, signal? })` Returns a promise that resolves once [`peer.bufferedAmount`](#peerbufferedamount) drops to or below `threshold` bytes (default `0`), letting you `await` backpressure relief in a send loop: ```ts // Throttle a fast producer to the client's pace for (const chunk of stream) { peer.send(chunk); if (peer.bufferedAmount > 1024 * 1024 /* 1MB */) { await peer.waitForDrain({ threshold: 256 * 1024 /* 256KB */ }); } } ``` It resolves immediately when there is no backpressure (or on adapters that do not report `bufferedAmount`). Otherwise it polls every `pollInterval` ms (default `100`) until the buffer drains, and also resolves early if the connection is no longer open so a send loop never hangs on a dropped client. Pass a `signal` (e.g. `AbortSignal.timeout(ms)`) to abort the wait β€” the promise then rejects with the signal's reason. > [!TIP] > For event-driven backpressure (instead of `await`ing in a loop), use the [`drain` hook](/guide/hooks) together with `peer.bufferedAmount`. ### `peer.subscribe(topic)` Join a broadcast topic. :read-more{to="/guide/pubsub"} ### `peer.unsubscribe(topic)` Leave a broadcast topic. :read-more{to="/guide/pubsub"} ### `peer.publish(topic, message)` Broadcast a message to the topic. The peer that the publish is called on itself is always excluded from the broadcast. :read-more{to="/guide/pubsub"} ### `peer.close(code?, number?)` Gracefully closes the connection. Here is a list of close codes: - `1000` means "normal closure" (default) - `1009` means a message was too big and was rejected - `1011` means the server encountered an error - `1012` means the server is restarting - `1013` means the server is too busy or the client is rate-limited - `4000` through `4999` are reserved for applications (you can use it!) To close the connection abruptly, use `peer.terminate()`. ### `peer.terminate()` Abruptly close the connection. To gracefully close the connection, use `peer.close()`. ### `peer.ping(data?)` Send an application-level WebSocket ping control frame to the client. Pair with the [`pong` hook](/guide/hooks) β€” embedding a correlatable payload (e.g. a timestamp) in `data` β€” to measure round-trip latency: ```ts import { defineHooks } from "crossws"; const hooks = defineHooks({ message(peer, message) { if (message.text() === "measure-latency") { // Embed the send time in the ping payload; the client echoes it back // in the pong so it can be told apart from keepalive pongs. peer.ping(`rtt:${Date.now()}`); } }, pong(peer, data) { const payload = new TextDecoder().decode(data); if (!payload.startsWith("rtt:")) return; // keepalive or unrelated pong const rtt = Date.now() - Number(payload.slice(4)); console.log(`[ws] round-trip latency: ${rtt}ms`); }, }); ``` Use the [`ping` hook](/guide/hooks) to observe pings the client sends unprompted (e.g. a graphql-ws style client heartbeat). > [!NOTE] > The `pong` hook also fires for the pongs the client sends in reply to the adapter's own internal keepalive pings (see [`idleTimeout`](/adapters#idletimeout)), not only your `peer.ping()` calls. To measure RTT reliably, embed a correlatable payload in `peer.ping(data)` and ignore pongs that don't carry it (as above) rather than relying on a shared timestamp that any pong would consume. > [!NOTE] > Not all adapters can send a ping frame, or surface inbound ping/pong frames to the `ping`/`pong` hooks. Refer to the [compatibility table](#compatibility). ## Compatibility | | [Bun][bun] | [Cloudflare][cfw] | [Cloudflare (durable)][cfd] | [Deno][deno] | [Node (ws)][nodews] | [Node (ΞΌWebSockets)][nodeuws] | [SSE][sse] | | --------------------------- | ---------- | ----------------- | --------------------------- | ------------ | ------------------- | ----------------------------- | ---------- | | `send()` | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | | `publish()` / `subscribe()` | βœ“ | ⨉ | βœ“ [^1] | βœ“ [^1] | βœ“ [^1] | βœ“ | βœ“ [^1] | | `close()` | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | | `terminate()` | βœ“ | βœ“ [^2] | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ [^2] | | `bufferedAmount` | βœ“ | ⨉ [^8] | ⨉ [^8] | βœ“ | βœ“ | βœ“ | ⨉ [^8] | | `drain` hook | βœ“ | ⨉ [^9] | ⨉ [^9] | ⨉ [^9] | βœ“ | βœ“ | ⨉ [^9] | | `ping()` | βœ“ | ⨉ [^10] | ⨉ [^10] | ⨉ [^10] | βœ“ | βœ“ | ⨉ [^10] | | `ping` / `pong` hooks | βœ“ | ⨉ [^10] | ⨉ [^10] | ⨉ [^10] | βœ“ | βœ“ | ⨉ [^10] | | `request` | βœ“ | βœ“ | βœ“ [^30] | βœ“ | βœ“ [^31] | βœ“ [^31] | βœ“ | | `remoteAddress` | βœ“ | ⨉ | ⨉ | βœ“ | βœ“ | βœ“ | ⨉ | | `websocket.url` | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | | `websocket.extensions` | βœ“ [^4] | ⨉ | ⨉ | βœ“ [^4] | βœ“ [^4] | βœ“ [^4] | ⨉ | | `websocket.protocol` | βœ“ [^5] | βœ“ [^5] | βœ“ [^5] | [^5] βœ“ | βœ“ [^5] | βœ“ [^5] | ⨉ | | `websocket.readyState` | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ [^6] | βœ“ [^6] | | `websocket.binaryType` | βœ“ [^7] | ⨉ | ⨉ | βœ“ | βœ“ [^7] | βœ“ | ⨉ | | `websocket.bufferedAmount` | ⨉ | ⨉ | ⨉ | βœ“ | βœ“ | βœ“ | ⨉ | [bun]: /adapters/bun [cfw]: /adapters/cloudflare [cfd]: /adapters/cloudflare#durable-objects [deno]: /adapters/deno [nodews]: /adapters/node [nodeuws]: /adapters/node#uwebsockets [sse]: /adapters/sse [^1]: pubsub is not natively handled by runtime. peers are internally tracked. [^2]: `close()` will be used for compatibility. [^30]: After durable object's hibernation, only `request.url` (and `peer.id`) remain available due to 2048 byte in-memory state limit. [^31]: using a proxy for [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) compatible interface (`url`, `headers` only) wrapping Node.js requests. [^4]: [`websocket.extensions`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/extensions) is polyfilled using [`sec-websocket-extensions`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism#websocket-specific_headers) request header. [^5]: [`websocket.protocol`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/protocol) is polyfilled using [`sec-websocket-protocol`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Protocol_upgrade_mechanism#websocket-specific_headers) request header. [^6]: [`websocket.readyState`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState) is polyfilled by tracking open/close events. [^7]: Some runtimes have non standard values including `"nodebuffer"` and `"uint8array"`. crossws auto converts them for [`message.data`](/guide/message#messagedata). [^8]: The runtime exposes no send-buffer signal, so `peer.bufferedAmount` reports `0`. (Cloudflare buffers and applies backpressure internally; SSE has no equivalent.) [^9]: The runtime emits no drain signal. Poll [`peer.bufferedAmount`](#peerbufferedamount) instead where it is available. [^10]: The runtime does not expose a ping/pong control-frame API to user code (pings/pongs are still handled transparently at the protocol level for liveness). --- # Message On `message` [hook](/guide/hooks), you receive a message object containing data from the client. > [!NOTE] > Message object is API-compatible with standard Websocket [`MessageEvent`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event) with convenient superset of utils. ## Instance properties ### `message.id` Unique random identifier ([uuid v4](https://developer.mozilla.org/en-US/docs/Glossary/UUID)) for the message. ### `message.event` Access to the original [message event](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/message_event) if available. ### `message.peer` Access to the [peer instance](/guide/peer) that emitted the message. ### `message.rawData` Raw message data (can be of any type). ### `message.data` Message data (value varies based on [`peer.binaryType`](/guide/peer#peerbinarytype)). ## Instance methods ### `message.text()` Get stringified text version of the message. If raw data is in any other format, it will be automatically converted or decoded. ### `message.json()` Get parsed version of the message text with [`JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse). > [!TIP] > You can optionally use [`unjs/destr`](https://github.com/unjs/destr) to safely parse the message object. > > It does not throw an error if the input is not valid JSON but falls back to text and also removes any fields that could potentially cause prototype pollution vulnerabilities. > > ```ts > const data = destr(await message.text()); > ``` ### `message.uint8Array()` Get data as [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) value. If raw data is in any other format or string, it will be automatically converted or encoded. ### `message.arrayBuffer()` Get data as [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) or [`SharedArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer) value. If raw data is in any other format or string, it will be automatically converted or encoded. ### `message.blob()` Get data as [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) value. If raw data is in any other format or string, it will be automatically converted or encoded. ## Adapter support | | [Bun][bun] | [Cloudflare][cfw] | [Cloudflare (durable)][cfd] | [Deno][deno] | [Node (ws)][nodews] | [Node (ΞΌWebSockets)][nodeuws] | [SSE][sse] | | ------- | ---------- | ----------------- | --------------------------- | ------------ | ------------------- | ----------------------------- | ---------- | | `event` | ⨉ | βœ“ | ⨉ | βœ“ | ⨉ | ⨉ | ⨉ | [bun]: /adapters/bun [cfw]: /adapters/cloudflare [cfd]: /adapters/cloudflare#durable-objects [deno]: /adapters/deno [nodews]: /adapters/node [nodeuws]: /adapters/node#uwebsockets [sse]: /adapters/sse --- # Pub / Sub crossws supports native pub-sub API integration. A [peer](/guide/peer) can be subscribed to a set of named topics using `peer.subscribe()`. Messages can be published to a topic using `peer.publish(, )`. When publishing to a topic, the peer that does the publish is excluded from the broadcast so it won't receive its own message. > [!NOTE] > Self-exclusion is always in effect, on every adapter: the publishing peer never receives its own message back. Adapters implement this in one of two equivalent ways β€” some (Node, Deno, SSE) explicitly filter the publisher out before sending, while others (Bun, ΞΌWebSockets) delegate straight to the runtime's native `publish()`, which already excludes the sender by its own documented pub/sub semantics. The observable behavior is identical either way. ```js import { defineHooks } from "crossws"; const hooks = defineHooks({ upgrade(req) { return { // namespace: new URL(req.url).pathname }; }, open(peer) { // Send welcome to the new client peer.send("Welcome to the server!"); // 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"); }, }); ``` By default pub/sub is in-memory and local to one instance. To span a cluster, relay messages over a shared backplane with a [sync adapter](/guide/sync). :read-more{to="/guide/sync"} ## Publishing from outside a peer `peer.publish()` is convenient inside a hook, but sometimes you need to broadcast from somewhere that isn't tied to any single connection: an HTTP route, a cron job, or a webhook handler. The object returned by an adapter factory (e.g. `nodeAdapter(...)`, or the instance backing `crossws/adapters/cloudflare`) exposes this same capability directly, alongside a read-only `peers` map of every currently connected peer: ```js import nodeAdapter from "crossws/adapters/node"; const ws = nodeAdapter({ hooks: { message() {} } }); // Broadcast to every peer subscribed to "chat", in every namespace ws.publish("chat", "Server is restarting for maintenance"); // Inspect connected peers (Map>) for (const [namespace, peers] of ws.peers) { console.log(namespace, peers.size); } ``` ### `publish(topic, message, { compress?, namespace? })` - **`namespace`**: scopes delivery to peers in that namespace only. Omit it for a **global publish** that reaches subscribers across every namespace. - **Timing**: delivery to local subscribers is fully **synchronous** β€” `publish()` sends to every matching local peer before it returns, and it does not return a promise. The only asynchronous, best-effort part is relaying to *other* instances over an optional [sync backplane](/guide/sync); that relay is fire-and-forget and never throws out of `publish()`, so a flaky backplane can't block or crash the caller. :read-more{to="/guide/sync"} --- # Sync Backplane > [!IMPORTANT] > Experimental! The `sync` option and `crossws/sync` API may change between 0.x versions. crossws [pub/sub](/guide/pubsub) is in-memory and **local to one instance**. When you run more than one instance β€” multiple regions, processes, or replicas behind a load balancer β€” a `peer.publish("chat", ...)` only reaches the peers connected to _that_ instance. A subscriber connected elsewhere never sees it. A **sync adapter** bridges that gap: it relays published messages between instances over a shared backplane (e.g. Redis pub/sub) and fans inbound messages out to local subscribers. You don't need to change your hooks β€” `subscribe` and `publish` keep working, now across the cluster. ```js 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" }), }); ``` Each driver takes a single options object. The `channel` name is **required** β€” it scopes the cluster, so unrelated servers don't silently bridge into each other. crossws subscribes to the backplane automatically. On shutdown, call `await ws.close()` β€” it closes every connected peer and tears down the backplane in one step (leaving any Redis/Postgres client you passed in connected; its lifecycle stays yours). Any server you created (e.g. the `http.Server`) is also yours to close. ## Delivery semantics The cross-instance relay is **best-effort and fire-and-forget** β€” design your topics to tolerate that: - **At-most-once and unordered.** A relayed message reaches remote subscribers at most once, and ordering across instances is not guaranteed. If you need to detect a gap, carry your own sequence/version in the payload rather than assuming a reliable stream. - **No replay or buffering.** Messages published while an instance is disconnected from the backplane β€” or before it finishes subscribing on startup β€” are not queued; that instance simply misses them (see the ioredis vs node-redis [reconnect note](#redis-client-channel-connector)). - **Local delivery is independent.** `peer.publish()` always reaches _local_ subscribers synchronously, even when the backplane is down. Only the cross-instance relay is best-effort. - **Failures never throw into your `publish()`.** A backplane error is isolated so it can't crash the process. Pass `onError` to observe a degraded backplane (for logging, metrics, or alerting): ```js 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); }, }); ``` Without `onError`, failures are logged to `console.error`. ## Choosing a driver | Driver | Backplane | Reach | When to use | | --- | --- | --- | --- | | [`redis`](#redis-client-channel-connector) | Redis pub/sub | Multi-region / multi-host | The general-purpose choice for a real cluster. | | [`pgsql`](#pgsql-client-channel-connector) | Postgres `LISTEN`/`NOTIFY` | Multi-region / multi-host | You already run Postgres and would rather not add Redis (payloads must stay under 8 KB). | | [`cluster`](#cluster-channel) | Node.js `cluster` IPC | Single host (forked processes) | Multiple processes on one host (Node `cluster` / PM2) without a network broker. | | [`broadcastChannel`](#broadcastchannel-channel) | `BroadcastChannel` | Single process (worker threads) | Local fan-out, tests, in-process worker threads. | All four ship in `crossws/sync` and have no third-party dependencies β€” `redis` and `pgsql` take a client you bring; `cluster` and `broadcastChannel` need nothing. > [!NOTE] > On **Cloudflare** the model is different β€” a single Durable Object is already cluster-global, so a backplane is only relevant for multi-instance sharding or the fallback path. See [Cloudflare β†’ Sync across instances](/adapters/cloudflare#sync-across-instances). ## Sync Drivers ### `redis({ client, channel, connector? })` Works out of the box with both [ioredis](https://github.com/redis/ioredis) and [node-redis](https://github.com/redis/node-redis). The client flavor is auto-detected; pass `connector: "ioredis" | "node-redis"` to override detection. ```js [ioredis] import { redis } from "crossws/sync"; import Redis from "ioredis"; const ws = nodeAdapter({ hooks, sync: redis({ client: new Redis(), channel: "my-app" }), }); ``` ```js [node-redis] import { redis } from "crossws/sync"; import { createClient } from "redis"; const client = await createClient().connect(); const ws = nodeAdapter({ hooks, sync: redis({ client, channel: "my-app" }), }); ``` crossws derives a dedicated `SUBSCRIBE` connection from your client via `duplicate()` (for node-redis it also `connect()`s it), so a single connected client is all you pass in. > [!NOTE] > On a dropped connection, ioredis automatically re-subscribes its channels; node-redis does not restore subscriptions the same way, so after a transient outage a node-redis-backed instance may stop receiving relayed messages until the client reconnects and re-subscribes. Prefer ioredis if resilience to flaky connections matters. ### `pgsql({ client, channel, connector? })` Relays over PostgreSQL [`LISTEN`/`NOTIFY`](https://www.postgresql.org/docs/current/sql-notify.html) β€” handy for clusters that already run Postgres and would rather not add Redis. Works with both [node-postgres](https://github.com/brianc/node-postgres) (`pg`) and [postgres.js](https://github.com/porsager/postgres); the flavor is auto-detected, with `connector: "pg" | "postgres.js"` to override. ```js [node-postgres] import { pgsql } from "crossws/sync"; import { Client } from "pg"; const client = new Client(); await client.connect(); const ws = nodeAdapter({ hooks, sync: pgsql({ client, channel: "my-app" }), }); ``` ```js [postgres.js] import { pgsql } from "crossws/sync"; import postgresjs from "postgres"; const sql = postgresjs(); const ws = nodeAdapter({ hooks, sync: pgsql({ client: sql, channel: "my-app" }), }); ``` Unlike Redis `SUBSCRIBE`, Postgres `LISTEN` doesn't block the connection, so no duplicate connection is needed (for node-postgres the same client both listens and notifies; postgres.js reserves its own dedicated connection internally). > [!IMPORTANT] > Pass a dedicated `Client`, **not** a `Pool`. Pool connections rotate per query, so a `LISTEN` lands on a backend that's then returned to the pool and notifications never reach a stable listener. A `Pool` is detected and rejected at construction. For the same reason, give this driver its own client rather than sharing one across two `pgsql()` instances on the same channel β€” closing one issues `UNLISTEN` and silences the others. Two transport limits to keep in mind: the `channel` name maps to a Postgres identifier, capped at **63 bytes** (rejected at construction if longer), and a `NOTIFY` payload is capped at **8000 bytes** (base64 inflates binary ~33%), so keep relayed messages small. ### `cluster({ channel })` Relays over Node.js [`cluster`](https://nodejs.org/api/cluster.html) IPC β€” bridges the **forked processes on a single host** (Node `cluster`, or PM2 `instances`) without standing up a network broker. This is the gap [`broadcastChannel`](#broadcastchannel-channel) leaves: its registry is per-process and silently won't sync across forks. For multiple hosts or regions you still want [`redis`](#redis-client-channel-connector) or [`pgsql`](#pgsql-client-channel-connector). The driver runs in the **workers**; the **primary** needs a one-line relay because cluster workers can't message each other directly β€” IPC only flows between each worker and the primary, which rebroadcasts. Call `setupPrimaryCluster()` once in the primary (it's a no-op in workers, so guarding with `cluster.isPrimary` is optional): ```js import cluster from "node:cluster"; import { availableParallelism } from "node:os"; import { setupPrimaryCluster, cluster as clusterSync } from "crossws/sync"; if (cluster.isPrimary) { setupPrimaryCluster(); for (let i = 0; i < availableParallelism(); i++) cluster.fork(); } else { const ws = nodeAdapter({ hooks, sync: clusterSync({ channel: "my-app" }), }); // ... start your server } ``` Binary payloads are base64-encoded, so default IPC serialization is enough β€” you don't need `serialization: "advanced"`. Calling `clusterSync()` outside a forked worker (no `process.send`) throws on `subscribe`, surfacing the misconfiguration rather than silently not syncing. ### `broadcastChannel({ channel })` Bridges instances that share a `BroadcastChannel` registry. On Node.js, Deno and Bun that registry is scoped to a **single process** β€” it spans the main thread and its worker threads, but **not** separate OS processes (e.g. Node `cluster`/PM2 forks), which each get an isolated registry and silently won't sync. For forked processes on one host use [`cluster`](#cluster-channel); across hosts or regions use [`redis`](#redis-client-channel-connector) or [`pgsql`](#pgsql-client-channel-connector). (Deno Deploy is the exception β€” its `BroadcastChannel` spans isolates.) ```js import { broadcastChannel } from "crossws/sync"; const ws = nodeAdapter({ hooks, sync: broadcastChannel({ channel: "my-app" }), }); ``` ## Writing a driver Any transport that can fan a message out to your other instances can back a sync driver. A driver is a `SyncAdapter`: a factory crossws calls **once per instance**, passing a stable per-instance `id`. It returns a `SyncDriver` with three methods: **`publish(msg)`** β€” called for every local `peer.publish()` / `adapter.publish()`. Relay `msg` to the other instances over your backplane. A `msg` is `{ namespace, topic, data }`, where `data` is a `string | Uint8Array`. **`subscribe(deliver)`** β€” called once on startup. Listen for messages from the other instances and hand each one to `deliver`, which fans it out to this instance's local subscribers. **`close()`** _(optional)_ β€” called when the adapter shuts down. Release any connection you opened. Two things are your responsibility on the wire: **suppress your own echo** (most backplanes deliver a publisher its own messages) and **preserve binary payloads** (most transports are text-only). Both are handled by a small envelope that stamps the sender `id` and base64-encodes binary `data`. crossws exports the same `encodeEnvelope` / `decodeEnvelope` helpers its built-in drivers use, so you don't have to reimplement them: ```ts import { encodeEnvelope, decodeEnvelope } from "crossws/sync"; import type { SyncAdapter } from "crossws/sync"; const mySync: SyncAdapter = ({ id }) => ({ subscribe(deliver) { backplane.on("message", (raw) => { const envelope = decodeEnvelope(raw); if (!envelope || envelope.id === id) { return; // ignore malformed messages and our own echo } deliver(envelope.msg); }); }, publish(msg) { backplane.send(encodeEnvelope(id, msg)); // stamps `id` so we can filter it back out }, close() { backplane.disconnect(); // optional: release the connection on shutdown }, }); ``` `encodeEnvelope(id, msg)` returns a JSON string; `decodeEnvelope(raw)` parses it back to `{ id, msg }` (or `undefined` for anything malformed). Binary `data` is base64-encoded inside the envelope and restored on decode. **Notes:** - **Skip the echo check and every publish is delivered twice locally** β€” once from your own loopback, once from `deliver`. The `envelope.id === id` guard is what prevents it. - **Reuse the envelope helpers** unless your transport carries structured data natively. `BroadcastChannel`, for example, can pass `{ id, msg }` (binary `Uint8Array` and all) straight through, so its built-in driver skips the JSON/base64 envelope entirely and filters on `id` directly. --- # Resolver API When integrating WebSockets with larger projects, it is often needed to dynamically route an incoming event to websocket hooks. crossws provides a very simple mechanism to do this using resolver API. > [!TIP] > Resolver supports async results. This allows implementing lazy loading. ```js // https://crossws.h3.dev/adapters import crossws from "crossws/adapters/"; import { defineHooks } from "crossws"; const websocket = crossws({ async resolve(req) { // TODO: Resolve hooks based on req.url, req.headers // You can return undefined in case there is no match return { /* resolved hooks */ }; }, }); ``` If you need to change resolve function (for cases like handling HMR): ```ts let resolveWebSocketHooks = (req) => /* ... */ const websocket = crossws({ async resolve(req) { return resolveWebSocketHooks(req) }, }); // Update reference to `resolveWebSocketHooks` later. ``` ## Server plugin When using the [`crossws/server`](/guide) plugin (srvx integration), `resolve` is **optional**. If you omit it, hooks are resolved by calling the server's own `fetch` handler and reading them back from either the [request](#attaching-hooks-to-the-request) or the `crossws` property of the returned `Response` (the srvx convention for attaching WebSocket hooks): ```js import { plugin as ws } from "crossws/server"; // No `resolve` needed β€” hooks come from the app's own fetch handler. serve(app, { plugins: [ws()] }); ``` Provide `resolve` only to customize routing, e.g. to resolve hooks without invoking the app: ```js serve(app, { plugins: [ws({ resolve: (req) => resolveWebSocketHooks(req) })], }); ``` `resolve` is invoked **once per connection** β€” its result is cached against the connection for the lifetime of the peer β€” so the app's `fetch` runs on connect, not on every message. > [!NOTE] > Passing inline hooks directly (`ws({ message })`) opts out of the fetch-based default: those hooks run with zero per-event overhead instead. ### Making your `fetch` handler upgrade-aware With the default resolver, your app's `fetch` handler is called with the raw WebSocket **upgrade request** on connect. Keep two things in mind: - **Attach hooks to the request, or via `.crossws`.** Hooks are read from two channels: the `Symbol.for("crossws.hooks")` property on the **request** (see [Attaching hooks to the request](#attaching-hooks-to-the-request)), and the `crossws` property of the value **returned** for the upgrade request. An **error or redirect** response with hooks on neither channel (any status that is not `2xx` or `101`, e.g. `new Response("Unauthorized", { status: 401 })` or a `302`) is sent back to the client and the handshake is rejected β€” handy for auth. The lone exception is a [`426`](#attaching-hooks-to-the-request) carrying request hooks. A **`2xx`** (or `101`) response with hooks on neither channel still completes the handshake but with **no hooks attached**, so every message is silently dropped. Make sure the upgrade path provides hooks, either on a `Response`: ```js import { defineHooks } from "crossws"; const hooks = defineHooks({ message(peer, message) { peer.send(message.text()); }, }); function fetch(req) { if (req.headers.get("upgrade") === "websocket") { const res = new Response(null); res.crossws = hooks; // opt this connection into WebSocket handling return res; } return new Response("Hello!"); // normal HTTP response } ``` …or, as a shortcut for the upgrade path, a plain object `{ crossws, headers }` (no `Response` needed). Any `headers` are sent on the WebSocket handshake response: ```js function fetch(req) { if (req.headers.get("upgrade") === "websocket") { return { crossws: hooks, headers: { "x-powered-by": "crossws" }, // optional handshake headers }; } return new Response("Hello!"); // normal HTTP response β€” must be a Response } ``` > [!NOTE] > The plain-object form is only for upgrade requests. Non-upgrade paths must still return a real `Response`. An HTTP `Response`'s own headers are **not** applied to the handshake β€” use the `{ crossws, headers }` shortcut (or an `upgrade` hook returning `{ headers }`) to set handshake headers. - **`fetch` runs per connection, so keep it side-effect-safe.** Any auth, logging, metrics, or database work in `fetch` now executes once for every WebSocket upgrade. If a code path must run only for regular HTTP requests, branch on the `upgrade` header first. - **Throwing/rejecting fails the handshake.** If `fetch` throws (or rejects) on the upgrade request, the WebSocket handshake is rejected rather than left hanging. Throw a `Response` to control the rejection; otherwise the client sees a `500`. To reject an upgrade _gracefully_, prefer returning a non-`2xx` `Response` (which is rendered to the client) over throwing. ### Attaching hooks to the request Hooks attached to a `Response` are **lost whenever some layer rebuilds it** β€” merging a staged header (a route rule, CORS middleware), wrapping a streamed body, or any plain `new Response(res.body, res)` in a middleware. A rebuilt response carries none of the original's own properties, so `.crossws` silently disappears and the handshake is rejected with an opaque `426`. Nothing in that chain replaces the *request*, so it is the durable channel. Attach hooks to it with `setWebSocketHooks()`: ```js import { setWebSocketHooks } from "crossws"; function fetch(req) { if (req.headers.get("upgrade") === "websocket") { setWebSocketHooks(req, hooks); return new Response("WebSocket upgrade is required.", { status: 426 }); } return new Response("Hello!"); } ``` The **symbol is the wire format**, not the helper β€” `Symbol.for("crossws.hooks")` is public API. A framework that depends on crossws for *types only* can write it with no import at all, and because it lives in the global symbol registry it also crosses duplicate module instances and realms: ```js req[Symbol.for("crossws.hooks")] = hooks; ``` > [!TIP] > `setWebSocketHooks()` also writes the request's srvx-style `context` bag when it has one, so hooks survive frameworks that derive a new request internally (e.g. mounting a sub-app under a base path). It guards the direct write too: assigning to a non-extensible request would *throw* in strict mode, which would take the upgrade down entirely. > [!IMPORTANT] > A symbol written directly on the request does **not** survive `req.clone()` or `new Request(req)` β€” cloning copies the HTTP fields, not the object's own properties, exactly as rebuilding a response drops `.crossws`. Only the `context` bag carries across, and only when the framework propagates the same reference. Attach hooks to the request the upgrade is actually handled with, and prefer `setWebSocketHooks()` over the bare symbol so the `context` copy is written too. Both channels are supported, and they compose predictably: - Hooks on the **result** (`.crossws`) always win. - Hooks on the **request** are a *recovery* channel: they never reinterpret a response. An error/redirect result still rejects the handshake, so auth middleware that rejects after the WebSocket handler ran keeps working. The one exception is **`426 Upgrade Required`** β€” the status a framework returns *because* it routed to a WebSocket handler β€” which is treated as an upgrade when the request carries hooks. A `426` with hooks on *neither* channel is still rejected, and logs a one-time warning naming the likely cause (its only other symptom is an opaque `Unexpected server response: 426` on the client). - The `{ crossws, headers }` shortcut still applies its handshake `headers` when the hooks came from the request. > [!NOTE] > Both channels are read only by the **default** resolver. A user-supplied `resolve` bypasses them entirely. --- # WebSocket Proxy > Forward incoming WebSocket connections to an upstream `ws://` or `wss://` target. crossws ships a small helper that returns a set of ready-made hooks which proxy every peer to an upstream WebSocket server. Use it to put crossws in front of an existing backend, split traffic across services, or bridge protocols between runtimes. > [!NOTE] > The proxy uses the global [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) constructor to dial the upstream, which is available on Node.js β‰₯ 22, Bun, Deno, Cloudflare Workers, and browsers. On older Node versions, pass a custom constructor via the [`WebSocket` option](#custom-websocket-constructor) or install a polyfill on `globalThis`. ## Usage `createWebSocketProxy()` returns a `Partial` object that you can pass straight to any crossws adapter. ```ts // https://crossws.h3.dev/adapters import crossws from "crossws/adapters/"; import { createWebSocketProxy } from "crossws"; const websocket = crossws({ hooks: createWebSocketProxy("wss://echo.websocket.org"), }); ``` Every incoming peer opens a matching upstream connection. Text and binary messages are forwarded in both directions, and close/error events are propagated to the client. > [!TIP] > Messages sent by the client before the upstream connection is ready are buffered and flushed as soon as the upstream is open. > [!CAUTION] > **The default proxy is an open relay.** It accepts every incoming connection and forwards it to the configured upstream without any authorization check. Always combine it with an [`upgrade` hook](#authentication) when the upstream is not itself publicly accessible β€” otherwise anyone who can reach the proxy can reach the upstream. ## Authentication `createWebSocketProxy()` returns a plain hooks object, so you can spread it and override individual hooks. Authenticate the upgrade request before proxying by wrapping the proxy's `upgrade` hook: ```ts import { createWebSocketProxy } from "crossws"; const proxyHooks = createWebSocketProxy("wss://backend.example.com"); const hooks = { ...proxyHooks, async upgrade(req) { const token = req.headers.get("authorization"); if (!(await isValidToken(token))) { return new Response("Unauthorized", { status: 401 }); } // Delegate to the proxy's own `upgrade` so subprotocol echoing still works. return proxyHooks.upgrade?.(req); }, }; ``` > [!NOTE] > The WHATWG `WebSocket` constructor cannot forward cookies, `Authorization`, or `Origin` to the upstream, so upstream identity checks relying on those headers will silently fail. Authenticate at the proxy, or pass a custom `WebSocket` client and use the [`headers` option](#forwarding-headers) to propagate identity. ## Dynamic target Pass a function to resolve the upstream URL from the incoming [`Peer`](/guide/peer) β€” useful for routing based on request URL, headers, or authenticated context. ```ts import { createWebSocketProxy } from "crossws"; const hooks = createWebSocketProxy({ target: (peer) => { const { pathname } = new URL(peer.request.url); return pathname.startsWith("/admin") ? "wss://admin.internal/ws" : "wss://public.internal/ws"; }, }); ``` > [!WARNING] > **SSRF risk.** A dynamic `target` resolver is a trust boundary. Never interpolate untrusted input (query strings, headers, path segments a client controls) directly into the returned URL β€” a naive resolver turns the proxy into an SSRF primitive that can dial `ws://127.0.0.1`, `ws://169.254.169.254`, or any reachable internal service. Always resolve against a hard-coded allowlist of hosts you control. The resolver may be **async** β€” return a promise when the upstream address isn't known yet at connect time (a worker that's still booting, a backend being hot-reloaded, a lookup against a registry). The proxy buffers any client frames that arrive while it resolves (bounded by [`maxBufferSize`](#api)) and a non-zero [`connectTimeout`](#api) also covers the resolution, so a resolver that never settles closes the peer with `1011` instead of hanging. With `connectTimeout: 0` (timeout disabled) a never-settling resolver instead keeps the peer open until `maxBufferSize` is reached (`1009`), so give your resolver its own deadline in that case. ```ts const hooks = createWebSocketProxy({ // Wait for the upstream worker to report its address before proxying. target: async () => { const addr = await worker.waitForAddress(); return `ws://${addr.host}:${addr.port}/`; }, }); ``` ## Subprotocol negotiation By default, the proxy forwards the client's `sec-websocket-protocol` header to the upstream and echoes the first requested subprotocol back in the upgrade response so the client handshake succeeds. Disable this if you want to negotiate subprotocols yourself: ```ts createWebSocketProxy({ target: "wss://backend.example.com", forwardProtocol: false, }); ``` > [!WARNING] > The proxy commits to a subprotocol in the upgrade response before the upstream connection is established. If the upstream ultimately picks a different subprotocol (or rejects), the client will still see the one the proxy promised. Only keep `forwardProtocol` enabled when the upstream is known to accept the same subprotocols the client negotiates. ### Rewriting the upstream subprotocol `forwardProtocol` can present a _different_ subprotocol to the upstream than the client offered β€” useful when a client re-labels a token to pass an intermediary and the proxy must restore the real token when dialing the origin. Pick the simplest form that fits: ```ts // 1. Fixed value β€” the upstream always expects one known subprotocol. createWebSocketProxy({ target: "wss://backend.example.com", forwardProtocol: "vite-hmr", }); // 2. Rewrite map β€” swap specific client tokens, pass the rest through. createWebSocketProxy({ target: "wss://backend.example.com", forwardProtocol: { "proxied-vite-hmr": "vite-hmr" }, }); // 3. Function β€” when the rewrite depends on more than the token value. createWebSocketProxy({ target: "wss://backend.example.com", forwardProtocol: (peer) => (peer.request.headers.get("sec-websocket-protocol") ?? "") .split(",") .map((p) => p.trim()) .filter(Boolean) .map((p) => (p.startsWith("proxied-") ? p.slice("proxied-".length) : p)), }); ``` The function receives the [`Peer`](/guide/peer) and returns a `string`, an array of strings, or `undefined` to offer none. > [!NOTE] > These forms control only what is offered to the **upstream**. The subprotocol echoed back to the **client** stays the first token the client offered β€” per RFC 6455 the selected subprotocol must be one the client proposed, so echoing a rewritten value (which the client may not have offered) would make browsers fail the connection. ## Custom WebSocket constructor Pass a `WebSocket` constructor via options to override the global β€” useful on Node.js < 22, to plug in a different client implementation, or to stub the upstream in tests. ```ts import { WebSocket } from "ws"; import { createWebSocketProxy } from "crossws"; const hooks = createWebSocketProxy({ target: "wss://backend.example.com", WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, }); ``` ## Unix domain sockets Proxy to an upstream listening on a Unix domain socket by returning a `ws+unix://:` target. This works **out of the box** on Node.js, Bun, and Deno β€” no custom `WebSocket` constructor required: ```ts import { createWebSocketProxy } from "crossws"; const hooks = createWebSocketProxy({ target: "ws+unix:///var/run/backend.sock:/chat", }); ``` The path before the `:` is the socket file (`/var/run/backend.sock`); the path after it is the request path forwarded to the upstream (`/chat`). A dynamic resolver can build it per peer: ```ts const hooks = createWebSocketProxy({ target: (peer) => `ws+unix:///run/backend.sock:${new URL(peer.request.url).pathname}`, }); ``` Under the hood the target is dialed through the [`crossws/websocket`](https://github.com/h3js/crossws/tree/main/src/websocket) client for the current runtime, which picks the right strategy automatically: - **Bun** β€” its global `WebSocket` dials `ws+unix:` natively. - **Node.js** β€” its global `WebSocket` rejects the scheme, so crossws dials with the [`ws`](https://github.com/websockets/ws) client instead (bundled with crossws, no extra dependency to install). - **Deno** β€” its global `WebSocket` rejects the scheme, so crossws routes the transport through [`Deno.createHttpClient`](https://docs.deno.com/api/deno/~/Deno.createHttpClient)'s unix `client`. Passing an explicit [`WebSocket` constructor](#custom-websocket-constructor) opts out of this per-runtime handling β€” the target is dialed with your constructor verbatim (e.g. `ws`, which accepts `ws+unix:` directly). > [!NOTE] > On **Deno**, the unix transport uses `Deno.createHttpClient`, an **unstable** API β€” run Deno with the `--unstable-net` flag to enable it. ## Per-peer constructor options For transports that can't be expressed in the target URL, `webSocketOptions` passes extra dialing options β€” a static object or a per-peer resolver β€” to the upstream `WebSocket` client, alongside any [`headers`](#forwarding-headers). Use it for runtime- or client-specific options the WHATWG signature doesn't cover β€” e.g. pinning the connection to a network interface, or supplying a custom transport client. ([Unix sockets](#unix-domain-sockets) are dialed automatically, so you don't need this for them.) For example, pinning the upstream connection to a specific network interface via [`ws`](https://github.com/websockets/ws)'s `createConnection`, or [`undici`](https://undici.nodejs.org)'s `dispatcher`: ```ts import { WebSocket } from "ws"; import { connect } from "node:net"; import { createWebSocketProxy } from "crossws"; const hooks = createWebSocketProxy({ target: "ws://backend.internal/chat", WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, webSocketOptions: () => ({ createConnection: () => connect({ host: "10.0.0.5", port: 80 }), }), }); ``` On Deno, `webSocketOptions` also carries a custom [`Deno.createHttpClient`](https://docs.deno.com/api/deno/~/Deno.createHttpClient) `client` β€” for example to route the upstream through an HTTP proxy (a plain unix socket is handled automatically by a [`ws+unix:` target](#unix-domain-sockets)): ```ts const hooks = createWebSocketProxy({ target: "wss://backend.internal/chat", webSocketOptions: () => ({ client: Deno.createHttpClient({ proxy: { url: "http://proxy.internal:8080" } }), }), }); ``` > [!NOTE] > `webSocketOptions` is only honored by `WebSocket` constructors that accept a dialing-options argument (the `ws`/`undici` clients, or Deno's global β€” crossws passes the options in the position each expects). The WHATWG browser global ignores them. Keys are merged with `headers`, with the dedicated `headers` option taking precedence over a `headers` key returned here. ## Forwarding headers Passing a `headers` option attaches extra headers to the upstream handshake. This is the usual way to forward identity (`cookie`, `authorization`, `origin`) or inject a shared secret to the upstream. ```ts import { WebSocket } from "ws"; import { createWebSocketProxy } from "crossws"; const hooks = createWebSocketProxy({ target: "wss://backend.example.com", WebSocket: WebSocket as unknown as typeof globalThis.WebSocket, headers: (peer) => ({ cookie: peer.request.headers.get("cookie") ?? "", "x-forwarded-for": peer.remoteAddress ?? "", }), }); ``` > [!IMPORTANT] > The WHATWG global `WebSocket` constructor does **not** accept custom headers. `headers` is only honored when a `WebSocket` constructor that takes a third options argument is passed via the [`WebSocket` option](#custom-websocket-constructor) β€” e.g. [`ws`](https://github.com/websockets/ws) or [`undici`](https://undici.nodejs.org). With the global constructor the option is silently ignored. ## Client idle timeout Sometimes a client disappears without closing its connection β€” the network drops, the device sleeps, or a proxy or tunnel between the client and your server quietly stops passing traffic. The upstream connection stays open, tying up resources for a client that will never return. Normally WebSocket ping/pong keepalive would notice a dead connection, but it isn't always enough: when there's a proxy or tunnel in the middle, that middlebox can answer the pings itself, so the connection keeps looking healthy long after the real client is gone. `clientIdleTimeout` catches these by watching for **actual messages from the client**. Each message the client sends resets the timer; traffic the server pushes to the client does not. If nothing arrives from the client within the window, the proxy closes both the peer (code `1001`) and the upstream connection. ```ts createWebSocketProxy({ target: "wss://backend.example.com", // Close a client that sends nothing for 60s. Assumes the protocol has the // client send periodic traffic (e.g. a heartbeat / keepalive message). clientIdleTimeout: 60_000, }); ``` > [!WARNING] > Only enable this for protocols where the client is expected to send traffic periodically. For **server-push-only** protocols where the client may be legitimately silent, a `clientIdleTimeout` will close idle-but-live connections β€” leave it disabled (the default) in that case. ## API ### `createWebSocketProxy(target)` Accepts either a target URL (`string` or `URL`), a resolver function, or an options object: - **`target`** β€” `string | URL | (peer: Peer) => string | URL | Promise`. The upstream WebSocket URL, or a function (optionally async) that resolves it per peer. Accepts `ws:`/`wss:`, and `ws+unix://:` for a [Unix-socket upstream](#unix-domain-sockets) (dialed out of the box on Node, Bun, and Deno). The proxy does not otherwise enforce a scheme allowlist β€” any URL a custom `WebSocket` constructor accepts works. See the [SSRF warning](#dynamic-target) before using a dynamic resolver. - **`forwardProtocol`** β€” `boolean | string | string[] | Record | (peer: Peer) => string | string[] | undefined` (default `true`). Controls the subprotocol(s) offered to the upstream: - `true` forwards the client's `sec-websocket-protocol` header verbatim; `false` offers none. - a `string`/`string[]` offers a fixed value upstream regardless of the client. - a `Record` rewrites matching client tokens to their mapped values, passing unmapped tokens through. - a function resolves the value per peer. See [rewriting the upstream subprotocol](#rewriting-the-upstream-subprotocol). In all cases the value echoed back to the client is the first client-offered token (RFC 6455), and values that are not valid RFC 7230 tokens are dropped from the echo. - **`headers`** β€” `HeadersInit | (peer: Peer) => HeadersInit`. Extra headers to send on the upstream handshake. Only honored when a custom `WebSocket` constructor that accepts a third options argument is supplied β€” the WHATWG global ignores it. - **`webSocketOptions`** β€” `Record | (peer: Peer) => Record`. Extra dialing options passed to the upstream `WebSocket` client, statically or per peer. The escape hatch for transport options the URL can't express β€” e.g. the `ws`/`undici` `createConnection`/`dispatcher`/`agent`, or a custom `Deno.createHttpClient` `client`. Merged with `headers` (the `headers` option wins over a `headers` key returned here). See [per-peer constructor options](#per-peer-constructor-options). Only honored by clients that accept dialing options (`ws`/`undici`, or Deno's global); the WHATWG browser global ignores them. - **`maxBufferSize`** β€” `number` (default `1048576`, i.e. 1 MiB). Maximum number of bytes buffered per peer while the upstream is still connecting. String frames are accounted at their UTF-8 worst case (3 bytes per UTF-16 code unit) to avoid undercounting multi-byte content. When exceeded, the peer is closed with code `1009` (Message Too Big). Set to `0` to disable. - **`connectTimeout`** β€” `number` (default `10000`). Milliseconds to wait for the upstream WebSocket handshake to complete β€” and, for an async `target` resolver, for the resolver to settle. If exceeded, the peer is closed with code `1011`. Set to `0` to disable. - **`clientIdleTimeout`** β€” `number` (default `0`, disabled). Milliseconds of **clientβ†’proxy** inactivity after which the peer and upstream are closed with code `1001`. Reset by every inbound client frame; unaffected by upstreamβ†’client traffic. Only suitable for protocols where the client sends traffic periodically. See [client idle timeout](#client-idle-timeout). Set to `0` to disable. - **`WebSocket`** β€” `typeof WebSocket` (default `globalThis.WebSocket`). Custom `WebSocket` constructor used to dial the upstream. Falls back to the global when omitted; throws at setup time if neither is available. Returns a `Partial` object containing `upgrade`, `open`, `message`, `close`, and `error` hooks. --- # WebSocket Client > A per-runtime WebSocket **client** for dialing upstream servers with one consistent signature. Every runtime exposes a slightly different `WebSocket` constructor for **dialing** a server: Bun and Deno take their dialing options as the constructor's second argument, the WHATWG/`ws` clients take a third, and only some accept the `ws+unix:` scheme or custom upgrade headers. crossws smooths this over with the `crossws/websocket` subpath β€” a thin per-runtime client that dials `ws:`, `wss:`, and `ws+unix:` upstreams through one uniform `(url, protocols, options)` signature. This is the same client [the proxy](/guide/proxy) uses under the hood. Reach for it directly when you need to dial an upstream WebSocket yourself β€” for example from a custom hook β€” and want custom headers or Unix-socket support to work the same way on every runtime. > [!NOTE] > `crossws/websocket` is a **client** (it dials out). To accept incoming connections, use an [adapter](/adapters) or [`crossws/server`](/guide). ## Usage Import the default export and construct it like a standard `WebSocket`: ```ts import WebSocket from "crossws/websocket"; const ws = new WebSocket("wss://echo.websocket.org"); ws.onopen = () => ws.send("hello"); ws.onmessage = (event) => console.log(event.data); ``` The returned instance is a standard [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) β€” the same events (`open`, `message`, `close`, `error`), methods (`send`, `close`), and static members (`WebSocket.OPEN`, …) you already know. ## Signature ```ts new WebSocket(url, protocols?, options?); ``` - **`url`** β€” `string | URL`. The upstream to dial: `ws:`, `wss:`, or a [`ws+unix://:`](#unix-domain-sockets) target. - **`protocols`** β€” `string | string[]` (optional). Subprotocol(s) offered during the handshake, exactly as the WHATWG constructor. - **`options`** β€” `Record` (optional). Extra dialing options β€” most commonly custom upgrade `headers`. crossws relays this into whichever argument position the current runtime expects. The third `options` argument is the value the WHATWG browser constructor can't express. crossws makes it behave the same everywhere: ```ts import WebSocket from "crossws/websocket"; const ws = new WebSocket("wss://backend.example.com", undefined, { headers: { authorization: "Bearer …" }, }); ``` > [!TIP] > When you call it with just `(url, protocols)` and no `options`, crossws keeps the runtime's native positional path β€” so option-less calls are identical to using the global `WebSocket` directly. ## Per-runtime behavior crossws resolves a runtime-specific implementation through [export conditions](https://nodejs.org/api/packages.html#conditional-exports), so a bundle built for one runtime tree-shakes away the others (e.g. `ws` and `node:*` never enter a Deno or browser bundle): | Runtime | Implementation | | --- | --- | | **Bun** | Global `WebSocket` β€” dials `ws:`/`wss:`/`ws+unix:` natively. crossws relays `options` into Bun's second-argument form. | | **Node.js** | Global `WebSocket` (undici) for the common `ws:`/`wss:` path; routes through [`ws`](https://github.com/websockets/ws) (bundled, no extra dependency) whenever `options` are passed or the target is `ws+unix:` β€” and everywhere on Node < 22, which has no global. | | **Deno** | Global `WebSocket`; relays `options` into Deno's second-argument form, and rewrites `ws+unix:` targets through [`Deno.createHttpClient`](https://docs.deno.com/api/deno/~/Deno.createHttpClient)'s unix `client`. | | **Browser / Workers / edge** | The WHATWG global `WebSocket` verbatim. It has no third-argument options, so custom `headers` are silently ignored. | > [!IMPORTANT] > Custom `headers` (and other `options`) are honored on Bun, Deno, and Node β€” not in browsers, Cloudflare Workers, or other edge runtimes, whose WHATWG constructor drops them. Don't rely on upstream identity via headers there. ## Unix domain sockets Dial an upstream listening on a Unix domain socket by passing a `ws+unix://:` URL. This works **out of the box** on Node.js, Bun, and Deno: ```ts import WebSocket from "crossws/websocket"; const ws = new WebSocket("ws+unix:///var/run/backend.sock:/chat"); ``` The path before the `:` is the socket file (`/var/run/backend.sock`); the path after it is the request path forwarded to the upstream (`/chat`). > [!NOTE] > On **Deno**, the unix transport uses `Deno.createHttpClient`, an **unstable** API β€” run Deno with the `--unstable-net` flag to enable it. ## Relationship to the proxy [`createWebSocketProxy()`](/guide/proxy) dials every upstream through this client, which is why its [`headers`](/guide/proxy#forwarding-headers), [`webSocketOptions`](/guide/proxy#per-peer-constructor-options), and [`ws+unix:` targets](/guide/proxy#unix-domain-sockets) work uniformly across runtimes without a custom constructor. Passing an explicit [`WebSocket` option](/guide/proxy#custom-websocket-constructor) to the proxy opts out of this per-runtime handling and dials with your constructor verbatim. ::read-more{to="/guide/proxy" title="WebSocket Proxy"} See the [WebSocket Proxy](/guide/proxy) guide for forwarding incoming connections to an upstream. :: --- # Adapters crossws allows integrating your WebSocket hooks with different runtimes and platforms using built-in adapters. Each runtime has a specific method of integrating WebSocket. Once integrated, will work consistently even if you change the runtime. See Adapters section to learn more about all available built-in adapters. ## Shared options Every adapter accepts these options in addition to its runtime-specific ones. ### `idleTimeout` Close a connection that has stayed idle β€” no incoming messages and no pong replies β€” for roughly this many **seconds**. This reclaims peers whose transport died silently ("half-open" sockets: laptop sleep, NAT/mobile idle timeout, power loss, a cut cable) without the TCP stack ever delivering a `FIN`/`RST`, which would otherwise leak forever (the peer, and anything it owns such as a [proxied upstream](/guide/proxy) connection). It maps to each runtime's liveness mechanism behind one consistent knob: - **Node** β€” the `ws` library has no built-in liveness, so crossws pings each peer on this interval and terminates any that miss the pong. - **Bun / Deno / uWebSockets / Bunny** β€” mapped to the runtime's native WebSocket idle timeout, which also auto-sends keepalive pings. Terminated peers surface through the normal `close` hook (Node reports code `1006`), so `close`/`error` teardown runs unchanged. **Defaults to `30` (seconds) on every runtime.** 30s stays under the common ~60s reverse-proxy / load-balancer idle timeout (so idle-but-alive connections aren't dropped) while reclaiming dead sockets promptly; pings are a few bytes and standards clients auto-pong, so a live connection is never disconnected. Set to `0` to disable. ### `handleProtocols` Select the WebSocket subprotocol to accept during the handshake. Browsers that open `new WebSocket(url, protocols)` send their offer in the `Sec-WebSocket-Protocol` request header and **reject the connection** unless the server echoes one of the offered values back. By default crossws accepts none, so supply this to negotiate one: ```ts crossws({ handleProtocols: (protocols /* Set */, request) => protocols.has("graphql-transport-ws") ? "graphql-transport-ws" : false, }); ``` It is called only when the client offered at least one subprotocol, and receives the set of offered values plus the upgrade `request`. Return the single subprotocol to accept (one of the offered values), or `false`/`undefined` for none. It may be async. This is the global default; the [`upgrade` hook](/guide/hooks#subprotocol-negotiation) can return `{ protocol }` to override it per connection. Consistent across the Node, Bun and Deno adapters. --- # Bun > Manually integrate crossws with Bun. > [!TIP] > You can use `serve` function from `crossws/server` to **automatically** integrate crossws with Bun! To manually integrate crossws with your Bun server, you need to handle upgrade with `handleUpgrade` util and also pass the `websocket` object returned from the adapter to server options. crossws leverages native bun [WebSocket API](https://bun.sh/docs/api/websockets). ```ts import crossws from "crossws/adapters/bun"; const ws = crossws({ hooks: { message: console.log, }, }); Bun.serve({ port: 3000, websocket: ws.websocket, fetch(request, server) { if (request.headers.get("upgrade") === "websocket") { return ws.handleUpgrade(request, server); } return new Response( ``, { headers: { "content-type": "text/html" } }, ); }, }); ``` ::read-more See [`test/fixture/bun.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/bun.ts) for demo and [`src/adapters/bun.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/bun.ts) for implementation. :: ## Idle timeout The shared [`idleTimeout`](/adapters#idletimeout) option (in **seconds**) closes connections that die silently (half-open sockets). It maps to Bun's native WebSocket idle timeout, which auto-sends keepalive pings. Defaults to `30`; pass `0` to disable. ```ts const ws = crossws({ idleTimeout: 60, hooks: { message: console.log } }); ``` --- # Bunny > Manually integrate crossws with Bunny.net Edge Scripting. > [!TIP] > You can use `serve` function from `crossws/server/bunny` to **automatically** integrate crossws with Bunny.net! [Bunny.net Edge Scripting](https://docs.bunny.net/scripting/websockets) supports WebSockets to deliver low-latency, bidirectional communication between your applications and users across the globe. ## Usage To integrate crossws with Bunny.net Edge Scripting, you need to check for the `upgrade` header and then call `handleUpgrade` method from the adapter passing the incoming request object. The returned value is the server upgrade response. ```ts import * as BunnySDK from "@bunny.net/edgescript-sdk"; import crossws from "crossws/adapters/bunny"; const ws = crossws({ hooks: { message: (peer, message) => { console.log("Received:", message.text()); peer.send(`Echo: ${message.text()}`); }, open: (peer) => { console.log("Client connected"); }, close: (peer) => { console.log("Client disconnected"); }, }, }); BunnySDK.net.http.serve(async (request: Request) => { if (request.headers.get("upgrade") === "websocket") { return ws.handleUpgrade(request); } return new Response( ``, { headers: { "content-type": "text/html" } }, ); }); ``` ## Protocol Negotiation You can control WebSocket protocol negotiation through the `upgrade` hook by setting the `sec-websocket-protocol` header. This is useful for implementing authorization or selecting specific subprotocols based on the request: ```ts const ws = crossws({ hooks: { upgrade(req) { // Check for authorization token const token = req.headers.get("authorization"); if (!token) { return new Response("Unauthorized", { status: 401 }); } // Negotiate protocol based on client request const requestedProtocol = req.headers.get("sec-websocket-protocol"); const supportedProtocols = ["graphql-ws", "graphql-transport-ws"]; const protocol = requestedProtocol ?.split(",") .map((p) => p.trim()) .find((p) => supportedProtocols.includes(p)); return { headers: { "sec-websocket-protocol": protocol || "", }, }; }, }, }); ``` ## Options The Bunny adapter supports the following options: ### `protocol` The WebSocket subprotocol to use for the connection. > [!NOTE] > This can be overridden by setting the `sec-websocket-protocol` header in the `upgrade` hook for dynamic protocol negotiation. ```ts const ws = crossws({ protocol: "graphql-ws", }); ``` ### `idleTimeout` The number of seconds to wait for a pong response before closing the connection. Defaults to `30`. If the client does not respond within this timeout, the connection is deemed unhealthy and closed. If no data is transmitted from the client for 2 minutes, the connection will be closed regardless of this configuration. ```ts const ws = crossws({ idleTimeout: 60, }); ``` ::read-more See [Bunny.net WebSocket Documentation](https://docs.bunny.net/scripting/websockets) for more details on the WebSocket API. :: ::read-more See [`src/adapters/bunny.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/bunny.ts) for implementation. :: ```` --- # Cloudflare > Integrate crossws with Cloudflare Workers and Durable Objects. To integrate crossws with Cloudflare [Durable Objects](https://developers.cloudflare.com/durable-objects/api/websockets/) with [pub/sub](/guide/pubsub) and [hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#websocket-hibernation-api) support, you need to check for the `upgrade` header and additionally export a DurableObject with crossws adapter hooks integrated. > [!NOTE] > If you skip durable object class export or in cases the binding is unavailable, crossws uses a **fallback mode** without pub/sub support in the same worker. ```js import { DurableObject } from "cloudflare:workers"; import crossws from "crossws/adapters/cloudflare"; const ws = crossws({ // bindingName: "$DurableObject", // instanceName: "crossws", hooks: { message: console.log, open(peer) { peer.subscribe("chat"); peer.publish("chat", { user: "server", message: `${peer} joined!` }); }, }, }); export default { async fetch(request, env, context) { if (request.headers.get("upgrade") === "websocket") { return ws.handleUpgrade(request, env, context); } return new Response( ``, { headers: { "content-type": "text/html" } }, ); }, }; export class $DurableObject extends DurableObject { constructor(state, env) { super(state, env); ws.handleDurableInit(this, state, env); } fetch(request) { return ws.handleDurableUpgrade(this, request); } webSocketMessage(client, message) { return ws.handleDurableMessage(this, client, message); } webSocketPublish(topic, message, opts) { return ws.handleDurablePublish(this, topic, message, opts); } webSocketClose(client, code, reason, wasClean) { return ws.handleDurableClose(this, client, code, reason, wasClean); } } ``` Update your `wrangler.toml` to specify Durable object: ```ini [[durable_objects.bindings]] name = "$DurableObject" class_name = "$DurableObject" [[migrations]] tag = "v1" new_classes = ["$DurableObject"] ``` ::read-more See [`test/fixture/cloudflare-durable.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/cloudflare-durable.ts) for demo and [`src/adapters/cloudflare.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/cloudflare.ts) for implementation. :: ### Adapter options > [!NOTE] > By default, crossws uses the durable object class `$DurableObject` from `env` with an instance named `crossws`. > You can customize this behavior by providing `resolveDurableStub` option. - `bindingName`: Durable Object binding name from environment (default: `$DurableObject`). - `instanceName`: Durable Object instance name (default: `crossws`). - `resolveDurableStub`: Custom function that resolves Durable Object binding to handle the WebSocket upgrade. This option will override `bindingName` and `instanceName`. - `sync`: Optional [sync backplane](/guide/sync) to relay [pub/sub](/guide/pubsub) across instances (see below). - `onError`: Observe sync backplane failures. See [delivery semantics](/guide/sync#delivery-semantics). ### Sync across instances The [sync backplane](/guide/sync) relays [pub/sub](/guide/pubsub) between crossws instances, but on Cloudflare the model is different from a Node-style cluster β€” so reach for it only when you actually need it. A backplane on Cloudflare requires **Durable Objects**: only a Durable Object's context owns its (hibernatable) sockets via `ctx.getWebSockets()` and can fan a message out to them. In **fallback mode** (no Durable Object binding) each connection is a separate Worker invocation that can't send to another connection's socket, so pub/sub β€” and a backplane β€” are not supported there. - **Single Durable Object (the default).** With one instance (`"crossws"`), every connection across your app already lands on that same Durable Object, so `peer.publish()` is cluster-global out of the box. **No backplane needed.** - **Sharded Durable Objects.** If you fan connections across **multiple** instances (e.g. one per room via `resolveDurableStub`), a publish in one instance won't reach the others β€” a `sync` backplane bridges them. ```js import crossws from "crossws/adapters/cloudflare"; import type { SyncAdapter } from "crossws"; const ws = crossws({ hooks, sync: myBackplane, // a custom SyncAdapter (see caveats below) }); ``` Two Cloudflare-specific caveats: - **Inbound delivery into a Durable Object is best-effort.** crossws seeds the fan-out of a relayed message from the instance's in-memory peer map, which a hibernated/evicted Durable Object loses even though its sockets survive in `ctx.getWebSockets()`. A message relayed _into_ a hibernated Durable Object may miss some sockets. **Outbound** relay β€” a `peer.publish()` in a Durable Object reaching the backplane β€” is reliable. - **Bring a Cloudflare-native driver.** The built-in `redis` / `pgsql` drivers open persistent connections and target Node-like runtimes, so they won't run inside workerd. Write a custom [`SyncAdapter`](/guide/sync#writing-a-driver) over a Cloudflare-native transport (a coordinator Durable Object, [Queues](https://developers.cloudflare.com/queues/), or fetch-based pub/sub) instead. --- # Deno > Manually integrate crossws with Deno. > [!TIP] > You can use `serve` function from `crossws/server` to **automatically** integrate crossws with Deno! To manually integrate crossws with your Deno server, you need to check for the `upgrade` header and then call `handleUpgrade` method from the adapter passing the incoming request object. The returned value is the server upgrade response. ```ts import crossws from "crossws/adapters/deno"; const ws = crossws({ hooks: { message: console.log, }, }); Deno.serve({ port: 3000 }, (request, info) => { if (request.headers.get("upgrade") === "websocket") { return ws.handleUpgrade(request, info); } return new Response( ``, { headers: { "content-type": "text/html" } }, ); }); ``` ::read-more See [`test/fixture/deno.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/deno.ts) for demo and [`src/adapters/deno.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/deno.ts) for implementation. :: ## Idle timeout The shared [`idleTimeout`](/adapters#idletimeout) option (in **seconds**) closes connections that die silently (half-open sockets). It maps to Deno's native `Deno.upgradeWebSocket` idle timeout, which auto-sends keepalive pings. Defaults to `30`; pass `0` to disable. ```ts const ws = crossws({ idleTimeout: 60, hooks: { message: console.log } }); ``` --- # Node.js > Integrate crossws with Node.js (manually) or uWebSockets.js > [!TIP] > You can use `serve` function from `crossws/server` to **automatically** integrate crossws with Node.js! To manually integrate crossws with your Node.js HTTP server, you need to connect the `upgrade` event to the `handleUpgrade` method returned from the adapter. crossws uses a prebundled version of [ws](https://github.com/websockets/ws). ```ts import { createServer } from "node:http"; import crossws from "crossws/adapters/node"; const ws = crossws({ hooks: { message: console.log, }, }); const server = createServer((req, res) => { res.end( ``, ); }).listen(3000); server.on("upgrade", (req, socket, head) => { if (req.headers.upgrade === "websocket") { ws.handleUpgrade(req, socket, head); } }); ``` ::read-more See [`test/fixture/node.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/node.ts) for demo and [`src/adapters/node.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/node.ts) for implementation. :: ## Detecting dead connections (`idleTimeout`) A normal disconnect β€” closed tab, killed process, `socket.destroy()` β€” sends a TCP `FIN`/`RST`, so `ws` emits `close` within milliseconds and your `close` hook fires. But a **half-open** connection (laptop sleep, NAT/mobile idle timeout, power loss, a yanked cable) vanishes without ever delivering a `FIN`/`RST`. The OS socket stays `ESTABLISHED` indefinitely, `ws` never emits `close`, and the peer β€” plus anything it owns, such as a proxied upstream connection β€” leaks. Unlike the native runtimes (Bun, Deno, uWebSockets), Node's `ws` does not ping idle connections for you β€” so crossws emulates it. The adapter pings every peer and terminates any that miss the pong, driven by the shared [`idleTimeout`](/adapters#idletimeout) option (in **seconds**). It **defaults to `30`** on every runtime, so half-open connections are cleaned up out of the box: ```ts import crossws from "crossws/adapters/node"; const ws = crossws({ // Optional β€” defaults to 30. Lower it to detect dead peers faster, or // set 0 to disable. idleTimeout: 30, hooks: { message: console.log, }, }); ``` Terminated peers surface through the usual `close` hook (code `1006`), so any teardown wired to `close`/`error` (including [`createWebSocketProxy`](/guide/proxy) closing its upstream) runs unchanged. Pass `idleTimeout: 0` to opt out. The same option and default apply on the Bun, Deno, and uWebSockets adapters, where it maps to the runtime's native idle timeout. ## Delegating to an existing Node.js upgrade handler If you already have a Node.js WebSocket library that exposes a raw `(req, socket, head)` upgrade handler (e.g. [`ws`](https://github.com/websockets/ws), `socket.io`, `express-ws`), you can route to it through crossws using `fromNodeUpgradeHandler`. This lets you keep crossws's upgrade-time request handling while delegating the WebSocket lifecycle to your existing library. ```ts import { WebSocketServer } from "ws"; import { fromNodeUpgradeHandler } from "crossws/adapters/node"; import { serve } from "crossws/server/node"; const wss = new WebSocketServer({ noServer: true }); wss.on("connection", (ws) => { ws.on("message", (data) => ws.send(data)); }); serve({ fetch: () => new Response("ok"), websocket: fromNodeUpgradeHandler((req, socket, head) => { wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); }), }); ``` The underlying handler takes full ownership of the socket, so crossws's other lifecycle hooks (`open`, `message`, `close`, `error`) are **not** invoked for connections routed through it β€” manage the WebSocket lifecycle inside your own library as usual. > [!NOTE] > `fromNodeUpgradeHandler` only works on the Node.js runtime, and must be used via the crossws node server plugin so the request carries `runtime.node.upgrade.{socket, head}`. ## uWebSockets You can alternatively use [uWebSockets.js](https://github.com/uNetworking/uWebSockets.js) for Node.js servers. First add `uNetworking/uWebSockets.js` as a dependency. ```ts import { App } from "uWebSockets.js"; import crossws from "crossws/adapters/uws"; const ws = crossws({ hooks: { message: console.log, }, }); const server = App().ws("/*", ws.websocket); server.get("/*", (res, req) => { res.writeStatus("200 OK").writeHeader("Content-Type", "text/html"); res.end( ``, ); }); server.listen(3001, () => { console.log("Listening to port 3001"); }); ``` ::read-more See [`test/fixture/node-uws.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/node-uws.ts) for demo and [`src/adapters/node-uws.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/node-uws.ts) for implementation. :: ## Socket.IO [Socket.IO](https://socket.io/) speaks two transports: a WebSocket upgrade and an HTTP long-polling fallback served under `/socket.io/`. Both can be routed through crossws by combining `fromNodeUpgradeHandler` with srvx's `fetchNodeHandler` helper to forward the polling path (and the optional client bundle at `/socket.io/socket.io.js`) to socket.io's own Node-style `(req, res)` listener: ```ts import { createServer } from "node:http"; import { Server as SocketIOServer } from "socket.io"; import { fromNodeUpgradeHandler } from "crossws/adapters/node"; import { serve } from "crossws/server/node"; import { fetchNodeHandler } from "srvx/node"; // Attach socket.io to a throwaway http.Server β€” it never `.listen()`s, // but the dummy server now holds socket.io's own `request` listener, // which handles the client bundle AND delegates polling to engine.io. const dummyServer = createServer(); const io = new SocketIOServer(dummyServer, { serveClient: true }); const [socketIoRequestListener] = dummyServer.listeners("request"); io.on("connection", (socket) => { socket.emit("welcome", { id: socket.id }); socket.on("message", (text) => io.emit("message", { from: socket.id, text })); }); const server = serve({ port: 3000, async fetch(req) { const url = new URL(req.url); if (url.pathname.startsWith("/socket.io/")) { return fetchNodeHandler(socketIoRequestListener, req); } return new Response("ok"); }, websocket: fromNodeUpgradeHandler((req, socket, head) => { io.engine.handleUpgrade(req, socket, head); }), }); await server.ready(); ``` If you only need the WebSocket transport, force the client to skip polling with `io(url, { transports: ["websocket"] })` and you can drop the `fetch`-side delegation entirely. ::read-more See [`examples/socket.io`](https://github.com/h3js/crossws/tree/main/examples/socket.io) for a runnable demo including a browser client. :: --- # SSE > Integrate crossws with server-sent events and fetch-api. If your deployment target does not supports handling WebSocket upgrades, crossws SSE adapter allows to add integration based on web platform standards ([`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) and [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)) > [!IMPORTANT] > This is an experimental adapter and requires a custom [`WebsocketSSE`](#client-side) client to connect. ## Usage ### Server side > [!NOTE] > HTTP/2 + TLS is recommended in order to increase browser limitations about number of SSE connections (from 6 to 100) and also to allow bidirectional messaging with streaming. Define adapter: ```ts import sseAdapter from "crossws/adapters/sse"; const ws = sseAdapter({ bidir: true, // Enable bidirectional messaging support hooks: { upgrade(request) { // In case of bidirectional mode, extra auth is recommended based on request // You can return a new Response() instead to abort return { headers: {}, }; }, open(peer) { // Use this hook to send messages to peer peer.send(`Welcome ${peer}`); }, message(peer, message) { // Accepting messages from peer (bidirectional mode) console.log(`Message from ${peer}: ${message}`); // Message from : ping }, }, }); ``` Inside your web server handler: ```js 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") } ``` ### Using with Express or other Node.js frameworks The SSE adapter's `ws.fetch` is a Web-standard fetch handler: it takes a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and returns a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). Node.js frameworks like [Express](https://expressjs.com/) instead work with the raw Node.js `(req, res)` style, so you need a small bridge to convert between the two. Rather than manually converting streams with `Readable.toWeb`/`Readable.fromWeb` and copying over status/headers by hand, use the [`toNodeHandler`](https://srvx.h3.dev/guide/node) helper from [srvx](https://srvx.h3.dev/), which turns any Web-standard fetch handler into a plain `(req, res)` Node.js handler: ```js import express from "express"; import { toNodeHandler } from "srvx/node"; import sseAdapter from "crossws/adapters/sse"; const ws = sseAdapter({ hooks: { /* ... */ }, }); const app = express(); app.use((req, res, next) => { if ( req.headers.accept === "text/event-stream" || req.headers["x-crossws-id"] ) { return toNodeHandler((request) => ws.fetch(request))(req, res); } next(); }); ``` > [!NOTE] > This is not specific to Express: `toNodeHandler` only needs the raw Node.js `req`/`res` objects, so the same pattern works for any framework whose handler ultimately exposes them β€” Fastify's `request.raw`/`reply.raw`, NestJS running on top of its Express or Fastify adapter, or a plain `node:http` server. ### Client side In order to make communication with server, we need a special `WebsocketSSE` client. ```js import { WebsocketSSE } from "crossws/websocket/sse"; const ws = new WebsocketSSE("https://", { bdir: true }); ws.addEventListener("open", () => { ws.send("ping"); }); ws.addEventListener("message", (event) => { console.log("Received:", event.data); }); ``` > [!NOTE] > Behind the scenes, `WebSocketSSE`, uses [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) to receive messages from server. In order to send messages to the server, it tries to make another connection stream using same peer id and if failed, fallback to [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) for each message. > In theory, it is possible to have communication on a single HTTP/2 connection, however, due to a [current limitation in fetch standard](https://github.com/whatwg/fetch/issues/1254) we need 2 connections, one for receiving messages and one for sending. ::read-more See [`test/fixture/sse.ts`](https://github.com/h3js/crossws/blob/main/test/fixture/sse.ts) for demo and [`src/adapters/sse.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/sse.ts) for implementation. :: --- # Vercel > Integrate crossws with Vercel Functions that expose WebSocket upgrades. The Vercel adapter uses Vercel's request context to access the raw Node.js upgrade request, socket, and head. That platform detail stays private inside the adapter, so applications can keep using the normal crossws hooks API. > [!NOTE] > Vercel adapter is not generally available yet. ## Fetch-style handlers Use `handleWebUpgrade` from runtimes that receive a Web `Request` and return a `Response`. ```ts import crossws from "crossws/adapters/vercel"; const ws = crossws({ hooks: { open(peer) { peer.send("hello"); }, }, }); export default { async fetch(request) { const response = await ws.handleWebUpgrade(request); if (response) { return response; } return new Response("ok"); }, }; ``` ## Node-style handlers Use `handleNodeUpgrade` from Vercel Node.js handlers that receive an `IncomingMessage` and `ServerResponse`. ```ts import crossws from "crossws/adapters/vercel"; const ws = crossws({ hooks: { message(peer, message) { peer.send(message.text()); }, }, }); export default async function handler(req, res) { if (await ws.handleNodeUpgrade(req, res)) { return; } res.end("ok"); } ``` `handleWebUpgrade` returns `undefined` and `handleNodeUpgrade` returns `false` when the request is not a WebSocket upgrade or when Vercel's upgrade context is unavailable. When a WebSocket upgrade is handled, the returned fetch response or Node response status is `204`; the WebSocket handshake itself is completed on the raw upgrade socket. ::read-more See [`test/adapters/vercel.test.ts`](https://github.com/h3js/crossws/blob/main/test/adapters/vercel.test.ts) for a local simulation and [`src/adapters/vercel.ts`](https://github.com/h3js/crossws/blob/main/src/adapters/vercel.ts) for implementation. ::