wails/v3/pkg/application/application_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

554 lines
19 KiB
Go

//go:build server
package application
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"unsafe"
)
// serverApp implements platformApp for server mode.
// It provides a minimal implementation that runs an HTTP server
// without any GUI components.
//
// Server mode is enabled by building with the "server" build tag:
//
// go build -tags server
type serverApp struct {
app *App
server *http.Server
listener net.Listener
broadcaster *WebSocketBroadcaster
}
// newPlatformApp creates a new server-mode platform app.
// This function is only compiled when building with the "server" tag.
func newPlatformApp(app *App) *serverApp {
app.info("Server mode enabled (built with -tags server)")
return &serverApp{
app: app,
}
}
// parsePort parses a port string into an integer.
func parsePort(s string) (int, error) {
p, err := strconv.Atoi(s)
if err != nil {
return 0, err
}
if p < 1 || p > 65535 {
return 0, errors.New("port out of range")
}
return p, nil
}
// run starts the HTTP server and blocks until shutdown.
func (h *serverApp) run() error {
// Set up common events
h.setupCommonEvents()
// Create WebSocket broadcaster for events
h.broadcaster = NewWebSocketBroadcaster(h.app)
globalBroadcaster = h.broadcaster // Set global reference for browser ID lookups
h.app.wailsEventListenerLock.Lock()
h.app.wailsEventListeners = append(h.app.wailsEventListeners, h.broadcaster)
h.app.wailsEventListenerLock.Unlock()
opts := h.app.options.Server
// Environment variables override config (useful for Docker/containers)
host := os.Getenv("WAILS_SERVER_HOST")
if host == "" {
host = opts.Host
}
if host == "" {
host = "localhost"
}
port := opts.Port
if envPort := os.Getenv("WAILS_SERVER_PORT"); envPort != "" {
if p, err := parsePort(envPort); err == nil {
port = p
}
}
if port == 0 {
port = 8080
}
readTimeout := opts.ReadTimeout
if readTimeout == 0 {
readTimeout = 30 * time.Second
}
writeTimeout := opts.WriteTimeout
if writeTimeout == 0 {
writeTimeout = 30 * time.Second
}
idleTimeout := opts.IdleTimeout
if idleTimeout == 0 {
idleTimeout = 120 * time.Second
}
shutdownTimeout := opts.ShutdownTimeout
if shutdownTimeout == 0 {
shutdownTimeout = 30 * time.Second
}
addr := fmt.Sprintf("%s:%d", host, port)
// Create HTTP handler from asset server
handler := h.createHandler()
h.server = &http.Server{
Addr: addr,
Handler: handler,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
}
// Create listener
var err error
h.listener, err = net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
h.app.info("Server mode starting", "address", addr)
// Start server in goroutine
errCh := make(chan error, 1)
go func() {
if opts.TLS != nil {
errCh <- h.server.ServeTLS(h.listener, opts.TLS.CertFile, opts.TLS.KeyFile)
} else {
errCh <- h.server.Serve(h.listener)
}
}()
// Wait for shutdown signal or error
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
case <-quit:
h.app.info("Shutdown signal received")
case <-h.app.ctx.Done():
h.app.info("Application context cancelled")
}
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := h.server.Shutdown(ctx); err != nil {
return fmt.Errorf("server shutdown error: %w", err)
}
h.app.info("Server stopped")
return nil
}
// customJS is the JavaScript that sets up WebSocket event connection for server mode.
// Events FROM frontend TO backend use the existing HTTP transport.
// This WebSocket is only for receiving broadcast events FROM backend TO all frontends.
const customJS = `(function() {
var protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
var clientId = window._wails && window._wails.clientId ? window._wails.clientId : '';
var wsUrl = protocol + '//' + location.host + '/wails/events' + (clientId ? '?clientId=' + encodeURIComponent(clientId) : '');
var ws;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[Wails] Event WebSocket connected');
};
ws.onmessage = function(e) {
try {
var event = JSON.parse(e.data);
if (window._wails && window._wails.dispatchWailsEvent) {
window._wails.dispatchWailsEvent(event);
}
} catch (err) {
console.error('[Wails] Failed to parse event:', err);
}
};
ws.onclose = function() {
console.log('[Wails] Event WebSocket disconnected, reconnecting...');
setTimeout(connect, 1000);
};
ws.onerror = function() {
ws.close();
};
}
connect();
})();`
// createHandler creates the HTTP handler for server mode.
func (h *serverApp) createHandler() http.Handler {
mux := http.NewServeMux()
// Health check endpoint
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
})
// Serve custom.js for server mode (WebSocket event connection)
mux.HandleFunc("/wails/custom.js", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.WriteHeader(http.StatusOK)
w.Write([]byte(customJS))
})
// WebSocket endpoint for events
mux.Handle("/wails/events", h.broadcaster)
// Serve all other requests through the asset server
mux.Handle("/", h.app.assets)
return mux
}
// destroy stops the server and cleans up.
func (h *serverApp) destroy() {
if h.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
h.server.Shutdown(ctx)
}
h.app.cleanup()
}
// setApplicationMenu is a no-op in server mode.
func (h *serverApp) setApplicationMenu(menu *Menu) {
// No-op: server mode has no GUI
}
// name returns the application name.
func (h *serverApp) name() string {
return h.app.options.Name
}
// getCurrentWindowID returns 0 in server mode (no windows).
func (h *serverApp) getCurrentWindowID() uint {
return 0
}
// showAboutDialog is a no-op in server mode.
func (h *serverApp) showAboutDialog(name string, description string, icon []byte) {
// No-op: server mode has no GUI
h.app.warning("showAboutDialog called in server mode - operation ignored")
}
// setIcon is a no-op in server mode.
func (h *serverApp) setIcon(icon []byte) {
// No-op: server mode has no GUI
}
// on is a no-op in server mode.
func (h *serverApp) on(id uint) {
// No-op: server mode has no platform-specific event handling
}
// dispatchOnMainThread executes the function directly in server mode.
func (h *serverApp) dispatchOnMainThread(id uint) {
// In server mode, there's no "main thread" concept from GUI frameworks
// Execute the function directly
mainThreadFunctionStoreLock.Lock()
fn, ok := mainThreadFunctionStore[id]
if ok {
delete(mainThreadFunctionStore, id)
}
mainThreadFunctionStoreLock.Unlock()
if ok && fn != nil {
fn()
}
}
// hide is a no-op in server mode.
func (h *serverApp) hide() {
// No-op: server mode has no GUI
}
// show is a no-op in server mode.
func (h *serverApp) show() {
// No-op: server mode has no GUI
}
// getPrimaryScreen returns nil in server mode.
func (h *serverApp) getPrimaryScreen() (*Screen, error) {
return nil, errors.New("screen information not available in server mode")
}
// getScreens returns an error in server mode (screen info unavailable).
func (h *serverApp) getScreens() ([]*Screen, error) {
return nil, errors.New("screen information not available in server mode")
}
// GetFlags returns the application flags for server mode.
func (h *serverApp) GetFlags(options Options) map[string]any {
flags := make(map[string]any)
flags["server"] = true
if options.Flags != nil {
for k, v := range options.Flags {
flags[k] = v
}
}
return flags
}
// isOnMainThread always returns true in server mode.
func (h *serverApp) isOnMainThread() bool {
// In server mode, there's no main thread concept
return true
}
// isDarkMode returns false in server mode.
func (h *serverApp) isDarkMode() bool {
return false
}
// getAccentColor returns empty string in server mode.
func (h *serverApp) getAccentColor() string {
return ""
}
// logPlatformInfo logs platform info for server mode.
func (a *App) logPlatformInfo() {
a.info("Platform Info:", "mode", "server")
}
// platformEnvironment returns environment info for server mode.
func (a *App) platformEnvironment() map[string]any {
return map[string]any{
"mode": "server",
}
}
// fatalHandler sets up fatal error handling for server mode.
func fatalHandler(errFunc func(error)) {
// In server mode, fatal errors are handled via standard mechanisms
}
// newClipboardImpl creates a clipboard implementation for server mode.
func newClipboardImpl() clipboardImpl {
return &serverClipboard{}
}
// serverClipboard is a no-op clipboard for server mode.
type serverClipboard struct{}
func (c *serverClipboard) setText(text string) bool {
return false
}
func (c *serverClipboard) text() (string, bool) {
return "", false
}
// newDialogImpl creates a dialog implementation for server mode.
func newDialogImpl(d *MessageDialog) messageDialogImpl {
return &serverDialog{}
}
// serverDialog is a no-op dialog for server mode.
type serverDialog struct{}
func (d *serverDialog) show() {
// No-op in server mode
}
// newOpenFileDialogImpl creates an open file dialog implementation for server mode.
func newOpenFileDialogImpl(d *OpenFileDialogStruct) openFileDialogImpl {
return &serverOpenFileDialog{}
}
// serverOpenFileDialog is a no-op open file dialog for server mode.
type serverOpenFileDialog struct{}
func (d *serverOpenFileDialog) show() (chan string, error) {
ch := make(chan string, 1)
close(ch)
return ch, errors.New("file dialogs not available in server mode")
}
// newSaveFileDialogImpl creates a save file dialog implementation for server mode.
func newSaveFileDialogImpl(d *SaveFileDialogStruct) saveFileDialogImpl {
return &serverSaveFileDialog{}
}
// serverSaveFileDialog is a no-op save file dialog for server mode.
type serverSaveFileDialog struct{}
func (d *serverSaveFileDialog) show() (chan string, error) {
ch := make(chan string, 1)
close(ch)
return ch, errors.New("file dialogs not available in server mode")
}
// newMenuImpl creates a menu implementation for server mode.
func newMenuImpl(menu *Menu) menuImpl {
return &serverMenu{}
}
// serverMenu is a no-op menu for server mode.
type serverMenu struct{}
func (m *serverMenu) update() {
// No-op in server mode
}
// newPlatformLock creates a platform-specific single instance lock for server mode.
func newPlatformLock(manager *singleInstanceManager) (platformLock, error) {
return &serverLock{}, nil
}
// serverLock is a basic lock for server mode.
type serverLock struct{}
func (l *serverLock) acquire(uniqueID string) error {
return nil
}
func (l *serverLock) release() {
// No-op in server mode
}
func (l *serverLock) notify(data string) error {
return errors.New("single instance not supported in server mode")
}
// newSystemTrayImpl creates a system tray implementation for server mode.
func newSystemTrayImpl(s *SystemTray) systemTrayImpl {
return &serverSystemTray{parent: s}
}
// serverSystemTray is a no-op system tray for server mode.
type serverSystemTray struct {
parent *SystemTray
}
func (t *serverSystemTray) setLabel(label string) {}
func (t *serverSystemTray) setTooltip(tooltip string) {}
func (t *serverSystemTray) run() {}
func (t *serverSystemTray) setIcon(icon []byte) {}
func (t *serverSystemTray) setMenu(menu *Menu) {}
func (t *serverSystemTray) setIconPosition(pos IconPosition) {}
func (t *serverSystemTray) setTemplateIcon(icon []byte) {}
func (t *serverSystemTray) destroy() {}
func (t *serverSystemTray) setDarkModeIcon(icon []byte) {}
func (t *serverSystemTray) bounds() (*Rect, error) { return nil, errors.New("system tray not available in server mode") }
func (t *serverSystemTray) getScreen() (*Screen, error) { return nil, errors.New("system tray not available in server mode") }
func (t *serverSystemTray) positionWindow(w Window, o int) error { return errors.New("system tray not available in server mode") }
func (t *serverSystemTray) openMenu() {}
func (t *serverSystemTray) Show() {}
func (t *serverSystemTray) Hide() {}
// newWindowImpl creates a webview window implementation for server mode.
func newWindowImpl(parent *WebviewWindow) *serverWebviewWindow {
return &serverWebviewWindow{parent: parent}
}
// serverWebviewWindow is a no-op webview window for server mode.
type serverWebviewWindow struct {
parent *WebviewWindow
}
// All webviewWindowImpl methods as no-ops for server mode
func (w *serverWebviewWindow) setTitle(title string) {}
func (w *serverWebviewWindow) setSize(width, height int) {}
func (w *serverWebviewWindow) setAlwaysOnTop(alwaysOnTop bool) {}
func (w *serverWebviewWindow) setURL(url string) {}
func (w *serverWebviewWindow) setResizable(resizable bool) {}
func (w *serverWebviewWindow) setMinSize(width, height int) {}
func (w *serverWebviewWindow) setMaxSize(width, height int) {}
func (w *serverWebviewWindow) execJS(js string) {}
func (w *serverWebviewWindow) setBackgroundColour(color RGBA) {}
func (w *serverWebviewWindow) run() {}
func (w *serverWebviewWindow) center() {}
func (w *serverWebviewWindow) size() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) width() int { return 0 }
func (w *serverWebviewWindow) height() int { return 0 }
func (w *serverWebviewWindow) destroy() {}
func (w *serverWebviewWindow) reload() {}
func (w *serverWebviewWindow) forceReload() {}
func (w *serverWebviewWindow) openDevTools() {}
func (w *serverWebviewWindow) zoomReset() {}
func (w *serverWebviewWindow) zoomIn() {}
func (w *serverWebviewWindow) zoomOut() {}
func (w *serverWebviewWindow) getZoom() float64 { return 1.0 }
func (w *serverWebviewWindow) setZoom(zoom float64) {}
func (w *serverWebviewWindow) close() {}
func (w *serverWebviewWindow) zoom() {}
func (w *serverWebviewWindow) setHTML(html string) {}
func (w *serverWebviewWindow) on(eventID uint) {}
func (w *serverWebviewWindow) minimise() {}
func (w *serverWebviewWindow) unminimise() {}
func (w *serverWebviewWindow) maximise() {}
func (w *serverWebviewWindow) unmaximise() {}
func (w *serverWebviewWindow) fullscreen() {}
func (w *serverWebviewWindow) unfullscreen() {}
func (w *serverWebviewWindow) isMinimised() bool { return false }
func (w *serverWebviewWindow) isMaximised() bool { return false }
func (w *serverWebviewWindow) isFullscreen() bool { return false }
func (w *serverWebviewWindow) isNormal() bool { return true }
func (w *serverWebviewWindow) isVisible() bool { return false }
func (w *serverWebviewWindow) isFocused() bool { return false }
func (w *serverWebviewWindow) focus() {}
func (w *serverWebviewWindow) show() {}
func (w *serverWebviewWindow) hide() {}
func (w *serverWebviewWindow) getScreen() (*Screen, error) { return nil, errors.New("screens not available in server mode") }
func (w *serverWebviewWindow) setFrameless(frameless bool) {}
func (w *serverWebviewWindow) openContextMenu(menu *Menu, data *ContextMenuData) {}
func (w *serverWebviewWindow) nativeWindow() unsafe.Pointer { return nil }
func (w *serverWebviewWindow) startDrag() error { return errors.New("drag not available in server mode") }
func (w *serverWebviewWindow) startResize(border string) error { return errors.New("resize not available in server mode") }
func (w *serverWebviewWindow) print() error { return errors.New("print not available in server mode") }
func (w *serverWebviewWindow) setEnabled(enabled bool) {}
func (w *serverWebviewWindow) physicalBounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setPhysicalBounds(bounds Rect) {}
func (w *serverWebviewWindow) bounds() Rect { return Rect{} }
func (w *serverWebviewWindow) setBounds(bounds Rect) {}
func (w *serverWebviewWindow) position() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setPosition(x int, y int) {}
func (w *serverWebviewWindow) relativePosition() (int, int) { return 0, 0 }
func (w *serverWebviewWindow) setRelativePosition(x int, y int) {}
func (w *serverWebviewWindow) flash(enabled bool) {}
func (w *serverWebviewWindow) handleKeyEvent(acceleratorString string) {}
func (w *serverWebviewWindow) getBorderSizes() *LRTB { return &LRTB{} }
func (w *serverWebviewWindow) setMinimiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setMaximiseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) setCloseButtonState(state ButtonState) {}
func (w *serverWebviewWindow) isIgnoreMouseEvents() bool { return false }
func (w *serverWebviewWindow) setIgnoreMouseEvents(ignore bool) {}
func (w *serverWebviewWindow) cut() {}
func (w *serverWebviewWindow) copy() {}
func (w *serverWebviewWindow) paste() {}
func (w *serverWebviewWindow) undo() {}
func (w *serverWebviewWindow) delete() {}
func (w *serverWebviewWindow) selectAll() {}
func (w *serverWebviewWindow) redo() {}
func (w *serverWebviewWindow) showMenuBar() {}
func (w *serverWebviewWindow) hideMenuBar() {}
func (w *serverWebviewWindow) toggleMenuBar() {}
func (w *serverWebviewWindow) setMenu(menu *Menu) {}
func (w *serverWebviewWindow) snapAssist() {}
func (w *serverWebviewWindow) setContentProtection(enabled bool) {}