Katman
Protocols

WebSocket

Bidirectional RPC over a persistent connection — ideal for real-time features and subscriptions.

Katman's default transport is HTTP — every procedure call is a separate request/response. WebSocket gives you a persistent connection instead. Messages skip the TCP handshake, which makes repeated calls faster and enables real-time server-to-client streaming.

When to use WebSocket

ScenarioRecommendation
Standard API callsHTTP (via serve() or handler())
High-frequency calls (many per second)WebSocket
Real-time subscriptionsWebSocket
Server-to-client pushWebSocket

For most APIs, HTTP is the right choice. WebSocket adds value when you need persistent connections or when you're making many calls per second and want to skip the per-request overhead.

Enable with serve()

If you're using serve(), add ws: true:

import {  } from "katman"

const  = ({ : () => ({}) })
const { , ,  } = 

const  = ({
  : (() => ({ : "ok" })),
  : (async function* () {
    for (let  = 5;  > 0; --) {
      yield { :  }
      await new (() => (, 1000))
    }
  }),
})

.(, {
  : 3000,
  : true,
})

HTTP and WebSocket share the same port. The server detects WebSocket upgrade requests and routes them to the WebSocket handler. Powered by crossws.

Standalone setup

If you have your own HTTP server (or an existing Fastify/Express app), attach WebSocket separately:

import {  } from "node:http"
import {  } from "katman/ws"

const  = (yourHttpHandler)
(, appRouter)
.(3000)

attachWebSocket listens for the upgrade event on the HTTP server and handles WebSocket connections.

Message protocol

Every WebSocket message is a JSON object. The id field links requests to responses, so the client can send multiple requests concurrently and match them up.

Request and response

The client sends a request with a path and optional input:

{ "id": "1", "path": "users/list", "input": { "limit": 10 } }

The server responds with the result:

{ "id": "1", "result": [{ "id": 1, "name": "Alice" }] }

Errors

If the procedure fails, the server sends an error:

{ "id": "2", "error": { "code": "NOT_FOUND", "status": 404, "message": "Procedure not found" } }

Streaming (subscriptions)

For procedures defined with k.subscription(), the server sends multiple data messages followed by a done signal:

{ "id": "3", "data": { "count": 5 } }
{ "id": "3", "data": { "count": 4 } }
{ "id": "3", "data": { "count": 3 } }
{ "id": "3", "data": { "count": 2 } }
{ "id": "3", "data": { "count": 1 } }
{ "id": "3", "data": null, "done": true }

The done: true message signals that the stream has ended.

Client example

Here's a basic client using the native WebSocket API:

const  = new ("ws://localhost:3000")

// Track pending requests by ID
const  = new <string, (: any) => void>()
let  = 1

function (: string, ?: unknown): <unknown> {
  return new ((, ) => {
    const  = (++)
    .(, () => {
      .()
      if (.error) (.error)
      else (.result)
    })
    .(.({ , ,  }))
  })
}

. = async () => {
  const  = await ("users/list", { : 10 })
  .("Users:", )
}

. = ({  }) => {
  const  = .()
  const  = .(.id)
  if () ()
}

Receiving streams

For subscriptions, you'll receive multiple messages with the same id:

ws.onmessage = ({  }) => {
  const  = .()

  if (.done) {
    .("Stream ended for", .id)
  } else if (.data !== ) {
    .("Stream data:", .data)
  } else if (.error) {
    .("Error:", .error)
  } else {
    .("Result:", .result)
  }
}

Binary WebSocket

Combine WebSocket with MessagePack for smaller messages. Pass binary: true when attaching:

import {  } from "katman/ws"

(server, appRouter, { : true })

With binary mode, messages are sent as MessagePack-encoded binary frames instead of JSON text. On the client side, you'd use msgpack to encode/decode instead of JSON.parse/JSON.stringify.

Binary WebSocket is especially useful for high-throughput scenarios where many small messages are sent per second. The combination of persistent connection + compact encoding gives you the lowest possible overhead.

What's next?

  • MessagePack — binary encoding for smaller payloads
  • devalue — rich type serialization for Date, Map, Set
  • Serverserve() options and lifecycle hooks
  • Procedures — defining subscriptions with k.subscription()

On this page