I had a full stack chat app powered by graphql with react front end and express backend.
It worked beautifully but issues arose when i tried to host backend with heroku , something about them only supporting a specific type of websockets
chat app with graphql subscriptions
client code init commit
One of the listed supported ones was socketio , so i tried it out
for the front end we'll be using react with vite because create-react-app has some deprecated webpack related libraries that are causing vulnerability warnings
My objective with this was to make it as minimal as possible to make it easier to host , so no database will be used
live priview of the chat app
to get started run,
npm init vite
and follow the instructions
dependancies are
npm install axios react-icons socket.io-client
i used tailwindcss for this one feel free to skip and implement your own styles, adding tailwind to a react app
in oredr to get started we'll first need to mockup the server.
so exit the react folder and run
npm init -y
then
npm install -D typescript
you can use your own custom tsconfig file or use this one that works for me
{
"compilerOptions": {
"target": "es2017",
"module": "commonjs",
"lib": ["dom", "es6", "es2017", "esnext.asynciterable"],
"skipLibCheck": true,
"sourceMap": true,
"outDir": "./dist",
"moduleResolution": "node",
"removeComments": true,
"noImplicitAny": false,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
then add the scripts to your package.json
"scripts": {
"watch": "tsc -w",
"start": "nodemon dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
create an src directory and add an index.ts file in it.
then install
npm install cors express body-parser nodemon socket.io
then create a simple server in index.ts
import express,{ Request,Response } from "express";
import { Server } from "socket.io";
import { createServer } from "http";
import { addUser, checkUserNameExists,removeUser} from './utils/usersutil';
import { makeTimeStamp } from './utils/utils';
// const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser')
const app = express();
const PORT = process.env.PORT||4000
const server = createServer(app);
// const httpServer = createServer(app);
var jsonParser = bodyParser.json()
// create application/x-www-form-urlencoded parser
// var urlencodedParser = bodyParser.urlencoded({ extended: false })
const io = new Server(server,{
cors: {
origin: "http://localhost:3000",
credentials: true,
allowedHeaders: ["my-custom-header"],
}
});
(async () => {
app.use(cors())
app.options('*', cors());
app.get('/', (req:Request, res:Response) => {
res.send({ message: "We did it!" })
});
app.get('/me', (req:Request, res:Response) => {
res.send({ message: "smoosh" })
});
app.post('/users',jsonParser,(req:Request, res:Response) => {
const user = req.body?.user.username
// //console.log("looking for ===== ",user)
const userExists = checkUserNameExists(user)
//console.log("looking for ===== ",user, userExists)
res.send({data:userExists})
});
io.on("connection", async(socket) => {
//console.log(`Client ${socket.id} connected`);
// Join a conversation
const { room,user } = socket.handshake.query;
const room_id = room as string
const user_count = addUser({id:socket.id,name:user,room})
// //console.log("room id / user==== ",room_id,user,user_count)
socket.join(room_id);
io.in(room_id).emit('room_data', {room,users:user_count});
const join_room_message={message:`${user} joined`, time:makeTimeStamp(), user:"server" }
io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
socket.on('new_message', (newMessage) => {
//console.log("new message ",newMessage,room_id)
const user = newMessage.user
//@ts-ignore
io.in(room_id).emit('new_message_added', { user: user?.name,newMessage});
})
socket.on("disconnect", () => {
//console.log("User Disconnected new user count ====", socket.id,user_count);
removeUser(socket.id)
io.in(room_id).emit('room_data', {room: room_id,users:user_count - 1 });
const join_room_message={message:`${user} left`, time:makeTimeStamp(), user:"server" }
io.in(room_id).emit('new_message_added', { user,newMessage:join_room_message});
});
});
server.listen(PORT, () => {
console.log(`listening on http://localhost:${PORT}`)
});
})().catch(e=> console.log('error on server ====== ',e)
)
there's also a user helper functions file
interface User{
id:string
name:string
room:string
}
const users:User[] = [];
const userExists=(users:User[],name:string)=>{
let status = false
for(let i = 0;i<users.length;i++){
if(users[i].name===name){
status = true;
break
}
}
return status;
}
//console.log("all users in list=== ",users)
export const addUser = ({id, name, room}) => {
name = name?.trim().toLowerCase();
room = room?.trim().toLowerCase();
//console.log("user to add ==== ",name)
const existingUser = userExists(users,name)
//console.log("existing user????====",existingUser)
if(existingUser) {
//console.log("existing user")
return users.length;
}else{
const user = {id,name,room};
//console.log("adding user === ",user)
users.push(user);
//console.log("all users === ",users)
const count = getUsersInRoom(room).length
return count
}
}
const userExistsIndex=(users:User[],id:string)=>{
let status = -1
for(let i = 0;i<users.length;i++){
if(users[i].id === id){
status = i;
break
}
}
return status;
}
export const checkUserNameExists=(name:string)=>{
let status = false
for(let i = 0;i<users.length;i++){
if(users[i].name === name){
status = true
break
}
}
return status;
}
export const removeUser = (id:string) => {
// const index = users.findIndex((user) => {
// user.id === id
// });
const index = userExistsIndex(users,id)
//console.log(index)
if(index !== -1) {
//console.log("user ",users[index].name ,"disconected , removing them")
return users.splice(index,1)[0];
}
}
export const getUser = (id:string) => users .find((user) => user.id === id);
export const getUsersInRoom = (room:string) => users.filter((user) => user.room === room);
export const userCount =()=>users.length
export const makeTimeStamp=()=>{
const hour = new Date(Date.now()).getHours()
const min = new Date(Date.now()).getMinutes()
const sec = new Date(Date.now()).getSeconds()
let mins= min+''
let secs=':'+sec
if(min<10){
mins = '0'+ min
}
if(sec<10){
secs = '0' + sec
}
return hour+':'+ mins + secs
}
It's a regular node js express server that connects and listens for a socket connection then it adds the user to a temporary list and returns a room name and number of users in it ,
there's a post /users rest endpoint which would normally be used to authenticate and add user to a database but in this case it’ll just check if the username provided is already in use which causes weird issues when two people use the same names in the same room.
The socket io instance will also be listening for new messages emitted by the clients and broadcasting them to everyone in the room.
finally it listens for disconnections in which case it removes the username from the temporary list and informs everyone in the room
To watch for changes in the index.ts folder and compile it to dist/index.js and run the server .
use the two commands below each in their own terminal instance
npm run watch
npm start
and let's head back to the client,
Remember we're trying to keepthis minimal So
most of the stateful logic will be held in the front end .
For starters we need to ensure the user has a username and room before they enter the chats components which is where we bring in reactcontext and localStorage
my final folder structure looks like this
the common types will look something like this
export type Chat ={ newMessage:{message: string; time: string,user:string }};
export interface User{username:string ; room:string}
export interface Room{users:number ; room:string}
export interface Message{
message: string;
time: string;
user: string;
}
inside context.ts we create the UserContext
mport React from 'react';
import { User } from './../App';
const user_data = { user:{username:"",room:"general"}, updateUser:(user:User)=>{}}
const UserContext = React.createContext(user_data);
export default UserContext;
then we'll make a custom hook to handle the socketio client
import { Room, User } from "./types"
import { useRef,useState,useEffect } from 'react';
import socketIOClient,{ Socket } from 'socket.io-client';
const NEW_MESSAGE_ADDAED = "new_message_added";
const ROOM_DATA = "room_data";
const devUrl="http://localhost:4000"
const useChats=(user:User)=>{
const socketRef = useRef<Socket>();
const [messages, setMessages] = useState<any>([]);
const [room, setRoom] = useState<Room>({users:0,room:""});
useEffect(() => {
socketRef.current = socketIOClient(devUrl, {
query: { room:user.room,user:user.username },
transports: ["websocket"],
withCredentials: true,
extraHeaders:{"my-custom-header": "abcd"}
})
socketRef.current?.on(NEW_MESSAGE_ADDAED, (msg:any) => {
// //console.log("new message added==== ",msg)
setMessages((prev: any) => [msg,...prev]);
});
socketRef.current?.on(ROOM_DATA, (msg:any) => {
//console.log("room data ==== ",msg)
setRoom(msg)});
return () => {socketRef.current?.disconnect()};
}, [])
const sendMessage = (message:any) => {
//console.log("sending message ..... === ",message)
socketRef.current?.emit("new_message", message)
};
return {room,messages,sendMessage}
}
export default useChats
we store the socket instance in a useRef because we don't want it re-initializing on every re-render , then we put it in a useEffect to only initialize on component mount we return the new messages,room data , and a sendmessage function
in order to keep this miniman i avoided routing and just did optional rendering because we'll only have two major components, we initially check if there's a user in the local storage and show the JoinRoom.tsx component if not
import { JoinRoom } from './components/JoinRoom';
import React from 'react'
import UserContext from "./utils/context";
import { Chats } from './components/Chats';
export interface User{username:string ; room:string}
let the_user:User
const user_room= localStorage.getItem("user-room");
if(user_room)
the_user = JSON.parse(user_room);
function App() {
const [user, setUser] = React.useState<User>(the_user);
const updateUser = (new_user:User) => {setUser(new_user)};
const user_exists = user && user.username !==""
return (
<div className="scroll-bar flex flex-col justify-between h-screen w-screen ">
<UserContext.Provider value ={{user,updateUser}}>
{user_exists?<Chats />:<JoinRoom/>}
</UserContext.Provider>
</div>
);
}
export default App;
for the custom tailwind classes add this to the index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.flex-center{
@apply flex justify-center items-center
}
.flex-center-col{
@apply flex flex-col justify-center items-center
}
.scroll-bar{
@apply scrollbar-thin scrollbar-thumb-purple-900 scrollbar-track-gray-400
}
the scrollbar class is a tailwind extension you can install and add to your tailwind config
npm install -D tailwind-scrollbar
then add it to your tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('tailwind-scrollbar'),
],
}
the loading component
import React from 'react'
interface LoadingProps {
}
export const Loading: React.FC<LoadingProps> = ({}) => {
return (
<div className='h-full w-full flex-center bg-slate-300 text-lg'>
loading ...
</div>
);
}
the JoinRoom.tsx which will be shown if no user found in local storage ,
import React, {useContext, useState} from "react";
import UserContext from './../utils/context';
import axios from 'axios';
interface JoinRoomProps {
}
export const JoinRoom: React.FC<JoinRoomProps> = () => {
const [input, setInput] = useState({ username: "", room: "general" });
const [error, setError] = useState({ name:"", message:"" });
const devUrl="http://localhost:4000"
const client = axios.create({ baseURL:devUrl});
const user = useContext(UserContext);
//console.log("JoinRoom.tsx user ==== ",user.user)
const handleChange = async (e: any) => {
const { value } = e.target;
setInput({
...input,
[e.target.id]: value,
});
};
const handleSubmit = async (e: any) => {
//console.log("inputon submit ==== ",input)
e.preventDefault();
if(input.username !== ""){
const roomname = input.room.toLowerCase()
const username = input.username.toLowerCase()
const room_data = {username,room:roomname}
// localStorage.setItem("user-room",JSON.stringify(room_data));
// user.updateUser(room_data)
client.post('/users', {user:room_data})
.then( (response)=> {
const user_exist =response.data.data
//console.log("user exists? === ",user_exist)
if(!user_exist){
localStorage.setItem("user-room",JSON.stringify(room_data));
user.updateUser(room_data)
}
else{
setError({name:"username",message:"username exists"})
}
})
.catch(function (error) {
//console.log("error logging in === ",error)
setError({name:"username",message:"connection error"})
});
}
else{
setError({name:"username",message:"nick name needed"})
}
};
const isError=()=>{
if(error.name === "") return false
return true}
return (
<div className="h-full w-full flex-center-col bg-gradient-to-l from-cyan-900 to-purple-900">
<form className="w-[95%] md:w-[50%] p-3 bg-slate-700 rounded-lg text-white shadow-lg
shadow-purple-500 ">
<div className="flex-center-col">
<label className="text-lg font-bold">Join</label>
<input
style={{borderColor:isError()?"red":""}}
className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
id="username"
placeholder="nick name"
onChange={handleChange}
value={input.username}
/>
<input
className="w-[80%] md:w-[80%] p-2 m-1 border-black border rounded-sm bg-black"
id="room"
placeholder="room name"
onChange={handleChange}
value={input.room}
/>
{isError()?<div className="text-md p-1m-1 text-red-300">{error.message}</div>:null}
<button
onClick={handleSubmit}
className="p-2 m-1 w-[30%] bg-purple-800 shadow-md
hover:shadow-purple-400 rounded-md">Join</button>
</div>
</form>
</div>
);
}
on submit it will make a post request which will check if the username is currently in use and add the user to local torage and user context if not,
and finally for the chats component
first the toolbar component
import React from 'react'
import {BiExit} from 'react-icons/bi'
import { User } from './../App';
interface ToolbarProps {
room:any
updateUser: (user: User) => void
}
export const Toolbar: React.FC<ToolbarProps> = ({room,updateUser}) => {
return (
<div className='bg-slate-600 text-white p-1 w-full flex justify-between items-center h-12'>
<div className='p-2 m-1 text-xl font-bold flex'>
<div className='p-2 text-xl font-bold '> {room?.room}</div>
{room.room?<div className='p-1 m-1 text-base font-normal shadow shadow-white hover:shadow-red-400
flex-center cursor-pointer'
onClick={()=>{
localStorage.removeItem('user-room') ;
updateUser({username:"",room:""})
}}><BiExit/></div>:null}
</div>
<div className='p-2 m-1 font-bold'>{room?.users}{room?.users?" online ":""}</div>
</div>
);
}
responsible for displaying the room name user count in the room,
it also has a leave room button which will set user to null causing a switch to the joinroom component.
Chats.tsx
import React, { useState, useRef,useEffect,useContext } from "react";
import {AiOutlineSend } from 'react-icons/ai';
import { IconContext } from "react-icons";
import { makeTimeStamp } from './../utils/utils';
import { Toolbar } from './Toolbar';
import { Chat, Message } from './../utils/types';
import UserContext from "../utils/context";
import useChats from "../utils/useChats";
import { Loading } from './Loading';
interface ChatsProps {
}
export const Chats: React.FC<ChatsProps> = ({}) => {
const user = useContext(UserContext);
//console.log("Text.tsx user ==== ",user.user)
const {room,messages,sendMessage} = useChats(user.user)
const room_loaded = room.users>0 && user.user.username !== ""
console.log("Text.tsx room ==== ",room)
const [input, setInput] = useState({ message: "", time:makeTimeStamp() });
const [error, setError] = useState({ name:"", message:"" });
const [size, setSize] = useState({x: window.innerWidth,y: window.innerHeight});
const updateSize = () =>setSize({x: window.innerWidth,y: window.innerHeight });
useEffect(() => {
window.onresize = updateSize
})
const handleChange = async (e: any) => {
const { value } = e.target;
setInput({
...input,
[e.target.id]: value,
});
};
const handleSubmit = async (e: any) => {
e.preventDefault();
// //console.log("submit in form ====",input.message)
if (input.message !== "" && user.user.username !=="") {
const message = { message: input.message, time:input.time,user:user.user.username };
// //console.log("message ====",message)
sendMessage(message)
setError({name:"",message:""})
}else{
//console.log("error ====",input,user)
setError({name:"username",message:"type something"})
}
};
const isError=()=>{
if(error.name === "") return false
return true}
if(!room_loaded){
return <Loading/>
}
return (
<div
style={{maxHeight:size.y}}
className="h-full overflow-x-hidden overflow-y-hiddenflex flex-col justify-between ">
<div className="fixed top-[0px] w-[100%] z-60">
<Toolbar room={room} updateUser={user.updateUser}/>
</div>
{/* <div className="fixed top-[10%] right-[40%] p-1 z-60 text-3xl font-bold">{size.y}</div> */}
<div
className="mt-10 w-full h-[55vh] md:h-[80vh] flex flex-col-reverse items-center overflow-y-scroll p-2 scroll-bar"
>
{messages &&
messages.map((chat: Chat, index: number) => {
return <Chatcard key={index} chat={chat} user={user.user}/>;
})}
</div>
<form
onSubmit={handleSubmit}
className="w-full p-1 fixed bottom-1 ">
<div className="flex-center">
<input
style={{borderColor:isError()?"red":""}}
className="w-[80%] md:w-[50%] p-2 m-1 border-black border-2 rounded-sm "
id="message"
placeholder="type.."
onChange={handleChange}
value={input.message}
autoComplete={"off"}
/>
<button type="submit">
<IconContext.Provider value={{
size: "30px",
className: "mx-1",
}}>
<AiOutlineSend /></IconContext.Provider>
</button>
</div>
</form>
</div>
);
};
interface ChatCardProps {
chat: Chat;
user: { username: string; room: string;}
}
export const Chatcard: React.FC<ChatCardProps> = ({ chat,user }) => {
const isMe = chat.newMessage.user === user.username
// //console.log("chat in chat card ==== ",chat)
return (
<div className="flex-center w-full m-2">
<div
style={{backgroundColor:isMe?"purple":"white",color:isMe?"white":""}}
className="capitalize p-5 h-6 w-6 text-xl font-bold mr-1 border-2 border-slate-400
rounded-[50%] flex-center"> {chat?.newMessage.user[0]}</div>
<div className="w-[80%] h-full border border-slate-800 rounded-md
m-1 p-2 flex justify-between items-center">
<div className="max-w-[80%] h-fit break-words whitespace-normal text-mdfont-normal">
{chat?.newMessage.message}
</div>
<div className="w-fit font-medium h-full flex flex-col justify-end items-stretch text-sm ">
<div className="w-full ">{chat?.newMessage.user}</div>
<div className="w-full ">{chat?.newMessage.time}</div>
</div>
</div>
</div>
);
};
and that's it .
in my opinion doing it with graphql subscriptions is neater and gives you more control over how the clients connect , so if anyone knows how to make it work on heroku i would love tohear from you.
during the writing of this article i've already noticed a possible bug that would cause a username collision.
if a user successfully joins with username "john" then leaves and closes the browser disconnecting the from the server , if he comes back and another user joined using the username john the client will grab the local storage value which is john and join a room with another user in the room with the same username .
i'll let you tackle that one please send me a pull request if you do or find any other bug .
Top comments (0)