From b10c5b88532299a22bcd218f1161bf400d793390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 2 Oct 2025 14:08:11 +0900 Subject: [PATCH 1/4] Feat: connect WebSocket --- package-lock.json | 359 ++++++++++++++++++++++++++++- package.json | 5 + src/app.module.ts | 2 + src/main.ts | 11 +- src/websocket/websocket.gateway.ts | 312 +++++++++++++++++++++++++ src/websocket/websocket.module.ts | 8 + test-client.html | 240 +++++++++++++++++++ 7 files changed, 932 insertions(+), 5 deletions(-) create mode 100644 src/websocket/websocket.gateway.ts create mode 100644 src/websocket/websocket.module.ts create mode 100644 test-client.html diff --git a/package-lock.json b/package-lock.json index 2242319..c06a38c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,22 @@ "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", - "@nestjs/mapped-types": "*", + "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.6", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.6", + "@types/socket.io": "^3.0.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.26", "ws": "^8.18.3", + "y-protocols": "^1.0.6", "y-websocket": "^3.0.0", "yjs": "^13.6.27" }, @@ -2453,6 +2458,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.6.tgz", + "integrity": "sha512-ozm+OKiRiFLNQdFLA3ULDuazgdVaPrdRdgtG/+404T7tcROXpbUuFL0eEmWJpG64CxMkBNwamclUSH6J0AeU7A==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.7", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz", @@ -2544,6 +2568,29 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.6.tgz", + "integrity": "sha512-jlBX5QpqhfEVfxkwxTesIjgl0bdhgFMoORQYzjRg1i+Z+Qouf4KmjNPv5DZE3DZRDg91E+3Bpn0VgW0Yfl94ng==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -2678,6 +2725,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2820,6 +2873,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -2960,7 +3022,6 @@ "version": "22.18.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3003,6 +3064,15 @@ "@types/send": "*" } }, + "node_modules/@types/socket.io": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz", + "integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==", + "license": "MIT", + "dependencies": { + "socket.io": "*" + } + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -4178,6 +4248,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5108,6 +5187,116 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -8224,6 +8413,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9248,6 +9446,162 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -10498,7 +10852,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { diff --git a/package.json b/package.json index a81190a..8062375 100644 --- a/package.json +++ b/package.json @@ -25,15 +25,20 @@ "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.6", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.6", + "@types/socket.io": "^3.0.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.1", "swagger-ui-express": "^5.0.1", "typeorm": "^0.3.26", "ws": "^8.18.3", + "y-protocols": "^1.0.6", "y-websocket": "^3.0.0", "yjs": "^13.6.27" }, diff --git a/src/app.module.ts b/src/app.module.ts index 0cec91c..17d7c3e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -8,6 +8,7 @@ import { Cardset as CardSet } from './cardset/entities/cardset.entity'; import { CardsetManager as CardSetManager } from './cardset-manager/entities/cardset-manager.entity'; import { CardModule } from './card/card.module'; import { Card as Card } from './card/entities/card.entity'; +import { WebSocketModule } from './websocket/websocket.module'; @Module({ imports: [ AuthModule, @@ -25,6 +26,7 @@ import { Card as Card } from './card/entities/card.entity'; CardsetModule, CardsetManagerModule, CardModule, + WebSocketModule, ], controllers: [], providers: [], diff --git a/src/main.ts b/src/main.ts index 780532b..6722a94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,16 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { IoAdapter } from '@nestjs/platform-socket.io'; +import { NestExpressApplication } from '@nestjs/platform-express'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + + // 정적 파일 서빙 설정 제거 (YJS 제거로 불필요) + + // Socket.IO 어댑터 설정 + app.useWebSocketAdapter(new IoAdapter(app)); const config = new DocumentBuilder() .setTitle('Flip Note API') @@ -27,4 +34,4 @@ async function bootstrap() { await app.listen(process.env.PORT ?? 3000); } -bootstrap(); +void bootstrap(); diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts new file mode 100644 index 0000000..8661970 --- /dev/null +++ b/src/websocket/websocket.gateway.ts @@ -0,0 +1,312 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayConnection, + OnGatewayDisconnect, + MessageBody, + ConnectedSocket, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { Logger } from '@nestjs/common'; +import * as Y from 'yjs'; + +@WebSocketGateway({ + cors: { + origin: '*', + }, + namespace: '/cardsets', +}) +export class CollaborationGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + private readonly logger = new Logger(CollaborationGateway.name); + private readonly heartbeatInterval = 10000; // 10초 + private heartbeatTimers = new Map(); + private documentMap = new Map(); // documentId -> Y.Doc + + constructor() {} + + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`); + + // 10초마다 헬스체크 시작 + this.startHeartbeat(client); + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + this.logger.log(`Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`); + + this.stopHeartbeat(client.id); + } + + @SubscribeMessage('joinRoom') + handleJoinDocument( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; userId?: string }, + ) { + try { + this.logger.log( + `Client ${client.id} joining document: ${data.documentId}`, + ); + + // 클라이언트를 룸에 조인 + client.join(data.documentId); + + // Yjs 문서 초기화 또는 가져오기 + let doc = this.documentMap.get(data.documentId); + if (!doc) { + doc = new Y.Doc(); + this.documentMap.set(data.documentId, doc); + } + + // 클라이언트에게 현재 문서 상태 전송 + const state = Y.encodeStateAsUpdate(doc); + client.emit('yjs-update', { + documentId: data.documentId, + update: Array.from(state), + }); + + // 조인 성공 응답 + client.emit('joinRoom', { + documentId: data.documentId, + clientId: client.id, + timestamp: new Date().toISOString(), + }); + + // 다른 클라이언트들에게 새 클라이언트 알림 + client.to(data.documentId).emit('user-joined', { + clientId: client.id, + userId: data.userId, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error(`Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`); + client.emit('error', { message: 'Failed to join document' }); + } + } + + @SubscribeMessage('leaveRoom') + handleLeaveDocument( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string }, + ) { + try { + this.logger.log( + `Client ${client.id} leaving document: ${data.documentId}`, + ); + + // YJS 서비스 제거로 인한 간단한 처리 + + // 룸에서 나가기 + client.leave(data.documentId); + + // 나가기 성공 응답 + client.emit('userLeft', { + documentId: data.documentId, + clientId: client.id, + timestamp: new Date().toISOString(), + }); + + // 다른 클라이언트들에게 클라이언트 나감 알림 + client.to(data.documentId).emit('userLeft', { + clientId: client.id, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error(`Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`); + client.emit('error', { message: 'Failed to leave document' }); + } + } + + @SubscribeMessage('sendMessage') + handleTextUpdate( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId: string; field: string; content: string }, + ) { + try { + this.logger.log(`Received text update from client ${client.id} for document ${data.documentId} (${data.field})`); + + // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 + client.to(data.documentId).emit('text-update', { + documentId: data.documentId, + field: data.field, + content: data.content, + fromClientId: client.id, + timestamp: new Date().toISOString(), + }); + } catch (error) { + this.logger.error(`Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + @SubscribeMessage('yjs-message') + handleYjsMessage( + @ConnectedSocket() client: Socket, + @MessageBody() data: { documentId?: string; type: string; data: any }, + ) { + try { + this.logger.log(`Received yjs-message from client ${client.id}: ${JSON.stringify(data)}`); + + const { documentId, type, data: messageData } = data; + + // auth 메시지 처리 + if (type === 'auth') { + this.handleAuth(client, messageData); + return; + } + + // documentId가 없으면 에러 + if (!documentId) { + this.logger.warn(`Document ID required for client ${client.id}`); + client.emit('error', { message: 'Document ID required' }); + return; + } + + const doc = this.documentMap.get(documentId); + + if (!doc) { + this.logger.warn(`Document not found: ${documentId} for client ${client.id}`); + client.emit('error', { message: 'Document not found' }); + return; + } + + switch (type) { + case 'sync': + this.handleSyncMessage(client, doc, documentId, messageData); + break; + case 'update': + this.handleUpdateMessage(client, doc, documentId, messageData); + break; + case 'awareness': + this.handleAwarenessMessage(client, doc, documentId, messageData); + break; + default: + this.logger.warn(`Unknown message type: ${type} from client ${client.id}`); + } + } catch (error) { + this.logger.error(`Error processing YJS message from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error(`Raw data: ${JSON.stringify(data)}`); + } + } + + private handleSyncMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { + const { syncStep, update } = data; + + if (syncStep === 0) { + // Step 0: 클라이언트가 현재 상태 벡터 전송 + const stateVector = Y.encodeStateVector(doc); + client.emit('yjs-message', { + type: 'sync', + data: { syncStep: 1, update: Array.from(stateVector) } + }); + } else if (syncStep === 1 && update) { + // Step 1: 서버가 차이점 전송 + const diff = Y.encodeStateAsUpdate(doc, new Uint8Array(update)); + client.emit('yjs-message', { + type: 'sync', + data: { syncStep: 1, update: Array.from(diff) } + }); + } + } + + private handleUpdateMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { + const { update } = data; + Y.applyUpdate(doc, new Uint8Array(update)); + + // 다른 클라이언트들에게 업데이트 브로드캐스트 + client.to(documentId).emit('yjs-message', { + type: 'update', + data: { update: Array.from(update) } + }); + } + + private handleAwarenessMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { + const { awareness } = data; + + // 다른 클라이언트들에게 awareness 브로드캐스트 + client.to(documentId).emit('yjs-message', { + type: 'awareness', + data: { awareness: Array.from(awareness) } + }); + } + + private handleAuth(client: Socket, data: { token: string; userId: string; documentId: string }) { + try { + this.logger.log(`Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`); + this.logger.log(`Auth data: ${JSON.stringify(data)}`); + + // TODO: 실제 JWT 토큰 검증 로직 구현 + // 임시로 토큰이 있으면 인증 성공으로 처리 + const hasAccess = true; + + const accessControlMessage = { + data: { + hasAccess, + message: hasAccess ? 'Authentication successful' : 'Authentication failed', + }, + clientId: client.id, + timestamp: new Date().toISOString(), + }; + + this.logger.log(`Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`); + client.emit('access-control', accessControlMessage); + + // 클라이언트가 이벤트를 받을 시간을 주기 위해 약간의 지연 + setTimeout(() => { + this.logger.log(`Auth result for client ${client.id}: ${hasAccess}`); + }, 100); + } catch (error) { + this.logger.error(`Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error(`Auth data: ${JSON.stringify(data)}`); + client.emit('access-control', { + data: { + hasAccess: false, + message: 'Authentication error', + }, + clientId: client.id, + timestamp: new Date().toISOString(), + }); + } + } + + @SubscribeMessage('heartbeat') + handleHeartbeat(@ConnectedSocket() client: Socket) { + // 클라이언트로부터 heartbeat 응답 받음 + this.logger.log(`Received heartbeat from client ${client.id}`); + client.emit('heartbeat-ack', { + clientId: client.id, + timestamp: new Date().toISOString(), + }); + } + + private startHeartbeat(client: Socket) { + const timer = setInterval(() => { + // 클라이언트가 연결되어 있는지 확인 + if (!client.connected) { + this.logger.warn(`Client ${client.id} is not connected, stopping heartbeat`); + this.stopHeartbeat(client.id); + return; + } + + // 클라이언트에게 heartbeat 전송 + client.emit('heartbeat', { + timestamp: new Date().toISOString(), + }); + }, this.heartbeatInterval); + + this.heartbeatTimers.set(client.id, timer); + } + + private stopHeartbeat(clientId: string) { + const timer = this.heartbeatTimers.get(clientId); + if (timer) { + clearInterval(timer); + this.heartbeatTimers.delete(clientId); + } + } +} \ No newline at end of file diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts new file mode 100644 index 0000000..a6c3327 --- /dev/null +++ b/src/websocket/websocket.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CollaborationGateway } from './websocket.gateway'; + +@Module({ + providers: [CollaborationGateway], + exports: [], +}) +export class WebSocketModule {} diff --git a/test-client.html b/test-client.html new file mode 100644 index 0000000..65d3435 --- /dev/null +++ b/test-client.html @@ -0,0 +1,240 @@ + + + + + + Socket.IO + YJS 테스트 + + + + + +

Socket.IO + YJS 테스트 클라이언트

+ +
연결되지 않음
+ +
+ + +
+ +
+ + + +
+ +
+ +
+ +
+

로그:

+
+
+ + + + From eb22d5ea2a459a8033a082d57ec2431a4522f54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 2 Oct 2025 14:17:27 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Fix:=20Lint=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/card/card.service.ts | 3 +- src/card/entities/card.entity.ts | 2 +- .../cardset-manager.service.ts | 4 +- src/cardset/cardset.service.ts | 4 +- src/websocket/websocket.gateway.ts | 147 ++++++++++++------ 5 files changed, 107 insertions(+), 53 deletions(-) diff --git a/src/card/card.service.ts b/src/card/card.service.ts index 2213e19..5f7ecb9 100644 --- a/src/card/card.service.ts +++ b/src/card/card.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; @Injectable() -export class CardService { -} +export class CardService {} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 389bbd9..4eb1b2e 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -21,4 +21,4 @@ export class Card { @Column({ type: 'text', nullable: false }) content!: string; -} \ No newline at end of file +} diff --git a/src/cardset-manager/cardset-manager.service.ts b/src/cardset-manager/cardset-manager.service.ts index ec47ce3..509abd7 100644 --- a/src/cardset-manager/cardset-manager.service.ts +++ b/src/cardset-manager/cardset-manager.service.ts @@ -1,6 +1,4 @@ import { Injectable } from '@nestjs/common'; @Injectable() -export class CardsetManagerService { - -} +export class CardsetManagerService {} diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index d222c9d..927df7f 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,6 +1,4 @@ import { Injectable } from '@nestjs/common'; @Injectable() -export class CardsetService { - -} +export class CardsetService {} diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index 8661970..313809c 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -39,7 +39,9 @@ export class CollaborationGateway handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - this.logger.log(`Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`); + this.logger.log( + `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, + ); this.stopHeartbeat(client.id); } @@ -55,7 +57,7 @@ export class CollaborationGateway ); // 클라이언트를 룸에 조인 - client.join(data.documentId); + void client.join(data.documentId); // Yjs 문서 초기화 또는 가져오기 let doc = this.documentMap.get(data.documentId); @@ -85,7 +87,9 @@ export class CollaborationGateway timestamp: new Date().toISOString(), }); } catch (error) { - this.logger.error(`Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); client.emit('error', { message: 'Failed to join document' }); } } @@ -103,7 +107,7 @@ export class CollaborationGateway // YJS 서비스 제거로 인한 간단한 처리 // 룸에서 나가기 - client.leave(data.documentId); + void client.leave(data.documentId); // 나가기 성공 응답 client.emit('userLeft', { @@ -118,7 +122,9 @@ export class CollaborationGateway timestamp: new Date().toISOString(), }); } catch (error) { - this.logger.error(`Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); client.emit('error', { message: 'Failed to leave document' }); } } @@ -129,8 +135,10 @@ export class CollaborationGateway @MessageBody() data: { documentId: string; field: string; content: string }, ) { try { - this.logger.log(`Received text update from client ${client.id} for document ${data.documentId} (${data.field})`); - + this.logger.log( + `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, + ); + // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 client.to(data.documentId).emit('text-update', { documentId: data.documentId, @@ -140,7 +148,9 @@ export class CollaborationGateway timestamp: new Date().toISOString(), }); } catch (error) { - this.logger.error(`Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); } } @@ -150,27 +160,38 @@ export class CollaborationGateway @MessageBody() data: { documentId?: string; type: string; data: any }, ) { try { - this.logger.log(`Received yjs-message from client ${client.id}: ${JSON.stringify(data)}`); - - const { documentId, type, data: messageData } = data; - + this.logger.log( + `Received yjs-message from client ${client.id}: ${JSON.stringify(data)}`, + ); + + const { + documentId, + type, + data: messageData, + } = data as { documentId?: string; type: string; data: unknown }; + // auth 메시지 처리 if (type === 'auth') { - this.handleAuth(client, messageData); + this.handleAuth( + client, + messageData as { token: string; userId: string; documentId: string }, + ); return; } - + // documentId가 없으면 에러 if (!documentId) { this.logger.warn(`Document ID required for client ${client.id}`); client.emit('error', { message: 'Document ID required' }); return; } - + const doc = this.documentMap.get(documentId); - + if (!doc) { - this.logger.warn(`Document not found: ${documentId} for client ${client.id}`); + this.logger.warn( + `Document not found: ${documentId} for client ${client.id}`, + ); client.emit('error', { message: 'Document not found' }); return; } @@ -186,82 +207,118 @@ export class CollaborationGateway this.handleAwarenessMessage(client, doc, documentId, messageData); break; default: - this.logger.warn(`Unknown message type: ${type} from client ${client.id}`); + this.logger.warn( + `Unknown message type: ${type} from client ${client.id}`, + ); } } catch (error) { - this.logger.error(`Error processing YJS message from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Error processing YJS message from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); this.logger.error(`Raw data: ${JSON.stringify(data)}`); } } - private handleSyncMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { - const { syncStep, update } = data; + private handleSyncMessage( + client: Socket, + doc: Y.Doc, + documentId: string, + data: any, + ) { + const { syncStep, update } = data as { + syncStep: number; + update?: number[]; + }; if (syncStep === 0) { // Step 0: 클라이언트가 현재 상태 벡터 전송 const stateVector = Y.encodeStateVector(doc); client.emit('yjs-message', { type: 'sync', - data: { syncStep: 1, update: Array.from(stateVector) } + data: { syncStep: 1, update: Array.from(stateVector) }, }); } else if (syncStep === 1 && update) { // Step 1: 서버가 차이점 전송 - const diff = Y.encodeStateAsUpdate(doc, new Uint8Array(update)); + const diff = Y.encodeStateAsUpdate( + doc, + new Uint8Array(update as unknown as ArrayBufferLike), + ); client.emit('yjs-message', { type: 'sync', - data: { syncStep: 1, update: Array.from(diff) } + data: { syncStep: 1, update: Array.from(diff) }, }); } } - private handleUpdateMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { - const { update } = data; - Y.applyUpdate(doc, new Uint8Array(update)); - + private handleUpdateMessage( + client: Socket, + doc: Y.Doc, + documentId: string, + data: any, + ) { + const { update } = data as { update: number[] }; + Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); + // 다른 클라이언트들에게 업데이트 브로드캐스트 client.to(documentId).emit('yjs-message', { type: 'update', - data: { update: Array.from(update) } + data: { update: Array.from(update) }, }); } - private handleAwarenessMessage(client: Socket, doc: Y.Doc, documentId: string, data: any) { - const { awareness } = data; - + private handleAwarenessMessage( + client: Socket, + doc: Y.Doc, + documentId: string, + data: any, + ) { + const { awareness } = data as { awareness: number[] }; + // 다른 클라이언트들에게 awareness 브로드캐스트 client.to(documentId).emit('yjs-message', { type: 'awareness', - data: { awareness: Array.from(awareness) } + data: { awareness: Array.from(awareness) }, }); } - private handleAuth(client: Socket, data: { token: string; userId: string; documentId: string }) { + private handleAuth( + client: Socket, + data: { token: string; userId: string; documentId: string }, + ) { try { - this.logger.log(`Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`); + this.logger.log( + `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, + ); this.logger.log(`Auth data: ${JSON.stringify(data)}`); - + // TODO: 실제 JWT 토큰 검증 로직 구현 // 임시로 토큰이 있으면 인증 성공으로 처리 const hasAccess = true; - + const accessControlMessage = { data: { hasAccess, - message: hasAccess ? 'Authentication successful' : 'Authentication failed', + message: hasAccess + ? 'Authentication successful' + : 'Authentication failed', }, clientId: client.id, timestamp: new Date().toISOString(), }; - - this.logger.log(`Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`); + + this.logger.log( + `Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`, + ); client.emit('access-control', accessControlMessage); - + // 클라이언트가 이벤트를 받을 시간을 주기 위해 약간의 지연 setTimeout(() => { this.logger.log(`Auth result for client ${client.id}: ${hasAccess}`); }, 100); } catch (error) { - this.logger.error(`Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`); + this.logger.error( + `Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); this.logger.error(`Auth data: ${JSON.stringify(data)}`); client.emit('access-control', { data: { @@ -288,7 +345,9 @@ export class CollaborationGateway const timer = setInterval(() => { // 클라이언트가 연결되어 있는지 확인 if (!client.connected) { - this.logger.warn(`Client ${client.id} is not connected, stopping heartbeat`); + this.logger.warn( + `Client ${client.id} is not connected, stopping heartbeat`, + ); this.stopHeartbeat(client.id); return; } @@ -309,4 +368,4 @@ export class CollaborationGateway this.heartbeatTimers.delete(clientId); } } -} \ No newline at end of file +} From f10602af914bc30320303dc6ed147dccc12cf604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 2 Oct 2025 14:24:50 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Delete:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test-client.html | 240 ----------------------------------------------- 1 file changed, 240 deletions(-) delete mode 100644 test-client.html diff --git a/test-client.html b/test-client.html deleted file mode 100644 index 65d3435..0000000 --- a/test-client.html +++ /dev/null @@ -1,240 +0,0 @@ - - - - - - Socket.IO + YJS 테스트 - - - - - -

Socket.IO + YJS 테스트 클라이언트

- -
연결되지 않음
- -
- - -
- -
- - - -
- -
- -
- -
-

로그:

-
-
- - - - From 799580e0672cdd1022c3f2105cfc7f42a96f7041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 2 Oct 2025 14:25:12 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Chore:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 8062375..d5e40d6 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@nestjs/websockets": "^11.1.6", - "@types/socket.io": "^3.0.1", "jsonwebtoken": "^9.0.2", "mysql2": "^3.14.4", "reflect-metadata": "^0.2.2",