Silgi
Protocols

WebSocket

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

Silgi'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.

Enabling WebSocket on procedures

Every procedure is reachable over WebSocket automatically — no flag or configuration required. Queries, mutations, and subscriptions all work over the same connection.

import {  } from 'silgi'

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

// Query — reachable via both HTTP and WebSocket
const  = .(({  }) => .db.users.findMany())

// Subscription — streams to the client as an async iterator over WebSocket
const  = .().(async function* () {
  for (let  = 5;  > 0; --) {
    yield { :  }
    await new (() => (, 1000))
  }
})

Server setup

s.serve() is the universal entry point. WebSocket support is enabled automatically whenever the router contains at least one subscription procedure. The same code works on Node, Bun, and Deno — silgi detects the runtime and wires the right crossws adapter under the hood.

s.serve(appRouter, { : 3000 })
// HTTP on http://127.0.0.1:3000
// WebSocket on ws://127.0.0.1:3000/_ws (if router has subscriptions)

Pass ws: false to disable auto-WS, or ws: { compress, keepalive, maxPayload } to tune crossws options.

With handler() — Nitro, Nuxt (zero config)

When you use s.handler(appRouter) as your entry point, WebSocket support is automatic through srvx's /_ws convention. No extra route file needed.

server.ts
import {  } from './rpc/instance'
import {  } from './rpc/router'

export default {
  : .handler(),
}

For Nuxt, enable the Nitro WebSocket feature in nuxt.config.ts:

export default defineNuxtConfig({
  nitro: { features: { websocket: true } },
})

Subscriptions in your router are reachable over ws://host/_ws out of the box.

With attachWebSocket — Express, raw Node HTTP (escape hatch)

Only needed when you already manage your own Node http.Server (Express, Fastify .server, raw Node HTTP). Prefer s.serve() when you can.

import {  } from 'silgi/ws'

// Express
const  = app.listen(3000)
await (, appRouter)

// Raw Node HTTP
import {  } from 'node:http'
const  = (httpHandler)
await (, appRouter)
.listen(3000)

attachWebSocket accepts a context factory via the context option. The factory receives the Peer object:

import {  } from 'silgi/ws'

await (server, appRouter, {
  : () => ({
    : .?..('x-user-id'),
  }),
})

When a client disconnects, all in-flight requests for that peer are automatically aborted.

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 or the path does not exist, the server sends an error:

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

Streaming (subscriptions)

For subscription procedures, 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

Use the built-in WSLink for a type-safe WebSocket client with automatic connection management:

import {  } from 'silgi/client'
import {  } from 'silgi/client/ws'

const  = new ({ : 'ws://localhost:3000/ws' })
const  = <>()

// Use it like any other client — connection is established on first call
const  = await .users.list({ : 10 })

// Clean up when done
.()

WSLink handles connection lifecycle automatically — it connects on the first call, multiplexes concurrent calls over a single WebSocket, and transparently returns an AsyncIterableIterator for subscription procedures:

const  = await client.clock()
for await (const  of ) {
  .(.count)
}
OptionTypeDefaultDescription
urlstring | URL(required)WebSocket server URL
protocol'json' | 'messagepack''json'Wire protocol
WebSockettypeof WebSocketglobalThis.WebSocketCustom WebSocket constructor

Native WebSocket

For custom scenarios, 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. Set protocol: 'messagepack' when setting up:

import {  } from 'silgi/ws'

await (server, appRouter, { : 'messagepack' })

With MessagePack protocol, messages are sent as binary WebSocket 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.

OpenAPI

WebSocket-enabled procedures are documented in the OpenAPI spec with an automatic note in the description field. Scalar and other OpenAPI viewers will display this alongside the regular HTTP documentation.

OpenAPI 3.x does not have a native WebSocket specification. When Scalar adds AsyncAPI support, Silgi will generate AsyncAPI specs for WebSocket procedures.

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 s.subscription()

On this page