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.
// https://crossws.h3.dev/adapters
import crossws from "crossws/adapters/<adapter>";
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):
let resolveWebSocketHooks = (req) => /* ... */
const websocket = crossws({
async resolve(req) {
return resolveWebSocketHooks(req)
},
});
// Update reference to `resolveWebSocketHooks` later.#Server plugin
When using the crossws/server 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 or the crossws property of the returned Response (the srvx convention for attaching WebSocket hooks):
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:
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: theSymbol.for("crossws.hooks")property on the request (see Attaching hooks to the request), and thecrosswsproperty of the value returned for the upgrade request. An error or redirect response with hooks on neither channel (any status that is not2xxor101, e.g.new Response("Unauthorized", { status: 401 })or a302) is sent back to the client and the handshake is rejected — handy for auth. The lone exception is a426carrying request hooks. A2xx(or101) 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 aResponse: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 }(noResponseneeded). Anyheadersare sent on the WebSocket handshake response: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 HTTPResponse's own headers are not applied to the handshake — use the{ crossws, headers }shortcut (or anupgradehook returning{ headers }) to set handshake headers.fetchruns per connection, so keep it side-effect-safe. Any auth, logging, metrics, or database work infetchnow executes once for every WebSocket upgrade. If a code path must run only for regular HTTP requests, branch on theupgradeheader first.Throwing/rejecting fails the handshake. If
fetchthrows (or rejects) on the upgrade request, the WebSocket handshake is rejected rather than left hanging. Throw aResponseto control the rejection; otherwise the client sees a500. To reject an upgrade gracefully, prefer returning a non-2xxResponse(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():
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:
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. A426with hooks on neither channel is still rejected, and logs a one-time warning naming the likely cause (its only other symptom is an opaqueUnexpected server response: 426on the client). - The
{ crossws, headers }shortcut still applies its handshakeheaderswhen the hooks came from the request.
Note
Both channels are read only by the default resolver. A user-supplied resolve bypasses them entirely.