wails/v3/pkg/application/websocket_server.go
Lea Anthony 9a363d7be5
feat(v3): add server mode for headless HTTP deployment (#4903)
* feat(v3): add server mode for headless HTTP deployment

Server mode allows Wails applications to run as pure HTTP servers
without native GUI dependencies. Enable with `-tags server` build tag.

Features:
- HTTP server with configurable host/port via ServerOptions
- WAILS_SERVER_HOST and WAILS_SERVER_PORT env var overrides
- WebSocket event broadcasting to connected browsers
- Browser clients represented as BrowserWindow (Window interface)
- Health check endpoint at /health
- Graceful shutdown with configurable timeout
- Docker support with Dockerfile.server template and tasks

Build and run:
  wails3 task build:server
  wails3 task run:server
  wails3 task build:docker
  wails3 task run:docker

Documentation at docs/guides/server-build.mdx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat(v3): add server mode for headless HTTP deployment

Server mode allows Wails applications to run as pure HTTP servers
without native GUI dependencies. Enable with `-tags server` build tag.

Features:
- HTTP server with configurable host/port via ServerOptions
- WAILS_SERVER_HOST and WAILS_SERVER_PORT env var overrides
- WebSocket event broadcasting to connected browsers
- Browser clients represented as BrowserWindow (Window interface)
- Health check endpoint at /health
- Graceful shutdown with configurable timeout
- Docker support with Dockerfile.server template and tasks

Build and run:
  wails3 task build:server
  wails3 task run:server
  wails3 task build:docker
  wails3 task run:docker

Documentation at docs/guides/server-build.mdx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review comments

- Fix corrupted test file with embedded terminal output
- Fix module name mismatch in gin-routing (was gin-example)
- Fix replace directive version mismatch in gin-service
- Fix placeholder module name in ios example (was changeme)
- Fix Dockerfile COPY path to work from both build contexts
- Fix bare URL in README (MD034 compliance)
- Fix comment accuracy in getScreens (returns error, not empty slice)
- Remove deprecated docker-compose version field
- Add port documentation in Taskfile template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address CodeRabbit review comments

- Add note about healthcheck wget not being available in distroless images
- Add !server build constraint to menu_windows.go and menu_darwin.go
- Downgrade window-visibility-test go.mod from 1.25 to 1.24 to match CI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:33:44 +11:00

137 lines
4 KiB
Go

//go:build server
package application
import (
"net/http"
"sync"
"sync/atomic"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
// globalBroadcaster holds a reference to the WebSocket broadcaster for server mode.
// Used by HTTP handler to look up BrowserWindows from runtime client IDs.
var globalBroadcaster *WebSocketBroadcaster
// GetBrowserWindow returns the BrowserWindow for a given runtime clientId.
// Only available in server mode. Returns nil if not found.
func GetBrowserWindow(clientId string) *BrowserWindow {
if globalBroadcaster == nil {
return nil
}
return globalBroadcaster.GetBrowserWindow(clientId)
}
// clientInfo holds information about a connected WebSocket client.
type clientInfo struct {
conn *websocket.Conn
window *BrowserWindow
}
// WebSocketBroadcaster manages WebSocket connections and broadcasts events to all connected clients.
// It implements WailsEventListener to receive events from the application.
type WebSocketBroadcaster struct {
clients map[*websocket.Conn]*clientInfo
windows map[string]*BrowserWindow // maps runtime clientId (nanoid) to BrowserWindow
mu sync.RWMutex
app *App
nextID atomic.Uint64
}
// NewWebSocketBroadcaster creates a new WebSocket broadcaster.
func NewWebSocketBroadcaster(app *App) *WebSocketBroadcaster {
return &WebSocketBroadcaster{
clients: make(map[*websocket.Conn]*clientInfo),
windows: make(map[string]*BrowserWindow),
app: app,
}
}
// GetBrowserWindow returns the BrowserWindow for a given runtime clientId.
// Returns nil if not found.
func (b *WebSocketBroadcaster) GetBrowserWindow(clientId string) *BrowserWindow {
b.mu.RLock()
defer b.mu.RUnlock()
return b.windows[clientId]
}
// ServeHTTP handles WebSocket upgrade requests.
func (b *WebSocketBroadcaster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Allow connections from any origin in server mode
InsecureSkipVerify: true,
})
if err != nil {
b.app.error("WebSocket accept error", "error", err)
return
}
// Get runtime's clientId from query parameter
runtimeClientID := r.URL.Query().Get("clientId")
// Create BrowserWindow for this connection
browserWindow := NewBrowserWindow(uint(b.nextID.Add(1)), runtimeClientID)
// Store mapping if runtime clientId was provided
if runtimeClientID != "" {
b.mu.Lock()
b.windows[runtimeClientID] = browserWindow
b.mu.Unlock()
}
b.register(conn, browserWindow)
defer b.unregister(conn, runtimeClientID)
// Keep connection alive - read loop for ping/pong and detecting disconnects
// Events from client to server are sent via HTTP, not WebSocket
for {
_, _, err := conn.Read(r.Context())
if err != nil {
break
}
}
}
// register adds a client connection with its BrowserWindow.
func (b *WebSocketBroadcaster) register(conn *websocket.Conn, window *BrowserWindow) {
client := &clientInfo{
conn: conn,
window: window,
}
b.mu.Lock()
b.clients[conn] = client
b.mu.Unlock()
b.app.info("WebSocket client connected", "id", window.Name(), "clients", len(b.clients))
}
// unregister removes a client connection and its BrowserWindow.
func (b *WebSocketBroadcaster) unregister(conn *websocket.Conn, runtimeClientID string) {
b.mu.Lock()
client := b.clients[conn]
delete(b.clients, conn)
if runtimeClientID != "" {
delete(b.windows, runtimeClientID)
}
b.mu.Unlock()
conn.Close(websocket.StatusNormalClosure, "")
if client != nil {
b.app.info("WebSocket client disconnected", "id", client.window.Name(), "clients", len(b.clients))
}
}
// DispatchWailsEvent implements WailsEventListener interface.
// It broadcasts the event to all connected WebSocket clients.
func (b *WebSocketBroadcaster) DispatchWailsEvent(event *CustomEvent) {
b.mu.RLock()
defer b.mu.RUnlock()
for _, client := range b.clients {
go func(c *clientInfo) {
if err := wsjson.Write(b.app.ctx, c.conn, event); err != nil {
b.app.debug("WebSocket write error", "error", err, "client", c.window.Name())
}
}(client)
}
}