commit 556277cf4785916e54921b384124c00cfa187a18
parent e3db10620f4077a9b55fb706179d97fda470248b
Author: Yongbin Kim <iam@yongbin.kim>
Date: Fri, 27 Jan 2023 12:41:28 +0900
feat: 웹소켓 서버/클라이언트 추가
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
9 files changed, 343 insertions(+), 9 deletions(-)
diff --git a/.pnp.cjs b/.pnp.cjs
@@ -48,6 +48,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["jest-environment-jsdom", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jsonwebtoken", "npm:9.0.0"],\
["mysql2", "npm:3.0.1"],\
+ ["nanoevents", "npm:7.0.1"],\
["nanoid", "npm:4.0.0"],\
["next", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.1.2"],\
["next-router-mock", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:0.9.1-beta.0"],\
@@ -60,6 +61,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["sass", "npm:1.57.1"],\
["superjson", "npm:1.12.2"],\
["typescript", "patch:typescript@npm%3A4.9.4#~builtin<compat/typescript>::version=4.9.4&hash=ad5954"],\
+ ["ws", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:8.12.0"],\
["yaml", "npm:2.2.1"]\
],\
"linkType": "SOFT"\
@@ -3374,6 +3376,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["jest-environment-jsdom", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:29.3.1"],\
["jsonwebtoken", "npm:9.0.0"],\
["mysql2", "npm:3.0.1"],\
+ ["nanoevents", "npm:7.0.1"],\
["nanoid", "npm:4.0.0"],\
["next", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:13.1.2"],\
["next-router-mock", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:0.9.1-beta.0"],\
@@ -3386,6 +3389,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["sass", "npm:1.57.1"],\
["superjson", "npm:1.12.2"],\
["typescript", "patch:typescript@npm%3A4.9.4#~builtin<compat/typescript>::version=4.9.4&hash=ad5954"],\
+ ["ws", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:8.12.0"],\
["yaml", "npm:2.2.1"]\
],\
"linkType": "SOFT"\
@@ -5945,7 +5949,7 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
["whatwg-encoding", "npm:2.0.0"],\
["whatwg-mimetype", "npm:3.0.0"],\
["whatwg-url", "npm:11.0.0"],\
- ["ws", "virtual:86945dd2a67c721fd3d1173f29c9cd6fda0897f9d8de98b9da1c6b174e5c10bf69cb54ec894fd2cea0000a7c41b5ab41eff91d089152dc3039d7fc882ec53c26#npm:8.12.0"],\
+ ["ws", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:8.12.0"],\
["xml-name-validator", "npm:4.0.0"]\
],\
"packagePeers": [\
@@ -6482,6 +6486,15 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
"linkType": "HARD"\
}]\
]],\
+ ["nanoevents", [\
+ ["npm:7.0.1", {\
+ "packageLocation": "./.yarn/cache/nanoevents-npm-7.0.1-54af7d9828-5c0704cfeb.zip/node_modules/nanoevents/",\
+ "packageDependencies": [\
+ ["nanoevents", "npm:7.0.1"]\
+ ],\
+ "linkType": "HARD"\
+ }]\
+ ]],\
["nanoid", [\
["npm:3.3.4", {\
"packageLocation": "./.yarn/cache/nanoid-npm-3.3.4-3d250377d6-2fddd6dee9.zip/node_modules/nanoid/",\
@@ -8672,10 +8685,10 @@ function $$SETUP_STATE(hydrateRuntimeState, basePath) {
],\
"linkType": "SOFT"\
}],\
- ["virtual:86945dd2a67c721fd3d1173f29c9cd6fda0897f9d8de98b9da1c6b174e5c10bf69cb54ec894fd2cea0000a7c41b5ab41eff91d089152dc3039d7fc882ec53c26#npm:8.12.0", {\
- "packageLocation": "./.yarn/__virtual__/ws-virtual-e77b8cfb90/0/cache/ws-npm-8.12.0-4e21348613-818ff3f874.zip/node_modules/ws/",\
+ ["virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:8.12.0", {\
+ "packageLocation": "./.yarn/__virtual__/ws-virtual-49359431ed/0/cache/ws-npm-8.12.0-4e21348613-818ff3f874.zip/node_modules/ws/",\
"packageDependencies": [\
- ["ws", "virtual:86945dd2a67c721fd3d1173f29c9cd6fda0897f9d8de98b9da1c6b174e5c10bf69cb54ec894fd2cea0000a7c41b5ab41eff91d089152dc3039d7fc882ec53c26#npm:8.12.0"],\
+ ["ws", "virtual:185201ad25745f54898a2e9f78d456a655eb75b6cdedfba903ba6a6b91ecfe544fb067556e53d84d3f5dee5e27bf03679e7ed01de96d422947cdba9f3cf4c1cd#npm:8.12.0"],\
["@types/bufferutil", null],\
["@types/utf-8-validate", null],\
["bufferutil", null],\
diff --git a/.yarn/cache/nanoevents-npm-7.0.1-54af7d9828-5c0704cfeb.zip b/.yarn/cache/nanoevents-npm-7.0.1-54af7d9828-5c0704cfeb.zip
Binary files differ.
diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
Binary files differ.
diff --git a/bin/event_queue.mjs b/bin/event_queue.mjs
@@ -0,0 +1,186 @@
+#!/usr/bin/env node
+
+// Event Queue는 Redis에서 이벤트를 구독하고, 웹소켓 클라이언트에
+// 이벤트를 전달하는 작은 프로그램입니다.
+//
+// 다음 환경 변수를 사용합니다.
+// REDIS_URL - Redis 서버의 URL입니다.
+// REDIS_USERNAME - Redis 서버의 사용자 이름입니다.
+// REDIS_PASSWORD - Redis 서버의 비밀번호입니다.
+// REDIS_DB - Redis 서버의 데이터베이스 번호입니다.
+// WSS_PORT - 웹소켓 서버의 포트 번호입니다.
+//
+// 다음 채널을 구독합니다.
+// event
+// 이벤트 페이로드의 형식은 다음과 같습니다.
+// [path]|[event]|[payload]
+
+import redis from 'redis'
+import { WebSocketServer } from 'ws'
+import { nanoid } from 'nanoid'
+
+/**
+ * @typedef {string} Path
+ * @typedef {string} SocketID
+ */
+
+/**
+ * 웹소켓 접속을 관리합니다.
+ */
+class SocketManager {
+ constructor () {
+ this.server = new WebSocketServer({
+ port: parseIntOrDefault(process.env.WSS_PORT, 3001),
+ clientTracking: false,
+ })
+
+ /**
+ * @type {Map<Path, Set<SocketID>>}
+ */
+ this.paths = new Map()
+
+ /**
+ * @type {Map<SocketID, Path>}
+ */
+ this.socketPaths = new Map()
+
+ /**
+ * @type {Map<SocketID, WebSocket>}
+ */
+ this.sockets = new Map()
+
+ // Setup socket handlers
+ this.server.on('connection', ws => {
+ this.#handleConnection(ws)
+ })
+ }
+
+ /**
+ * @param ws {WebSocket}
+ */
+ #handleConnection (ws) {
+ const id = nanoid()
+ this.sockets.set(id, ws)
+
+ console.log(`Connected: ${id}`)
+
+ ws.on('message', (message, isBinary) => {
+ this.#handleMessage(id, ws, message, isBinary)
+ })
+ ws.on('close', () => {
+ this.#unregister(id)
+ })
+
+ setTimeout(() => {
+ ws.send('HAND')
+ }, 500)
+ }
+
+ /**
+ * @param id {SocketID}
+ */
+ #unregister (id) {
+ const path = this.socketPaths.get(id)
+ if (this.paths.has(path)) {
+ this.paths.get(path).delete(id)
+ }
+ this.socketPaths.delete(id)
+ }
+
+ /**
+ * @param message {import('ws').WebSocket.RawData}
+ * @return {[string, string]}
+ */
+ #parseCommand (message) {
+ const src = message.toString().trim()
+ return [src.slice(0, 4), src.slice(4)] // Command is always 4 characters
+ }
+
+ /**
+ * @param id {SocketID}
+ * @param ws {import('ws').WebSocket}
+ * @param message {import('ws').WebSocket.RawData}
+ * @param isBinary {boolean}
+ */
+ #handleMessage (id, ws, message, isBinary) {
+ if (!this.sockets.has(id)) {
+ return
+ }
+
+ const [command, payload] = this.#parseCommand(message)
+ switch (command) {
+ case 'PATH':
+ this.#changePath(id, payload)
+ ws.send('OKAY')
+ break
+
+ case 'TEST':
+ this.broadcast(payload, 'TEST')
+ break
+
+ default: {
+ ws.send('????')
+ break
+ }
+ }
+ }
+
+ /**
+ * @param id {SocketID}
+ * @param path {Path}
+ */
+ #changePath (id, path) {
+ const oldPath = this.socketPaths.get(id)
+ if (oldPath) {
+ this.paths.get(oldPath).delete(id)
+ }
+
+ if (!this.paths.has(path)) {
+ this.paths.set(path, new Set())
+ }
+ this.paths.get(path).add(id)
+ }
+
+ /**
+ * @param path {Path}
+ * @param message {string}
+ */
+ broadcast (path, message) {
+ if (!this.paths.has(path)) {
+ return
+ }
+ this.paths.get(path).forEach(id => {
+ const ws = this.sockets.get(id)
+ ws.send(message)
+ })
+ }
+}
+
+function parseIntOrDefault (value, defaultValue) {
+ const parsed = parseInt(value, 10)
+ return Number.isNaN(parsed) ? defaultValue : parsed
+}
+
+!(async () => {
+ const redisClient = redis.createClient({
+ url: process.env.REDIS_URL,
+ username: process.env.REDIS_USERNAME,
+ password: process.env.REDIS_PASSWORD,
+ database: parseIntOrDefault(process.env.REDIS_DB, undefined),
+ })
+
+ const socketManager = new SocketManager()
+
+ await redisClient.connect()
+
+ await redisClient.subscribe('event', (message) => {
+ const [path, event, payload] = message.split('|')
+ socketManager.broadcast(path, `EMIT${event} ${payload}`)
+ })
+
+ console.log('Started')
+})()
+ .catch(err => {
+ console.error(err)
+ process.exit(1)
+ })
diff --git a/components/contexts/SocketContext.tsx b/components/contexts/SocketContext.tsx
@@ -0,0 +1,40 @@
+import { SocketClient } from '@/lib/sockets'
+import { useRouter } from 'next/router'
+import { createContext, ReactNode, useContext, useEffect, useMemo, useReducer, useRef } from 'react'
+
+const SocketContext = createContext<SocketClient>(null as any)
+
+export function SocketProvider ({ children }: { children: ReactNode }) {
+ const router = useRouter()
+ const client = useMemo(() => new SocketClient(), [])
+ const [reconnectDependency, reconnect] = useReducer(v => !v, false)
+ const reconnectTimeoutRef = useRef<any>(null)
+
+ useEffect(() => {
+ client.connect()
+ .then(() => {
+ client.changePath(router.pathname)
+ }, () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+ reconnectTimeoutRef.current = setTimeout(() => reconnect(), 5000)
+ })
+ return () => {
+ client.disconnect()
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current)
+ }
+ }
+ }, [client, router.pathname, reconnectDependency])
+
+ return (
+ <SocketContext.Provider value={client}>
+ {children}
+ </SocketContext.Provider>
+ )
+}
+
+export function useSocket () {
+ return useContext(SocketContext)
+}
diff --git a/lib/sockets.ts b/lib/sockets.ts
@@ -0,0 +1,81 @@
+import { createNanoEvents, Emitter } from 'nanoevents'
+
+interface SocketEvent {
+ payload: string
+}
+
+interface EventMap {
+ [event: string]: (e: SocketEvent) => void
+}
+
+export class SocketClient {
+ private eventEmitter: Emitter<EventMap>
+ private socket?: WebSocket
+
+ constructor () {
+ this.eventEmitter = createNanoEvents()
+ }
+
+ get isConnected () {
+ if (typeof WebSocket === 'undefined') {
+ return false
+ }
+ return this.socket?.readyState === WebSocket.OPEN
+ }
+
+ connect () {
+ return new Promise((resolve, reject) => {
+ this.socket = new WebSocket(process.env.WIKI_SOCKET_URL ?? 'ws://localhost:3001/ws')
+
+ this.socket.addEventListener('close', () => {
+ console.log('SocketClient: disconnected')
+ reject()
+ })
+
+ this.socket.addEventListener('message', event => {
+ this.handleMessage(event.data)
+ })
+
+ this.socket.addEventListener('open', () => {
+ console.log('SocketClient: connected')
+ setTimeout(() => {
+ resolve(this)
+ }, 500)
+ })
+ })
+ }
+
+ disconnect () {
+ this.socket?.close()
+ }
+
+ public changePath (path: string) {
+ this.socket?.send(`PATH${path}`)
+ }
+
+ public on (event: string, callback: (e: SocketEvent) => void) {
+ return this.eventEmitter.on(event, callback)
+ }
+
+ private handleMessage (message: string) {
+ const [command, commandPayload] = this.parseMessage(message)
+
+ if (command === 'EMIT') {
+ const [eventName, eventArgs] = this.parseEmitArgs(commandPayload)
+ this.eventEmitter.emit(eventName, { payload: eventArgs })
+ }
+ }
+
+ private parseMessage (message: string) {
+ message = message.trim()
+ return [message.slice(0, 4), message.slice(4)]
+ }
+
+ private parseEmitArgs (command: string) {
+ const index = command.indexOf(' ')
+ if (index === -1) {
+ return [command, '']
+ }
+ return [command.slice(0, index), command.slice(index + 1)]
+ }
+}
diff --git a/package.json b/package.json
@@ -22,6 +22,7 @@
"handlebars": "^4.7.7",
"jsonwebtoken": "^9.0.0",
"mysql2": "^3.0.1",
+ "nanoevents": "^7.0.1",
"nanoid": "^4.0.0",
"next": "13.1.2",
"next-superjson-plugin": "^0.5.4",
@@ -32,6 +33,7 @@
"redis": "^4.6.1",
"superjson": "^1.12.2",
"typescript": "4.9.4",
+ "ws": "^8.12.0",
"yaml": "^2.2.1"
},
"devDependencies": {
diff --git a/pages/_app.tsx b/pages/_app.tsx
@@ -1,4 +1,5 @@
import '@/styles/globals.css'
+import { SocketProvider } from '@/components/contexts/SocketContext'
import { TokenProvider } from '@/components/contexts/TokenContext'
import Header from '@/components/layout/Header'
import type { AppProps } from 'next/app'
@@ -6,10 +7,12 @@ import type { AppProps } from 'next/app'
export default function App ({ Component, pageProps }: AppProps) {
return (
<TokenProvider>
- <div>
- <Header/>
- <Component {...pageProps} />
- </div>
+ <SocketProvider>
+ <div>
+ <Header/>
+ <Component {...pageProps} />
+ </div>
+ </SocketProvider>
</TokenProvider>
)
}
diff --git a/yarn.lock b/yarn.lock
@@ -2457,6 +2457,7 @@ __metadata:
jest-environment-jsdom: ^29.3.1
jsonwebtoken: ^9.0.0
mysql2: ^3.0.1
+ nanoevents: ^7.0.1
nanoid: ^4.0.0
next: 13.1.2
next-router-mock: ^0.9.1-beta.0
@@ -2469,6 +2470,7 @@ __metadata:
sass: ^1.57.1
superjson: ^1.12.2
typescript: 4.9.4
+ ws: ^8.12.0
yaml: ^2.2.1
languageName: unknown
linkType: soft
@@ -5115,6 +5117,13 @@ __metadata:
languageName: node
linkType: hard
+"nanoevents@npm:^7.0.1":
+ version: 7.0.1
+ resolution: "nanoevents@npm:7.0.1"
+ checksum: 5c0704cfeb7af9052a70b1ce53e556e3743cefbbd09758121e0062e4fac2cf70ebea1e6b1414f18a81975ee1bdf4af05e13c82e1b015975493ef8ef34737787d
+ languageName: node
+ linkType: hard
+
"nanoid@npm:^3.3.4":
version: 3.3.4
resolution: "nanoid@npm:3.3.4"
@@ -7039,7 +7048,7 @@ __metadata:
languageName: node
linkType: hard
-"ws@npm:^8.11.0":
+"ws@npm:^8.11.0, ws@npm:^8.12.0":
version: 8.12.0
resolution: "ws@npm:8.12.0"
peerDependencies: