Server & Bridge (Web UI Backend)
The Web UI backend consists of two packages: internal/server (HTTP server) and internal/bridge (WebSocket fan-out bridge). Together they serve the embedded SPA and relay commands/events between the browser and the coordinator.
Package: internal/server
The HTTP server binds to 127.0.0.1 and serves three routes:
| Route | Method | Purpose |
|---|---|---|
/ | GET | Serves the embedded SPA with SPA fallback (all non-/ws//health routes serve index.html) |
/ws | GET | Upgrades to WebSocket, hands connection to the bridge |
/health | GET | Returns { "ok": true, "clients": 0 } JSON |
Server Struct
type Server struct {
addr string // bind address (e.g., "127.0.0.1:0" for ephemeral)
bridge Bridge // bridge interface for WS upgrades
spa http.Handler // embedded SPA handler
logger *slog.Logger
httpSrv *http.Server
}Server Lifecycle
New(addr, bridge, spa, logger)- Creates the server.Start(ctx)- Binds the listener, starts serving in a background goroutine, returns the bound address. Listens forctx.Done()to trigger graceful shutdown.Wait()- Blocks until the server has fully shut down.URL()- Returns the reachable HTTP URL (e.g.,http://127.0.0.1:9343).
Graceful shutdown sequence:
ctxis cancelled (viawebCancelfrom the app layer).httpSrv.Shutdown(5s timeout)stops accepting new connections and drains existing ones.bridge.Close()disconnects all WebSocket clients and unsubscribes from the event bus.
Port Selection
- When
addris"127.0.0.1:0", the OS assigns a free ephemeral port. - When a specific port is given (e.g.,
"127.0.0.1:9343"), that port is used. - Port-only strings (e.g.,
":9343") are prefixed with127.0.0.1.
Bridge Interface
type Bridge interface {
UpgradeHTTP(w http.ResponseWriter, r *http.Request) error
ClientCount() int
Close() error
}Package: internal/bridge
The bridge is the WebSocket ↔ event bus adapter. It subscribes to ChatEvent on the event bus and fans out every event to all connected WebSocket clients. It also accepts WebSocket messages, unmarshals them into ChatCommand values, and forwards them to the coordinator.
Bridge Struct
type Bridge struct {
runtime Runtime // coordinator (ChatRuntime)
bus *eventbus.Bus
client *eventbus.Client // "web" client
sub *eventbus.Subscriber[tauchat.ChatEvent]
clients map[*client]struct{} // connected WebSocket clients
initData []byte // pre-marshalled init message
lastSnapshot []byte // cached for new client replay
upgrader websocket.Upgrader
logger *slog.Logger
}Runtime Interface
type Runtime interface {
Send(cmd tauchat.ChatCommand) error
Close()
}InitInfo
Sent to every browser on connection:
type InitInfo struct {
SessionID string
Model string
Provider string
Models []tauchat.ChatModelRef
Providers []string
Commands []tauchat.CommandRef
}The Models and Providers fields enable rich model selection and cross-provider switching in the Web UI.
Connection Lifecycle
NewBridge(runtime, bus, init, logger)- Creates the bridge, subscribes toChatEventon the bus, starts thebroadcastLoop()goroutine.UpgradeHTTP(w, r)- Handles a WebSocket upgrade request:- Upgrades HTTP to WebSocket via
gorilla/websocket. - Creates a
clientwith a buffered send channel (64 messages). - Sends the
initmessage immediately. - Replays the cached
lastSnapshotso the client sees existing history. - Enters
readLoop()to process incoming messages. - On return (connection closed), removes the client.
- Upgrades HTTP to WebSocket via
Close()- Closes all client connections, unsubscribes from the bus, waits for goroutines.
Client Model
Each connected browser has a client:
type client struct {
bridge *Bridge
conn *websocket.Conn
send chan []byte // buffered outbound channel (cap 64)
closeOnce sync.Once
}readLoop()- Reads text messages from the WebSocket, unmarshals them asChatCommand, and callsruntime.Send(cmd). Handles pong frames for keepalive (60s read deadline). On error, returns and the client is removed.writeLoop()- Reads from thesendchannel and writes to the WebSocket. Sends ping frames every 30 seconds for keepalive. Closes the WebSocket when the channel is closed or the bridge is done.close()- Closes the send channel once (viasync.Once).
Event Broadcast
The broadcastLoop() goroutine:
- Receives
ChatEventfrom the bus subscriber. - Marshals the event to a JSON envelope via
MarshalEvent(). - If the event is a
ChatSessionSnapshotEvent, caches it inlastSnapshotfor replay to new clients. - Fans out the marshalled data to all connected clients' send channels (non-blocking - slow clients are warned and dropped).
Wire Format
The wire format uses JSON envelopes with a type discriminator field. See internal/bridge/wire.go:
type Envelope struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}Event serialization (MarshalEvent):
- The event value is first marshalled to JSON as the payload.
- The payload is wrapped in an envelope with the event's concrete type name (e.g.,
"ChatResponseDeltaEvent").
Command deserialization (UnmarshalCommand):
- The envelope is parsed to extract the
typefield. - A switch on the type name unmarshals the payload into the corresponding concrete command struct.
Event Type Names
The eventTypeName() function maps concrete event types to wire names via a type switch. Supported event types:
ChatSessionSnapshotEvent, ChatResponseStartedEvent, ChatResponseDeltaEvent, ChatReasoningDeltaEvent, ChatToolCallDeltaEvent, ChatToolExecutionStartedEvent, ChatToolOutputEvent, ChatToolExecutionCompletedEvent, ChatResponseCompletedEvent, ChatResponseCancelledEvent, ChatRuntimeErrorEvent, ChatNotificationEvent, ExtensionsReloadedEvent, ExtensionCommandsChangedEvent, ExtensionCommandResultEvent, InteractivePromptRequestedEvent, SessionsListedEvent, SessionLoadedEvent, SessionDeletedEvent, SessionExportedEvent, CommandsChangedEvent.
Command Type Names
unmarshalCommandPayload() handles: StartChatSessionCommand, SubmitChatPromptCommand, SteerChatPromptCommand, UpdateChatSessionCommand, CancelChatRequestCommand, ResetChatSessionCommand, CloseChatSessionCommand, ReloadExtensionsCommand, RunExtensionCommandCommand, RespondInteractivePromptCommand, ListSessionsCommand, LoadSessionCommand, DeleteSessionCommand, ExportSessionCommand.
App Integration
The server and bridge are wired together in internal/app/web.go:
func startWebUI(
runtime webbridge.Runtime,
bus *eventbus.Bus,
opts ChatOptions,
sessionID, modelID string,
availableModels []tauchat.ChatModelRef,
availableProviders []string,
commands []tauchat.CommandRef,
logger *slog.Logger,
) (*webServerResult, error)The function:
- Creates the bridge with
InitInfoincluding available models, providers, and commands. - Picks the bind address (
127.0.0.1:<port>). - Creates and starts the server with the embedded SPA handler.
- Returns the server, URL, and shutdown/wait functions.
In app.RunChat(), the web UI lifecycle is:
// 1. Start the web UI
webRes, _ := startWebUI(...)
webURL = webRes.URL
// 2. Optionally open browser
if opts.Web { openBrowser(ctx, webURL) }
// 3. Run the TUI (blocks until exit)
tui.Run(ctx, coordinator, tuiCfg)
// 4. Shutdown web UI after TUI exits
webShutdown()
webWait()This ensures the web server stays alive for the entire TUI session and shuts down cleanly when the user quits.