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>
This commit is contained in:
Ruben Fiszel
2026-03-17 12:48:59 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 08215c708b
commit 372023e995
16 changed files with 349 additions and 64 deletions
+2
View File
@@ -45,6 +45,7 @@ use windmill_common::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -526,6 +527,7 @@ pub async fn get_global_setting(
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
&& key != APP_WORKSPACED_ROUTE_SETTING
&& key != WS_BASE_URL_SETTING
{
require_super_admin(&db, &authed.email).await?;
}
@@ -2,6 +2,7 @@ pub const CUSTOM_TAGS_SETTING: &str = "custom_tags";
pub const DEFAULT_TAGS_PER_WORKSPACE_SETTING: &str = "default_tags_per_workspace";
pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
pub const BASE_URL_SETTING: &str = "base_url";
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
pub const OAUTH_SETTING: &str = "oauths";
pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs";
pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days";
@@ -245,6 +245,8 @@ pub struct GlobalSettings {
// String settings
#[serde(skip_serializing_if = "Option::is_none")]
pub ws_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub_base_url: Option<String>,
+12 -1
View File
@@ -1076,10 +1076,14 @@ const server = Bun.serve({
if (path === '/health') {
return new Response(JSON.stringify({
status: 'ok',
service: 'debugger',
endpoints: ['/python', '/typescript', '/bun'],
nsjail: config.nsjail.enabled
}), {
headers: { 'Content-Type': 'application/json' }
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
})
}
@@ -1096,6 +1100,13 @@ const server = Bun.serve({
path = path.slice('/ws_debug'.length)
}
// Handle ping test — respond and close immediately
if (path === '/ping') {
ws.send(JSON.stringify({ type: 'pong', service: 'debugger' }))
ws.close()
return
}
logger.info(`New client connected: ${path}`)
// Create appropriate session based on path
+15 -30
View File
@@ -7,6 +7,7 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { buildWsUrl } from '$lib/wsUrl'
import { sendUserToast } from '$lib/toast'
import { createEventDispatcher, onDestroy, onMount, untrack } from 'svelte'
@@ -995,7 +996,6 @@
}
}
const wsProtocol = BROWSER && window.location.protocol == 'https:' ? 'wss' : 'ws'
const hostname = getHostname()
let encodedImportMap = ''
@@ -1023,7 +1023,7 @@
}
encodedImportMap = 'data:text/plain;base64,' + btoa(JSON.stringify(importMap))
await connectToLanguageServer(
`${wsProtocol}://${window.location.host}/ws/deno`,
buildWsUrl('/ws/deno'),
'deno',
{
certificateStores: null,
@@ -1065,7 +1065,7 @@
)
} else if (lang === 'python') {
await connectToLanguageServer(
`${wsProtocol}://${window.location.host}/ws/pyright`,
buildWsUrl('/ws/pyright'),
'pyright',
{},
(params, token, next) => {
@@ -1095,15 +1095,10 @@
}
)
connectToLanguageServer(
`${wsProtocol}://${window.location.host}/ws/ruff`,
'ruff',
{},
undefined
)
connectToLanguageServer(buildWsUrl('/ws/ruff'), 'ruff', {}, undefined)
} else if (lang === 'go') {
connectToLanguageServer(
`${wsProtocol}://${window.location.host}/ws/go`,
buildWsUrl('/ws/go'),
'go',
{
'build.allowImplicitNetworkAccess': true
@@ -1112,7 +1107,7 @@
)
} else if (lang === 'shell') {
connectToLanguageServer(
`${wsProtocol}://${window.location.host}/ws/diagnostic`,
buildWsUrl('/ws/diagnostic'),
'shellcheck',
{
linters: {
@@ -1912,15 +1907,12 @@
})
})
let isTsWorkerInitialized = resource(
[() => lang, () => initialized],
async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
await waitForWorkerInitialization(uri)
return true
}
)
let isTsWorkerInitialized = resource([() => lang, () => initialized], async () => {
if (lang !== 'typescript' || !initialized) return false
// Use the stable model URI (computed once at mount), not filePath which changes on rename
await waitForWorkerInitialization(uri)
return true
})
// Update SQL query type information in the TypeScript worker
// This enables TypeScript to show proper types for SQL template literals
@@ -1939,16 +1931,9 @@
updateSqlQueriesInWorker(uri, $state.snapshot(preparedAssetsSqlQueries))
}, 250)
watch(
[
() => preparedAssetsSqlQueries,
() => lang,
() => isTsWorkerInitialized.current
],
() => {
handleSqlTypingInTs()
}
)
watch([() => preparedAssetsSqlQueries, () => lang, () => isTsWorkerInitialized.current], () => {
handleSqlTypingInTs()
})
watch([() => customTsTypesData.current], setTypescriptCustomTypes)
</script>
@@ -23,6 +23,7 @@
import SmtpSettings from './instanceSettings/SmtpSettings.svelte'
import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte'
import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte'
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
@@ -285,12 +286,16 @@
{:else}
<SettingCard
label={setting.key === 'disable_stats'
? ($enterpriseLicense ? 'Minimal telemetry' : 'Disable telemetry')
: (setting.fieldType != 'smtp_connect' ? setting.label : undefined)}
? $enterpriseLicense
? 'Minimal telemetry'
: 'Disable telemetry'
: setting.fieldType != 'smtp_connect'
? setting.label
: undefined}
description={setting.key === 'disable_stats'
? ($enterpriseLicense
? $enterpriseLicense
? 'Reduces telemetry to only what is needed for license compliance (no job usage data).'
: 'Disables telemetry entirely.')
: 'Disables telemetry entirely.'
: setting.description}
ee_only={setting.ee_only}
tooltip={setting.tooltip}
@@ -719,6 +724,8 @@
<SecretBackendConfig {values} disabled={loading} />
{:else if setting.fieldType == 'github_enterprise_app'}
<GhesAppSettings {values} disabled={loading || !$enterpriseLicense} />
{:else if setting.fieldType == 'ws_connectivity'}
<WsConnectivityTest {values} />
{/if}
{#if hasError}
<span class="text-red-600 dark:text-red-400 text-xs">
@@ -1,6 +1,5 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { buildWsUrl } from '$lib/wsUrl'
import type { Schema, SupportedLanguage } from '$lib/common'
import {
type CompletedJob,
@@ -1079,10 +1078,8 @@
}
let yContentInit = ydoc.getText('content')
const wsProtocol = BROWSER && window.location.protocol == 'https:' ? 'wss' : 'ws'
wsProvider = new WebsocketProvider(
`${wsProtocol}://${window.location.host}/ws_mp/`,
buildWsUrl('/ws_mp/'),
$workspaceStore + '/' + (path ?? 'no-room-name'),
ydoc,
{ connect: false }
+3 -6
View File
@@ -66,6 +66,8 @@ export {
getDebugFileExtension
} from './debugUtils'
import { buildWsUrl } from '$lib/wsUrl'
/**
* Language to debug endpoint path mapping.
* Uses the unified DAP Debug Service with path-based routing.
@@ -92,12 +94,7 @@ export type DebugLanguage = keyof typeof DAP_ENDPOINT_PATHS
*/
export function getDebugServerUrl(language: DebugLanguage): string {
const path = DAP_ENDPOINT_PATHS[language] || DAP_ENDPOINT_PATHS.python3
if (typeof window === 'undefined') {
// SSR fallback
return `ws://localhost:3003${path}`
}
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
return `${wsProtocol}://${window.location.host}/ws_debug${path}`
return buildWsUrl(`/ws_debug${path}`)
}
/**
@@ -52,6 +52,7 @@ export interface Setting {
| 'otel_tracing_proxy'
| 'secret_backend'
| 'github_enterprise_app'
| 'ws_connectivity'
storage: SettingStorage
advancedToggle?: {
label: string
@@ -683,6 +684,23 @@ export const settings: Record<string, Setting[]> = {
return !!(v?.base_url && v?.app_id && v?.app_slug && v?.private_key)
}
}
],
WebSocket: [
{
label: 'WebSocket connectivity',
description:
'Test connectivity to multiplayer, LSP, and debugger WebSocket services. Enable custom URL override for deployments where WebSocket traffic routes to a different host.',
key: 'ws_base_url',
fieldType: 'ws_connectivity',
storage: 'setting',
requiresReloadOnChange: true,
isValid: (value: string | undefined) =>
!value ||
(value.startsWith('ws') &&
value.includes('://') &&
!value.endsWith('/') &&
!value.endsWith(' '))
}
]
}
@@ -815,6 +833,12 @@ export const instanceSettingsNavigationGroups = [
label: 'Secret Storage',
aiId: 'instance-settings-secret-storage',
aiDescription: 'Instance secret storage settings'
},
{
id: 'websocket',
label: 'WebSocket',
aiId: 'instance-settings-websocket',
aiDescription: 'WebSocket connectivity test and URL override'
}
]
}
@@ -835,7 +859,8 @@ export const tabToCategoryMap: Record<string, string> = {
object_storage: 'Object Storage',
jobs: 'Jobs',
private_hub: 'Private Hub',
github_enterprise_app: 'GitHub Enterprise App'
github_enterprise_app: 'GitHub Enterprise App',
websocket: 'WebSocket'
}
export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = {
@@ -865,7 +890,8 @@ export const categoryToTabMap: Record<string, string> = {
'Object Storage': 'object_storage',
Jobs: 'jobs',
'Private Hub': 'private_hub',
'GitHub Enterprise App': 'github_enterprise_app'
'GitHub Enterprise App': 'github_enterprise_app',
WebSocket: 'websocket'
}
export interface SearchableSettingItem {
@@ -0,0 +1,199 @@
<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>
@@ -1,16 +1,14 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { run } from 'svelte/legacy'
import { enterpriseLicense, userStore, workspaceStore, awarenessStore } from '$lib/stores'
import { BROWSER } from 'esm-env'
import { WebsocketProvider } from 'y-websocket'
import * as Y from 'yjs'
import type { Awareness } from 'y-protocols/awareness'
import { page } from '$app/stores'
import { slide } from 'svelte/transition'
const wsProtocol = BROWSER && window.location.protocol == 'https:' ? 'wss' : 'ws'
import { buildWsUrl } from '$lib/wsUrl'
let awareness: Awareness | undefined = $state(undefined)
let wsProvider: WebsocketProvider | undefined = undefined
@@ -18,11 +16,7 @@
let connected = $state(false)
function connectWorkspace(workspace: string) {
const ydoc = new Y.Doc()
wsProvider = new WebsocketProvider(
`${wsProtocol}://${window.location.host}/ws_mp/`,
workspace,
ydoc
)
wsProvider = new WebsocketProvider(buildWsUrl('/ws_mp/'), workspace, ydoc)
wsProvider.on('sync', (isSynced: boolean) => {
connected = true
})
@@ -52,10 +46,10 @@
name: $userStore?.username,
url: $page.url.pathname
})
});
})
run(() => {
$enterpriseLicense && $workspaceStore && connectWorkspace($workspaceStore)
});
})
function showActivity(url: string) {
if (url.startsWith('/scripts/add')) {
+1
View File
@@ -83,6 +83,7 @@ export const superadmin = writable<string | false | undefined>(undefined)
export const devopsRole = writable<string | false | undefined>(undefined)
export const lspTokenStore = writable<string | undefined>(undefined)
export const hubBaseUrlStore = writable<string>(DEFAULT_HUB_BASE_URL)
export const wsBaseUrlStore = writable<string | undefined>(undefined)
export const disableHubStore = writable<boolean>(false)
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
[usersWorkspaceStore, superadmin],
+16
View File
@@ -0,0 +1,16 @@
import { get } from 'svelte/store'
import { BROWSER } from 'esm-env'
import { wsBaseUrlStore } from './stores'
export function buildWsUrl(path: string): string {
const override = get(wsBaseUrlStore)
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}`
}
@@ -26,6 +26,7 @@
type UserExt,
defaultScripts,
hubBaseUrlStore,
wsBaseUrlStore,
disableHubStore,
usedTriggerKinds,
devopsRole,
@@ -158,6 +159,7 @@
loadUsage()
syncTutorialsTodos()
loadHubBaseUrl()
loadWsBaseUrl()
loadDisableHub()
loadUsedTriggerKinds()
}
@@ -178,6 +180,13 @@
DEFAULT_HUB_BASE_URL
}
async function loadWsBaseUrl() {
const val = (await SettingService.getGlobal({ key: 'ws_base_url' })) as string
if (val) {
$wsBaseUrlStore = val
}
}
async function loadDisableHub() {
$disableHubStore =
((await SettingService.getGlobal({ key: 'disable_hub' })) as boolean) ?? false
+23 -1
View File
@@ -90,6 +90,26 @@ class MainHandler(web.RequestHandler):
def get(self):
self.write("ok")
class HealthHandler(web.RequestHandler):
def set_default_headers(self):
self.set_header("Access-Control-Allow-Origin", "*")
self.set_header("Content-Type", "application/json")
def get(self):
self.write(json.dumps({"status": "ok", "service": "lsp"}))
def options(self):
self.set_status(204)
self.finish()
class PingHandler(websocket.WebSocketHandler):
def open(self):
self.write_message(json.dumps({"type": "pong", "service": "lsp"}))
self.close()
def check_origin(self, origin):
return True
if __name__ == "__main__":
monaco_path = "/tmp/monaco"
os.makedirs(monaco_path, exist_ok=True)
@@ -107,8 +127,10 @@ if __name__ == "__main__":
(r"/ws/ruff", RuffLS),
(r"/ws/deno", DenoLS),
(r"/ws/go", GoLS),
(r"/ws/ping", PingHandler),
(r"/ws/health", HealthHandler),
(r"/", MainHandler),
(r"/health", MainHandler),
(r"/health", HealthHandler),
]
)
app.listen(port, address="0.0.0.0")
+20 -4
View File
@@ -122,9 +122,12 @@ const setupWSConnection = (conn, req, docName) => {
}
const server = http.createServer((req, res) => {
if (req.url === '/' || req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.end('okay')
if (req.url === '/' || req.url === '/health' || req.url === '/ws_mp/health') {
res.writeHead(200, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
})
res.end(JSON.stringify({ status: 'ok', service: 'multiplayer' }))
} else {
res.writeHead(404)
res.end('not found')
@@ -134,7 +137,20 @@ const server = http.createServer((req, res) => {
const wss = new WebSocketServer({ server })
wss.on('connection', (ws, req) => {
const docName = req.url?.slice(1).split('?')[0] || 'unknown'
let docName = req.url?.slice(1).split('?')[0] || 'unknown'
// Strip ws_mp/ prefix if present (when accessed without reverse proxy path stripping)
if (docName.startsWith('ws_mp/')) {
docName = docName.slice('ws_mp/'.length)
}
// Handle ping test — respond and close immediately
if (docName === '__ping__') {
ws.send(JSON.stringify({ type: 'pong', service: 'multiplayer' }))
ws.close()
return
}
const clientIp = req.socket.remoteAddress
console.log(`[${new Date().toISOString()}] CONNECT: doc="${docName}" from=${clientIp}`)