diff --git a/webclient/eslint.boundaries.mjs b/webclient/eslint.boundaries.mjs index 164dc6ed6..05bb6cf83 100644 --- a/webclient/eslint.boundaries.mjs +++ b/webclient/eslint.boundaries.mjs @@ -23,16 +23,22 @@ const rules = [ { from: { type: 'websocket' }, allow: types('generated') }, { from: { type: 'store' }, allow: types('types') }, - { from: { type: 'api' }, allow: types('types', 'store', 'websocket') }, + { from: { type: 'api' }, allow: types('store', 'types', 'websocket') }, - { from: { type: 'hooks' }, allow: types('services', 'types') }, + { from: { type: 'hooks' }, allow: types('api', 'services', 'types', 'websocket') }, { from: { type: 'images' }, allow: types('types') }, { from: { type: 'services' }, allow: types('api', 'store', 'types') }, - { from: { type: 'components' }, allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') }, - { from: { type: 'containers' }, allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'types', 'store') }, - { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'types', 'store') }, - { from: { type: 'forms' }, allow: types('components', 'hooks', 'types', 'services', 'store') }, + { + from: { type: 'components' }, + allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + }, + { + from: { type: 'containers' }, + allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types') + }, + { from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types') }, + { from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types') }, ]; export const boundariesConfig = [ diff --git a/webclient/src/api/index.ts b/webclient/src/api/index.ts index 618e6fc71..fb201a02a 100644 --- a/webclient/src/api/index.ts +++ b/webclient/src/api/index.ts @@ -1,13 +1,2 @@ -export { initWebClient } from './initWebClient'; export { createWebClientResponse } from './response'; export { createWebClientRequest } from './request'; - -import { createWebClientRequest } from './request'; - -/** - * UI-facing request surface. The request implementations are created once - * at module load. They access `WebClient.instance` at call time (via lazy - * internal references), so the singleton only needs to exist by the first - * actual command send. - */ -export const request = createWebClientRequest(); diff --git a/webclient/src/api/initWebClient.ts b/webclient/src/api/initWebClient.ts deleted file mode 100644 index 8337c0e0e..000000000 --- a/webclient/src/api/initWebClient.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - WebClient, - SessionEvents, - RoomEvents, - GameEvents, - SessionCommands, -} from '@app/websocket'; -import type { WebClientConfig } from '@app/websocket'; - -import { createWebClientResponse } from './response'; - -export function initWebClient(): void { - const response = createWebClientResponse(); - - const config: WebClientConfig = { - response, - sessionEvents: SessionEvents, - roomEvents: RoomEvents, - gameEvents: GameEvents, - keepAliveFn: (cb) => SessionCommands.ping(cb), - }; - - new WebClient(config); -} diff --git a/webclient/src/components/KnownHosts/KnownHosts.tsx b/webclient/src/components/KnownHosts/KnownHosts.tsx index 3efdf67ac..e233bc5ae 100644 --- a/webclient/src/components/KnownHosts/KnownHosts.tsx +++ b/webclient/src/components/KnownHosts/KnownHosts.tsx @@ -13,7 +13,7 @@ import AddIcon from '@mui/icons-material/Add'; import EditRoundedIcon from '@mui/icons-material/Edit'; import ErrorOutlinedIcon from '@mui/icons-material/ErrorOutlined'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { KnownHostDialog } from '@app/dialogs'; import { useReduxEffect } from '@app/hooks'; import { HostDTO } from '@app/services'; @@ -64,6 +64,7 @@ const KnownHosts = (props) => { const { touched, error, warning } = meta; const { t } = useTranslation(); + const webClient = useWebClient(); const [hostsState, setHostsState] = useState({ hosts: [], @@ -197,7 +198,7 @@ const KnownHosts = (props) => { setTestingConnection(TestConnection.TESTING); const options = { ...App.getHostPort(hostsState.selectedHost) }; - request.authentication.testConnection(options); + webClient.request.authentication.testConnection(options); } return ( diff --git a/webclient/src/components/UserDisplay/UserDisplay.tsx b/webclient/src/components/UserDisplay/UserDisplay.tsx index 6e38fab5c..c3d46446e 100644 --- a/webclient/src/components/UserDisplay/UserDisplay.tsx +++ b/webclient/src/components/UserDisplay/UserDisplay.tsx @@ -6,7 +6,7 @@ import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import { Images } from '@app/images'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { ServerSelectors } from '@app/store'; import { App, Data } from '@app/types'; import { useAppSelector } from '@app/store'; @@ -18,6 +18,7 @@ const UserDisplay = ({ user }: UserDisplayProps) => { const buddyList = useAppSelector(state => ServerSelectors.getBuddyList(state)); const ignoreList = useAppSelector(state => ServerSelectors.getIgnoreList(state)); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + const webClient = useWebClient(); const { name, country } = user; @@ -32,19 +33,19 @@ const UserDisplay = ({ user }: UserDisplayProps) => { const isIgnored = Boolean(ignoreList[user.name]); const onAddBuddy = () => { - request.session.addToBuddyList(user.name); + webClient.request.session.addToBuddyList(user.name); handleClose(); }; const onRemoveBuddy = () => { - request.session.removeFromBuddyList(user.name); + webClient.request.session.removeFromBuddyList(user.name); handleClose(); }; const onAddIgnore = () => { - request.session.addToIgnoreList(user.name); + webClient.request.session.addToIgnoreList(user.name); handleClose(); }; const onRemoveIgnore = () => { - request.session.removeFromIgnoreList(user.name); + webClient.request.session.removeFromIgnoreList(user.name); handleClose(); }; diff --git a/webclient/src/containers/Account/Account.tsx b/webclient/src/containers/Account/Account.tsx index 05d0b1161..ee06f590a 100644 --- a/webclient/src/containers/Account/Account.tsx +++ b/webclient/src/containers/Account/Account.tsx @@ -7,7 +7,7 @@ import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; import { UserDisplay, VirtualList, AuthGuard, LanguageDropdown } from '@app/components'; -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { ServerSelectors } from '@app/store'; import Layout from '../Layout/Layout'; import { useAppSelector } from '@app/store'; @@ -23,17 +23,18 @@ const Account = () => { const serverName = useAppSelector(state => ServerSelectors.getName(state)); const serverVersion = useAppSelector(state => ServerSelectors.getVersion(state)); const user = useAppSelector(state => ServerSelectors.getUser(state)); + const webClient = useWebClient(); const { country, realName, name, userLevel, accountageSecs, avatarBmp } = user || {}; let url = URL.createObjectURL(new Blob([avatarBmp as BlobPart], { 'type': 'image/png' })); const { t } = useTranslation(); const handleAddToBuddies = ({ userName }) => { - request.session.addToBuddyList(userName); + webClient.request.session.addToBuddyList(userName); }; const handleAddToIgnore = ({ userName }) => { - request.session.addToIgnoreList(userName); + webClient.request.session.addToIgnoreList(userName); }; return ( diff --git a/webclient/src/containers/Layout/LeftNav.tsx b/webclient/src/containers/Layout/LeftNav.tsx index b9b2d377c..00d89455b 100644 --- a/webclient/src/containers/Layout/LeftNav.tsx +++ b/webclient/src/containers/Layout/LeftNav.tsx @@ -8,8 +8,8 @@ import CloseIcon from '@mui/icons-material/Close'; import MailOutlineRoundedIcon from '@mui/icons-material/MailOutlineRounded'; import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; -import { request } from '@app/api'; import { CardImportDialog } from '@app/dialogs'; +import { useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { RoomsSelectors, ServerSelectors } from '@app/store'; import { App } from '@app/types'; @@ -28,6 +28,7 @@ const LeftNav = () => { const isConnected = useAppSelector(ServerSelectors.getIsConnected); const isModerator = useAppSelector(ServerSelectors.getIsUserModerator); const navigate = useNavigate(); + const webClient = useWebClient(); const [state, setState] = useState({ anchorEl: null, showCardImportDialog: false, @@ -66,7 +67,7 @@ const LeftNav = () => { const leaveRoom = (event, roomId) => { event.preventDefault(); - request.rooms.leaveRoom(roomId); + webClient.request.rooms.leaveRoom(roomId); }; const openImportCardWizard = () => { diff --git a/webclient/src/containers/Login/Login.tsx b/webclient/src/containers/Login/Login.tsx index 6727bf89d..70896ce77 100644 --- a/webclient/src/containers/Login/Login.tsx +++ b/webclient/src/containers/Login/Login.tsx @@ -6,11 +6,10 @@ import Button from '@mui/material/Button'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { request } from '@app/api'; import { RegistrationDialog, RequestPasswordResetDialog, ResetPasswordDialog, AccountActivationDialog } from '@app/dialogs'; import { LanguageDropdown } from '@app/components'; import { LoginForm } from '@app/forms'; -import { useReduxEffect, useFireOnce } from '@app/hooks'; +import { useReduxEffect, useFireOnce, useWebClient } from '@app/hooks'; import { Images } from '@app/images'; import { HostDTO, serverProps } from '@app/services'; import { App, Enriched } from '@app/types'; @@ -67,6 +66,7 @@ const Root = styled('div')(({ theme }) => ({ const Login = () => { const description = useAppSelector(s => ServerSelectors.getDescription(s)); const isConnected = useAppSelector(ServerSelectors.getIsConnected); + const webClient = useWebClient(); const { t } = useTranslation(); const [pendingActivationOptions, setPendingActivationOptions] = useState(null); @@ -134,7 +134,7 @@ const Login = () => { options.hashedPassword = selectedHost.hashedPassword; } - request.authentication.login(options); + webClient.request.authentication.login(options); }, []); const [submitButtonDisabled, resetSubmitButton, handleLogin] = useFireOnce(onSubmitLogin); @@ -153,7 +153,7 @@ const Login = () => { setRememberLogin(registerForm); const { userName, password, email, country, realName, selectedHost } = registerForm; - request.authentication.register({ + webClient.request.authentication.register({ ...App.getHostPort(selectedHost), userName, password, @@ -167,7 +167,7 @@ const Login = () => { if (!pendingActivationOptions) { return; } - request.authentication.activateAccount({ + webClient.request.authentication.activateAccount({ host: pendingActivationOptions.host, port: pendingActivationOptions.port, userName: pendingActivationOptions.userName, @@ -180,17 +180,17 @@ const Login = () => { const { host, port } = App.getHostPort(selectedHost); if (email) { - request.authentication.resetPasswordChallenge({ userName, email, host, port }); + webClient.request.authentication.resetPasswordChallenge({ userName, email, host, port }); } else { setUserToResetPassword(userName); - request.authentication.resetPasswordRequest({ userName, host, port }); + webClient.request.authentication.resetPasswordRequest({ userName, host, port }); } }; const handleResetPasswordDialogSubmit = ({ userName, token, newPassword, selectedHost }) => { const { host, port } = App.getHostPort(selectedHost); - request.authentication.resetPassword({ userName, token, newPassword, host, port }); + webClient.request.authentication.resetPassword({ userName, token, newPassword, host, port }); }; const skipTokenRequest = (userName) => { diff --git a/webclient/src/containers/Logs/Logs.tsx b/webclient/src/containers/Logs/Logs.tsx index 7d592b82c..8ab9a181b 100644 --- a/webclient/src/containers/Logs/Logs.tsx +++ b/webclient/src/containers/Logs/Logs.tsx @@ -1,9 +1,9 @@ // eslint-disable-next-line import React, { useEffect } from "react"; -import { request } from '@app/api'; import { AuthGuard, ModGuard } from '@app/components'; import { SearchForm } from '@app/forms'; +import { useWebClient } from '@app/hooks'; import { ServerDispatch, ServerSelectors } from '@app/store'; import { Data } from '@app/types'; import { useAppSelector } from '@app/store'; @@ -13,6 +13,7 @@ import './Logs.css'; const Logs = () => { const logs = useAppSelector(state => ServerSelectors.getLogs(state)); + const webClient = useWebClient(); const MAXIMUM_RESULTS = 1000; useEffect(() => { @@ -51,7 +52,7 @@ const Logs = () => { trimmedFields.maximumResults = MAXIMUM_RESULTS; if (required.length) { - request.moderator.viewLogHistory(trimmedFields); + webClient.request.moderator.viewLogHistory(trimmedFields); } else { // @TODO use yet-to-be-implemented banner/alert } diff --git a/webclient/src/containers/Room/Room.tsx b/webclient/src/containers/Room/Room.tsx index a0312ae94..5877c57b5 100644 --- a/webclient/src/containers/Room/Room.tsx +++ b/webclient/src/containers/Room/Room.tsx @@ -4,8 +4,8 @@ import { useNavigate, useParams, generatePath } from 'react-router-dom'; import ListItemButton from '@mui/material/ListItemButton'; import Paper from '@mui/material/Paper'; -import { request } from '@app/api'; import { ScrollToBottomOnChanges, ThreePaneLayout, UserDisplay, VirtualList, AuthGuard } from '@app/components'; +import { useWebClient } from '@app/hooks'; import { RoomsSelectors } from '@app/store'; import { useAppSelector } from '@app/store'; import { App } from '@app/types'; @@ -29,6 +29,7 @@ const Room = () => { const room = rooms[roomId]; const roomMessages = messages[roomId]; const users = useAppSelector(state => RoomsSelectors.getSortedRoomUsers(state, roomId)); + const webClient = useWebClient(); useEffect(() => { if (!joined.find(r => r.info.roomId === roomId)) { @@ -38,7 +39,7 @@ const Room = () => { const handleRoomSay = ({ message }) => { if (message) { - request.rooms.roomSay(roomId, message); + webClient.request.rooms.roomSay(roomId, message); } } diff --git a/webclient/src/containers/Server/Rooms.tsx b/webclient/src/containers/Server/Rooms.tsx index c4f16652c..1eecce729 100644 --- a/webclient/src/containers/Server/Rooms.tsx +++ b/webclient/src/containers/Server/Rooms.tsx @@ -9,20 +9,20 @@ import TableCell from '@mui/material/TableCell'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; - -import { request } from '@app/api'; +import { useWebClient } from '@app/hooks'; import { App } from '@app/types'; import './Rooms.css'; const Rooms = ({ rooms, joinedRooms }) => { const navigate = useNavigate(); + const webClient = useWebClient(); function onClick(roomId) { if (joinedRooms.find(room => room.info.roomId === roomId)) { navigate(generatePath(App.RouteEnum.ROOM, { roomId })); } else { - request.rooms.joinRoom(roomId); + webClient.request.rooms.joinRoom(roomId); } } diff --git a/webclient/src/hooks/index.ts b/webclient/src/hooks/index.ts index b5e9cca55..a9385d50b 100644 --- a/webclient/src/hooks/index.ts +++ b/webclient/src/hooks/index.ts @@ -3,3 +3,4 @@ export * from './useFireOnce'; export * from './useDebounce'; export * from './useLocaleSort'; export * from './useReduxEffect'; +export * from './useWebClient'; diff --git a/webclient/src/hooks/useWebClient.tsx b/webclient/src/hooks/useWebClient.tsx new file mode 100644 index 000000000..469f03dc9 --- /dev/null +++ b/webclient/src/hooks/useWebClient.tsx @@ -0,0 +1,19 @@ +import { createContext, useContext, useState, ReactNode } from 'react'; +import { WebClient } from '@app/websocket'; +import { createWebClientRequest, createWebClientResponse } from '@app/api'; + +const WebClientContext = createContext(null); + +export function WebClientProvider({ children }: { children: ReactNode }) { + const [client] = useState(() => new WebClient(createWebClientRequest(), createWebClientResponse())); + + return {children}; +} + +export function useWebClient(): WebClient { + const client = useContext(WebClientContext); + if (!client) { + throw new Error('useWebClient must be used within a WebClientProvider'); + } + return client; +} diff --git a/webclient/src/index.tsx b/webclient/src/index.tsx index d90364a30..be51341a4 100644 --- a/webclient/src/index.tsx +++ b/webclient/src/index.tsx @@ -2,40 +2,29 @@ // creates the Redux store or connects to Redux DevTools. import './polyfills'; -import { StrictMode, useRef } from 'react'; +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { StyledEngineProvider } from '@mui/material'; import { ThemeProvider } from '@mui/material/styles'; -import { initWebClient } from '@app/api'; +import { WebClientProvider } from '@app/hooks'; import { AppShell } from '@app/containers'; import { materialTheme } from './material-theme'; import './i18n'; import './index.css'; -function useInitWebClient() { - const initialized = useRef(false); - - if (!initialized.current) { - initialized.current = true; - initWebClient(); - } -} - const AppWithMaterialTheme = () => { - // Instantiate the WebClient singleton before any container renders or any - // hook touches WebClient.instance. - useInitWebClient(); - return ( - - - - - - - + + + + + + + + + ); } diff --git a/webclient/src/websocket/WebClient.spec.ts b/webclient/src/websocket/WebClient.spec.ts index c76911949..2fab4d695 100644 --- a/webclient/src/websocket/WebClient.spec.ts +++ b/webclient/src/websocket/WebClient.spec.ts @@ -1,7 +1,6 @@ const captured = vi.hoisted(() => ({ wsOptions: null as WebSocketServiceConfig | null, pbOptions: null as SocketTransport | null, - pbEvents: null as EventRegistries | null, })); vi.mock('./services/WebSocketService', () => ({ @@ -18,9 +17,8 @@ vi.mock('./services/WebSocketService', () => ({ })); vi.mock('./services/ProtobufService', () => ({ - ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport, events: EventRegistries) { + ProtobufService: vi.fn().mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) { captured.pbOptions = transport; - captured.pbEvents = events; return { handleMessageEvent: vi.fn(), resetCommands: vi.fn(), @@ -34,10 +32,10 @@ import { ProtobufService } from './services/ProtobufService'; import { StatusEnum } from './interfaces/StatusEnum'; import { Subject } from 'rxjs'; import { Mock } from 'vitest'; -import { SocketTransport, EventRegistries } from './services/ProtobufService'; +import { SocketTransport } from './services/ProtobufService'; import { WebSocketServiceConfig } from './services/WebSocketService'; -import type { IWebClientResponse } from './interfaces'; -import type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; +import type { IWebClientResponse, IWebClientRequest } from './interfaces'; +import type { ConnectTarget } from './interfaces/WebClientConfig'; import { installMockWebSocket } from './__mocks__/helpers'; function makeMockResponse(): IWebClientResponse { @@ -58,28 +56,21 @@ function makeMockResponse(): IWebClientResponse { } as unknown as IWebClientResponse; } -function makeMockConfig(response: IWebClientResponse): WebClientConfig { - return { - response, - sessionEvents: [], - roomEvents: [], - gameEvents: [], - keepAliveFn: vi.fn(), - }; +function makeMockRequest(): IWebClientRequest { + return {} as IWebClientRequest; } describe('WebClient', () => { let client: WebClient; let mockResponse: IWebClientResponse; - let mockConfig: WebClientConfig; + let mockRequest: IWebClientRequest; let messageSubject: Subject; beforeEach(() => { (WebClient as unknown as { _instance: WebClient | null })._instance = null; - (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport, events: EventRegistries) { + (ProtobufService as Mock).mockImplementation(function ProtobufServiceImpl(transport: SocketTransport) { captured.pbOptions = transport; - captured.pbEvents = events; return { handleMessageEvent: vi.fn(), resetCommands: vi.fn(), @@ -99,8 +90,8 @@ describe('WebClient', () => { vi.spyOn(console, 'log').mockImplementation(() => {}); mockResponse = makeMockResponse(); - mockConfig = makeMockConfig(mockResponse); - client = new WebClient(mockConfig); + mockRequest = makeMockRequest(); + client = new WebClient(mockRequest, mockResponse); }); afterEach(() => { @@ -109,9 +100,9 @@ describe('WebClient', () => { }); describe('constructor', () => { - it('stores the response and config on the instance', () => { + it('stores the request and response on the instance', () => { + expect(client.request).toBe(mockRequest); expect(client.response).toBe(mockResponse); - expect(client.config).toBe(mockConfig); }); it('subscribes socket.message$ to protobuf.handleMessageEvent', () => { @@ -129,7 +120,7 @@ describe('WebClient', () => { }); it('throws when instantiated more than once', () => { - expect(() => new WebClient(makeMockConfig(makeMockResponse()))).toThrow(/singleton/); + expect(() => new WebClient(makeMockRequest(), makeMockResponse())).toThrow(/singleton/); }); }); @@ -224,10 +215,9 @@ describe('WebClient', () => { }); describe('constructor closures', () => { - it('keepAliveFn forwards from config to WebSocketService', () => { - const cb = vi.fn(); - captured.wsOptions!.keepAliveFn(cb); - expect(mockConfig.keepAliveFn).toHaveBeenCalledWith(cb); + it('keepAliveFn is set to ping function in WebSocketService', () => { + expect(captured.wsOptions!.keepAliveFn).toBeDefined(); + expect(typeof captured.wsOptions!.keepAliveFn).toBe('function'); }); it('onStatusChange routes to response.session.updateStatus and updates own status', () => { @@ -241,12 +231,6 @@ describe('WebClient', () => { expect(mockResponse.session.connectionFailed).toHaveBeenCalled(); }); - it('passes event registries from config to ProtobufService', () => { - expect(captured.pbEvents!.sessionEvents).toBe(mockConfig.sessionEvents); - expect(captured.pbEvents!.roomEvents).toBe(mockConfig.roomEvents); - expect(captured.pbEvents!.gameEvents).toBe(mockConfig.gameEvents); - }); - it('send closure delegates to socket.send', () => { const data = new Uint8Array([1, 2, 3]); captured.pbOptions!.send(data); diff --git a/webclient/src/websocket/WebClient.ts b/webclient/src/websocket/WebClient.ts index 725780643..ddd197ce6 100644 --- a/webclient/src/websocket/WebClient.ts +++ b/webclient/src/websocket/WebClient.ts @@ -1,39 +1,40 @@ -import { StatusEnum } from './interfaces/StatusEnum'; +import { ping } from './commands/session'; +import { CLIENT_OPTIONS } from './config'; +import type { + ConnectTarget, + IWebClientRequest, + IWebClientResponse, +} from './interfaces'; +import { StatusEnum } from './interfaces'; import { ProtobufService } from './services/ProtobufService'; import { WebSocketService } from './services/WebSocketService'; -import { CLIENT_OPTIONS } from './config'; -import type { IWebClientResponse } from './interfaces'; -import type { WebClientConfig, ConnectTarget } from './interfaces/WebClientConfig'; export class WebClient { private static _instance: WebClient | null = null; - public static get instance(): WebClient { + static get instance(): WebClient { if (!WebClient._instance) { throw new Error( - 'WebClient has not been initialized. Instantiate it via `new WebClient(config)` before accessing `WebClient.instance`.' + 'WebClient has not been initialized. Instantiate it via `new WebClient()` before accessing `WebClient.instance`.' ); } return WebClient._instance; } - public socket: WebSocketService; - public protobuf: ProtobufService; - public response: IWebClientResponse; - public config: WebClientConfig; + protobuf: ProtobufService; + socket: WebSocketService; + status: StatusEnum; - public status: StatusEnum; - - constructor(config: WebClientConfig) { + constructor( + public request: IWebClientRequest, + public response: IWebClientResponse + ) { if (WebClient._instance) { throw new Error('WebClient is a singleton and has already been initialized.'); } - this.config = config; - this.response = config.response; - this.socket = new WebSocketService({ - keepAliveFn: config.keepAliveFn, + keepAliveFn: ping, onStatusChange: (status, description) => { this.response.session.updateStatus(status, description); this.updateStatus(status); @@ -47,12 +48,7 @@ export class WebClient { { send: (data) => this.socket.send(data), isOpen: () => this.socket.checkReadyState(WebSocket.OPEN), - }, - { - sessionEvents: config.sessionEvents, - roomEvents: config.roomEvents, - gameEvents: config.gameEvents, - }, + } ); this.socket.message$.subscribe((message: MessageEvent) => { diff --git a/webclient/src/websocket/interfaces/index.ts b/webclient/src/websocket/interfaces/index.ts index 9478bac59..6c4459c91 100644 --- a/webclient/src/websocket/interfaces/index.ts +++ b/webclient/src/websocket/interfaces/index.ts @@ -17,3 +17,7 @@ export type { IModeratorRequest, IWebClientRequest, } from './WebClientRequest'; + +export * from './WebClientConfig'; +export * from './WebSocketConfig'; +export * from './StatusEnum'; diff --git a/webclient/src/websocket/services/ProtobufService.spec.ts b/webclient/src/websocket/services/ProtobufService.spec.ts index 5e7b58568..7880b9099 100644 --- a/webclient/src/websocket/services/ProtobufService.spec.ts +++ b/webclient/src/websocket/services/ProtobufService.spec.ts @@ -7,13 +7,25 @@ vi.mock('@bufbuild/protobuf', async (importOriginal) => ({ setExtension: vi.fn(), })); -vi.mock('../WebClient'); +vi.mock('../events/game', () => ({ + GameEvents: [], +})); + +vi.mock('../events/room', () => ({ + RoomEvents: [], +})); + +vi.mock('../events/session', () => ({ + SessionEvents: [], +})); import { create, fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf'; import type { GenExtension } from '@bufbuild/protobuf/codegenv2'; import { ProtobufService } from './ProtobufService'; -import type { EventRegistries } from './ProtobufService'; +import { GameEvents } from '../events/game'; +import { RoomEvents } from '../events/room'; +import { SessionEvents } from '../events/session'; import type { AdminCommand, @@ -43,18 +55,17 @@ type ProtobufInternal = ProtobufService & { }; let mockSocket: { isOpen: ReturnType; send: ReturnType }; -let mockEvents: EventRegistries; beforeEach(() => { mockSocket = { isOpen: vi.fn().mockReturnValue(true), send: vi.fn(), }; - mockEvents = { - sessionEvents: [], - roomEvents: [], - gameEvents: [], - }; + + // Reset event registries + (GameEvents as any).length = 0; + (RoomEvents as any).length = 0; + (SessionEvents as any).length = 0; }); describe('ProtobufService', () => { @@ -67,7 +78,7 @@ describe('ProtobufService', () => { describe('resetCommands', () => { it('resets cmdId and pendingCommands', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendSessionCommand(sessionExt, vi.fn()); expect((service as ProtobufInternal).cmdId).toBe(1); service.resetCommands(); @@ -78,7 +89,7 @@ describe('ProtobufService', () => { describe('sendCommand', () => { it('increments cmdId and stores callback', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendCommand(create(CommandContainerSchema), cb); expect((service as ProtobufInternal).cmdId).toBe(1); @@ -86,14 +97,14 @@ describe('ProtobufService', () => { }); it('sends encoded data when socket is OPEN', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); mockSocket.isOpen.mockReturnValue(true); service.sendCommand(create(CommandContainerSchema), vi.fn()); expect(mockSocket.send).toHaveBeenCalled(); }); it('does not register callback or increment cmdId when transport is closed', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); mockSocket.isOpen.mockReturnValue(false); const cb = vi.fn(); service.sendCommand(create(CommandContainerSchema), cb); @@ -105,14 +116,14 @@ describe('ProtobufService', () => { describe('sendSessionCommand', () => { it('stores callback and increments cmdId', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendSessionCommand(sessionExt, {}); expect((service as ProtobufInternal).cmdId).toBe(1); expect((service as ProtobufInternal).pendingCommands.get(1)).toBeTypeOf('function'); }); it('invokes onResponse with raw response when the pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendSessionCommand(sessionExt, {}, { onResponse: cb }); @@ -123,7 +134,7 @@ describe('ProtobufService', () => { }); it('does not throw when no callback is provided and pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendSessionCommand(sessionExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; @@ -133,13 +144,13 @@ describe('ProtobufService', () => { describe('sendRoomCommand', () => { it('stores callback and increments cmdId', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendRoomCommand(42, roomExt, {}); expect((service as ProtobufInternal).cmdId).toBe(1); }); it('invokes onResponse with raw response when the pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendRoomCommand(42, roomExt, {}, { onResponse: cb }); @@ -150,7 +161,7 @@ describe('ProtobufService', () => { }); it('does not throw when no callback is provided and pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendRoomCommand(42, roomExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; @@ -160,13 +171,13 @@ describe('ProtobufService', () => { describe('sendGameCommand', () => { it('stores callback and increments cmdId', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendGameCommand(7, gameExt, {}); expect((service as ProtobufInternal).cmdId).toBe(1); }); it('invokes onResponse with raw response when the pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendGameCommand(7, gameExt, {}, { onResponse: cb }); @@ -177,7 +188,7 @@ describe('ProtobufService', () => { }); it('does not throw when no callback is provided and pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendGameCommand(7, gameExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; @@ -187,13 +198,13 @@ describe('ProtobufService', () => { describe('sendModeratorCommand', () => { it('stores callback and increments cmdId', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendModeratorCommand(moderatorExt, {}); expect((service as ProtobufInternal).cmdId).toBe(1); }); it('invokes onResponse with raw response when the pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendModeratorCommand(moderatorExt, {}, { onResponse: cb }); @@ -204,7 +215,7 @@ describe('ProtobufService', () => { }); it('does not throw when no callback is provided and pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendModeratorCommand(moderatorExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; @@ -214,13 +225,13 @@ describe('ProtobufService', () => { describe('sendAdminCommand', () => { it('stores callback and increments cmdId', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendAdminCommand(adminExt, {}); expect((service as ProtobufInternal).cmdId).toBe(1); }); it('invokes onResponse with raw response when the pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); service.sendAdminCommand(adminExt, {}, { onResponse: cb }); @@ -231,7 +242,7 @@ describe('ProtobufService', () => { }); it('does not throw when no callback is provided and pending command is triggered', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); service.sendAdminCommand(adminExt, {}); const storedCb = (service as ProtobufInternal).pendingCommands.get(1)!; @@ -241,7 +252,7 @@ describe('ProtobufService', () => { describe('handleMessageEvent', () => { it('routes RESPONSE message to processServerResponse', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const cb = vi.fn(); (service as ProtobufInternal).cmdId = 1; (service as ProtobufInternal).pendingCommands.set(1, cb); @@ -259,7 +270,7 @@ describe('ProtobufService', () => { }); it('routes ROOM_EVENT message', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const processRoomEvent = vi.spyOn(service as ProtobufInternal, 'processRoomEvent'); vi.mocked(fromBinary).mockReturnValue( @@ -273,7 +284,7 @@ describe('ProtobufService', () => { }); it('routes SESSION_EVENT message', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const processSessionEvent = vi.spyOn(service as ProtobufInternal, 'processSessionEvent'); vi.mocked(fromBinary).mockReturnValue( @@ -287,7 +298,7 @@ describe('ProtobufService', () => { }); it('routes GAME_EVENT_CONTAINER message', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const processGameEvent = vi.spyOn(service as ProtobufInternal, 'processGameEvent'); vi.mocked(fromBinary).mockReturnValue( @@ -301,7 +312,7 @@ describe('ProtobufService', () => { }); it('logs unknown message types (default case)', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); vi.mocked(fromBinary).mockReturnValue( @@ -316,13 +327,13 @@ describe('ProtobufService', () => { }); it('does nothing when decoded message is null', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); vi.mocked(fromBinary).mockReturnValue(null!); expect(() => service.handleMessageEvent({ data: new ArrayBuffer(0) } as MessageEvent)).not.toThrow(); }); it('catches and logs decode errors', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.mocked(fromBinary).mockImplementation(() => { throw new Error('decode error'); @@ -335,7 +346,7 @@ describe('ProtobufService', () => { describe('processGameEvent', () => { it('returns early when container has no eventList', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(false); (service as ProtobufInternal).processGameEvent(null, {}); expect(hasExtension).not.toHaveBeenCalled(); @@ -346,8 +357,8 @@ describe('ProtobufService', () => { const mockExt = {} as GenExtension; const payload = { someData: 1 }; - mockEvents.gameEvents.push([mockExt, handler] as any); - const service = new ProtobufService(mockSocket, mockEvents); + (GameEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -364,8 +375,8 @@ describe('ProtobufService', () => { const mockExt = {} as GenExtension; const payload = { someData: 1 }; - mockEvents.gameEvents.push([mockExt, handler] as any); - const service = new ProtobufService(mockSocket, mockEvents); + (GameEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -380,7 +391,7 @@ describe('ProtobufService', () => { describe('processServerResponse', () => { it('returns early when response is undefined', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); (service as ProtobufInternal).pendingCommands.set(1, vi.fn()); (service as ProtobufInternal).processServerResponse(undefined); expect((service as ProtobufInternal).pendingCommands.size).toBe(1); @@ -389,7 +400,7 @@ describe('ProtobufService', () => { describe('processRoomEvent', () => { it('returns early when event is undefined', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(false); (service as ProtobufInternal).processRoomEvent(undefined); expect(hasExtension).not.toHaveBeenCalled(); @@ -400,8 +411,8 @@ describe('ProtobufService', () => { const mockExt = {} as GenExtension; const payload = { roomData: 1 }; - mockEvents.roomEvents.push([mockExt, handler] as any); - const service = new ProtobufService(mockSocket, mockEvents); + (RoomEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); @@ -414,7 +425,7 @@ describe('ProtobufService', () => { describe('processSessionEvent', () => { it('returns early when event is undefined', () => { - const service = new ProtobufService(mockSocket, mockEvents); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(false); (service as ProtobufInternal).processSessionEvent(undefined); expect(hasExtension).not.toHaveBeenCalled(); @@ -425,8 +436,8 @@ describe('ProtobufService', () => { const mockExt = {} as GenExtension; const payload = { sessionData: 1 }; - mockEvents.sessionEvents.push([mockExt, handler] as any); - const service = new ProtobufService(mockSocket, mockEvents); + (SessionEvents as any).push([mockExt, handler]); + const service = new ProtobufService(mockSocket); vi.mocked(hasExtension).mockReturnValue(true); vi.mocked(getExtension).mockReturnValue(payload); diff --git a/webclient/src/websocket/services/ProtobufService.ts b/webclient/src/websocket/services/ProtobufService.ts index 6c0bb003f..05437f80e 100644 --- a/webclient/src/websocket/services/ProtobufService.ts +++ b/webclient/src/websocket/services/ProtobufService.ts @@ -21,10 +21,11 @@ import { type GameEventContainer, type SessionEvent, type RoomEvent, - type RegistryEntry, - type GameEvent, } from '@app/generated'; +import { GameEvents } from '../events/game'; +import { RoomEvents } from '../events/room'; +import { SessionEvents } from '../events/session'; import type { GameEventMeta } from '../interfaces/WebSocketConfig'; import { type CommandOptions, handleResponse } from './command-options'; @@ -33,23 +34,11 @@ export interface SocketTransport { isOpen(): boolean; } -export interface EventRegistries { - sessionEvents: RegistryEntry[]; - roomEvents: RegistryEntry[]; - gameEvents: RegistryEntry[]; -} - export class ProtobufService { private cmdId = 0; private pendingCommands = new Map void>(); - private transport: SocketTransport; - private events: EventRegistries; - - constructor(transport: SocketTransport, events: EventRegistries) { - this.transport = transport; - this.events = events; - } + constructor(private transport: SocketTransport) {} public resetCommands() { this.cmdId = 0; @@ -189,7 +178,7 @@ export class ProtobufService { if (!event) { return; } - for (const [ext, handler] of this.events.roomEvents) { + for (const [ext, handler] of RoomEvents) { if (hasExtension(event, ext)) { handler(getExtension(event, ext), event); return; @@ -201,7 +190,7 @@ export class ProtobufService { if (!event) { return; } - for (const [ext, handler] of this.events.sessionEvents) { + for (const [ext, handler] of SessionEvents) { if (hasExtension(event, ext)) { handler(getExtension(event, ext), undefined); return; @@ -225,7 +214,7 @@ export class ProtobufService { forcedByJudge: forcedByJudge ?? 0, }; - for (const [ext, handler] of this.events.gameEvents) { + for (const [ext, handler] of GameEvents) { if (hasExtension(event, ext)) { handler(getExtension(event, ext), meta); break;