feat(v3): add server mode for headless HTTP deployment#4903
Conversation
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>
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>
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. WalkthroughThis pull request introduces experimental server mode for Wails v3, enabling applications to run as pure HTTP servers without native GUI dependencies. It includes comprehensive documentation, build configurations, a complete example implementation, and platform-specific adjustments to gate desktop-only code paths. Key additions involve HTTP server setup, WebSocket event broadcasting, browser window abstractions, and supporting infrastructure across build tasks and runtime assets. Changes
Sequence Diagram(s)sequenceDiagram
participant Browser as Browser Client
participant Server as HTTP Server
participant EventMgr as Event Manager
participant BackendApp as Backend Application
rect rgba(100, 200, 100, 0.5)
Note over Browser,BackendApp: Server Mode Initialization
Server->>Server: Start HTTP Server (port 8080)
Server->>Server: Register WebSocket Broadcaster
Server->>Server: Setup Asset Routes (/health, /wails/events)
Server->>EventMgr: Add broadcaster as listener
end
rect rgba(100, 150, 200, 0.5)
Note over Browser,BackendApp: Client Connection Flow
Browser->>Server: HTTP GET /
Server->>Browser: Serve frontend assets
Browser->>Server: Establish WebSocket /wails/events
Server->>Server: Upgrade to WebSocket
Server->>Server: Create BrowserWindow(clientID)
Server->>Browser: WebSocket connection established
end
rect rgba(200, 150, 100, 0.5)
Note over Browser,BackendApp: Event Broadcasting
BackendApp->>EventMgr: Emit event (e.g., "server-tick")
EventMgr->>Server: Notify listeners
Server->>Browser: Broadcast via WebSocket
Browser->>Browser: Handle event in JavaScript
end
rect rgba(150, 100, 200, 0.5)
Note over Browser,BackendApp: Graceful Shutdown
BackendApp->>Server: Receive shutdown signal
Server->>Browser: Close WebSocket
Server->>EventMgr: Cleanup
Server->>Server: Stop HTTP listener
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes This PR introduces a substantial new feature with 80+ affected files spanning multiple concerns: comprehensive server mode implementation (~554 lines in core file), platform build tag updates across 20+ files, go.mod dependency updates across 10+ examples, build configuration additions, documentation, and example code. While many changes are repetitive (build tag updates), the core implementation is logic-dense and introduces new public types (ServerOptions, TLSOptions, BrowserWindow, WebSocketBroadcaster) requiring careful validation. The heterogeneous nature of changes—spanning runtime, configuration, documentation, and platform-specific code—demands separate reasoning for different cohorts. Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Deploying wails with
|
| Latest commit: |
7c4d735
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d10940cc.wails.pages.dev |
| Branch Preview URL: | https://feature-headless-server-mode.wails.pages.dev |
There was a problem hiding this comment.
Actionable comments posted: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
v3/pkg/application/application_android.go (2)
516-522: Memory leak:C.CStringallocations are never freed.The
C.CStringfunction allocates memory that must be explicitly freed withC.free. These allocations leak memory on every JNI call.🔧 Suggested fix
//export Java_com_wails_app_WailsBridge_nativeHandleMessage func Java_com_wails_app_WailsBridge_nativeHandleMessage(env *C.JNIEnv, obj C.jobject, jmessage C.jstring) C.jstring { // Convert Java string to Go string cMessage := C.jstringToC(env, jmessage) defer C.releaseJString(env, jmessage, cMessage) goMessage := C.GoString(cMessage) androidLogf("debug", "🤖 [JNI] nativeHandleMessage: %s", goMessage) globalAppLock.RLock() app := globalApp globalAppLock.RUnlock() if app == nil { errorResponse := `{"error":"App not initialized"}` - return C.createJString(env, C.CString(errorResponse)) + cErr := C.CString(errorResponse) + defer C.free(unsafe.Pointer(cErr)) + return C.createJString(env, cErr) } // Parse and handle the message response := handleMessageForAndroid(app, goMessage) - return C.createJString(env, C.CString(response)) + cResp := C.CString(response) + defer C.free(unsafe.Pointer(cResp)) + return C.createJString(env, cResp) }
526-533: Memory leak:C.CStringallocation not freed innativeGetAssetMimeType.Same issue as above — the
C.CString(mimeType)allocation leaks memory.🔧 Suggested fix
//export Java_com_wails_app_WailsBridge_nativeGetAssetMimeType func Java_com_wails_app_WailsBridge_nativeGetAssetMimeType(env *C.JNIEnv, obj C.jobject, jpath C.jstring) C.jstring { // Convert Java string to Go string cPath := C.jstringToC(env, jpath) defer C.releaseJString(env, jpath, cPath) goPath := C.GoString(cPath) mimeType := getMimeTypeForPath(goPath) - return C.createJString(env, C.CString(mimeType)) + cMime := C.CString(mimeType) + defer C.free(unsafe.Pointer(cMime)) + return C.createJString(env, cMime) }
🤖 Fix all issues with AI agents
In `@docs/src/content/docs/guides/server-build.mdx`:
- Around line 280-286: The healthcheck block uses wget which isn't present in
distroless images; update the healthcheck "test" entry to use a tool available
in minimal images (e.g., curl with --fail) or document that a healthcheck binary
must be added to the Custom Dockerfile. Specifically, change the healthcheck
"test" command in the healthcheck block (the "test" array) to use curl (or an
HTTP-native healthcheck) or add a note next to the healthcheck example
explaining that distroless images require embedding a client binary for HTTP
probes.
- Around line 272-286: Remove the deprecated top-level "version: '3.8'" entry
from the compose snippet so the compose file uses the modern schema; in the
Docker Compose YAML shown (the service named "app" with
ports/environment/healthcheck) delete the version line and leave the services
block (app, ports, environment, healthcheck) intact to avoid deprecation
warnings.
In `@v3/examples/gin-routing/go.mod`:
- Line 1: The module declaration currently reads "module gin-example" which
doesn't match the directory; update the module statement in the go.mod (the
module declaration symbol) to reflect the actual module path for this package
(e.g., the repository path that includes the v3/examples/gin-routing segment or
a module name matching the directory) so the module name aligns with the folder
structure and import paths.
In `@v3/examples/gin-service/go.mod`:
- Line 74: The replace directive currently targets v3.0.0-alpha.9 while the
module requires v3.0.0-alpha.62, so update the replace directive in go.mod (the
line starting with "replace github.com/wailsapp/wails/v3") to reference
v3.0.0-alpha.62 so the replace will apply to the required version.
In `@v3/examples/ios/go.mod`:
- Line 1: The go.mod module name is a placeholder ("changeme"); update the
module declaration in the go.mod file so it reflects the real project (for
example "ios-example" or the intended module name) by replacing the current
module token on the first line; ensure the new module name follows Go module
naming conventions (a valid module path) so imports and builds resolve
correctly.
In `@v3/examples/server/Dockerfile`:
- Around line 42-43: The COPY instruction copying frontend assets from the
builder stage uses a hardcoded path (/app/examples/server/frontend/dist)
inconsistent with the conditional build contexts; update the Dockerfile so the
assets are placed/copied from a consistent location regardless of build
context—either add a conditional COPY that checks both possible builder paths or
change the build stage to always output frontend assets to a fixed directory
(e.g., /app/frontend/dist) and update the COPY --from=builder line to use that
fixed path; reference the builder stage name "builder" and the existing COPY
--from=builder /app/examples/server/frontend/dist /frontend/dist when making the
change.
In `@v3/examples/server/README.md`:
- Line 38: Replace the bare URL "http://localhost:8080" in the README sentence
("Then open http://localhost:8080 in your browser.") with an autolink or
Markdown link to satisfy MD034; for example use <http://localhost:8080> or a
Markdown link such as [http://localhost:8080](http://localhost:8080) or a
descriptive label like [Open the app](http://localhost:8080).
In `@v3/internal/commands/build_assets/Taskfile.tmpl.yml`:
- Around line 147-177: The run:docker task hardcodes the container's internal
port to 8080 in the `docker run` command, which will break if the app is
configured with a different port via WAILS_SERVER_PORT or ServerOptions.Port;
update the task to make the container internal port configurable (e.g. add an
INTERNAL_PORT/CONTAINER_PORT var and use it in the `docker run` command inside
the run:docker `cmds`), ensure the build:docker task and vars (TAG, PORT) remain
consistent, and add a brief note in the task description that the internal
container port must match WAILS_SERVER_PORT/ServerOptions.Port when overriding
the port.
In `@v3/pkg/application/application_server_test.go`:
- Around line 133-137: Remove the accidental terminal output embedded in the
Assets.AssetOptions block: delete the stray text beginning with "task:
[build:docker] docker build ..." so that the struct literal only contains valid
fields (e.g., keep the Handler: http.HandlerFunc(...) entry). Specifically edit
the AssetOptions entry (Assets) in the test so the corrupt token is removed and
the Handler field (http.HandlerFunc func) is the next valid field in the struct,
ensuring the test compiles.
In `@v3/pkg/application/application_server.go`:
- Around line 301-304: Update the comment above serverApp.getScreens to reflect
actual behavior: it returns nil and an error rather than an empty slice; change
the comment from "getScreens returns empty slice in server mode." to something
like "getScreens returns nil and an error in server mode (screen information not
available)." Ensure the comment mentions serverApp.getScreens and the nil,error
return semantics.
In `@v3/pkg/application/menu_linux.go`:
- Line 1: Update the build constraints on the platform-specific menu
implementations to match menu_linux.go by adding the "!server" exclusion: add
the same "//go:build <os> && !android && !server" style tag to both
menu_windows.go and menu_darwin.go so they are excluded in server builds; this
keeps them consistent with application_windows.go/application_darwin.go and the
server stubs (serverMenu, serverSystemTray, serverWebviewWindow).
In `@v3/tests/window-visibility-test/go.mod`:
- Line 3: The module declares "go 1.25" in go.mod but the CI only tests older
toolchains; either update the CI workflow's Go versions matrix (the "go-version"
entries used in the build-and-test-v3.yml workflow) to include "1.25" so the
tested toolchain matches the go directive, or change the go.mod line from "go
1.25" back to "go 1.24" so it matches the existing CI matrix; make the change
consistently (either CI matrix or go.mod) and run the CI matrix to verify tests
pass.
♻️ Duplicate comments (1)
v3/examples/ios/go.mod (1)
3-3: Same Go 1.25 version concern as other modules.See comment on gin-example/go.mod regarding Go version verification.
🧹 Nitpick comments (14)
v3/UNRELEASED_CHANGELOG.md (1)
20-20: Optional: include PR reference in changelog entry.
Helps traceability in the release notes.📝 Suggested tweak
-- Add experimental server mode for headless/web deployments (`-tags server`). Enables running Wails apps as HTTP servers without native GUI dependencies. Build with `wails3 task build:server`. See `examples/server` for details. +- Add experimental server mode for headless/web deployments (`-tags server`). Enables running Wails apps as HTTP servers without native GUI dependencies. Build with `wails3 task build:server`. See `examples/server` for details. (`#4903`)v3/internal/commands/build_assets/docker/Dockerfile.server (2)
18-22: Addgo mod downloadbefore build for better Docker layer caching.Running
go mod tidyensures module consistency, but adding an explicitgo mod downloadstep allows Docker to cache dependencies separately from source changes, improving rebuild times.Suggested improvement
# Remove local replace directive if present (for production builds) RUN sed -i '/^replace/d' go.mod || true -# Download dependencies +# Download dependencies (separate layer for caching) RUN go mod tidy +RUN go mod download # Build the server binary RUN go build -tags server -ldflags="-s -w" -o server .
30-31: Hardcoded frontend path assumes specific project structure.The path
/app/frontend/distassumes a standard Vite/frontend build output location. If a project uses a different frontend structure or output directory, this COPY will fail. Consider documenting this assumption or making it configurable via build args.Optional: Add ARG for flexibility
+ARG FRONTEND_DIST=frontend/dist + # Copy frontend assets -COPY --from=builder /app/frontend/dist /frontend/dist +COPY --from=builder /app/${FRONTEND_DIST} /frontend/distv3/internal/runtime/desktop/@wailsio/runtime/src/index.ts (1)
88-98: Promise resolves before script execution completes.The function returns a Promise that resolves after the HEAD check and script tag injection, but before the script has actually loaded and executed. If callers need to wait for the script to be ready, they'll get a false positive.
For the current use case (line 101), this is acceptable since
custom.jsis fire-and-forget. However, since this is a public export, consider documenting this behavior or optionally waiting for script load.Optional: Wait for script load
export function loadOptionalScript(url: string): Promise<void> { return fetch(url, { method: 'HEAD' }) .then(response => { if (response.ok) { - const script = document.createElement('script'); - script.src = url; - document.head.appendChild(script); + return new Promise<void>((resolve) => { + const script = document.createElement('script'); + script.src = url; + script.onload = () => resolve(); + script.onerror = () => resolve(); // Still resolve - script is optional + document.head.appendChild(script); + }); } }) .catch(() => {}); // Silently ignore - script is optional }v3/pkg/application/application_options.go (1)
137-165: Consider adding TLS configuration options for production hardening.The
ServerOptionsstruct provides good defaults and documentation. For production server deployments, consider extendingTLSOptionsto support:
MinVersion(e.g., TLS 1.2 minimum)- Custom
*tls.Configfor advanced scenariosThis is optional since users can wrap/proxy the server, but could be valuable for standalone deployments.
💡 Optional enhancement for TLSOptions
// TLSOptions configures HTTPS for the headless server. type TLSOptions struct { // CertFile is the path to the TLS certificate file. CertFile string // KeyFile is the path to the TLS private key file. KeyFile string // MinVersion is the minimum TLS version (e.g., tls.VersionTLS12). // Default: tls.VersionTLS12 MinVersion uint16 }v3/Taskfile.yaml (1)
395-401: Test filter may be overly restrictive.The
-run TestServerModefilter only runs tests matching that specific pattern. As the server mode codebase grows, you may want to run all server-tagged tests:💡 Consider broader test coverage
test:server: summary: Run server mode unit tests dir: 'pkg/application' cmds: - echo "Running server mode tests..." - - go test -tags server -v -run TestServerMode . + - go test -tags server -v . - echo "✅ Server mode tests passed"Alternatively, if you want to keep tests isolated, consider using a naming convention like
TestServer*for all server-related tests.v3/examples/server/Taskfile.yml (1)
23-30: Thesourcesdirective serves no purpose here.The
sourcesdirective is used to determine if a task should re-run based on file changes. However, with only a precondition and no commands, specifyingsourceshas no effect. Consider removing it for clarity.Suggested simplification
build:frontend: summary: Ensures frontend assets exist (static HTML, no build needed) dir: frontend - sources: - - dist/index.html preconditions: - sh: test -f dist/index.html msg: "frontend/dist/index.html not found"v3/pkg/application/browser_window.go (1)
66-68: Consider returning meaningful errors for unsupported operations.Methods like
GetScreen()return(nil, nil)andPrint()returnsnil. In server mode, these operations are genuinely unsupported. Returning a sentinel error (e.g.,ErrNotSupported) would help callers distinguish "not supported" from "succeeded with no result."Example pattern
var ErrNotSupportedInServerMode = errors.New("operation not supported in server mode") func (b *BrowserWindow) GetScreen() (*Screen, error) { return nil, ErrNotSupportedInServerMode } func (b *BrowserWindow) Print() error { return ErrNotSupportedInServerMode }v3/examples/server/main.go (2)
57-60: Hardcoded URLs may be misleading when env vars override configuration.The log messages display
localhost:8080but the actual host/port can be overridden viaWAILS_SERVER_HOSTandWAILS_SERVER_PORTenvironment variables. Consider reading the effective values or noting this caveat.
68-77: Goroutine lacks graceful shutdown.The ticker goroutine runs indefinitely and doesn't stop when the application shuts down. While this is acceptable for an example, consider using a context or shutdown channel for clean termination.
♻️ Suggested improvement
+ ctx, cancel := context.WithCancel(context.Background()) + app.OnShutdown(func() { + cancel() + }) + // Emit periodic events to test WebSocket broadcasting go func() { time.Sleep(2 * time.Second) // Wait for server to start ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() - for { - <-ticker.C - app.Event.Emit("server-tick", time.Now().Format(time.RFC3339)) - log.Println("Emitted server-tick event") + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + app.Event.Emit("server-tick", time.Now().Format(time.RFC3339)) + log.Println("Emitted server-tick event") + } } }()Note: You'll need to add
"context"to the imports.v3/pkg/application/application_server_test.go (1)
45-46: Consider using a retry loop instead of fixed sleep for server startup.Using
time.Sleep(200 * time.Millisecond)to wait for server startup can be flaky in CI environments under load. A retry loop with backoff would be more robust.v3/pkg/application/websocket_server.go (3)
103-107: Minor race condition:len(b.clients)accessed after releasing lock.The log statement accesses
len(b.clients)after the mutex is released, which could show a stale count if another goroutine modifies the map concurrently. This is only a cosmetic issue for logging.♻️ Suggested fix
func (b *WebSocketBroadcaster) register(conn *websocket.Conn, window *BrowserWindow) { client := &clientInfo{ conn: conn, window: window, } b.mu.Lock() b.clients[conn] = client + clientCount := len(b.clients) b.mu.Unlock() - b.app.info("WebSocket client connected", "id", window.Name(), "clients", len(b.clients)) + b.app.info("WebSocket client connected", "id", window.Name(), "clients", clientCount) }
109-122: Same race condition inunregisterfor logging.Similar to
register,len(b.clients)is accessed after the mutex is released.♻️ Suggested fix
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) } + clientCount := len(b.clients) b.mu.Unlock() conn.Close(websocket.StatusNormalClosure, "") if client != nil { - b.app.info("WebSocket client disconnected", "id", client.window.Name(), "clients", len(b.clients)) + b.app.info("WebSocket client disconnected", "id", client.window.Name(), "clients", clientCount) } }
62-65: Security note:InsecureSkipVerify: trueallows any origin.This is appropriate for server mode where browser clients connect from various origins, but consider adding a configuration option in
ServerOptionsto restrict allowed origins for production deployments that need stricter CORS control.
- 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>
- 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>
|
* 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>


Summary
-tags server) for running Wails apps as pure HTTP servers without GUI dependencies/healthDockerfile.servertemplate and Taskfile tasksWAILS_SERVER_HOST,WAILS_SERVER_PORTFeatures
wails3 task build:server,wails3 task run:serverwails3 task build:docker,wails3 task run:dockerTest plan
go test -tags server)Documentation
Added
docs/guides/server-build.mdxwith full usage guide.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Chores
✏️ Tip: You can customize this high-level summary in your review settings.