mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-31 00:13:53 +00:00
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:
co-authored by
Claude Opus 4.6
parent
08215c708b
commit
372023e995
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user