Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ ALLOWED_GITHUB_USERS=
# 0.0.0.0/0; inspect the Docker/ingress network and list only its gateway CIDR.
TRUSTED_PROXY_CIDRS=

# Public WebSocket URL the Next.js client connects to.
NEXT_PUBLIC_WS_URL=wss://dashboard.example.com/ui/ws
# Browser controls use same-origin /ui/ws. Route that path to the server with
# WebSocket upgrades enabled; there is no build-time dashboard URL to keep in sync.

# Optional legacy shared agent token. Leave empty to disable; the server
# rejects an empty AGENT_SECRET so a default value cannot become a
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,6 @@ services:
ports:
# Loopback only — reached via nginx proxy_pass to 127.0.0.1:3000.
- "127.0.0.1:3000:3000"
environment:
- NEXT_PUBLIC_WS_URL=${NEXT_PUBLIC_WS_URL:-wss://dashboard.example.com/ui/ws}
restart: unless-stopped
networks:
- shellfleet_net
Expand Down
34 changes: 14 additions & 20 deletions web/src/components/providers/WebSocketProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,15 @@ const CONNECT_TIMEOUT_MS = 12_000;
const DIRECTORY_SYNC_INTERVAL_MS = 15_000;
const DIRECTORY_STALE_AFTER_MS = 45_000;

// Resolve the WS URL once on import. Order of precedence:
// 1. NEXT_PUBLIC_WS_URL — explicit override baked at build time, used
// when web and server live on different hosts.
// 2. window.location — same-origin /ui/ws, derived per request. This
// makes a fresh deploy "just work" wherever it's hosted, no env
// var or rebuild needed.
// 3. SSR placeholder — never actually reached by the browser, but
// keeps TypeScript happy and avoids accidental crashes if the
// provider is ever evaluated outside a browser.
// Resolve at connection time, in the browser, from the page's current
// origin. NEXT_PUBLIC_* values are frozen into Next.js client bundles at build
// time, so a runtime container environment variable can silently point the
// dashboard at a stale or placeholder host. ShellFleet deliberately exposes
// /ui/ws on the same public origin as the dashboard and API.
function resolveWsUrl(): string {
if (typeof process !== 'undefined' && process.env.NEXT_PUBLIC_WS_URL) {
return process.env.NEXT_PUBLIC_WS_URL;
}
if (typeof window !== 'undefined') {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/ui/ws`;
}
return 'wss://dashboard.example.com/ui/ws';
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/ui/ws`;
}
const WS_URL = resolveWsUrl();

export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const { status } = useSession();
Expand Down Expand Up @@ -207,7 +196,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {

let ws: WebSocket;
try {
ws = new WebSocket(WS_URL);
ws = new WebSocket(resolveWsUrl());
} catch (error) {
console.error('[shellfleet] failed to create UI WebSocket:', error);
scheduleReconnect();
Expand Down Expand Up @@ -255,7 +244,12 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}, DIRECTORY_SYNC_INTERVAL_MS);
};

ws.onclose = () => {
ws.onclose = (event) => {
if (!disposed && event.code !== 1000) {
console.warn(
`[shellfleet] UI WebSocket closed (code=${event.code}, reason=${event.reason || 'none'})`,
);
}
if (!disposed) retire(ws, false);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ describe('WebSocketProvider', () => {
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:3000/ui/ws');
});

it('does not let a stale build-time URL override the dashboard origin', () => {
vi.stubEnv('NEXT_PUBLIC_WS_URL', 'wss://stale.example.com/ui/ws');

renderProvider();

expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toBe('ws://localhost:3000/ui/ws');
});

it('ignores late messages from a retired socket generation', () => {
renderProvider();
const first = MockWebSocket.instances[0];
Expand Down
Loading