diff --git a/packages/frontend/package.json b/packages/frontend/package.json index c2040ce..5881a2d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -18,6 +18,7 @@ "@ih3t/shared": "workspace:*", "@tanstack/react-query": "^5.91.2", "clsx": "^2.1.1", + "engine.io-client": "^6.6.4", "immer": "^10.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/packages/frontend/src/earlySocketConnection.ts b/packages/frontend/src/earlySocketConnection.ts new file mode 100644 index 0000000..60cf5d6 --- /dev/null +++ b/packages/frontend/src/earlySocketConnection.ts @@ -0,0 +1,68 @@ +import { getSocketUrl } from "./query/apiClient" + +export type EarlySocket = { + socketUrl: string, + socket: WebSocket, + + consumeTimeout: ReturnType, + events: (() => void)[], +} + +function createEarlySocket(url: string): EarlySocket { + let targetUrl = url + .replace("https://", "wss://") + .replace("http://", "ws://") + + if (!targetUrl.endsWith("/")) { + targetUrl = `${targetUrl}/` + } + + const socketUrl = `${targetUrl}socket.io/?EIO=4&transport=websocket` + const socket = new WebSocket(socketUrl) + + const earlySocket: EarlySocket = { + socketUrl, + socket, + + consumeTimeout: setTimeout(() => { + console.warn(`Early connect to ${targetUrl} has not been used!`); + socket.close(); + }, 30_000), + events: [], + }; + + socket.onerror = event => { earlySocket.events.push(() => socket.onerror?.(event)) } + socket.onopen = event => { earlySocket.events.push(() => socket.onopen?.(event)) } + socket.onmessage = event => { earlySocket.events.push(() => socket.onmessage?.(event)) } + socket.onclose = event => { earlySocket.events.push(() => socket.onclose?.(event)) } + + console.log(`Early connect to ${targetUrl}`); + return earlySocket; +} + +function consumeEarlySocket(socket: EarlySocket): WebSocket { + clearTimeout(socket.consumeTimeout); + setTimeout(() => { + for (const eventCallback of socket.events) { + eventCallback() + } + }, 0); + + return socket.socket; +} + +let earlyWebSocket: EarlySocket | null = null; +export function takeEarlyWebSocket(url: string): WebSocket | null { + if (url !== earlyWebSocket?.socketUrl) { + return null; + } + + const socket = earlyWebSocket; + earlyWebSocket = null; + return consumeEarlySocket(socket); +} + +if (!import.meta.env.SSR) { + /* do not create the web socket in SSR mode */ + earlyWebSocket = createEarlySocket(getSocketUrl()); +} \ No newline at end of file diff --git a/packages/frontend/src/earlySocketTransport.ts b/packages/frontend/src/earlySocketTransport.ts new file mode 100644 index 0000000..ae6e55d --- /dev/null +++ b/packages/frontend/src/earlySocketTransport.ts @@ -0,0 +1,14 @@ +import { WebSocket } from "engine.io-client" +import { takeEarlyWebSocket } from "./earlySocketConnection"; + +export class EarlyWebSocket extends WebSocket { + createSocket(uri: string, protocols: string | string[] | undefined, opts: Record) { + const socket = takeEarlyWebSocket(uri); + if (socket && socket.readyState < window.WebSocket.CLOSING) { + console.info(`Used early socket for socker.io connection. Early socket state ${socket.readyState}.`); + return socket; + } + + return super.createSocket(uri, protocols, opts) + } +} \ No newline at end of file diff --git a/packages/frontend/src/entry-web.tsx b/packages/frontend/src/entry-web.tsx index 25db6e9..4ca7cd7 100644 --- a/packages/frontend/src/entry-web.tsx +++ b/packages/frontend/src/entry-web.tsx @@ -1,6 +1,7 @@ import './index.css' import 'react-toastify/dist/ReactToastify.css' import { installSoundEffects } from './soundEffects' +import "./earlySocketConnection"; installSoundEffects() diff --git a/packages/frontend/src/liveGameClient.ts b/packages/frontend/src/liveGameClient.ts index c6a2b7d..883e7f5 100644 --- a/packages/frontend/src/liveGameClient.ts +++ b/packages/frontend/src/liveGameClient.ts @@ -12,6 +12,7 @@ import { queryClient } from './query/queryClient' import { buildSessionPath } from './routes/archiveRouteState' import { sortLobbySessions } from './utils/lobby' import { queryKeys } from './query/queryDefinitions' +import { EarlyWebSocket } from './earlySocketTransport' let socket: Socket | null = null let shouldHandleDisconnect = true @@ -58,7 +59,7 @@ export function startLiveGameClient() { versionHash: APP_VERSION_HASH }, withCredentials: true, - transports: ["websocket"] + transports: [EarlyWebSocket], }) socket.on('connect_error', (error) => { diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index 8520721..b78f4f2 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -1,9 +1,9 @@ -import { StrictMode } from "react"; -import { createClientRouter } from "./router"; -import { getDehydratedStateFromWindow } from "./ssrState"; -import { queryClient } from "./query/queryClient"; -import { createRoot, hydrateRoot } from "react-dom/client"; -import App from "./App"; +import { StrictMode } from "react"; +import { createClientRouter } from "./router"; +import { getDehydratedStateFromWindow } from "./ssrState"; +import { queryClient } from "./query/queryClient"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import App from "./App"; let root = document.getElementById('root'); if (!root) { @@ -26,4 +26,4 @@ if (root.hasChildNodes()) { hydrateRoot(root, app) } else { createRoot(root).render(app) -} +} \ No newline at end of file diff --git a/packages/frontend/src/query/apiClient.ts b/packages/frontend/src/query/apiClient.ts index fa5a767..3e57613 100644 --- a/packages/frontend/src/query/apiClient.ts +++ b/packages/frontend/src/query/apiClient.ts @@ -15,7 +15,7 @@ export function getApiBaseUrl() { return 'http://localhost:3001' } -export function getSocketUrl() { +export function getSocketUrl(): string { return import.meta.env.VITE_SOCKET_URL ?? getApiBaseUrl() } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8b0031..15649e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -111,6 +111,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + engine.io-client: + specifier: ^6.6.4 + version: 6.6.4 immer: specifier: ^10.2.0 version: 10.2.0