Files
windmill/frontend/src/lib/components/instanceSettings/WsConnectivityTest.svelte
T
Ruben FiszelandClaude Opus 4.6 372023e995 feat: add ws_base_url instance setting for WebSocket URL override (#8405)
* feat: add ws_base_url instance setting to override WebSocket base URL

Allow deployments behind reverse proxies to route WebSocket traffic
(LSP, debugger, multiplayer) to a different host/port than the main
frontend via a new instance setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: move ws_base_url to Advanced section with toggle and connectivity test

- Move setting from Core to Advanced > WebSocket section
- Render as toggle "Custom websocket base url from frontend to
  multiplayer/lsp/debugger" with conditional URL text field
- Add Test connectivity button (always visible) that checks HTTP health
  and WebSocket ping for all three services (LSP, Multiplayer, Debugger)
- Add /ws/ping and /ws/health endpoints to LSP service
- Add /ws_mp/health HTTP and __ping__ WS handlers to multiplayer service
- Add /ping WS handler to debugger service
- Add CORS headers to health endpoints for cross-origin testing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: toggle enabled check and testWs promise resolution

- Fix enabled derived to check only for null (not empty string),
  otherwise the toggle never turns on since toggleEnabled sets ''
- Fix testWs onclose handler to resolve(false) so the promise
  doesn't hang if the server closes without sending a message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: make connectivity test work with existing services

- HTTP test: accept plain text "ok"/"okay" (old services) in addition
  to JSON {"status": "ok"} (new services), reject HTML (SPA fallback)
- WS test: resolve on onopen (connection established) instead of
  waiting for a specific pong message, so the test works even with
  services that don't have the new /ping handler yet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 12:48:59 +00:00

200 lines
5.2 KiB
Svelte

<script lang="ts">
import { BROWSER } from 'esm-env'
import Toggle from '../Toggle.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { Button } from '../common'
import { CheckCircle2, XCircle, Loader2 } from 'lucide-svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
}
let { values }: Props = $props()
let enabled = $derived($values['ws_base_url'] != null)
interface ServiceResult {
name: string
http: 'pending' | 'ok' | 'fail'
ws: 'pending' | 'ok' | 'fail'
error?: string
}
let testing = $state(false)
let results = $state<ServiceResult[]>([])
const SERVICES = [
{ name: 'LSP', httpPath: '/ws/health', wsPath: '/ws/ping' },
{ name: 'Multiplayer', httpPath: '/ws_mp/health', wsPath: '/ws_mp/__ping__' },
{ name: 'Debugger', httpPath: '/ws_debug/health', wsPath: '/ws_debug/ping' }
]
function buildTestUrl(path: string): string {
const override = $values['ws_base_url']
if (override) {
const base = override.endsWith('/') ? override.slice(0, -1) : override
return `${base}${path}`
}
if (!BROWSER) return `ws://localhost:3003${path}`
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${wsProtocol}://${window.location.host}${path}`
}
function wsToHttp(url: string): string {
return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:')
}
async function testHttp(wsUrl: string): Promise<boolean> {
try {
const httpUrl = wsToHttp(wsUrl)
const response = await fetch(httpUrl, { signal: AbortSignal.timeout(5000) })
if (!response.ok) return false
const text = await response.text()
// Accept plain text "ok"/"okay" (old services) or JSON {"status": "ok"} (new services)
if (text === 'ok' || text === 'okay') return true
try {
const data = JSON.parse(text)
return data.status === 'ok'
} catch {
// Non-JSON, non-"ok" response (e.g. HTML from SPA fallback)
return false
}
} catch {
return false
}
}
function testWs(url: string): Promise<boolean> {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url)
const timeout = setTimeout(() => {
ws.close()
resolve(false)
}, 5000)
ws.onopen = () => {
// Connection established — service is reachable
// If a pong arrives via onmessage, great; otherwise onclose resolves
clearTimeout(timeout)
ws.close()
resolve(true)
}
ws.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
ws.onclose = () => {
clearTimeout(timeout)
resolve(false)
}
} catch {
resolve(false)
}
})
}
async function runTests() {
testing = true
results = SERVICES.map((s) => ({
name: s.name,
http: 'pending' as const,
ws: 'pending' as const
}))
await Promise.all(
SERVICES.map(async (service, i) => {
const wsUrl = buildTestUrl(service.wsPath)
const healthUrl = buildTestUrl(service.httpPath)
// HTTP test first
const httpOk = await testHttp(healthUrl)
results[i] = { ...results[i], http: httpOk ? 'ok' : 'fail' }
// WebSocket test
const wsOk = await testWs(wsUrl)
results[i] = { ...results[i], ws: wsOk ? 'ok' : 'fail' }
})
)
testing = false
}
function toggleEnabled() {
if (enabled) {
$values['ws_base_url'] = null
} else {
$values['ws_base_url'] = ''
}
}
</script>
<div class="flex flex-col gap-3">
<div>
<Button variant="default" unifiedSize="sm" onclick={runTests} disabled={testing}>
{#if testing}
Test connectivity...
{:else}
Test connectivity
{/if}
</Button>
</div>
{#if results.length > 0}
<div class="flex flex-col gap-1.5 text-xs">
{#each results as result (result.name)}
<div class="flex items-center gap-3">
<span class="w-20 font-medium text-primary">{result.name}</span>
<span class="flex items-center gap-1">
{#if result.http === 'pending'}
<Loader2 size={14} class="animate-spin text-tertiary" />
{:else if result.http === 'ok'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-500" />
{/if}
<span class="text-secondary">HTTP</span>
</span>
<span class="flex items-center gap-1">
{#if result.ws === 'pending'}
<Loader2 size={14} class="animate-spin text-tertiary" />
{:else if result.ws === 'ok'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-500" />
{/if}
<span class="text-secondary">WebSocket</span>
</span>
</div>
{/each}
</div>
{/if}
<div>
<Toggle
size="xs"
options={{ right: 'Custom websocket base url from frontend to multiplayer/lsp/debugger' }}
checked={enabled}
on:change={toggleEnabled}
/>
</div>
{#if enabled}
<TextInput
inputProps={{
type: 'text',
id: 'ws_base_url',
placeholder: 'wss://ws.example.com'
}}
bind:value={$values['ws_base_url']}
class="max-w-lg"
/>
{@const val = $values['ws_base_url']}
{#if val && (!val.startsWith('ws') || !val.includes('://') || val.endsWith('/') || val.endsWith(' '))}
<span class="text-red-600 dark:text-red-400 text-xs">
Must start with ws:// or wss:// and not end with / or a space
</span>
{/if}
{/if}
</div>