- lighthouse:after_create/update/delete also declare Before(gorm:commit_or_rollback_transaction); an After-only anchor put them past GORM's own commit, so a plain gdb.Create enqueued its broadcast job after the commit on the pool (deferred from 11-05) - lighthouse gets the testcontainers Postgres harness and TestBroadcastTx (commit publishes once, rollback nothing, single-statement write enqueues on its own transaction, failed write enqueues nothing)
20 KiB
lighthouse
Transport-neutral realtime: a publisher interface with pluggable drivers, subscribe-time channel authorization, and model broadcasts enqueued in the write transaction.
import "git.golem15.com/golem15/summercms/modules/lighthouse"
import _ "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
Overview
lighthouse is the SummerCMS counterpart of the WinterCMS websockets plugin. The package itself knows no transport. It owns the interfaces application code writes against, and a driver package supplies the transport. A driver registers itself from its init function, the way database/sql drivers do, and the application picks one with realtime.driver.
lighthouse.From builds the app-scoped lighthouse.Service on first use and publishes it on the app. The service holds the selected driver, the application's user lookup, and the broadcast settings.
A driver may need HTTP endpoints, such as a token route for signed-in users or a callback the realtime server calls. It declares them as lighthouse.Route values, each tagged with a lighthouse.Surface. The application mounts them once with lighthouse.Mount and decides the guard, group and rate-limit bucket per surface. Switching drivers never edits the application's route file.
Channel authorization is transport-neutral too. Plugins register a lighthouse.Authorizer per channel namespace on the service's lighthouse.Registry. The driver's subscribe endpoint asks the authorizer of the channel's namespace on every subscribe, so a user who loses access is denied the next time the client subscribes. Nothing is cached.
Model broadcasts follow the WinterCMS BroadcastableModel trait with one change. A create, update or delete of a broadcastable model enqueues a River job (through conga) inside the write's own transaction, so nothing is published for a write that rolls back. The job publishes after commit, with one attempt and best effort: a failed publish is logged and never touches the write. Suppression is per model type and scoped to a context, and lighthouse.Service.Emit publishes one explicit summary event instead.
The centrifugo sub-package is the Centrifugo driver. It has a hand-rolled net/http client for the Centrifugo HTTP API, a token issuer with the claims of the WinterCMS JwtTokenGenerator, the token route handler, and the subscribe proxy handler.
Features
-
Driver selection by
realtime.driver. The built-in drivers arenull(the default; it discards everything),log(logs channel names and the event, never the payload) andmemory(lighthouse.MemoryDriverrecords everylighthouse.Publicationfor tests). An unknown name is a boot error that lists the registered drivers. -
Third-party drivers:
lighthouse.RegisterDriverwith alighthouse.DriverFactory. A duplicate name panics at init. -
The
lighthouse.Publisherinterface (Publishfor one channel,Broadcastfor several) and thelighthouse.Driverinterface, which addsNameandRoutes. -
Route mounting by surface:
lighthouse.UserAuth,lighthouse.ServerToServerandlighthouse.Public.lighthouse.Mountputs user and public routes inGroupand server-to-server routes inGroupRaw, each with the surface middleware followed bylighthouse.Surfaces.Middleware. Alighthouse.UserAuthroute with no user middleware is refused, so a token route can never be mounted without a guard. Every route is validated before any is registered. -
Users and actors: the application installs a
lighthouse.UserLookupwithlighthouse.Service.SetUserLookup.lighthouse.Service.Userloads alighthouse.User(id and display name).lighthouse.Service.Actorreturns thelighthouse.Actorof a request, andlighthouse.SystemActorwhen there is no signed-in user or the principal is a backend admin. -
Channel rules. A channel is
namespace:entity:id, optionally prefixed once withpresence:.lighthouse.ParseChannelreturns the namespace. It returns "" for apresence:presence:prefix or for more than three segments. The lookup is byte-exact and case-sensitive.lighthouse.ChannelIDreturns segment 1 converted with PHP's(int)cast (lighthouse.PHPInt):5abcis 5,abcis 0, and out-of-range values saturate.lighthouse.FormatChannelslowercases channel names and applies the broadcast namespace prefix.
-
Authorizer registry:
lighthouse.Registry(fromlighthouse.Service.Registry) maps namespaces to alighthouse.Authorizerorlighthouse.AuthorizerFunc. Registering an empty namespace, a namespace that contains:, a nil authorizer or a namespace twice is an error.lighthouse.Registry.Namespacesis sorted. An authorizer returnslighthouse.Allowed(optionally with info, capabilities and overrides) orlighthouse.Deniedwith an internal reason that only reaches the logs. It reads the realtime client id withlighthouse.ClientID. -
Model broadcasts. A model broadcasts when its pointer type implements
lighthouse.Broadcastable(BroadcastChannels(ctx, tx)), or when alighthouse.Bindingis registered for it withlighthouse.Bind. A binding keeps payload code out of the model package. Optional overrides:lighthouse.BroadcastPayloaderorBinding.Payloadreplaces the default payload{"model":…,"actor":…,"timestamp":"…+00:00","ttl":60}.lighthouse.BroadcastAliaserorBinding.Aliasreplaces the alias.lighthouse.BroadcastFilterorBinding.ShouldBroadcastcan veto an action.lighthouse.BroadcastTTLerorBinding.TTLreplaces the ttl.
The event name is
{action}.{alias}lowercased:lighthouse.ActionCreated,lighthouse.ActionUpdatedorlighthouse.ActionDeleted, then an alias that defaults to<plugin>.<model>(the Go package name, or the parent directory of amodelspackage, and the type name). The payload builder receives alighthouse.Eventwith the action, thelighthouse.Actor, the timestamp and the ttl. A soft delete counts as a delete. A delete's channels and payload are computed from a fresh read of the row before it is deleted, so deleting a model that holds only its id still broadcasts. An empty channel list means no broadcast. -
Transactional delivery. GORM callbacks (
lighthouse.CallbackAfterCreate,lighthouse.CallbackAfterUpdate,lighthouse.CallbackSnapshotandlighthouse.CallbackAfterDelete) are installed throughlagoon.OnDatabase. The after-write callbacks run after the model's own after hook and before GORM commits the transaction it opens for a single-statement write, so they enqueue alighthouse.BroadcastArgsjob on the write's*sql.Txin every case (an explicit transaction or a singleCreate,SaveorDelete), on therealtime.broadcast_queuequeue with MaxAttempts 1 and therealtime.broadcast_timeouttimeout. Channel and payload queries and the enqueue run inside a savepoint, so a failure is rolled back to it, logged at Warn with channels and event (never the payload), and the write goes on. A write with a zero primary key, such asModel(&T{}).Where(…).Updates(…), is not broadcast; bulk paths suppress and emit instead. The null driver, or a driver whoseEnabledreports false (Centrifugo without an API key), gets no jobs. -
The broadcast job lowercases the channels and adds the
realtime.broadcast_namespaceprefix unless a channel already has it. It then publishes to one channel or broadcasts to several. A failure is logged asrealtime: broadcast failedand is not retried. Delivery order across separate jobs is not guaranteed. The payload travels inside the job as a JSON string, so its key order survives Postgres JSONB. -
Suppression:
lighthouse.WithoutBroadcastingsilences one model type for writes made with the context it hands to its function. Other types still broadcast, and a write through an outer context is not suppressed.lighthouse.Service.Emitenqueues onelighthouse.Broadcaston the caller's transaction and returns its error. Together they turn N row events into one summary event. -
Centrifugo driver (
centrifugo.Driver, driver namecentrifugo):-
centrifugo.ClientPOSTspublish,broadcast,presenceandunsubscribecalls withAuthorization: apikey <key>and a 5 s timeout. The publish body is{"channel":…,"data":{"event":…,"payload":…,"timestamp":"…+00:00"}}, with an empty payload sent as[]. Any 2xx status counts as success. With an empty API key nothing is sent and the call returnscentrifugo.ErrNotConfigured. The key never appears in logs or errors. -
centrifugo.TokenIssuersigns HS256 tokens with five generators, the same as the WinterCMS generator:ForUser(claimssub,exp,infowith onlyname),Subscription,Anonymous(sub"" and a 5-minute lifetime),ForIdentifier(an emptyinfois encoded as[]) andSubscriptionForIdentifier. It refuses to sign with an empty secret. -
centrifugo.TokenHandlerserves the token route. It answers 401{"error":"Unauthorized"}when no user is signed in, 503{"error":"WebSocket not configured"}when the token secret is empty, and otherwise 200{"token":"…"}. It sendsCache-Control: no-cache, privateand no trailing newline. -
centrifugo.ProxyHandleris the subscribe proxy endpoint. Centrifugo reads a non-200 status as an internal error, so every answer is HTTP 200. The checks run in this order:X-Centrifugo-Secretmust equalrealtime.centrifugo.proxy_secret, compared in constant time. An empty configured secret denies every subscribe.- An empty or
"0"user denies. The user may arrive as a JSON string or number; any other type counts as empty. - A missing channel denies. Centrifugo always sends one.
- The channel's namespace must have a registered authorizer.
- The authorizer receives the user id and the full original channel.
An allow answers
{"result":{"info":…}}, with an empty info encoded as[]. Apresence:channel also getsallow(the authorizer's capabilities, or["prs"]) andoverride. The override starts from the defaultspresenceandjoin_leavetrue andforce_push_join_leavefalse, then applies the authorizer's overrides. Every deny answers{"error":{"code":403,"message":"Access denied"}}and logsSubscription deniedat Warn with the reason, and never either secret. The request body is capped at 64 KiB.
-
Usage
An application selects the driver in config/realtime.yaml:
driver: centrifugo
centrifugo:
token_secret: "" # set with SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET
A plugin imports the driver package for its side effect, builds the service at Boot, installs a user lookup and registers its channel authorizers:
package acme
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lighthouse"
_ "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
)
func (p *Plugin) Boot(app *backpack.App) error {
svc, err := lighthouse.From(app)
if err != nil {
return err
}
p.realtime = svc
svc.SetUserLookup(func(ctx context.Context, id uint) (lighthouse.User, bool, error) {
return lookupAcmeUser(ctx, id) // the application's own user model
})
// Allow room:{id} to members only; re-checked on every subscribe.
return svc.Registry().Register("room", lighthouse.AuthorizerFunc(
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
if isRoomMember(ctx, userID, lighthouse.ChannelID(channel)) {
return lighthouse.Allowed(nil)
}
return lighthouse.Denied("not a room member")
}))
}
and mounts the driver's routes once:
func (p *Plugin) Routes(r pact.Router) error {
return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{
UserAuth: surf.Use("jwt.auth"),
ServerToServer: surf.Use(),
Middleware: surf.Use("throttle:acme-realtime"),
})
}
A model package stays free of realtime code; the plugin binds the model at Boot:
err := lighthouse.Bind[models.Post](svc, lighthouse.Binding[models.Post]{
Alias: "blog.post",
Channels: func(ctx context.Context, tx *gorm.DB, p *models.Post) ([]string, error) {
return []string{"blog:" + strconv.FormatUint(uint64(p.BlogID), 10)}, nil
},
})
A bulk import suppresses the per-row events and publishes one summary after commit:
err := lighthouse.WithoutBroadcasting[models.Post](ctx, func(ctx context.Context) error {
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
for _, p := range posts {
if err := tx.Create(&p).Error; err != nil {
return err
}
}
return svc.Emit(ctx, tx, lighthouse.Broadcast{
Channels: []string{"blog:7"},
Event: "blog.bulk_updated",
Payload: struct {
Reason string `json:"reason"`
Count int `json:"count"`
}{"import", len(posts)},
})
})
})
Tests select the memory driver and read what was published:
mem := svc.Driver().(*lighthouse.MemoryDriver)
for _, pub := range mem.Publications() {
fmt.Println(pub.Method, pub.Channels, pub.Event)
}
API reference
lighthouse
| Identifier | Description |
|---|---|
lighthouse.From(app) |
The app's *lighthouse.Service, built and published on first use. |
lighthouse.Service |
The realtime service: Driver, Registry, Logger, Namespace, Queue, Timeout, SetUserLookup, User, Actor. |
lighthouse.Publisher |
Publish(ctx, channel, event, payload) and Broadcast(ctx, channels, event, payload). |
lighthouse.Driver |
lighthouse.Publisher plus Name() and Routes(). |
lighthouse.DriverFactory |
func(app, svc) (lighthouse.Driver, error). |
lighthouse.RegisterDriver(name, factory) |
Registers a driver from an init function. |
lighthouse.MemoryDriver, lighthouse.NewMemoryDriver, lighthouse.Publication |
The recording driver and its records (Method, Channels, Event, Payload, Timestamp). |
lighthouse.Route |
Name, Method, Path, Surface, Handler. |
lighthouse.Surface, lighthouse.UserAuth, lighthouse.ServerToServer, lighthouse.Public |
Who calls a route. |
lighthouse.Surfaces |
Application middleware per surface plus Middleware for every route. |
lighthouse.Mount(r, driver, surfaces) |
Registers a driver's routes. |
lighthouse.Authorizer, lighthouse.AuthorizerFunc |
Authorize(ctx, userID, channel) lighthouse.Result. |
lighthouse.Result, lighthouse.Allowed, lighthouse.Denied |
A subscribe decision: Allowed, Info, Capabilities, Overrides and Reason(). |
lighthouse.Registry, lighthouse.NewRegistry |
Namespace to authorizer map: Register, Get, Namespaces. |
lighthouse.ParseChannel, lighthouse.ChannelID, lighthouse.PHPInt, lighthouse.FormatChannels |
Channel rules. |
lighthouse.WithClientID, lighthouse.ClientID |
The realtime client id of a subscribe request, carried in the context. |
lighthouse.Action, lighthouse.ActionCreated, lighthouse.ActionUpdated, lighthouse.ActionDeleted |
Broadcast actions. |
lighthouse.Event |
Action, Actor, Timestamp, TTL of a change. |
lighthouse.Broadcastable, lighthouse.BroadcastPayloader, lighthouse.BroadcastAliaser, lighthouse.BroadcastFilter, lighthouse.BroadcastTTLer |
The model-method broadcast contract. |
lighthouse.Binding, lighthouse.Bind |
Broadcast contract registered from outside the model package. |
lighthouse.WithoutBroadcasting |
Suppresses one model type for writes made with the given context. |
lighthouse.Broadcast, lighthouse.Service.Emit |
One explicit event enqueued on the caller's transaction. |
lighthouse.BroadcastArgs |
The River job (kind summer.broadcast). |
lighthouse.DefaultTTL |
The default payload ttl, 60 seconds. |
lighthouse.CallbackSnapshot, lighthouse.CallbackAfterCreate, lighthouse.CallbackAfterUpdate, lighthouse.CallbackAfterDelete |
Names of the GORM callbacks. |
lighthouse.User, lighthouse.UserLookup |
A user id with a display name, and the application's lookup. |
lighthouse.Actor, lighthouse.SystemActor |
Who caused a broadcast: {"user_id":…,"name":…}. |
lighthouse.DurationSetting(cfg, path) |
Reads a duration string or an integer number of seconds. |
lighthouse.DefaultDriver, lighthouse.DefaultQueue, lighthouse.DefaultTimeout |
Defaults of realtime.driver, realtime.broadcast_queue and realtime.broadcast_timeout. |
lighthouse/centrifugo
| Identifier | Description |
|---|---|
centrifugo.Config, centrifugo.LoadConfig |
The realtime.centrifugo.* settings with their defaults, plus TrustedProxies from http.trusted_proxies for logging client IPs. |
centrifugo.Client, centrifugo.NewClient |
HTTP API client: Publish, Broadcast, Presence, Unsubscribe, Info (the connectivity probe; an error body is an error), Enabled, DebugInfo. |
centrifugo.DebugInfo |
api_url, enabled, api_key_set. |
centrifugo.TokenIssuer, centrifugo.NewTokenIssuer |
HS256 token generators: ForUser, Subscription, Anonymous, ForIdentifier, SubscriptionForIdentifier, Configured. |
centrifugo.TokenHandler(svc, issuer) |
The token route handler. |
centrifugo.ProxyHandler(svc, cfg) |
The subscribe proxy handler. |
centrifugo.Driver, centrifugo.NewDriver, centrifugo.DriverName |
The lighthouse.Driver, with Client, Issuer, Config and Enabled (an API key is set). |
centrifugo.Commands(app), centrifugo.HealthCommandName |
The websockets:health command and its name. |
centrifugo.ErrNotConfigured |
Returned when the API key or token secret an operation needs is empty. |
Configuration
| Key | Default | Description |
|---|---|---|
realtime.driver |
null |
null, log, memory, or a registered driver such as centrifugo. |
realtime.broadcast_namespace |
"" |
Prefix applied to broadcast channel names. |
realtime.broadcast_queue |
broadcasts |
River queue of broadcast jobs. |
realtime.broadcast_timeout |
5 |
Broadcast job timeout, in seconds or as a duration string. |
realtime.centrifugo.api_url |
http://127.0.0.1:8001/api |
Centrifugo HTTP API base. |
realtime.centrifugo.api_key |
"" |
HTTP API key; empty disables publishing. |
realtime.centrifugo.token_secret |
"" |
HS256 token secret; empty makes the token route answer 503. |
realtime.centrifugo.token_ttl |
3600 |
Token lifetime, in seconds or as a duration string. |
realtime.centrifugo.ws_url |
/ws |
WebSocket URL of the Centrifugo server. |
realtime.centrifugo.proxy_secret |
"" |
Expected X-Centrifugo-Secret of subscribe proxy calls. |
realtime.centrifugo.token_path |
/api/realtime/token |
Path of the token route. |
realtime.centrifugo.subscribe_path |
/api/realtime/subscribe |
Path of the subscribe proxy route. |
CLI commands
centrifugo.Commands(app) returns websockets:health for the application binary. An application adds it to the list its plugin returns from Commands.
| Command | Description |
|---|---|
websockets:health |
With an empty realtime.centrifugo.api_key it prints Centrifugo not configured (API key missing) and exits 1 without sending a request. Otherwise it prints the API URL and calls the Centrifugo info API method. On success it prints Configuration OK and a Setting/Value table (API URL, Enabled, API Key Set); on any failure it prints Connection check failed: … and exits 1. The API key is never printed, only whether it is set. |
Dependencies
backpack,bouncer,compass,conga(the broadcast job),lagoon(callback installation),pactandwirefrom this repository; the centrifugo driver also usessurffor the client IP andbonfirefor its command.gorm.io/gorm(broadcast callbacks).github.com/golang-jwt/jwt/v5(centrifugo token signing).- The Centrifugo client is plain
net/http; no Centrifugo SDK is used.
Testing
go test ./modules/lighthouse/...
A test selects realtime.driver: memory and reads lighthouse.MemoryDriver.Publications, or points realtime.centrifugo.api_url at an httptest server to see the exact Centrifugo requests. Broadcast tests need a running conga worker (conga.StartWorker) to deliver the jobs.