Browse Source

Introduce message handlers, fix login reducer.

main
Titouan Rigoudy 4 years ago
parent
commit
41e0edbcfb
8 changed files with 229 additions and 124 deletions
  1. +4
    -3
      src/app/store.ts
  2. +8
    -4
      src/components/LoginStatusPane.js
  3. +39
    -62
      src/containers/ConnectPage.js
  4. +45
    -0
      src/modules/login/message.ts
  5. +15
    -13
      src/modules/login/slice.ts
  6. +39
    -0
      src/modules/websocket/message.ts
  7. +75
    -41
      src/modules/websocket/middleware.ts
  8. +4
    -1
      src/modules/websocket/slice.ts

+ 4
- 3
src/app/store.ts View File

@ -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))
)
});


+ 8
- 4
src/components/LoginStatusPane.js View File

@ -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 = (
<span id="login-status-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 = (
<span id="login-status-reason">
Reason: {props.reason}
</span>
);
break;
default:
statusText = `invalid status ${props.status}`;
break;
}
return (


+ 39
- 62
src/containers/ConnectPage.js View File

@ -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<Props> {
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 (
<div id="connect-page">
<ConnectForm socket={socket} />
</div>
);
}
// 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 (
<Redirect to={{
pathname: "/app/rooms",
state: { from: location },
}} />
);
}
return (
<div id="connect-page">
<ConnectForm socket={socket} />
</div>
);
}
const mapStateToProps = ({ socket }) => ({ socket });
export default connect(mapStateToProps)(withRouter(ConnectPage));
export default ConnectPage;

+ 45
- 0
src/modules/login/message.ts View File

@ -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,
];

+ 15
- 13
src/modules/login/slice.ts View File

@ -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<LoginSliceState>) => {
state = action.payload;
},
},
});
export const { } =
loginSlice.actions;
export const { loginSetState } = loginSlice.actions;
export const selectLogin = (state: RootState) => state.login;
export const loginFetchStatus = createAction<undefined>("loginFetchStatus");
export const loginGetStatus = () => {};
//export const socketSendMessage = createAction<object>("socketSendMessage");
export const selectLogin = (state: RootState) => state.login;
export default loginSlice.reducer;

+ 39
- 0
src/modules/websocket/message.ts View File

@ -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<any>,
};
// 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<any> | 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,
};

+ 75
- 41
src/modules/websocket/middleware.ts View File

@ -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<string, SocketMessageDataHandler[]> {
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<string, SocketMessageDataHandler[]>) =>
(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;

+ 4
- 1
src/modules/websocket/slice.ts View File

@ -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<object>("socketSendMessage");
export const socketSendMessage =
createAction<SocketMessage>("socketSendMessage");
export default socketSlice.reducer;

Loading…
Cancel
Save