├── .editorconfig ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── components ├── Refreshable.ts ├── contexts │ ├── profile │ │ ├── UserProfileContext.ts │ │ └── UserProfileProvider.tsx │ └── socket │ │ ├── RoomMembersIface.ts │ │ ├── SocketContext.ts │ │ ├── SocketIface.ts │ │ ├── SocketProvider.tsx │ │ ├── SocketUserIface.ts │ │ └── useRoomState.ts ├── index │ └── Rooms.tsx └── rooms │ ├── Body.tsx │ ├── DateString.tsx │ ├── JoinRoomForm.tsx │ ├── RoomMembersList.tsx │ ├── SendTextForm.tsx │ ├── TextLogs.tsx │ └── User.tsx ├── index.ts ├── next-env.d.ts ├── nodemon.json ├── package-lock.json ├── package.json ├── pages ├── 404.tsx ├── api │ └── ua.ts ├── index.tsx ├── new.tsx └── rooms │ └── [rid] │ └── index.tsx ├── server ├── createRequestHandler.ts ├── createSocketHandler.ts ├── emitJSON.ts ├── generateUserName.ts ├── getRoomId.ts ├── getRoomName.ts ├── getUserId.ts ├── isRoomName.ts └── isUserName.ts ├── shared ├── TextIface.ts └── UserProfileIface.ts ├── tsconfig-server.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_size = 2 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JavaScript files 2 | **/*.js 3 | 4 | # Next.js build cache 5 | .next/ 6 | 7 | # Node.js modules 8 | node_modules/ 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "EditorConfig.EditorConfig"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextjs-socketio-chat-example 2 | 3 | A simple chat using Socket.io and Next.js, written in pure TypeScript. 4 | 5 | ## Overview 6 | 7 | Demo site: https://nextjs-socketio-chat-example.herokuapp.com/ 8 | 9 | - `components/`: React components 10 | - `pages/`: Next.js pages 11 | - `server/`: Socket.io-related server-side code 12 | - `shared/`: type definitions shared between server and client 13 | 14 | ## Development 15 | 16 | ### Install 17 | 18 | ```sh 19 | npm install 20 | ``` 21 | 22 | ### Start server 23 | 24 | ```sh 25 | npm run dev 26 | ``` 27 | 28 | ### Build deployment-ready code 29 | 30 | ```sh 31 | npm run build:all 32 | ``` 33 | 34 | --- 35 | 36 | (c) Arch Inc., 2019-2020. 37 | -------------------------------------------------------------------------------- /components/Refreshable.ts: -------------------------------------------------------------------------------- 1 | export interface Refreshable { 2 | refresh(): void; 3 | } 4 | -------------------------------------------------------------------------------- /components/contexts/profile/UserProfileContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | import { UserProfileIface } from "../../../shared/UserProfileIface"; 4 | 5 | const UserProfileContext = createContext(null); 6 | 7 | export default UserProfileContext; 8 | -------------------------------------------------------------------------------- /components/contexts/profile/UserProfileProvider.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect, useMemo } from "react"; 2 | 3 | import { UserProfileIface } from "../../../shared/UserProfileIface"; 4 | import UserProfileContext from "./UserProfileContext"; 5 | 6 | export const UserProfileProvider: FC = ({ children }) => { 7 | const [ua, setUa] = useState(null); 8 | const [name, setName] = useState(null); 9 | 10 | // fetch user agent and set "ua" 11 | useEffect(() => { 12 | if (typeof window === "undefined") { 13 | return; 14 | } 15 | let mounted = true; 16 | fetch("/api/ua").then(async (res) => { 17 | if (!mounted || res.status !== 200) { 18 | return; 19 | } 20 | setUa(await res.json()); 21 | }); 22 | return () => (mounted = false); 23 | }, []); 24 | 25 | const data: UserProfileIface = useMemo( 26 | () => ({ 27 | ua, 28 | get name() { 29 | return name; 30 | }, 31 | /** allow setting name from child components */ 32 | set name(val) { 33 | setName(val); 34 | }, 35 | }), 36 | [ua, name] 37 | ); 38 | 39 | return ( 40 | 41 | {children} 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/contexts/socket/RoomMembersIface.ts: -------------------------------------------------------------------------------- 1 | import { SocketUserIface } from "./SocketUserIface"; 2 | 3 | export interface RoomMembersIface { 4 | [socketId: string]: SocketUserIface; 5 | } 6 | -------------------------------------------------------------------------------- /components/contexts/socket/SocketContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | import { SocketIface } from "./SocketIface"; 4 | 5 | const SocketContext = createContext(null); 6 | 7 | export default SocketContext; 8 | -------------------------------------------------------------------------------- /components/contexts/socket/SocketIface.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from "socket.io-client"; 2 | 3 | import { RoomMembersIface } from "./RoomMembersIface"; 4 | import { TextIface } from "../../../shared/TextIface"; 5 | 6 | export interface SocketIface { 7 | readonly socket: typeof Socket; 8 | readonly roomId: string; 9 | readonly roomMembers: RoomMembersIface; 10 | readonly textLogs: TextIface[]; 11 | join(roomId: string): void; 12 | leave(): void; 13 | text(message: string): void; 14 | } 15 | -------------------------------------------------------------------------------- /components/contexts/socket/SocketProvider.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, useEffect, useCallback, useContext } from "react"; 2 | import SocketIO, { Socket } from "socket.io-client"; 3 | 4 | import UserProfileContext from "../profile/UserProfileContext"; 5 | import { UserProfileIface } from "../../../shared/UserProfileIface"; 6 | import { TextIface } from "../../../shared/TextIface"; 7 | 8 | import SocketContext from "./SocketContext"; 9 | import { SocketIface } from "./SocketIface"; 10 | import { RoomMembersIface } from "./RoomMembersIface"; 11 | 12 | const SocketProvider: FC = ({ children }) => { 13 | const [socket, setSocket] = useState(null); 14 | const [roomId, setRoomId] = useState(null); 15 | const myProfile = useContext(UserProfileContext); 16 | const [roomMembers, setRoomMembers] = useState(null); 17 | const [textLogs, setTextLogs] = useState(null); 18 | 19 | useEffect(() => { 20 | if (typeof window === "undefined") { 21 | return; 22 | } 23 | console.log("[mounted]"); 24 | 25 | // connect to Socket.io server 26 | const s = SocketIO(); 27 | setSocket(s); 28 | 29 | // initialize state 30 | setRoomMembers({}); 31 | setTextLogs([]); 32 | 33 | return () => { 34 | console.log("[unmounted]"); 35 | if (socket && socket.connected) { 36 | socket.disconnect(); 37 | } 38 | }; 39 | }, []); 40 | 41 | useEffect(() => { 42 | if (!socket || !myProfile) return; 43 | 44 | /** 45 | * connection lost 46 | */ 47 | function disconnect() { 48 | console.log("[received] disconnect"); 49 | setSocket(null); 50 | 51 | socket.once("reconnect", () => { 52 | console.log("[reconnected]"); 53 | setSocket(socket); 54 | setRoomMembers({}); 55 | join(roomId); 56 | }); 57 | } 58 | 59 | /** 60 | * somebody joined this session and said hello 61 | * @param profile 62 | * @param socketId 63 | */ 64 | function hello(profile: UserProfileIface, socketId: string) { 65 | console.log("[received] hello", profile, "from", socketId); 66 | const cs = Object.assign({}, roomMembers); 67 | cs[socketId] = { 68 | profile, 69 | }; 70 | setRoomMembers(cs); 71 | socket.emit("hello-ack", roomId, myProfile); 72 | } 73 | 74 | /** 75 | * somebody responded to my greeting 76 | * @param profile somebody 77 | * @param socketId somebody's socket id 78 | */ 79 | function helloAck(profile: UserProfileIface, socketId: string) { 80 | console.log("[received] hello-ack", profile, "from", socketId); 81 | const cs = Object.assign({}, roomMembers); 82 | cs[socketId] = { 83 | profile, 84 | }; 85 | setRoomMembers(cs); 86 | } 87 | 88 | /** 89 | * somebody is leaving this session 90 | * @param socketId somebody's socket id 91 | */ 92 | function bye(socketId: string) { 93 | console.log( 94 | "[received] bye from", 95 | socketId, 96 | "exists?", 97 | !!roomMembers[socketId] 98 | ); 99 | if (roomMembers[socketId]) { 100 | const cs = Object.assign({}, roomMembers); 101 | delete cs[socketId]; 102 | setRoomMembers(cs); 103 | } 104 | } 105 | 106 | socket.on("disconnect", disconnect); 107 | socket.on("hello", hello); 108 | socket.on("hello-ack", helloAck); 109 | socket.on("bye", bye); 110 | 111 | return () => { 112 | socket.off("hello", hello); 113 | socket.off("hello-ack", helloAck); 114 | socket.off("bye", bye); 115 | }; 116 | }, [socket, myProfile, roomId, roomMembers]); 117 | 118 | useEffect(() => { 119 | if (!socket) return; 120 | 121 | /** 122 | * somebody sent a message 123 | */ 124 | function text(data: TextIface) { 125 | const logs = textLogs.slice(); 126 | logs.push(data); 127 | setTextLogs(logs); 128 | } 129 | 130 | /** 131 | * somebody requested logs 132 | */ 133 | function logs(userName: string) { 134 | socket.emit("logs", userName, textLogs); 135 | } 136 | 137 | /** 138 | * somebody sent logs 139 | */ 140 | function logsAck(source: TextIface[]) { 141 | const merged: TextIface[] = []; 142 | const target = textLogs.slice(); 143 | while (source.length > 0 || target.length > 0) { 144 | // insert the rest 145 | if (source.length <= 0) { 146 | merged.push(target.shift()); 147 | continue; 148 | } else if (target.length <= 0) { 149 | merged.push(source.shift()); 150 | continue; 151 | } 152 | 153 | // insert earlier log 154 | let s = source[0], 155 | t = target[0]; 156 | if (s.time > t.time) { 157 | merged.push(target.shift()); 158 | continue; 159 | } else if (s.time < t.time) { 160 | merged.push(source.shift()); 161 | continue; 162 | } 163 | 164 | // insert either one (same time = duplicate) 165 | merged.push(source.shift()); 166 | target.shift(); 167 | } 168 | setTextLogs(merged); 169 | } 170 | 171 | socket.on("text", text); 172 | socket.on("logs", logs); 173 | socket.on("logs-ack", logsAck); 174 | 175 | return () => { 176 | socket.off("text", text); 177 | socket.off("logs", logs); 178 | socket.off("logs-ack", logsAck); 179 | }; 180 | }, [socket, roomId, textLogs]); 181 | 182 | const join = useCallback( 183 | (roomId: string) => { 184 | if (!roomId || !myProfile) { 185 | return; 186 | } 187 | setRoomId(roomId); 188 | setTextLogs([]); 189 | socket.emit("hello", roomId, myProfile); 190 | console.log("[sent] hello in", roomId); 191 | }, 192 | [myProfile, socket] 193 | ); 194 | 195 | const leave = useCallback(() => { 196 | if (socket && roomId) { 197 | socket.emit("bye", roomId); 198 | console.log("[sent] bye in", roomId); 199 | } 200 | setRoomMembers({}); 201 | setRoomId(null); 202 | setTextLogs([]); 203 | }, [socket, roomId]); 204 | 205 | const text = useCallback( 206 | (message: string) => { 207 | if (!socket || !roomId) { 208 | return; 209 | } 210 | socket.emit("text", roomId, message); 211 | }, 212 | [socket, roomId] 213 | ); 214 | 215 | const data: SocketIface = Object.freeze({ 216 | roomId, 217 | socket, 218 | roomMembers, 219 | textLogs, 220 | join, 221 | leave, 222 | text, 223 | }); 224 | 225 | return ( 226 | {children} 227 | ); 228 | }; 229 | 230 | export default SocketProvider; 231 | -------------------------------------------------------------------------------- /components/contexts/socket/SocketUserIface.ts: -------------------------------------------------------------------------------- 1 | import { UserProfileIface } from "../../../shared/UserProfileIface"; 2 | 3 | export interface SocketUserIface { 4 | profile: UserProfileIface; 5 | } 6 | -------------------------------------------------------------------------------- /components/contexts/socket/useRoomState.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from "react"; 2 | 3 | import UserProfileContext from "../profile/UserProfileContext"; 4 | import SocketContext from "./SocketContext"; 5 | 6 | interface RoomStateIface { 7 | /** 8 | * true if connected but joined 9 | */ 10 | readonly joinable: boolean; 11 | 12 | /** 13 | * true if joined 14 | */ 15 | readonly joined: boolean; 16 | } 17 | 18 | export function useRoomState(): RoomStateIface { 19 | const profile = useContext(UserProfileContext); 20 | const socket = useContext(SocketContext); 21 | 22 | const joinable = useMemo( 23 | () => 24 | /* connected */ socket && 25 | /* profile set */ profile && 26 | /* not yet joined to any room */ !socket.roomId && 27 | /* user name set */ profile.name && 28 | /* user name non-empty string */ profile.name.length > 0, 29 | [socket, profile] 30 | ); 31 | 32 | const joined = useMemo(() => !!socket?.roomId, [socket]); 33 | 34 | return { joined, joinable }; 35 | } 36 | -------------------------------------------------------------------------------- /components/index/Rooms.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useEffect, 3 | useState, 4 | forwardRef, 5 | useImperativeHandle, 6 | useCallback, 7 | } from "react"; 8 | import Link from "next/link"; 9 | 10 | import { Refreshable } from "../Refreshable"; 11 | 12 | export const Rooms = forwardRef((_, ref) => { 13 | const [rooms, setRooms] = useState(null); 14 | 15 | const refresh = useCallback(() => { 16 | setRooms(null); 17 | fetch("/api/rooms") 18 | .then((res) => res.json()) 19 | .then(setRooms); 20 | }, []); 21 | 22 | useEffect(() => { 23 | refresh(); 24 | }, []); 25 | 26 | useImperativeHandle(ref, () => ({ refresh }), [refresh]); 27 | 28 | return ( 29 |
    30 | {rooms ? ( 31 | rooms.length > 0 ? ( 32 | rooms.map((room, i) => ( 33 |
  • 34 | 35 | {room} 36 | 37 |
  • 38 | )) 39 | ) : ( 40 |
  • not found
  • 41 | ) 42 | ) : ( 43 |
  • loading ...
  • 44 | )} 45 |
46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /components/rooms/Body.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import { useRoomState } from "../contexts/socket/useRoomState"; 4 | 5 | import { JoinRoomForm } from "./JoinRoomForm"; 6 | import { RoomMembersList } from "./RoomMembersList"; 7 | import { TextLogs } from "./TextLogs"; 8 | import { SendTextForm } from "./SendTextForm"; 9 | 10 | interface IProps { 11 | roomId: string; 12 | } 13 | 14 | export const Body: FC = ({ roomId }) => { 15 | const { joined } = useRoomState(); 16 | return ( 17 |
18 | 19 | {joined && ( 20 | <> 21 | 22 | 23 | 24 | 25 | )} 26 |
27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /components/rooms/DateString.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, FC } from "react"; 2 | 3 | interface DateStringProps { 4 | time: number; 5 | } 6 | 7 | export const DateString: FC = ({ time }) => { 8 | const dateString = useMemo(() => { 9 | const d = new Date(); 10 | d.setTime(time); 11 | return d.toLocaleString(); 12 | }, [time]); 13 | return <>{dateString}; 14 | }; 15 | -------------------------------------------------------------------------------- /components/rooms/JoinRoomForm.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext, useCallback, ChangeEvent, FormEvent } from "react"; 2 | 3 | import UserProfileContext from "../contexts/profile/UserProfileContext"; 4 | import SocketContext from "../contexts/socket/SocketContext"; 5 | import { useRoomState } from "../contexts/socket/useRoomState"; 6 | 7 | interface IProps { 8 | roomId: string; 9 | } 10 | 11 | export const JoinRoomForm: FC = ({ roomId }) => { 12 | const profile = useContext(UserProfileContext); 13 | const socket = useContext(SocketContext); 14 | const { joinable, joined } = useRoomState(); 15 | 16 | /** 17 | * join the room with the specified name 18 | */ 19 | const handleJoin = useCallback(() => { 20 | if (!joinable) { 21 | return; 22 | } 23 | socket.join(roomId); 24 | }, [joinable, socket, roomId]); 25 | 26 | /** 27 | * leave the current room 28 | */ 29 | const handleLeave = useCallback(() => { 30 | socket.leave(); 31 | }, [socket]); 32 | 33 | /** 34 | * update user name 35 | */ 36 | const handleNameChange = useCallback( 37 | (e: ChangeEvent) => { 38 | profile.name = e.target.value; 39 | }, 40 | [profile] 41 | ); 42 | 43 | /** 44 | * handle form submission (call join) 45 | */ 46 | const handleSubmit = useCallback( 47 | (ev: FormEvent) => { 48 | ev.preventDefault(); 49 | handleJoin(); 50 | }, 51 | [handleJoin] 52 | ); 53 | 54 | return ( 55 |
56 |

57 | hello world! {joined ? "you've just joined" : "you are about to join"}{" "} 58 | {roomId}. 59 |

60 | {joined ? ( 61 |
62 |

you can leave this room at any time:

63 | 64 |
65 | ) : ( 66 |
67 |

enter your name and hit button to join this room:

68 | 73 | 76 |
77 | )} 78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /components/rooms/RoomMembersList.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from "react"; 2 | 3 | import UserProfileContext from "../contexts/profile/UserProfileContext"; 4 | import SocketContext from "../contexts/socket/SocketContext"; 5 | 6 | export const RoomMembersList: FC = () => { 7 | const profile = useContext(UserProfileContext); 8 | const socket = useContext(SocketContext); 9 | return ( 10 |
11 |

me

12 |
{profile ? JSON.stringify(profile, null, "  ") : "-"}
13 |

other members

14 |
15 |         {socket?.roomMembers
16 |           ? JSON.stringify(socket.roomMembers, null, "  ")
17 |           : "-"}
18 |       
19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /components/rooms/SendTextForm.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FC, 3 | useContext, 4 | useCallback, 5 | ChangeEvent, 6 | useState, 7 | FormEvent, 8 | } from "react"; 9 | 10 | import SocketContext from "../contexts/socket/SocketContext"; 11 | 12 | export const SendTextForm: FC = () => { 13 | const socket = useContext(SocketContext); 14 | const [message, setMessage] = useState(""); 15 | 16 | /** 17 | * say something 18 | */ 19 | const handleText = useCallback(() => { 20 | if (!socket || message.length <= 0) { 21 | return; 22 | } 23 | socket.text(message); 24 | setMessage(""); 25 | }, [socket, message]); 26 | 27 | /** 28 | * handle form submission (call text) 29 | */ 30 | const handleSubmit = useCallback( 31 | (ev: FormEvent) => { 32 | ev.preventDefault(); 33 | handleText(); 34 | }, 35 | [handleText] 36 | ); 37 | 38 | /** 39 | * update text field 40 | */ 41 | const handleChange = useCallback((ev: ChangeEvent) => { 42 | setMessage(ev.target.value); 43 | }, []); 44 | 45 | return ( 46 |
47 | 48 | 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /components/rooms/TextLogs.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from "react"; 2 | 3 | import SocketContext from "../contexts/socket/SocketContext"; 4 | import { User } from "./User"; 5 | import { DateString } from "./DateString"; 6 | 7 | export const TextLogs: FC = () => { 8 | const socket = useContext(SocketContext); 9 | return ( 10 |
11 | 16 |

logs

17 |
    18 | {Array.isArray(socket?.textLogs) && socket.textLogs.length > 0 ? ( 19 | socket.textLogs.map((t, i) => ( 20 |
  • 21 | {t.message}{" "} 22 | 23 | 24 | , 25 | 26 |
  • 27 | )) 28 | ) : ( 29 |
  • no text logs found
  • 30 | )} 31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /components/rooms/User.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { UserProfileIface } from "../../shared/UserProfileIface"; 3 | 4 | interface UserProps { 5 | data?: UserProfileIface; 6 | } 7 | 8 | export const User: FC = ({ data }) => { 9 | if (!data) { 10 | return <>-; 11 | } 12 | return ( 13 | <>{`${data.name} (${data.ua.browser.name} v${data.ua.browser.major} on ${ 14 | data.ua.os.name 15 | } ${data.ua.os.version || "(unknown version)"})`} 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import next from "next"; 2 | import { Server } from "http"; 3 | import io from "socket.io"; 4 | import path from "path"; 5 | import fs from "fs"; 6 | 7 | import { generateUserName } from "./server/generateUserName"; 8 | import { createSocketHandler } from "./server/createSocketHandler"; 9 | import { createRequestHandler } from "./server/createRequestHandler"; 10 | 11 | const port = parseInt(process.env.PORT) || 3000; 12 | const env = process.env.NODE_ENV || "production"; 13 | const dev = env !== "production"; 14 | const pkg = JSON.parse( 15 | fs.readFileSync(path.join(__dirname, "package.json")).toString() 16 | ); 17 | 18 | const nextApp = next({ 19 | dir: ".", 20 | dev, 21 | }); 22 | const handler = nextApp.getRequestHandler(); 23 | 24 | nextApp 25 | .prepare() 26 | .then(async () => { 27 | // create http server 28 | const httpServer = new Server((req, res) => { 29 | requestHandler(req, res) || handler(req, res); 30 | }); 31 | 32 | // create Socket.io server 33 | const socketServer = io(httpServer); 34 | const socketHandler = createSocketHandler(socketServer); 35 | const requestHandler = createRequestHandler(socketServer); 36 | socketServer.engine["generateId"] = generateUserName; 37 | socketServer.on("connection", socketHandler); 38 | 39 | // start listening 40 | httpServer.listen(port, () => { 41 | console.error("Web server started to listen port:", port); 42 | }); 43 | }) 44 | .catch((err) => { 45 | console.error("Next.js server failed to start", err); 46 | }); 47 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["index.ts", "server/**/*.ts", "shared/**/*.ts"], 3 | "ignore": [".next"], 4 | "ext": "ts", 5 | "exec": "npm run clean && npm run build && node index.js" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-socketio-chat-example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1", 7 | "build": "npm run build:ts", 8 | "build:all": "npm run build:ts && npm run build:next", 9 | "build:ts": "tsc -p tsconfig-server.json", 10 | "build:next": "next build", 11 | "clean": "npm run clean:ts", 12 | "clean:all": "npm run clean:ts && npm run clean:next", 13 | "clean:ts": "del-cli ./server/**/*.js ./shared/**/*.js", 14 | "clean:next": "del-cli ./.next", 15 | "start": "node index.js", 16 | "dev": "cross-env NODE_ENV=development APP_ENV=release nodemon", 17 | "heroku-postbuild": "npm run build:all" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/arch-inc/nextjs-socketio-chat-example.git" 22 | }, 23 | "keywords": [ 24 | "next", 25 | "nextjs", 26 | "next.js", 27 | "socketio", 28 | "socket.io", 29 | "chat" 30 | ], 31 | "author": "Jun Kato (https://junkato.jp)", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/arch-inc/nextjs-socketio-chat-example/issues" 35 | }, 36 | "homepage": "https://github.com/arch-inc/nextjs-socketio-chat-example#readme", 37 | "description": "A simple chat using Socket.io and Next.js, written in pure TypeScript", 38 | "engines": { 39 | "node": "12.x" 40 | }, 41 | "devDependencies": { 42 | "@types/react": "^16.9.44", 43 | "@types/react-dom": "^16.9.8", 44 | "@types/socket.io": "^2.1.10", 45 | "@types/socket.io-client": "^1.4.33", 46 | "@types/ua-parser-js": "^0.7.33", 47 | "@types/uuid": "^8.0.0", 48 | "cross-env": "^7.0.2", 49 | "del-cli": "^3.0.1", 50 | "nodemon": "^2.0.4", 51 | "prettier": "^2.0.5", 52 | "typescript": "^3.9.7" 53 | }, 54 | "dependencies": { 55 | "next": "^9.5.1", 56 | "react": "^16.13.1", 57 | "react-dom": "^16.13.1", 58 | "socket.io": "^2.3.0", 59 | "socket.io-client": "^2.3.0", 60 | "ua-parser-js": "^0.7.21", 61 | "uuid": "^8.3.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | 3 | const page: NextPage = () =>

not found

; 4 | 5 | export default page; 6 | -------------------------------------------------------------------------------- /pages/api/ua.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { UAParser } from "ua-parser-js"; 3 | 4 | export default (req: NextApiRequest, res: NextApiResponse) => { 5 | const parser = new UAParser(req.headers["user-agent"]); 6 | res.status(200).json(parser.getResult()); 7 | }; 8 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import Link from "next/link"; 3 | import { Rooms } from "../components/index/Rooms"; 4 | import { useRef, useCallback } from "react"; 5 | import { Refreshable } from "../components/Refreshable"; 6 | 7 | const page: NextPage = () => { 8 | const ref = useRef(); 9 | 10 | const handleRefresh = useCallback( 11 | () => ref.current && ref.current.refresh(), 12 | [ref.current] 13 | ); 14 | 15 | return ( 16 |
17 |

18 | hello world! to create a new room, visit{" "} 19 | 20 | this link 21 | 22 | . 23 |

24 |

existing rooms are listed below:

25 | 26 | 29 |
30 | ); 31 | }; 32 | 33 | export default page; 34 | -------------------------------------------------------------------------------- /pages/new.tsx: -------------------------------------------------------------------------------- 1 | import Router from "next/router"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import page from "./404"; 5 | 6 | page.getInitialProps = async ({ res }) => { 7 | const uuid = uuidv4(); 8 | if (res) { 9 | res.writeHead(302, { 10 | Location: `/rooms/${uuid}`, 11 | }); 12 | res.end(); 13 | return {}; 14 | } 15 | Router.push(`/rooms/${uuid}`); 16 | return {}; 17 | }; 18 | 19 | export default page; 20 | -------------------------------------------------------------------------------- /pages/rooms/[rid]/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from "next"; 2 | import { useRouter } from "next/router"; 3 | import { useEffect, useState } from "react"; 4 | import { UserProfileProvider } from "../../../components/contexts/profile/UserProfileProvider"; 5 | import SocketProvider from "../../../components/contexts/socket/SocketProvider"; 6 | import { Body } from "../../../components/rooms/Body"; 7 | 8 | const page: NextPage = () => { 9 | const [roomId, setRoomId] = useState(null); 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | setRoomId(router.query.rid as string); 14 | }, [router.query]); 15 | 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default page; 26 | -------------------------------------------------------------------------------- /server/createRequestHandler.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "socket.io"; 2 | import { IncomingMessage, ServerResponse } from "http"; 3 | 4 | import { emitJSON } from "./emitJSON"; 5 | import { isRoomName } from "./isRoomName"; 6 | import { getRoomId } from "./getRoomId"; 7 | import { getRoomName } from "./getRoomName"; 8 | import { isUserName } from "./isUserName"; 9 | import { getUserId } from "./getUserId"; 10 | 11 | type RequestHandler = (req: IncomingMessage, res: ServerResponse) => boolean; 12 | 13 | export function createRequestHandler(server: Server): RequestHandler { 14 | return function (req, res): boolean { 15 | if (typeof req.url !== "string") { 16 | return false; 17 | } 18 | // get list of rooms 19 | if (/^\/api\/rooms\??.*$/.test(req.url)) { 20 | emitJSON( 21 | res, 22 | Object.keys(server.of("/").adapter.rooms) 23 | .filter(isRoomName) 24 | .map(getRoomId) 25 | ); 26 | return true; 27 | } 28 | 29 | // get list of users 30 | if (/^\/api\/users(\?.*)?$/.test(req.url)) { 31 | emitJSON( 32 | res, 33 | Object.keys(server.of("/").adapter.rooms) 34 | .filter(isUserName) 35 | .map(getUserId) 36 | ); 37 | return true; 38 | } 39 | 40 | // get list of users in a room 41 | let usersInRoom: RegExpExecArray; 42 | if ((usersInRoom = /^\/api\/users\/in\/(.+)\??.*$/.exec(req.url))) { 43 | const roomId = usersInRoom[1]; 44 | const rooms = server.of("/").adapter.rooms; 45 | const room = rooms[getRoomName(roomId)]; 46 | if (!room) { 47 | emitJSON(res, []); 48 | return true; 49 | } 50 | emitJSON(res, Object.keys(room.sockets)); 51 | return true; 52 | } 53 | 54 | return false; 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /server/createSocketHandler.ts: -------------------------------------------------------------------------------- 1 | import { Server, Socket } from "socket.io"; 2 | import { UserProfileIface } from "../shared/UserProfileIface"; 3 | import { getRoomName } from "./getRoomName"; 4 | import { isRoomName } from "./isRoomName"; 5 | import { TextIface } from "../shared/TextIface"; 6 | 7 | export function createSocketHandler(server: Server) { 8 | return function socketHandler(socket: Socket) { 9 | /** 10 | * somebody is leaving the session 11 | */ 12 | function onDisconnecting() { 13 | // broadcast "bye" message in rooms of which s/he is a member 14 | Object.keys(socket.rooms) 15 | .filter(isRoomName) 16 | .forEach((roomName) => { 17 | socket.to(roomName).emit("bye", socket.id); 18 | console.log( 19 | "[disconnecting] somebody is leaving the room", 20 | roomName, 21 | "sent from", 22 | socket.id 23 | ); 24 | }); 25 | } 26 | 27 | /** 28 | * somebody joined this session and said hello 29 | * @param roomId - room id 30 | * @param profile - user profile 31 | */ 32 | function onHello(roomId: string, profile: UserProfileIface) { 33 | const roomName = getRoomName(roomId); 34 | 35 | // request to send text logs 36 | const room = server.sockets.adapter.rooms[roomName]; 37 | if (room) { 38 | const users = Object.keys(room.sockets); 39 | if (users.length > 0) { 40 | server.to(users[0]).emit("logs", socket.id); 41 | } 42 | } 43 | 44 | socket.join(roomName, () => { 45 | // broadcast "hello" message to room members 46 | socket.to(roomName).emit("hello", profile, socket.id); 47 | }); 48 | 49 | console.log( 50 | "somebody joined this session and said hello", 51 | roomId, 52 | profile, 53 | "sent from", 54 | socket.id 55 | ); 56 | } 57 | 58 | /** 59 | * somebody responded to one's greeting 60 | * @param roomId - room id 61 | * @param profile - user profile 62 | */ 63 | function onHelloAck(roomId: string, profile: UserProfileIface) { 64 | // broadcast "hello-ack" message to room members 65 | socket.to(getRoomName(roomId)).emit("hello-ack", profile, socket.id); 66 | console.log( 67 | "somebody responded to one's greeting", 68 | roomId, 69 | profile, 70 | "sent from", 71 | socket.id 72 | ); 73 | } 74 | 75 | /** 76 | * somebody is leaving the room 77 | * @param roomId - room id 78 | */ 79 | function onBye(roomId: string) { 80 | socket.to(getRoomName(roomId)).emit("bye", socket.id); 81 | socket.leave(getRoomName(roomId)); 82 | console.log( 83 | "somebody is leaving the room", 84 | roomId, 85 | "sent from", 86 | socket.id 87 | ); 88 | } 89 | 90 | /** 91 | * somebody sent logs 92 | */ 93 | function onLogs(socketId: string, logs: TextIface[]) { 94 | server.to(socketId).emit("logs-ack", logs); 95 | console.log( 96 | "somebody sent", 97 | Array.isArray(logs) ? logs.length : 0, 98 | "logs to", 99 | socketId, 100 | "sent from", 101 | socket.id 102 | ); 103 | } 104 | 105 | /** 106 | * somebody said something 107 | * @param roomId - room id 108 | * @param message - text message 109 | */ 110 | function onText(roomId: string, message: string) { 111 | server.to(getRoomName(roomId)).emit("text", { 112 | message, 113 | sender: socket.id, 114 | time: Date.now(), 115 | } as TextIface); 116 | console.log( 117 | "somebody said", 118 | message, 119 | "in", 120 | roomId, 121 | "sent from", 122 | socket.id 123 | ); 124 | } 125 | 126 | socket.on("disconnecting", onDisconnecting); 127 | socket.on("hello", onHello); 128 | socket.on("hello-ack", onHelloAck); 129 | socket.on("text", onText); 130 | socket.on("logs", onLogs); 131 | socket.on("bye", onBye); 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /server/emitJSON.ts: -------------------------------------------------------------------------------- 1 | import { ServerResponse } from "http"; 2 | export function emitJSON(res: ServerResponse, json: any) { 3 | const str = JSON.stringify(json); 4 | res.writeHead(200, { 5 | "Content-Type": "application/json", 6 | "Content-Length": str.length, 7 | }); 8 | res.end(str); 9 | } 10 | -------------------------------------------------------------------------------- /server/generateUserName.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import { IncomingMessage } from "http"; 3 | 4 | export function generateUserName(_req: IncomingMessage) { 5 | return `user:${uuidv4()}`; 6 | } 7 | -------------------------------------------------------------------------------- /server/getRoomId.ts: -------------------------------------------------------------------------------- 1 | export function getRoomId(roomName: string) { 2 | const res = /^room:(.+)$/.exec(roomName); 3 | return res ? res[1] : null; 4 | } 5 | -------------------------------------------------------------------------------- /server/getRoomName.ts: -------------------------------------------------------------------------------- 1 | export function getRoomName(id: string) { 2 | return `room:${id}`; 3 | } 4 | -------------------------------------------------------------------------------- /server/getUserId.ts: -------------------------------------------------------------------------------- 1 | export function getUserId(userName: string) { 2 | const res = /^user:(.+)$/.exec(userName); 3 | return res ? res[1] : null; 4 | } 5 | -------------------------------------------------------------------------------- /server/isRoomName.ts: -------------------------------------------------------------------------------- 1 | export function isRoomName(roomName: string) { 2 | return /^room:.+$/.test(roomName); 3 | } 4 | -------------------------------------------------------------------------------- /server/isUserName.ts: -------------------------------------------------------------------------------- 1 | export function isUserName(roomName: string) { 2 | return /^user:.+$/.test(roomName); 3 | } 4 | -------------------------------------------------------------------------------- /shared/TextIface.ts: -------------------------------------------------------------------------------- 1 | export interface TextIface { 2 | message: string; 3 | sender: string; 4 | time: number; 5 | } 6 | -------------------------------------------------------------------------------- /shared/UserProfileIface.ts: -------------------------------------------------------------------------------- 1 | export interface UserProfileIface { 2 | ua: IUAParser.IResult; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig-server.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "esModuleInterop": true 5 | }, 6 | "include": ["index.ts"], 7 | "exclude": [".next"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------