gateway

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 42 Imported by: 0

README

goakt-gateway

Cluster-aware ingress tier for GoAkt: a WebSocket/SSE connection registry with two-tier delivery, cluster-wide presence, and cluster-shared TLS termination (Cloudflare Origin CA, Authenticated Origin Pulls).

What it is

goakt-gateway turns a GoAkt cluster into its own ingress tier - the role an nginx/caddy front-tier normally plays in front of a stateless HTTP fleet - for two things specifically: giving long-lived WebSocket/SSE connections an addressable identity so any node can deliver a message to them, and terminating TLS for domains shared cluster-wide from a certificate arbitrated so that, as long as issuance completes within the configured lock TTL, at most one process issues it (see Cluster-shared TLS for the lock-TTL caveat).

Why it lives outside the actor core

This is a satellite library, not part of GoAkt itself, on purpose. A WebSocket gateway is an opinionated shape on top of the actor system - a particular registry design, a particular delivery model, a particular certificate-issuance workflow - and GoAkt's core has no opinion on any of that. Keeping it as a separate module means:

  • GoAkt's dependency footprint does not grow for users who never touch this;
  • this library can iterate and version independently of GoAkt's release cadence;
  • it is held to the same bar as any third-party consumer of GoAkt: it depends on GoAkt exclusively through the public actor API (Spawn, ActorOf, TopicActor, Subscribe/Unsubscribe, ScheduleWithCron, ...) - nothing here reaches into a GoAkt internal package, which is both proof that the public API is sufficient for this kind of integration and a guarantee that upgrading GoAkt does not silently break this library through an internal it was never supposed to depend on.

Two design boundaries drive every type in this package:

  • Plain HTTP requests are ordinary net/http handlers. Nothing in this package puts an actor in the path of a request/response cycle that lives for a few milliseconds - that would be pure overhead.
  • Only long-lived WebSocket/SSE connections get an addressable identity. Each accepted connection is registered in a local Registry and backed by a lightweight ephemeral actor (relocation disabled, long-lived passivation) whose only job is to relay a delivery to the socket it owns when another node needs to reach it.

Install

go get github.com/StringKe/goakt-gateway

Requires Go 1.26.5 and github.com/tochemey/goakt/v4 v4.3.1 or later. WebSocket support is built on github.com/coder/websocket.

Quickstart

A complete, runnable WebSocket echo server plus an HTTP handler that pushes a message into a registered connection - the "HTTP handler talks to a websocket connection" pattern the whole package exists for. A longer version of this lives in examples/echo.

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/tochemey/goakt/v4/actor"
	golog "github.com/tochemey/goakt/v4/log"

	gateway "github.com/StringKe/goakt-gateway"
)

func main() {
	ctx := context.Background()

	system, err := actor.NewActorSystem("gateway-echo", actor.WithLogger(golog.DiscardLogger))
	if err != nil {
		log.Fatal(err)
	}
	if err := system.Start(ctx); err != nil {
		log.Fatal(err)
	}
	defer func() { _ = system.Stop(ctx) }()

	registry := gateway.NewRegistry(system, golog.DiscardLogger)
	defer func() { _ = registry.Close(ctx) }()

	mux := http.NewServeMux()

	mux.Handle("/ws", gateway.NewWSHandler(registry,
		gateway.WithWSAuth(func(r *http.Request) (*gateway.ConnInfo, error) {
			// Resolve the caller's identity once, here. Whatever this returns is what
			// the connection is registered as and what every callback below receives.
			return &gateway.ConnInfo{ID: r.URL.Query().Get("id")}, nil
		}),
		gateway.WithWSOnMessage(func(ctx context.Context, info *gateway.ConnInfo, payload []byte) {
			// The connection is always local to this process here, so
			// SendToConnection takes the direct-write fast path: no actor, no
			// cluster lookup.
			_ = registry.SendToConnection(ctx, info.ID, payload)
		}),
	))

	mux.HandleFunc("/send", func(w http.ResponseWriter, r *http.Request) {
		id := r.URL.Query().Get("id")
		if err := registry.SendToConnection(r.Context(), id, []byte(r.URL.Query().Get("msg"))); err != nil {
			http.Error(w, err.Error(), http.StatusNotFound)
			return
		}
		w.WriteHeader(http.StatusOK)
	})

	server, err := gateway.NewServer("127.0.0.1:8080", mux)
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(server.ListenAndServe(ctx))
}

Run it, connect a WebSocket client to ws://127.0.0.1:8080/ws?id=alice, then from another terminal:

curl "http://127.0.0.1:8080/send?id=alice&msg=hello-from-http"

The message shows up on the WebSocket connection - delivered by an ordinary HTTP handler, with no actor or cluster machinery involved because the connection is local.

Registry and two-tier delivery

Registry.SendToConnection, Registry.SendToGroup, and Registry.Broadcast all follow the same shape:

  1. Local hit: write directly. If the target connection (or, for group/topic fan-out, a member) is registered on this node, the payload is written straight to the socket.
  2. Local miss: cluster fallback. Only when the connection is not held locally does Registry resolve it through the cluster-aware actor directory (ActorSystem.ActorOf) and deliver remotely. For group and topic fan-out, remote members are reached through a small pub/sub bridge (see below) built on GoAkt's public actor.Subscribe/actor.Unsubscribe messaging.

Cross-node messaging - the only scenario that needs any cluster machinery at all - is opt-in by construction: a single-node deployment never touches ActorSystem.ActorOf or the topic actor.

Fan-out calls return a DeliveryResult rather than a bare error, because "did anything happen?" is the question a caller actually needs answered:

type DeliveryResult struct {
    Delivered int // written into a local connection's outbound buffer
    Dropped   int // a local connection had a full buffer, the message was dropped
    Remote    int // fanned out to remote nodes (whether the socket write happened is unknowable from here)
}

func (d DeliveryResult) Total() int  // Delivered + Dropped + Remote
func (d DeliveryResult) None() bool  // Total() == 0

None() is the signal to fall back to an offline channel (web push, email, a stored inbox) - see Groups and presence.

The pub/sub bridge

GoAkt's fork-only ActorSystem.SubscribeTopic convenience is not part of the public API, so Registry's topic fan-out is built from scratch on primitives that are: it spawns its own small bridge actor per topic (actor.ActorSystem.Spawn), sends the public actor.Subscribe message to system.TopicActor(), and forwards whatever the topic actor delivers back to Registry's local topic members. The bridge is torn down with actor.Unsubscribe once the last local member leaves the topic. See bridge.go.

Because groups and topics both ride this bridge, registering a connection with a group or with topics requires an actor system started with actor.WithPubSub(). A bridge that cannot be established fails Register/Join with ErrPubSubUnavailable rather than silently registering a connection that will never receive a broadcast.

Groups and presence

Topics are subscriptions. Groups are identities. They are deliberately different things:

Topic Group
Means "this connection is interested in room:42" "this connection is user:123"
Cardinality a connection joins many a connection has exactly one
Set with WithConnTopics("room:42", ...) WithConnGroup("user:123")
Fan out with Broadcast(ctx, topic, payload) SendToGroup(ctx, group, payload)
Answers "is X online?" no yes, via IsOnline

A group is how you address the human rather than the socket: one identity typically has several live connections (a phone, a laptop, three browser tabs), and SendToGroup fans out to all of them, everywhere in the cluster.

registry := gateway.NewRegistry(system, logger,
    gateway.WithPresence(presence),          // cluster-wide; omit and you only know about this node
    gateway.WithPresenceTTL(30*time.Second), // default; Registry refreshes in the background at ttl/3
)
defer registry.Close(ctx) // stops the presence refresh goroutine

res, err := registry.SendToGroup(ctx, "user:123", payload)
if err != nil {
    return err
}
if res.None() {
    // Nobody is holding a socket for this identity anywhere in the cluster.
    // This is the moment to fall back to an offline channel.
    return webpush.Send(ctx, "user:123", payload)
}
Presence

Presence is the cluster-wide answer to "which connection ids does this identity currently have, and where?". It is an interface with a lease-based (TTL'd) model, and it is optional:

  • No Presence (the default): IsOnline and LocalConnectionsOf only see this node's connections. SendToGroup still reaches the whole cluster (the group bridge is a topic under the hood), but DeliveryResult.Remote can only report "one cluster fan-out happened", not how many remote members received it - so None() is not a reliable offline signal.
  • NewMemoryPresence(): process-local. Correct for single-node deployments and tests.
  • presence/redis.NewPresence(client): backed by Redis or Valkey, shared by every node. This is the configuration in which IsOnline and DeliveryResult.None() become cluster-aware rather than single-node. It is a separate package so importing the root module never pulls in github.com/redis/go-redis/v9 for applications that do not want it. See Redis / Valkey backends for how it shares one instance with the other three backends. Note that presence membership is a lease-based estimate: a node that has just crashed keeps its members visible for up to one TTL, so with presence alone None() can miss a genuinely offline identity for that window. Add WithDeliveryConfirmation when you need None() to be exact - it counts acknowledged remote writes instead of trusting the lease, at the cost of making the offline fallback at-least-once (a slow node that acks late may get both an in-app and an offline copy).
import (
	"github.com/redis/go-redis/v9"

	gateway "github.com/StringKe/goakt-gateway"
	gatewaypresence "github.com/StringKe/goakt-gateway/presence/redis"
)

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
registry := gateway.NewRegistry(system, logger,
    gateway.WithPresence(gatewaypresence.NewPresence(client)),
)

The Redis implementation models one group as one ZSET whose members are connection ids scored by absolute lease expiry, so a single Lua script both sweeps expired members and returns a consistent snapshot, all against a single key (and therefore a single Redis Cluster slot). The trade-offs are spelled out in that package's doc comment.

Because leases expire, presence is eventually consistent: a node that dies without unregistering leaves its connection ids visible for up to one TTL. IsOnline returning true therefore means "someone recently held a socket for this identity", not "a socket is guaranteed writable right now". Treat it as a routing hint; treat DeliveryResult as the truth.

Backpressure

Every registered connection has a bounded outbound buffer (WithWSSendBuffer / WithSSESendBuffer, default 256). A slow consumer that stops reading fills it. The library refuses to let that block the producer - a broadcast to 10,000 connections must not stall on the one client with a stalled TCP window - so a full buffer is a decision point, and BackpressurePolicy is how you make it:

Policy Behavior Use when
BackpressureDrop (default) Drop the message. SendToConnection returns ErrBackpressure; the message is counted in DeliveryResult.Dropped and reported to Observer.DeliveryDropped. The connection stays up. Messages are snapshots/notifications, and losing one is better than losing the client.
BackpressureClose Close the connection. The client is expected to reconnect and resynchronize. The stream is an ordered log where a hole is corruption, and a client that cannot keep up is better off starting over.
gateway.NewWSHandler(registry,
    gateway.WithWSSendBuffer(1024),
    gateway.WithWSBackpressurePolicy(gateway.BackpressureClose),
)

There is no third "block until there is room" option, and there will not be: it converts one slow client into a stalled fan-out for everyone.

Inbound pressure is separate and off by default: WithWSReadLimit (default 1 MiB) caps a single message, and WithWSInboundRate(perSecond, burst) rate-limits a connection's inbound message rate (ErrRateLimited).

Observability

Observer is an optional hook set. Every method may be called from a connection's goroutine, so implementations must be cheap and non-blocking (increment a counter, do not write to a database). A nil Observer is the default and every call site checks for it.

type Observer interface {
    ConnectionRegistered(id, group string)
    ConnectionUnregistered(id, group string)
    ConnectionReplaced(id, group string)      // a reconnect took over an existing id
    DeliveryDropped(id, group string)         // backpressure
    DeliveryFailed(id string, err error)
    BroadcastFanout(topic string, localMembers int)
}

registry := gateway.NewRegistry(system, logger, gateway.WithObserver(myMetrics))

The two worth alerting on: a rising DeliveryDropped rate means send buffers are too small or consumers are too slow, and a rising ConnectionReplaced rate means clients are reconnecting in a loop.

WebSocket and SSE listeners

NewWSHandler and NewSSEHandler return ordinary http.Handler values:

mux.Handle("/ws", gateway.NewWSHandler(registry,
    gateway.WithWSAuth(func(r *http.Request) (*gateway.ConnInfo, error) {
        claims, err := verifyBearerToken(r)
        if err != nil {
            return nil, err // rejects the handshake with 403
        }
        return &gateway.ConnInfo{
            ID:     claims.DeviceID,               // unique per socket
            Group:  "user:" + claims.UserID,       // the identity behind the socket
            Topics: []string{"room:" + roomOf(r)},
            Meta:   map[string]string{"tenant": claims.Tenant},
        }, nil
    }),
    gateway.WithWSOriginPatterns("app.example.com"),
    gateway.WithWSOnMessage(handleInbound),
))
mux.Handle("/events", gateway.NewSSEHandler(registry,
    gateway.WithSSEAuth(authenticate),
    gateway.WithSSEHistory(gateway.NewMemorySSEHistory(64)),
))

WSAuthFunc/SSEAuthFunc resolve the caller's identity exactly once, at handshake time, and everything downstream (registration, OnConnect, OnMessage, OnDisconnect) is handed the resulting *ConnInfo. WithWSIDFunc/WithWSTopics remain only as fallbacks for the fields auth left empty; an empty ConnInfo.ID gets a generated UUID.

Concern How it's covered
Auth WithWSAuth/WithSSEAuth run during the handshake/request; a non-nil error rejects the connection.
Origin Same-host only by default (coder/websocket's default). Widen with WithWSOriginPatterns, or disable with WithWSInsecureSkipOriginCheck - which is exactly as dangerous as it sounds, because a browser will then happily let any site open an authenticated socket to you with the user's cookies.
Liveness WithWSPingInterval (default 30s) plus WithWSPongTimeout (default 10s) detect a half-open TCP connection that would otherwise linger for minutes.
Reconnect takeover A reconnect with an already-registered id takes over: the old registration is replaced and its socket closed. This is the default precisely because of half-open TCP - the alternative locks a user out of their own account until the dead socket times out. Because takeover is on by default, the connection id is a security boundary: derive it from an authenticated principal (in WithWSAuth/WithSSEAuth), never from an unauthenticated request field, or a client can kick and impersonate any other connection by supplying its id.
Backpressure See Backpressure.
Clean shutdown The connection is always unregistered (and its ephemeral actor stopped) when the socket closes.
Grouping WithConnGroup for identity, WithConnTopics for subscriptions. Both require actor.WithPubSub().
SSE resume

SSE is one-way (server to client); pair it with an ordinary HTTP endpoint for inbound data. In exchange it gets the browser's built-in reconnect, and WithSSEHistory makes that reconnect lossless over the window that matters:

mux.Handle("/events", gateway.NewSSEHandler(registry,
    gateway.WithSSEHistory(gateway.NewMemorySSEHistory(64)), // retain 64 events per connection
    gateway.WithSSERetry(3*time.Second),                     // "retry:" hint to the browser
))

On reconnect the browser sends Last-Event-ID; the handler replays everything after it. If that id is no longer retained, the client is told so explicitly with a gateway-gap event (SSEGapEventName) rather than silently resuming from a hole, so the application can resynchronize from its own source of truth.

Two limits worth internalizing. MemorySSEHistory is per-process, so replay only works if the client reconnects to the same node. ssehistory/redis.New(client) removes that limit: it records every connection's buffer in a shared Redis or Valkey backend, so a client whose EventSource reconnects to any node in the deployment is replayed correctly instead of being told the history is gone. It is wired the same way - WithSSEHistory(ssehistory/redis.New(client)) - and is one of the five interchangeable Redis / Valkey backends. And history records what was written toward a registered connection: it covers the real, often-long window between a socket dying and the server noticing, not an arbitrary offline period. For a genuinely offline client, SendToGroup returns None() and the offline channel is the right answer.

Cluster-shared TLS

Manager lets every process terminate TLS for any hosted domain from one certificate, with issuance arbitrated across every process that shares its Coordinator so that, as long as your CertIssuer completes within the configured issuance lock TTL, at most one process calls it:

manager := gateway.NewManager(actorSystem, logger,
    gateway.WithCertIssuer(issuer),
    gateway.WithCoordinator(coordinator),      // optional; defaults to an in-memory, process-local one
    gateway.WithCertStore(myPersistentStore),  // optional; defaults to in-memory
    gateway.WithAllowedDomains("chat.example.com", "*.tenants.example.com"),
    gateway.WithRenewBefore(30*24*time.Hour),  // default
)

if err := manager.Start(ctx); err != nil { // registers the renewal schedule
    log.Fatal(err)
}
defer manager.Stop(ctx)

tlsConfig := manager.TLSConfig() // GetCertificate wired for SNI lookup
  • Issuance arbitration. The first request for a domain (or the renewal schedule finding one close to expiry) races Coordinator.TryLock for that domain; only the winner calls the configured CertIssuer. Losers poll the Coordinator until the winner publishes, or give up with ErrIssuanceTimeout.
  • Issuance lock TTL caveat. The lock is not renewed while the CertIssuer call is in flight (Coordinator has no lock-extension method). If issuance takes longer than WithIssuanceLockTTL (default 2 minutes), the lock can expire mid-issuance and a second process can acquire it and also call the issuer. Manager detects this after the fact and returns ErrIssuanceLockExpired instead of caching the result, but it does not prevent the extra CertIssuer call. Set WithIssuanceLockTTL comfortably longer than your CertIssuer's worst-case latency.
  • Distribution. The issued certificate is written to the Coordinator (so every process sharing it can serve it immediately) and to the configured CertStore (so a single-process restart doesn't need the Coordinator at all).
  • Renewal. Driven by GoAkt's cron scheduler (ActorSystem.ScheduleWithCron, default: hourly, configurable via WithRenewInterval). In a GoAkt cluster, cron schedules fire once cluster-wide by scheduler design, so exactly one node re-checks/re-issues per tick regardless of cluster size.
  • SNI lookup. Manager.GetCertificate implements the tls.Config.GetCertificate signature directly.
Domain admission

An SNI-driven issuer facing the open internet is a way to burn a CA rate limit: anything that can open a TCP connection can name any domain. Admission is therefore explicit, and deny-by-default: a Manager configured with WithCertIssuer but neither WithAllowedDomains nor WithDomainPolicy refuses every domain with ErrDomainNotAllowed rather than issuing for whatever SNI value shows up. You must declare admitted domains through one (or both) of:

  • WithAllowedDomains("chat.example.com", "*.tenants.example.com") - a static list. Wildcards match a single label (*.example.com matches a.example.com, not a.b.example.com).
  • WithDomainPolicy(func(ctx, domain) (bool, error)) - a dynamic check, for the custom-domain case where the answer lives in a database.
  • WithNegativeCacheTTL (default 1 minute) caches rejections and issuance failures, so a flood of junk SNI cannot turn into a flood of issuer calls.
  • WithMaxCachedCerts (default 1024) bounds the in-memory certificate cache (LRU), so a flood of valid SNI cannot exhaust memory either.

This deny-by-default only applies once a CertIssuer is configured. Without one, Manager can only ever serve a certificate a CertStore already has or a WithFallbackCertificate provides, so admitting every domain costs nothing and remains the default.

Fallback certificates and hot reload

Not every deployment issues its own certificates. The common shape - Cloudflare terminating TLS at the edge, the origin serving one long-lived catch-all certificate - needs no issuer at all, just a certificate that is reloaded when it rotates on disk:

reloading, err := gateway.NewReloadingCertificate("/tls/tls.crt", "/tls/tls.key", time.Minute, logger)
if err != nil {
    log.Fatal(err)
}
reloading.Start(ctx)
defer reloading.Stop()

manager := gateway.NewManager(actorSystem, logger,
    gateway.WithFallbackCertificate(reloading.Get),
)

WithFallbackCertificate serves a certificate when the client sent no SNI, or when the domain has no certificate and no issuer is configured. (A domain that is explicitly not allowed is rejected, never served the fallback.)

ReloadingCertificate polls a SHA-256 hash of the file contents, never the mtime, and re-parses with tls.X509KeyPair only when the bytes change - this is what makes it survive a Kubernetes Secret/ConfigMap rotation, which swaps a ..data symlink underneath the path instead of rewriting the file, so the mtime a naive watcher would stat never moves. If a reload fails (a half-written pair, a bad key), the last known-good certificate is kept and the error is logged; a rotation typo does not take TLS down.

NewFileCertStore(dir) is the matching persistent CertStore: dir/<domain>.crt and dir/<domain>.key, each written atomically, key mode 0600. Get re-reads on a cert/key mismatch, so a handshake racing a renewal never sees the new certificate paired with the old key across the two renames - the pair swap is all-or-nothing, the same as MemoryCertStore and store/redis. store/redis.New(client) is the shared alternative - one Redis or Valkey key per domain, so a cold-started node reads already-issued certificates back without depending on the issuer, and every node sees the same store. See Redis / Valkey backends.

Coordinator

GoAkt's cluster KV store is not part of the public API this library depends on, and its underlying storage documents its own lock as "recommended for efficiency, not correctness" - the wrong substrate for an issuance lock, since a duplicated issuance burns CA rate limit. Coordinator is a small interface this library owns instead:

type Coordinator interface {
    Get(ctx context.Context, key string) (value []byte, ok bool, err error)
    Put(ctx context.Context, key string, value []byte, ttl time.Duration) error
    TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(context.Context) error, err error)
}

Two implementations ship:

  • NewMemoryCoordinator (the default) is process-local - correct for a single process, local development, and tests, but does not coordinate issuance across processes. It provides strict WithOwnerLease fencing only inside that one process.
  • coordinator/redis.New is backed by Redis or Valkey (SET NX PX for the lock, a Lua compare-and-delete for release - a real mutual exclusion, not a best-effort one) and coordinates issuance across every process pointed at the same instance. It is a separate package specifically so importing the root module never pulls in github.com/redis/go-redis/v9 for applications that do not want it. It remains valid for certificate issuance coordination and certificate distribution, but it cannot provide strict WithOwnerLease fencing across asynchronous-replication failover.
import (
	"github.com/redis/go-redis/v9"

	gateway "github.com/StringKe/goakt-gateway"
	gatewayredis "github.com/StringKe/goakt-gateway/coordinator/redis"
)

client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
manager := gateway.NewManager(actorSystem, logger,
	gateway.WithCoordinator(gatewayredis.New(client)),
	gateway.WithCertIssuer(issuer),
	gateway.WithAllowedDomains("chat.example.com"), // required: see Domain admission
)

Bring your own Coordinator implementation by implementing the three-method interface directly. Strict multi-instance WithOwnerLease additionally requires a consensus-backed LinearizableFencingCoordinator; do not use Redis or Valkey for that guarantee.

Certificate issuers
type CertIssuer interface {
    Issue(ctx context.Context, domain string) (*Certificate, error)
}
  • StaticIssuer serves a fixed, pre-provisioned certificate.
  • CloudflareOriginCAIssuer calls the Cloudflare Origin CA API over plain net/http - no Cloudflare SDK dependency:
issuer := &gateway.CloudflareOriginCAIssuer{
    APIToken:           os.Getenv("CF_ORIGIN_CA_KEY"),
    RequestCertificate: gateway.NewRSACertificateRequest(2048),
}

Authenticated Origin Pulls

If traffic only ever reaches your gateway through Cloudflare, AuthenticatedOriginPulls adds mTLS verification that inbound connections present Cloudflare's origin-pull client certificate. CAPEM is supplied by the caller rather than embedded, so deployments track Cloudflare's currently published CA bundle instead of trusting a copy vendored into this library:

pulls := &gateway.AuthenticatedOriginPulls{CAPEM: cloudflareOriginPullCA}

server, err := gateway.NewServer(":8443", mux,
    gateway.WithTLSManager(manager),
    gateway.WithAuthenticatedOriginPulls(pulls),
)

Server and graceful shutdown

Server is an optional, thin wrapper around *http.Server. http.Server.Shutdown cannot evict long-lived connections on its own - hijacked WebSocket sockets are ignored entirely, and open SSE streams are waited on until the shutdown context expires - so register handlers with WithDrainOnShutdown:

wsHandler := gateway.NewWSHandler(registry)
sseHandler := gateway.NewSSEHandler(registry)

server, err := gateway.NewServer(":8443", mux,
    gateway.WithTLSManager(manager),
    gateway.WithDrainOnShutdown(wsHandler, sseHandler),
    gateway.WithServerErrorLog(stdlog.New(io.Discard, "", 0)), // optional: silence TLS probe noise
)

Drain sends a WebSocket 1001 GoingAway close frame (SSE streams are simply closed), the per-connection actors unregister, and reconnecting clients land on the replicas that stay up. WithServerErrorLog exists because a TLS listener logs every failed handshake, and load balancer readiness probes that connect without completing one otherwise produce a steady drip of http: TLS handshake error ...: EOF on stderr.

Host deployment contract

This repository is a Go library, not a deployable gateway service. The host application owns its container image, Kubernetes resources, health endpoints, tracing, dashboards, alerts, SBOM, signing, release promotion, and rollback. A production host running shared state must enforce the following exact contract:

  • replicas: 3, terminationGracePeriodSeconds: 30, and a PodDisruptionBudget with minAvailable: 2.
  • A preStop path calls Server.Shutdown with a 30 second context after constructing the server with every WS and SSE handler in WithDrainOnShutdown.
  • Readiness is false until the GoAkt actor system and every configured shared backend answer their own health check. Liveness only proves the host process can serve its health endpoint.
  • HPA uses host-owned CPU, memory, and connection metrics. A rollout must preserve at least two ready replicas while one replica drains.

Run this host-owned rollout verification against the deployment that embeds this library:

kubectl --context production scale deployment/gateway --replicas=3
kubectl --context production rollout status deployment/gateway --timeout=30s
kubectl --context production rollout restart deployment/gateway
kubectl --context production rollout status deployment/gateway --timeout=30s
kubectl --context production rollout undo deployment/gateway

Redis / Valkey backends

Five of this library's pluggable abstractions ship a shared backend built on github.com/redis/go-redis/v9. The RESP protocol is the abstraction: all five constructors share one signature - each takes a redis.UniversalClient (which covers standalone, Cluster, Sentinel, and Ring) - and go-redis speaks the identical protocol to a Redis server and to a Valkey server, so one client, constructed once, backs any or all of them against either with no branch. Point the client wherever your deployment prefers - Valkey is BSD-licensed, Redis is RSALv2/SSPL - and pick by license, not by capability. Every backend uses only commands present and identical on Redis 7.2 and Valkey 8; none touches a Redis-proprietary module or a post-7.2 command Valkey has not adopted.

Abstraction In-memory default Shared backend Import path Key namespace
Coordinator (issuance lock + certificate distribution) NewMemoryCoordinator() coordinator/redis.New(client) github.com/StringKe/goakt-gateway/coordinator/redis WithKeyPrefix
Presence (cluster-wide online status) NewMemoryPresence() presence/redis.NewPresence(client) github.com/StringKe/goakt-gateway/presence/redis WithKeyPrefix
CertStore (certificate persistence) NewMemoryCertStore() / NewFileCertStore(dir) store/redis.New(client) github.com/StringKe/goakt-gateway/store/redis WithKeyPrefix (keys carry a cert: infix)
SSEHistory (Last-Event-ID replay buffer) NewMemorySSEHistory(perConn) ssehistory/redis.New(client) github.com/StringKe/goakt-gateway/ssehistory/redis WithKeyPrefix (default gateway:ssehistory:)
Outbox (at-least-once delivery) NewMemoryOutbox() persistence/redis.New(client) github.com/StringKe/goakt-gateway/persistence/redis WithKeyPrefix

One Redis or Valkey instance can carry all five at once: give each a distinct WithKeyPrefix and their keys never collide, so a single server (or Cluster) backs a whole gateway deployment's coordination, presence, certificate, replay, and outbox state.

import (
	"github.com/redis/go-redis/v9"

	gateway "github.com/StringKe/goakt-gateway"
	rediscoordinator "github.com/StringKe/goakt-gateway/coordinator/redis"
	redispresence "github.com/StringKe/goakt-gateway/presence/redis"
	redisstore "github.com/StringKe/goakt-gateway/store/redis"
	redishistory "github.com/StringKe/goakt-gateway/ssehistory/redis"
	redisoutbox "github.com/StringKe/goakt-gateway/persistence/redis"
)

// One client, pointed at either a Redis or a Valkey server.
client := redis.NewUniversalClient(&redis.UniversalOptions{Addrs: []string{"localhost:6379"}})

coordinator := rediscoordinator.New(client, rediscoordinator.WithKeyPrefix("gw:coord:"))
presence := redispresence.NewPresence(client, redispresence.WithKeyPrefix("gw:presence:"))
certStore := redisstore.New(client, redisstore.WithKeyPrefix("gw:certs:"))
history := redishistory.New(client, redishistory.WithKeyPrefix("gw:sse:"))
outbox := redisoutbox.New(client, redisoutbox.WithKeyPrefix("gw:outbox:"))

All five backends are validated by conformance suites (coordinator/conformance, presence/conformance, store/conformance, ssehistory/conformance, persistence/conformance) that run the same assertions against both the in-memory implementation and the RESP one, and the RESP suites are run against a real Redis server and a real Valkey server (a two-service docker-compose.yml locally; see Development). Interchangeability is a tested property, not a claim.

Examples

Example Demonstrates
echo The smallest useful thing: a WebSocket echo endpoint plus an HTTP handler that pushes into a live connection.
chat Topic broadcast: room-based chat, fan-out to every member except the sender (WithExclude).
cluster The whole point of the Registry: three nodes, and a message handed to node A arrives on a socket held by node C.
notification Groups, presence, and the offline fallback: SendToGroup to a multi-device identity, with DeliveryResult.None() driving a web-push retreat. presence/redis when REDIS_ADDR is set.
sse-resume SSE Last-Event-ID replay across a reconnect, including the gateway-gap event when history has aged out. Set REDIS_ADDR to run two nodes over one shared ssehistory/redis and reconnect across them.
graphql-ws That Registry does not need WSHandler: a hand-rolled graphql-transport-ws server registering its own sockets.
tls-cloudflare Cluster-shared TLS end to end: Origin CA issuance plus Authenticated Origin Pulls mTLS. coordinator/redis + store/redis when REDIS_ADDR is set, including a cold start served from the store with no issuer.
offline-push WithOfflineChannel: SendToGroup to an identity with no live socket drives gateway.OfflineChannel automatically, with offline/webpush against a fake push service and OfflineObserver.OfflineFallback reporting the result.
delivery-confirm WithDeliveryConfirmation: a two-node cluster where DeliveryResult.Remote counts a confirmed remote socket write (Ask) rather than a fire-and-forget hand-off, including the ErrConfirmationTimeout path.
persistence Opt-in at-least-once: WithOutbox + Registry.Ack persist, deliver, wait for the client ack, and redeliver unacked messages on reconnect. persistence/redis when REDIS_ADDR is set.
presence-watch WatchPresence and GroupMembers: a live join/leave event stream plus cluster-wide member enumeration with metadata. presence/redis (Pub/Sub watch, directory) when REDIS_ADDR is set.
reauth-kick WithWSReauth periodic re-authorization plus Registry.Disconnect/DisconnectGroup forced eviction, both ending the socket with a 1008 close and reason.

A note that costs people an afternoon otherwise, documented at length in examples/cluster: GoAkt derives its memberlist gossip label from the actor system's name, so nodes whose actor systems are named differently silently refuse to cluster with each other, and every cross-node lookup then fails with a bare ErrConnectionNotFound. Every node in a cluster must use the same actor system name.

Opt-in reliability and lifecycle features

Everything below is off by default. The default Registry and handlers behave exactly as the rest of this README describes: a single fire-and-forget socket write, no persistence, no extra round-trip, no background goroutine you did not ask for. Each feature is a constructor option you add only when you need it, and each one names its own cost in the sentence that turns it on. A deployment that wires none of them pays for none of them.

Offline fallback

WithOfflineChannel(ch) closes the gap between "the identity is not connected here" and "do something about it". When SendToGroup finds no live socket for the target anywhere in the cluster (DeliveryResult.None() is true), the Registry calls ch.Deliver(ctx, group, payload) for you instead of leaving that if result.None() check to every call site.

registry := gateway.NewRegistry(system, logger,
    gateway.WithPresence(presence),                 // None() is only trustworthy with a Presence backend
    gateway.WithOfflineChannel(myWebPush),          // gateway.OfflineChannel
)

The fallback runs off the main delivery path: SendToGroup returns its DeliveryResult as before, and Deliver's error is reported through the optional OfflineObserver.OfflineFallback hook and the logger, not returned to the caller. offline/webpush is a ready OfflineChannel backed by VAPID Web Push; bring any Deliver(ctx, group, payload) for email, APNs, or a store-and-forward queue. Boundary: the fallback fires exactly when None() is true, so it inherits None()'s accuracy. Without a Presence backend None() just means "not on this node" and never fires in a cluster; with presence alone a stale lease can suppress it for up to one TTL; only WithDeliveryConfirmation makes it exact, and then it is at-least-once - a node that acknowledges a write late can still trigger the fallback, so an identity may receive both an in-app and an offline copy. That is the deliberate trade: never lose a notification, at the cost of a possible duplicate.

Delivery confirmation

By default a cross-node delivery is actor.Tell: DeliveryResult.Remote counts what was handed to the cluster, not what reached a socket. WithDeliveryConfirmation() switches the remote hand-off to Ask, so the originating node waits for the remote connActor to report that the socket write succeeded, and Remote then counts confirmed writes into a remote outbound buffer.

registry := gateway.NewRegistry(system, logger,
    gateway.WithDeliveryConfirmation(),
    gateway.WithConfirmationTimeout(3*time.Second),  // default 5s
)

Cost and boundary: this adds a network round-trip per remote target and a per-call timeout. A target that does not confirm within WithConfirmationTimeout (default 5s) is not counted in Remote, and the timed-out branch surfaces ErrConfirmationTimeout to the delivery's error path. This is what lets SendToGroup's None() be exact under presence - a crashed node's stale lease no longer counts, because only an acknowledgement counts - but it also means a slow node that buffers the payload and acks after the timeout is treated as unreached, so a paired WithOfflineChannel is at-least-once and may duplicate. Confirmation still ends at the remote buffer, not at the client: it proves the socket accepted the bytes, not that the client read them. The default path is untouched - connActor answers the confirmation unconditionally, and under Tell that answer is a no-op, so nodes that never opt in do zero extra work.

Presence watch and directory

Two questions the point-in-time IsOnline/GroupMembers pair cannot answer well: "tell me the moment a member of this group joins or leaves" and "enumerate every connection for this group across the whole cluster, with metadata". Both are optional capabilities a Presence backend may implement.

events, cancel, err := registry.WatchPresence(ctx, "user:123")   // PresenceWatcher
defer cancel()
for ev := range events {
    // ev.Kind is PresenceJoin or PresenceLeave
}

entries, err := registry.GroupMembers(ctx, "user:123")           // PresenceDirectory

MemoryPresence implements both, as does presence/redis. WatchPresence returns ErrPresenceWatchUnsupported if the configured backend does not implement PresenceWatcher; GroupMembers falls back to this node's local group index when the backend does not implement PresenceDirectory. Boundary: presence/redis watch is Redis Pub/Sub and is therefore best-effort - events published while a watcher is disconnected are not replayed, and a full watcher channel drops rather than blocks. Treat it as a liveness hint that lets you skip polling, not a durable event log. To carry per-connection metadata into GroupMembers, register with WithConnMeta; the Registry forwards it to the backend's JoinWithMeta when the backend supports it, and a bare Refresh never re-emits a join or loses the metadata.

Persistence and at-least-once delivery

The default socket write is at-most-once: a message to a connection that is not registered anywhere is gone. WithOutbox(o) turns SendToConnection into store-then-deliver, and Registry.Ack plus reconnect redelivery close the loop into at-least-once.

registry := gateway.NewRegistry(system, logger,
	gateway.WithOutbox(gateway.NewMemoryOutbox()),  // or persistence/redis for a cluster
	gateway.WithOutboxEnvelope(),
)

// application, on receiving the client's ack frame:
_ = registry.Ack(ctx, connID, msgID)

With an Outbox wired, SendToConnection first Appends the payload - the Outbox itself mints the UUID msgID and assigns the per-connection monotonic Seq, returning both

  • and then delivers it; a fresh registration for that connection id automatically redelivers everything still unacked. The application calls Registry.Ack once the client has acknowledged, and the stored copy is dropped. NewMemoryOutbox() is process-local (single node, tests); persistence/redis survives process restart and works across nodes. The Seq is assigned by the store, not by the sending process, precisely so it stays monotonic across a restart and across nodes appending to the same connection: a per-process counter would restart at 1 on reboot and collide with a still-stored message, so two payloads could land on the same Seq and a Seq-deduping client would silently drop one. The counter is reclaimed only by Outbox.DropConn (or a persistence/redis WithTTL), which is also what keeps the store from accumulating one entry per connection id ever seen. Boundary: at-least-once means duplicates are possible - an unacked message is redelivered on reconnect even if the client did in fact receive it, so clients must dedupe on the ID/Seq carried by the optional wire envelope. WithOutboxEnvelope makes real-time delivery and replay write the same ASCII base64 frame containing msgID, Seq, and the original payload. Decode it with DecodeOutboxTextEnvelope, then send that msgID to Registry.Ack. The default remains a raw payload for compatibility when WithOutboxEnvelope is absent; in that default mode, the application owns any wire framing used for its acknowledgements. Registry.Ack is a no-op returning nil when no Outbox is configured, so the call site is safe to keep unconditionally.
Reauth and disconnect

A handshake authenticates the caller once; authorization then drifts under a long-lived socket. WithWSReauth/WithSSEReauth re-run your auth function on an interval and tear the connection down the instant it fails, and Registry.Disconnect/DisconnectGroup let you evict a connection or an entire identity on demand (a ban, a permission revocation, a forced re-login).

gateway.NewWSHandler(registry,
    gateway.WithWSAuth(authFn),
    gateway.WithWSReauth(30*time.Second, authFn),   // fails -> 1008 close + Unregister
)

n, err := registry.DisconnectGroup(ctx, "user:123", "session revoked")

The mechanism is a close hook registered at Register time (WithConnCloseHook), so there is no window between registration and the connection becoming closable, and the hook is race-free against a normal Unregister. A WS connection closed this way receives a StatusPolicyViolation (1008) frame with the reason (clamped to the 123-byte close-frame limit on a UTF-8 boundary, so an over-long reason still produces a clean 1008 rather than an abrupt 1006); an SSE stream is ended with a terminating comment. Disconnect drives the close only; the socket's own teardown runs the existing unregister path, so takeover and reserve/rollback correctness is unchanged. Boundary: WS reauth re-runs the auth function against the original handshake request, so it can only re-validate header/cookie-style credentials (the hijacked body is gone), and it checks liveness of the credential, not a re-parsed identity applied to the live socket.

Compression and rate limiting

WithWSCompression(mode) enables permessage-deflate on the WebSocket (coder/websocket's CompressionMode); the default is CompressionDisabled, its zero value, so the wire is untouched unless you ask. WithWSGroupRate(perSecond, burst) adds a per-group inbound token bucket shared by every local connection of that group, composing with the per-connection WithWSInboundRate (a frame must pass both):

gateway.NewWSHandler(registry,
    gateway.WithWSCompression(websocket.CompressionContextTakeover),
    gateway.WithWSInboundRate(20, 40),      // per connection
    gateway.WithWSGroupRate(100, 200),      // per group, shared across its local sockets
)

Boundary: the group bucket is per node (it throttles the connections a single node holds for that group, not the group cluster-wide), and connections with an empty group are never group-limited. Exceeding either limit closes the offending connection with the configured backpressure policy.

Strict multi-instance correctness

Everything above - including reconnect takeover - relies on GoAkt's actor directory to know who currently owns a connection id. That directory is PA/EC eventually consistent (memberlist/olric last-write-wins), not an atomic cluster lock: two nodes racing Register for the same id can both observe "no owner" and both succeed, producing two live owners for one id (split brain). Sequential takeover - one node evicting an owner it can already see - works without anything below and is unaffected by it; what needs WithOwnerLease is a true race between two Register calls that never see each other's write in time.

WithOwnerLease(c) only accepts a LinearizableFencingCoordinator: a coordinator that explicitly declares linearizable fencing across failover. NewMemoryCoordinator supplies that capability for one process only. coordinator/redis.New deliberately does not supply it: Redis or Valkey remains valid for certificate coordination, but asynchronous replication can lose a completed CAS during failover and grant the same OwnerLease again. A multi-instance deployment must provide a consensus-backed LinearizableFencingCoordinator; this module does not ship a provider for that boundary.

WithOwnerLease(c) makes every Register acquire a CAS-arbitrated lease for the connection id from c before publishing it locally. The lease value carries the owning node's id, a monotonically increasing generation, and an expiry; a takeover (WithReplaceExisting) can only succeed by winning a compare-and-swap that bumps the generation, so of two nodes racing Register for the same id, exactly one wins the CAS and the other fails fast with ErrOwnerHeld - never both, and the loser never publishes a local entry to race against. Once a node holds a generation, every fencing-aware operation carries it and is rejected with ErrStaleOwner the instant a newer generation exists elsewhere:

  • Local and cross-node delivery - SendToConnection, SendToGroup and Broadcast fence every local write (not only the cross-node connActor path) against the target entry's generation before it ever reaches the socket, so a superseded local entry this node has not yet evicted cannot keep delivering on the old owner's behalf. A remote connActor re-checks the generation of whichever entry currently answers to the id, so a same-node reconnect that has since replaced it is still served correctly rather than rejected as stale.
  • Background lease renewal - the old owner's own refresh loop observes the takeover and self-evicts the local connection (closing it with ownerLeaseStaleEvictReason), with no message from the new owner required.
  • Presence Refresh/Leave (PresenceFencer, implemented by MemoryPresence and presence/redis) - a stale owner's delayed refresh cannot keep its own superseded membership alive, and its delayed teardown cannot delete a takeover's already-(re)established membership.
  • SSE history (GenerationalHistory, implemented by MemorySSEHistory and ssehistory/redis) - an event append from a superseded generation is rejected rather than recorded, and a takeover's SSEHandler.open advances the history's generation floor itself the moment the new owner registers, so a still-draining previous owner's queued write cannot land in shared history merely because the new owner has not appended anything of its own yet.
  • Outbox Ack (OutboxGenerationAdvancer, implemented by persistence/redis and NewMemoryOutbox) - an ack from a superseded generation is rejected rather than applied, and Register raises the Ack fencing floor itself the moment a takeover is confirmed, so a stale owner's in-flight ack cannot slip in just because the new owner has not acked anything yet.
  • Entry-guarded unregister (Registry.RegisterHandle / ConnHandle.UnregisterHandle) - a handler holding a stale handle cannot tear down the entry a takeover has since replaced.
  • Failed takeovers restore, not strand, the prior owner - if the physical eviction behind a WithReplaceExisting takeover never completes (e.g. ErrTakeoverTimeout), the lease is restored to whichever owner it preempted instead of leaving the failed attempt's own claim (or a bare tombstone) permanently fencing out a connection that was never actually taken over.

Without WithOwnerLease (the default), a Registry has exactly the single-instance-safe, zero-cost semantics it always had: no lease acquisition, no generation fencing, no extra Coordinator round trip, and the split-brain window above is only as safe as the actor directory's own consistency window - fine for sequential takeover, not for a genuine concurrent race on the same id. Turning it on costs one CAS round trip per Register and a background renewal goroutine per node; WithOwnerLease mirrors the WithDeliveryConfirmation precedent of paying for a guarantee only when you ask for it.

What this library does and does not guarantee

Does:

  • A locally registered connection is always written to directly, with no actor mailbox or cluster round-trip on the hot path.
  • SendToConnection for a connection registered anywhere in a GoAkt cluster resolves and delivers correctly, given the cluster's own consistency model for actor directory propagation (not instantaneous - see the multi-node tests for the propagation delay this library's own test suite budgets for).
  • Broadcast's WithExclude is honored on every node, not just the originating one: the exclusion list travels inside the cluster envelope.
  • A reconnect on an existing connection id takes over even when the old registration lives on a different node: the takeover evicts the remote owner (closing its socket and freeing the cluster-unique actor name) and then claims the id, rather than the two fighting. Eviction is bounded - if the remote owner cannot be displaced within the takeover budget (directory propagation stall, partition) Register returns ErrTakeoverTimeout instead of hanging. This is a sequential takeover guarantee: one Register observes the existing owner and evicts it before claiming the id. It does not by itself resolve two nodes calling Register for the same id at the same instant, both observing no owner in the (eventually consistent) actor directory, and both succeeding - see "Strict multi-instance correctness" for the opt-in that closes that window.
  • Certificate issuance is deduplicated within a process (singleflight) and, with a shared Coordinator, arbitrated across every process sharing it so at most one process calls the configured CertIssuer for a given domain at a time, provided issuance completes within the configured issuance lock TTL (see the lock TTL caveat below).
  • MemoryCoordinator.TryLock and coordinator/redis.Coordinator.TryLock are both real mutual exclusion: an unlock can never release a lock a later caller has since acquired. This is a shared conformance suite (coordinator/conformance), run against both implementations, so a regression to e.g. a bare DEL in either would fail tests. Presence, CertStore, and SSEHistory have the same treatment (presence/conformance, store/conformance, ssehistory/conformance); the RESP backend of each is run against both a real Redis and a real Valkey server, so "interchangeable between Redis and Valkey" is a tested property.
  • coordinator/redis is suitable for certificate coordination, not strict multi-instance connection ownership. WithOwnerLease requires a consensus-backed LinearizableFencingCoordinator; Redis or Valkey failover cannot prove that fencing property.

Does not:

  • Guarantee message delivery by default. SendToConnection/SendToGroup/Broadcast are best-effort out of the box, same as any actor Tell: no acknowledgement, no retry, no persistence. The opt-in escape is WithOutbox + Registry.Ack (at-least-once, with client-side dedupe on the redelivered ID/Seq); see "Persistence and at-least-once delivery". Nothing changes for callers who do not wire it.
  • Guarantee that a fan-out actually reached a remote socket by default. DeliveryResult.Remote counts what was handed to the cluster, not what was written to a socket. Only Delivered (a local connection's outbound buffer accepted it) is first-hand knowledge, and even that is a buffer, not an ack from the client. A cross-node delivery that arrives at a node whose connection just died is silently lost. WithDeliveryConfirmation upgrades Remote to count confirmed remote socket writes at the cost of a round-trip and a timeout; it still ends at the remote buffer, not the client.
  • Tell you the truth about cluster-wide presence without a Presence backend. Without one, IsOnline and LocalConnectionsOf see only this node, and DeliveryResult.None() is not a trustworthy "user is offline" signal - it only means "not here". Wire presence/redis before you build an offline-notification path on None().
  • Guarantee IsOnline is instantaneously correct even with a Presence backend. Leases are TTL'd; a node that dies without unregistering leaves its ids visible for up to one TTL, so true means "recently held a socket", not "writable right now".
  • Guarantee ordering across Broadcast calls to different topics, or between a local direct write and a remote delivery of the same broadcast racing each other. Delivery into a single connection preserves the order in which that connection's buffer accepted the messages, and nothing more.
  • Persist anything by default. A message sent to a connection that is not registered anywhere is gone; there is no queue, no inbox, no store-and-forward. SSEHistory replays what was already routed to a still-registered connection, and the opt-in WithOutbox stores unacked messages for redelivery on reconnect - but neither is on unless you wire it.
  • Provide its own certificate validation, ACME support, or CA. CertIssuer is a seam; bring your own (an ACME implementation is a natural third one, deliberately not shipped here to keep the dependency tree minimal).
  • Renew the issuance lock while a CertIssuer call is in flight. Coordinator has no lock-extension method, so if issuance takes longer than WithIssuanceLockTTL, the lock can expire mid-issuance and a second process can acquire it and also call the issuer; Manager returns ErrIssuanceLockExpired after the fact rather than caching the result, but does not prevent the extra call. Set the TTL comfortably longer than your CertIssuer's worst-case latency.
  • Re-check domain admission for an already-cached certificate. Once a domain is in the hot cache it is served until eviction or expiry without re-consulting WithDomainPolicy, so "revoke a custom domain within N seconds" is not a guarantee this library makes.
  • Drain in-flight application state on shutdown beyond closing the socket/stream - Drain terminates connections, it does not flush or persist anything for you.

Development

mise exec go@1.26.5 --command "go build ./..."
mise exec go@1.26.5 --command "go vet ./..."
mise exec go@1.26.5 --command "go test -race ./..."

The five RESP backends (coordinator/redis, presence/redis, store/redis, ssehistory/redis, persistence/redis) have conformance tests that are skipped unless TEST_REDIS_ADDR is set. Because interchangeability between Redis and Valkey is the whole point, run the same suite against both. docker-compose.yml starts one of each:

docker compose up -d                                          # redis on :6399, valkey on :6400

TEST_REDIS_ADDR=127.0.0.1:6399 mise exec go@1.26.5 --command "go test ./... -race -count=1"   # against Redis
TEST_REDIS_ADDR=127.0.0.1:6400 mise exec go@1.26.5 --command "go test ./... -race -count=1"   # against Valkey

docker compose down

Both runs must show coordinator/redis, presence/redis, store/redis, ssehistory/redis, and persistence/redis as ok (not [no test files] or skipped), and presence/redis's Watch/Directory tests run under the same gate. Point TEST_REDIS_ADDR at any single instance to run just one backend, e.g. TEST_REDIS_ADDR=127.0.0.1:6399 mise exec go@1.26.5 --command "go test ./store/redis/...".

See also

  • GoAkt - the actor system this library sits on top of.
  • CHANGELOG.md - breaking changes and migration notes.
  • CONTRIBUTING.md - local verification and pull request requirements.
  • SECURITY.md - private vulnerability reporting policy.
  • SUPPORT.md - issue and support routing.
  • examples/ - twelve runnable samples, indexed above.

Documentation

Overview

Package gateway turns a GoAkt cluster into a single, cluster-aware ingress tier for HTTP, WebSocket, and Server-Sent Events (SSE) traffic - the role an nginx/caddy front-tier normally plays in front of a stateless HTTP fleet.

Two design boundaries drive every type in this package:

  • Plain HTTP requests are served by ordinary net/http handlers with zero actor involvement. Actorizing a request/response cycle that lives for a few milliseconds is pure overhead; nothing in this package puts one in the way of a short-lived HTTP handler.
  • Only long-lived WebSocket/SSE connections get an addressable identity. Each accepted connection is registered in a local Registry and, so that it can be addressed from any node in the cluster, backed by a lightweight ephemeral actor (relocation disabled, long-lived passivation) whose sole job is to relay a delivery to the socket it owns.

Delivery

Message delivery to a connection is two-tier: Registry.SendToConnection checks the local connection table first and, on a hit, writes the socket directly with no actor or cluster machinery involved. Only on a miss does it fall back to a cluster-wide actor lookup (actor.ActorSystem.ActorOf) and a remote Tell to the node that owns the connection. Registry.SendToGroup and Registry.Broadcast follow the same shape for fan-out: local members are written directly, and remote nodes are reached through a small pub/sub bridge built entirely on GoAkt's public actor.Subscribe/actor.Unsubscribe API (see bridge.go) - this package has no dependency on any GoAkt internal package. Registering a connection with a group or with topics therefore requires an actor system started with actor.WithPubSub.

Fan-out returns a DeliveryResult rather than a bare error. Only DeliveryResult.Delivered is first-hand knowledge (a local connection's outbound buffer accepted the payload); DeliveryResult.Remote counts what was handed to the cluster, and whether it reached a socket is unknowable from the sending node. DeliveryResult.None reports that nothing at all happened, which is the signal to fall back to an offline channel such as web push.

Identity, groups, and presence

A connection id identifies one socket. A ConnInfo.Group identifies the party behind it ("user:123"), which typically holds several sockets at once - a phone, a laptop, three browser tabs. SendToGroup addresses the party; Broadcast addresses a topic subscription.

Presence answers "which connections does this identity hold, cluster-wide?" through TTL'd leases that Registry refreshes in the background. It is optional: without it, Registry.IsOnline and Registry.LocalConnectionsOf see only the local node, and DeliveryResult.None is not a trustworthy cluster-wide offline signal. NewMemoryPresence covers a single process; the presence/redis subpackage shares presence across a cluster. Because leases expire, presence is eventually consistent: IsOnline reporting true means a socket was recently held, not that one is writable right now.

Opt-in reliability and lifecycle

Everything above is the default behavior and costs nothing extra. Five capabilities are available as constructor options that a deployment adds only when it needs them; a Registry that wires none of them behaves exactly as described above.

  • WithOfflineChannel routes a SendToGroup that finds the target offline cluster-wide (DeliveryResult.None) to an OfflineChannel such as offline/webpush, off the main delivery path.
  • WithDeliveryConfirmation switches the remote hand-off from Tell to Ask so DeliveryResult.Remote counts confirmed remote socket writes, bounded by WithConfirmationTimeout (default 5s, ErrConfirmationTimeout on expiry). It still ends at the remote outbound buffer, not the client. The default Tell path is unaffected.
  • WithOutbox plus Registry.Ack make SendToConnection at-least-once: persist, deliver, wait for the client ack, redeliver unacked messages on reconnect. The Outbox assigns each message a per-connection Seq from its own durable state, so the sequence stays monotonic across restarts and across nodes appending to the same connection; a per-process counter could not. Duplicates are still possible, so clients dedupe on the PersistedMessage ID/Seq. NewMemoryOutbox is process-local; the persistence/redis subpackage survives restart and works across nodes. WithOutboxEnvelope makes both real-time delivery and reconnect replay use the same ASCII base64 frame containing the message ID, sequence, and original payload. Without WithOutboxEnvelope, delivery uses the original raw payload.
  • Registry.WatchPresence and Registry.GroupMembers expose optional PresenceWatcher and PresenceDirectory capabilities (both implemented by MemoryPresence and presence/redis); the redis watch is best-effort Redis Pub/Sub, not a durable event log.
  • Registry.Disconnect / Registry.DisconnectGroup evict a connection or an identity via a close hook registered at Register time; WithWSReauth / WithSSEReauth re-run auth on an interval and tear the connection down on failure. WithWSCompression and WithWSGroupRate tune the WebSocket wire and per-group inbound rate.

Strict multi-instance correctness

The GoAkt actor directory a Registry otherwise relies on to make a connection addressable cluster-wide is PA/EC eventually consistent, not an atomic cluster lock: two nodes racing Register for the same connection id can both observe no owner and both succeed (split brain). Sequential takeover - one node evicting an owner it can already see - works without anything below; only a genuine concurrent race needs it. WithOwnerLease(c) only accepts a LinearizableFencingCoordinator, which explicitly declares linearizable fencing across failover. The MemoryCoordinator provides that capability only for a single process. The Redis or Valkey Coordinator remains valid for certificate coordination, but asynchronous replication cannot provide strict OwnerLease fencing across failover and it deliberately does not implement LinearizableFencingCoordinator. A multi-instance deployment therefore supplies a consensus-backed LinearizableFencingCoordinator. WithOwnerLease closes the window by acquiring a CAS-arbitrated lease per connection id before Register publishes it locally, and fencing every subsequent operation - every local and cross-node delivery path (SendToConnection, SendToGroup, Broadcast, and a remote connActor's own delivery), background lease renewal, Presence Refresh/Leave (PresenceFencer), SSE history append (GenerationalHistory), Outbox Ack (OutboxGenerationAdvancer), and handler unregister (Registry.RegisterHandle / ConnHandle.UnregisterHandle) - by the generation that lease bumps on every takeover, so a dispossessed owner is rejected with ErrStaleOwner rather than silently continuing to act. A takeover whose physical eviction never completes (e.g. ErrTakeoverTimeout) restores the lease to whichever owner it preempted instead of leaving that owner permanently fenced out by an attempt that never actually succeeded. Without WithOwnerLease, Registry keeps its current single-instance-safe, zero-cost semantics unchanged.

Redis / Valkey backends

Five abstractions have a shared backend on github.com/redis/go-redis/v9: Coordinator (coordinator/redis), Presence (presence/redis), CertStore (store/redis), SSEHistory (ssehistory/redis), and Outbox (persistence/redis). Each takes a redis.UniversalClient; go-redis speaks the identical RESP protocol to a Redis and a Valkey server, and every backend uses only commands present on both, so one client pointed at either server works with no code change. One instance can carry all five under distinct WithKeyPrefix namespaces. Interchangeability is checked by conformance suites run against both a real Redis and a real Valkey server.

Backpressure

Every connection has a bounded outbound buffer. A slow consumer that fills it must never stall a fan-out to everyone else, so a full buffer is resolved by BackpressurePolicy: BackpressureDrop (the default) discards the message, returns ErrBackpressure, and keeps the connection; BackpressureClose closes the connection and lets the client reconnect and resynchronize. There is deliberately no blocking policy.

TLS

The TLS side of the package (CertIssuer, CertStore, Manager) lets every node in the cluster terminate TLS for any hosted domain from one certificate, issued exactly once cluster-wide: issuance is arbitrated through a storage-agnostic Coordinator this library owns (see coordinator.go), the resulting certificate is distributed to every node through the same Coordinator, and renewal is driven by GoAkt's cluster-single-fire cron schedule (actor.ActorSystem.ScheduleWithCron) so only one node re-issues per renewal window. Domain admission (WithAllowedDomains, WithDomainPolicy) plus a negative cache and a bounded certificate cache keep a flood of arbitrary SNI from burning CA rate limit or memory. Deployments that terminate TLS at an edge instead can skip issuance entirely and serve one hot-reloadable certificate through WithFallbackCertificate and ReloadingCertificate. See Manager, CertIssuer, CertStore, and Coordinator.

Index

Examples

Constants

View Source
const SSEGapEventName = "gateway-gap"

SSEGapEventName is the SSE "event:" name used to tell a reconnecting client that the events between its Last-Event-ID and the oldest retained event are gone for good. Its data is the Last-Event-ID that could not be resolved. It is a named event, so a client that does not listen for it is unaffected (an EventSource's onmessage only sees unnamed events), while a client that cares can resynchronize through its regular API instead of silently running on an incomplete stream.

Variables

View Source
var (
	// ErrConnectionNotFound is returned by Registry.SendToConnection when the target
	// connection id is not registered locally and cannot be located anywhere else in
	// the cluster either.
	ErrConnectionNotFound = errors.New("gateway: connection not found")

	// ErrConnectionExists is returned by Registry.Register when the given connection id
	// is already registered.
	ErrConnectionExists = errors.New("gateway: connection already registered")

	// ErrConnectionClosed is returned when writing to a connection that has already
	// been unregistered/closed.
	ErrConnectionClosed = errors.New("gateway: connection closed")

	// ErrBackpressure is returned when a connection's outbound buffer is full and the
	// write could not be queued without blocking.
	ErrBackpressure = errors.New("gateway: connection send buffer full")

	// ErrUnauthorized is returned by the WebSocket/SSE upgrade path when the configured
	// AuthFunc rejects the incoming request.
	ErrUnauthorized = errors.New("gateway: unauthorized")

	// ErrPubSubUnavailable is returned when a topic bridge is requested but the actor
	// system was not started with pub/sub enabled (see actor.ActorSystem.TopicActor).
	ErrPubSubUnavailable = errors.New("gateway: pub/sub is not enabled on this actor system")

	// ErrDomainNotAllowed is returned by Manager.GetCertificate and EnsureCertificate
	// when the requested SNI/domain is not covered by the configured allow list.
	ErrDomainNotAllowed = errors.New("gateway: domain not allowed")

	// ErrNoIssuer is returned by Manager when certificate issuance is required but no
	// CertIssuer was configured.
	ErrNoIssuer = errors.New("gateway: no certificate issuer configured")

	// ErrIssuanceTimeout is returned when this node lost the coordinator-arbitrated
	// issuance race for a domain and the winning node did not publish a certificate
	// before the wait deadline elapsed.
	ErrIssuanceTimeout = errors.New("gateway: timed out waiting for coordinated certificate issuance")

	// ErrCertificateNotFound is returned by CertStore.Get when no certificate is stored
	// for the given domain.
	ErrCertificateNotFound = errors.New("gateway: certificate not found")

	// ErrOriginPullVerificationFailed is returned when Authenticated Origin Pulls is
	// enabled and the inbound client certificate does not chain to the configured CA.
	ErrOriginPullVerificationFailed = errors.New("gateway: origin pull client certificate verification failed")

	// ErrLockNotAcquired is returned by Coordinator.TryLock when the lock is already
	// held by someone else.
	ErrLockNotAcquired = errors.New("gateway: lock not acquired")

	// ErrOriginNotAllowed is returned by the WebSocket upgrade path when the request's
	// Origin header matches none of the configured origin patterns.
	ErrOriginNotAllowed = errors.New("gateway: websocket origin not allowed")

	// ErrPayloadTooLarge tags an inbound message that exceeds the connection's configured
	// read limit. The WebSocket handler does not return it: coder/websocket closes the
	// socket with status 1009 (message too big) on its own, and the handler logs this
	// sentinel as the reason. It is not delivered to any caller.
	ErrPayloadTooLarge = errors.New("gateway: inbound payload exceeds the configured read limit")

	// ErrRegistryClosed is returned by Registry.Register once the Registry has been closed.
	ErrRegistryClosed = errors.New("gateway: registry is closed")

	// ErrRateLimited tags an inbound message rejected by a connection's inbound rate limit.
	// The WebSocket handler does not return it: it logs this sentinel and, under
	// BackpressureClose, uses it as the socket's close reason. It is not delivered to any
	// caller.
	ErrRateLimited = errors.New("gateway: inbound message rate limit exceeded")

	// ErrHistoryGap is returned by SSEHistory.Since when the requested Last-Event-ID
	// cannot be located for the connection, because it was evicted from a bounded buffer,
	// because it belongs to a different connection, or because the connection's history is
	// gone entirely. The events that are still retained are returned alongside it: the
	// caller learns both what it can replay and that something was lost. SSEHandler turns
	// it into an SSEGapEventName event on the wire instead of silently resuming. It is part
	// of the SSEHistory contract that third-party implementations must honour.
	ErrHistoryGap = errors.New("gateway: sse history gap, the requested Last-Event-ID is no longer retained")

	// ErrSSESharedHistoryRequiresOwnerLease is returned by SSEHandler before it accepts a
	// request when a SharedSSEHistory has no owner lease. Without a lease, two replicas can
	// append to the same replay stream after a takeover.
	ErrSSESharedHistoryRequiresOwnerLease = errors.New("gateway: shared sse history requires WithOwnerLease")

	// ErrSSESharedHistoryRequiresGenerationalHistory is returned by SSEHandler before it
	// accepts a request when a shared history cannot fence stale writers after takeover.
	ErrSSESharedHistoryRequiresGenerationalHistory = errors.New("gateway: shared sse history requires GenerationalHistory")

	// ErrIssuanceLockExpired is returned by Manager when a CertIssuer call outlives the
	// configured issuance lock TTL (see WithIssuanceLockTTL). The Coordinator lock has no
	// renewal/heartbeat, so a slow issuer can let the lock expire and a second process
	// acquire it while the first is still mid-issuance; returning this error instead of
	// silently caching the result makes that window visible rather than quietly risking a
	// duplicate CertIssuer call. Configure a WithIssuanceLockTTL comfortably longer than
	// your CertIssuer's worst-case latency to avoid it.
	ErrIssuanceLockExpired = errors.New("gateway: certificate issuance took longer than the issuance lock ttl")

	// ErrPresenceWatchUnsupported is returned by Registry.WatchPresence when no Presence
	// backend is configured or the configured one does not implement PresenceWatcher.
	ErrPresenceWatchUnsupported = errors.New("gateway: presence backend does not support Watch")

	// ErrConfirmationTimeout is returned by the cross-node delivery path when
	// WithDeliveryConfirmation is enabled and the remote connActor did not acknowledge the
	// write within the configured confirmation timeout (see WithConfirmationTimeout).
	ErrConfirmationTimeout = errors.New("gateway: cross-node delivery confirmation timed out")

	// ErrStaleOwner is returned by an owner-lease-fenced operation (refresh, release, and
	// (in later phases) delivery/presence/outbox calls that carry a connection generation)
	// once a newer generation has taken over the connection. It tells the caller its
	// generation is no longer current, not that the operation itself failed transiently.
	ErrStaleOwner = errors.New("gateway: operation rejected: a newer generation has taken over this connection")

	// ErrOwnerHeld is returned by a non-takeover owner lease acquisition when the lease is
	// currently held by another node and has not yet expired.
	ErrOwnerHeld = errors.New("gateway: connection owner lease is held by another node")

	// ErrOwnerLeaseUnsupported is returned when WithOwnerLease is configured with a
	// Coordinator that does not implement LinearizableFencingCoordinator. Strict owner lease
	// fencing requires one linearizable order across every participating process.
	ErrOwnerLeaseUnsupported = errors.New("gateway: WithOwnerLease requires a LinearizableFencingCoordinator")
)
View Source
var ErrOfflineFallbackOverloaded = errors.New("gateway: offline fallback dropped, concurrency limit reached")

ErrOfflineFallbackOverloaded is reported through OfflineObserver and the logger when an offline fallback is dropped because the concurrency bound is saturated, so the drop is visible rather than silent.

View Source
var ErrStaleGeneration = errors.New("gateway: sse history append rejected: generation has been superseded by a newer owner")

ErrStaleGeneration is returned by GenerationalHistory.AppendGenerational when generation is lower than the highest generation already recorded for the connection: a write from a node whose connection owner lease (see WithOwnerLease in the registry) was superseded by a takeover elsewhere. The call is rejected outright rather than recorded, so a reconnect's replay can never interleave events written by two nodes that both believed they owned the connection at once.

View Source
var ErrTakeoverTimeout = errors.New("gateway: cross-node connection takeover timed out")

ErrTakeoverTimeout is returned by Registry.Register when a cross-node reconnect takeover (WithReplaceExisting) could not evict the connection's previous owner on another node within takeoverEvictTimeout, so the cluster-unique actor name never freed up. It lives here rather than in errors.go because it is internal to the registry lifecycle.

Functions

func DecodeOutboxEnvelope added in v0.2.0

func DecodeOutboxEnvelope(data []byte) (msgID string, seq uint64, payload []byte, err error)

DecodeOutboxEnvelope parses a frame written by EncodeOutboxEnvelope. The returned payload aliases data's backing array; a caller that retains it past data's lifetime must copy it. A malformed frame is reported as such rather than guessed at, since it can only come from a build running an incompatible version.

func DecodeOutboxTextEnvelope added in v0.2.0

func DecodeOutboxTextEnvelope(data []byte) (msgID string, seq uint64, payload []byte, err error)

DecodeOutboxTextEnvelope decodes a frame written by EncodeOutboxTextEnvelope.

func EncodeOutboxEnvelope added in v0.2.0

func EncodeOutboxEnvelope(msgID string, seq uint64, payload []byte) ([]byte, error)

EncodeOutboxEnvelope frames payload with msgID and seq using the wire format documented above. It returns an error only if msgID is too long to fit the format's 16-bit length prefix; an id minted by Outbox.Append (a UUID string) never is, so this only matters to a caller supplying its own id.

func EncodeOutboxTextEnvelope added in v0.2.0

func EncodeOutboxTextEnvelope(msgID string, seq uint64, payload []byte) ([]byte, error)

EncodeOutboxTextEnvelope wraps the binary envelope in base64 so one opt-in frame works for both WebSocket text messages and UTF-8 Server-Sent Events data fields.

func NewRSACertificateRequest

func NewRSACertificateRequest(bits int) func(domain string) (csrPEM, keyPEM []byte, err error)

NewRSACertificateRequest builds a PEM-encoded PKCS#10 certificate signing request and matching RSA private key for domain, suitable for use as CloudflareOriginCAIssuer.RequestCertificate. It uses only the standard library, so it adds no dependency beyond what go.mod already carries.

func ReplayTailSeq added in v0.2.0

func ReplayTailSeq(msgs []PersistedMessage) uint64

ReplayTailSeq returns the highest Seq among msgs, the replay tail's high-water mark, or 0 if msgs is empty. A caller that redelivers an Outbox's unacknowledged tail on reconnect and must not let a fresh, real-time send overtake it on the wire uses this as the sequence a subsequent real-time envelope's Seq must exceed. Although Unacked returns msgs sorted ascending by Seq, this public helper also accepts caller-constructed slices, so it scans every element instead of relying on input order.

Types

type AuthenticatedOriginPulls

type AuthenticatedOriginPulls struct {
	// CAPEM is the PEM-encoded CA certificate (or bundle) that inbound client
	// certificates must chain to.
	CAPEM []byte
}

AuthenticatedOriginPulls configures mutual TLS verification of inbound connections against a trusted CA - the shape Cloudflare's "Authenticated Origin Pulls" feature requires of an origin server: only connections presenting a client certificate that chains to Cloudflare's published origin-pull CA are accepted.

The CA is supplied by the caller (CAPEM) rather than embedded, since a real deployment should track Cloudflare's currently published origin-pull CA bundle rather than trust a copy vendored into this library.

func (*AuthenticatedOriginPulls) Apply

func (a *AuthenticatedOriginPulls) Apply(cfg *tls.Config) error

Apply overlays mTLS verification onto cfg: it sets ClientAuth to tls.RequireAndVerifyClientCert and ClientCAs to a pool built from CAPEM. It returns an error if CAPEM does not contain a valid PEM-encoded certificate.

func (*AuthenticatedOriginPulls) Verify

Verify checks that the peer certificates presented in state chain to the configured CA. tls.Config.ClientAuth = tls.RequireAndVerifyClientCert already performs this verification during the handshake itself; Verify exists for callers that want to re-check an already-established connection's state explicitly (e.g. in an HTTP handler, via *http.Request.TLS) rather than solely trust the handshake outcome.

type BackpressurePolicy added in v0.2.0

type BackpressurePolicy int

BackpressurePolicy decides what a handler does with a connection whose outbound buffer is full.

const (
	// BackpressureDrop discards the message and reports ErrBackpressure to the sender,
	// leaving the connection open. This is the default: one slow read must not cost the
	// client its session.
	BackpressureDrop BackpressurePolicy = iota

	// BackpressureClose closes the connection instead of dropping the message. Use it when
	// a client that cannot keep up is more harmful than a client that has to reconnect,
	// e.g. when the stream is only meaningful in full.
	BackpressureClose
)

type BroadcastOption added in v0.2.0

type BroadcastOption func(*broadcastConfig)

BroadcastOption configures a single Registry.Broadcast call.

func WithExclude added in v0.2.0

func WithExclude(ids ...string) BroadcastOption

WithExclude keeps the given connection ids out of a broadcast, typically the sender's own connection. The exclusion travels with the payload, so it holds on every node, not just the one the Broadcast was issued from.

type CASCoordinator added in v0.2.0

type CASCoordinator interface {
	Coordinator

	// CompareAndSwap atomically writes newValue (with the given ttl, same semantics as
	// Put) under key if and only if the key's current value matches expected, and reports
	// whether the swap happened. expected == nil requires the key to be absent or already
	// expired; a non-nil expected requires an exact byte-for-byte match against the
	// existing value. A failed comparison is not an error: it returns (false, nil).
	CompareAndSwap(ctx context.Context, key string, expected, newValue []byte, ttl time.Duration) (bool, error)
}

CASCoordinator is an optional Coordinator extension that adds an atomic compare-and-swap primitive. Implementations opt in by satisfying this interface (checked with a type assertion, not embedded in Coordinator) so existing third-party Coordinator implementations keep compiling unchanged. Features that need stronger guarantees can require an additional optional capability without changing this base contract.

CompareAndSwap shares its key space with Get/Put: the "current value" a CAS call compares against, and the value a successful CAS leaves behind, are exactly what Get would return for that key.

type CertIssuer

type CertIssuer interface {
	// Issue returns a new Certificate for domain. It is called by Manager both for the
	// first issuance of a domain and for renewal ahead of expiry.
	Issue(ctx context.Context, domain string) (*Certificate, error)
}

CertIssuer obtains a certificate for a domain. Manager arbitrates calls to Issue so that, when using a cluster-shared Coordinator, at most one node calls it for a given domain at a time (see Manager.EnsureCertificate).

Implementations must be safe for concurrent use.

type CertStore

type CertStore interface {
	// Get returns the certificate stored for domain. It returns ErrCertificateNotFound
	// if none is stored.
	Get(ctx context.Context, domain string) (*Certificate, error)
	// Put stores cert, overwriting any previous certificate for the same domain.
	Put(ctx context.Context, cert *Certificate) error
	// Delete removes the certificate stored for domain, if any.
	Delete(ctx context.Context, domain string) error
}

CertStore persists issued certificates so that a cold start does not have to re-issue one for every hosted domain. Manager always keeps a copy in the configured Coordinator for fast, shared SNI lookups; a CertStore is the layer behind that - by default an in-memory MemoryCertStore, but applications facing a full restart of every node should plug in a persistent implementation (e.g. backed by a database or object storage) to avoid depending on the issuer being reachable/willing to re-issue on every cold start.

Implementations must be safe for concurrent use.

type Certificate

type Certificate struct {
	// Domain is the domain the certificate was issued for.
	Domain string
	// CertPEM is the PEM-encoded leaf certificate (and, where applicable, intermediate
	// chain) as returned by the issuer.
	CertPEM []byte
	// KeyPEM is the PEM-encoded private key matching CertPEM.
	KeyPEM []byte
	// NotAfter is the certificate's expiry, used by Manager to decide when to renew.
	NotAfter time.Time
}

Certificate is the PEM-encoded issuance result CertIssuer.Issue returns. It is what Manager stores in a CertStore and hands to tls.Config.GetCertificate after parsing.

type CloudflareOriginCAIssuer

type CloudflareOriginCAIssuer struct {
	// APIToken authenticates against the Origin CA API. Origin CA calls use an API
	// Token/Key dedicated to Origin CA rather than a general Cloudflare API token; see
	// the Cloudflare documentation for how to obtain one.
	APIToken string
	// BaseURL overrides the Cloudflare API base URL. Defaults to
	// "https://api.cloudflare.com/client/v4" when empty; tests point this at a local
	// httptest.Server.
	BaseURL string
	// RequestedValidityDays is the requested certificate lifetime in days, one of the
	// values Cloudflare's API accepts (7, 30, 90, 365, 730, 1095, or 5475). Defaults to
	// 365 when zero.
	RequestedValidityDays int
	// RequestCertificate builds the PEM-encoded PKCS#10 certificate signing request and
	// matching private key for domain. It is a function rather than a fixed
	// implementation so callers can plug in their preferred key type/size; see
	// NewRSACertificateRequest for a ready-made RSA implementation.
	RequestCertificate func(domain string) (csrPEM, keyPEM []byte, err error)
	// HTTPClient performs the API request. Defaults to http.DefaultClient when nil.
	HTTPClient *http.Client
}

CloudflareOriginCAIssuer issues certificates through the Cloudflare Origin CA REST API (https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/), using only net/http - no Cloudflare SDK dependency. It signs a certificate request created locally (CertRequestPEM) and returns whatever Cloudflare hands back.

Cloudflare-issued origin certificates are trusted by Cloudflare's edge but not by public clients, which is the intended shape for a service that only ever receives traffic proxied through Cloudflare.

func (*CloudflareOriginCAIssuer) Issue

func (c *CloudflareOriginCAIssuer) Issue(ctx context.Context, domain string) (*Certificate, error)

Issue implements CertIssuer by calling the Cloudflare Origin CA "create certificate" endpoint (POST /certificates).

type ConnHandle added in v0.2.0

type ConnHandle struct {
	// contains filtered or unexported fields
}

ConnHandle is the entry-guarded handle Registry.RegisterHandle returns alongside a successful registration. It is bound to the exact connEntry that call created, the same entry identity removeEntryLocked already guards the internal rollback and cross-node eviction paths with, so UnregisterHandle can never tear down a newer connection that a same-id takeover has since installed.

func (*ConnHandle) UnregisterHandle added in v0.2.0

func (h *ConnHandle) UnregisterHandle(ctx context.Context) error

UnregisterHandle removes exactly the connection h was issued for: the entry-guarded counterpart to Registry.Unregister(id). It is a safe no-op if a takeover has since replaced this connection under its id - h's own entry is no longer the current owner of that id, so there is nothing left for this specific handle to tear down. Callers that must remove whatever currently owns an id, regardless of which registration installed it, use the plain, id-scoped Unregister instead.

type ConnInfo added in v0.2.0

type ConnInfo struct {
	// ID is the connection id, unique cluster-wide. When empty, the handler generates a
	// UUID for it.
	ID string

	// Group is the identity the connection belongs to, e.g. "user:123". Several devices
	// or browser tabs of the same identity share one Group, which is what makes
	// Registry.SendToGroup and Registry.IsOnline talk about a person rather than a socket.
	Group string

	// Topics are the pub/sub topics the connection is joined to at registration time.
	Topics []string

	// Meta carries application data resolved during the handshake (roles, tenant, plan)
	// through to the onConnect/onMessage callbacks unchanged.
	Meta map[string]string
}

ConnInfo is the identity of a single connection, resolved once during the WebSocket/SSE handshake and then carried through registration and every subsequent callback. Resolving it once is the point: without it the auth token would be parsed again for the id, again for the topics, and again for whatever the application needs in its message handler.

type Coordinator

type Coordinator interface {
	// Get returns the value stored for key. ok is false if key is absent or has
	// expired; a missing key is not an error.
	Get(ctx context.Context, key string) (value []byte, ok bool, err error)
	// Put stores value under key. A ttl of zero or less means the value never expires
	// on its own (it is overwritten or explicitly replaced instead).
	Put(ctx context.Context, key string, value []byte, ttl time.Duration) error
	// TryLock attempts to acquire an exclusive, TTL-bounded lock on key. On success it
	// returns an unlock function that releases the lock; unlock is safe to call more
	// than once and safe to call after ttl has already elapsed (a no-op in that case,
	// since the lock is already gone). On failure it returns ErrLockNotAcquired.
	//
	// The lock is a real mutual exclusion primitive: implementations must guarantee
	// that at most one caller holds a given key at a time, and that a caller's unlock
	// never releases a lock some other caller has since acquired (e.g. after the
	// original holder's TTL expired).
	TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(context.Context) error, err error)
}

Coordinator is the storage-agnostic backend Manager uses to arbitrate certificate issuance and distribute the result across every process that shares it. It is owned by this library rather than by GoAkt: GoAkt's cluster KV store is PA/EC with last-write-wins semantics and documents its own lock as "recommended for efficiency, not correctness", which is the wrong substrate for a certificate issuance lock - a duplicated issuance burns CA rate limit.

A Coordinator does not have to be cluster-shared: NewMemoryCoordinator (the default) is process-local and gives Manager the same single-flight-only behavior a non-clustered deployment needs. Share a Coordinator implementation across processes (e.g. coordinator/redis) to get cluster-wide arbitration and distribution.

Implementations must be safe for concurrent use.

type DeliveryResult added in v0.2.0

type DeliveryResult struct {
	// Delivered counts the connections held by this node whose outbound buffer accepted
	// the payload. It is not an acknowledgement that the bytes reached the peer.
	Delivered int

	// Dropped counts the connections held by this node that could not take the payload,
	// overwhelmingly because their outbound buffer was full (see ErrBackpressure). It also
	// counts a local entry a cross-node takeover has already fenced but not yet evicted (see
	// WithOwnerLease and Registry.staleOwner): the connection is still present in this node's
	// table, so it counts here rather than nowhere, but nothing was written to its socket.
	Dropped int

	// Remote counts the cluster fan-outs this node published for the delivery, or, on the
	// confirmed path, the remote sockets that acknowledged the write.
	//
	// Its meaning, and how much to trust it, depends on configuration:
	//   - No Presence backend: the registry cannot tell how many nodes hold members, so it
	//     is 1 whenever a cluster fan-out was published and 0 otherwise.
	//   - Presence, no WithDeliveryConfirmation: it is the number of group members Presence
	//     reports on other nodes. Presence membership is lease-based and lags reality, so a
	//     member on a node that has just crashed still counts until its lease lapses (up to
	//     the presence TTL). Remote can therefore be non-zero for a delivery no live socket
	//     will take.
	//   - Presence and WithDeliveryConfirmation: it is the number of remote sockets that
	//     acknowledged the write within the confirmation timeout. A member that received the
	//     payload but did not acknowledge in time (a slow or paused node) is not counted, so
	//     Remote can undercount an at-least-once delivery.
	Remote int
}

DeliveryResult reports what a fan-out delivery (Registry.SendToGroup, Registry.Broadcast) actually did. It is deliberately three separate counters rather than a single "sent" number, because the three outcomes have very different meanings for the caller: Delivered is a socket that will get the payload, Dropped is a socket that exists but lost the payload to backpressure, and Remote is a fan-out this node handed to the cluster and can no longer observe.

func (DeliveryResult) None added in v0.2.0

func (d DeliveryResult) None() bool

None reports whether the delivery reached nothing this node could account for: no local connection, and no remote fan-out or confirmed remote write (see Remote). It is the signal to fall back to an offline channel such as web push.

None is exactly as accurate as Remote, and so is not an absolute "nothing anywhere took this payload":

  • Without a Presence backend a clustered registry always publishes a fan-out, so None is never true even when no node holds a matching connection.
  • With Presence but no WithDeliveryConfirmation, a stale membership lease for a just crashed node keeps None false for a delivery that in fact reached no live socket, up to the presence TTL. Only WithDeliveryConfirmation closes this window.
  • With Presence and WithDeliveryConfirmation, None becomes true when no remote socket acknowledged within the confirmation timeout. A node that buffered the payload but was too slow to acknowledge is not counted, so the offline fallback is at-least-once: it may fire for an identity a slow node did ultimately deliver to, duplicating that message on the offline channel. That is the deliberate trade: never lose a notification, at the cost of a possible duplicate.

func (DeliveryResult) Total added in v0.2.0

func (d DeliveryResult) Total() int

Total returns the number of connections this delivery reached in any way, successful or not.

type DomainPolicy added in v0.2.0

type DomainPolicy func(ctx context.Context, domain string) (bool, error)

DomainPolicy decides at handshake time whether domain may be served (and, if no certificate exists yet, issued for). It is the dynamic counterpart to WithAllowedDomains: multi-tenant deployments that let customers bring their own domain cannot enumerate a static allow list, so they plug in a policy that consults their own source of truth (e.g. "is this hostname bound to an active tenant?").

It is called on the TLS handshake path, so it must be fast and safe for concurrent use. Its verdict is only consulted for domains that are neither already cached nor negatively cached (see WithNegativeCacheTTL), so a policy backed by a database is not queried once per handshake.

Returning an error (as opposed to false) means the policy itself could not reach a verdict; the handshake fails and nothing is cached, so the next handshake asks again.

type Drainer

type Drainer interface {
	Drain()
}

Drainer is implemented by connection handlers (WSHandler, SSEHandler) that hold long-lived connections http.Server.Shutdown cannot evict on its own: Shutdown ignores hijacked WebSocket sockets entirely and would wait on open SSE streams until its context expired.

type FileCertStore added in v0.2.0

type FileCertStore struct {
	// contains filtered or unexported fields
}

FileCertStore is a CertStore backed by a directory on disk: the certificate for a domain lives in <dir>/<domain>.crt and its private key in <dir>/<domain>.key. It survives a process restart, so a node that comes back up with a warm volume (or a persistent container filesystem) serves its domains without depending on the issuer or the Coordinator being reachable.

Certificate.NotAfter is not stored separately: it is read back from the leaf certificate, which keeps the on-disk format a plain PEM pair that other tooling (openssl, a k8s secret) can produce and consume.

func NewFileCertStore added in v0.2.0

func NewFileCertStore(dir string) (*FileCertStore, error)

NewFileCertStore creates a FileCertStore rooted at dir, creating the directory if it does not exist. The directory holds private keys, so it is created with 0700 permissions.

func (*FileCertStore) Delete added in v0.2.0

func (f *FileCertStore) Delete(_ context.Context, domain string) error

Delete implements CertStore.

func (*FileCertStore) Get added in v0.2.0

func (f *FileCertStore) Get(_ context.Context, domain string) (*Certificate, error)

Get implements CertStore.

FileCertStore serializes its own Put and Delete operations against Get, so a caller using one store instance never observes the gap between the two file renames. The bounded retry remains for pairs written by external tools, which cannot participate in this in-process lock.

func (*FileCertStore) Put added in v0.2.0

func (f *FileCertStore) Put(_ context.Context, cert *Certificate) error

Put implements CertStore. Both files are written to a temporary name and renamed into place so a concurrent Get never observes a half-written PEM file.

Both temporary files are written and fsync'd before either is renamed, and the two renames then run back to back. This keeps the window in which a concurrent Get can see the new certificate beside the still-old key down to the gap between two adjacent rename syscalls - no fsync in between - which Get's bounded re-read reliably outlasts. Renaming the cert and only then starting to write and fsync the key temp would instead hold that mismatched pair on disk for as long as the key's fsync takes (milliseconds), far too long for Get to hide.

type GenerationalHistory added in v0.2.0

type GenerationalHistory interface {
	SSEHistory

	// AppendGenerational is Append fenced by generation. It records the event and returns the
	// sequence number assigned to it - a per-connection counter that increases by exactly one
	// on every accepted call, with no gaps and no repeats, so a caller can detect reordering or
	// loss independently of whatever event IDs it chose - unless generation is lower than the
	// highest generation already observed for connID (by a prior AppendGenerational or
	// AdvanceGeneration call), in which case it returns ErrStaleGeneration and neither records
	// the event nor advances any state.
	AppendGenerational(ctx context.Context, connID, eventID string, payload []byte, generation uint64) (seq uint64, err error)

	// AdvanceGeneration records that connID's owner lease has moved to generation without
	// appending any event, so a stale writer using the previous generation is rejected by the
	// very first AppendGenerational call it attempts after a takeover instead of racing to
	// append one more event before the new owner does. Call it once a takeover's lease
	// acquisition succeeds, before traffic resumes on the new owner.
	//
	// It is a no-op, not an error, when generation is not strictly greater than what is already
	// recorded for connID - that means a takeover (this one or a still newer one) already
	// advanced the record past it, so there is nothing to do.
	AdvanceGeneration(ctx context.Context, connID string, generation uint64) error
}

GenerationalHistory is an optional capability of an SSEHistory that fences writes by generation once WithOwnerLease is configured for a connection's owner lease. It exists alongside the plain SSEHistory contract, not in place of it: a caller that never acquired a lease keeps calling Append and Since exactly as before, at zero additional cost, and only a caller that holds a fenced generation type-asserts up to this interface to use it. This mirrors the WithDeliveryConfirmation opt-in precedent elsewhere in this package.

MemorySSEHistory and ssehistory/redis.History both implement it. A shared third-party SSEHistory must also implement SharedSSEHistory or SSEHandler treats it as explicitly local-only. A marked shared backend without this capability is rejected at handler setup.

type LinearizableFencingCoordinator added in v0.2.0

type LinearizableFencingCoordinator interface {
	CASCoordinator

	LinearizableFencing()
}

LinearizableFencingCoordinator explicitly declares that Get and CompareAndSwap provide a single, linearizable order across every process that uses the Coordinator. WithOwnerLease requires this capability: atomic CAS alone is insufficient when a replicated backend can acknowledge a write and later fail over to a replica that never observed it.

LinearizableFencing is a marker method so implementations must make this guarantee deliberately. It has no runtime work; its presence is the provider's public contract.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager terminates TLS for one or more domains from certificates shared across every process that shares its Coordinator: issuance for a given domain is arbitrated through Coordinator.TryLock so at most one caller invokes the configured CertIssuer, the result is distributed through Coordinator.Put/Get, and a cron renewal schedule (single-fire cluster-wide by GoAkt scheduler design when running in a GoAkt cluster) drives renewal ahead of expiry.

The issuance lock is held for at most WithIssuanceLockTTL and is not renewed/extended while a CertIssuer call is outstanding: if issuance takes longer than lockTTL, the lock can expire mid-issuance, letting a second process acquire it and also call the issuer. doEnsure detects this after the fact and returns ErrIssuanceLockExpired instead of caching the result, but the extra CertIssuer call itself is not prevented. Set WithIssuanceLockTTL comfortably longer than your CertIssuer's worst-case latency.

Without an explicit WithCoordinator, Manager defaults to a process-local MemoryCoordinator: issuance is still deduplicated (so concurrent handshakes for a cold domain only call the issuer once per process) but nothing is shared across processes. Share a cluster-backed Coordinator (e.g. coordinator/redis) to get cluster-wide arbitration and distribution.

Because the SNI server name a Manager is asked for is entirely attacker-controlled, a Manager configured with a CertIssuer denies every domain by default: WithAllowedDomains, WithDomainPolicy, or both must be configured to declare which domains may be served or issued for. Without either, EnsureCertificate/GetCertificate fail with ErrDomainNotAllowed for every domain rather than letting an arbitrary SNI value trigger a real issuance against the CA (which is rate limited and quota'd). This deny-by-default only applies once a CertIssuer is configured; without one, Manager can only ever serve a pre-provisioned certificate (from CertStore or WithFallbackCertificate), so allowing every domain through costs nothing. Domains that are refused, or whose issuance fails, are negatively cached for WithNegativeCacheTTL, and the hot certificate cache is bounded by WithMaxCachedCerts.

A Manager is safe for concurrent use. Use NewManager to construct one.

Example

ExampleManager shows a Manager that admits domains dynamically with WithDomainPolicy and serves a WithFallbackCertificate for connections it has no per-domain certificate for. A multi-tenant gateway uses this shape when the set of servable hostnames lives in a database rather than in a static allow list.

GetCertificate is driven directly here; in production it is assigned to tls.Config.GetCertificate (see Manager.TLSConfig). Neither GetCertificate nor the renewal schedule is exercised, so no ActorSystem is needed for the example to run.

// The fallback is served both when the ClientHello carries no SNI and when an admitted
// domain has no CertIssuer configured to obtain a per-domain certificate.
certPEM, keyPEM := generateTestCertificate("fallback.example.com", time.Now().Add(24*time.Hour))
fallback, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
	panic(err)
}

// The policy is the dynamic admission check: only "tenant.example.com" is servable.
// A real deployment would consult its own source of truth (e.g. a tenant lookup).
policy := func(_ context.Context, domain string) (bool, error) {
	return domain == "tenant.example.com", nil
}

manager := gateway.NewManager(nil, nil,
	gateway.WithDomainPolicy(policy),
	gateway.WithFallbackCertificate(func() (*tls.Certificate, error) { return &fallback, nil }),
)

// No SNI: nothing to look a certificate up by, so the fallback is served.
noSNI, err := manager.GetCertificate(&tls.ClientHelloInfo{})
fmt.Println("no SNI served:", err == nil && noSNI != nil)

// An admitted domain with no CertIssuer also falls back to the fallback certificate.
admitted, err := manager.GetCertificate(&tls.ClientHelloInfo{ServerName: "tenant.example.com"})
fmt.Println("admitted domain served:", err == nil && admitted != nil)

// A refused domain is never handed the fallback: admission wins, so the handshake
// fails with ErrDomainNotAllowed instead of leaking a valid certificate.
_, err = manager.GetCertificate(&tls.ClientHelloInfo{ServerName: "blocked.example.com"})
fmt.Println("refused domain blocked:", errors.Is(err, gateway.ErrDomainNotAllowed))
Output:
no SNI served: true
admitted domain served: true
refused domain blocked: true

func NewManager

func NewManager(system actor.ActorSystem, logger log.Logger, opts ...ManagerOption) *Manager

NewManager creates a Manager backed by system. Call Start before serving traffic so the renewal schedule is registered, and Stop during shutdown to release it.

func (*Manager) EnsureCertificate

func (m *Manager) EnsureCertificate(ctx context.Context, domain string) (*tls.Certificate, error)

EnsureCertificate returns a ready-to-serve certificate for domain, issuing (or fetching from another caller's issuance) one if necessary. Concurrent calls for the same domain on this process are deduplicated; concurrent calls across every process that shares this Manager's Coordinator are arbitrated so only one of them calls the configured CertIssuer (see Manager documentation).

Admission is checked before anything reaches the CertIssuer: a domain covered by neither WithAllowedDomains nor WithDomainPolicy is refused with ErrDomainNotAllowed. A refusal, like a failed issuance, is remembered for WithNegativeCacheTTL so a repeat of the same request costs neither a policy lookup nor a CA call.

It returns ErrNoIssuer if issuance is required but no CertIssuer was configured.

func (*Manager) GetCertificate

func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)

GetCertificate implements the tls.Config.GetCertificate signature for SNI-based dynamic certificate lookup: assign it directly, e.g. tls.Config{GetCertificate: manager.GetCertificate}.

A ClientHello without SNI is served the WithFallbackCertificate, if one is configured, and otherwise fails: there is nothing to look a certificate up by. A domain with no certificate and no CertIssuer to obtain one also falls back, which is what makes a Manager configured with nothing but WithFallbackCertificate behave as a catch-all certificate server.

func (*Manager) Start

func (m *Manager) Start(ctx context.Context) error

Start registers the renewal schedule (cluster-single-fire when system is running in a GoAkt cluster, driven by the schedule reference rather than by actor identity - see certRenewalActorNamePrefix). It is a no-op if renewal was disabled via WithRenewInterval(""). Concurrent calls for the same Manager share one local renewal actor, while distinct Managers on separate nodes use unique names.

func (*Manager) Stop

func (m *Manager) Stop(ctx context.Context) error

Stop cancels the renewal schedule and stops its backing actor. It is a no-op if Start was never called or renewal was disabled.

func (*Manager) TLSConfig

func (m *Manager) TLSConfig() *tls.Config

TLSConfig returns a *tls.Config wired to serve certificates dynamically by SNI through this Manager.

type ManagerOption

type ManagerOption func(*Manager)

ManagerOption configures a Manager created with NewManager.

func WithAllowedDomains

func WithAllowedDomains(domains ...string) ManagerOption

WithAllowedDomains restricts issuance/serving to the given domains. A domain may be given as an exact name ("example.com") or as a single-label wildcard ("*.example.com", which matches "a.example.com" but neither "example.com" itself nor "a.b.example.com", mirroring how TLS clients match a wildcard SAN).

When WithDomainPolicy is also configured, the static list is checked first and the policy is only consulted for domains it does not cover, so the common, statically known domains never pay for a policy lookup.

Without either option, a Manager configured with WithCertIssuer denies every domain by default; see the Manager documentation.

func WithCertIssuer

func WithCertIssuer(issuer CertIssuer) ManagerOption

WithCertIssuer sets the CertIssuer used to obtain certificates that are not already cached or stored. Without one, Manager can only serve certificates that a CertStore already has (e.g. pre-populated via CertStore.Put).

SECURITY: the SNI server name a Manager is asked for is entirely attacker-controlled, so configuring a CertIssuer alone denies every domain by default. WithAllowedDomains, WithDomainPolicy, or both must also be configured to declare which domains may be admitted; without either, EnsureCertificate/GetCertificate return ErrDomainNotAllowed for every domain. This is what stops a remote party from exhausting the CA's (rate-limited, quota'd) issuance budget by opening handshakes with random hostnames. Refused/failed domains are additionally negatively cached (WithNegativeCacheTTL) and the hot cache is bounded (WithMaxCachedCerts), but neither substitutes for an admission policy.

func WithCertStore

func WithCertStore(store CertStore) ManagerOption

WithCertStore overrides the persistent CertStore layer. Defaults to a MemoryCertStore.

func WithCoordinator

func WithCoordinator(coordinator Coordinator) ManagerOption

WithCoordinator overrides the Coordinator Manager uses to arbitrate issuance and distribute certificates. Defaults to a process-local NewMemoryCoordinator; pass a cluster-shared implementation (e.g. coordinator/redis) to coordinate issuance across every process that shares it.

func WithDomainPolicy added in v0.2.0

func WithDomainPolicy(p DomainPolicy) ManagerOption

WithDomainPolicy sets a dynamic admission check consulted for domains not covered by WithAllowedDomains. Use it for multi-tenant custom domains, where the set of servable domains lives in a database rather than in configuration.

func WithFallbackCertificate added in v0.2.0

func WithFallbackCertificate(get func() (*tls.Certificate, error)) ManagerOption

WithFallbackCertificate sets the certificate served when the ClientHello carries no SNI, or when the requested domain has no certificate and no CertIssuer is configured to obtain one. It covers the "TLS terminated at a CDN edge, origin serves a single catch-all certificate" deployment, where SNI is irrelevant and the origin simply presents the same certificate to every connection; combine it with NewReloadingCertificate to pick up rotations of a certificate mounted from a Kubernetes secret.

A domain that was explicitly refused (by WithAllowedDomains/WithDomainPolicy) is never served the fallback: the handshake fails with ErrDomainNotAllowed, because answering a refused name with a valid certificate would defeat the admission check.

func WithIssuanceLockTTL

func WithIssuanceLockTTL(d time.Duration) ManagerOption

WithIssuanceLockTTL sets how long the coordinated issuance lock for a domain is held for. Defaults to 2 minutes. The lock is not renewed while the CertIssuer call is in flight, so set this comfortably longer than your CertIssuer's worst-case latency: if issuance overruns it, doEnsure returns ErrIssuanceLockExpired instead of silently caching a result whose single-issuer guarantee is no longer certain.

func WithMaxCachedCerts added in v0.2.0

func WithMaxCachedCerts(n int) ManagerOption

WithMaxCachedCerts bounds the number of entries Manager keeps in its hot certificate cache and in its negative cache, evicting the least recently used entry beyond that. Defaults to 1024. Both caches are keyed by the peer-supplied SNI server name, so a bound is what keeps memory from growing with the number of distinct names a peer chooses to send. Values <= 0 are ignored and keep the default.

func WithNegativeCacheTTL added in v0.2.0

func WithNegativeCacheTTL(d time.Duration) ManagerOption

WithNegativeCacheTTL sets how long a refused domain (or one whose issuance failed) is remembered so that a repeated handshake for it is answered from memory. Defaults to one minute. This is what keeps a flood of handshakes carrying unknown SNI values from turning into a flood of DomainPolicy lookups and CA issuance attempts. Pass a value <= 0 to disable negative caching entirely (not recommended when a CertIssuer is configured).

func WithRenewBefore

func WithRenewBefore(d time.Duration) ManagerOption

WithRenewBefore sets how far ahead of a certificate's expiry Manager renews it. Defaults to 30 days.

func WithRenewInterval

func WithRenewInterval(cronExpression string) ManagerOption

WithRenewInterval sets the cron expression (go-quartz syntax, 6 fields with seconds first) the renewal schedule uses to check for expiring certificates. Defaults to hourly ("0 0 * * * *"). Pass an empty string to disable automatic renewal entirely (EnsureCertificate/GetCertificate will still re-issue on-demand once a served certificate has actually expired).

type MemoryCertStore

type MemoryCertStore struct {
	// contains filtered or unexported fields
}

MemoryCertStore is the default, in-process CertStore. It does not survive a process restart, which is why Manager also always keeps a copy in the configured Coordinator: on a single node restart the certificate is fetched back from there, and on a full cold start (no Coordinator, or a fresh one) it is re-issued.

func NewMemoryCertStore

func NewMemoryCertStore() *MemoryCertStore

NewMemoryCertStore creates an empty MemoryCertStore.

func (*MemoryCertStore) Delete

func (m *MemoryCertStore) Delete(_ context.Context, domain string) error

Delete implements CertStore.

func (*MemoryCertStore) Get

func (m *MemoryCertStore) Get(_ context.Context, domain string) (*Certificate, error)

Get implements CertStore.

func (*MemoryCertStore) Put

func (m *MemoryCertStore) Put(_ context.Context, cert *Certificate) error

Put implements CertStore.

type MemoryCoordinator

type MemoryCoordinator struct {
	// contains filtered or unexported fields
}

MemoryCoordinator is the default, in-process Coordinator. It is appropriate for a single-node deployment, local development, and tests: TryLock/Put/Get only coordinate callers within this process, so it gives Manager exactly the "issue once per process" behavior a non-clustered gateway needs. Share a cluster-backed Coordinator (e.g. coordinator/redis) across processes to get cluster-wide arbitration.

func NewMemoryCoordinator

func NewMemoryCoordinator() *MemoryCoordinator

NewMemoryCoordinator creates an empty MemoryCoordinator.

func (*MemoryCoordinator) CompareAndSwap added in v0.2.0

func (m *MemoryCoordinator) CompareAndSwap(_ context.Context, key string, expected, newValue []byte, ttl time.Duration) (bool, error)

CompareAndSwap implements CASCoordinator. It shares MemoryCoordinator's entries map with Get/Put (same key space, same lazy-expiry check) and performs the read-compare-write under the same mutex, so it is atomic with respect to every other Coordinator method.

func (*MemoryCoordinator) Get

func (m *MemoryCoordinator) Get(_ context.Context, key string) ([]byte, bool, error)

Get implements Coordinator.

func (*MemoryCoordinator) LinearizableFencing added in v0.2.0

func (*MemoryCoordinator) LinearizableFencing()

LinearizableFencing declares that MemoryCoordinator serializes Get and CompareAndSwap under one process-wide mutex. It is therefore suitable for strict OwnerLease fencing only within that process.

func (*MemoryCoordinator) Put

func (m *MemoryCoordinator) Put(_ context.Context, key string, value []byte, ttl time.Duration) error

Put implements Coordinator.

func (*MemoryCoordinator) TryLock

func (m *MemoryCoordinator) TryLock(_ context.Context, key string, ttl time.Duration) (func(context.Context) error, error)

TryLock implements Coordinator. Ownership is tracked with a per-acquisition token so that an unlock call can never release a lock some other caller has since acquired after this one's ttl elapsed.

type MemoryOutbox added in v0.2.0

type MemoryOutbox struct {
	// contains filtered or unexported fields
}

MemoryOutbox is an in-process Outbox. It fits a single-node deployment and tests; in a cluster it is the wrong choice, because a connection that reconnects to a different node finds none of the unacknowledged messages the original node held.

func NewMemoryOutbox added in v0.2.0

func NewMemoryOutbox() *MemoryOutbox

NewMemoryOutbox creates an in-process Outbox.

func (*MemoryOutbox) Ack added in v0.2.0

func (o *MemoryOutbox) Ack(_ context.Context, connID, msgID string, generation uint64) error

Ack removes the message identified by msgID from connID, fenced by generation (see the Outbox.Ack doc comment). Acking a connID this Outbox has never appended to is a true no-op: nothing is created, and no fencing floor is recorded. Acking a known connID with an unknown msgID still raises the fencing floor to generation (if higher) even though no message is removed, so a later, lower-generation ack for that same connID is correctly rejected as stale regardless of which specific message it names.

func (*MemoryOutbox) AdvanceGeneration added in v0.2.0

func (o *MemoryOutbox) AdvanceGeneration(_ context.Context, connID string, generation uint64) error

AdvanceGeneration implements OutboxGenerationAdvancer: it raises connID's Ack fencing floor to generation, creating tracking state for connID if none exists yet (unlike Ack, which never creates state for a connID it has no message for - see the generations field doc comment for why that gate does not apply here).

func (*MemoryOutbox) Append added in v0.2.0

func (o *MemoryOutbox) Append(_ context.Context, connID string, payload []byte) (string, uint64, error)

Append records payload as unacknowledged for connID under a freshly minted message id, stamping it with the next per-connection sequence number. The payload is copied before it is stored: the caller (and SendToConnection, which reuses the same slice to write the socket) may mutate or reuse the backing array afterwards, and the stored redelivery snapshot must not change with it. A serializing backend such as persistence/redis copies inherently; MemoryOutbox matches that contract explicitly.

func (*MemoryOutbox) DropConn added in v0.2.0

func (o *MemoryOutbox) DropConn(_ context.Context, connID string) error

DropConn removes all state for connID, including its sequence counter and generation fencing floor, so a future connection reusing the id starts fresh. It is the only thing that reclaims the counter: Ack deliberately leaves it in place so Seq keeps rising while the id is live.

func (*MemoryOutbox) Unacked added in v0.2.0

func (o *MemoryOutbox) Unacked(_ context.Context, connID string) ([]PersistedMessage, error)

Unacked returns every unacknowledged message for connID in ascending Seq order. Each returned message carries an independent copy of its payload: copying the PersistedMessage struct alone would alias the stored slice's backing array, so a caller mutating a returned Payload (or two concurrent readers touching theirs) would corrupt the stored snapshot and each other. The tail is small, so cloning it per read is cheap.

type MemoryPresence added in v0.2.0

type MemoryPresence struct {
	// contains filtered or unexported fields
}

MemoryPresence is an in-process Presence backend. It is the right choice for a single-node deployment and for tests, and the wrong choice for anything clustered: its view of "online" stops at the process boundary. It also implements PresenceWatcher and PresenceDirectory, so WatchPresence and GroupMembers are fully functional against it on a single node.

func NewMemoryPresence added in v0.2.0

func NewMemoryPresence() *MemoryPresence

NewMemoryPresence creates an in-process Presence backend.

func (*MemoryPresence) Entries added in v0.2.0

func (p *MemoryPresence) Entries(_ context.Context, group string) ([]PresenceEntry, error)

Entries returns every live member of group with its recorded metadata.

func (*MemoryPresence) Join added in v0.2.0

func (p *MemoryPresence) Join(_ context.Context, group, connID string, ttl time.Duration) error

Join records connID as an online member of group for at most ttl. A non-positive ttl records a member that never expires. It emits a PresenceJoin event only when connID was not already a member, so a re-join of a live member does not double-count.

func (*MemoryPresence) JoinWithMeta added in v0.2.0

func (p *MemoryPresence) JoinWithMeta(_ context.Context, group, connID string, meta map[string]string, ttl time.Duration) error

JoinWithMeta is Join with metadata recorded for the connection, used by Registry.Register so GroupMembers can return it.

func (*MemoryPresence) Leave added in v0.2.0

func (p *MemoryPresence) Leave(_ context.Context, group, connID string) error

Leave removes connID from group. It is a no-op when the member is already gone, and emits a PresenceLeave event only when a live member was actually removed.

func (*MemoryPresence) LeaveGen added in v0.2.0

func (p *MemoryPresence) LeaveGen(_ context.Context, group, connID string, generation uint64) error

LeaveGen is Leave fenced by generation: see PresenceFencer.

func (*MemoryPresence) Members added in v0.2.0

func (p *MemoryPresence) Members(_ context.Context, group string) ([]string, error)

Members returns the connection ids currently online for group, dropping and reclaiming lapsed leases on the way.

func (*MemoryPresence) Online added in v0.2.0

func (p *MemoryPresence) Online(_ context.Context, group string) (bool, error)

Online reports whether group has at least one live member.

func (*MemoryPresence) Refresh added in v0.2.0

func (p *MemoryPresence) Refresh(_ context.Context, group, connID string, ttl time.Duration) error

Refresh extends the lease of connID in group by ttl. A member whose lease already lapsed is re-recorded rather than rejected: the caller is the node that actually holds the socket, so its view wins over an expired lease. Refresh keeps any recorded metadata and, being an ongoing renewal rather than a fresh join, emits no event for a member that was already present.

func (*MemoryPresence) RefreshGen added in v0.2.0

func (p *MemoryPresence) RefreshGen(_ context.Context, group, connID string, generation uint64, ttl time.Duration) error

RefreshGen is Refresh fenced by generation: see PresenceFencer.

func (*MemoryPresence) Watch added in v0.2.0

func (p *MemoryPresence) Watch(ctx context.Context, group string) (<-chan PresenceEvent, func(), error)

Watch subscribes to membership changes for group. The returned cancel unsubscribes and closes the channel and is safe to call more than once.

type MemorySSEHistory added in v0.2.0

type MemorySSEHistory struct {
	// contains filtered or unexported fields
}

MemorySSEHistory is an in-process SSEHistory: a ring buffer of the last perConn events per connection, bounded to maxConns connections.

Reclamation is deliberately not tied to disconnects: replay happens after a disconnect, so dropping a connection's buffer when its stream ends would defeat the entire feature. What bounds the memory instead is (1) the per-connection cap, and (2) an LRU over connections, so a connection that never comes back is evicted once maxConns newer ones have gone through. Applications that know a session is over for good (logout, account deletion) can reclaim it immediately with Drop.

It is a single-process buffer, so it only replays for a client that reconnects to the same node. Route reconnects by connection id (sticky sessions), or plug in a shared backend implementing SSEHistory.

func NewMemorySSEHistory added in v0.2.0

func NewMemorySSEHistory(perConn int, opts ...MemorySSEHistoryOption) *MemorySSEHistory

NewMemorySSEHistory returns a MemorySSEHistory retaining the last perConn events of each connection. A perConn below 1 is raised to 1.

func (*MemorySSEHistory) AdvanceGeneration added in v0.2.0

func (h *MemorySSEHistory) AdvanceGeneration(_ context.Context, connID string, generation uint64) error

AdvanceGeneration implements GenerationalHistory.

func (*MemorySSEHistory) Append added in v0.2.0

func (h *MemorySSEHistory) Append(_ context.Context, connID, eventID string, payload []byte) error

Append implements SSEHistory. The payload is copied: the caller owns its buffer and the history outlives the write.

func (*MemorySSEHistory) AppendGenerational added in v0.2.0

func (h *MemorySSEHistory) AppendGenerational(_ context.Context, connID, eventID string, payload []byte, generation uint64) (uint64, error)

AppendGenerational implements GenerationalHistory.

func (*MemorySSEHistory) Drop added in v0.2.0

func (h *MemorySSEHistory) Drop(connID string)

Drop discards everything retained for connID. Use it when a session is over for good and waiting for LRU eviction is not good enough.

func (*MemorySSEHistory) Len added in v0.2.0

func (h *MemorySSEHistory) Len() int

Len reports how many connections currently have retained events.

func (*MemorySSEHistory) Since added in v0.2.0

func (h *MemorySSEHistory) Since(_ context.Context, connID, lastEventID string) ([]SSEEvent, error)

Since implements SSEHistory.

type MemorySSEHistoryOption added in v0.2.0

type MemorySSEHistoryOption func(*MemorySSEHistory)

MemorySSEHistoryOption configures a MemorySSEHistory.

func WithSSEHistoryMaxConnections added in v0.2.0

func WithSSEHistoryMaxConnections(n int) MemorySSEHistoryOption

WithSSEHistoryMaxConnections caps how many connections a MemorySSEHistory retains buffers for. When the cap is reached, the least recently touched connection is evicted whole. Values below 1 are ignored. Defaults to 4096.

type Observer added in v0.2.0

type Observer interface {
	// ConnectionRegistered is called once a connection is fully registered and
	// addressable cluster-wide.
	ConnectionRegistered(id, group string)

	// ConnectionUnregistered is called once a connection has been removed from the local
	// table, including when it is removed to make room for a takeover.
	ConnectionUnregistered(id, group string)

	// ConnectionReplaced is called when a registration with WithReplaceExisting evicted
	// an existing connection with the same id.
	ConnectionReplaced(id, group string)

	// DeliveryDropped is called when a payload could not be queued for a local connection
	// because its outbound buffer was full.
	DeliveryDropped(id, group string)

	// DeliveryFailed is called when a local connection's send function reported an error
	// other than backpressure, e.g. a socket that is already gone.
	DeliveryFailed(id string, err error)

	// BroadcastFanout is called once per Registry.Broadcast with the number of local
	// members the payload was written to on this node.
	BroadcastFanout(topic string, localMembers int)
}

Observer receives the connection and delivery events a Registry produces, so that metrics, tracing and audit logging can be attached without this library depending on any particular telemetry stack.

Every method is called synchronously on the goroutine that produced the event, which is frequently a delivery hot path: implementations must not block. An Observer is entirely optional (see WithObserver); a Registry without one calls nothing.

type OfflineChannel added in v0.2.0

type OfflineChannel interface {
	// Deliver hands payload to the offline transport for the identity group. The group is
	// the same identity string SendToGroup fans out to; the implementation maps it to the
	// concrete push subscriptions or addresses it holds.
	Deliver(ctx context.Context, group string, payload []byte) error
}

OfflineChannel is an out-of-band delivery path for a group whose identity is not reachable over any live socket in the cluster: web push, mail, SMS. Registry.SendToGroup calls Deliver automatically when a fan-out reached nothing (DeliveryResult.None) and an OfflineChannel is configured (see WithOfflineChannel), so an application does not have to branch on the delivery result itself.

Deliver runs off the caller's SendToGroup goroutine and must not be relied on to block that call: its outcome is reported through OfflineObserver and the Registry logger, not through SendToGroup's return.

type OfflineObserver added in v0.2.0

type OfflineObserver interface {
	// OfflineFallback is called after Registry.SendToGroup routes a group with no reachable
	// socket to the configured OfflineChannel. err is nil on success and the channel's
	// error on failure.
	OfflineFallback(group string, err error)
}

OfflineObserver is an optional Observer extension. When the configured Observer also implements it, the Registry reports every OfflineChannel fallback attempt through it: once with a nil error when the offline delivery succeeded, and once with the underlying error when it failed. It is kept off the core Observer interface so existing Observer implementations do not have to change to compile against a Registry that never uses an offline channel.

type OfflineOption added in v0.2.0

type OfflineOption func(*boundedOffline)

OfflineOption tunes how the Registry drives a configured OfflineChannel.

func WithOfflineMaxConcurrent added in v0.2.0

func WithOfflineMaxConcurrent(n int) OfflineOption

WithOfflineMaxConcurrent caps how many offline fallback deliveries run concurrently. When the cap is reached a further fallback is dropped rather than queued, and the drop is reported through OfflineObserver and the logger. Values below 1 are ignored. Defaults to 256.

func WithOfflineTimeout added in v0.2.0

func WithOfflineTimeout(d time.Duration) OfflineOption

WithOfflineTimeout bounds a single offline delivery: the context handed to Deliver is cancelled after d. Values below or equal to zero are ignored. Defaults to 10 seconds.

type Outbox added in v0.2.0

type Outbox interface {
	// Append records payload as unacknowledged for connID, minting a unique message id and
	// stamping it with a per-connection sequence number, both returned so the caller can embed
	// them in the wire envelope it writes to the socket (see EncodeOutboxEnvelope) before the
	// payload ever reaches the client. It is called before the payload is written to the
	// socket, so a send that never reaches the client still leaves a record to redeliver.
	//
	// The Outbox, not the caller, allocates both the id and the Seq, from its own durable and
	// shared state: only that keeps them unique and monotonic across every process and node
	// that appends to the same connID through the same backend, and across a restart of any of
	// them. The sequence is reclaimed only by DropConn (or a backend TTL), so a connection that
	// acks its whole tail still keeps counting up rather than reusing a Seq a still-connected
	// client already saw.
	Append(ctx context.Context, connID string, payload []byte) (msgID string, seq uint64, err error)

	// Unacked returns every message still unacknowledged for connID, in ascending Seq
	// order. The Registry calls it when connID registers, to redeliver what the previous
	// socket did not ack. A caller enforcing a replay-then-realtime delivery order reads the
	// tail's high-water mark off the result with ReplayTailSeq.
	Unacked(ctx context.Context, connID string) ([]PersistedMessage, error)

	// Ack removes the message identified by msgID from connID's unacknowledged set, fenced by
	// generation: once an Ack for connID has been accepted under a given generation, a later
	// call carrying a lower one is rejected with ErrStaleOwner instead of applied, so a node
	// whose owner lease (see WithOwnerLease) has already been taken over by another node
	// cannot keep draining the tail out from under its successor. Without a configured lease
	// callers always pass generation 0, which can never be fenced - the zero-cost,
	// single-instance default every opt-in feature in this package shares.
	//
	// Acking an unknown id on a connID the Outbox has never held a message for is not an error
	// and establishes no fencing state: an ack can arrive for a message a prior DropConn or
	// TTL already removed, or for a connID this Outbox has simply never seen.
	Ack(ctx context.Context, connID, msgID string, generation uint64) error

	// DropConn removes all state for connID, including any generation fencing floor Ack has
	// recorded. Applications call it when a connection is gone for good and its unacknowledged
	// tail should not be redelivered to a future reconnect.
	DropConn(ctx context.Context, connID string) error
}

Outbox stores the messages a connection has been sent but has not yet acknowledged, so they can be redelivered after a reconnect. It turns the Registry's default at-most-once delivery into at-least-once for the connections whose sends go through it: a message is appended before it is written to the socket, redelivered on the next Register if still unacknowledged, and removed only once the client acks it.

An Outbox is opt-in (see WithOutbox); a Registry without one never calls it and pays none of its storage or latency cost.

type OutboxGenerationAdvancer added in v0.2.0

type OutboxGenerationAdvancer interface {
	Outbox

	// AdvanceGeneration raises connID's Ack fencing floor to generation without acking any
	// message. It is a no-op, not an error, when connID has no Outbox state or generation is
	// not strictly greater than the recorded floor.
	AdvanceGeneration(ctx context.Context, connID string, generation uint64) error
}

OutboxGenerationAdvancer is an optional Outbox extension for backends that participate in owner-lease generation fencing (see WithOwnerLease and Outbox.Ack): AdvanceGeneration raises a connection's Ack fencing floor the moment a takeover's lease acquisition is confirmed, before traffic resumes on the new owner - mirroring GenerationalHistory.AdvanceGeneration in sse_history.go. Without it, the floor is only ever raised by an Ack call itself, so a stale owner's in-flight Ack at its own (lower) generation can still be accepted after a takeover, simply because the new owner has not yet Acked anything to raise the floor against it.

MemoryOutbox and persistence/redis.Outbox both implement it. An Outbox that does not is used unfenced by generation exactly as before WithOwnerLease existed - the same opt-in-capability pattern GenerationalHistory and PresenceFencer already establish elsewhere in this package.

type PersistedMessage added in v0.2.0

type PersistedMessage struct {
	// ID is the Outbox-minted token Registry.Ack and Outbox.Ack match on to drop this stored
	// message. Append mints it and returns it so the caller can embed it in the envelope
	// written to the socket (see EncodeOutboxEnvelope); a client echoes it back verbatim when
	// acknowledging, and the server passes that value straight through to Registry.Ack.
	ID string

	// Seq is the Outbox-internal per-connection order redelivery replays in, ascending. The
	// Outbox assigns it on Append. See ReplayTailSeq for reading the high-water mark of a
	// batch Unacked returns.
	Seq uint64

	// Payload is the exact application bytes that were, or will be, written to the socket.
	// Unacked returns an independent copy so a caller may mutate it without touching stored
	// state. Any client-facing dedupe id lives in these bytes, chosen by the application.
	Payload []byte
}

PersistedMessage is one message an Outbox holds for a connection until it is acknowledged. It is the return shape of Outbox.Unacked and, as ID and Seq, also of Outbox.Append.

ID and Seq are Outbox-internal bookkeeping, minted by the Outbox itself rather than chosen by the caller: Append takes only connID and payload. A caller that writes payload to the socket embeds ID and Seq in the wire envelope it sends (see EncodeOutboxEnvelope), so a client can report ID back verbatim when it acknowledges receipt, and Registry.Ack / Outbox.Ack match on that ID to remove the stored message. Seq is the internal per-connection order redelivery replays in, ascending; ReplayTailSeq reads the high-water mark of a batch Unacked returns. Both fields must stay unique and monotonic across every process, node, and restart that shares one backend, which is why the Outbox - not the caller - assigns them.

type Presence added in v0.2.0

type Presence interface {
	// Join records connID as an online member of group for at most ttl.
	Join(ctx context.Context, group, connID string, ttl time.Duration) error

	// Leave removes connID from group. It must not fail when the member is already gone.
	Leave(ctx context.Context, group, connID string) error

	// Refresh extends the lease of connID in group by ttl.
	Refresh(ctx context.Context, group, connID string, ttl time.Duration) error

	// Members returns the connection ids currently online for group, on any node.
	Members(ctx context.Context, group string) ([]string, error)

	// Online reports whether group has at least one live member on any node.
	Online(ctx context.Context, group string) (bool, error)
}

Presence is the cluster-wide answer to "is this identity connected anywhere, and where". A Registry without a Presence backend only ever knows about the connections it holds itself, which is enough for delivery (the cluster fan-out reaches the rest) but not enough to decide whether an identity is offline and a web push should be sent instead.

Entries are leased rather than owned: the Registry renews them on an interval derived from the configured TTL (see WithPresenceTTL), so a node that dies without unregistering its connections stops holding them online once the lease lapses.

type PresenceDirectory added in v0.2.0

type PresenceDirectory interface {
	// Entries returns every online member of group across the cluster, with metadata.
	Entries(ctx context.Context, group string) ([]PresenceEntry, error)
}

PresenceDirectory is an optional Presence extension: a backend that can enumerate a group's members cluster-wide, with their metadata, implements it, and Registry.GroupMembers uses it. Without one, Registry.GroupMembers falls back to this node's local view only.

type PresenceEntry added in v0.2.0

type PresenceEntry struct {
	// ConnID is the connection id.
	ConnID string

	// Meta is the application metadata the connection registered with, or nil if none was
	// recorded.
	Meta map[string]string
}

PresenceEntry is one member of a group as reported by a PresenceDirectory: its connection id and the metadata recorded for it at registration time.

type PresenceEvent added in v0.2.0

type PresenceEvent struct {
	// Group is the identity group the change happened in.
	Group string

	// ConnID is the connection the change is about.
	ConnID string

	// Kind is whether ConnID joined or left.
	Kind PresenceEventKind
}

PresenceEvent is a single change to a group's membership delivered over a PresenceWatcher subscription.

type PresenceEventKind added in v0.2.0

type PresenceEventKind int

PresenceEventKind distinguishes a member joining a group from a member leaving it.

const (
	// PresenceJoin marks a connection newly recorded as online in a group.
	PresenceJoin PresenceEventKind = iota

	// PresenceLeave marks a connection removed from a group.
	PresenceLeave
)

type PresenceFencer added in v0.2.0

type PresenceFencer interface {
	// RefreshGen is Refresh fenced by generation: a call whose generation is lower than the
	// generation last recorded for connID in group is rejected with ErrStaleOwner instead of
	// extending the lease, and leaves the recorded membership untouched. A generation greater
	// than or equal to what is recorded succeeds, extends the lease, and (re)sets the recorded
	// generation to it.
	RefreshGen(ctx context.Context, group, connID string, generation uint64, ttl time.Duration) error

	// LeaveGen is Leave fenced by generation: a call whose generation is lower than the
	// generation last recorded for connID in group is rejected with ErrStaleOwner and the
	// member is not removed, so a takeover's (re)join cannot be undone by the previous owner's
	// delayed Leave. Like Leave, it is a no-op, not an error, when the member is already gone.
	LeaveGen(ctx context.Context, group, connID string, generation uint64) error
}

PresenceFencer is an optional Presence extension for backends that participate in owner-lease generation fencing (see WithOwnerLease and Registry.staleOwner in registry.go): Refresh and Leave calls carry the caller's connection generation, so a node whose lease has been taken over by a newer generation cannot use a delayed Refresh to keep a group membership alive past the takeover, nor a delayed Leave to remove a membership the new owner already (re)established.

Join is deliberately not part of this extension: by the time a node is allowed to call Join (or JoinWithMeta) for a connection, the owner-lease compare-and-swap has already serialized who won any takeover, so a stale Join cannot happen the way a stale, in-flight Refresh or Leave can from a goroutine that read the old generation just before it lapsed.

A Presence backend that does not implement PresenceFencer is still fully valid: Registry then relies solely on the connection-level generation check (Registry.staleOwner) to stop a superseded node from writing to the socket, at the cost of a stale Refresh potentially keeping a Presence entry alive for up to one more renewal interval than a fenced backend would allow, and a stale Leave being able to remove a membership a takeover just restored.

type PresenceMetaJoiner added in v0.2.0

type PresenceMetaJoiner interface {
	// JoinWithMeta records connID as an online member of group for at most ttl, along with
	// its metadata. It has the same lease semantics as Presence.Join.
	JoinWithMeta(ctx context.Context, group, connID string, meta map[string]string, ttl time.Duration) error
}

PresenceMetaJoiner is an optional Presence extension for backends that also store per-connection metadata. When the configured Presence implements it, Registry.Register records ConnInfo.Meta through JoinWithMeta so a PresenceDirectory can return it cluster-wide. Backends that do not implement it still work: the metadata simply is not stored in presence and GroupMembers falls back to the local view for it.

type PresenceWatcher added in v0.2.0

type PresenceWatcher interface {
	// Watch subscribes to membership changes for group. It returns a receive channel of
	// events, a cancel function that unsubscribes and closes the channel, and an error. The
	// channel is closed when cancel is called or ctx is cancelled.
	Watch(ctx context.Context, group string) (events <-chan PresenceEvent, cancel func(), err error)
}

PresenceWatcher is an optional Presence extension: a backend that can stream membership changes for a group implements it, and Registry.WatchPresence surfaces the stream. Backends that cannot (Registry.WatchPresence returns ErrPresenceWatchUnsupported for them) are still valid Presence implementations.

A watch is best-effort by contract: an implementation may drop events to a subscriber that is not keeping up, and events that occur while a subscriber is disconnected from the backend are not backfilled. Treat the stream as a hint to re-read authoritative state, not as a gap-free log.

type RegisterOption added in v0.2.0

type RegisterOption func(*registerConfig)

RegisterOption configures a single Registry.Register call.

func WithConnCloseHook added in v0.2.0

func WithConnCloseHook(hook func(reason string)) RegisterOption

WithConnCloseHook records how to force-close this connection out of band, for Registry.Disconnect and Registry.DisconnectGroup. The WebSocket/SSE handlers pass it at registration time so the close is wired atomically with the entry, leaving no window in which a registered connection cannot be disconnected. hook is invoked with the disconnect reason from a goroutine that does not hold the Registry lock; it must be non-blocking and safe to call concurrently with the connection's own teardown. Applications that register connections directly, without a handler, simply omit it: Disconnect then has nothing to run and reports the connection as closable only if another node owns it.

func WithConnGroup added in v0.2.0

func WithConnGroup(group string) RegisterOption

WithConnGroup puts the connection in an identity group, e.g. "user:123". Every device and browser tab of one identity shares a group, which is what SendToGroup fans out to and what Presence tracks.

func WithConnMeta added in v0.2.0

func WithConnMeta(meta map[string]string) RegisterOption

WithConnMeta attaches application metadata to the connection.

func WithConnTopics added in v0.2.0

func WithConnTopics(topics ...string) RegisterOption

WithConnTopics joins the connection to topics as part of registering it. A failure to bridge any of them fails the registration, since a connection silently missing broadcasts is worse than one that never came up.

func WithReplaceExisting added in v0.2.0

func WithReplaceExisting() RegisterOption

WithReplaceExisting evicts an already-registered connection with the same id instead of failing with ErrConnectionExists. This is the right default for a reconnecting client: behind a half-open TCP connection the previous socket can stay "alive" for many minutes, and refusing the new one would keep the client offline for exactly as long.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry is the local, per-node table of registered WebSocket/SSE connections. It is the "local connection table" the two-tier delivery model described in the gateway package documentation is built around: SendToConnection, SendToGroup and Broadcast always prefer a direct write to a locally held connection over any actor/cluster machinery, and only fall back to cluster addressing when the target is not held by this node.

A Registry is safe for concurrent use.

func NewRegistry

func NewRegistry(system actor.ActorSystem, logger log.Logger, opts ...RegistryOption) *Registry

NewRegistry creates a Registry backed by system. system is used to spawn the per-connection ephemeral actors (relocation disabled, long-lived passivation) that make registered connections addressable from other nodes, and to bridge topic and group fan-out across the cluster (see bridge.go).

Joining a topic or registering into a group requires the actor system to have been started with pub/sub enabled (actor.WithPubSub): both ride on the topic actor.

A Registry configured with a Presence backend starts a background renewal goroutine; call Close to stop it.

func (*Registry) Ack added in v0.2.0

func (r *Registry) Ack(ctx context.Context, connID, msgID string) error

Ack removes a message from connID's outbox once the client confirms it received it. It is how the at-least-once loop terminates: an unacknowledged message is redelivered on the next Register, an acknowledged one is not. Without an Outbox configured it is a no-op that returns nil, so application ack handling can stay unconditional.

The generation Ack forwards to the Outbox is whatever this node's local entry for connID currently holds - 0 when WithOwnerLease is not configured, and also 0 when this node holds no local entry for connID at all, which fences the ack exactly as if it came from a node whose generation has already been superseded. Only the node actually serving a connection's socket ever receives that connection's ack frame, so the local generation is always the caller's own current one; it is never something the caller has to supply.

func (*Registry) Broadcast

func (r *Registry) Broadcast(ctx context.Context, topic string, payload []byte, opts ...BroadcastOption) (DeliveryResult, error)

Broadcast delivers payload to every connection joined to topic across the cluster. Local members are written to directly; remote members are reached through the topic bridge (see bridge.go).

func (*Registry) Close added in v0.2.0

func (r *Registry) Close(_ context.Context) error

Close stops the Registry's background resources: the presence renewal goroutine and every cluster fan-out bridge it still holds. Registered connections are left alone - their handlers own them and will unregister them as their sockets die. Close is idempotent, and a closed Registry refuses further registrations with ErrRegistryClosed.

func (*Registry) Disconnect added in v0.2.0

func (r *Registry) Disconnect(ctx context.Context, id, reason string) error

Disconnect force-closes the connection identified by id, wherever in the cluster it is held, sending the given reason to the client. A locally held connection is closed through the close hook its handler registered (see WithConnCloseHook); a connection held by another node is reached through its connActor, which runs its own local close hook. The socket's handler unregisters the connection as it tears down, so Disconnect does not unregister it itself.

It returns ErrConnectionNotFound when id is not registered anywhere in the cluster. A locally held connection whose handler registered no close hook is reported as closed (there is nothing to force), since the caller's intent - that this node stop holding the socket - is already the handler's responsibility.

func (*Registry) DisconnectGroup added in v0.2.0

func (r *Registry) DisconnectGroup(ctx context.Context, group, reason string) (int, error)

DisconnectGroup force-closes every connection of an identity group across the cluster, sending reason to each client, and returns how many connections it acted on. Local members are closed through their registered close hooks; remote members are reached through their connActors when a Presence backend can enumerate them. The count reflects the connections Disconnect was dispatched for, not a cluster-wide confirmation that every socket has finished closing.

func (*Registry) GroupMembers added in v0.2.0

func (r *Registry) GroupMembers(ctx context.Context, group string) ([]PresenceEntry, error)

GroupMembers enumerates the connections of an identity group. With a Presence backend that implements PresenceDirectory the answer covers the whole cluster and carries each member's metadata; otherwise it falls back to the connections this node holds for group, with the metadata they registered with.

func (*Registry) Has

func (r *Registry) Has(id string) bool

Has reports whether id is registered on this node.

func (*Registry) IsOnline added in v0.2.0

func (r *Registry) IsOnline(ctx context.Context, group string) (bool, error)

IsOnline reports whether the identity group has at least one live connection. With a Presence backend the answer covers the whole cluster; without one it only covers this node, and returns false for an identity connected to a sibling node.

func (*Registry) Join

func (r *Registry) Join(ctx context.Context, id, topic string) error

Join adds an already-registered connection to a topic's local membership, creating the cluster fan-out bridge for that topic on first use. It returns ErrConnectionNotFound if id is not registered.

A bridge that cannot be established fails the Join and leaves the connection out of the topic: a connection joined to a topic it can never receive broadcasts on is a silent data-loss bug, not a degraded mode.

func (*Registry) Leave

func (r *Registry) Leave(_ context.Context, id, topic string) error

Leave removes an already-registered connection from a topic's local membership, tearing down the cluster fan-out bridge for that topic once its last local member leaves.

func (*Registry) Len

func (r *Registry) Len() int

Len returns the number of connections registered on this node.

func (*Registry) LocalConnectionsOf added in v0.2.0

func (r *Registry) LocalConnectionsOf(group string) []string

LocalConnectionsOf returns the ids of the connections this node holds for group.

func (*Registry) Register

func (r *Registry) Register(ctx context.Context, id string, send func([]byte) error, opts ...RegisterOption) error

Register adds a new connection to the local table and makes it addressable cluster-wide under connActorName(id). send is invoked (from any goroutine, including remote-delivery ones) whenever a payload must be written to the underlying socket; it must be non-blocking or perform its own internal buffering, since Registry never queues on the caller's behalf.

The id is reserved in the table before the backing actor is spawned, so a concurrent Register for the same id fails immediately with ErrConnectionExists rather than racing past the reservation. If an Unregister for id arrives while the actor is still spawning, Register rolls back the spawn and returns ErrConnectionClosed instead of resurrecting a connection the Unregister caller already believes is gone.

Anything that can leave the connection half-wired - a topic bridge that cannot be established, a presence backend that rejects the join, an owner lease held by another node - rolls the whole registration back and returns the error.

It returns ErrConnectionExists if id is already registered and WithReplaceExisting was not given, and ErrRegistryClosed if the Registry has been closed. With WithOwnerLease configured it can also return ErrOwnerLeaseUnsupported (the configured Coordinator does not implement LinearizableFencingCoordinator) or ErrOwnerHeld (another node holds id's lease, unexpired, and WithReplaceExisting was not given).

func (*Registry) RegisterHandle added in v0.2.0

func (r *Registry) RegisterHandle(ctx context.Context, id string, send func([]byte) error, opts ...RegisterOption) (*ConnHandle, error)

RegisterHandle is Register with an entry-guarded handle for teardown (see ConnHandle). A caller - typically a WebSocket/SSE handler - that unregisters through ConnHandle.UnregisterHandle instead of the id-scoped Unregister closes the same ABA hazard the rollback and cross-node eviction paths already guard against for their own internal teardowns: a handler whose connection has since been evicted or replaced by a same-id takeover can never delete the newer owner's entry out from under it.

func (*Registry) SendToConnection

func (r *Registry) SendToConnection(ctx context.Context, id string, payload []byte) error

SendToConnection delivers payload to the connection identified by id anywhere in the cluster. If id is registered on this node, payload is written directly to the socket with no actor or cluster machinery involved. Otherwise, SendToConnection resolves the connection's owning node through the cluster-aware actor directory (ActorSystem.ActorOf) and delivers payload there.

It returns ErrConnectionNotFound if id is not registered anywhere in the cluster.

func (*Registry) SendToGroup added in v0.2.0

func (r *Registry) SendToGroup(ctx context.Context, group string, payload []byte) (DeliveryResult, error)

SendToGroup delivers payload to every connection of an identity group, cluster-wide. Local members are written to directly; the remaining nodes are reached through the group's internal fan-out topic.

The returned DeliveryResult is what an application uses to decide whether the identity was reachable at all: DeliveryResult.None is the signal that an offline channel (web push, mail) is the only way left. How exact that signal is depends on configuration, and the precise contract - including why a stale Presence lease can suppress it and why the confirmed path can duplicate onto the offline channel - is documented on DeliveryResult.None and DeliveryResult.Remote. In short: exact reachability needs WithDeliveryConfirmation, and even then the offline fallback is at-least-once.

Example

ExampleRegistry_SendToGroup shows one identity ("user:1") with two devices registered on this node. SendToGroup fans out to both of them, and a send to an identity that is not connected anywhere reports DeliveryResult.None, which is the signal to fall back to an offline channel such as web push.

package main

import (
	"context"
	"fmt"

	"github.com/tochemey/goakt/v4/actor"
	"github.com/tochemey/goakt/v4/log"

	gateway "github.com/StringKe/goakt-gateway"
)

func main() {
	ctx := context.Background()

	// Group fan-out rides on the internal pub/sub topic actor, so the system needs pub/sub
	// enabled even for a single-node, non-clustered deployment.
	system, err := actor.NewActorSystem("example", actor.WithLogger(log.DiscardLogger), actor.WithPubSub())
	if err != nil {
		panic(err)
	}
	if err := system.Start(ctx); err != nil {
		panic(err)
	}
	defer func() { _ = system.Stop(ctx) }()

	registry := gateway.NewRegistry(system, log.DiscardLogger)

	// Two devices of the same identity share one group.
	send := func([]byte) error { return nil }
	_ = registry.Register(ctx, "phone", send, gateway.WithConnGroup("user:1"))
	_ = registry.Register(ctx, "laptop", send, gateway.WithConnGroup("user:1"))

	online, _ := registry.SendToGroup(ctx, "user:1", []byte("ping"))
	fmt.Println("delivered:", online.Delivered)
	fmt.Println("online none:", online.None())

	offline, _ := registry.SendToGroup(ctx, "user:2", []byte("ping"))
	fmt.Println("offline none:", offline.None())

}
Output:
delivered: 2
online none: false
offline none: true

func (*Registry) Unregister

func (r *Registry) Unregister(ctx context.Context, id string) error

Unregister removes a connection from the local table, leaves every topic and group it had joined, and shuts down its backing actor. It is a no-op if id is not registered.

If id is currently reserved by an in-flight Register (its actor is still spawning), Unregister marks the reservation dead and returns immediately: the in-flight Register observes this and rolls back the spawn itself, since it is the only side that can safely stop the actor it just created.

func (*Registry) WatchPresence added in v0.2.0

func (r *Registry) WatchPresence(ctx context.Context, group string) (<-chan PresenceEvent, func(), error)

WatchPresence subscribes to membership changes for group. It requires the configured Presence backend to implement PresenceWatcher and returns ErrPresenceWatchUnsupported when none is configured or the configured one does not. See PresenceWatcher for the best-effort delivery contract.

type RegistryOption added in v0.2.0

type RegistryOption func(*Registry)

RegistryOption configures a Registry created with NewRegistry.

func WithConfirmationTimeout added in v0.2.0

func WithConfirmationTimeout(d time.Duration) RegistryOption

WithConfirmationTimeout sets how long a confirmed cross-node delivery waits for the owning node's acknowledgement before giving up with ErrConfirmationTimeout. It only has an effect alongside WithDeliveryConfirmation. Defaults to 5 seconds.

func WithDeliveryConfirmation added in v0.2.0

func WithDeliveryConfirmation() RegistryOption

WithDeliveryConfirmation makes cross-node delivery wait for the owning node to acknowledge that it wrote the payload to the target socket's outbound buffer, instead of the default fire-and-forget fan-out. With it enabled, DeliveryResult.Remote counts confirmed remote writes rather than fan-outs issued, and SendToConnection to a remote connection returns only once the write is acknowledged (or ErrConfirmationTimeout once the confirmation timeout elapses). It costs a round trip per remote delivery; leave it off for the low-latency at-most-once default.

func WithObserver added in v0.2.0

func WithObserver(o Observer) RegistryOption

WithObserver attaches an Observer that receives the Registry's connection and delivery events. Its methods run inline on delivery paths and must not block.

func WithOfflineChannel added in v0.2.0

func WithOfflineChannel(ch OfflineChannel, opts ...OfflineOption) RegistryOption

WithOfflineChannel attaches an OfflineChannel the Registry falls back to when SendToGroup finds an identity offline everywhere in the cluster. Without it, SendToGroup simply returns a DeliveryResult whose None reports the same fact and leaves the fallback to the application. The fallback fires exactly when DeliveryResult.None is true, so its accuracy is that of None (see its doc): a clustered SendToGroup without a Presence backend never falls back, a stale Presence lease can suppress the fallback for up to the presence TTL, and with WithDeliveryConfirmation the fallback is at-least-once and may duplicate a slow in-cluster delivery onto the offline channel.

Each fallback runs on its own goroutine bounded by WithOfflineMaxConcurrent and with a context deadline set by WithOfflineTimeout, so a storm of offline identities cannot spawn unbounded goroutines and a stalled transport cannot pin one forever.

func WithOutbox added in v0.2.0

func WithOutbox(o Outbox) RegistryOption

WithOutbox attaches an Outbox, upgrading SendToConnection to at-least-once delivery: each payload is persisted before it is written to the socket, and any still-unacknowledged message is redelivered when the connection registers again. Acknowledge messages with Registry.Ack. Without an Outbox the Registry keeps its default at-most-once, fire-and-forget behaviour and stores nothing.

func WithOutboxEnvelope added in v0.2.0

func WithOutboxEnvelope() RegistryOption

WithOutboxEnvelope writes every persisted message as an ASCII base64 Outbox envelope. It has an effect only with WithOutbox. The default remains raw payloads for compatibility.

func WithOwnerLease added in v0.2.0

func WithOwnerLease(c Coordinator) RegistryOption

WithOwnerLease turns on strict multi-instance connection ownership: Register acquires a CAS-arbitrated lease for each connection id from c before publishing it locally, takeovers (WithReplaceExisting) fence out the previous owner by bumping the lease's generation, and every fencing-aware operation (refresh, release, and a receiving connActor's delivery check) rejects a caller whose generation has been superseded.

It exists because the GoAkt actor directory a Registry otherwise relies on to make a connection addressable cluster-wide is PA/EC eventually consistent (see the Coordinator doc comment): two nodes racing a takeover of the same connection id can both observe "no owner" and both succeed, producing two live owners for one id. A CAS primitive is what closes that window; the actor directory alone cannot.

c must implement LinearizableFencingCoordinator. A c that does not makes every subsequent Register fail with ErrOwnerLeaseUnsupported, since NewRegistry itself has no error return to report the mismatch immediately.

Without WithOwnerLease a Registry keeps its current single-instance semantics at zero cost: no lease acquisition, no generation fencing, no extra coordinator round trips. This mirrors WithDeliveryConfirmation's opt-in philosophy - pay for the guarantee only when you need it.

func WithPresence added in v0.2.0

func WithPresence(p Presence) RegistryOption

WithPresence attaches a cluster-wide presence backend. Without one a Registry only knows about the connections it holds itself, so IsOnline degrades to "connected to this node" and DeliveryResult.None cannot distinguish an offline identity from one connected elsewhere in the cluster.

func WithPresenceTTL added in v0.2.0

func WithPresenceTTL(d time.Duration) RegistryOption

WithPresenceTTL sets the lease length the Registry asks the Presence backend for. Presence entries are renewed every third of it, so a node that dies takes at most one TTL to be forgotten. Defaults to 30 seconds.

type ReloadingCertificate added in v0.2.0

type ReloadingCertificate struct {
	// contains filtered or unexported fields
}

ReloadingCertificate serves a single certificate loaded from a PEM certificate/key file pair and keeps it up to date while the process runs, which is what a certificate mounted from a Kubernetes secret (rotated in place by kubelet, or by cert-manager) requires.

Change detection hashes the file contents rather than comparing modification times: a Kubernetes secret volume is a symlink tree (tls.crt -> ..data/tls.crt) whose "..data" link is atomically re-pointed at a new directory, so the path a process holds resolves to a different inode without any write ever landing on the old one, and the mtime a naive watcher would stat is not the one that moved.

A reload that fails (truncated write, mismatched pair, unreadable file) keeps the last certificate that did load: a botched rotation must not take TLS termination down with it.

Pass Get to WithFallbackCertificate, or use it directly as tls.Config.GetCertificate via a closure, to serve the same catch-all certificate regardless of SNI.

func NewReloadingCertificate added in v0.2.0

func NewReloadingCertificate(certPath, keyPath string, interval time.Duration, logger log.Logger) (*ReloadingCertificate, error)

NewReloadingCertificate loads the certificate/key pair at certPath/keyPath and returns a ReloadingCertificate that re-checks them every interval once Start is called. It fails if the initial load fails, since there would be nothing to serve. An interval <= 0 disables polling: the certificate is loaded once and never refreshed.

func (*ReloadingCertificate) Get added in v0.2.0

Get returns the certificate currently loaded. It never blocks on the filesystem, so it is safe to call from a TLS handshake.

func (*ReloadingCertificate) Start added in v0.2.0

func (c *ReloadingCertificate) Start(ctx context.Context)

Start begins polling the certificate/key files for changes until ctx is canceled or Stop is called. Calling it twice without an intervening Stop is a no-op.

func (*ReloadingCertificate) Stop added in v0.2.0

func (c *ReloadingCertificate) Stop()

Stop stops the polling goroutine started by Start and waits for it to exit. It is idempotent.

type SSEAuthFunc added in v0.2.0

type SSEAuthFunc func(*http.Request) (*ConnInfo, error)

SSEAuthFunc authenticates an SSE request and resolves the identity of the connection it will open. A non-nil error rejects the request with HTTP 403 Forbidden.

The returned ConnInfo is the single source of truth for the connection: its ID, Group, Topics and Meta are used for registration and handed to every subsequent callback, so the auth token is parsed once instead of once per concern.

type SSEEvent added in v0.2.0

type SSEEvent struct {
	// ID is the event id as written to the client, e.g. "conn-7-42".
	ID string

	// Payload is the raw event body, before SSE "data:" framing.
	Payload []byte
}

SSEEvent is one replayable event: the id that was written on the wire as the SSE "id:" field, and the payload that followed it.

type SSEHandler

type SSEHandler struct {
	// contains filtered or unexported fields
}

SSEHandler opens a Server-Sent Events stream for every incoming request and registers each one in its Registry for the lifetime of the connection. SSE is one-way (server to client); inbound application data, if any, belongs in an ordinary HTTP endpoint the client posts to separately.

Every event carries an id of the form "<connID>-<seq>" with seq counting from 1 within the connection. The id is what a browser echoes back in Last-Event-ID after an automatic reconnect, and it is self-describing on purpose: an id from another connection is recognizable as such rather than being mistaken for a position in this connection's stream. With a SSEHistory configured, seq resumes past the replayed events instead of restarting, so ids stay unique for as long as the history retains them.

A reconnecting client that reuses its connection id takes over from the previous stream: the old registration is replaced and the old stream is terminated. A dead SSE stream is invisible to the server until a write to it fails, which behind a half-open TCP connection can take minutes, and refusing the client for that long is worse than dropping a stream nobody is reading.

Example

ExampleSSEHandler wires Last-Event-ID replay: a client whose stream drops reconnects with the id of the last event it saw, and the handler replays everything after it (backed by an SSEHistory) before the live stream resumes. This is exactly the reconnect a browser's EventSource performs on its own.

// MIT License
//
// Copyright (c) 2026 StringKe
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

package main

import (
	"bufio"
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/tochemey/goakt/v4/actor"
	"github.com/tochemey/goakt/v4/log"

	gateway "github.com/StringKe/goakt-gateway"
)

// ExampleSSEHandler wires Last-Event-ID replay: a client whose stream drops reconnects with the
// id of the last event it saw, and the handler replays everything after it (backed by an
// SSEHistory) before the live stream resumes. This is exactly the reconnect a browser's
// EventSource performs on its own.
func main() {
	system, err := actor.NewActorSystem("sse-example", actor.WithLogger(log.DiscardLogger))
	if err != nil {
		fmt.Println(err)
		return
	}
	if err := system.Start(context.Background()); err != nil {
		fmt.Println(err)
		return
	}
	defer func() { _ = system.Stop(context.Background()) }()

	registry := gateway.NewRegistry(system, log.DiscardLogger)
	history := gateway.NewMemorySSEHistory(16)

	handler := gateway.NewSSEHandler(registry,
		// The connection id is the replay key; bind it to an authenticated principal in real code.
		gateway.WithSSEIDFunc(func(r *http.Request) string { return r.URL.Query().Get("id") }),
		// A history is what turns an EventSource reconnect into a gap-free resume.
		gateway.WithSSEHistory(history),
		gateway.WithSSERetry(0),
		gateway.WithSSEKeepAlive(time.Hour),
	)

	server := httptest.NewServer(handler)
	defer server.Close()

	// First connection: receive two live events so the history has something to replay.
	first := openExampleStream(server.URL+"/?id=user-42", "")
	defer func() { _ = first.Body.Close() }()

	for !registry.Has("user-42") {
		time.Sleep(5 * time.Millisecond)
	}
	_ = registry.SendToConnection(context.Background(), "user-42", []byte("event-1"))
	_ = registry.SendToConnection(context.Background(), "user-42", []byte("event-2"))

	// Draining both frames off the wire guarantees the history has recorded them: the writer
	// appends to the history before it writes each event.
	reader := bufio.NewReader(first.Body)
	for range 2 {
		if _, err := readSSEFrame(reader); err != nil {
			fmt.Println(err)
			return
		}
	}

	// Reconnect echoing the id of the first event. The handler replays everything after it -
	// here just event-2 - as the takeover stream opens.
	second := openExampleStream(server.URL+"/?id=user-42", "user-42-1")
	defer func() { _ = second.Body.Close() }()

	frame, err := readSSEFrame(bufio.NewReader(second.Body))
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("replayed %s as %s\n", frame.data, frame.id)

}

// openExampleStream opens a streaming SSE request, sending lastEventID as the header a browser's
// EventSource sends on reconnect when it is non-empty.
func openExampleStream(url, lastEventID string) *http.Response {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		panic(err)
	}
	if lastEventID != "" {
		req.Header.Set("Last-Event-ID", lastEventID)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	return resp
}
Output:
replayed event-2 as user-42-2

func NewSSEHandler

func NewSSEHandler(registry *Registry, opts ...SSEHandlerOption) *SSEHandler

NewSSEHandler returns an SSEHandler bound to registry. The returned handler is an http.Handler; wire its Drain method into Server via WithDrainOnShutdown (or call it from your own shutdown path) so open streams terminate promptly on shutdown.

func (*SSEHandler) Drain

func (h *SSEHandler) Drain()

Drain terminates every open SSE stream and makes new requests fail fast with 503, so a graceful server shutdown is not held hostage by long-lived streams. It blocks until every stream that was open when Drain was called - and every registration still being admitted at that instant - has actually unregistered from the Registry: ending the HTTP response alone would free the socket but not the connection's cluster-wide actor name or (with WithOwnerLease) its owner lease, leaving them to a caller that kills the process right after Drain returns. Safe to call more than once.

func (*SSEHandler) ServeHTTP

func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type SSEHandlerOption

type SSEHandlerOption func(*SSEHandler)

SSEHandlerOption configures an http.Handler created with NewSSEHandler.

func WithSSEAuth added in v0.2.0

func WithSSEAuth(f SSEAuthFunc) SSEHandlerOption

WithSSEAuth sets the auth hook run before a connection is accepted and registered. A non-nil error rejects the request with HTTP 403 Forbidden.

func WithSSEBackpressurePolicy added in v0.2.0

func WithSSEBackpressurePolicy(p BackpressurePolicy) SSEHandlerOption

WithSSEBackpressurePolicy decides what happens when a connection's outbound buffer is full. Defaults to BackpressureDrop.

func WithSSEEventName added in v0.2.0

func WithSSEEventName(f func(payload []byte) string) SSEHandlerOption

WithSSEEventName sets the function that names each outgoing event, written as the SSE "event:" field. Returning an empty name emits an anonymous event, which is what an EventSource's onmessage handler receives; a named event only reaches listeners registered for that name. Defaults to anonymous events.

func WithSSEHistory added in v0.2.0

func WithSSEHistory(h SSEHistory) SSEHandlerOption

WithSSEHistory enables Last-Event-ID replay through h. Every event written to a stream is appended to the history under its connection id; when a client reconnects with the same id and a Last-Event-ID header, the events after it are replayed before the live stream resumes. If the requested id is no longer retained, whatever is left is replayed after an SSEGapEventName event, so the loss is visible rather than silent.

Without a history, event ids are still emitted but restart at 1 on every connection and no replay is possible. A backend implementing SharedSSEHistory is accepted only with a Registry configured by WithOwnerLease and a backend implementing GenerationalHistory. This prevents a stale replica from writing into a new owner's shared replay stream after takeover.

func WithSSEIDFunc

func WithSSEIDFunc(f func(*http.Request) string) SSEHandlerOption

WithSSEIDFunc sets the function used to derive a connection's id from the request. It is a fallback, consulted only when SSEAuthFunc did not return an ID. Without either, a random UUID is generated per connection, which also means Last-Event-ID replay cannot work: history is keyed by connection id, so a client that wants to be caught up after a reconnect must come back under the same id.

Security: SSEHandler always registers with takeover enabled, so the connection id is a takeover key. A client that controls the id it registers under can evict any live stream holding that id and rebind it, redirecting every later SendToConnection and SendToGroup delivery for that id to itself. Derive the id from an authenticated principal (do that in SSEAuthFunc, which runs first), never from an unauthenticated request field.

func WithSSEKeepAlive

func WithSSEKeepAlive(d time.Duration) SSEHandlerOption

WithSSEKeepAlive sets the interval at which a comment-only keepalive event is sent to detect dead connections and prevent idle-timing-out intermediate proxies. Defaults to 15 seconds; 0 disables it.

func WithSSELogger

func WithSSELogger(logger log.Logger) SSEHandlerOption

WithSSELogger sets the logger used to report connection-handling errors. Defaults to log.DiscardLogger.

func WithSSEOnConnect

func WithSSEOnConnect(f func(ctx context.Context, info *ConnInfo, r *http.Request)) SSEHandlerOption

WithSSEOnConnect sets the callback invoked once a connection has been registered and the response stream has been opened.

func WithSSEOnDisconnect

func WithSSEOnDisconnect(f func(info *ConnInfo)) SSEHandlerOption

WithSSEOnDisconnect sets the callback invoked once a connection's stream has ended.

func WithSSEReauth added in v0.2.0

func WithSSEReauth(interval time.Duration, f SSEAuthFunc) SSEHandlerOption

WithSSEReauth periodically re-runs f against the original request to re-check a stream whose authorization can be revoked while it is open (a role change, a logout, an expiring token). The first failure ends the stream: a terminating comment naming the reason is written and the connection is unregistered. Disabled by default; a non-positive interval or a nil f leaves it disabled.

f is the same shape as the handshake SSEAuthFunc but is consulted only for its error: the re-resolved identity is not applied to the live stream, because changing a stream's id or group mid-flight would strand deliveries already addressed to it. Only header and cookie based auth can be re-checked this way, since that is all the request carries.

func WithSSERetry added in v0.2.0

func WithSSERetry(d time.Duration) SSEHandlerOption

WithSSERetry sets the reconnection delay advertised to the client as an SSE "retry:" field when the stream opens. Defaults to 3 seconds; 0 omits the field and leaves the browser's own default (typically 3 seconds) in place.

func WithSSESendBuffer

func WithSSESendBuffer(size int) SSEHandlerOption

WithSSESendBuffer sets the size of each connection's outbound buffer. When full, the configured BackpressurePolicy decides between dropping the message (the sender sees ErrBackpressure) and closing the stream. A value at or below zero is raised to the default. Defaults to 256.

func WithSSETopics

func WithSSETopics(f func(*http.Request) []string) SSEHandlerOption

WithSSETopics sets the function used to derive the topics a connection is joined to at registration time. It is a fallback, consulted only when SSEAuthFunc returned no Topics.

func WithSSEWriteTimeout added in v0.2.0

func WithSSEWriteTimeout(d time.Duration) SSEHandlerOption

WithSSEWriteTimeout bounds a single write+flush to the client. A slow or dead reader that stalls a write is treated as a connection failure and torn down, rather than blocking the streaming loop and Drain forever. A value at or below zero is raised to the default. Defaults to 10 seconds.

type SSEHistory added in v0.2.0

type SSEHistory interface {
	// Append records that eventID/payload was written to connID's stream. It is called from
	// the connection's writer goroutine, in wire order.
	Append(ctx context.Context, connID, eventID string, payload []byte) error

	// Since returns the events written to connID strictly after lastEventID, oldest first.
	//
	// An empty lastEventID returns everything still retained for the connection and no
	// error. An unknown lastEventID returns everything still retained together with
	// ErrHistoryGap, so the caller can replay what survives and tell the client that
	// earlier events are unrecoverable.
	Since(ctx context.Context, connID, lastEventID string) (events []SSEEvent, err error)
}

SSEHistory retains recently delivered events per connection so that a client reconnecting with a Last-Event-ID header can be caught up on what it missed. Browsers reconnect an EventSource on their own and always send Last-Event-ID, so without a history every event written between the socket dying and the server noticing is lost with no trace.

Implementations must be safe for concurrent use.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is a thin convenience wrapper around http.Server that wires a Manager's cluster-shared, SNI-dynamic TLS certificates (and, optionally, Authenticated Origin Pulls mTLS verification) into the standard library's TLS listener. Plain HTTP handlers, WebSocket upgrades (NewWSHandler), and SSE streams (NewSSEHandler) are all just http.Handler values mounted on Server like on any other *http.Server - Server adds nothing actor- or cluster-related to the request path itself.

Using Server is entirely optional: any application already managing its own *http.Server can instead set TLSConfig: manager.TLSConfig() directly.

func NewServer

func NewServer(addr string, handler http.Handler, opts ...ServerOption) (*Server, error)

NewServer creates a Server listening on addr and dispatching to handler.

func (*Server) ListenAndServe

func (s *Server) ListenAndServe(ctx context.Context) error

ListenAndServe starts the Manager's renewal schedule (if any) and serves traffic, terminating TLS through it when WithTLSManager was set, or serving plain HTTP otherwise. It blocks until the server stops (see Shutdown) and returns http.ErrServerClosed on a clean shutdown.

func (*Server) Shutdown

func (s *Server) Shutdown(ctx context.Context) error

Shutdown gracefully stops the server: it first drains handlers registered via WithDrainOnShutdown (evicting long-lived WebSocket/SSE connections that http.Server.Shutdown cannot terminate itself), then stops the Manager's renewal schedule if any, and finally shuts the HTTP listener down.

type ServerOption

type ServerOption func(*Server)

ServerOption configures a Server created with NewServer.

func WithAuthenticatedOriginPulls

func WithAuthenticatedOriginPulls(pulls *AuthenticatedOriginPulls) ServerOption

WithAuthenticatedOriginPulls enables mTLS verification of inbound connections against pulls's configured CA (see AuthenticatedOriginPulls), rejecting any connection that does not present a valid client certificate. Requires WithTLSManager.

func WithDrainOnShutdown

func WithDrainOnShutdown(drainers ...Drainer) ServerOption

WithDrainOnShutdown registers connection handlers whose Drain method Server.Shutdown invokes before shutting the HTTP listener down, so long-lived WebSocket/SSE connections are evicted promptly and clients reconnect to surviving replicas during rolling deploys.

func WithReadHeaderTimeout

func WithReadHeaderTimeout(d time.Duration) ServerOption

WithReadHeaderTimeout sets the underlying http.Server's ReadHeaderTimeout.

func WithServerErrorLog added in v0.2.0

func WithServerErrorLog(logger *stdlog.Logger) ServerOption

WithServerErrorLog sets the underlying http.Server's ErrorLog.

A TLS listener logs every failed handshake, so load balancer and Kubernetes readiness probes that connect and disconnect without completing one produce a steady stream of "http: TLS handshake error ...: EOF" lines on stderr. Routing them to a logger the application controls is the only way to filter or silence that noise. To drop them entirely, pass stdlog.New(io.Discard, "", 0); leaving this option unset keeps net/http's default of writing to the standard logger.

func WithTLSManager

func WithTLSManager(manager *Manager) ServerOption

WithTLSManager configures Server to terminate TLS using manager's cluster-shared, SNI-dynamic certificates. Without this option, Server serves plain HTTP.

type SharedSSEHistory added in v0.2.0

type SharedSSEHistory interface {
	SSEHistory

	SharedSSEHistory()
}

SharedSSEHistory marks a replay backend whose state is visible to more than one process. SSEHandler rejects this capability unless its Registry has WithOwnerLease configured and the backend also implements GenerationalHistory. Local-only histories must not implement this marker: they are safe only when reconnects remain on the same process.

type StaticIssuer

type StaticIssuer struct {
	// contains filtered or unexported fields
}

StaticIssuer is a CertIssuer that serves a fixed, pre-provisioned certificate for one or more domains, e.g. loaded from files at startup. It never renews: Certificate.NotAfter reflects whatever was supplied, and Manager's renewal schedule will simply keep re-"issuing" (returning) the same static material.

func NewStaticIssuer

func NewStaticIssuer(cert *Certificate, domains ...string) *StaticIssuer

NewStaticIssuer creates a StaticIssuer that serves cert for every domain in domains.

func (*StaticIssuer) Issue

func (s *StaticIssuer) Issue(_ context.Context, domain string) (*Certificate, error)

Issue implements CertIssuer.

type WSAuthFunc added in v0.2.0

type WSAuthFunc func(*http.Request) (*ConnInfo, error)

WSAuthFunc authenticates an upgrade request and resolves the connection's identity in one step. Returning a non-nil error rejects the upgrade with HTTP 403.

The returned ConnInfo is carried through registration and into every subsequent callback, so the auth token is parsed exactly once instead of once per attribute the handler needs. Any field may be left zero: an empty ID falls back to WithWSIDFunc and then to a generated UUID, and empty Topics fall back to WithWSTopics.

type WSHandler

type WSHandler struct {
	// contains filtered or unexported fields
}

WSHandler upgrades incoming requests to WebSocket connections and registers each one in its Registry for the lifetime of the socket. It implements http.Handler; mount it only on the path(s) meant to accept WebSocket upgrades - plain (non-upgrade) HTTP handling is entirely out of scope here by design.

func NewWSHandler

func NewWSHandler(registry *Registry, opts ...WSHandlerOption) *WSHandler

NewWSHandler returns a WSHandler bound to registry. The returned handler is an http.Handler; wire its Drain method into Server via WithDrainOnShutdown (or call it from your own shutdown path) so established sockets are evicted on shutdown.

func (*WSHandler) Drain

func (h *WSHandler) Drain()

Drain closes every established WebSocket connection with status 1001 (going away) and rejects further upgrades, so a graceful server shutdown evicts long-lived sockets instead of leaving them until the peer disconnects.

The close code matters: 1001 tells the client this is a planned server-side departure (a rolling deploy), which is what lets it reconnect promptly instead of applying the long backoff it would use for an abnormal close. Safe to call more than once.

func (*WSHandler) ServeHTTP

func (h *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type WSHandlerOption

type WSHandlerOption func(*WSHandler)

WSHandlerOption configures a WebSocket http.Handler created with NewWSHandler.

func WithWSAuth added in v0.2.0

func WithWSAuth(f WSAuthFunc) WSHandlerOption

WithWSAuth sets the auth hook run before the upgrade is accepted. A non-nil error rejects the request with HTTP 403 Forbidden.

func WithWSBackpressurePolicy added in v0.2.0

func WithWSBackpressurePolicy(p BackpressurePolicy) WSHandlerOption

WithWSBackpressurePolicy decides what happens to a connection whose outbound buffer is full. Defaults to BackpressureDrop.

func WithWSCompression added in v0.2.0

func WithWSCompression(mode websocket.CompressionMode) WSHandlerOption

WithWSCompression enables the permessage-deflate extension with the given mode. Defaults to websocket.CompressionDisabled, so an unconfigured handler negotiates no compression and pays nothing for it. Compression is only negotiated when the client offers it too; a client that does not falls back to uncompressed frames transparently.

Modes are the ones coder/websocket defines: CompressionContextTakeover keeps a sliding window across messages for a better ratio at the cost of memory, CompressionNoContextTakeover resets per message for less memory and a worse ratio.

func WithWSGroupRate added in v0.2.0

func WithWSGroupRate(perSecond float64, burst int) WSHandlerOption

WithWSGroupRate limits the aggregate inbound message rate of every connection in the same group on this node, sharing one token bucket per group (perSecond sustained, burst depth). Disabled by default; a perSecond <= 0 leaves it disabled, and a burst < 1 is treated as 1 so a misconfigured burst limits hard rather than rejecting every message.

It composes with WithWSInboundRate: a message must satisfy both the per-connection and the per-group limiter to be accepted. On excess the backpressure policy decides the outcome, the same as the per-connection limiter: BackpressureDrop discards the offending message, BackpressureClose closes the connection with status 1008. Connections with an empty group are not group-limited, since they share no group bucket.

func WithWSIDFunc

func WithWSIDFunc(f func(*http.Request) string) WSHandlerOption

WithWSIDFunc sets the fallback used to derive a connection's id from the upgrade request when WSAuthFunc did not supply one. Without either, a random UUID is generated per connection.

Security: the connection id is a takeover key, not just a label. Because takeover is on by default (see WithWSReplaceExisting), a client that controls the id it registers under can evict any live connection that already holds that id and rebind it to itself: every later SendToConnection and SendToGroup delivery for that id then lands on the attacker's socket. Derive the id from an authenticated principal (do that in WSAuthFunc, which runs first), never from an unauthenticated request field such as a query parameter or client-supplied header.

func WithWSInboundRate added in v0.2.0

func WithWSInboundRate(perSecond float64, burst int) WSHandlerOption

WithWSInboundRate limits how many inbound messages a single connection may send per second, with burst as the bucket depth. Disabled by default. A perSecond <= 0 leaves it disabled; a burst < 1 is treated as 1 (a burst of 0 would otherwise reject every message).

What happens on excess follows the backpressure policy: BackpressureDrop discards the offending message, BackpressureClose tears the connection down with status 1008. Either way the rejection is logged as ErrRateLimited.

func WithWSInsecureSkipOriginCheck added in v0.2.0

func WithWSInsecureSkipOriginCheck() WSHandlerOption

WithWSInsecureSkipOriginCheck disables origin verification entirely.

Any web page on any site can then open an authenticated WebSocket to this handler using the visitor's cookies, read everything pushed to that user and send messages as them. Only use it when the handler is not cookie-authenticated at all (e.g. a bearer token supplied by the client) and you accept that consequence.

func WithWSLogger

func WithWSLogger(logger log.Logger) WSHandlerOption

WithWSLogger sets the logger used to report connection-handling errors. Defaults to log.DiscardLogger.

func WithWSMessageType added in v0.2.0

func WithWSMessageType(t websocket.MessageType) WSHandlerOption

WithWSMessageType sets the frame type used for outbound payloads. Defaults to websocket.MessageText, because a browser handed a binary frame receives a Blob rather than a string and every JSON-over-WebSocket client breaks on it.

func WithWSOnConnect

func WithWSOnConnect(f func(ctx context.Context, info *ConnInfo, r *http.Request)) WSHandlerOption

WithWSOnConnect sets the callback invoked once a connection has been registered and is ready to receive deliveries.

func WithWSOnDisconnect

func WithWSOnDisconnect(f func(info *ConnInfo)) WSHandlerOption

WithWSOnDisconnect sets the callback invoked once a connection's socket is gone. It also runs for a connection evicted by a takeover, which is the only way an application can tell that this particular socket - rather than the identity behind it - went away.

func WithWSOnMessage

func WithWSOnMessage(f func(ctx context.Context, info *ConnInfo, payload []byte)) WSHandlerOption

WithWSOnMessage sets the callback invoked for every message received from the client, with the identity resolved during the handshake.

func WithWSOriginPatterns added in v0.2.0

func WithWSOriginPatterns(patterns ...string) WSHandlerOption

WithWSOriginPatterns authorizes cross-origin upgrade requests whose Origin host matches one of the patterns. Patterns are matched case-insensitively with path.Match against the origin host, or against "scheme://host" when the pattern itself contains "://".

By default only same-Host origins are accepted. WebSockets are exempt from the same-origin policy while the browser still attaches cookies to the upgrade request, so an unchecked origin is a cross-site WebSocket hijacking hole.

func WithWSPingInterval added in v0.2.0

func WithWSPingInterval(d time.Duration) WSHandlerOption

WithWSPingInterval sets how often an otherwise idle connection is probed with a ping. Defaults to 30 seconds; 0 disables keepalive probing.

func WithWSPongTimeout added in v0.2.0

func WithWSPongTimeout(d time.Duration) WSHandlerOption

WithWSPongTimeout sets how long a ping waits for its pong before the connection is torn down. Defaults to 10 seconds.

func WithWSReadLimit added in v0.2.0

func WithWSReadLimit(n int64) WSHandlerOption

WithWSReadLimit caps the size of a single inbound message. Exceeding it closes the connection with status 1009 (message too big). Defaults to 1 MiB; a negative value disables the limit.

func WithWSReauth added in v0.2.0

func WithWSReauth(interval time.Duration, f WSAuthFunc) WSHandlerOption

WithWSReauth periodically re-runs f against the original upgrade request to re-check a connection whose authorization can be revoked while the socket is open (a role change, a logout, an expiring token). The first failure closes the connection with status 1008 (policy violation) and unregisters it. Disabled by default; a non-positive interval or a nil f leaves it disabled.

f is the same shape as the handshake WSAuthFunc but is consulted only for its error: the re-resolved identity is not applied to the live connection, because changing a socket's id or group mid-flight would strand deliveries already addressed to it. Only header and cookie based auth can be re-checked this way, since that is all the retained request still carries.

func WithWSReplaceExisting added in v0.2.0

func WithWSReplaceExisting() WSHandlerOption

WithWSReplaceExisting makes a new connection evict any existing one with the same id. It is already the default and the option exists to state that intent explicitly.

Takeover is the default because the alternative strands the user: behind a half-open TCP connection (a phone switching from wifi to cellular) the previous socket stays readable for as long as TCP keepalive takes to notice, and rejecting the reconnect with ErrConnectionExists would keep the client offline for exactly that long.

Security: takeover trusts that the reconnecting client is the same principal as the one it evicts. That is only true when the connection id is bound to an authenticated identity. If the id comes from an unauthenticated request field (see the warning on WithWSIDFunc), an attacker who supplies a victim's id both kicks the victim offline and hijacks every future delivery addressed to it.

func WithWSSendBuffer

func WithWSSendBuffer(size int) WSHandlerOption

WithWSSendBuffer sets the size of each connection's outbound buffer. When full, deliveries to that connection follow the backpressure policy instead of blocking. Defaults to 256.

func WithWSSubprotocols added in v0.2.0

func WithWSSubprotocols(protocols ...string) WSHandlerOption

WithWSSubprotocols lists the subprotocols the handler is willing to negotiate, in order of preference. The negotiated result is published to the application as ConnInfo.Meta["subprotocol"].

func WithWSTopics

func WithWSTopics(f func(*http.Request) []string) WSHandlerOption

WithWSTopics sets the fallback used to derive the topics a connection joins at registration time when WSAuthFunc did not supply any (e.g. a room query parameter).

func WithWSWriteTimeout added in v0.2.0

func WithWSWriteTimeout(d time.Duration) WSHandlerOption

WithWSWriteTimeout bounds a single outbound frame write. Defaults to 10 seconds.

Directories

Path Synopsis
coordinator
conformance
Package conformance is a shared test suite every gateway.Coordinator implementation must pass, so gateway.MemoryCoordinator and coordinator/redis.Coordinator (and any third-party implementation) are held to the exact same contract.
Package conformance is a shared test suite every gateway.Coordinator implementation must pass, so gateway.MemoryCoordinator and coordinator/redis.Coordinator (and any third-party implementation) are held to the exact same contract.
redis
Package redis provides a Redis-backed gateway.Coordinator, for sharing certificate issuance arbitration and distribution across every process in a deployment.
Package redis provides a Redis-backed gateway.Coordinator, for sharing certificate issuance arbitration and distribution across every process in a deployment.
examples
chat command
Command chat is a runnable demonstration of topic broadcast in the gateway package: a room-based WebSocket chat where every message a client sends is fanned out to every other client in the same room via Registry.Broadcast, and WithExclude keeps the sender from getting an echo of its own line back.
Command chat is a runnable demonstration of topic broadcast in the gateway package: a room-based WebSocket chat where every message a client sends is fanned out to every other client in the same room via Registry.Broadcast, and WithExclude keeps the sender from getting an echo of its own line back.
cluster command
Command cluster is a runnable demonstration of the reason this package exists: a connection can be registered on one node and receive a message handed to a completely different node.
Command cluster is a runnable demonstration of the reason this package exists: a connection can be registered on one node and receive a message handed to a completely different node.
delivery-confirm command
Command delivery-confirm is a runnable, single-process demonstration of gateway.WithDeliveryConfirmation: the opt-in that turns cross-node delivery from a fire-and-forget actor.Tell into an actor.Ask that waits for the connection-owning node to acknowledge the socket write.
Command delivery-confirm is a runnable, single-process demonstration of gateway.WithDeliveryConfirmation: the opt-in that turns cross-node delivery from a fire-and-forget actor.Tell into an actor.Ask that waits for the connection-owning node to acknowledge the socket write.
echo command
Command echo is a minimal, runnable demonstration of the gateway package:
Command echo is a minimal, runnable demonstration of the gateway package:
graphql-ws command
Command graphql-ws demonstrates that gateway.Registry is usable without gateway.WSHandler at all.
Command graphql-ws demonstrates that gateway.Registry is usable without gateway.WSHandler at all.
notification command
Command notification is a minimal, runnable version of the "multi-device group push with offline fallback" pattern every notification-style consumer of gateway needs:
Command notification is a minimal, runnable version of the "multi-device group push with offline fallback" pattern every notification-style consumer of gateway needs:
offline-push command
Command offline-push demonstrates Registry.WithOfflineChannel: the library-native replacement for the old pattern where every caller of SendToGroup had to inspect DeliveryResult.None itself and remember to invoke its own offline transport (see examples/notification, which still shows that manual style for comparison).
Command offline-push demonstrates Registry.WithOfflineChannel: the library-native replacement for the old pattern where every caller of SendToGroup had to inspect DeliveryResult.None itself and remember to invoke its own offline transport (see examples/notification, which still shows that manual style for comparison).
persistence command
Command persistence is a minimal, runnable version of gateway's opt-in at-least-once delivery: a gateway.Outbox plus Registry.Ack turning the default fire-and-forget socket write into "persist, deliver, wait for the client's ack, redeliver on reconnect if it never came".
Command persistence is a minimal, runnable version of gateway's opt-in at-least-once delivery: a gateway.Outbox plus Registry.Ack turning the default fire-and-forget socket write into "persist, deliver, wait for the client's ack, redeliver on reconnect if it never came".
presence-watch command
Command presence-watch is a minimal, runnable version of the "friend came online" pattern: a watcher subscribes once to a group's membership changes with Registry.WatchPresence and receives a PresenceJoin/PresenceLeave event the instant a connection registers or unregisters for that group, instead of polling Registry.GroupMembers or Registry.IsOnline on a timer.
Command presence-watch is a minimal, runnable version of the "friend came online" pattern: a watcher subscribes once to a group's membership changes with Registry.WatchPresence and receives a PresenceJoin/PresenceLeave event the instant a connection registers or unregisters for that group, instead of polling Registry.GroupMembers or Registry.IsOnline on a timer.
reauth-kick command
Command reauth-kick is a minimal, runnable version of the "authorization can be revoked out from under a live socket" pattern: a WebSocket handshake only proves who the caller was at connect time, not who they still are five minutes into a long-lived session.
Command reauth-kick is a minimal, runnable version of the "authorization can be revoked out from under a live socket" pattern: a WebSocket handshake only proves who the caller was at connect time, not who they still are five minutes into a long-lived session.
sse-resume command
Command sse-resume is a minimal, runnable demonstration of Last-Event-ID replay.
Command sse-resume is a minimal, runnable demonstration of Last-Event-ID replay.
tls-cloudflare command
Command tls-cloudflare demonstrates gateway.Manager wired for the two TLS shapes described in README.md:
Command tls-cloudflare demonstrates gateway.Manager wired for the two TLS shapes described in README.md:
observer
otel
Package otel provides an OpenTelemetry-backed implementation of gateway.Observer.
Package otel provides an OpenTelemetry-backed implementation of gateway.Observer.
offline
webpush
Package webpush implements gateway.OfflineChannel over the Web Push protocol (RFC 8291 encryption, RFC 8292 VAPID).
Package webpush implements gateway.OfflineChannel over the Web Push protocol (RFC 8291 encryption, RFC 8292 VAPID).
persistence
conformance
Package conformance is a shared test suite every gateway.Outbox implementation must pass, so gateway.MemoryOutbox and persistence/redis.Outbox (and any third-party implementation) are held to the exact same at-least-once contract: a message is retained once appended, stamped with a per-connection id and sequence the Outbox itself mints in append order, remains readable until acknowledged, is redeliverable in Seq order, disappears only on Ack or DropConn, and rejects an Ack whose generation trails one already accepted for the same connection.
Package conformance is a shared test suite every gateway.Outbox implementation must pass, so gateway.MemoryOutbox and persistence/redis.Outbox (and any third-party implementation) are held to the exact same at-least-once contract: a message is retained once appended, stamped with a per-connection id and sequence the Outbox itself mints in append order, remains readable until acknowledged, is redeliverable in Seq order, disappears only on Ack or DropConn, and rejects an Ack whose generation trails one already accepted for the same connection.
redis
Package redis provides a Redis- or Valkey-backed gateway.Outbox, so a connection's unacknowledged tail survives a node failure: a client that reconnects to a different process still finds the messages the original process had sent but not yet had acknowledged, and they are redelivered.
Package redis provides a Redis- or Valkey-backed gateway.Outbox, so a connection's unacknowledged tail survives a node failure: a client that reconnects to a different process still finds the messages the original process had sent but not yet had acknowledged, and they are redelivered.
presence
conformance
Package conformance is a shared test suite every gateway.Presence implementation must pass, so gateway.MemoryPresence and presence/redis.Presence (and any third-party implementation) are held to the exact same contract - most importantly the lease contract, because an implementation that keeps a dead node's connections online forever makes IsOnline permanently true and silently disables the offline delivery channel.
Package conformance is a shared test suite every gateway.Presence implementation must pass, so gateway.MemoryPresence and presence/redis.Presence (and any third-party implementation) are held to the exact same contract - most importantly the lease contract, because an implementation that keeps a dead node's connections online forever makes IsOnline permanently true and silently disables the offline delivery channel.
redis
RefreshGeneration and LeaveGeneration are the generation-fenced counterparts of Refresh and Leave: they let a caller that knows its connection's owner-lease generation (see the root gateway package's WithOwnerLease/ownerLease) attach it to a presence write, so a write issued by a node whose ownership a takeover has already superseded is rejected rather than resurrecting or destroying membership state a newer owner established since.
RefreshGeneration and LeaveGeneration are the generation-fenced counterparts of Refresh and Leave: they let a caller that knows its connection's owner-lease generation (see the root gateway package's WithOwnerLease/ownerLease) attach it to a presence write, so a write issued by a node whose ownership a takeover has already superseded is rejected rather than resurrecting or destroying membership state a newer owner established since.
ssehistory
conformance
Package conformance is a shared test suite every gateway.SSEHistory implementation must pass, so gateway.MemorySSEHistory and ssehistory/redis.History (and any third-party implementation) are held to the exact same Last-Event-ID contract - most importantly the three Since cases, because an implementation that reports a gap where there is none (or hides a real gap) makes EventSource reconnect either replay nothing or silently skip events the client never saw.
Package conformance is a shared test suite every gateway.SSEHistory implementation must pass, so gateway.MemorySSEHistory and ssehistory/redis.History (and any third-party implementation) are held to the exact same Last-Event-ID contract - most importantly the three Since cases, because an implementation that reports a gap where there is none (or hides a real gap) makes EventSource reconnect either replay nothing or silently skip events the client never saw.
redis
Package redis provides a Redis- or Valkey-backed gateway.SSEHistory, so a client that reconnects its EventSource to any node in a deployment can be replayed from Last-Event-ID, not only to the same node it originally landed on.
Package redis provides a Redis- or Valkey-backed gateway.SSEHistory, so a client that reconnects its EventSource to any node in a deployment can be replayed from Last-Event-ID, not only to the same node it originally landed on.
store
conformance
Package conformance is a shared test suite every gateway.CertStore implementation must pass, so gateway.MemoryCertStore, gateway.FileCertStore and store/redis.Store (and any third-party implementation) are held to the exact same contract.
Package conformance is a shared test suite every gateway.CertStore implementation must pass, so gateway.MemoryCertStore, gateway.FileCertStore and store/redis.Store (and any third-party implementation) are held to the exact same contract.
redis
Package redis provides a Redis- or Valkey-backed gateway.CertStore, so that every process in a deployment shares one persistent certificate store: a node that cold-starts fetches already-issued certificates back from the server instead of depending on the issuer being reachable/willing to re-issue.
Package redis provides a Redis- or Valkey-backed gateway.CertStore, so that every process in a deployment shares one persistent certificate store: a node that cold-starts fetches already-issued certificates back from the server instead of depending on the issuer being reachable/willing to re-issue.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL