Skip to content

pdkovacs/wsgw

Repository files navigation

wsgw — WebSocket gateway

Stateful WebSocket connections don't compose well with stateless backends — they pin clients to specific instances and turn horizontal scaling into a routing problem. wsgw lifts that one concern out of the application: it owns the WebSocket connections so the backend can stay stateless, and it speaks plain HTTP to the backend in both directions.

It's a small, focused service for low- to medium-workload deployments that want to scale the application horizontally without taking on the operational weight of a heavyweight gateway.

How it works

Client                  wsgw                              Backend
  |                      |                                   |
  |--- GET /connect ---->|--- GET /ws/connect/{id} -------->|
  |                      |<------------ 200 OK --------------|
  |<-- 101 + header id --|                                   |
  |                      |                                   |
  |---- WS frame ------->|--- POST /ws/message/{id} ------->|
  |                      |<------------ 200 OK --------------|
  |                      |                                   |
  |                      |<-- POST /message/{id} ------------|
  |<--- WS frame --------|------------- 204 ----------------->|
  |                      |                                   |
  |--- WS close -------->|--- POST /ws/disconnected/{id} -->|
  1. Client opens a WebSocket: GET /connect.
  2. wsgw generates a connection ID and forwards the request as a regular HTTP GET to the backend's /ws/connect/{id} for authentication.
  3. Backend returns 200 → wsgw upgrades the HTTP connection to a WebSocket and (optionally) returns the assigned connection ID in a response header.
  4. Client → backend: wsgw forwards each WS frame to the backend as POST /ws/message/{id}.
  5. Backend → client: backend POSTs to wsgw's /message/{id}; wsgw forwards the body over the WebSocket.
  6. Either side closes the WS → wsgw notifies the backend via POST /ws/disconnected/{id} (best-effort).

Quick start

task build           # build ./cmd/wsgw
task test-all        # run integration suites
task watch           # rebuild + restart on file changes (needs fswatch)

To run a single instance against a local backend listening on :45678:

export WSGW_SERVER_PORT=45679
export WSGW_APP_BASE_URL=http://localhost:45678
export WSGW_ACK_NEW_CONN_WITH_CONN_ID=true
./cmd/wsgw

A working backend implementation suitable for poking at locally lives in test/mockapp/ (Go) and the pdkovacs/wsgw-node-ref companion repository (Node.js).

Endpoint reference

Provided to clients and backends

Method Path Purpose
GET /connect Client opens a WebSocket. Returns 101 on success, 401 if the backend rejects auth, 500 otherwise. The X-WSGW-CONNECTION-ID response header carries the connect-ack (see below) when WSGW_ACK_NEW_CONN_WITH_CONN_ID=true.
POST /message/{connectionId} Backend sends a message to a specific client. Body is opaque (delivered to the WebSocket as-is). Returns 204 on success, 404 if the connection is unknown, 503 if the per-connection buffer is saturated, 400/500 on input/internal errors.
GET /app-info Build/version info.

Expected from the backend

The backend must serve three endpoints under whatever base URL is configured via WSGW_APP_BASE_URL:

Method Path Purpose
GET /ws/connect/{connectionId} Authenticate a new connection. Return 200 to accept, 401 to reject, anything else is treated as an internal error. The original client headers (including Authorization) are passed through.
POST /ws/message/{connectionId} Receive a frame the client sent. Return 200 to acknowledge; a non-200 response causes wsgw to forward the response body back to the client over the WebSocket.
POST /ws/disconnected/{connectionId} Notification that a client disconnected. Best-effort: wsgw does not retry, and the response status is logged but not acted on.

Headers and protocol notes

  • Connection ID — a path parameter on every wsgw↔backend leg (/ws/connect/{id}, /ws/message/{id}, /ws/disconnected/{id}), just as on the backend→wsgw leg (/message/{id}). It is never carried in a header on these legs.
  • Authorization — passed through from the client's GET /connect to the backend's GET /ws/connect/{id} unchanged. wsgw does no auth itself.
  • X-WSGW-CONNECTION-ID — set by wsgw only on the GET /connect response to the client (the 101 handshake), when WSGW_ACK_NEW_CONN_WITH_CONN_ID=true. Carries the gateway-assigned connection ID for clients that need it for out-of-band correlation. Note that browsers cannot read headers on the 101 handshake response; this is intended for native/non-browser clients.
  • Per-connection rate limiting — incoming client frames are rate-limited at 1 msg / 100 ms with a burst of 8, with a 1024-message buffer. Sustained overload causes the backend's POST /message/{id} to receive 503.

Configuration

All configuration is via environment variables, prefixed WSGW_.

Variable Default Description
WSGW_SERVER_HOST "" (all interfaces) Bind address.
WSGW_SERVER_PORT Listening port. Required.
WSGW_APP_BASE_URL Base URL of the backend (e.g. http://app:8080). Required.
WSGW_HTTP2 false Enable H2C between wsgw and the backend.
WSGW_ACK_NEW_CONN_WITH_CONN_ID false Return the connection ID as the X-WSGW-CONNECTION-ID header on the GET /connect response.
WSGW_LOAD_BALANCER_ADDRESS "" Allowed Origin for the WS handshake. Slated for removal.
WSGW_OTLP_ENDPOINT "" OTLP/HTTP exporter endpoint for traces & metrics. Empty disables OTel export.
WSGW_OTLP_SERVICE_NAMESPACE "" OTel service.namespace resource attribute.
WSGW_OTLP_SERVICE_NAME wsgw OTel service.name resource attribute.
WSGW_OTLP_SERVICE_INSTANCE_ID hostname OTel service.instance.id resource attribute.
WSGW_OTLP_TRACE_SAMPLE_ALL false Sample every trace (otherwise the SDK default).

Observability

wsgw is instrumented with OpenTelemetry traces and metrics, exported via OTLP/HTTP (set WSGW_OTLP_ENDPOINT). Notable metrics include active connections, deliveries, read/write errors, and per-connection backpressure. Traces cover the connect, push, and disconnect paths.

Logs are structured JSON via zerolog. A LogQL example for the test/e2e/ harness:

{filename="/mnt/workspace/logs/e2e-app"} | json | line_format `{{.time}} [{{.method}}] {{.message}} {{.req_url}}`

Deployment

Reference Kubernetes manifests live in deploy/k8s/. The image and k8s tasks in taskfile.yaml build the container image and apply the manifests against a local cluster (minikube friendly).

Reference implementations

Two working backends demonstrate the contract end-to-end:

  • test/mockapp/ — Go, used by the integration suite. Useful as a precise spec.
  • pdkovacs/wsgw-node-ref — Node.js companion repo. More accessible if you're not coming from Go.

Non-goals

  • Authentication. Delegated entirely to the backend's /ws/connect.
  • TLS termination. Expected to be handled by a load balancer or sidecar.
  • Message persistence or delivery guarantees. Frames not delivered to the WebSocket (closed connection, overloaded buffer) surface as HTTP errors to the backend; retry/durability is the backend's concern.
  • Horizontal scaling of wsgw itself. wsgw is intended to run as a single instance per application. Scale the application; treat wsgw as a small piece of stateful glue.

Status

Early-stage. The wire contract is stable enough for the integration and e2e suites; expect breaking changes elsewhere. Built with Go 1.25. Released under the MIT License — see LICENSE.

About

Service providing websocket functionality for clustered applications with stateless backends

Resources

License

Stars

0 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors