Documentation
¶
Overview ¶
Package joaju is the WebSocket server: it holds open sockets, keeps them grouped into channels, and delivers a message to every socket in a group.
This file declares the types the rest of the repository is written against. Nothing here opens a socket, parses a frame or serves an HTTP route; the packages that do those things take and return the values declared here.
The wire format ¶
The wire format is the Pusher protocol, which is why several names here are the protocol's rather than Go's: Member has user_id and user_info, the event names carry the "pusher:" and "pusher_internal:" prefixes, and a channel's kind is read off its name prefix. Those are bytes a browser client already knows how to read, and renaming them would mean shipping a client nobody else can talk to.
One connection is one goroutine. There is no event loop to size, and the ceiling on concurrent sockets is the process's file descriptor limit.
The authorization shape, which is the point ¶
Two decisions happen, and each one is a Policy that issues an auth.Grant:
[Connect] opening the socket -> [ConnectPolicy] broadcasting.ChannelJoin listening on one -> [SubscriptionPolicy]
They are two decisions and not one because they answer different questions. The first is whether this subject may hold a socket at all, and it is where an origin allowlist belongs -- see Handshake. The second is whether this subject may hear what goes out on one particular channel, and it is asked again for every channel, because subscribing is a read and no read reaches a channel without a policy behind it.
A Connection cannot be built without a Grant, and a ChannelName cannot be built without one either. The tenant is enforced by the type and not by a review comment: it is read off the Grant by NewChannelName, and the name the client sent is refused outright if it so much as contains the separator the tenant goes before. A client that could put "acme:" in front of a channel is a client choosing whose events it hears.
There is deliberately no constructor that builds a ChannelName out of a name that already carries a tenant. The server that receives a relayed message over Redis pub/sub has such a name in hand, and the temptation is to parse the tenant back out of it -- which is the tenant coming off the wire. It compares the string against the ChannelName.String of the channels it already holds instead, and those were each built from a Grant.
What hesape already answers, and is not answered again here ¶
github.com/arandu-io/hesape/broadcasting owns the channel vocabulary shared with the SSE fanout, and this package imports it rather than restating it: broadcasting.PrivateChannelPrefix and broadcasting.PresenceChannelPrefix, broadcasting.TenantSeparator, broadcasting.RequestedChannel, broadcasting.TenantChannel and the broadcasting.ChannelJoin action are all used here as they stand. What is new is what SSE has no equivalent of: the three cache-channel prefixes, because SSE has no cache channel, and MaxChannelNameLength with ChannelNameCharacters, because a name here is held for the life of a subscription and a name there is not held at all. Neither of those is a second opinion about what hesape already refuses -- they are read after it, over the name it accepted.
What this package deliberately leaves out ¶
Multi-application is out of the first version: a Go binary is one process, and running one per application is both simpler to operate and free of the cross-application state the tenant rules exist to prevent.
The protocol's private-encrypted- channels have no ChannelType of their own. End-to-end encrypted channels are a key distribution feature, not a channel kind: this server never holds the key and never reads a payload, so there is nothing about one it could report that private does not already say.
The prefix is still read, and ChannelName.Type is where. What it is read for is the two properties this server does implement -- authorized, and replayed to whoever subscribes next -- so "private-encrypted-" is a PrivateChannel and "private-encrypted-cache-" is a PrivateCacheChannel. Leaving it unread cost nothing on the first and a replay on the second, which is the shape of a prefix that is half known: the guarded half was right by accident, because it is the half "private-" already carried.
Channel has no second broadcast method for an event this server was relayed rather than handed. Suppressing the re-cache of a replayed payload is the relay's business, and putting it here would give every implementation a second way to broadcast to explain.
One process, and the day there are two ¶
Everything above is finished in one process, and a deployment of one is one this package serves whole. The socket, the channels, both policies, the tenant rule, the presence member lists, the cache replay and every route answer for the process that holds them -- which, there, is the only process there is.
A second instance changes three of those answers, and none of the three announces itself:
- an event published on one instance reaches the sockets that instance holds, and no others;
- a presence channel lists the members connected to the instance that was asked, so two subscribers of one channel are shown two different rooms;
- the metrics routes count what one process holds, which is a fraction of the application and looks exactly like the whole of it.
Nothing fails, which is why it is written here rather than left to be found: each of the three is a well-formed answer to a question the caller did not ask, and it is a question nobody asks until the deployment stops being one binary. Until then there is nothing to configure and nothing missing.
Relay is what that day needs and the whole of what it needs -- Redis pub/sub, RelayedBroker over the Broker so that the channels held and the channels relayed are one set, and both handed to ServerConfig.Relay and to the Protocol. It carries exactly those three things. Nothing else moves: the policies, the channel names, the frames and the routes are what they were, and a client cannot tell a fleet of one from a fleet of ten.
The routes these types serve ¶
One, and it is the socket:
GET /app/{appKey} the socket itself
A deployment answers eight more, and they are a wire format's rather than this package's -- the ones below are the Pusher protocol's, brought by Protocol.Routes and mounted beside the socket route:
POST /apps/{appId}/events publish one
POST /apps/{appId}/batch_events publish many
GET /apps/{appId}/connections metrics
GET /apps/{appId}/channels list
GET /apps/{appId}/channels/{channel} one
GET /apps/{appId}/channels/{channel}/users presence members
POST /apps/{appId}/users/{userId}/terminate_connections disconnect
GET /up health
Every one of the seven that reads or writes channel state needs a Grant, and Broker is why: there is no method on it that reaches a channel without one. API is the whole of what a route may reach, and there is nothing on it that writes to the connection registry or reads one without a Grant.
The protocol ¶
Subpackage protocols/pusher is the wire format: the frames, the codecs, the channels, the in-memory Broker, the eight HTTP routes and the Protocol that answers a socket. It imports this package and this package does not import it, which is what keeps the two apart -- the server here owns the socket and knows no frame, and a second protocol would be a second subpackage rather than a branch in this one.
The transport ¶
Subpackage ws is this project's own implementation of RFC 6455, and it is why the dependency graph has one entry in it. It is not a fork and it borrows no code: the surface this server actually uses is nineteen names -- twelve of the package and seven methods of Conn -- so writing them costs less than carrying a library's client stack, proxy support and compression -- and less than tracking somebody else's security advisories forever.
The other end ¶
Subpackage client is the browser half: the JavaScript that speaks this protocol from a page, embedded and served rather than fetched from npm or a CDN. It is not routed by Server and the reason is in its own documentation -- the script has to be served from the origin the PAGE is on, and a socket server is frequently not that origin.
Index ¶
- Constants
- Variables
- func MetricsReplyTopic(id InstanceID) string
- func Topic(name ChannelName) string
- type API
- type Broker
- type Bus
- type Carrier
- type Channel
- type ChannelName
- type ChannelTally
- type ChannelType
- type ConnectPolicy
- type Connection
- type Counter
- func (c *Counter) ChannelCreated(_ context.Context, name ChannelName)
- func (c *Counter) ChannelRemoved(_ context.Context, name ChannelName)
- func (c *Counter) ConnectionClosed(_ context.Context, _ SocketID, tenant, _ string)
- func (c *Counter) ConnectionOpened(_ context.Context, _ SocketID, tenant string)
- func (c *Counter) MessageReceived(_ context.Context, _ SocketID, _ []byte)
- func (c *Counter) MessageSent(_ context.Context, _ SocketID, _ []byte)
- func (c *Counter) Read(tenant string) TenantCount
- func (c *Counter) Tenants() []string
- type Event
- type FleetTally
- type Handshake
- type InstanceID
- type Member
- type NopObserver
- func (NopObserver) ChannelCreated(context.Context, ChannelName)
- func (NopObserver) ChannelRemoved(context.Context, ChannelName)
- func (NopObserver) ConnectionClosed(context.Context, SocketID, string, string)
- func (NopObserver) ConnectionOpened(context.Context, SocketID, string)
- func (NopObserver) MessageReceived(context.Context, SocketID, []byte)
- func (NopObserver) MessageSent(context.Context, SocketID, []byte)
- type Observer
- type Protocol
- type Refusal
- type Registry
- type Relay
- type Server
- func (s *Server) Close(ctx context.Context)
- func (s *Server) Connections(g auth.Grant) (int, error)
- func (s *Server) Fleet(ctx context.Context, g auth.Grant, channel string) FleetTally
- func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (s *Server) Terminate(ctx context.Context, g auth.Grant, subject string) (int, error)
- type ServerConfig
- type Sink
- type SocketID
- type Subscriber
- type Subscription
- type SubscriptionPolicy
- type TenantCount
Constants ¶
const ( // ReasonClient is the client hanging up, which is the ordinary case. ReasonClient = "client" // ReasonTimeout is nothing heard within PongTimeout. ReasonTimeout = "timeout" // ReasonTerminated is POST /apps/{id}/users/{userId}/terminate_connections. ReasonTerminated = "terminated" // ReasonShutdown is the server closing. ReasonShutdown = "shutdown" // ReasonLimit is the connection limit, refused after the upgrade. ReasonLimit = "limit" )
Reasons a connection ended, as Observer.ConnectionClosed receives them.
const ( // CacheChannelPrefix marks a public cache channel. CacheChannelPrefix = "cache-" // PrivateCacheChannelPrefix marks a cache channel that must be authorized. PrivateCacheChannelPrefix = broadcasting.PrivateChannelPrefix + CacheChannelPrefix // PresenceCacheChannelPrefix marks a cache channel that publishes its // members. PresenceCacheChannelPrefix = broadcasting.PresenceChannelPrefix + CacheChannelPrefix // EncryptedPrivateCacheChannelPrefix marks a cache channel that must be // authorized and whose payloads the subscribers encrypt among themselves. // // The encryption is not this server's: what it replays is the bytes it was // handed. The prefix is named because it is the one place where the // protocol puts something between "private-" and "cache-", so it is the one // place [ChannelName.Type] would read a cache channel as a plain private // one and stop replaying to it. EncryptedPrivateCacheChannelPrefix = broadcasting.EncryptedPrivateChannelPrefix + CacheChannelPrefix )
The prefixes a Pusher cache channel carries.
broadcasting.PrivateChannelPrefix and broadcasting.PresenceChannelPrefix already name the other two, and this package uses those rather than spelling them a second time. Only these three are new, because the SSE fanout hesape/broadcasting was written for has no cache channel.
A cache channel replays the last event it carried to whoever subscribes next, so a client that connects after the event still sees the current state instead of waiting for the next one.
const ( // MaxChannelNameLength is the longest name a channel may have, prefix // counted -- "private-cache-" spends fourteen of it. // // Without the limit the only ceiling is [DefaultMaxMessageSize], and a name // at that ceiling is not paid for once: it is held in the channel the // [Broker] keeps, in the key the subscription is recorded under and in the // [ChannelName] beside it, for as long as the subscription lasts. Measured // on one socket, a subscription to a name at the message limit costs some // 36 KiB of heap where one at this limit costs some 650 bytes. MaxChannelNameLength = 164 // ChannelNameCharacters is every character a name may hold, besides the // letters and the digits. // // It is a small set and that is the point: a name reaches a log line, a // metrics label, a Redis channel and a URL path, and the characters that // mean something in one of those are the ones missing from here. ChannelNameCharacters = "_-=@,.;" )
What a channel name may be, which NewChannelName enforces.
Both are the protocol's own, so a name this package refuses is a name no client of the protocol was going to send and no other server of it would have accepted. They are not a second opinion about the tenant: the separator is refused before either of these is reached, and it is not in the set below anyway.
const ( // ProtocolPrefix is the namespace of the protocol's own events. ProtocolPrefix = "pusher:" // InternalPrefix is the namespace of events only the server may send. InternalPrefix = "pusher_internal:" )
The two reserved namespaces of the Pusher protocol.
An event a client publishes may use neither: the first is the protocol's own traffic, and the second is the server talking about channel state. A client that could send pusher_internal:member_added could invent members.
const ( // EventConnectionEstablished carries the [SocketID] to a client that has // just connected. It is the first frame the server sends, and the client // needs it: the socket id is what it puts in a publish so its own broadcast // does not come back to it. EventConnectionEstablished = ProtocolPrefix + "connection_established" // EventError reports a refusal. A subscription denied by a Policy is one. EventError = ProtocolPrefix + "error" // EventSubscribe is a client asking to listen on a channel. EventSubscribe = ProtocolPrefix + "subscribe" // EventUnsubscribe is a client asking to stop. EventUnsubscribe = ProtocolPrefix + "unsubscribe" // EventPing is a client checking the socket is alive. EventPing = ProtocolPrefix + "ping" // EventPong answers [EventPing]. EventPong = ProtocolPrefix + "pong" // EventSubscriptionSucceeded confirms a subscription and carries // [Channel.Data], which for a presence channel is the member list. EventSubscriptionSucceeded = InternalPrefix + "subscription_succeeded" // EventMemberAdded tells a presence channel a member joined. EventMemberAdded = InternalPrefix + "member_added" // EventMemberRemoved tells a presence channel a member left. EventMemberRemoved = InternalPrefix + "member_removed" )
The protocol event names.
They are the Pusher protocol's, and are here so that the two ends of this repository -- the code that reads a frame and the code that writes one -- name them once.
const ( // DefaultMaxMessageSize is the largest frame a client may send, and is the // Pusher protocol's own limit of 10 KiB. It is a limit on what a client // sends, not on what the server sends: a presence channel's member list is // larger than this and goes out fine. DefaultMaxMessageSize int64 = 10 << 10 // DefaultMaxBodySize is the largest body an API route reads. A batch // publish is the big one. DefaultMaxBodySize int64 = 1 << 20 // DefaultOutboundQueue is how many frames may be waiting on one socket // before the client is judged to have fallen behind. DefaultOutboundQueue = 64 // DefaultMaxConnections is how many sockets one tenant may hold open. // // Zero in [ServerConfig] means this, and this is not "no limit": a server // that accepts sockets until the file descriptors run out stops answering // for EVERY tenant, and the one that exhausted them is not necessarily the // one that notices. The number here is a default that a deployment raises // knowingly. DefaultMaxConnections = 10_000 // DefaultWriteTimeout is how long one frame may take to reach the client. DefaultWriteTimeout = 10 * time.Second // DefaultPingInterval is how often the server sends a WebSocket ping. DefaultPingInterval = 30 * time.Second // DefaultPongTimeout is how long the server waits to hear anything from a // client before it hangs up. It has to be longer than // [DefaultPingInterval], or the read deadline expires before the pong the // ping asked for can arrive. DefaultPongTimeout = 70 * time.Second )
The defaults NewServer fills in for a zero ServerConfig field.
const Connect auth.Action = "joaju.connect"
Connect is the auth.Action the Grant behind a Connection is issued for.
It is the first of the two decisions this package makes, and it is separate from broadcasting.ChannelJoin on purpose. This one answers whether a subject may hold a socket at all; the other answers whether they may hear one channel, and it is asked again for every channel they name. Issuing one Grant for both would mean the per-channel policy never runs: one constant undoing every subscription decision.
const DefaultMetricsTimeout = 500 * time.Millisecond
DefaultMetricsTimeout is how long a metrics route waits for the rest of the fleet before it answers with what it has.
Unlike DefaultRetryInterval this one is a default and not the whole answer, because it is the number here that depends on the deployment: instances sharing a datacentre answer in single-digit milliseconds and instances spread across a region do not. What it may not be is absent. An instance that has stopped answering must not hold a route open, and most of the fleet's numbers now is worth more than all of them never.
const DefaultRetryInterval = time.Second
DefaultRetryInterval is how long a Relay waits before opening a subscription again after the one it had was lost.
It is a constant and not a knob because a knob would be a second way to answer "how fast does this reconnect". A second is short enough that a Redis restart costs one message and long enough that a Redis that is down is not asked a thousand times a second by every instance at once.
const MetricsTopic = TopicPrefix + "metrics"
MetricsTopic is the pub/sub channel one instance asks the others what they are holding on.
It is one topic for the whole fleet and not one per tenant, because the question is asked by an instance and not by a customer: every instance subscribes once, when its server is built, and a topic per tenant would mean a subscription that changes every time a customer's first socket opens. The tenant travels inside the question, where it is a filter and never an authority -- see [Relay.answer].
It cannot be mistaken for a channel's Topic. A channel's topic is TopicPrefix, the tenant, broadcasting.TenantSeparator and the name, so it always carries a second colon; this carries none.
const TopicPrefix = "joaju:"
TopicPrefix is what every Redis pub/sub channel this package publishes on begins with.
It is there so that relay traffic is distinguishable from everything else on the same Redis -- in particular from what hesape's RedisBroadcaster publishes, which is an application talking to a relay and not two relays talking to each other. The two carry different payloads, and a subscriber that heard both would decode one of them wrong.
Variables ¶
var ErrChannelName = errors.New("joaju: the channel name is not one this protocol carries")
ErrChannelName is what NewChannelName answers for a name outside what the protocol carries: one longer than MaxChannelNameLength, or one holding a character that is not in ChannelNameCharacters.
It is not an auth.ErrForbidden. Nothing was refused on authority -- the name is one no client of this protocol could be answered about, whoever asked for it -- so what reaches the client says the message was unreadable rather than that it was denied.
var ErrConnectionLimit = errors.New("joaju: this tenant is holding as many connections as it may")
ErrConnectionLimit is the tenant already holding as many sockets as ServerConfig.MaxConnections allows.
It is refused AFTER the upgrade, not before, and that is on purpose: the client is a websocket by then, so it can be told why in a protocol frame it understands instead of getting an HTTP status it has no handler for.
It is not an auth.ErrForbidden. Nothing was refused on authority -- the same client with the same Grant is admitted the moment somebody disconnects.
var ErrNoChannel = errors.New("joaju: no such channel")
ErrNoChannel is what Broker.Find answers when no channel is held under the name.
var ErrNoGrant = fmt.Errorf("%w: joaju: no grant", auth.ErrForbidden)
ErrNoGrant is what a constructor answers when the Grant it was handed authorized nothing.
It wraps auth.ErrForbidden, so a caller that already distinguishes a refusal from a failure keeps doing so.
var ErrRelayClosed = errors.New("joaju: the relay is closed")
ErrRelayClosed is what a Relay answers once Relay.Close has been called.
var ErrSocketClosed = errors.New("joaju: the socket is closed")
ErrSocketClosed is what Sink.Send answers once the socket is gone.
A client that cannot keep up gets it too, wrapped: the socket is closed first, because the alternative is a broadcast that blocks on the slowest subscriber and takes the channel down with it.
var ErrWrongTenant = fmt.Errorf("%w: joaju: the grant and the channel belong to different tenants", auth.ErrForbidden)
ErrWrongTenant is what a Broker answers when the Grant it was handed and the ChannelName it was handed disagree about whose channel it is.
It cannot happen through NewChannelName, which reads the tenant off the Grant. It can happen when a name built under one Grant is used with another, which is the mistake worth a distinct error: both values are valid, and nothing but this comparison notices.
Functions ¶
func MetricsReplyTopic ¶
func MetricsReplyTopic(id InstanceID) string
MetricsReplyTopic is where the answers to one instance's question go.
An answer is addressed to the instance that asked rather than published back to the fleet, and what an answer carries is the reason: the members of a presence channel are people's ids, and a shared reply topic would put one customer's member list in front of every instance in the fleet, including the ones holding none of that customer's sockets.
The separator is "/" and not broadcasting.TenantSeparator so that no reply topic can be read as a channel's Topic: a tenant is letters, digits, "-" and "_", so a topic with a "/" in that position was never a channel's.
func Topic ¶
func Topic(name ChannelName) string
Topic is the Redis pub/sub channel a joaju channel is relayed on.
It is TopicPrefix followed by ChannelName.String, so the tenant is in the middle of it -- "joaju:acme:orders" and "joaju:globex:orders" are two topics, and two customers who both called a channel "orders" never hear each other. That is the tenant reaching the one place where a channel name leaves this process, and it holds by construction: a ChannelName cannot be built without a Grant to read the tenant off.
The zero ChannelName has no topic and answers the empty string, which Relay.Join and Relay.Publish refuse before they get here.
Types ¶
type API ¶ added in v0.5.0
type API struct {
// AppID is the {appId} the routes carry. A request naming another app is
// answered 404, and one server is one application.
AppID string
// Broker holds the channels, and there is no method on it that reaches one
// without a Grant.
Broker Broker
// Connect decides whether a subject may act on this server at all. It is
// the policy the socket route runs, asked the same question with no socket
// on it -- an API caller wants no socket opened.
Connect ConnectPolicy
// Subscribe decides whether a subject may reach one channel. It runs once
// per channel a route touches, listing and counting included: reading who
// is talking to whom is a read, and there is no exception for reads.
Subscribe SubscriptionPolicy
// Registry is the sockets this process holds and what the other instances
// answered about theirs. See [Registry].
Registry Registry
// MaxBodySize is the largest body a route may read.
MaxBodySize int64
// Log is where a refused request is recorded. It is never nil.
Log *slog.Logger
}
API is what a Protocol builds its HTTP routes out of, and is the whole of what the server lets one of those routes reach.
Six of the seven fields are the ServerConfig the server was built from, so a route answers for the application the server answers for, runs the policies the server runs, and reads channels through the Broker the sockets read them through. There is no second place to say any of it. The seventh is API.Registry.
What is NOT here is the shape of it: nothing writes to the connection registry, and nothing reads one without a Grant. A Protocol that wanted to count another tenant's sockets would have to be handed a Grant of that tenant, and a ConnectPolicy is what issues one.
A Protocol is handed one and does not build one.
type Broker ¶
type Broker interface {
// Find is the channel held under the name, or [ErrNoChannel].
//
// It returns [ErrWrongTenant] when auth.Tenant(g) is not
// [ChannelName.Tenant] -- which [NewChannelName] cannot produce, and a
// name carried over from another Grant can.
Find(ctx context.Context, g auth.Grant, name ChannelName) (Channel, error)
// FindOrCreate is Find, creating the channel of the kind
// [ChannelName.Type] names when it is not there yet.
//
// It is what a subscription calls: the first subscriber to a channel is
// what brings it into existence. Creating one discloses nothing and grants
// nothing -- [Channel.Subscribe] still asks a [SubscriptionPolicy].
FindOrCreate(ctx context.Context, g auth.Grant, name ChannelName) (Channel, error)
// Remove drops the channel. An implementation calls it when the last
// subscriber leaves.
Remove(ctx context.Context, g auth.Grant, name ChannelName) error
// All is every channel in the Grant's tenant, and never another tenant's.
//
// It answers GET /apps/{appId}/channels. A broker holding several tenants
// filters here; the filter is the whole reason this takes a Grant.
All(ctx context.Context, g auth.Grant) ([]Channel, error)
}
Broker holds the channels and hands them out. It is the only way to one.
Picking which kind of channel a name denotes is ChannelName.Type; keeping the instances is this. A caller that could get a channel from either of two places would have two answers to compare.
Every method takes an auth.Grant, and that is not ceremony. The metrics routes -- the channel list, one channel, its members -- are reads of who is talking to whom, and a read with no policy behind it is a tenant boundary that exists everywhere except the dashboard.
Implementations are safe for concurrent use.
func RelayedBroker ¶ added in v0.2.0
relayedBroker is the Broker a server holds when it was built with a Relay, and it is the inbound half of horizontal scaling: what makes this instance receive from the bus the channels it is actually holding, and only those.
The two methods it wraps are the two ends of a channel's life on one instance, which is what Broker already says they are: Broker.FindOrCreate is what a subscription calls, because the first subscriber is what brings a channel into existence, and Broker.Remove is what the last subscriber leaving calls. So the fleet's topic is joined where a channel appears here and left where it goes, and neither a Protocol nor an application's Broker has to remember either call.
Not having to remember is the reason this wraps instead of being asked of every implementation. Broker is an interface an application writes, and a relay that had to be joined by hand would be joined by hand wrongly in exactly one deployment -- where the symptom is a client hearing nothing, which from inside a browser is indistinguishable from a quiet channel.
Broker.Find and Broker.All are the embedded ones and are untouched, because they read. A route that looks a channel up is not a socket listening on one, and an instance that opened a pipe from the fleet for every channel a dashboard mentioned would be receiving traffic it has nobody to deliver to. RelayedBroker is the Broker an instance uses when it is one of several.
It wraps the application's Broker so that a channel coming into existence here subscribes this instance to the fleet's topic for it, and the last subscriber leaving unsubscribes. Without it an instance relays what its own API publishes and never hears what the others publish.
Wire it once and hand it to both ¶
The same value has to reach the Protocol -- which is built over a Broker of its own -- and ServerConfig.Broker. A Protocol holding the raw Broker never joins a topic, however well the server is configured, because a socket's subscription passes through the Protocol and not through the server.
relay, err := joaju.NewRelay(ctx, id, bus, log)
broker := joaju.RelayedBroker(pusher.NewMemoryBroker(), relay)
protocol := pusher.NewPusher(broker, subscribe, pusher.PusherConfig{})
server, err := joaju.NewServer(joaju.ServerConfig{
Broker: broker, Protocol: protocol, Relay: relay,
})
NewServer refuses a Relay whose Broker did not come from here, so the half-wired arrangement is a startup error and not a message that never arrives.
A nil relay returns base unchanged, and wrapping twice returns the wrapper rather than a second layer.
type Bus ¶
type Bus interface {
// Publish sends one message to one channel and answers how many subscribers
// received it. It is the PUBLISH command.
Publish(ctx context.Context, channel string, message any) (int64, error)
// Subscribe listens on a set of channels and calls callback for each
// message, with the message first and the channel it arrived on second. It
// blocks until ctx is cancelled or the connection is lost.
Subscribe(ctx context.Context, channels []string, callback func(message, channel string)) error
}
Bus is the little of a Redis connection that horizontal scaling needs.
The two methods are github.com/arandu-io/hesape/redis/connections.Connection's Publish and Subscribe, signature for signature, so that a *connections.Connection satisfies this by having been written -- there is no adapter to keep in step.
It is declared here rather than imported because github.com/arandu-io/hesape/redis is a separate module: the driver beneath it is a third-party dependency, and Go has no optional dependency. Importing it would put that driver in this repository's go.mod, and the dependency graph has one entry in it. Stating the contract and letting the application pass its connection in costs nothing and keeps the graph.
Publish and Subscribe both prefix the channel name with the connection's own key prefix, on both sides, which is why nothing here compensates for it and why the name handed to the callback is not the name passed to Subscribe.
type Carrier ¶ added in v0.4.0
type Carrier interface {
Broker
// Carry hands an event to the other instances, after the sockets on this
// one have been served by [Channel.Broadcast] and never instead of it.
//
// It answers nothing because there is nothing the caller could do with the
// answer: every socket here already has the message, and a bus that is down
// costs reach and not delivery. The failure is recorded where it happens.
Carry(ctx context.Context, e Event)
}
Carrier is a Broker that also reaches the other instances.
RelayedBroker answers one and a plain Broker does not, so asking a Broker for this is asking whether there is a fleet at all. A Protocol relaying an event a client published asks its own Broker, because the Broker is the one value the wiring already makes the two halves of a server share -- a Protocol holding a Relay of its own would be a second thing to wire, a second thing to leave half-wired, and a second answer to which relay this instance is on.
type Channel ¶
type Channel interface {
// Name is the channel's name, with its tenant.
Name() ChannelName
// Connections is every current subscriber.
//
// It is a slice and not a map keyed by socket id, because every caller of
// it iterates. [Channel.Find] is the keyed lookup.
Connections() []Subscriber
// Find is the subscriber on this channel with the given socket id, if it
// is subscribed.
Find(id SocketID) (Subscriber, bool)
// Subscribe adds a connection to the channel.
//
// g must be a Grant issued for broadcasting.ChannelJoin by a
// [SubscriptionPolicy] asked about this exact [Subscription], and its
// tenant must be the tenant in [Channel.Name] -- otherwise
// [ErrWrongTenant]. An implementation checks the Grant before it touches
// its state, on every kind of channel and not only the guarded ones:
// [ChannelType.Guarded] says whether a policy may allow a subscription
// freely, not whether one is consulted.
//
// member is the presence data the client offered, and is ignored off a
// presence channel. On a presence channel, subscribing announces the new
// member to the others with [EventMemberAdded].
Subscribe(ctx context.Context, g auth.Grant, conn *Connection, member Member) error
// Unsubscribe removes a connection from the channel, and on a presence
// channel announces its departure with [EventMemberRemoved].
//
// It takes no Grant. Leaving is not a read: nothing is disclosed by it, and
// a socket that drops has no one left to ask.
Unsubscribe(ctx context.Context, conn *Connection) error
// Subscribed reports whether this connection is on this channel.
Subscribed(conn *Connection) bool
// Broadcast delivers the event to every subscriber except the one named by
// [Event.Socket].
Broadcast(ctx context.Context, e Event) error
// BroadcastToAll delivers the event to every subscriber, [Event.Socket]
// included. It is what the server itself sends on -- a member list, a
// refusal -- where there is no sender to spare.
BroadcastToAll(ctx context.Context, e Event) error
// Data is what [EventSubscriptionSucceeded] carries to a new subscriber.
//
// It is empty on every kind of channel but a presence one, where it is
// the protocol's presence block: {"presence": {"count": n, "ids": [...],
// "hash": {...}}}. It stays a map because it is a payload on its way to
// JSON and nothing in this repository reads a field out of it.
Data() map[string]any
}
Channel is a group of sockets that receive the same events.
Subscribe takes a context and an auth.Grant rather than a signature it verifies itself: a subscription that a Policy did not decide is the leak this repository exists not to have, and a Grant in the signature is how the compiler asks for the decision.
The reads carry no context and return no error, because the sockets in a channel are the ones this process holds and looking at them is not I/O. Everything that writes to a socket does both.
Implementations are safe for concurrent use. One connection is one goroutine and a channel is shared between all of them, so the alternative is a lock every caller has to remember.
type ChannelName ¶
type ChannelName struct {
// contains filtered or unexported fields
}
ChannelName is a channel name with its tenant already in it.
It has only unexported fields, so it cannot be written as a struct literal with a tenant somebody chose. NewChannelName is the way to one, and it reads the tenant off an auth.Grant -- the same shape auth.Grant itself uses to make an authorization decision unforgeable.
It has two string forms and they are not interchangeable:
[ChannelName.String] "acme:private-orders.17" the key, and what Redis publishes on [ChannelName.Requested] "private-orders.17" what the client sent, and what goes back to it
The client never sees the first. It asked for the second, it is answered about the second, and the tenant it was scoped to is not its to know or to choose.
The zero value is not a channel. ChannelName.IsZero reports it and ChannelName.String answers the empty string rather than a bare separator, so a zero value cannot be mistaken for a channel named "".
func NewChannelName ¶
func NewChannelName(g auth.Grant, requested string) (ChannelName, error)
NewChannelName builds the name a channel is held under, out of the Grant and the raw name the client asked for.
requested is exactly what came off the wire, prefix and all: "private-orders.17". It is refused if it is empty or contains broadcasting.TenantSeparator, because that is where the tenant goes and a client that names one is a client choosing whose events it hears. The Grant is refused if it carries no tenant, or one that cannot be a namespace.
Those refusals are broadcasting.RequestedChannel and broadcasting.TenantChannel doing the work, because the SSE fanout already authenticates channel names and two definitions of a valid one are how the looser of the two gets found. What this adds on top is not a second opinion about the same question but the shape this protocol's names have and the other's do not: MaxChannelNameLength and ChannelNameCharacters, answered with ErrChannelName.
The length is counted in bytes and the protocol counts characters, which is the same count for every name that gets through: nothing in ChannelNameCharacters takes more than one byte, so a name that is over the limit in bytes and under it in characters is one the character set refuses either way.
func (ChannelName) IsZero ¶
func (n ChannelName) IsZero() bool
IsZero reports whether this is the zero value, which is not a channel.
func (ChannelName) Requested ¶
func (n ChannelName) Requested() string
Requested is the name the client asked for, with no tenant in it.
This is the value that goes back on the wire, in the "channel" field of every frame, because it is the name the client used and the only one it knows.
func (ChannelName) String ¶
func (n ChannelName) String() string
String is the published name, "<tenant>:<channel>".
It is the key a Broker holds the channel under and the name a message is published on for the other servers in the fleet, and it is never sent to a client.
func (ChannelName) Tenant ¶
func (n ChannelName) Tenant() string
Tenant is whose channel this is. It came from the Grant and from nowhere else.
func (ChannelName) Type ¶
func (n ChannelName) Type() ChannelType
Type reads the kind off the prefix.
The order matters and is the reason this is one function: "private-cache-" also begins with "private-", so the compound prefixes are tested first. Two copies of this switch would be two copies of that ordering, and the second one is where a private cache channel quietly becomes a plain private one.
An encrypted channel is read for the two properties this server implements and not for the encryption, which it has no part in: EncryptedPrivateCacheChannelPrefix is a PrivateCacheChannel and the plain "private-encrypted-" is a PrivateChannel, reached through the "private-" it begins with. There is no kind of its own, because what the prefix promises the subscribers is a key they share and nothing this type could report.
It reads ChannelName.Requested and not ChannelName.String, because the prefix is at the front of the name the client sent and the published name has the tenant in front of it. Asking the published name instead is a mistake with a shape: "acme:private-orders.17" does not begin with "private-", so every private channel reports itself unguarded.
type ChannelTally ¶ added in v0.5.0
type ChannelTally struct {
// Subscriptions is how many sockets they hold on it, summed. One socket is
// held by exactly one instance, so subscriptions add.
Subscriptions int
// Users are the distinct [Member.UserID]s they hold on it, as a set. It is
// a set and not a count because one person with a tab on two instances is
// counted by both and is one member, so the reply counts what is distinct
// rather than adding.
Users map[string]bool
}
ChannelTally is the other instances' part of one channel.
func (ChannelTally) Members ¶ added in v0.5.0
func (t ChannelTally) Members() []string
Members is the fleet's user ids on one channel, in one order.
Sorted for the reason Channel.Connections is sorted: a member list that comes back in a different order on every request is a route nobody can diff, and a map's order is not an order.
type ChannelType ¶
type ChannelType uint8
ChannelType is which of the six kinds of channel a name denotes.
The kind is read off the name's prefix rather than stored, because the client chooses it by what it asks for: a client that sends "private-orders.17" is asking for a private channel by naming one. ChannelName.Type is the rule, and it is the only place the prefixes are compared.
const ( // PublicChannel has no prefix: no authorization, anyone connected may // listen. // // It still cannot cross a tenant. Every name carries one, so "public" // means public within one customer's namespace and never wider. PublicChannel ChannelType = iota // PrivateChannel is "private-": a subscription a [SubscriptionPolicy] has // to allow. PrivateChannel // PresenceChannel is "presence-": private, and its subscribers are // published to each other as [Member] values. PresenceChannel // CacheChannel is "cache-": public, and it replays its last event to // whoever subscribes next. CacheChannel // PrivateCacheChannel is "private-cache-": [PrivateChannel] and // [CacheChannel] together. PrivateCacheChannel // PresenceCacheChannel is "presence-cache-": [PresenceChannel] and // [CacheChannel] together. PresenceCacheChannel )
The six channel kinds.
Which one a name denotes is ChannelName.Type's answer, read off the name's prefix; the channels themselves are held by a Broker.
func (ChannelType) Cache ¶
func (t ChannelType) Cache() bool
Cache reports whether this kind of channel replays its last event to whoever subscribes next.
func (ChannelType) Guarded ¶
func (t ChannelType) Guarded() bool
Guarded reports whether a subscription to this kind of channel has to be authorized by a SubscriptionPolicy.
It answers no for the public kinds -- which does not make them reachable across a tenant, because the tenant is in every name and comes from the Grant that built it.
func (ChannelType) Presence ¶
func (t ChannelType) Presence() bool
Presence reports whether this kind of channel publishes its subscribers to each other, which is what makes Member and Channel.Data mean anything.
func (ChannelType) String ¶
func (t ChannelType) String() string
String names the kind, for a log line and for the metrics routes.
type ConnectPolicy ¶
ConnectPolicy decides whether a client may open a socket. Its Grant is issued for Connect.
It is an alias and not a new interface because there is one way to authorize in this ecosystem, and it is auth.Policy. The alias exists so the resource type is named at the point the decision is described.
type Connection ¶
type Connection struct {
// contains filtered or unexported fields
}
Connection is one client's open socket.
It has only unexported fields and one constructor, so it cannot be written as a struct literal: NewConnection takes an auth.Grant and refuses a Grant that authorized nothing. That is the same shape auth.Grant uses on itself, and it is here for the same reason -- a Connection carries the tenant every channel name it reaches is built from, and a Connection assembled by hand would be a tenant somebody typed.
It is a struct and not an interface because there is one kind of connection. What varies is where the bytes go, and that is Sink.
func NewConnection ¶
NewConnection is the only way to a Connection.
g must be a Grant issued for Connect -- that is, one an auth.Policy answered about this handshake. A zero Grant, or one issued for another action, is refused here rather than at the first channel the client names.
func (*Connection) Grant ¶
func (c *Connection) Grant() auth.Grant
Grant is the proof the handshake was authorized.
It is issued for Connect and it is not a subscription: a caller that has it still has to run a SubscriptionPolicy to reach a channel, because auth.Grant.Check refuses a Grant issued for another action.
func (*Connection) Send ¶
func (c *Connection) Send(ctx context.Context, message []byte) error
Send writes one encoded frame to this client.
func (*Connection) Subject ¶
func (c *Connection) Subject() auth.Subject
Subject is who this socket was authenticated as.
func (*Connection) Tenant ¶
func (c *Connection) Tenant() string
Tenant is whose socket this is, read off the Grant and from nowhere else.
type Counter ¶
type Counter struct {
// contains filtered or unexported fields
}
Counter is an Observer that counts, and the one an application usually wants.
It answers what GET /apps/{appId}/connections answers: how many are connected, how many messages crossed, how many channels exist. It holds numbers and nothing else -- no history, no per-connection record -- because a server that remembers every connection is a server whose memory grows with churn.
Safe for concurrent use. Every method may be called from any connection's goroutine at any time, which is the normal case rather than the exception.
func NewCounter ¶
func NewCounter() *Counter
NewCounter builds a Counter with no tenant recorded yet. A tenant appears the first time something is counted for it.
func (*Counter) ChannelCreated ¶
func (c *Counter) ChannelCreated(_ context.Context, name ChannelName)
func (*Counter) ChannelRemoved ¶
func (c *Counter) ChannelRemoved(_ context.Context, name ChannelName)
func (*Counter) ConnectionClosed ¶
func (*Counter) ConnectionOpened ¶
func (*Counter) MessageReceived ¶
MessageReceived and MessageSent count frames and never keep them.
The bytes are on the Observer interface because a diagnosis wants them; a counter that stored them would be a full traffic log nobody asked for, of customer data, growing without bound.
func (*Counter) MessageSent ¶
func (*Counter) Read ¶
func (c *Counter) Read(tenant string) TenantCount
Read is one tenant's numbers, copied.
A copy rather than a pointer, so that a caller reading a dashboard cannot hold a reference that changes while it renders -- or write through it.
func (*Counter) Tenants ¶
Tenants is every tenant this counter has seen, for an operator's dashboard.
It is NOT reachable from a request handler: the HTTP routes answer for the Grant's tenant and one tenant only. Anything calling this is running with the operator's own authority, outside the request path.
type Event ¶
type Event struct {
// Name is the event name, and goes out in the frame's "event" field.
// [ProtocolPrefix] and [InternalPrefix] are reserved: an event a client
// published may use neither.
Name string
// Channel is where it goes.
Channel ChannelName
// Data is the payload, left encoded because this package never reads it.
Data json.RawMessage
// Socket is who sent it, and it exists so that they do not receive it.
//
// A client that publishes a message has already drawn its own result on
// screen; delivering it back makes the message appear twice. The client
// learns its own id from [EventConnectionEstablished] and puts it in the
// publish. [Channel.Broadcast] skips it; [Channel.BroadcastToAll] ignores
// this field, which is the only difference between the two.
//
// Empty means the event came from the server and goes to everyone.
Socket SocketID
// UserID is who published a relayed client event, and is empty on
// everything else.
//
// It is the sender's [Member.UserID] as the channel holds it, which is the
// identity a [SubscriptionPolicy] settled when it seated them -- never the
// one the sending frame claimed. A receiver drawing "somebody is typing"
// has no other way to name them: the payload is a stranger's bytes, so a
// user_id inside it says whatever that stranger wanted it to say.
//
// It is empty off a presence channel, because a channel that publishes
// nothing about who is listening has no member to name. The frame a protocol
// builds from it then leaves the field out rather than sending it empty.
UserID string
}
Event is what travels: one message, on one channel.
It has no JSON tags, and that is deliberate -- it is the value this server passes around, not the frame. The protocol layer builds the frame, and two fields do not go out as they are held:
- Channel goes out as ChannelName.Requested, never as ChannelName.String. The client asked for "private-orders.17" and that is the name it is answered about; the tenant is not its to see.
- Data goes out as a JSON string containing JSON, which is what the Pusher protocol specifies. It is held decoded here so that nothing double-encodes twice.
type FleetTally ¶ added in v0.5.0
type FleetTally struct {
// Connections is how many sockets the other instances hold for the tenant.
Connections int
// Channels is one entry per channel the other instances hold, keyed by
// [ChannelName.Requested].
Channels map[string]ChannelTally
}
FleetTally is what the OTHER instances answered about one tenant, added together. It is what Server.Fleet returns.
It holds what they said and never what this instance knows. The local half of a metrics route is answered from the Broker under the Grant the route was authorized with, and the two are added at the point the reply is built, so the local numbers keep coming from a policy.
The zero value is the true answer for a deployment of one, for a server with no relay, and for a relay that could not reach the bus.
func (FleetTally) Channel ¶ added in v0.5.0
func (t FleetTally) Channel(requested string) ChannelTally
Channel is what the fleet answered about one channel, or the zero value when no other instance holds it. The name is ChannelName.Requested, with no tenant in it.
type Handshake ¶
type Handshake struct {
// Socket is the id minted for this client.
Socket SocketID
// Origin is the Origin header of the upgrade request, verbatim. It is what
// the browser claims, so it is evidence for a policy and never a tenant.
Origin string
}
Handshake is what a ConnectPolicy decides about: one client asking to open a socket.
The origin is here rather than in configuration because refusing an origin is a decision about who may connect, and this repository has one place where those are made. An allowed-origins list in configuration answers the same question; a policy answers it beside every other one.
type InstanceID ¶
type InstanceID string
InstanceID identifies one joaju process within a fleet of them.
It is the whole of the deduplication. Redis pub/sub has no notion of a publisher, so an instance that publishes on a topic it is subscribed to hears its own message come back -- and it has already delivered that message to its own connections, which is what publishing was in aid of. Every relayed message carries the id of the instance that sent it, and an instance drops the ones carrying its own: without that, every message would reach half the fleet once and the instance it came from twice.
It has to differ between instances and nothing here can check that. A hostname, a pod name or a random string minted at start-up are all fine; a constant compiled into the binary is not, because then no instance can tell its own traffic from its neighbour's and the deduplication silently drops every message in the fleet.
type Member ¶
type Member struct {
// UserID is user_id: who this subscriber is, within the tenant.
UserID string `json:"user_id"`
// Info is user_info: whatever else the application publishes about them,
// left encoded because this package never looks inside it.
Info json.RawMessage `json:"user_info,omitempty"`
}
Member is what a presence channel publishes about one subscriber.
The field names are Pusher's, because they are the JSON keys a browser client reads out of EventMemberAdded and out of the presence block of Channel.Data.
UserID is not the tenant and is not authority. It says which person a subscriber is inside one customer's channel; whose channel it is was settled by the Grant that built the ChannelName before this value was looked at.
type NopObserver ¶
type NopObserver struct{}
NopObserver ignores everything, and is what a server without one uses.
It exists so that the server never checks for nil on a path it runs per message. The check would be free; the branch in six places would not be, and a nil that reaches one of them is a panic on a live connection.
func (NopObserver) ChannelCreated ¶
func (NopObserver) ChannelCreated(context.Context, ChannelName)
func (NopObserver) ChannelRemoved ¶
func (NopObserver) ChannelRemoved(context.Context, ChannelName)
func (NopObserver) ConnectionClosed ¶
func (NopObserver) ConnectionOpened ¶
func (NopObserver) ConnectionOpened(context.Context, SocketID, string)
func (NopObserver) MessageReceived ¶
func (NopObserver) MessageReceived(context.Context, SocketID, []byte)
func (NopObserver) MessageSent ¶
func (NopObserver) MessageSent(context.Context, SocketID, []byte)
type Observer ¶
type Observer interface {
// ChannelCreated is the first subscription to a channel that did not exist.
ChannelCreated(ctx context.Context, name ChannelName)
// ChannelRemoved is the last subscriber leaving.
ChannelRemoved(ctx context.Context, name ChannelName)
// ConnectionOpened is a socket that finished the upgrade and was
// authorised. A refused upgrade never reaches here.
ConnectionOpened(ctx context.Context, id SocketID, tenant string)
// ConnectionClosed is a socket that went away, for any reason: the client
// closed it, the pong did not arrive in time, or the server terminated it.
//
// There is one event for every way a socket can end, and a reason string
// to tell them apart: the read deadline does the pruning, so a pruned
// connection is not a separate kind of closure.
ConnectionClosed(ctx context.Context, id SocketID, tenant, reason string)
// MessageReceived is a frame that arrived from a client, before it is acted
// on. The raw bytes, because that is what a diagnosis wants.
MessageReceived(ctx context.Context, id SocketID, message []byte)
// MessageSent is a frame written to a client.
MessageSent(ctx context.Context, id SocketID, message []byte)
}
Observer is told what the server did, after it did it.
It exists so that an application can count, log or audit without the server knowing about counting, logging or auditing. There is no event dispatcher in this ecosystem, so the destination is a value somebody passes in.
Every method runs after the fact, and its return value is ignored ¶
None of these can refuse anything. A channel is created because somebody authorised a subscription; a message is sent because it was already broadcast. An observer that could veto would be a second authorisation path, and there is one -- the SubscriptionPolicy.
It must not block ¶
The server calls these on the path that serves connections. An observer that talks to a database on MessageSent turns every broadcast into a round trip, and a thousand messages a second into a thousand of them. Count in memory, and let something else read the counter.
Nothing here is called with a lock held, so an observer may call back into the server without deadlocking. That is deliberate and it is tested.
type Protocol ¶
type Protocol interface {
// Open is called once, after the handshake was authorized and the socket is
// writable, and before any client frame is read. It is where
// [EventConnectionEstablished] goes out.
//
// An error closes the socket.
Open(ctx context.Context, conn *Connection) error
// Message is called for each frame the client sends.
//
// An error does not close the socket: a client that asks for a channel it
// may not have is told so with [EventError] and goes on using the ones it
// has. The server logs it and reads the next frame.
Message(ctx context.Context, conn *Connection, message []byte) error
// Close is called once, after the socket is gone and the connection has
// been dropped from the server's registry. It is where a channel's
// subscribers are cleaned up and [EventMemberRemoved] goes out.
//
// It gets a context that is already cancelled if the process is shutting
// down, so an implementation that has to reach Redis derives one with its
// own deadline.
Close(ctx context.Context, conn *Connection)
// Refuse answers with the bytes that tell a client about one [Refusal],
// ready to be written to its socket. An empty answer is written as nothing,
// and the refusal takes its course in silence.
//
// It takes no [Connection] and no context because it decides nothing and
// reaches nobody: the answer depends on the refusal alone, and the caller is
// the one holding the socket it belongs to. An implementation is free to
// encode the three once for the process and hand back the same slice every
// time -- the caller writes it, and neither keeps nor modifies it.
Refuse(r Refusal) []byte
// Routes are the HTTP routes this protocol answers, mounted by the server
// beside the socket route it keeps. A nil answer is a protocol that answers
// none, and then the socket route is the whole of what the server serves.
//
// It is called once, from [NewServer], with everything a route may reach.
// The socket route is not among them: an upgrade is the transport's, and
// what comes back here is handed a request that will stay a request.
Routes(api API) http.Handler
}
Protocol is the frame layer: what the Server hands a socket's traffic to.
The server owns the socket -- the upgrade, the two goroutines, the deadlines, the registry -- and owns no part of the Pusher protocol. It builds no frame of its own, not even EventConnectionEstablished, because a second place that builds a frame is a second answer to what a frame looks like. The three refusals that are the transport's own go out through Protocol.Refuse, which is the implementation's bytes and not the server's.
The HTTP routes are the protocol's too, and arrive through Protocol.Routes. A wire protocol is what a client speaks over a socket AND what it calls over HTTP, and the server that owns neither owns neither of them: it holds the sockets, answers Protocol.Routes with an API, and mounts what comes back.
There is no method for reporting an error: the error comes back from the call that failed.
The four socket methods are called from the one goroutine that reads a given socket, so calls concerning one Connection are ordered and never concurrent with each other. Calls concerning different connections are concurrent, and a route runs on the goroutine net/http gave it.
type Refusal ¶ added in v0.4.0
type Refusal uint8
Refusal is one of the three things a Server decides on its own, without any frame having been understood.
Each of them is the transport's: how many sockets a tenant may hold, how fast one socket may send, and which opcode carries a message. A Protocol is never handed the frame that caused one and could not answer for it -- but the client still has to be told, and in something it can read. Protocol.Refuse is where these three become bytes, so that the wire format stays in one place and the server writes what it is given.
const ( // RefusalOverQuota is the tenant already holding as many sockets as // [ServerConfig.MaxConnections] allows. It is decided after the upgrade, so // the socket is writable when the answer goes out, and it closes after. RefusalOverQuota Refusal = iota // RefusalRateLimited is a frame dropped because the socket is sending // faster than [ServerConfig.MaxMessagesPerSecond] allows. The socket stays. RefusalRateLimited // RefusalUnreadable is a frame the transport delivered and this server // cannot act on, which is one that was not a text frame. The socket stays. RefusalUnreadable )
type Registry ¶ added in v0.5.0
type Registry interface {
// Connections is how many sockets this process holds for the Grant's
// tenant.
Connections(g auth.Grant) (int, error)
// Terminate closes every socket the Grant's tenant holds for one subject,
// and answers how many it closed.
Terminate(ctx context.Context, g auth.Grant, subject string) (int, error)
// Fleet is what the OTHER instances hold for the Grant's tenant. channel is
// [ChannelName.Requested] of the one channel being asked about, and is
// empty to ask about the whole tenant.
Fleet(ctx context.Context, g auth.Grant, channel string) FleetTally
}
Registry is the sockets a Server knows about, as an HTTP route may read them: what this process holds, what the other instances hold, and the one way to close what this process holds.
Every question takes a Grant a ConnectPolicy or a SubscriptionPolicy issued, and the tenant on it is the only filter -- because it is the only one that did not arrive with the request. There is nothing here that opens a socket, nothing that closes one outside the Grant's tenant, and nothing that counts across tenants: a count that spans them tells one customer how many of another's people are online.
Server is what implements it.
type Relay ¶
type Relay struct {
// contains filtered or unexported fields
}
Relay is horizontal scaling: it carries what this instance received to the other instances, and delivers what they received to this instance's connections.
Every server publishes on Redis pub/sub, every server subscribes, and no server has to know which of the others holds a given socket. One topic per channel per tenant -- see Topic -- so an instance receives only the traffic of channels it is actually holding.
The flow of one message, and it is worth reading in order, because the local delivery is not this type's job:
- a client publishes on a channel this instance holds;
- the caller delivers it locally with Channel.Broadcast, which skips the socket that sent it;
- the caller hands the same Event to Relay.Publish;
- every instance holding that channel receives it, this one included;
- this one drops it, on the InstanceID; the others deliver it.
Step three is called by the code that received the event from outside, and never by the code in step four. A relay that republished what it was relayed would be a fleet talking to itself forever.
The other direction: the metrics routes ¶
The same bus carries the four metrics routes' questions, and it carries them because those routes are wrong without it. A count of open sockets served out of one process is a count of one process's sockets, and two instances serving one application answer two different numbers of which neither is the answer.
So a route asks: it publishes a [fleetQuestion] on MetricsTopic, every other instance answers with what it holds on MetricsReplyTopic of the asker, and the asker adds those to what it answered from its own Broker. An instance that does not answer in time is left out and the route replies anyway -- see [Relay.ask] for why a partial answer is the answer.
A Relay is safe for concurrent use, and it is meant to be: one connection is one goroutine, and each of them can publish.
func NewRelay ¶
NewRelay is the only way to a Relay.
ctx is the server's lifetime and not a request's: closing it stops every subscription, which is what Relay.Close does through it.
bus may be nil, and that is a supported deployment rather than a mistake: one instance has nobody to relay to. It starts degraded and says so once, because an operator who meant to configure Redis and did not needs to read that in the log rather than infer it from clients on different instances not seeing each other.
log may be nil, in which case slog.Default is used.
func (*Relay) Close ¶
Close stops every subscription and waits for them.
It is idempotent, and after it every Relay.Join and Relay.Publish answers ErrRelayClosed rather than working on a relay whose goroutines are gone.
func (*Relay) Degraded ¶
Degraded reports whether this instance is currently unable to reach the bus.
A degraded instance still works: it accepts connections, authorizes subscriptions and delivers to the sockets it holds. What it has lost is the other instances, so a client on this one and a client on another stop hearing each other. That is worth answering GET /up with and worth putting on the metrics route, because from inside one browser it is indistinguishable from a quiet channel.
func (*Relay) Join ¶
Join starts relaying a channel: from now on, what the other instances publish on it is delivered to ch.
g must be a Grant issued for broadcasting.ChannelJoin, and its tenant must be the tenant of ch's name, or ErrWrongTenant. That is the same Grant the SubscriptionPolicy answered for the first subscriber, and asking for it here is not ceremony: this is the call that opens a pipe from every other instance into this channel, and a pipe nobody authorized is an unauthorized read with a network in the middle of it.
It takes no context because it starts I/O rather than doing any -- the subscription runs on the lifetime the relay was built with. It is idempotent: a channel already joined is left as it is, since the first subscriber is what brings a channel into existence and every later one finds it already relayed.
A relay with no bus, or one that cannot reach the bus, joins successfully. Refusing here would turn a Redis outage into every subscription failing, when what an outage actually costs is the other instances.
func (*Relay) Joined ¶
Joined reports how many channels this instance is relaying. It answers the metrics route, and it is what a test asserts on.
func (*Relay) Leave ¶
func (r *Relay) Leave(name ChannelName) error
Leave stops relaying a channel.
The caller is whatever removed the channel -- Broker.Remove, when the last subscriber left. It takes no Grant for the reason Channel.Unsubscribe takes none: leaving discloses nothing.
A channel that was never joined is not an error. It is what a broker that removes a channel unconditionally will do, and there is nothing to report.
func (*Relay) Publish ¶
Publish sends an event to the other instances.
It is called once per event, by whatever received that event from outside -- a client frame, or POST /apps/{appId}/events -- and after the local delivery, not instead of it. This instance's own connections are served by Channel.Broadcast; this call is only about the other instances.
A bus that cannot be reached is not an error and does not come back as one. The event has already been delivered to every socket this instance holds, so there is nothing to fail: what was lost is reach, and reach is reported by Relay.Degraded and by one log line at the moment it goes. Returning an error would make a client whose message was delivered read that it was not, and would make every publish in the fleet fail for as long as Redis was down -- which is exactly the outage the caller is being protected from.
What does come back as an error is a malformed event: no channel, no name, or data that will not encode. Those are bugs in the caller and no amount of Redis fixes them.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server answers one route -- the socket -- and mounts whatever the Protocol answers over HTTP beside it.
One and not nine, because the other eight are a wire format's and this owns no wire format. What it owns is the socket: the upgrade, the two goroutines, the deadlines, the registry and the limits. See Protocol for the other side of that line and API for what a route is given to cross it.
It is an http.Handler and not a net/http.Server, so that it is mounted the way any other handler is -- behind hesape/auth's Authenticate middleware, which is where the auth.Subject on the request context comes from. That middleware is the only thing in the ecosystem that calls auth.WithSubject, and this server does not authenticate anybody: it reads the subject the middleware put there and asks a Policy about it.
The authorization shape, which the socket route follows ¶
[ConnectPolicy] once, before the upgrade may this subject be here at all
It runs before the upgrade, so a refusal is an HTTP status a browser reports rather than a socket that opens and shuts. What a subject may then reach is the SubscriptionPolicy's answer, asked once per channel by whoever reaches one -- the Protocol on a frame, and its routes on a request.
func NewServer ¶
func NewServer(cfg ServerConfig) (*Server, error)
NewServer builds the server, or says which part of the config was missing.
func (*Server) Close ¶
Close terminates every socket this server holds. It is what a shutdown calls, and it takes no Grant because it crosses every tenant on purpose.
func (*Server) Connections ¶
Connections is how many sockets this process holds for the Grant's tenant.
It takes a Grant for the reason Broker.All does: a count of who is connected is a read, and a count that spans tenants tells one customer how many of another's people are online. The Grant has to be one a ConnectPolicy issued -- auth.Grant.Check is what says so -- and the tenant it carries is the only filter, because it is the only one that did not come in with the request.
func (*Server) Fleet ¶ added in v0.5.0
Fleet is what the OTHER instances hold for the Grant's tenant, and is the second half of each of the four metrics routes.
The first half is the caller's and still the whole of the answer on a deployment of one: the route reads its own Broker and its own registry under the Grant it was authorized with, and adds this to it. channel is ChannelName.Requested of the one channel being asked about, and is empty to ask about the whole tenant.
The tenant is read off the Grant and is the only filter, because it is the only one that did not arrive with the request. A Grant carrying no tenant answers with the zero FleetTally rather than with everyone's numbers, and an instance that answers about another tenant is dropped rather than added.
It never fails and never blocks longer than ServerConfig.MetricsTimeout: the zero value is what comes back from a server with no relay, from a relay that cannot reach the bus, and from a fleet that did not answer in time. A metrics route served late by one instance being replaced is a dashboard that is down.
func (*Server) ServeHTTP ¶
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP routes the socket route and whatever the Protocol brought.
func (*Server) Terminate ¶
Terminate closes every socket the Grant's tenant holds for one subject, and answers how many it closed.
It answers POST /apps/{appId}/users/{userId}/terminate_connections, and it is what a sign-out or a revoked membership calls: the socket was authorized once, at the handshake, and nothing about it expires on its own.
type ServerConfig ¶
type ServerConfig struct {
// AppID is the {appId} the API routes carry, and AppKey is the {appKey} the
// socket route carries. A request naming another app is answered 404.
//
// One server is one application: a Go binary is a process, and running one
// per application costs nothing that would justify multiplexing them.
// These are here so the names in a client's configuration mean what they
// mean everywhere else.
AppID string
AppKey string
// Broker holds the channels. Every route that reads or writes channel state
// goes through it, and there is no method on it that reaches a channel
// without a Grant.
Broker Broker
// Connect decides whether a subject may be on this server at all. It runs
// on the socket route and on every API route, before anything else.
Connect ConnectPolicy
// Subscribe decides whether a subject may reach one channel. It runs once
// per channel touched, on every route that touches one.
Subscribe SubscriptionPolicy
// Protocol is the frame layer. See [Protocol].
Protocol Protocol
// Relay is the other instances, and a deployment of more than one needs it.
// With one, an event published here reaches the sockets the other instances
// hold, what they publish reaches the sockets held here, and the four
// metrics routes answer for the whole fleet. Without one, each of those
// three is answered by this process alone -- which is the true answer for a
// deployment of one and the wrong answer for any other: two instances
// serving one application each hold half the sockets, and neither can see
// the other half.
//
// It is optional because a relay is a Redis, and a server that refused to
// start without one would make the single-instance deployment the harder of
// the two. See [Relay].
Relay *Relay
// MetricsTimeout is how long a metrics route waits for the other instances
// before it answers with what has arrived. Zero means
// [DefaultMetricsTimeout].
//
// It is a bound and not a promise: an instance that answers after it is
// left out of that reply, because a dashboard served late by one instance
// being replaced is a dashboard that is down. See [Relay.ask].
MetricsTimeout time.Duration
// Log is where a refusal and a dropped socket are recorded. nil means
// slog.Default.
Log *slog.Logger
// MaxMessageSize, MaxBodySize, OutboundQueue, WriteTimeout, PingInterval
// and PongTimeout are the socket's limits. Zero means the Default of the
// same name.
MaxMessageSize int64
MaxBodySize int64
// MaxConnections is how many sockets ONE TENANT may hold. Zero means
// [DefaultMaxConnections]; a negative number means no limit, and saying so
// takes writing -1 rather than leaving a field out.
//
// Per tenant and not per server: a global limit lets one customer's
// traffic refuse another customer's connections,
// which is a denial of service one of them did not cause and cannot see.
MaxConnections int
// MaxMessagesPerSecond is how many frames ONE SOCKET may send in a second.
// Zero, which is the default, is no limit.
//
// Per socket and not per tenant, which is the opposite of what
// MaxConnections does and is the same reasoning taken one layer in: a socket
// is the smallest thing a noisy client owns, so metering it spends nothing
// of what the tenant's other sockets have. An allowance shared across a
// tenant would let one runaway browser tab refuse the frames of every other
// tab that customer has open, which is the denial of service the connection
// limit exists to keep out.
//
// Zero is no limit rather than a Default of its own, because there is no
// rate that is right for traffic this server has not seen: a chat client and
// one that reports a cursor position differ by two orders of magnitude, and
// a limit that refuses the frames of a correct client is worse than no limit
// at all. It is turned on by a deployment that measured its own traffic.
//
// A frame past the limit is answered with [RefusalRateLimited] and dropped,
// and the socket stays open. There is no second setting that closes it:
// two ways to answer one refusal is two behaviours to explain, and
// the client that a limit is aimed at is the one worth keeping addressable.
MaxMessagesPerSecond int
// Observer is told what happened, after it happened. Nil means
// [NopObserver]. See [Observer] for why nothing there can refuse anything.
Observer Observer
OutboundQueue int
WriteTimeout time.Duration
PingInterval time.Duration
PongTimeout time.Duration
}
ServerConfig is what a Server is built from.
Everything without a default is required, and NewServer refuses a config missing one rather than filling in something safe-looking: a server with no ConnectPolicy would accept every socket, and a nil policy is exactly the mistake this shape exists to make impossible.
type Sink ¶
type Sink interface {
// Send writes one message. The message is a complete protocol frame,
// already encoded.
Send(ctx context.Context, message []byte) error
// Terminate closes the socket.
Terminate(ctx context.Context) error
}
Sink is the write half of a socket: where a Connection's bytes go.
It is an interface so that this package declares no transport. The ws subpackage implements it over a real socket, and a test implements it over a slice.
Splitting the two write operations out of Connection is what lets it be a struct with a constructor that cannot be bypassed: an interface cannot have one.
type SocketID ¶
type SocketID string
SocketID identifies one open socket, and is the id the protocol calls socket_id.
It is handed to the client in EventConnectionEstablished and comes back in a publish as the sender, so Channel.Broadcast can skip it. Pusher's clients expect the shape "<digits>.<digits>"; nothing here reads it, and the package that mints one keeps to that shape because the clients print it.
type Subscriber ¶
type Subscriber struct {
// Conn is the socket.
Conn *Connection
// Member is the presence data, and is the zero value on a channel that is
// not a presence channel.
Member Member
}
Subscriber is one Connection's membership of one Channel.
Presence data belongs to the pair and not to the socket: one socket may be in a presence channel as one member and in another as a different one.
type Subscription ¶
type Subscription struct {
// Channel is the channel asked for, tenant already in it.
Channel ChannelName
// Member is the presence data the client offered, and is the zero value
// off a presence channel. It came from the client, so a policy that lets a
// subscriber name their own Member.UserID is a policy that lets them
// impersonate one -- compare it against the subject.
Member Member
// Socket is which of the subject's connections is asking.
Socket SocketID
// Auth is the Pusher subscription signature the client offered --
// "<app key>:<hex HMAC-SHA256>" -- verbatim, or empty when it offered none.
//
// It is evidence and not authority, which is the whole of why it may be
// here. Nothing in this package reads it, nothing derives a tenant from it,
// and holding it allows no channel: the decision is still the
// [SubscriptionPolicy]'s and the Grant it issues is still the only way to a
// [Channel]. It is carried for the reason [Handshake.Origin] is carried --
// a policy cannot weigh evidence it is never shown, and this is the evidence
// the Pusher protocol puts on the wire about a browser.
//
// A policy that checks it recomputes the HMAC of
// "<socket id>:<channel>:<channel data>" -- the last part only where there
// is one -- under the app secret and compares in constant time. The channel
// in that string is [ChannelName.Requested] and never [ChannelName.String]:
// the client signed the name it sent, and the tenant was never its to see.
// The socket id is what makes the signature one connection's; a policy that
// leaves it out accepts a signature anybody who saw it can replay.
//
// A policy for a mounted application ignores it, and should. There the
// subject on the Grant arrived through the host application's own front
// door, so a signature that could also allow a subscription would be a
// second mechanism for one decision. Where there is no front door --
// cmd/joaju is that process -- there is no first mechanism for it to be a
// second one to.
Auth string
// ChannelData is the presence data exactly as it arrived, and it is what
// the third part of the signed string above is.
//
// It is [Subscription.Member] before it was read: the same bytes, undecoded.
// Both are here because a policy compares fields and a signature covers
// bytes -- re-encoding Member would produce a different JSON text, and so a
// different hash, for the same claim.
ChannelData json.RawMessage
}
Subscription is what a SubscriptionPolicy decides about: one client asking to listen on one channel.
The Channel is a ChannelName, which means the tenant was settled before the policy ran. A policy is asked whether this subject may hear this channel; it is never asked whose channel it is, because that was not the client's to say.
type SubscriptionPolicy ¶
type SubscriptionPolicy = auth.Policy[Subscription]
SubscriptionPolicy decides whether a client may listen on a channel. Its Grant is issued for broadcasting.ChannelJoin, which hesape/broadcasting already declares for the SSE fanout's half of the same question.
It runs for every channel, on every subscription, including a resubscription after a reconnect. Subscribing is a read, and there is no exception for reads: a channel a policy never saw is a channel whose contents nobody decided anyone could have.
type TenantCount ¶
type TenantCount struct {
// Connections is how many are open right now. It goes down.
Connections int64
// Channels is how many exist right now. It goes down.
Channels int64
// Received and Sent are totals since the process started. They only go up,
// so a reader can subtract two readings and get a rate.
Received int64
Sent int64
}
TenantCount is one tenant's numbers.
Per tenant and never global, for the reason the tenant is on everything else here: a single number tells one customer how busy another one is. The rate of a competitor's traffic is a business fact, and an operator reading a dashboard is not a reason to publish it.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package client is the browser half of the Pusher protocol: the JavaScript a page loads to talk to a github.com/arandu-io/joaju.Server, and the bytes and handler that put it on the wire.
|
Package client is the browser half of the Pusher protocol: the JavaScript a page loads to talk to a github.com/arandu-io/joaju.Server, and the bytes and handler that put it on the wire. |
|
cmd
|
|
|
joaju
command
Command joaju runs the WebSocket server as a process.
|
Command joaju runs the WebSocket server as a process. |
|
protocols
|
|
|
redis
module
|
|
|
Package tests is the base the suites of this module build on.
|
Package tests is the base the suites of this module build on. |
|
Specification/echo
command
Command echo is the server the Autobahn TestSuite fuzzing client attacks.
|
Command echo is the server the Autobahn TestSuite fuzzing client attacks. |
|
Specification/report
command
Command report turns an Autobahn TestSuite run into a verdict.
|
Command report turns an Autobahn TestSuite run into a verdict. |
|
Package ws is the WebSocket protocol of RFC 6455, written for this server.
|
Package ws is the WebSocket protocol of RFC 6455, written for this server. |