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
| Scenario | Recommendation |
|---|---|
| Standard API calls | HTTP (via serve() or handler()) |
| High-frequency calls (many per second) | WebSocket |
| Real-time subscriptions | WebSocket |
| Server-to-client push | WebSocket |
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 'silgi'
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 'silgi/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 s.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 'silgi/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
- Server —
serve()options and lifecycle hooks - Procedures — defining subscriptions with
s.subscription()