Redux Saga socket.io - redux

I'm currently developing a chat client which have to be able to receive messages and to send messages. The only problem I'm facing to is that I really don't know how to send messages inside the given component from the Saga example.
I found the example on their API documentation https://redux-saga.js.org/docs/advanced/Channels.html.
Am I be able to reuse the socket const I created inside the watchSocketChannel function? Or do I need to just create the connection twice? What would you advise to do?
import {all, apply, call, fork, put, take} from 'redux-saga/effects'
import {eventChannel} from 'redux-saga';
import * as actions from "../actions";
import io from 'socket.io-client';
function createSocketConnection(url, namespace) {
return io(url + '/' + namespace);
}
function createSocketChannel(socket) {
return eventChannel(emit => {
const eventHandler = (event) => {
emit(event.payload);
};
const errorHandler = (errorEvent) => {
emit(new Error(errorEvent.reason));
};
socket.on('message', eventHandler);
socket.on('error', errorHandler);
const unsubscribe = () => {
socket.off('message', eventHandler);
};
return unsubscribe;
});
}
function* emitResponse(socket) {
yield apply(socket, socket.emit, ['message received']);
}
function* writeSocket(socket) {
while (true) {
const { eventName, payload } = yield take(actions.WEBSOCKET_SEND);
socket.emit(eventName, payload);
}
}
function* watchSocketChannel() {
const socket = yield call(createSocketConnection, 'http://localhost:3000', 'terminal');
const socketChannel = yield call(createSocketChannel, socket);
console.log(socket);
while (true) {
try {
const payload = yield take(socketChannel);
yield put({type: actions.WEBSOCKET_MESSAGE, payload});
yield fork(emitResponse, socket);
} catch (err) {
console.log('socket error: ', err);
}
}
}
export default function* root() {
yield all([
fork(watchSocketChannel),
])
I know that the fork function is attaching the watchSocketChannel function to saga and constantly listening.

I'm not sure I have understood correctly your question... If you're asking how/where to fork the writeSocket saga to allow you to dispatch the actions.WEBSOCKET_SEND) action and have your message sent to the socket:
isn't sufficient to do add a fork in the middle of the socket channel creation?
const socket = yield call(createSocketConnection, 'http://localhost:3000', 'terminal');
fork(writeSocket, socket); // I've added this line
const socketChannel = yield call(createSocketChannel, socket);

Related

Infinite loop on Redux saga yield call using Axios with JWT tokens

I have been trying to obtain data using Axios through Redux-saga using Redux-toolkit & react. It appears that intercepting a saga call with a token gets redux-saga in an infinite loop? Or is it because of my watchers?
I have recently been learning how to program so my skills in all areas are not yet great, hope you dont mind the way the code is written as I have been following tutorials mostly.
On handleSubmit from a Header.tsx to dispatch
const handleSubmit = (e) => {
e.preventDefault();
dispatch(getCurrentUser());
};
my rootSaga.tsx includes all watcherSagas notices the dispatch for getCurrentUser()
import { takeLatest } from "redux-saga/effects";
import {
handleLogInUser,
handleGetCurrentUser,
handleSetCurrentUser,
} from "./handlers/user";
import {
logInUser,
getCurrentUser,
setCurrentUser,
} from "../slices/user/userSlice";
export function* watcherSaga() {
yield takeLatest(logInUser.type, handleLogInUser);
yield takeLatest(getCurrentUser.type, handleGetCurrentUser);
yield takeLatest(setCurrentUser.type, handleSetCurrentUser);
}
the watcher calls handleGetCurrentUser for the saga located in user.tsx file in handler folder:
import { call, put } from "redux-saga/effects";
import { setCurrentUser } from "../../slices/user/userSlice";
import { requestLogInUser, requestGetCurrentUser } from "../requests/user";
export function* handleLogInUser(action) {
try {
console.log(action + "in handleLogInUser");
yield call(requestLogInUser(action));
} catch (error) {
console.log(error);
}
}
export function* handleGetCurrentUser(action) {
try {
const response = yield call(requestGetCurrentUser);
const userData = response;
yield put(setCurrentUser({ ...userData }));
} catch (error) {
console.log(error);
}
}
Which then uses yield call to requestGetCurrentUser which fires off the request to the following user.tsx in requests folder
import axiosInstance from "../../../axios/Axios";
export function requestGetCurrentUser() {
return axiosInstance.request({ method: "get", url: "/user/currentUser/" });
}
The response is given back and put in const userData, I consoleLog()'d the handler and discovered the following:
it will reach the handler successfully
go to the yield call
obtain the data successfully
return the data back to the handler
then it restarts the entire yield call again?
It also never makes it back to the userSlice in order to put the data.
axiosInstance in my axios.tsx file which includes the interceptor and gets the access_token and adds it to the header.
import axios from "axios";
const baseURL = "http://127.0.0.1:8000/api/";
const axiosInstance = axios.create({
baseURL: baseURL,
timeout: 5000,
headers: {
Authorization: "Bearer " + localStorage.getItem("access_token"),
"Content-Type": "application/json",
accept: "application/json",
},
});
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async function (error) {
const originalRequest = error.config;
if (typeof error.response === "undefined") {
alert(
"A server/network error occurred. " +
"Looks like CORS might be the problem. " +
"Sorry about this - we will get it fixed shortly."
);
return Promise.reject(error);
}
if (
error.response.status === 401 &&
originalRequest.url === baseURL + "token/refresh/"
) {
window.location.href = "/login/";
return Promise.reject(error);
}
if (
error.response.data.code === "token_not_valid" &&
error.response.status === 401 &&
error.response.statusText === "Unauthorized"
) {
const refreshToken = localStorage.getItem("refresh_token");
if (refreshToken) {
const tokenParts = JSON.parse(atob(refreshToken.split(".")[1]));
// exp date in token is expressed in seconds, while now() returns milliseconds:
const now = Math.ceil(Date.now() / 1000);
console.log(tokenParts.exp);
if (tokenParts.exp > now) {
return axiosInstance
.post("/token/refresh/", {
refresh: refreshToken,
})
.then((response) => {
localStorage.setItem("access_token", response.data.access);
localStorage.setItem("refresh_token", response.data.refresh);
axiosInstance.defaults.headers["Authorization"] =
"JWT " + response.data.access;
originalRequest.headers["Authorization"] =
"JWT " + response.data.access;
return axiosInstance(originalRequest);
})
.catch((err) => {
console.log(err);
});
} else {
console.log("Refresh token is expired", tokenParts.exp, now);
window.location.href = "/login/";
}
} else {
console.log("Refresh token not available.");
window.location.href = "/login/";
}
}
// specific error handling done elsewhere
return Promise.reject(error);
}
);
export default axiosInstance;
The userSlice.tsx
import { createSlice } from "#reduxjs/toolkit";
const userSlice = createSlice({
name: "user",
initialState: {},
reducers: {
logInUser(state, action) {},
getCurrentUser() {},
setCurrentUser(state, action) {
const userData = action.payload;
console.log(userData + "we are now back in slice");
return { ...state, ...userData };
},
},
});
export const { logInUser, getCurrentUser, setCurrentUser } = userSlice.actions;
export default userSlice.reducer;
I discovered that if I were to remove the authorization token it only fires off once and gets out of the infinite loop since it throws the unauthorised error.
Any suggestions would be greatly appreciated, thanks!
Apologies for getting back so late, I managed to fix it a while ago by pure chance and I dont exactly understand why.
But I believe what fixed it were the following two things:
Changing the useEffect that dispatched the action and ensuring that the handler returned data that the useEffect was expecting to be updated.
In the handler I deconstructed the userData to { userData } which I believe means that the data returned from the axios request is not the entire request but the actual returned data.
my handler
export function* handleGetCurrentUser() {
try {
console.log("in request get user");
const response = yield call(requestGetCurrentUser);
const { data } = response;
yield put(setCurrentUser({ ...data }));
} catch (error) {
console.log(error);
}
}
I forgot to add my useEffect to the post, which created the action.
my useEffect in the App.tsx would dispatch the call when the App was rendered for the first time. However because the returned data did not update what was expected it kept rerendering.
I cant exactly remember what my useEffect was but currently it is the following:
my useEffect in App.tsx
const dispatch = useDispatch();
useEffect(() => {
dispatch(getCurrentUser());
}, [dispatch]);
const user = useSelector((state) => state.user);

Handling errors with redux-toolkit

The information about the error in my case sits deeply in the response, and I'm trying to move my project to redux-toolkit. This is how it used to be:
catch(e) {
let warning
switch (e.response.data.error.message) {
...
}
}
The problem is that redux-toolkit doesn't put that data in the rejected action creator and I have no access to the error message, it puts his message instead of the initial one:
While the original response looks like this:
So how can I retrieve that data?
Per the docs, RTK's createAsyncThunk has default handling for errors - it dispatches a serialized version of the Error instance as action.error.
If you need to customize what goes into the rejected action, it's up to you to catch the initial error yourself, and use rejectWithValue() to decide what goes into the action:
const updateUser = createAsyncThunk(
'users/update',
async (userData, { rejectWithValue }) => {
const { id, ...fields } = userData
try {
const response = await userAPI.updateById(id, fields)
return response.data.user
} catch (err) {
if (!err.response) {
throw err
}
return rejectWithValue(err.response.data)
}
}
)
We use thunkAPI, the second argument in the payloadCreator; containing all of the parameters that are normally passed to a Redux thunk function, as well as additional options: For our example async(obj, {dispatch, getState, rejectWithValue, fulfillWithValue}) is our payloadCreator with the required arguments;
This is an example using fetch api
import { createSlice, createAsyncThunk } from "#reduxjs/toolkit";
export const getExampleThunk = createAsyncThunk(
'auth/getExampleThunk',
async(obj, {dispatch, getState, rejectWithValue, fulfillWithValue}) => {
try{
const response = await fetch('https://reqrefs.in/api/users/yu');
if (!response.ok) {
return rejectWithValue(response.status)
}
const data = await response.json();
return fulfillWithValue(data)
}catch(error){
throw rejectWithValue(error.message)
}
}
)
Simple example in slice:
const exampleSlice = createSlice({
name: 'example',
initialState: {
httpErr: false,
},
reducers: {
//set your reducers
},
extraReducers: {
[getExampleThunk.pending]: (state, action) => {
//some action here
},
[getExampleThunk.fulfilled]: (state, action) => {
state.httpErr = action.payload;
},
[getExampleThunk.rejected]: (state, action) => {
state.httpErr = action.payload;
}
}
})
Handling Error
Take note:
rejectWithValue - utility (additional option from thunkAPI) that you can return/throw in your action creator to return a rejected response with a defined payload and meta. It will pass whatever value you give it and return it in the payload of the rejected action.
For those that use apisauce (wrapper that uses axios with standardized errors + request/response transforms)
Since apisauce always resolves Promises, you can check !response.ok and handle it with rejectWithValue. (Notice the ! since we want to check if the request is not ok)
export const login = createAsyncThunk(
"auth/login",
async (credentials, { rejectWithValue }) => {
const response = await authAPI.signin(credentials);
if (!response.ok) {
return rejectWithValue(response.data.message);
}
return response.data;
}
);

How to test asynchonous functions using sinon?

I have a class called PostController, and I trying to test the following function create:
class PostController {
constructor(Post) {
this.Post = Post;
}
async create(req, res) {
try {
this.validFieldRequireds(req);
const post = new this.Post(req.body);
post.user = req.user;
...some validations here
await post.save();
return res.status(201).send(message.success.default);
} catch (err) {
console.error(err.message);
const msg = err.name === 'AppError' ? err.message :
message.error.default;
return res.status(422).send(msg);
}
}
My test class is:
import sinon from 'sinon';
import PostController from '../../../src/controllers/posts';
import Post from '../../../src/models/post';
describe('Controller: Post', async () => {
it.only('should call send with sucess message', () => {
const request = {
user: '56cb91bdc3464f14678934ca',
body: {
type: 'Venda',
tradeFiatMinValue: '1',
... some more attributes here
},
};
const response = {
send: sinon.spy(),
status: sinon.stub(),
};
response.status.withArgs(201).returns(response);
sinon.stub(Post.prototype, 'save');
const postController = new PostController(Post);
return postController.create(request, response).then(() => {
sinon.assert.calledWith(response.send);
});
});
});
But I'm getting the following error:
Error: Timeout of 5000ms exceeded. For async tests and hooks, ensure
"done()"
is called; if returning a Promise, ensure it resolves.
(D:\projeto\mestrado\localbonnum-back-end\test\unit\controllers\post_spec.js)
Why?
Most probably it's because misuse of sinon.stub.
You've
sinon.stub(Post.prototype, 'save');
without telling what this stub will do, so in principle this stub will do nothing (meaning it returns undefined).
IDK, why you don't see other like attempt to await on stub.
Nevertheless, you should properly configuture 'save' stub - for example like this:
const saveStub = sinon.stub(Post.prototype, 'save');
saveStub.resolves({foo: "bar"});

Redux-saga firebase onAuthStateChanged eventChannel

How to handle firebase auth state observer in redux saga?
firebase.auth().onAuthStateChanged((user) => {
});
I want to run APP_START saga when my app starts which will run firebase.auth().onAuthStateChanged observer and will run other sagas depending on the callback.
As I understand eventChannel is right way to do it. But I don't understand how to make it work with firebase.auth().onAuthStateChanged.
Can someone show how to put firebase.auth().onAuthStateChanged in to eventChannel?
You can use eventChannel. Here is an example code:
function getAuthChannel() {
if (!this.authChannel) {
this.authChannel = eventChannel(emit => {
const unsubscribe = firebase.auth().onAuthStateChanged(user => emit({ user }));
return unsubscribe;
});
}
return this.authChannel;
}
function* watchForFirebaseAuth() {
...
// This is where you wait for a callback from firebase
const channel = yield call(getAuthChannel);
const result = yield take(channel);
// result is what you pass to the emit function. In this case, it's an object like { user: { name: 'xyz' } }
...
}
When you are done, you can close the channel using this.authChannel.close().
Create your own function onAuthStateChanged() that will return a Promise
function onAuthStateChanged() {
return new Promise((resolve, reject) => {
firebase.auth().onAuthStateChanged((user) => {
if (user) {
resolve(user);
} else {
reject(new Error('Ops!'));
}
});
});
}
Then use call method to get the user synchronously
const user = yield call(onAuthStateChanged);
This could be handled in the Saga such as the following for Redux Saga Firebase:
// Redux Saga: Firebase Auth Channel
export function* firebaseAuthChannelSaga() {
try {
// Auth Channel (Events Emit On Login And Logout)
const authChannel = yield call(reduxSagaFirebase.auth.channel);
while (true) {
const { user } = yield take(authChannel);
// Check If User Exists
if (user) {
// Redux: Login Success
yield put(loginSuccess(user));
}
else {
// Redux: Logout Success
yield put(logoutSuccess());
}
}
}
catch (error) {
console.log(error);
}
};
here is how you would run the onAuthStateChanged observable using redux-saga features (mainly eventChannel)
import { eventChannel } from "redux-saga";
import { take, call } from "redux-saga/effects";
const authStateChannel = function () {
return eventChannel((emit) => {
const unsubscribe = firebase.auth().onAuthStateChanged(
(doc) => emit({ doc }),
(error) => emit({ error })
);
return unsubscribe;
});
};
export const onAuthStateChanged = function* () {
const channel = yield call(authStateChannel);
while (true) {
const { doc, error } = yield take(channel);
if (error) {
// handle error
} else {
if (doc) {
// user has signed in, use `doc.toJSON()` to check
} else {
// user has signed out
}
}
}
};
please note that other solutions that don't utilize channel sagas are not optimal for redux-saga, because turning an observable into a promise is not a valid solution in this case since you would need to call the promise each time you anticipate a change in authentication state (like for example: taking every USER_SIGNED_IN action and calling the "promisified" observable), which will negate the whole purpose of an observable

redux saga yield call throw error TypeError: Cannot read property 'ready' of null

I have app which loads 'favorites' each time user logs in. Data are loaded from IndexedDB using localforage. It works perfectly when app is starting fresh (after window refresh). When I logout and login (root saga task running) call that loads 'favorites' data throws error:
TypeError: Cannot read property 'ready' of null
at step:
export function* handleRecoverFavorites() {
try {
const resp = yield call(localforage.getItem, 'favorites')
saga code extract:
export function* handleRecoverFavorites() {
try {
const resp = yield call(localforage.getItem, 'favorites')
if(resp) {
yield put(recoverFavorites(resp))
yield all(resp.map(symbol => put(getTickPricesSubscribe(symbol))));
}
} catch(err) {
let response={"errorDescr":"Error whilst recovering favorites: "+err}
yield put({ type: types.RESPONSE_ERR, response })
console.log(response.errorDescr)
}
}
function* runAtStartupSubscirptions(socket, streamSessionId) {
yield fork(send, {"command": "getBalance", "streamSessionId": streamSessionId }, socket );
yield fork(handleRecoverFavorites)
yield fork(handleRecoverCharts)
while(true) {
yield call(delay, STREAMING_PING_TIME)
yield call(send, {"command": "ping", "streamSessionId": streamSessionId }, socket );
}
}
function* handleRequests(socket) {
let streamSessionId = yield select(state => state.get("auth").get("streamSessionId"))
while(true) {
yield take(types.STREAMING_SOCKET_STATUS)
if(socket.readyState === 1)
yield fork(runAtStartupSubscirptions, socket, streamSessionId)
}
}
export function* handleStreamingConnection() {
let server = yield select(state => state.get("auth").get("server"))
const socket = yield call(createWebSocketConnection, server+"Stream" )
const socketChannel = yield call(createSocketChannel, socket, null)
const task = yield fork(handleRequests, socket)
let channelMsg;
do {
channelMsg = yield take(socketChannel)
if(channelMsg.socketResponse) {
const response = channelMsg.socketResponse;
switch (response.command) {
case "candle":
yield put({ type: types.GET_CANDLES_STREAMING, response })
break;
(...)
default:
console.log("unrequested data: "+response)
}
}
if(channelMsg.socketStatus) {
console.log(channelMsg)
yield put({ type: types.STREAMING_SOCKET_STATUS, channelMsg })
}
} while (channelMsg.socketStatus!=="Disconnected")
yield cancel(task)
}
export default function* rootSaga() {
while(true) {
// Wait for log-in
yield take(types.LOGIN_SUCCESS);
const handleStreamingtask = yield fork(handleStreamingConnection)
yield take([types.LOGOUT_REQUEST, types.RECONNECTING ])
yield cancel(handleStreamingtask)
const channelMsg={"socketStatus" : "Disconnected"}
yield put({ type: types.STREAMING_SOCKET_STATUS, channelMsg })
}
}
I will be appreciated for any suggestions.
I have added console.log line to see result without saga call:
export function* handleRecoverFavorites() {
try {
console.log(localforage.getItem('favorites'))
const resp = yield call(localforage.getItem, 'favorites')
it returns every time Promise with correct value:
Promise {<pending>}
__proto__:Promise
[[PromiseStatus]]:"resolved"
[[PromiseValue]]:Array(1)
0:"CHFPLN"
length:1
This is how I solved it, instead of direct call of localforage method inside "yield call", I have wraped it into separate class:
export class LocalDataService {
getData = ( param ) => {
return localforage.getItem(param)
.then( result => result)
.catch( reason => reason)
}
}
export function* handleRecoverFavorites() {
try {
const api = new LocalDataService()
const resp = yield call( api.getData, 'favorites')
(...)
As per Sylwek reply, and investigating a bit further, the issue relies on the internal setItem context not binding as we expected.
By calling localforage with the following form, we can avoid creating a class:
yield call([localforage, localforage.getItem], 'favorites')
This fixed it for me :)
Check the this context section in saga docs: https://redux-saga.js.org/docs/basics/DeclarativeEffects/

Resources