inkan

WebSockets

app.ws: a socket with a contract both ways, a typed client, origins checked, limits set.

A WebSocket route is a route: the upgrade request is routed, goes through onRequest, the security, the params, query and header checks, middleware and preHandler like a GET, and only once all of them let it through does it become a socket. A no answers with its status (400, 401, 404, …) and no socket. RFC 6455 is written on node:http's upgrade event, with no dependency.

const  = .("type", {
  : .({ : .("say"), : .().(200) }),
  : .({ : .("leave") }),
});
const  = .({ : .(["said", "joined"]), : .(), : .() });

.ws(
  "/rooms/:room",
  { : "A chat room", : .({ : .() }), : .({ : .() }), : , :  },
  (, ) => {
    void .send({ : "joined", : .params.room, : .query.name });
    .on("message", () => {
      if (.type === "leave") return .close(4000, "bye");
      void .send({ : "said", : .text, : .query.name });
    });
  },
);

message and send

message is what the client sends. With it, every message is JSON checked against it, typed in socket.on("message") and in for await (const m of socket). One that is not JSON, or breaks the contract, closes the socket with 1007 and the reason (the path and the rule, never the value sent); a binary frame where the contract wants JSON closes it with 1003.

send is what the server sends. socket.send(v) writes it as JSON with only what the contract lists, as a route's answer is trimmed. In development a value that breaks it throws (reported, and the socket closes with 1011).

Without message, text arrives as a string and binary as a Buffer, a copy of its own. sendRaw(data) sends text or bytes as they are, past the contract.

The socket

.ws("/feed", { : Tick }, async (, ) => {
  for (let  = 0; !.signal.aborted; ++) {
    await .send({  }); // resolves once the socket takes more
    await sleep(1000);
  }
});
send(value)JSON by the send contract. Resolves once the socket can take more, so await it in a loop that sends a lot; on a closing socket it does nothing
sendRaw(data)a string or bytes, as they are
close(code?, reason?)starts the closing handshake; default 1000, a reason of at most 123 bytes
on("message" | "close" | "error", fn), off(…)close gets the code and the reason
for await (const m of socket)the messages as they come, until it closes
readyState1 open, 2 closing, 3 closed
bufferedAmountbytes written but not yet handed to the network

ctx is the upgrade request's context, with its params, query, headers, decorations and state. ctx.signal aborts when the socket closes. Messages that come before the handler listens wait for it. A listener that throws is reported and closes the socket with 1011.

The typed client

const  = shop.ws("/rooms/:room", { : { : "tea" }, : { : "ana" } });
.on("message", () => .type === "said" && show(.text)); // narrowed by the route's send
await .ready;                                           // rejects when it closes first (a 401, a 404)
.send({ : "say", : "hello" });                  // typed by the route's message

for await (const  of ) if (.type === "joined") greet(.from); // leaving the loop closes it

client.ws(path, { params, query }) opens a WebSocket route with the platform's WebSocket: http becomes ws, https wss. In Node, headers go with the upgrade; a browser sends none. raw is the socket underneath, and { WebSocket } in the client's options puts in another class. OpenAPI lists a WebSocket route as a GET with a 101 and an x-inkan-websocket extension holding both schemas; the docs page shows it as WS.

Who may open it: origins

A browser sends the user's cookies with a WebSocket upgrade from any page, and the same-origin policy does not stop that page from reading the answers. Without a check, a site the user happens to visit can open your socket as the user and talk on it: cross-site WebSocket hijacking. So inkan checks the Origin header browsers send before any hook runs. By default only the app's own origin (the Host header and ctx.protocol) may open the socket, and a request without an Origin, which is not a browser, goes through. Anything else is a 403 origin-not-allowed.

.("/live", { : ["https://shop.example"] }, live);            // the own origin, and these
.("/live", { : (, ) => ?.(".shop.example") ?? true }, live); // decides alone
.("/public", { : "*" }, feed);                               // every page

({ : { : ["https://admin.shop.example"] } });             // for every route without its own

A function gets the request's Origin and a PreContext: the request before hooks and input checks (method, path, headers, protocol, ip), so it cannot read decorations. The same Origins type serves cors({ origins }). Origins are compared as URL origins: http://X:80 is http://x.

Options

optiondefault
params, query, headerschecked on the upgrade request, as for a GET
message, sendthe contracts, above
security, use, metaas for any route: credentials, middleware, free-form meta
originsthe app's ws.origins, else the own originabove
maxMessage1048576 (1 MiB)bytes one message may have, fragments together; past it, 1009
heartbeat30000ms between pings; a peer that has not answered the last one by the next is cut off. false for none
maxConnectionsnonesockets this route holds at once, handshakes on the way counted; past it the upgrade is a 503 too-many-connections
handshakeTimeout10000ms the upgrade request has to get its answer (hooks, security, middleware) before the connection is cut
summary, description, tags, operationId, deprecated, hiddenfor the docs and OpenAPI

The app's ws option sets the defaults for every route that does not set its own:

({ : { : ["https://admin.shop.example"], : 64 * 1024, : 15_000, : 5_000 } });

Before any hook, too: an upgrade that is not a GET over HTTP/1.1 without a body is a 400, and sec-websocket-key has to be canonical base64. A plain request to a WebSocket path is a 426.

Close codes

codewhen
1001the server stops: on SIGINT or SIGTERM every socket is closed with it
1002a protocol error: an unmasked frame from the client, reserved bits, an unknown opcode, a bad close frame
1003binary where the message contract wants JSON text
1007text that is not UTF-8, a message that is not JSON or breaks message
1008more than 4 × maxMessage (1 MiB at least) waits to be written: the peer does not read, and is cut off
1009a message past maxMessage, or in more than 1024 frames
1011a listener threw, or in development send was handed a value that breaks its contract

Your own codes go from 4000 to 4999, as socket.close(4000, "bye") above.

Limits

  • No permessage-deflate. No extension is negotiated, so every frame goes as it is. No subprotocol is chosen either; a hook may set sec-websocket-protocol.
  • Not on app.fetch. Bun, Deno and serverless platforms upgrade their own way. listen listens for upgrades when the app has a WebSocket route; a server of your own takes server.on("upgrade", app.upgradeListener), and @inkanjs/uws runs them on uWebSockets.js.
  • A message comes in at most 1024 frames, each after the first counted 64 bytes against maxMessage; text is checked as UTF-8 frame by frame. A peer that floods pings without reading gets only its latest one answered.
  • Per process. With workers, each process holds its own sockets. Broadcast with a Set of sockets per process, or through a bus the processes share: the publish/subscribe of a shared Store, Redis pub/sub, say:
import type { ,  } from "@vxnsin/inkan";

const  = new <<<typeof Say>, <typeof Said>>>();

.ws("/room", { : Say, : Said }, () => {
  .();
  .on("close", () => .());
  .on("message", () => {
    if (.type === "say") .(() => void .send({ : "said", : .text, : "someone" }));
  });
});

On this page