Skip to content

Commit a50cb8b

Browse files
committed
Derive WebSocket scheme from page protocol
The client hardcoded `ws://` for both live sockets, so behind any TLS-terminating proxy (Cloudflare Tunnel, nginx, ngrok) the browser blocked them as mixed content and live updates died. It only worked over http://localhost, making the bug easy to miss. Add a shared `wsUrl(path)` helper that picks `wss://` when the page is served over HTTPS and `ws://` otherwise, and route both call sites (`/ws` and `/api/workspaces/ws`) through it. No server change needed — the proxy forwards `wss://` to the origin's plain `ws://`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpGBXW5iHAsc8K2543Z7BU
1 parent acff988 commit a50cb8b

4 files changed

Lines changed: 47 additions & 2 deletions

File tree

client/hooks/useMeiEvents.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useRef } from 'react'
22

3+
import { wsUrl } from '@/client/lib/ws-url'
34
import type { ViewInfo, WidgetInfo } from '@/lib/types'
45

56
export type MeiEvent =
@@ -26,7 +27,7 @@ function ensureConnection() {
2627
if (ws || connecting) return
2728
connecting = true
2829

29-
const socket = new WebSocket(`ws://${location.host}/api/workspaces/ws`)
30+
const socket = new WebSocket(wsUrl('/api/workspaces/ws'))
3031

3132
socket.onopen = () => {
3233
ws = socket

client/lib/connection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
22

33
import { workspaceKeys } from '@/client/api/workspaces'
44
import { getScratchExecutor } from '@/client/lib/scratch-executor'
5+
import { wsUrl } from '@/client/lib/ws-url'
56
import { liveStore } from '@/client/store/live'
67
import { applyEvent } from '@/lib/format'
78
import type {
@@ -55,7 +56,7 @@ function connect() {
5556
reconnectTimer = null
5657
}
5758

58-
const s = new WebSocket(`ws://${location.host}/ws`)
59+
const s = new WebSocket(wsUrl('/ws'))
5960
socket = s
6061

6162
s.onopen = () => {

client/lib/ws-url.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { afterEach, describe, expect, test } from 'bun:test'
2+
3+
import { wsUrl } from '@/client/lib/ws-url'
4+
5+
const realLocation = globalThis.location
6+
7+
function stubLocation(loc: { protocol: string; host: string }) {
8+
Object.defineProperty(globalThis, 'location', {
9+
value: loc,
10+
configurable: true,
11+
writable: true
12+
})
13+
}
14+
15+
afterEach(() => {
16+
Object.defineProperty(globalThis, 'location', {
17+
value: realLocation,
18+
configurable: true,
19+
writable: true
20+
})
21+
})
22+
23+
describe('wsUrl', () => {
24+
test('uses ws:// over http', () => {
25+
stubLocation({ protocol: 'http:', host: 'localhost:13337' })
26+
expect(wsUrl('/ws')).toBe('ws://localhost:13337/ws')
27+
})
28+
29+
test('uses wss:// over https (behind a TLS proxy)', () => {
30+
stubLocation({ protocol: 'https:', host: 'moi.example.com' })
31+
expect(wsUrl('/api/workspaces/ws')).toBe('wss://moi.example.com/api/workspaces/ws')
32+
})
33+
})

client/lib/ws-url.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Build a WebSocket URL for a same-origin path, deriving the scheme from the
2+
// page protocol: `wss://` when the page is served over HTTPS, `ws://` otherwise.
3+
// Hardcoding `ws://` breaks live updates behind any TLS-terminating proxy
4+
// (Cloudflare Tunnel, nginx, ngrok) — the browser blocks the insecure socket as
5+
// mixed content. The proxy forwards `wss://` to the origin's plain `ws://`, so
6+
// no server change is needed.
7+
export function wsUrl(path: string) {
8+
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
9+
return `${scheme}://${location.host}${path}`
10+
}

0 commit comments

Comments
 (0)