From 41e0edbcfbb9a8000399007628fc5948290ebae9 Mon Sep 17 00:00:00 2001 From: Titouan Rigoudy Date: Tue, 27 Jul 2021 17:59:30 -0400 Subject: [PATCH] Introduce message handlers, fix login reducer. --- src/app/store.ts | 7 +- src/components/LoginStatusPane.js | 12 ++- src/containers/ConnectPage.js | 101 ++++++++++-------------- src/modules/login/message.ts | 45 +++++++++++ src/modules/login/slice.ts | 28 +++---- src/modules/websocket/message.ts | 39 ++++++++++ src/modules/websocket/middleware.ts | 116 ++++++++++++++++++---------- src/modules/websocket/slice.ts | 5 +- 8 files changed, 229 insertions(+), 124 deletions(-) create mode 100644 src/modules/login/message.ts create mode 100644 src/modules/websocket/message.ts diff --git a/src/app/store.ts b/src/app/store.ts index c7b2864..89d3112 100644 --- a/src/app/store.ts +++ b/src/app/store.ts @@ -4,12 +4,12 @@ import counterReducer from "../features/counter/counterSlice"; // TODO: Rework these to use a slice, otherwise redux complains about // non-serializable Immutable.js types. // -// import login from "../reducers/login"; // import rooms from "../reducers/rooms"; // import users from "../reducers/users"; // +import { loginSocketMessageHandlers } from "../modules/login/message"; import loginReducer from "../modules/login/slice"; -import socketMiddleware from "../modules/websocket/middleware"; +import makeSocketMiddleware from "../modules/websocket/middleware"; import socketReducer from "../modules/websocket/slice"; export const store = configureStore({ @@ -21,7 +21,8 @@ export const store = configureStore({ //users, }, middleware: (getDefaultMiddleware) => ( - getDefaultMiddleware().concat(socketMiddleware) + getDefaultMiddleware().concat(makeSocketMiddleware( + loginSocketMessageHandlers)) ) }); diff --git a/src/components/LoginStatusPane.js b/src/components/LoginStatusPane.js index 9b8e8aa..cbf6574 100644 --- a/src/components/LoginStatusPane.js +++ b/src/components/LoginStatusPane.js @@ -10,16 +10,16 @@ const LoginStatusPane = (props: LoginSliceState) => { statusText = "unknown"; break; - case LoginStatus.Getting: + case LoginStatus.Fetching: statusText = "fetching"; break; case LoginStatus.Pending: - statusText = `logging in as ${this.props.username}`; + statusText = `logging in as ${props.username}`; break; case LoginStatus.Success: - statusText = `logged in as ${this.props.username}`; + statusText = `logged in as ${props.username}`; motd = ( MOTD: {props.motd} @@ -28,13 +28,17 @@ const LoginStatusPane = (props: LoginSliceState) => { break; case LoginStatus.Failure: - statusText = `failed to log in as ${this.props.username}`; + statusText = `failed to log in as ${props.username}`; reason = ( Reason: {props.reason} ); break; + + default: + statusText = `invalid status ${props.status}`; + break; } return ( diff --git a/src/containers/ConnectPage.js b/src/containers/ConnectPage.js index 3a76f63..c6c2949 100644 --- a/src/containers/ConnectPage.js +++ b/src/containers/ConnectPage.js @@ -1,69 +1,46 @@ -import { History } from "history"; -import React from "react"; -import { connect } from "react-redux"; -import { withRouter } from "react-router"; -import { bindActionCreators } from "redux"; - -import { SocketSliceState, SocketState } from "../modules/websocket/slice"; -import LoginActions from "../actions/LoginActions"; -import SocketActions from "../actions/SocketActions"; -import SocketHandlerActions from "../actions/SocketHandlerActions"; - -import { STATE_OPEN } from "../constants/socket"; -import { - LOGIN_STATUS_SUCCESS, - LOGIN_STATUS_UNKNOWN -} from "../constants/login"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation } from "react-router"; +import { Redirect } from "react-router-dom"; import ConnectForm from "../components/ConnectForm"; import LoginStatusPane from "../components/LoginStatusPane"; - -interface Props { - history: History, - socket: SocketSliceState, -}; - -// TODO: Gate access on login state too. -class ConnectPage extends React.Component { - constructor(props) { - super(props); - } - - componentDidMount() { - this.getLoginStatusOrRedirect(this.props); - } - - componentWillReceiveProps(nextProps) { - this.getLoginStatusOrRedirect(nextProps); - } - - getLoginStatusOrRedirect({ history, socket }: Props) { - if (socket.state === SocketState.Open) { - history.push("/app/rooms"); - } - /* - switch (login.status) { - case LOGIN_STATUS_UNKNOWN: - actions.login.getStatus(); - break; - - case LOGIN_STATUS_SUCCESS: - router.push("/app/rooms"); - break; - } - */ +import { LoginStatus, selectLogin } from "../modules/login/slice"; +import { loginStatusRequest } from "../modules/login/message"; +import { + selectSocket, + socketSendMessage, + SocketSliceState, + 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())); } - render() { - const { socket } = this.props; - return ( -
- -
- ); - } + // 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... + return ( + + ); + } + + return ( +
+ +
+ ); } -const mapStateToProps = ({ socket }) => ({ socket }); - -export default connect(mapStateToProps)(withRouter(ConnectPage)); +export default ConnectPage; diff --git a/src/modules/login/message.ts b/src/modules/login/message.ts new file mode 100644 index 0000000..f76b6fa --- /dev/null +++ b/src/modules/login/message.ts @@ -0,0 +1,45 @@ +import { loginSetState } from "../login/slice"; +import { SocketMessage } from "../websocket/message"; + +export function loginStatusRequest(): SocketMessage { + return { + variant: "LoginStatusRequest", + } +} + +const loginStatusResponseHandler: SocketMessageHandler = { + variant: "LoginStatusResponse", + + handleData: ({ variant, fields }) => { + switch (variant) { + case "Pending": { + const [ username ] = fields; + return loginSetState({ + status: LoginStatus.Pending, + username, + }); + } + case "Success": { + const [ username, motd ] = data.fields; + return loginSetState({ + status: LoginStatus.Success, + username, + motd, + }); + } + + case "Failure": { + const [ username, reason ] = data.fields; + return loginSetState({ + status: LoginStatus.Failure, + username, + reason, + }); + } + } + }, +}; + +export const loginSocketMessageHandlers = [ + loginStatusResponseHandler, +]; diff --git a/src/modules/login/slice.ts b/src/modules/login/slice.ts index e2073a7..981a95c 100644 --- a/src/modules/login/slice.ts +++ b/src/modules/login/slice.ts @@ -1,9 +1,10 @@ import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; + import { RootState } from '../../app/store'; export enum LoginStatus { Unknown, - Getting, + Fetching, Pending, Success, Failure, @@ -11,32 +12,33 @@ export enum LoginStatus { export interface LoginSliceState { status: LoginStatus, - username: string | null, - motd: string | null, - reason: string | null, + username?: string, + motd?: string, + reason?: string, }; const initialState: LoginSliceState = { status: LoginStatus.Unknown, - url: null, - motd: null, - reason: null, + url: undefined, + motd: undefined, + reason: undefined, }; export const loginSlice = createSlice({ name: 'login', initialState, reducers: { - // TODO: add reducer for message received. + loginSetState: + (state: RootState, action: PayloadAction) => { + state = action.payload; + }, }, }); -export const { } = - loginSlice.actions; +export const { loginSetState } = loginSlice.actions; -export const selectLogin = (state: RootState) => state.login; +export const loginFetchStatus = createAction("loginFetchStatus"); -export const loginGetStatus = () => {}; -//export const socketSendMessage = createAction("socketSendMessage"); +export const selectLogin = (state: RootState) => state.login; export default loginSlice.reducer; diff --git a/src/modules/websocket/message.ts b/src/modules/websocket/message.ts new file mode 100644 index 0000000..2a555f6 --- /dev/null +++ b/src/modules/websocket/message.ts @@ -0,0 +1,39 @@ +// Defines the type of socket messages and related abstractions. + +import { PayloadAction } from "@reduxjs/toolkit"; + +// The type of a message exchanged over a websocket. +export interface SocketMessage { + variant: string, + fields?: List, +}; + +// Serializes the given message for sending over the wire. +export function serializeMessage(message: SocketMessage): string { + return JSON.stringify(message); +} + +// Attempts to parse a message out of the given data. +// Throws an error if unsuccessful. +export function parseMessage(serialized: string): SocketMessage { + const { variant, fields } = JSON.parse(serialized); + if (typeof variant === "undefined") { + throw new Error('Missing "variant" field in socket message'); + } + + return { variant, fields }; +}; + +// Handles the `data` field of a `SocketMessage` and returns an optional action. +export type SocketMessageDataHandler = (any) => PayloadAction | undefined; + +// A message handler creates an action out of a socket message. +// +// 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, +}; diff --git a/src/modules/websocket/middleware.ts b/src/modules/websocket/middleware.ts index 8283c56..5f658e4 100644 --- a/src/modules/websocket/middleware.ts +++ b/src/modules/websocket/middleware.ts @@ -5,78 +5,112 @@ import { Middleware } from "redux"; +import { + SocketMessageDataHandler, + SocketMessageHandler, + parseMessage, +} from "./message"; import { socketOpen, socketOpened, socketClose, socketClosed, socketSendMessage, - socketReceiveMessage, } from "./slice"; -import { RootState } from "../app/store"; -import { SOCKET_RECEIVE_MESSAGE } from "../../constants/ActionTypes"; +import { AppDispatch, RootState } from "../app/store"; // The WebSocket singleton. +// TODO: use undefined instead, it is more idiomatic. let socket: WebSocket | null = null; -const onOpen = dispatch => event => { +const onOpen = (dispatch: AppDispatch) => event => { console.log('Websocket open', event.target.url); dispatch(socketOpened(event.target.url)); }; -const onClose = dispatch => () => { +const onClose = (dispatch: AppDispatch) => () => { dispatch(socketClosed()); }; -const onMessage = dispatch => event => { - console.log(`Websocket received message: ${event.data}`); +function prepareHandlers(handlers: SocketMessageHandler[]) + : Map { + 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; +} - // TODO: dispatch different actions based on payload type. +const onMessage = + (dispatch: AppDispatch, handlers: Map) => + (event: MessageEvent) => { + console.log("Websocket received message", event.data); - const action = { type: SOCKET_RECEIVE_MESSAGE }; + let message; try { - const { variant, fields: [data] } = JSON.parse(event.data); - if (typeof variant === "undefined") { - throw new Error('Missing "variant" field in control response'); - } - action.payload = { variant, data }; + message = parseMessage(event.data); } catch (err) { - action.error = true; - action.payload = err; + console.log("Websocket received invalid message:", err); + return; + } + + const list = handlers.get(message.variant); + if (list === undefined) { + return; } - dispatch(action); + for (handler of list) { + const action = handler(message); + if (action !== undefined) { + dispatch(action); + } + } }; // See: https://redux.js.org/tutorials/fundamentals/part-4-store#writing-custom-middleware -const middleware: Middleware<{}, RootState> = storeApi => next => action => { - if (socketOpen.match(action)) { - if (socket !== null) { - socket.close(); - } +function makeMiddleware(handlers: SocketMessageHandler[]) + : Middleware<{}, RootState> { + const handlerMap = prepareHandlers(handlers); + + return storeApi => next => action => { + if (socketOpen.match(action)) { + if (socket !== null) { + socket.close(); + } - // Connect to the remote host. - socket = new WebSocket(action.payload); + // Connect to the remote host. + socket = new WebSocket(action.payload); - // Bind websocket handlers. - socket.onmessage = onMessage(storeApi.dispatch); - socket.onclose = onClose(storeApi.dispatch); - socket.onopen = onOpen(storeApi.dispatch); + // Bind websocket handlers. + socket.onmessage = onMessage(storeApi.dispatch, handlerMap); + socket.onclose = onClose(storeApi.dispatch); + socket.onopen = onOpen(storeApi.dispatch); - } else if (socketClose.match(action)) { - if (socket !== null) { - socket.close(); - } + } else if (socketClose.match(action)) { + if (socket !== null) { + socket.close(); + } - socket = null; - console.log('Websocket closed.'); + socket = null; + console.log('Websocket closed.'); - } else if (socketSendMessage.match(action)) { - console.log('Websocket sending message', action.payload); - socket.send(JSON.stringify(action.payload)); - } + } else if (socketSendMessage.match(action)) { + console.log('Websocket sending message', action.payload); + socket.send(JSON.stringify(action.payload)); + } - return next(action); -}; + return next(action); + }; +} -export default middleware; +export default makeMiddleware; diff --git a/src/modules/websocket/slice.ts b/src/modules/websocket/slice.ts index 5ce3f81..d97d0d3 100644 --- a/src/modules/websocket/slice.ts +++ b/src/modules/websocket/slice.ts @@ -1,4 +1,6 @@ import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { SocketMessage } from "./message"; import { RootState } from '../../app/store'; export enum SocketState { @@ -44,6 +46,7 @@ export const { socketOpen, socketOpened, socketClose, socketClosed } = export const selectSocket = (state: RootState) => state.socket; -export const socketSendMessage = createAction("socketSendMessage"); +export const socketSendMessage = + createAction("socketSendMessage"); export default socketSlice.reducer;