broadcasters

package
v0.39.0 Latest Latest
Warning

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

Go to latest
Published: Sep 10, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package broadcasters holds the drivers a broadcast is published through.

RedisBroadcaster publishes on Redis pub/sub, LogBroadcaster writes the payload to a log, and NullBroadcaster drops it. All three embed Broadcaster, the channel registry and the authorization walk they share, and Register puts all three on a manager in one call.

Index

Constants

View Source
const (
	// LogDriver names [LogBroadcaster].
	LogDriver = "log"
	// NullDriver names [NullBroadcaster], and is the driver a connection falls
	// back to when none is configured.
	NullDriver = "null"
	// RedisDriver names [RedisBroadcaster].
	RedisDriver = "redis"
)

The names a connection's Driver field carries.

View Source
const ChannelJoin = broadcasting.ChannelJoin

ChannelJoin is broadcasting.ChannelJoin, which is where the action is declared so that BroadcastController can check the Grant a driver answered.

It stays spelled here because this is the package that issues the Grant, and it is the same constant rather than a second one: two spellings of an action is a Grant that passes Check in one package and fails it in the other.

Variables

View Source
var ErrChannelUndecided = errors.New("broadcasting: the channel handler did not decide")

ErrChannelUndecided is what a channel handler returns to say the pattern it was registered under does not apply after all, so the search should carry on to the next one.

A handler has three answers: false denies immediately, any other value allows, and (nil, nil) keeps looking. This error is what the third turns into once auth.Authorize has been asked -- a Policy that returns nil has allowed, so "keep looking" has to be an error to travel back out.

Functions

func CreateLogDriver

func CreateLogDriver(logger *slog.Logger) broadcasting.DriverCreator

CreateLogDriver builds the creator for LogBroadcaster.

It is a function rather than a method on the manager, and the reason is the import graph: BroadcastManager lives in github.com/arandu-io/hesape/broadcasting, this package imports that one for Channel and BroadcastError, and Go refuses the cycle a method constructing a LogBroadcaster would close.

The logger is the argument, and the returned creator is what broadcasting.BroadcastManager.Extend takes.

func CreateNullDriver

func CreateNullDriver() broadcasting.DriverCreator

CreateNullDriver builds the creator for NullBroadcaster. See CreateLogDriver for why it is a function.

func CreateRedisDriver

func CreateRedisDriver(redis RedisFactory) broadcasting.DriverCreator

CreateRedisDriver builds the creator for RedisBroadcaster. See CreateLogDriver for why it is a function.

The connection name and the key prefix are read off broadcasting.ConnectionConfig.

func Register

Register puts the three drivers this ecosystem carries on a manager.

No driver is built in: this package cannot be imported by the one the manager is in, so all three are registered the same way a custom driver is, with Extend. That is wiring, and wiring belongs in bootstrap/app.go.

A nil logger is slog.Default and a nil factory still registers the redis driver -- it fails when it is used, naming itself, rather than at start-up for a connection nobody configured.

Types

type Broadcaster

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

Broadcaster is the channel registry and the authorization walk every driver shares, and it is embedded by each of them.

Embedding cannot call back into the driver -- Go has no virtual dispatch -- so Broadcaster.VerifyUserCanAccessChannel answers the raw handler result instead of a response, and each driver's Auth passes that through its own ValidAuthenticationResponse.

A Broadcaster is safe for concurrent use: channels are registered at start-up and read on every subscription.

func (*Broadcaster) Channel

func (b *Broadcaster) Channel(channel string, handler ChannelHandler) *Broadcaster

Channel registers a channel authenticator under a pattern.

There is no options argument: the subject comes from the context (auth.SubjectFrom), so there is no authentication guard to pick between.

Registering the same pattern twice replaces the handler and keeps its place in the registration order.

func (*Broadcaster) ChannelFor

func (b *Broadcaster) ChannelFor(channel broadcasting.HasBroadcastChannel, handler ChannelHandler) *Broadcaster

ChannelFor registers a channel authenticator for a model, under its BroadcastChannelRoute -- the pattern, not the instance's own name.

func (*Broadcaster) ChannelNameMatchesPattern

func (b *Broadcaster) ChannelNameMatchesPattern(channel, pattern string) bool

ChannelNameMatchesPattern reports whether a channel name matches a registered pattern.

It carries no tenant and needs none: both arguments are already normalized names, which is what UsePusherChannelConventions.NormalizeChannelName answers and what the walk in Broadcaster.VerifyUserCanAccessChannel matches against.

func (*Broadcaster) ExtractAuthParameters

func (b *Broadcaster) ExtractAuthParameters(pattern, channel string) map[string]string

ExtractAuthParameters is the {placeholders} of the pattern, filled in from the channel that was asked for.

The values are strings and stay strings: nothing here turns "17" into an Order. A channel handler that wants the order loads it through a repository, with the Grant it is about to be given.

func (*Broadcaster) FormatChannels

func (b *Broadcaster) FormatChannels(g auth.Grant, channels []broadcasting.Channel) ([]string, error)

FormatChannels is the channels as the strings a driver puts on the wire.

It takes the Grant, so a driver cannot name a channel without a tenant. That matters because it is promoted into every driver: a version that dropped the tenant would be inherited in silence by the next driver written by copying another one, and `d.FormatChannels(channels)` would have compiled. RedisBroadcaster.FormatChannels shadows this only to put the Redis key prefix in front of what it answers -- it does not decide the tenant a second time.

func (*Broadcaster) GetChannels

func (b *Broadcaster) GetChannels() map[string]ChannelHandler

GetChannels is every registered channel, by pattern. The map is a copy.

func (*Broadcaster) NormalizeChannelHandlerToCallable

func (b *Broadcaster) NormalizeChannelHandlerToCallable(handler ChannelHandler) ChannelHandlerFunc

NormalizeChannelHandlerToCallable turns any ChannelHandler into the function form.

A nil handler becomes one that declines, so a pattern registered with nothing behind it does not panic on the subscription that matches it.

func (*Broadcaster) ResolveAuthenticatedUser

func (b *Broadcaster) ResolveAuthenticatedUser(ctx context.Context, r *http.Request) (any, error)

ResolveAuthenticatedUser is the user payload for the incoming connection, or nil when no callback was registered.

See https://pusher.com/docs/channels/library_auth_reference/auth-signatures for the document the client expects.

func (*Broadcaster) ResolveAuthenticatedUserUsing

func (b *Broadcaster) ResolveAuthenticatedUserUsing(callback func(ctx context.Context, r *http.Request) (any, error))

ResolveAuthenticatedUserUsing registers the callback that answers who the connection belongs to.

func (*Broadcaster) RetrieveUser

func (b *Broadcaster) RetrieveUser(ctx context.Context, channel string) (auth.Subject, bool)

RetrieveUser is who is asking: auth.SubjectFrom(ctx), the subject the session middleware put on the context.

The channel is a parameter so that a driver could answer differently per channel; nothing reads it today.

func (*Broadcaster) VerifyUserCanAccessChannel

func (b *Broadcaster) VerifyUserCanAccessChannel(ctx context.Context, channel string) (auth.Grant, any, error)

VerifyUserCanAccessChannel walks the registered patterns and lets the first one that matches decide.

This is where a channel becomes an authorization decision. The handler is wrapped in an auth.Policy and run through auth.Authorize, so the refusal is auth.ErrForbidden and the success is an auth.Grant. That is not decoration: the Grant carries the tenant every published channel name is built from, and nothing in this framework reaches tenant-scoped data without one.

The subject is not an argument. It comes from the context, where the session middleware put it, which is what makes it impossible for the request being authorized to name the subject it is authorized as.

It returns the raw handler result, not a response. Go has no virtual dispatch through an embedded struct, so the driver applies its own ValidAuthenticationResponse to what comes back -- see Broadcaster.

type ChannelAuthorization

type ChannelAuthorization struct {
	// Name is the normalized channel name -- no private-, presence- or
	// private-encrypted- prefix, and no Redis key prefix.
	Name string
	// Parameters are the {placeholders} of the registered pattern, filled in
	// from the channel the client asked for: "orders.{orderId}" against
	// "orders.17" gives {"orderId": "17"}.
	Parameters map[string]string
}

ChannelAuthorization is the resource a channel Policy decides about.

The pattern's parameters arrive as a map rather than spread across the handler's arguments, because Go cannot inspect a func's parameter names: the handler reads the ones it registered for.

type ChannelHandler

type ChannelHandler interface {
	// Join decides whether the subject may listen on the channel.
	//
	// A nil error allows. A non-nil error denies. A nil result with a nil error
	// means this pattern declines to decide and the next one is tried -- see
	// [ErrChannelUndecided].
	//
	// The value returned on success is what ValidAuthenticationResponse turns
	// into the presence channel's user_info. `true` is the ordinary answer for
	// a private channel.
	Join(ctx context.Context, s auth.Subject, parameters map[string]string) (any, error)
}

ChannelHandler is what Broadcaster.Channel registers: the thing that decides whether a subject may listen on a channel.

ChannelHandlerFunc is the plain-function form of the same thing.

type ChannelHandlerFunc

type ChannelHandlerFunc func(ctx context.Context, s auth.Subject, parameters map[string]string) (any, error)

ChannelHandlerFunc is a function that is a ChannelHandler.

func (ChannelHandlerFunc) Join

func (f ChannelHandlerFunc) Join(ctx context.Context, s auth.Subject, parameters map[string]string) (any, error)

Join calls f, so a plain function satisfies ChannelHandler.

type LogBroadcaster

type LogBroadcaster struct {
	Broadcaster
	// contains filtered or unexported fields
}

LogBroadcaster writes what would have been broadcast to the log.

It is what a developer points the default connection at while the socket server is not running yet, and it is why the log line carries the whole payload: the line is the only evidence the event happened.

func NewLogBroadcaster

func NewLogBroadcaster(logger *slog.Logger) *LogBroadcaster

NewLogBroadcaster builds the driver over the logger it writes to.

A nil logger becomes slog.Default, because a driver whose whole job is to write somewhere must not be the reason a broadcast panics.

func (*LogBroadcaster) Auth

func (l *LogBroadcaster) Auth(ctx context.Context, channel string) (auth.Grant, any, error)

Auth authorizes nobody: it answers the zero auth.Grant, and that fails every auth.Grant.Check. A driver that only writes to a file decides nothing.

func (*LogBroadcaster) Broadcast

func (l *LogBroadcaster) Broadcast(ctx context.Context, g auth.Grant, channels []broadcasting.Channel, event string, payload map[string]any) error

Broadcast writes the event, the channels and the payload at info level, with the payload pretty-printed after a newline.

The channel names are the ones that would go on the wire, tenant included -- a log that showed "orders.17" while the broker saw "acme:orders.17" would be the wrong evidence. They come from the embedded Broadcaster.FormatChannels, which is the one place a channel name is built.

func (*LogBroadcaster) ValidAuthenticationResponse

func (l *LogBroadcaster) ValidAuthenticationResponse(ctx context.Context, g auth.Grant, channel broadcasting.Channel, result any) (any, error)

ValidAuthenticationResponse answers nothing, because LogBroadcaster.Auth authorizes nobody.

type NullBroadcaster

type NullBroadcaster struct {
	Broadcaster
}

NullBroadcaster has an empty body in every method, and nothing leaves the process.

It is the driver a connection resolves to when none is configured, so an application that never set broadcasting up still runs.

func NewNullBroadcaster

func NewNullBroadcaster() *NullBroadcaster

NewNullBroadcaster builds the driver.

func (*NullBroadcaster) Auth

func (n *NullBroadcaster) Auth(ctx context.Context, channel string) (auth.Grant, any, error)

Auth authorizes nobody: it answers the zero auth.Grant, which fails every auth.Grant.Check, so a caller that took this answer for an authorization reaches nothing.

func (*NullBroadcaster) Broadcast

func (n *NullBroadcaster) Broadcast(ctx context.Context, g auth.Grant, channels []broadcasting.Channel, event string, payload map[string]any) error

Broadcast drops the event.

func (*NullBroadcaster) ValidAuthenticationResponse

func (n *NullBroadcaster) ValidAuthenticationResponse(ctx context.Context, g auth.Grant, channel broadcasting.Channel, result any) (any, error)

ValidAuthenticationResponse answers nothing, because NullBroadcaster.Auth authorizes nobody.

type RedisBroadcaster

type RedisBroadcaster struct {
	Broadcaster
	UsePusherChannelConventions
	// contains filtered or unexported fields
}

RedisBroadcaster publishes on Redis pub/sub, and a socket process on the other side relays to the browser.

There is one publishing path: a Lua script that publishes to every channel in one round trip.

func NewRedisBroadcaster

func NewRedisBroadcaster(redis RedisFactory, connection, prefix string) *RedisBroadcaster

NewRedisBroadcaster builds the driver over the factory it publishes through.

func (*RedisBroadcaster) Auth

func (r *RedisBroadcaster) Auth(ctx context.Context, channel string) (auth.Grant, any, error)

Auth authorizes the incoming subscription.

The channel name has the Redis prefix cut off the front -- as a prefix, not as the first occurrence anywhere in the name -- and is then normalized: private-, presence- and private-encrypted- come off, because that is the name channels are registered under. An empty channel is refused, and so is a guarded channel with nobody on the context.

The decision is made by a Policy through auth.Authorize, the refusal is auth.ErrForbidden, and the auth.Grant that comes back is what the published channel name is built from. The subject comes from the context and never from the request being authorized.

The name the client asked for goes through broadcasting.RequestedChannel, which refuses a client that names a tenant, and the authorized name is built from the Grant by RedisBroadcaster.ValidAuthenticationResponse -- the same call RedisBroadcaster.Broadcast names its channels with.

func (*RedisBroadcaster) Broadcast

func (r *RedisBroadcaster) Broadcast(ctx context.Context, g auth.Grant, channels []broadcasting.Channel, event string, payload map[string]any) error

Broadcast publishes the event on every channel in one round trip.

The document is event, data and socket, with the socket id lifted out of the data. A subscriber that carries that socket id skips the message, and that is how ToOthers reaches the browser.

func (*RedisBroadcaster) FormatChannels

func (r *RedisBroadcaster) FormatChannels(g auth.Grant, channels []broadcasting.Channel) ([]string, error)

FormatChannels is the channel names as they go on the wire, with the Redis key prefix in front.

The name published is "<prefix><tenant>:<channel>", so two customers subscribing to the same channel name are on two channels, and neither of them chose the tenant -- it comes off the Grant that authorized the subscription.

It shadows the embedded Broadcaster.FormatChannels rather than overriding it, because Go has no virtual dispatch; every call inside this driver reaches this one. What it adds is the Redis key prefix and nothing else -- the tenant is decided once, by the embedded method it calls, and not a second time here.

Auth reaches this too, through RedisBroadcaster.ValidAuthenticationResponse, so the name the authorization examines and the name Broadcast publishes are the same string built by the same call from the same Grant.

func (*RedisBroadcaster) ValidAuthenticationResponse

func (r *RedisBroadcaster) ValidAuthenticationResponse(ctx context.Context, g auth.Grant, channel broadcasting.Channel, result any) (any, error)

ValidAuthenticationResponse is the JSON document the socket client is sent back.

A boolean result is the whole answer for a private channel. Anything else is the presence channel's user_info, wrapped in channel_data beside the identifier of whoever was authorized.

That identifier is auth.Grant.Subject().ID: taking it off the Grant rather than off the request is what makes it the id that was authorized rather than the id that was claimed.

The document names the channel it is about. An answer that said only `true` would leave the relay hearing yes without hearing yes-to-what, and it would sign the socket onto the string the client sent -- the only channel name it has. The name comes from RedisBroadcaster.FormatChannels, which is what RedisBroadcaster.Broadcast names its channels with: one Grant, one name, both sides of the wire.

It is also why this method cannot be called without a Grant that carries a tenant. A Grant that does not is refused here, and Auth answers the refusal.

type RedisConnection

type RedisConnection interface {
	// Eval runs a Lua script server-side. numberOfKeys is how many of the
	// arguments are keys; the rest are ARGV.
	Eval(ctx context.Context, script string, numberOfKeys int, arguments ...any) (any, error)
}

RedisConnection is the one command RedisBroadcaster.Broadcast issues.

type RedisFactory

type RedisFactory interface {
	// Connection is the named connection, or the default one when the name is
	// empty.
	Connection(name string) (RedisConnection, error)
}

RedisFactory is the little of a Redis connection factory that this broadcaster uses.

It is declared here and not imported. github.com/arandu-io/hesape/redis is a separate module with its own go.mod, because the driver under it is a third party dependency and in Go there is no optional dependency; the root module cannot import its own submodule, and this package is in the root module. So the contract is stated here and an application wires its *redis.RedisManager through a two-line adapter -- Go has no covariant return types, so a manager whose Connection answers *connections.Connection does not satisfy an interface whose Connection answers an interface, however compatible the two are.

type UsePusherChannelConventions

type UsePusherChannelConventions struct{}

UsePusherChannelConventions is the two questions a driver asks about a channel name before it does anything with it.

It is an empty struct a driver embeds, and both methods are promoted onto it. The conventions are the Pusher wire format, which is what the socket clients on the other end speak, so RedisBroadcaster embeds it.

func (UsePusherChannelConventions) IsGuardedChannel

func (UsePusherChannelConventions) IsGuardedChannel(channel string) bool

IsGuardedChannel is true when the channel is one that has to be authorized.

The tenant is cut off first. A published channel is named "acme:private-orders.17" and does not begin with "private-", so asking strings.HasPrefix of the raw wire name answers false for exactly the private channels the question protects -- and the subscription then walks past the "nobody on the context" refusal underneath it.

func (UsePusherChannelConventions) NormalizeChannelName

func (UsePusherChannelConventions) NormalizeChannelName(channel string) string

NormalizeChannelName is the channel name with its prefix taken off, which is the name channels are registered under.

The tenant comes off here too, for the same reason and with a second one: the result is what Broadcaster.VerifyUserCanAccessChannel matches registered patterns against, and a name with the tenant still in it forces an application to register "{tenant}:private-orders.{orderId}" -- which makes Broadcaster.ExtractAuthParameters hand the handler a tenant taken out of the request. The pattern an application registers carries no tenant, because the name matched against it carries none.

The order matters: private-encrypted- is tried before private-, because it starts with it.

Jump to

Keyboard shortcuts

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