diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js index 92c32e77..5076e53e 100644 --- a/frontend/src/api/websocket.js +++ b/frontend/src/api/websocket.js @@ -140,8 +140,14 @@ export class WebSocketClient { #buildUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - let basePath = this.basePath || ''; - if (basePath && !basePath.endsWith('/')) basePath += '/'; + // basePath comes from window.__X_UI_BASE_PATH__ which is only injected + // by the Go binary in production. In dev (Vite serves directly) the + // global is missing and basePath would be '' — without the fallback to + // '/' we'd build `ws://host:portws` (no separator) and the WebSocket + // constructor throws a SyntaxError. + let basePath = this.basePath || '/'; + if (!basePath.startsWith('/')) basePath = '/' + basePath; + if (!basePath.endsWith('/')) basePath += '/'; return `${protocol}//${window.location.host}${basePath}ws`; } diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js new file mode 100644 index 00000000..a4810d21 --- /dev/null +++ b/frontend/src/composables/useWebSocket.js @@ -0,0 +1,48 @@ +import { onBeforeUnmount, onMounted } from 'vue'; +import { WebSocketClient } from '@/api/websocket.js'; + +// One client per browser tab (= per multi-page entry). WebSocketClient is +// idempotent: repeated connect() calls while the socket is already open +// are no-ops, so multiple components on the same page can share a single +// underlying connection without each spawning their own. +let sharedClient = null; + +function getSharedClient() { + if (sharedClient) return sharedClient; + const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || ''; + sharedClient = new WebSocketClient(basePath); + return sharedClient; +} + +// useWebSocket lets a Vue component subscribe to live server-pushed +// events. Pass a map of { eventName: handler } and the composable wires +// connect()/disconnect() into the component lifecycle and unsubscribes +// every handler on unmount so a stale closure can't fire after the +// page has moved on. +// +// Example: +// useWebSocket({ +// traffic: (payload) => applyTrafficEvent(payload), +// client_stats: (payload) => applyClientStatsEvent(payload), +// invalidate: ({ dataType }) => { if (dataType === 'inbounds') refresh(); }, +// }); +// +// Built-in lifecycle events ('connected' / 'disconnected' / 'error') +// can be subscribed to alongside server-emitted types. +export function useWebSocket(handlers) { + const client = getSharedClient(); + const entries = Object.entries(handlers || {}); + + onMounted(() => { + for (const [event, fn] of entries) client.on(event, fn); + client.connect(); + }); + + onBeforeUnmount(() => { + for (const [event, fn] of entries) client.off(event, fn); + // Don't disconnect — another mounted component on the same page may + // still be subscribed. The client closes naturally on page unload. + }); + + return { client }; +} diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index f473dd33..09b583d6 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -1,11 +1,9 @@