dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

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:
M.pnp.cjs | 21+++++++++++++++++----
A.yarn/cache/nanoevents-npm-7.0.1-54af7d9828-5c0704cfeb.zip | 0
M.yarn/install-state.gz | 0
Abin/event_queue.mjs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acomponents/contexts/SocketContext.tsx | 40++++++++++++++++++++++++++++++++++++++++
Alib/sockets.ts | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 2++
Mpages/_app.tsx | 11+++++++----
Myarn.lock | 11++++++++++-
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: