feat(embeddednats): optional WebSocket listener for browser clients

Add WebsocketConfig to ServerConfig so the embedded nats-server can expose an
additional WebSocket port (nats.ws) alongside the TCP data plane. This lets a
browser SPA speak the NATS protocol directly, the way native TCP peers (Go,
Kotlin/android) already do — the first enabler for uniweb becoming a
browser-native client with no Go gateway (issue uniweb/0001, Phase 0).

The client authenticator applies to WebSocket connections too, so this adds a
transport, not a trust bypass. Plain ws:// is used only without TLS (loopback
dev); a certificate yields wss://. An empty AllowedOrigins enforces same-origin.
Nil WebsocketConfig keeps the server TCP-only, so existing single-node and
cluster deployments are unchanged.

Tests: WebSocket listener opens and completes the upgrade handshake (101); no
listener opens when WebsocketConfig is nil.
This commit is contained in:
2026-06-13 22:11:39 +02:00
parent 9661a5ce1f
commit 36f4ba0eaf
2 changed files with 167 additions and 0 deletions
+59
View File
@@ -79,6 +79,42 @@ type ServerConfig struct {
// availability (issue 0003a). Nil keeps the server standalone (the legacy
// single-node behavior).
Cluster *ClusterConfig
// Websocket, when non-nil, opens an ADDITIONAL WebSocket listener on the
// embedded nats-server so browser clients (nats.ws) can reach the data plane
// directly, the same way native TCP peers (Go, Kotlin) do (issue uniweb/0001).
// Native TCP clients are unaffected: the WebSocket listener is a separate port
// layered on top of the existing TCP listener, and the client authenticator
// (Auth) applies to both. Nil keeps the server TCP-only (legacy behavior).
Websocket *WebsocketConfig
}
// WebsocketConfig configures the embedded nats-server's WebSocket listener so a
// browser can speak the NATS protocol over ws://. A browser cannot open a raw TCP
// socket, so this is the only way the SPA reaches the data plane without a Go
// gateway in between.
//
// Security: off loopback a browser requires wss:// (TLS) — set TLS with a
// certificate the browser trusts. NoTLS plain ws:// is acceptable only for a
// loopback dev stack. The WebSocket upgrade also enforces an Origin allowlist
// (browser same-origin policy); AllowedOrigins must list the SPA's origins or the
// browser handshake is refused.
type WebsocketConfig struct {
// Host is the bind interface for the WebSocket listener; "" lets nats-server
// pick its default. Use "127.0.0.1" to keep it loopback-only in dev.
Host string
// Port is the WebSocket listen port (e.g. 8480). Required (non-zero) for the
// listener to open.
Port int
// NoTLS serves plain ws:// instead of wss://. Loopback/dev only: browsers refuse
// ws:// to a non-loopback origin. Ignored when TLS is set (TLS implies wss://).
NoTLS bool
// TLS, when set, serves wss:// with this certificate. Required for any browser
// origin that is not loopback.
TLS *tls.Config
// AllowedOrigins is the allowlist of browser Origin headers permitted to upgrade
// the WebSocket. Empty = same-origin only (nats-server SameOrigin). Never use a
// wildcard in production; list the exact SPA origins.
AllowedOrigins []string
}
// Start is a thin backward-compatible wrapper: embedded JetStream server on the
@@ -170,6 +206,29 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
opts.TLS = true
}
if cfg.Websocket != nil {
// Layer a WebSocket listener on top of the TCP data plane so browser
// clients (nats.ws) can connect. The client authenticator (opts.*Auth above)
// applies to WebSocket connections too, so a browser still has to pass the
// nkey + allowlist check; this only adds a transport, not a trust bypass.
ws := server.WebsocketOpts{
Host: cfg.Websocket.Host,
Port: cfg.Websocket.Port,
AllowedOrigins: cfg.Websocket.AllowedOrigins,
}
if cfg.Websocket.TLS != nil {
ws.TLSConfig = cfg.Websocket.TLS
} else {
// No certificate: plain ws:// (loopback/dev only). Browsers refuse this
// off-loopback, which is the intended guard rail.
ws.NoTLS = true
}
// Empty AllowedOrigins means "same-origin only": tell nats-server to enforce
// it rather than defaulting to accept-any-origin.
ws.SameOrigin = len(cfg.Websocket.AllowedOrigins) == 0
opts.Websocket = ws
}
if cfg.Cluster != nil {
if err := applyClusterOpts(opts, cfg.Cluster); err != nil {
return nil, err