$ git clone https://github.com/angvp/tango $ cd tango/examples/realtime-chat $ go run .
Realtime chat example
This example shows a broadcast chat room built on tanGO's realtime and
realtime/websocket packages, authenticated with auth/jwt.
It demonstrates:
one realtime.Hub serving one room type (chatLogic), mounted at
GET /rooms/{id}/ws;
realtime.RoomContext.Broadcast/BroadcastExcept for chat messages and
join/leave notices;
realtime.RoomContext.ResetTimer for a per-user idle notice;
auth/jwt.QueryToken for authentication, since a browser WebSocket
handshake cannot set a custom Authorization header the way an ordinary
HTTP client can — this is the concrete transport QueryToken exists for
(see the docs/limitations.md note on query-token logging exposure);
reconnecting within the room's reconnect window restores membership and
delivers a fresh Logic.Snapshot;
graceful shutdown: hub.Close is registered as a tango.Lifecycle
(registry.RegisterLifecycle(tango.Lifecycle{Name: "chat-hub", Stop: hub.Close})),
and main builds its context with signal.NotifyContext and calls
tango.ServeContext instead of tango.Serve — Ctrl-C or SIGTERM now
drains in-flight connections before closing the hub, instead of the
process exiting mid-request. See the
application lifecycle guide.
Its hardcoded JWT secret and -issue-token flag are development-only
tools, not a production credential or authentication endpoint — same
convention as examples/jwt-api.
git clone https://github.com/angvp/tango
cd tango/examples/realtime-chat
go run . -issue-token alice
go run .
# in another terminal, using any WebSocket client:# connect to ws://localhost:8000/rooms/demo/ws?token=<token>
The address is TANGO_ADDR, else the PORT hosting platforms set, else :8000.
Deliberately out of scope for this example (and for realtime itself):
no persistence across a restart, no distributed/multi-process
rooms, and no chat history replay on join — Logic.Snapshot here is a
static welcome message, not a message backlog.
examples/realtime-chat/main.go
packagemainimport("context""flag""fmt""net/http""os""os/signal""strings""syscall""time""github.com/angvp/tango"tangojwt"github.com/angvp/tango/auth/jwt""github.com/angvp/tango/db""github.com/angvp/tango/realtime"realtimews"github.com/angvp/tango/realtime/websocket")varexampleSecret=[]byte("example-only-secret-not-for-production-use")// idleTimeout is deliberately short (a real chat app would use minutes,// not milliseconds) so this example's own integration test doesn't need// to wait long for the idle-timer demonstration to fire.constidleTimeout=300*time.Millisecondfuncmain(){os.Exit(run())}funcrun()int{issueSubject:=flag.String("issue-token","","issue a development-only JWT for this subject and exit")check:=flag.Bool("check",false,"validate app registration and exit")flag.Parse()jwtService,err:=newJWTService()iferr!=nil{fmt.Fprintln(os.Stderr,err)return1}if*issueSubject!=""{token,err:=jwtService.Issue(*issueSubject,15*time.Minute)iferr!=nil{fmt.Fprintln(os.Stderr,err)return1}fmt.Println(token)return0}hub,err:=newChatHub()iferr!=nil{fmt.Fprintln(os.Stderr,err)return1}if*check{iferr:=tango.Check(exampleConfig(hub,jwtService));err!=nil{fmt.Fprintln(os.Stderr,"check failed:",err)return1}fmt.Println("check passed")return0}// signal.NotifyContext is the host's own responsibility — tanGO never// installs OS signal handling itself. Canceling this ctx (Ctrl-C or// SIGTERM) triggers ServeContext's graceful shutdown: draining// in-flight connections, then stopping hub.Close via the registered// "chat-hub" Lifecycle.ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)deferstop()config:=exampleConfig(hub,jwtService)fmt.Println("listening on",config.Addr)iferr:=tango.ServeContext(ctx,config,nil,db.SQLite);err!=nil{fmt.Fprintln(os.Stderr,err)return1}return0}funcnewJWTService()(*tangojwt.Service,error){returntangojwt.NewService(tangojwt.Key{ID:"example-v1",Secret:exampleSecret},nil,"realtime-chat-example","realtime-chat-example",)}funcnewChatHub()(*realtime.Hub,error){returnrealtime.NewHub(func(string)realtime.Logic{returnchatLogic{}},realtime.Options{})}// authenticate verifies a JWT carried as a query-string token — WebSocket// handshakes are initiated by browser client code that cannot always set a// custom Authorization header, which is exactly the case jwt.QueryToken// exists for (see docs/guides/jwt-auth.md). Prefer jwt.BearerToken wherever the// transport allows it; this example demonstrates the query-token path// specifically because WebSocket is the concrete case that needs it.funcauthenticate(jwtService*tangojwt.Service)realtimews.Authenticate{extract:=tangojwt.QueryToken("token")returnfunc(r*http.Request)(realtime.Principal,error){token,err:=extract(r)iferr!=nil{returnrealtime.Principal{},err}claims,err:=jwtService.Verify(token)iferr!=nil{returnrealtime.Principal{},err}returnrealtime.Principal{UserID:claims.Subject},nil}}funcbuildHandler(hub*realtime.Hub,jwtService*tangojwt.Service)(http.Handler,error){registry,err:=tango.BuildRegistry(exampleConfig(hub,jwtService))iferr!=nil{returnnil,err}iferr:=registry.RunRegistration();err!=nil{returnnil,err}returnregistry.Routes().Handler()}funcexampleConfig(hub*realtime.Hub,jwtService*tangojwt.Service)tango.Config{roomID:=func(ctx*tango.Context)(string,error){returnctx.Param("id"),nil}view:=realtimews.View(hub,authenticate(jwtService),roomID)app:=tango.NewApp("realtime-chat",func(registry*tango.Registry)error{iferr:=registry.Routes().Include("/",tango.URLs{tango.Path(http.MethodGet,"/rooms/{id}/ws",view,tango.Name("chat-ws")),});err!=nil{returnerr}returnregistry.RegisterLifecycle(tango.Lifecycle{Name:"chat-hub",Stop:hub.Close,})})// The address is TANGO_ADDR, else the PORT hosting platforms set, else :8000.config:=tango.LoadConfigFromEnv(tango.WithPortFromEnv())config.InstalledApps=[]tango.App{app}returnconfig}// chatLogic is the one room type this example's Hub serves: a plain// broadcast chat room with join/leave notices and a per-user idle timer.// It carries no state of its own — membership and delivery are entirely// realtime.Hub's job.typechatLogicstruct{}func(chatLogic)Handle(rc*realtime.RoomContext,evrealtime.Event)error{switchev.Kind{caserealtime.EventJoin:// EventJoin fires after membership is installed, so the joiner is// already in the room — BroadcastExcept keeps them from seeing// their own "joined" notice. EventLeave doesn't need the same// treatment: membership is removed before Handle runs for it, so// Broadcast already excludes the leaver naturally.returnrc.BroadcastExcept(ev.Principal.UserID,[]byte(ev.Principal.UserID+" joined"))caserealtime.EventLeave:returnrc.Broadcast([]byte(ev.Principal.UserID+" left"))caserealtime.EventAction:iferr:=rc.ResetTimer("idle:"+ev.Principal.UserID,idleTimeout);err!=nil{returnerr}returnrc.BroadcastExcept(ev.Principal.UserID,append([]byte(ev.Principal.UserID+": "),ev.Payload...))caserealtime.EventTimer:userID:=strings.TrimPrefix(ev.Timer,"idle:")returnrc.Broadcast([]byte(userID+" is idle"))}returnnil}func(chatLogic)Snapshot(_*realtime.RoomContext,prealtime.Principal)([]byte,error){return[]byte("welcome "+p.UserID),nil}