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 ¶
- Constants
- Variables
- func DecodeOutboxEnvelope(data []byte) (msgID string, seq uint64, payload []byte, err error)
- func DecodeOutboxTextEnvelope(data []byte) (msgID string, seq uint64, payload []byte, err error)
- func EncodeOutboxEnvelope(msgID string, seq uint64, payload []byte) ([]byte, error)
- func EncodeOutboxTextEnvelope(msgID string, seq uint64, payload []byte) ([]byte, error)
- func NewRSACertificateRequest(bits int) func(domain string) (csrPEM, keyPEM []byte, err error)
- func ReplayTailSeq(msgs []PersistedMessage) uint64
- type AuthenticatedOriginPulls
- type BackpressurePolicy
- type BroadcastOption
- type CASCoordinator
- type CertIssuer
- type CertStore
- type Certificate
- type CloudflareOriginCAIssuer
- type ConnHandle
- type ConnInfo
- type Coordinator
- type DeliveryResult
- type DomainPolicy
- type Drainer
- type FileCertStore
- type GenerationalHistory
- type LinearizableFencingCoordinator
- type Manager
- func (m *Manager) EnsureCertificate(ctx context.Context, domain string) (*tls.Certificate, error)
- func (m *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
- func (m *Manager) Start(ctx context.Context) error
- func (m *Manager) Stop(ctx context.Context) error
- func (m *Manager) TLSConfig() *tls.Config
- type ManagerOption
- func WithAllowedDomains(domains ...string) ManagerOption
- func WithCertIssuer(issuer CertIssuer) ManagerOption
- func WithCertStore(store CertStore) ManagerOption
- func WithCoordinator(coordinator Coordinator) ManagerOption
- func WithDomainPolicy(p DomainPolicy) ManagerOption
- func WithFallbackCertificate(get func() (*tls.Certificate, error)) ManagerOption
- func WithIssuanceLockTTL(d time.Duration) ManagerOption
- func WithMaxCachedCerts(n int) ManagerOption
- func WithNegativeCacheTTL(d time.Duration) ManagerOption
- func WithRenewBefore(d time.Duration) ManagerOption
- func WithRenewInterval(cronExpression string) ManagerOption
- type MemoryCertStore
- type MemoryCoordinator
- func (m *MemoryCoordinator) CompareAndSwap(_ context.Context, key string, expected, newValue []byte, ttl time.Duration) (bool, error)
- func (m *MemoryCoordinator) Get(_ context.Context, key string) ([]byte, bool, error)
- func (*MemoryCoordinator) LinearizableFencing()
- func (m *MemoryCoordinator) Put(_ context.Context, key string, value []byte, ttl time.Duration) error
- func (m *MemoryCoordinator) TryLock(_ context.Context, key string, ttl time.Duration) (func(context.Context) error, error)
- type MemoryOutbox
- func (o *MemoryOutbox) Ack(_ context.Context, connID, msgID string, generation uint64) error
- func (o *MemoryOutbox) AdvanceGeneration(_ context.Context, connID string, generation uint64) error
- func (o *MemoryOutbox) Append(_ context.Context, connID string, payload []byte) (string, uint64, error)
- func (o *MemoryOutbox) DropConn(_ context.Context, connID string) error
- func (o *MemoryOutbox) Unacked(_ context.Context, connID string) ([]PersistedMessage, error)
- type MemoryPresence
- func (p *MemoryPresence) Entries(_ context.Context, group string) ([]PresenceEntry, error)
- func (p *MemoryPresence) Join(_ context.Context, group, connID string, ttl time.Duration) error
- func (p *MemoryPresence) JoinWithMeta(_ context.Context, group, connID string, meta map[string]string, ...) error
- func (p *MemoryPresence) Leave(_ context.Context, group, connID string) error
- func (p *MemoryPresence) LeaveGen(_ context.Context, group, connID string, generation uint64) error
- func (p *MemoryPresence) Members(_ context.Context, group string) ([]string, error)
- func (p *MemoryPresence) Online(_ context.Context, group string) (bool, error)
- func (p *MemoryPresence) Refresh(_ context.Context, group, connID string, ttl time.Duration) error
- func (p *MemoryPresence) RefreshGen(_ context.Context, group, connID string, generation uint64, ttl time.Duration) error
- func (p *MemoryPresence) Watch(ctx context.Context, group string) (<-chan PresenceEvent, func(), error)
- type MemorySSEHistory
- func (h *MemorySSEHistory) AdvanceGeneration(_ context.Context, connID string, generation uint64) error
- func (h *MemorySSEHistory) Append(_ context.Context, connID, eventID string, payload []byte) error
- func (h *MemorySSEHistory) AppendGenerational(_ context.Context, connID, eventID string, payload []byte, generation uint64) (uint64, error)
- func (h *MemorySSEHistory) Drop(connID string)
- func (h *MemorySSEHistory) Len() int
- func (h *MemorySSEHistory) Since(_ context.Context, connID, lastEventID string) ([]SSEEvent, error)
- type MemorySSEHistoryOption
- type Observer
- type OfflineChannel
- type OfflineObserver
- type OfflineOption
- type Outbox
- type OutboxGenerationAdvancer
- type PersistedMessage
- type Presence
- type PresenceDirectory
- type PresenceEntry
- type PresenceEvent
- type PresenceEventKind
- type PresenceFencer
- type PresenceMetaJoiner
- type PresenceWatcher
- type RegisterOption
- type Registry
- func (r *Registry) Ack(ctx context.Context, connID, msgID string) error
- func (r *Registry) Broadcast(ctx context.Context, topic string, payload []byte, opts ...BroadcastOption) (DeliveryResult, error)
- func (r *Registry) Close(_ context.Context) error
- func (r *Registry) Disconnect(ctx context.Context, id, reason string) error
- func (r *Registry) DisconnectGroup(ctx context.Context, group, reason string) (int, error)
- func (r *Registry) GroupMembers(ctx context.Context, group string) ([]PresenceEntry, error)
- func (r *Registry) Has(id string) bool
- func (r *Registry) IsOnline(ctx context.Context, group string) (bool, error)
- func (r *Registry) Join(ctx context.Context, id, topic string) error
- func (r *Registry) Leave(_ context.Context, id, topic string) error
- func (r *Registry) Len() int
- func (r *Registry) LocalConnectionsOf(group string) []string
- func (r *Registry) Register(ctx context.Context, id string, send func([]byte) error, ...) error
- func (r *Registry) RegisterHandle(ctx context.Context, id string, send func([]byte) error, ...) (*ConnHandle, error)
- func (r *Registry) SendToConnection(ctx context.Context, id string, payload []byte) error
- func (r *Registry) SendToGroup(ctx context.Context, group string, payload []byte) (DeliveryResult, error)
- func (r *Registry) Unregister(ctx context.Context, id string) error
- func (r *Registry) WatchPresence(ctx context.Context, group string) (<-chan PresenceEvent, func(), error)
- type RegistryOption
- func WithConfirmationTimeout(d time.Duration) RegistryOption
- func WithDeliveryConfirmation() RegistryOption
- func WithObserver(o Observer) RegistryOption
- func WithOfflineChannel(ch OfflineChannel, opts ...OfflineOption) RegistryOption
- func WithOutbox(o Outbox) RegistryOption
- func WithOutboxEnvelope() RegistryOption
- func WithOwnerLease(c Coordinator) RegistryOption
- func WithPresence(p Presence) RegistryOption
- func WithPresenceTTL(d time.Duration) RegistryOption
- type ReloadingCertificate
- type SSEAuthFunc
- type SSEEvent
- type SSEHandler
- type SSEHandlerOption
- func WithSSEAuth(f SSEAuthFunc) SSEHandlerOption
- func WithSSEBackpressurePolicy(p BackpressurePolicy) SSEHandlerOption
- func WithSSEEventName(f func(payload []byte) string) SSEHandlerOption
- func WithSSEHistory(h SSEHistory) SSEHandlerOption
- func WithSSEIDFunc(f func(*http.Request) string) SSEHandlerOption
- func WithSSEKeepAlive(d time.Duration) SSEHandlerOption
- func WithSSELogger(logger log.Logger) SSEHandlerOption
- func WithSSEOnConnect(f func(ctx context.Context, info *ConnInfo, r *http.Request)) SSEHandlerOption
- func WithSSEOnDisconnect(f func(info *ConnInfo)) SSEHandlerOption
- func WithSSEReauth(interval time.Duration, f SSEAuthFunc) SSEHandlerOption
- func WithSSERetry(d time.Duration) SSEHandlerOption
- func WithSSESendBuffer(size int) SSEHandlerOption
- func WithSSETopics(f func(*http.Request) []string) SSEHandlerOption
- func WithSSEWriteTimeout(d time.Duration) SSEHandlerOption
- type SSEHistory
- type Server
- type ServerOption
- func WithAuthenticatedOriginPulls(pulls *AuthenticatedOriginPulls) ServerOption
- func WithDrainOnShutdown(drainers ...Drainer) ServerOption
- func WithReadHeaderTimeout(d time.Duration) ServerOption
- func WithServerErrorLog(logger *stdlog.Logger) ServerOption
- func WithTLSManager(manager *Manager) ServerOption
- type SharedSSEHistory
- type StaticIssuer
- type WSAuthFunc
- type WSHandler
- type WSHandlerOption
- func WithWSAuth(f WSAuthFunc) WSHandlerOption
- func WithWSBackpressurePolicy(p BackpressurePolicy) WSHandlerOption
- func WithWSCompression(mode websocket.CompressionMode) WSHandlerOption
- func WithWSGroupRate(perSecond float64, burst int) WSHandlerOption
- func WithWSIDFunc(f func(*http.Request) string) WSHandlerOption
- func WithWSInboundRate(perSecond float64, burst int) WSHandlerOption
- func WithWSInsecureSkipOriginCheck() WSHandlerOption
- func WithWSLogger(logger log.Logger) WSHandlerOption
- func WithWSMessageType(t websocket.MessageType) WSHandlerOption
- func WithWSOnConnect(f func(ctx context.Context, info *ConnInfo, r *http.Request)) WSHandlerOption
- func WithWSOnDisconnect(f func(info *ConnInfo)) WSHandlerOption
- func WithWSOnMessage(f func(ctx context.Context, info *ConnInfo, payload []byte)) WSHandlerOption
- func WithWSOriginPatterns(patterns ...string) WSHandlerOption
- func WithWSPingInterval(d time.Duration) WSHandlerOption
- func WithWSPongTimeout(d time.Duration) WSHandlerOption
- func WithWSReadLimit(n int64) WSHandlerOption
- func WithWSReauth(interval time.Duration, f WSAuthFunc) WSHandlerOption
- func WithWSReplaceExisting() WSHandlerOption
- func WithWSSendBuffer(size int) WSHandlerOption
- func WithWSSubprotocols(protocols ...string) WSHandlerOption
- func WithWSTopics(f func(*http.Request) []string) WSHandlerOption
- func WithWSWriteTimeout(d time.Duration) WSHandlerOption
Examples ¶
Constants ¶
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 ¶
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") // AuthFunc rejects the incoming request. ErrUnauthorized = errors.New("gateway: unauthorized") // 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") // 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") // 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") )
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.
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.
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
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
DecodeOutboxTextEnvelope decodes a frame written by EncodeOutboxTextEnvelope.
func EncodeOutboxEnvelope ¶ added in v0.2.0
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
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 ¶
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 ¶
func (a *AuthenticatedOriginPulls) Verify(state tls.ConnectionState) error
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
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 ¶
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 ¶
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.
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) 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
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
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
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
LeaveGen is Leave fenced by generation: see PresenceFencer.
func (*MemoryPresence) Members ¶ added in v0.2.0
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
Online reports whether group has at least one live member.
func (*MemoryPresence) Refresh ¶ added in v0.2.0
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
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.
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
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
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
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
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
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) IsOnline ¶ added in v0.2.0
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 ¶
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 ¶
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) LocalConnectionsOf ¶ added in v0.2.0
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 ¶
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 ¶
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
func (c *ReloadingCertificate) Get() (*tls.Certificate, error)
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
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 (*Server) ListenAndServe ¶
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 ¶
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 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
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.
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 ¶
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.
Source Files
¶
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. |