From 14b099b075e6a24c6d3e4b345d79096310de611a Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 21 Feb 2026 15:21:36 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20Replace=20socket.io=20with=20native=20WS?= =?UTF-8?q?=20adapter=20=E2=80=94=20fixes=20WebSocket=201006?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend uses native WebSocket API, backend was using socket.io which speaks an incompatible protocol. Switched to @nestjs/platform-ws so both sides speak native WebSocket. Also fixed JWT TTL override in docker-compose.yml (was hardcoded to 900s, now 14400s/4h). Co-Authored-By: Claude Opus 4.6 --- backend-nest/package-lock.json | 325 +++--------------- backend-nest/package.json | 6 +- .../src/gateways/nats-bridge.gateway.ts | 108 ++++-- backend-nest/src/main.ts | 4 + .../src/modules/console/console.gateway.ts | 88 ++--- docker/docker-compose.yml | 2 +- 6 files changed, 169 insertions(+), 364 deletions(-) diff --git a/backend-nest/package-lock.json b/backend-nest/package-lock.json index a068ba3..c1809b2 100644 --- a/backend-nest/package-lock.json +++ b/backend-nest/package-lock.json @@ -15,7 +15,7 @@ "@nestjs/microservices": "^10.4.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.0", - "@nestjs/platform-socket.io": "^10.4.0", + "@nestjs/platform-ws": "^10.4.22", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", @@ -33,7 +33,8 @@ "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.20", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@nestjs/cli": "^10.4.0", @@ -44,6 +45,7 @@ "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/uuid": "^9.0.8", + "@types/ws": "^8.18.1", "typescript": "^5.4.0" } }, @@ -886,14 +888,14 @@ "node": ">= 0.8.0" } }, - "node_modules/@nestjs/platform-socket.io": { + "node_modules/@nestjs/platform-ws": { "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz", - "integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz", + "integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==", "license": "MIT", "dependencies": { - "socket.io": "4.8.1", - "tslib": "2.8.1" + "tslib": "2.8.1", + "ws": "8.18.0" }, "funding": { "type": "opencollective", @@ -905,6 +907,27 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "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/@nestjs/schedule": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", @@ -1077,12 +1100,6 @@ "node": ">=14" } }, - "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", @@ -1157,15 +1174,6 @@ "@types/node": "*" } }, - "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", @@ -1378,6 +1386,16 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -1804,15 +1822,6 @@ ], "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/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2589,101 +2598,6 @@ "node": ">= 0.8" } }, - "node_modules/engine.io": { - "version": "6.6.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", - "integrity": "sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==", - "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.4.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" - }, - "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.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "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/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "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/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -5384,159 +5298,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "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.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", - "license": "MIT", - "dependencies": { - "debug": "~4.4.1", - "ws": "~8.18.3" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-adapter/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/socket.io-parser": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz", - "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-parser/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "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/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "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", @@ -6676,9 +6437,9 @@ "peer": true }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/backend-nest/package.json b/backend-nest/package.json index 63ee7cf..00a6298 100644 --- a/backend-nest/package.json +++ b/backend-nest/package.json @@ -20,7 +20,7 @@ "@nestjs/microservices": "^10.4.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.4.0", - "@nestjs/platform-socket.io": "^10.4.0", + "@nestjs/platform-ws": "^10.4.22", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.2", @@ -38,7 +38,8 @@ "rxjs": "^7.8.1", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.20", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "ws": "^8.19.0" }, "devDependencies": { "@nestjs/cli": "^10.4.0", @@ -49,6 +50,7 @@ "@types/passport-jwt": "^4.0.1", "@types/qrcode": "^1.5.5", "@types/uuid": "^9.0.8", + "@types/ws": "^8.18.1", "typescript": "^5.4.0" } } diff --git a/backend-nest/src/gateways/nats-bridge.gateway.ts b/backend-nest/src/gateways/nats-bridge.gateway.ts index 4c5a208..045c584 100644 --- a/backend-nest/src/gateways/nats-bridge.gateway.ts +++ b/backend-nest/src/gateways/nats-bridge.gateway.ts @@ -4,32 +4,35 @@ import { OnGatewayConnection, OnGatewayDisconnect, SubscribeMessage, + MessageBody, + ConnectedSocket, } from '@nestjs/websockets'; import { Logger } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; +import { IncomingMessage } from 'http'; +import WebSocket, { Server } from 'ws'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import { NatsBridgeService } from '../services/nats-bridge.service'; import { NatsService } from '../services/nats.service'; -interface AuthenticatedSocket extends Socket { - data: { - userId: string; - licenseId: string; - email: string; - }; +interface ClientMeta { + userId: string; + licenseId: string; + email: string; } -@WebSocketGateway({ - namespace: '/ws', - cors: { origin: '*' }, -}) +@WebSocketGateway({ path: '/api/ws' }) export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect { private readonly logger = new Logger(NatsBridgeGateway.name); @WebSocketServer() server!: Server; + // Client metadata and listener tracking (native WS has no .data or .join()) + private clientMeta = new Map(); + private licenseClients = new Map>(); + private clientListeners = new Map void>(); + constructor( private jwtService: JwtService, private configService: ConfigService, @@ -37,70 +40,101 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne private natsService: NatsService, ) {} - async handleConnection(client: AuthenticatedSocket) { + async handleConnection(client: WebSocket, request: IncomingMessage) { try { - const token = client.handshake.query.token as string; + // Parse token from query string + const url = new URL(request.url || '/', `http://${request.headers.host}`); + const token = url.searchParams.get('token'); + if (!token) { - client.emit('error', { message: 'Authentication required' }); - client.disconnect(); + client.send(JSON.stringify({ type: 'error', message: 'Authentication required' })); + client.close(4001, 'Authentication required'); return; } const secret = this.configService.get('jwt.secret'); const payload = this.jwtService.verify(token, { secret }); - client.data = { + const meta: ClientMeta = { userId: payload.sub, licenseId: payload.license_id, email: payload.email, }; + this.clientMeta.set(client, meta); + // Track client by license for broadcasting if (payload.license_id) { - await client.join(`license:${payload.license_id}`); - } + if (!this.licenseClients.has(payload.license_id)) { + this.licenseClients.set(payload.license_id, new Set()); + } + this.licenseClients.get(payload.license_id)!.add(client); - if (payload.license_id) { + // Subscribe to NATS events for this license const listener = (event: string, data: unknown) => { - client.emit('event', { - type: 'event', - license_id: payload.license_id, - event, - data, - }); + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify({ + type: 'event', + license_id: payload.license_id, + event, + data, + })); + } }; this.natsBridge.addListener(payload.license_id, listener); - (client as Socket & { _natsListener?: typeof listener })._natsListener = listener; + this.clientListeners.set(client, listener); } - client.emit('connected', { type: 'connected', license_id: payload.license_id }); + client.send(JSON.stringify({ type: 'connected', license_id: payload.license_id })); this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`); } catch { - client.emit('error', { message: 'Invalid token' }); - client.disconnect(); + client.send(JSON.stringify({ type: 'error', message: 'Invalid token' })); + client.close(4002, 'Invalid token'); } } - handleDisconnect(client: AuthenticatedSocket) { - if (client.data?.licenseId) { - const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener; + handleDisconnect(client: WebSocket) { + const meta = this.clientMeta.get(client); + if (meta?.licenseId) { + // Remove NATS listener + const listener = this.clientListeners.get(client); if (listener) { - this.natsBridge.removeListener(client.data.licenseId, listener); + this.natsBridge.removeListener(meta.licenseId, listener); + this.clientListeners.delete(client); + } + // Remove from license client set + this.licenseClients.get(meta.licenseId)?.delete(client); + if (this.licenseClients.get(meta.licenseId)?.size === 0) { + this.licenseClients.delete(meta.licenseId); } } + this.clientMeta.delete(client); } @SubscribeMessage('console_input') - async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) { - if (!client.data?.licenseId) return; - await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command }); + async handleConsoleInput( + @ConnectedSocket() client: WebSocket, + @MessageBody() data: { command: string }, + ) { + const meta = this.clientMeta.get(client); + if (!meta?.licenseId) return; + await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command }); } sendToLicense(licenseId: string, event: string, data: unknown): void { - this.server.to(`license:${licenseId}`).emit(event, { + const clients = this.licenseClients.get(licenseId); + if (!clients) return; + + const message = JSON.stringify({ type: 'event', license_id: licenseId, event, data, }); + + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } } } diff --git a/backend-nest/src/main.ts b/backend-nest/src/main.ts index b157ca0..70faecc 100644 --- a/backend-nest/src/main.ts +++ b/backend-nest/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { WsAdapter } from '@nestjs/platform-ws'; import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; @@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto async function bootstrap() { const app = await NestFactory.create(AppModule); + // Use native WebSocket adapter (not socket.io) + app.useWebSocketAdapter(new WsAdapter(app)); + // Global prefix — all routes under /api app.setGlobalPrefix('api'); diff --git a/backend-nest/src/modules/console/console.gateway.ts b/backend-nest/src/modules/console/console.gateway.ts index 39932aa..43b2fa7 100644 --- a/backend-nest/src/modules/console/console.gateway.ts +++ b/backend-nest/src/modules/console/console.gateway.ts @@ -7,43 +7,47 @@ import { MessageBody, ConnectedSocket, } from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; +import WebSocket, { Server } from 'ws'; +import { IncomingMessage } from 'http'; import { Logger, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { NatsService } from '../../services/nats.service'; +interface ClientMeta { + licenseId: string; + userId: string; +} + /** * Console Gateway * - * Provides real-time WebSocket connectivity for server console I/O. - * Clients connect with JWT token in query params, join a room by license_id, - * and can send/receive console commands and output. + * NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule). + * Console I/O is handled by NatsBridgeGateway instead. + * Kept for potential future use as a dedicated console-only WebSocket endpoint. */ -@WebSocketGateway({ namespace: '/ws', cors: true }) +@WebSocketGateway({ path: '/api/console-ws' }) export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly logger = new Logger(ConsoleGateway.name); + private clientMeta = new Map(); + private licenseClients = new Map>(); constructor( private readonly jwtService: JwtService, private readonly natsService: NatsService, ) {} - /** - * Handle client connection - * Extract JWT from query param, validate, and join room by license_id - */ - async handleConnection(client: Socket) { + async handleConnection(client: WebSocket, request: IncomingMessage) { try { - const token = client.handshake.query.token as string; + const url = new URL(request.url || '/', `http://${request.headers.host}`); + const token = url.searchParams.get('token'); if (!token) { throw new UnauthorizedException('No token provided'); } - // Verify JWT const payload = this.jwtService.verify(token); const licenseId = payload.license_id; @@ -51,65 +55,65 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect throw new UnauthorizedException('Invalid token: no license_id'); } - // Store license_id on socket for later use - client.data.licenseId = licenseId; - client.data.userId = payload.sub; + this.clientMeta.set(client, { licenseId, userId: payload.sub }); - // Join room specific to this license - await client.join(licenseId); + if (!this.licenseClients.has(licenseId)) { + this.licenseClients.set(licenseId, new Set()); + } + this.licenseClients.get(licenseId)!.add(client); - this.logger.log(`Client ${client.id} connected to license ${licenseId}`); + this.logger.log(`Client connected to license ${licenseId}`); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; this.logger.error(`Connection failed: ${message}`); - client.disconnect(); + client.close(4001, message); } } - /** - * Handle client disconnection - */ - handleDisconnect(client: Socket) { - const licenseId = client.data.licenseId; - this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`); + handleDisconnect(client: WebSocket) { + const meta = this.clientMeta.get(client); + if (meta?.licenseId) { + this.licenseClients.get(meta.licenseId)?.delete(client); + if (this.licenseClients.get(meta.licenseId)?.size === 0) { + this.licenseClients.delete(meta.licenseId); + } + } + this.clientMeta.delete(client); } - /** - * Handle console input from client - * Forward the command to NATS for execution on the game server - */ @SubscribeMessage('console_input') async handleConsoleInput( - @ConnectedSocket() client: Socket, + @ConnectedSocket() client: WebSocket, @MessageBody() data: { command: string }, ) { - const licenseId = client.data.licenseId; + const meta = this.clientMeta.get(client); + if (!meta?.licenseId) return; if (!data.command) { return { error: 'Command is required' }; } - this.logger.debug(`Console input from ${licenseId}: ${data.command}`); + this.logger.debug(`Console input from ${meta.licenseId}: ${data.command}`); - // Forward to NATS - await this.natsService.sendServerCommand(licenseId, 'command', { + await this.natsService.sendServerCommand(meta.licenseId, 'command', { command: data.command, }); return { success: true }; } - /** - * Send console output or event to all clients in a license room - */ sendToLicense(licenseId: string, event: string, data: any) { - this.server.to(licenseId).emit(event, data); + const clients = this.licenseClients.get(licenseId); + if (!clients) return; + + const message = JSON.stringify({ event, data }); + for (const client of clients) { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + } } - /** - * Broadcast console output to a specific license - * This method would be called by a NATS subscriber when output is received - */ broadcastConsoleOutput(licenseId: string, output: string) { this.sendToLicense(licenseId, 'console_output', { output }); } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e298e88..bb4b9ce 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -37,7 +37,7 @@ services: DATABASE_MAX_CONNECTIONS: "20" NATS_URL: nats://nats:4222 JWT_SECRET: ${JWT_SECRET} - JWT_ACCESS_EXPIRY_SECONDS: "900" + JWT_ACCESS_EXPIRY_SECONDS: "14400" JWT_REFRESH_EXPIRY_SECONDS: "604800" ENCRYPTION_KEY: ${ENCRYPTION_KEY} CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}