fix: Replace socket.io with native WS adapter — fixes WebSocket 1006
All checks were successful
Test Asgard Runner / test (push) Successful in 3s

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 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 15:21:36 -05:00
parent d04e7b6a15
commit 14b099b075
6 changed files with 169 additions and 364 deletions

View File

@@ -15,7 +15,7 @@
"@nestjs/microservices": "^10.4.0", "@nestjs/microservices": "^10.4.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0", "@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/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0", "@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
@@ -33,7 +33,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"uuid": "^9.0.0" "uuid": "^9.0.0",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
@@ -44,6 +45,7 @@
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/ws": "^8.18.1",
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
}, },
@@ -886,14 +888,14 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/@nestjs/platform-socket.io": { "node_modules/@nestjs/platform-ws": {
"version": "10.4.22", "version": "10.4.22",
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz", "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-10.4.22.tgz",
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==", "integrity": "sha512-ZBL66p8axCyvQw6lP6R5uMAamVGfDb0/LtbdxDjMjbWb5/wi070P0MWrjzTudEA3ThsDMNOsfawZlsFUkSfCzg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"socket.io": "4.8.1", "tslib": "2.8.1",
"tslib": "2.8.1" "ws": "8.18.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -905,6 +907,27 @@
"rxjs": "^7.1.0" "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": { "node_modules/@nestjs/schedule": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
@@ -1077,12 +1100,6 @@
"node": ">=14" "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": { "node_modules/@sqltools/formatter": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -1157,15 +1174,6 @@
"@types/node": "*" "@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": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -1378,6 +1386,16 @@
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"license": "MIT" "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": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -1804,15 +1822,6 @@
], ],
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.9.19", "version": "2.9.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
@@ -2589,101 +2598,6 @@
"node": ">= 0.8" "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": { "node_modules/enhanced-resolve": {
"version": "5.19.0", "version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -5384,159 +5298,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/source-map": {
"version": "0.7.4", "version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
@@ -6676,9 +6437,9 @@
"peer": true "peer": true
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.3", "version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@@ -20,7 +20,7 @@
"@nestjs/microservices": "^10.4.0", "@nestjs/microservices": "^10.4.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.0", "@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/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0", "@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
@@ -38,7 +38,8 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
"uuid": "^9.0.0" "uuid": "^9.0.0",
"ws": "^8.19.0"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.4.0", "@nestjs/cli": "^10.4.0",
@@ -49,6 +50,7 @@
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/ws": "^8.18.1",
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
} }

View File

@@ -4,32 +4,35 @@ import {
OnGatewayConnection, OnGatewayConnection,
OnGatewayDisconnect, OnGatewayDisconnect,
SubscribeMessage, SubscribeMessage,
MessageBody,
ConnectedSocket,
} from '@nestjs/websockets'; } from '@nestjs/websockets';
import { Logger } from '@nestjs/common'; 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 { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NatsBridgeService } from '../services/nats-bridge.service'; import { NatsBridgeService } from '../services/nats-bridge.service';
import { NatsService } from '../services/nats.service'; import { NatsService } from '../services/nats.service';
interface AuthenticatedSocket extends Socket { interface ClientMeta {
data: { userId: string;
userId: string; licenseId: string;
licenseId: string; email: string;
email: string;
};
} }
@WebSocketGateway({ @WebSocketGateway({ path: '/api/ws' })
namespace: '/ws',
cors: { origin: '*' },
})
export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect { export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconnect {
private readonly logger = new Logger(NatsBridgeGateway.name); private readonly logger = new Logger(NatsBridgeGateway.name);
@WebSocketServer() @WebSocketServer()
server!: Server; server!: Server;
// Client metadata and listener tracking (native WS has no .data or .join())
private clientMeta = new Map<WebSocket, ClientMeta>();
private licenseClients = new Map<string, Set<WebSocket>>();
private clientListeners = new Map<WebSocket, (event: string, data: unknown) => void>();
constructor( constructor(
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
@@ -37,70 +40,101 @@ export class NatsBridgeGateway implements OnGatewayConnection, OnGatewayDisconne
private natsService: NatsService, private natsService: NatsService,
) {} ) {}
async handleConnection(client: AuthenticatedSocket) { async handleConnection(client: WebSocket, request: IncomingMessage) {
try { 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) { if (!token) {
client.emit('error', { message: 'Authentication required' }); client.send(JSON.stringify({ type: 'error', message: 'Authentication required' }));
client.disconnect(); client.close(4001, 'Authentication required');
return; return;
} }
const secret = this.configService.get<string>('jwt.secret'); const secret = this.configService.get<string>('jwt.secret');
const payload = this.jwtService.verify(token, { secret }); const payload = this.jwtService.verify(token, { secret });
client.data = { const meta: ClientMeta = {
userId: payload.sub, userId: payload.sub,
licenseId: payload.license_id, licenseId: payload.license_id,
email: payload.email, email: payload.email,
}; };
this.clientMeta.set(client, meta);
// Track client by license for broadcasting
if (payload.license_id) { 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) => { const listener = (event: string, data: unknown) => {
client.emit('event', { if (client.readyState === WebSocket.OPEN) {
type: 'event', client.send(JSON.stringify({
license_id: payload.license_id, type: 'event',
event, license_id: payload.license_id,
data, event,
}); data,
}));
}
}; };
this.natsBridge.addListener(payload.license_id, listener); 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})`); this.logger.log(`Client connected: ${payload.email} (license: ${payload.license_id})`);
} catch { } catch {
client.emit('error', { message: 'Invalid token' }); client.send(JSON.stringify({ type: 'error', message: 'Invalid token' }));
client.disconnect(); client.close(4002, 'Invalid token');
} }
} }
handleDisconnect(client: AuthenticatedSocket) { handleDisconnect(client: WebSocket) {
if (client.data?.licenseId) { const meta = this.clientMeta.get(client);
const listener = (client as Socket & { _natsListener?: (event: string, data: unknown) => void })._natsListener; if (meta?.licenseId) {
// Remove NATS listener
const listener = this.clientListeners.get(client);
if (listener) { 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') @SubscribeMessage('console_input')
async handleConsoleInput(client: AuthenticatedSocket, data: { command: string }) { async handleConsoleInput(
if (!client.data?.licenseId) return; @ConnectedSocket() client: WebSocket,
await this.natsService.sendServerCommand(client.data.licenseId, 'command', { command: data.command }); @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 { 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', type: 'event',
license_id: licenseId, license_id: licenseId,
event, event,
data, data,
}); });
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
}
} }
} }

View File

@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { WsAdapter } from '@nestjs/platform-ws';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { TransformInterceptor } from './common/interceptors/transform.interceptor';
@@ -8,6 +9,9 @@ import { TransformInterceptor } from './common/interceptors/transform.intercepto
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
// Use native WebSocket adapter (not socket.io)
app.useWebSocketAdapter(new WsAdapter(app));
// Global prefix — all routes under /api // Global prefix — all routes under /api
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');

View File

@@ -7,43 +7,47 @@ import {
MessageBody, MessageBody,
ConnectedSocket, ConnectedSocket,
} from '@nestjs/websockets'; } 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 { Logger, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { NatsService } from '../../services/nats.service'; import { NatsService } from '../../services/nats.service';
interface ClientMeta {
licenseId: string;
userId: string;
}
/** /**
* Console Gateway * Console Gateway
* *
* Provides real-time WebSocket connectivity for server console I/O. * NOTE: This gateway is NOT currently loaded (ConsoleModule not imported in AppModule).
* Clients connect with JWT token in query params, join a room by license_id, * Console I/O is handled by NatsBridgeGateway instead.
* and can send/receive console commands and output. * 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 { export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer() @WebSocketServer()
server: Server; server: Server;
private readonly logger = new Logger(ConsoleGateway.name); private readonly logger = new Logger(ConsoleGateway.name);
private clientMeta = new Map<WebSocket, ClientMeta>();
private licenseClients = new Map<string, Set<WebSocket>>();
constructor( constructor(
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly natsService: NatsService, private readonly natsService: NatsService,
) {} ) {}
/** async handleConnection(client: WebSocket, request: IncomingMessage) {
* Handle client connection
* Extract JWT from query param, validate, and join room by license_id
*/
async handleConnection(client: Socket) {
try { 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) { if (!token) {
throw new UnauthorizedException('No token provided'); throw new UnauthorizedException('No token provided');
} }
// Verify JWT
const payload = this.jwtService.verify(token); const payload = this.jwtService.verify(token);
const licenseId = payload.license_id; const licenseId = payload.license_id;
@@ -51,65 +55,65 @@ export class ConsoleGateway implements OnGatewayConnection, OnGatewayDisconnect
throw new UnauthorizedException('Invalid token: no license_id'); throw new UnauthorizedException('Invalid token: no license_id');
} }
// Store license_id on socket for later use this.clientMeta.set(client, { licenseId, userId: payload.sub });
client.data.licenseId = licenseId;
client.data.userId = payload.sub;
// Join room specific to this license if (!this.licenseClients.has(licenseId)) {
await client.join(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) { } catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error(`Connection failed: ${message}`); this.logger.error(`Connection failed: ${message}`);
client.disconnect(); client.close(4001, message);
} }
} }
/** handleDisconnect(client: WebSocket) {
* Handle client disconnection const meta = this.clientMeta.get(client);
*/ if (meta?.licenseId) {
handleDisconnect(client: Socket) { this.licenseClients.get(meta.licenseId)?.delete(client);
const licenseId = client.data.licenseId; if (this.licenseClients.get(meta.licenseId)?.size === 0) {
this.logger.log(`Client ${client.id} disconnected from license ${licenseId}`); 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') @SubscribeMessage('console_input')
async handleConsoleInput( async handleConsoleInput(
@ConnectedSocket() client: Socket, @ConnectedSocket() client: WebSocket,
@MessageBody() data: { command: string }, @MessageBody() data: { command: string },
) { ) {
const licenseId = client.data.licenseId; const meta = this.clientMeta.get(client);
if (!meta?.licenseId) return;
if (!data.command) { if (!data.command) {
return { error: 'Command is required' }; 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(meta.licenseId, 'command', {
await this.natsService.sendServerCommand(licenseId, 'command', {
command: data.command, command: data.command,
}); });
return { success: true }; return { success: true };
} }
/**
* Send console output or event to all clients in a license room
*/
sendToLicense(licenseId: string, event: string, data: any) { 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) { broadcastConsoleOutput(licenseId: string, output: string) {
this.sendToLicense(licenseId, 'console_output', { output }); this.sendToLicense(licenseId, 'console_output', { output });
} }

View File

@@ -37,7 +37,7 @@ services:
DATABASE_MAX_CONNECTIONS: "20" DATABASE_MAX_CONNECTIONS: "20"
NATS_URL: nats://nats:4222 NATS_URL: nats://nats:4222
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
JWT_ACCESS_EXPIRY_SECONDS: "900" JWT_ACCESS_EXPIRY_SECONDS: "14400"
JWT_REFRESH_EXPIRY_SECONDS: "604800" JWT_REFRESH_EXPIRY_SECONDS: "604800"
ENCRYPTION_KEY: ${ENCRYPTION_KEY} ENCRYPTION_KEY: ${ENCRYPTION_KEY}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN} CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}