diff --git a/Caddyfile b/Caddyfile index 4b330de42f..96c29626f0 100644 --- a/Caddyfile +++ b/Caddyfile @@ -11,18 +11,8 @@ {$BASE_URL} { bind {$ADDRESS} - # LSP - Language Server Protocol for code intelligence (windmill_extra:3001) - reverse_proxy /ws/* http://windmill_extra:3001 - - # Multiplayer - Real-time collaboration, Enterprise Edition (windmill_extra:3002) - # Uncomment and set ENABLE_MULTIPLAYER=true in docker-compose.yml - # reverse_proxy /ws_mp/* http://windmill_extra:3002 - - # Debugger - Interactive debugging via DAP WebSocket (windmill_extra:3003) - # Set ENABLE_DEBUGGER=true in docker-compose.yml to enable - handle_path /ws_debug/* { - reverse_proxy http://windmill_extra:3003 - } + # Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway) + reverse_proxy /ws/* /ws_mp/* /ws_debug/* http://windmill_extra:3000 # Search indexer, Enterprise Edition (windmill_indexer:8002) # reverse_proxy /api/srch/* http://windmill_indexer:8002 diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index 68b4a282e2..190f65caba 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -109,6 +109,7 @@ WORKDIR /multiplayer # Copy multiplayer server files COPY multiplayer/package.json . COPY multiplayer/server.mjs . +COPY multiplayer/gateway.mjs . # Install dependencies RUN npm install @@ -129,13 +130,15 @@ RUN chmod -R a+rX /usr/local && \ chmod -R a+rX /debugger # Expose all service ports -EXPOSE 3001 3002 3003 +EXPOSE 3000 3001 3002 3003 # Environment variables for service control ENV ENABLE_LSP=true ENV ENABLE_MULTIPLAYER=true ENV ENABLE_DEBUGGER=true # nsjail sandboxing for debugger (requires --privileged, off by default) +ENV ENABLE_GATEWAY=true +ENV GATEWAY_PORT=3000 ENV ENABLE_NSJAIL=false # LSP port diff --git a/docker/entrypoint-extra.sh b/docker/entrypoint-extra.sh index eedd169a31..385db124fb 100644 --- a/docker/entrypoint-extra.sh +++ b/docker/entrypoint-extra.sh @@ -43,6 +43,7 @@ echo "[entrypoint] Starting Windmill Extra Services" echo "[entrypoint] ENABLE_LSP=${ENABLE_LSP:-true}" echo "[entrypoint] ENABLE_MULTIPLAYER=${ENABLE_MULTIPLAYER:-true}" echo "[entrypoint] ENABLE_DEBUGGER=${ENABLE_DEBUGGER:-true}" +echo "[entrypoint] ENABLE_GATEWAY=${ENABLE_GATEWAY:-true}" # Start LSP service if [ "${ENABLE_LSP:-true}" = "true" ]; then @@ -81,6 +82,15 @@ if [ "${ENABLE_DEBUGGER:-true}" = "true" ]; then echo "[entrypoint] Debugger started (PID: ${PIDS[-1]})" fi +# Start Gateway reverse proxy (routes /ws/*, /ws_mp/*, /ws_debug/* to the right service) +if [ "${ENABLE_GATEWAY:-true}" = "true" ]; then + echo "[entrypoint] Starting Gateway on port ${GATEWAY_PORT:-3000}..." + cd /multiplayer + PORT=${GATEWAY_PORT:-3000} node gateway.mjs & + PIDS+=($!) + echo "[entrypoint] Gateway started (PID: ${PIDS[-1]})" +fi + # Check if any services were started if [ ${#PIDS[@]} -eq 0 ]; then echo "[entrypoint] WARNING: No services enabled. Set ENABLE_LSP, ENABLE_MULTIPLAYER, or ENABLE_DEBUGGER to true." diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 686b3645aa..14886d9294 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -58,20 +58,19 @@ const config = { cookieDomainRewrite: 'localhost' }, '^/ws/.*': { - target: process.env.REMOTE_LSP ?? 'https://app.windmill.dev', + target: process.env.REMOTE_LSP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, ws: true }, '^/ws_mp/.*': { - target: process.env.REMOTE_MP ?? 'https://app.windmill.dev', + target: process.env.REMOTE_MP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, ws: true }, '^/ws_debug/.*': { - target: process.env.REMOTE_DEBUG ?? 'https://app.windmill.dev', + target: process.env.REMOTE_DEBUG ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, - ws: true, - rewrite: (path) => path.replace(/^\/ws_debug/, '') + ws: true }, '^/ui_builder/.*': { target: 'http://localhost:4000', diff --git a/multiplayer/gateway.mjs b/multiplayer/gateway.mjs new file mode 100644 index 0000000000..4749e61688 --- /dev/null +++ b/multiplayer/gateway.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Lightweight reverse proxy gateway for windmill-extra services. + * + * Listens on a single port (default 3000) and dispatches requests + * to the correct backend service based on URL prefix: + * + * /ws/* → LSP (default: localhost:3001) + * /ws_mp/* → Multiplayer (default: localhost:3002) + * /ws_debug/* → Debugger (default: localhost:3003) + * everything else → LSP (fallback) + * + * The prefix is stripped before forwarding so each service receives + * clean paths (e.g. /ws_mp/room-name → /room-name on port 3002). + * + * Handles both HTTP and WebSocket upgrades. + */ + +import http from 'http' + +const PORT = parseInt(process.env.PORT || process.env.GATEWAY_PORT || '3000') +const HOST = process.env.GATEWAY_HOST || '0.0.0.0' + +const LSP_PORT = parseInt(process.env.LSP_PORT || '3001') +const MULTIPLAYER_PORT = parseInt(process.env.MULTIPLAYER_PORT || '3002') +const DEBUGGER_PORT = parseInt(process.env.DEBUGGER_PORT || '3003') + +const ROUTES = [ + { prefix: '/ws_mp/', target: MULTIPLAYER_PORT, strip: '/ws_mp' }, + { prefix: '/ws_mp', target: MULTIPLAYER_PORT, strip: '/ws_mp' }, + { prefix: '/ws_debug/', target: DEBUGGER_PORT, strip: '/ws_debug' }, + { prefix: '/ws_debug', target: DEBUGGER_PORT, strip: '/ws_debug' }, + { prefix: '/ws/', target: LSP_PORT, strip: '' }, + { prefix: '/ws', target: LSP_PORT, strip: '' }, +] + +function resolve (url) { + for (const r of ROUTES) { + if (url === r.prefix || url.startsWith(r.prefix + (r.prefix.endsWith('/') ? '' : '/'))) { + const stripped = r.strip ? url.slice(r.strip.length) || '/' : url + return { port: r.target, path: stripped } + } + } + // fallback: forward as-is to LSP + return { port: LSP_PORT, path: url } +} + +// ---- HTTP proxy ---- + +const server = http.createServer((clientReq, clientRes) => { + const { port, path } = resolve(clientReq.url) + + const opts = { + hostname: '127.0.0.1', + port, + path, + method: clientReq.method, + headers: clientReq.headers, + } + + const proxy = http.request(opts, (proxyRes) => { + clientRes.writeHead(proxyRes.statusCode, proxyRes.headers) + proxyRes.pipe(clientRes, { end: true }) + }) + + proxy.on('error', (err) => { + console.error(`[gateway] HTTP proxy error → :${port}${path}: ${err.message}`) + if (!clientRes.headersSent) { + clientRes.writeHead(502, { 'Content-Type': 'text/plain' }) + } + clientRes.end('Bad Gateway') + }) + + clientReq.pipe(proxy, { end: true }) +}) + +// ---- WebSocket proxy (upgrade) ---- + +server.on('upgrade', (clientReq, clientSocket, head) => { + const { port, path } = resolve(clientReq.url) + + const opts = { + hostname: '127.0.0.1', + port, + path, + method: 'GET', + headers: clientReq.headers, + } + + const proxy = http.request(opts) + + proxy.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + // Relay the 101 back to the client + let response = `HTTP/1.1 101 Switching Protocols\r\n` + for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) { + response += `${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r\n` + } + response += '\r\n' + + clientSocket.write(response) + + if (proxyHead.length) { + clientSocket.write(proxyHead) + } + + // Bi-directional pipe + proxySocket.pipe(clientSocket) + clientSocket.pipe(proxySocket) + + proxySocket.on('error', () => clientSocket.destroy()) + clientSocket.on('error', () => proxySocket.destroy()) + }) + + proxy.on('error', (err) => { + console.error(`[gateway] WS proxy error → :${port}${path}: ${err.message}`) + clientSocket.destroy() + }) + + proxy.end() +}) + +server.listen(PORT, HOST, () => { + console.log(`[gateway] listening on ${HOST}:${PORT}`) + console.log(`[gateway] /ws/* → :${LSP_PORT}`) + console.log(`[gateway] /ws_mp/* → :${MULTIPLAYER_PORT}`) + console.log(`[gateway] /ws_debug/* → :${DEBUGGER_PORT}`) + console.log(`[gateway] (fallback) → :${LSP_PORT}`) +}) diff --git a/multiplayer/server.mjs b/multiplayer/server.mjs index f5431822fc..ed3262068a 100644 --- a/multiplayer/server.mjs +++ b/multiplayer/server.mjs @@ -122,8 +122,14 @@ const setupWSConnection = (conn, req, docName) => { } const server = http.createServer((req, res) => { + // Strip /ws_mp/ prefix if present (when accessed without reverse proxy path stripping) + if (req.url?.startsWith('/ws_mp/')) { + req.url = req.url.slice('/ws_mp'.length) + } else if (req.url === '/ws_mp') { + req.url = '/' + } console.log(`[${new Date().toISOString()}] HTTP ${req.method} ${req.url} from=${req.socket.remoteAddress}`) - if (req.url === '/' || req.url === '/health' || req.url === '/ws_mp/health') { + if (req.url === '/' || req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'