From fd6cb726257130e032785230ea71597a4afa6f4e Mon Sep 17 00:00:00 2001 From: Titouan Rigoudy Date: Wed, 28 Jul 2021 22:00:54 -0400 Subject: [PATCH] Define socket message middleware. --- src/app/store.ts | 4 +- src/components/LoginStatusPane.tsx | 18 ++++++- src/containers/ConnectPage.tsx | 16 +------ src/modules/login/message.ts | 74 +++++++++++++++++++---------- src/modules/login/slice.ts | 9 ++-- src/modules/websocket/message.ts | 29 ++++++----- src/modules/websocket/middleware.ts | 72 ++++++++++++---------------- src/modules/websocket/slice.ts | 1 + 8 files changed, 124 insertions(+), 99 deletions(-) diff --git a/src/app/store.ts b/src/app/store.ts index 5b6ddb9..25f9743 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -12,7 +12,7 @@ import counterReducer from "../features/counter/counterSlice"; // import rooms from "../reducers/rooms"; // import users from "../reducers/users"; // -import { loginSocketMessageHandlers } from "../modules/login/message"; +import { loginSocketMessageMiddleware } from "../modules/login/message"; import loginReducer from "../modules/login/slice"; import roomReducer from "../modules/room/slice"; import makeSocketMiddleware from "../modules/websocket/middleware"; @@ -36,7 +36,7 @@ export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat( - makeSocketMiddleware(loginSocketMessageHandlers) + makeSocketMiddleware([loginSocketMessageMiddleware]) ), }); diff --git a/src/components/LoginStatusPane.tsx b/src/components/LoginStatusPane.tsx index 54bc918..206aa77 100644 --- a/src/components/LoginStatusPane.tsx +++ b/src/components/LoginStatusPane.tsx @@ -1,12 +1,26 @@ -import { FC } from "react"; +import { FC, useEffect } from "react"; +import { useDispatch } from "react-redux"; -import { LoginStatus, LoginSliceState } from "../modules/login/slice"; +import { + loginFetchStatus, + LoginStatus, + LoginSliceState, +} from "../modules/login/slice"; interface Props { login: LoginSliceState; } const LoginStatusPane: FC = ({ login }) => { + const dispatch = useDispatch(); + + // Asynchronously fetch the login status if it is still unknown. + useEffect(() => { + if (login.status === LoginStatus.Unknown) { + dispatch(loginFetchStatus()); + } + }); + let statusText; let motd; let reason; diff --git a/src/containers/ConnectPage.tsx b/src/containers/ConnectPage.tsx index 79a123b..90fc838 100644 --- a/src/containers/ConnectPage.tsx +++ b/src/containers/ConnectPage.tsx @@ -1,28 +1,16 @@ -import { useDispatch, useSelector } from "react-redux"; +import { useSelector } from "react-redux"; import { useLocation } from "react-router"; import { Redirect } from "react-router-dom"; import ConnectForm from "../components/ConnectForm"; -import { LoginStatus, selectLogin } from "../modules/login/slice"; -import { loginStatusRequest } from "../modules/login/message"; -import { - selectSocket, - socketSendMessage, - SocketState, -} from "../modules/websocket/slice"; +import { selectSocket, SocketState } from "../modules/websocket/slice"; const ConnectPage: React.FC = () => { const socket = useSelector(selectSocket); - const login = useSelector(selectLogin); const location = useLocation(); - const dispatch = useDispatch(); // If the socket is open and we are logged in, we can proceed to the app. if (socket.state === SocketState.Open) { - if (login.status === LoginStatus.Unknown) { - dispatch(socketSendMessage(loginStatusRequest())); - } - // TODO: Only redirect once login status is known. The solstice client does // not seem to respond to our request for the login status, however. Until // that is the case, we cannot block on receiving a response... diff --git a/src/modules/login/message.ts b/src/modules/login/message.ts index 736a07c..a35a709 100644 --- a/src/modules/login/message.ts +++ b/src/modules/login/message.ts @@ -1,44 +1,70 @@ -import { loginSetState, LoginStatus } from "../login/slice"; -import { SocketMessage, SocketMessageHandler } from "../websocket/message"; +import { AppDispatch } from "../../app/store"; +import { loginFetchStatus, loginSetState, LoginStatus } from "../login/slice"; +import { SocketMessage, SocketMessageMiddleware } from "../websocket/message"; -export function loginStatusRequest(): SocketMessage { +function loginStatusRequest(): SocketMessage { return { variant: "LoginStatusRequest", fields: [], }; } -const loginStatusResponseHandler: SocketMessageHandler = { - variant: "LoginStatusResponse", +function handleLoginStatusResponse(dispatch: AppDispatch, outerFields: any[]) { + if (outerFields.length !== 1) { + console.log("LoginStatusResponse has wrong number of fields:", outerFields); + return; + } - handleData: ({ variant, fields }) => { - switch (variant) { - case "Pending": { - const [username] = fields; - return loginSetState({ + const { variant, fields } = outerFields[0]; + switch (variant) { + case "Pending": { + const [username] = fields; + dispatch( + loginSetState({ status: LoginStatus.Pending, username, - }); - } - case "Success": { - const [username, motd] = fields; - return loginSetState({ + }) + ); + break; + } + case "Success": { + const [username, motd] = fields; + dispatch( + loginSetState({ status: LoginStatus.Success, username, motd, - }); - } + }) + ); + break; + } - case "Failure": { - const [username, reason] = fields; - return loginSetState({ + case "Failure": { + const [username, reason] = fields; + dispatch( + loginSetState({ status: LoginStatus.Failure, username, reason, - }); - } + }) + ); + break; + } + } +} + +export const loginSocketMessageMiddleware: SocketMessageMiddleware = { + handleMessage: (dispatch, { variant, fields }) => { + switch (variant) { + case "LoginStatusResponse": + handleLoginStatusResponse(dispatch, fields); + break; } }, -}; -export const loginSocketMessageHandlers = [loginStatusResponseHandler]; + handleAction: (send, action) => { + if (loginFetchStatus.match(action)) { + send(loginStatusRequest()); + } + }, +}; diff --git a/src/modules/login/slice.ts b/src/modules/login/slice.ts index 9107da3..284ad54 100644 --- a/src/modules/login/slice.ts +++ b/src/modules/login/slice.ts @@ -1,4 +1,4 @@ -import { createAction, createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { RootState } from "../../app/store"; @@ -25,6 +25,9 @@ export const loginSlice = createSlice({ name: "login", initialState, reducers: { + loginFetchStatus: (state: LoginSliceState) => { + state.status = LoginStatus.Fetching; + }, loginSetState: ( state: LoginSliceState, action: PayloadAction @@ -34,9 +37,7 @@ export const loginSlice = createSlice({ }, }); -export const { loginSetState } = loginSlice.actions; - -export const loginFetchStatus = createAction("loginFetchStatus"); +export const { loginFetchStatus, loginSetState } = loginSlice.actions; export const selectLogin = (state: RootState) => state.login; diff --git a/src/modules/websocket/message.ts b/src/modules/websocket/message.ts index 9495aa8..7e89024 100644 --- a/src/modules/websocket/message.ts +++ b/src/modules/websocket/message.ts @@ -2,6 +2,8 @@ import { PayloadAction } from "@reduxjs/toolkit"; +import { AppDispatch } from "../../app/store"; + // The type of a message exchanged over a websocket. export interface SocketMessage { variant: string; @@ -24,18 +26,21 @@ export function parseMessage(serialized: string): SocketMessage { return { variant, fields }; } -// Handles the `data` field of a `SocketMessage` and returns an optional action. -export type SocketMessageDataHandler = ( - data: any -) => PayloadAction | undefined; +// An interface passed to socket message middleware, used to send messages. +export type SocketMessageSender = (message: SocketMessage) => void; -// A message handler creates an action out of a socket message. +// A message middleware translates messages into actions and vice-versa. // -// Handlers are used by the websocket middleware to translate incoming messages -// into actions dispatched to the store. This way reducers can stay focused on -// their actions only. Otherwise, most reducers would have to handle all -// incoming messages. -export interface SocketMessageHandler { - variant: string; - handleData: SocketMessageDataHandler; +// It is passed to the main middleware of this module, and observes both +// messages received from the socket, and actions flowing to the Redux store. +// +// Messages can be translated into actions and dispatched to the store. +// Actions can be translated into messages and sent on the socket. +export interface SocketMessageMiddleware { + // Handles the given `message`, and possible `dispatch` some actions. + handleMessage: (dispatch: AppDispatch, message: SocketMessage) => void; + + // Handles the given action, and possible `send` some messages. + // Note that this is only called when the socket is open. + handleAction: (send: SocketMessageSender, action: PayloadAction) => void; } diff --git a/src/modules/websocket/middleware.ts b/src/modules/websocket/middleware.ts index cec4bc4..68e9c81 100644 --- a/src/modules/websocket/middleware.ts +++ b/src/modules/websocket/middleware.ts @@ -6,9 +6,11 @@ import { Middleware } from "redux"; import { - SocketMessageDataHandler, - SocketMessageHandler, parseMessage, + serializeMessage, + SocketMessage, + SocketMessageMiddleware, + SocketMessageSender, } from "./message"; import { socketOpen, @@ -22,6 +24,13 @@ import { AppDispatch, RootState } from "../../app/store"; // The WebSocket singleton. let socket: WebSocket | undefined; +function socketMessageSender(socket: WebSocket): SocketMessageSender { + return (message: SocketMessage) => { + console.log("WebSocket: sending message", message); + socket.send(serializeMessage(message)); + }; +} + const makeOnOpen = (dispatch: AppDispatch) => (event: Event) => { const target = event.target as WebSocket; console.log("WebSocket open", target.url); @@ -32,28 +41,11 @@ const makeOnClose = (dispatch: AppDispatch) => () => { dispatch(socketClosed()); }; -type HandlerMap = Map; - -function prepareHandlers(handlers: SocketMessageHandler[]): HandlerMap { - const map = new Map(); - - for (const handler of handlers) { - console.log(`Registering handler for message variant ${handler.variant}`); - - let list = map.get(handler.variant); - if (list === undefined) { - list = []; - map.set(handler.variant, list); - } - - list.push(handler.handleData); - } - - return map; -} - -const makeOnMessage = - (dispatch: AppDispatch, handlers: HandlerMap) => (event: MessageEvent) => { +const makeOnMessage = ( + dispatch: AppDispatch, + middlewares: SocketMessageMiddleware[] +) => { + return (event: MessageEvent) => { console.log("WebSocket received message", event.data); let message; @@ -64,28 +56,21 @@ const makeOnMessage = return; } - const list = handlers.get(message.variant); - if (list === undefined) { - return; - } - - for (const handler of list) { - const action = handler(message); - if (action !== undefined) { - dispatch(action); - } + for (const middleware of middlewares) { + middleware.handleMessage(dispatch, message); } }; +}; type SocketFactory = (url: string) => WebSocket; function makeSocketFactory( dispatch: AppDispatch, - handlers: HandlerMap + middlewares: SocketMessageMiddleware[] ): SocketFactory { const onOpen = makeOnOpen(dispatch); const onClose = makeOnClose(dispatch); - const onMessage = makeOnMessage(dispatch, handlers); + const onMessage = makeOnMessage(dispatch, middlewares); return (url) => { socket = new WebSocket(url); @@ -101,12 +86,13 @@ function makeSocketFactory( // See: https://redux.js.org/tutorials/fundamentals/part-4-store#writing-custom-middleware function makeMiddleware( - handlers: SocketMessageHandler[] + messageMiddlewares: SocketMessageMiddleware[] ): Middleware<{}, RootState> { - const handlerMap = prepareHandlers(handlers); - return (storeApi) => { - const socketFactory = makeSocketFactory(storeApi.dispatch, handlerMap); + const socketFactory = makeSocketFactory( + storeApi.dispatch, + messageMiddlewares + ); return (next) => (action) => { if (socketOpen.match(action)) { @@ -128,10 +114,14 @@ function makeMiddleware( } else if (socketSendMessage.match(action)) { if (socket !== undefined) { console.log("WebSocket sending message", action.payload); - socket.send(JSON.stringify(action.payload)); + socket.send(serializeMessage(action.payload)); } else { console.log("Ignoring socketSendMessage action, socket is closed."); } + } else if (socket !== undefined) { + for (const middleware of messageMiddlewares) { + middleware.handleAction(socketMessageSender(socket), action); + } } return next(action); diff --git a/src/modules/websocket/slice.ts b/src/modules/websocket/slice.ts index df7208f..287a331 100644 --- a/src/modules/websocket/slice.ts +++ b/src/modules/websocket/slice.ts @@ -45,6 +45,7 @@ export const { socketOpen, socketOpened, socketClose, socketClosed } = export const selectSocket = (state: RootState) => state.socket; +// TODO: Remove this in favor of message middlewares. export const socketSendMessage = createAction("socketSendMessage");